Configuring Celery Queues for Spatial Workloads
A single default Celery queue that mixes a multi-minute polygon reprojection behind a sub-second metadata lookup guarantees head-of-line blocking: the cheap task waits on the expensive one, prefetch hoards messages a busy worker cannot start, and a runaway tiling job pins a core no time limit ever reclaims. This operation, part of Async Execution for Heavy Spatial Queries within the Federated Ownership & Routing Architecture, routes heavy spatial jobs — tiling, spatial joins, CRS reprojection — into dedicated queues with their own priority, prefetch, and hard time limits, and keeps every task idempotent by claiming a SHA-256(bbox+crs+params) key before doing any work, exactly the record sized in Tuning Redis Idempotency Cache TTL for Spatial Jobs.
Figure — spatial tasks are routed by kind into isolated queues, each drained by a worker pool tuned for its CPU or IO profile.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Celery | >= 5.3, kombu >= 5.3 |
Message priority and task_routes support |
| Broker | RabbitMQ >= 3.12 (priority queues) or Redis >= 7 |
Priority requires x-max-priority on RabbitMQ |
| Result / dedup store | Redis redis-idem.internal:6379 |
Holds the SHA-256 idempotency claim |
| CLI tools | celery, kubectl, python3 |
For inspect, rollout checks, and task submission |
| CRS contract | Payloads carry bbox in EPSG:4326; reprojection to EPSG:3857 on egress |
Same contract as the async join pipeline |
| Access role | spatial-worker (consume + execute), read-only broker admin |
Zero-trust: workers cannot purge queues |
| Env vars | CELERY_BROKER_URL, REDIS_URL, WORKER_QUEUE, MAX_HEAP_MB |
One deployment per queue class |
Step-by-Step Implementation
Isolation is the whole design: each task kind names its queue, each queue gets a worker pool matched to its CPU or IO profile, and no heavy job can starve an interactive one. Every step is verifiable before the next.
1. Declare the queues and default routing
Define explicit queues rather than relying on the implicit celery default, and set the message priority ceiling so the priority queue can actually preempt.
from kombu import Queue
from celery import Celery
app = Celery("spatial", broker="amqp://mesh@rabbit:5672/mesh")
app.conf.task_queues = (
Queue("cpu-heavy", routing_key="cpu.#", queue_arguments={"x-max-priority": 9}),
Queue("io-light", routing_key="io.#", queue_arguments={"x-max-priority": 9}),
Queue("priority", routing_key="rt.#", queue_arguments={"x-max-priority": 9}),
)
app.conf.task_default_queue = "io-light"
app.conf.task_default_priority = 3
Verify the queues were declared on the broker:
celery -A tasks inspect active_queues | grep -E 'cpu-heavy|io-light|priority'
2. Route each spatial task to its queue
task_routes maps task names to queues so callers never hard-code a queue. Heavy geometry work goes to cpu-heavy; catalog and metadata work goes to io-light.
app.conf.task_routes = {
"spatial.tiling.*": {"queue": "cpu-heavy", "priority": 5},
"spatial.join.*": {"queue": "cpu-heavy", "priority": 5},
"spatial.reproject.*": {"queue": "cpu-heavy", "priority": 5},
"spatial.catalog.*": {"queue": "io-light", "priority": 3},
"spatial.metadata.*": {"queue": "io-light", "priority": 3},
"spatial.interactive.*": {"queue": "priority", "priority": 9},
}
Verify a submitted task lands on the expected queue:
python3 -c "from tasks import build_tiles; print(build_tiles.apply_async(kwargs={'bbox':[-74.5,40.1,-73.9,40.9],'crs':'EPSG:4326'}).id)"
celery -A tasks inspect reserved | grep cpu-heavy
3. Make heavy tasks idempotent and bounded
Every heavy task claims its SHA-256(bbox+crs+params) key before doing work, so a redelivery after a worker crash is a no-op. time_limit hard-kills a runaway; acks_late with reject_on_worker_lost requeues an interrupted job for a clean redelivery.
import redis, os
r = redis.from_url(os.environ["REDIS_URL"])
@app.task(bind=True, acks_late=True, reject_on_worker_lost=True,
time_limit=600, soft_time_limit=540, max_retries=3)
def build_tiles(self, bbox, crs, **params):
key = idem_key(bbox, crs, params) # from the TTL page
if not r.set(key, self.request.id, nx=True, ex=1575):
return {"status": "deduplicated", "key": key} # already done / in-flight
return run_tiling(bbox, crs, params) # EPSG:4326 in, MVT out
Verify a replayed identical task is deduplicated, not recomputed:
celery -A tasks call spatial.tiling.build_tiles \
--kwargs '{"bbox":[-74.5,40.1,-73.9,40.9],"crs":"EPSG:4326"}'
redis-cli -u "$REDIS_URL" TTL "idem:$HASH" # positive TTL confirms the claim
4. Tune prefetch and pool per queue class
CPU-bound workers must run --prefetch-multiplier=1 so a worker holds exactly one heavy message and the rest stay fair-dispatchable; IO-bound workers can prefetch more. Run one deployment per queue.
# CPU-heavy: solo pool, prefetch 1, one process reclaimed at the time limit
celery -A tasks worker -Q cpu-heavy --pool=prefork --concurrency=4 \
--prefetch-multiplier=1 --max-tasks-per-child=50 -n cpu@%h
# IO-light: threaded, high prefetch for cheap catalog work
celery -A tasks worker -Q io-light --pool=threads --concurrency=16 \
--prefetch-multiplier=8 -n io@%h
Verify the running pool and prefetch match the intent:
celery -A tasks inspect stats | jq '.[] | {pool: .pool.implementation, prefetch: .prefetch_count}'
5. Confirm isolation under load
Submit a burst of heavy jobs and one interactive job; the interactive job must complete on the priority queue without waiting on the heavy backlog.
for i in $(seq 1 20); do celery -A tasks call spatial.join.run --kwargs "{\"bbox\":[-74.5,40.1,-73.9,40.9],\"crs\":\"EPSG:4326\",\"n\":$i}"; done
celery -A tasks call spatial.interactive.lookup --kwargs '{"id":"abc"}'
celery -A tasks inspect active | jq '.[] | length'
Configuration Reference
| Parameter | Scope | Value | Rationale |
|---|---|---|---|
task_queues |
app | cpu-heavy, io-light, priority |
Isolate task kinds; no head-of-line blocking |
x-max-priority |
queue | 9 |
Enables in-queue priority preemption |
task_routes |
app | name → queue map | Callers never hard-code a queue |
prefetch-multiplier |
cpu worker | 1 |
One heavy message per worker; keeps dispatch fair |
prefetch-multiplier |
io worker | 8 |
Amortizes broker round-trips for cheap tasks |
pool |
cpu worker | prefork |
True parallelism for CPU-bound geometry work |
pool |
io worker | threads |
Cheap concurrency for IO-bound catalog work |
time_limit |
heavy task | 600 |
Hard-kills a runaway tiling / join |
acks_late + reject_on_worker_lost |
heavy task | true |
Requeue on crash; idempotency key prevents dup work |
max-tasks-per-child |
cpu worker | 50 |
Recycles workers to bound GEOS/GDAL memory growth |
Common Failure Modes & Fixes
- Symptom: cheap metadata calls stall behind tiling jobs. Root cause: both kinds share one queue, or the CPU worker prefetched a batch. Fix: route metadata to
io-lightviatask_routesand set--prefetch-multiplier=1on the CPU worker. - Symptom: a job runs to completion twice after a worker restart. Root cause:
acks_laterequeued the task but it was not idempotent. Fix: claim theSHA-256key before any side effect, as in Step 3, and confirm the dedup branch returns early. - Symptom: worker RSS grows until the pod is OOM-killed. Root cause: GDAL/GEOS native allocations accumulate across tasks. Fix: set
--max-tasks-per-child=50and capMAX_HEAP_MBso each child recycles before it bloats. - Symptom: the
priorityqueue never preempts. Root cause: the broker queue was declared withoutx-max-priority. Fix: redeclare withqueue_arguments={"x-max-priority": 9}— priority is set at queue creation and cannot be added later. - Symptom: interrupted heavy tasks vanish silently. Root cause:
acks_early(the default) acked before completion, so a crash lost the job. Fix: setacks_late=Truewithreject_on_worker_lost=Trueso an interrupted task is redelivered.
FAQ
Why separate CPU-heavy and IO-light queues instead of one big worker?
The two profiles want opposite settings. CPU-bound tiling and joins need prefork with prefetch=1 so one heavy message occupies one process and the rest stay fairly dispatchable; IO-bound catalog writes want threaded concurrency and high prefetch to amortize broker round-trips. Mixing them forces a single compromise that starves one or the other, and a runaway join in a shared queue blocks every cheap lookup behind it.
How do Celery queues stay idempotent under redelivery?
Each heavy task claims a SHA-256(bbox+crs+params) key in Redis with SET NX EX before doing any work. With acks_late=True a crashed task is redelivered, but the redelivery finds the key already set and returns the deduplicated branch instead of recomputing. The TTL on that key is sized to cover the retry and fallback windows in the companion TTL guide.
Does message priority actually preempt a running task?
No — priority orders messages waiting in a queue; it does not interrupt a task already executing. That is why interactive work gets its own priority queue with its own worker: preemption comes from having a dedicated pool that is never blocked by the heavy backlog, combined with prefetch=1 so the heavy worker cannot hoard messages.
What does max-tasks-per-child protect against?
Native geometry libraries (GDAL, GEOS, PROJ) allocate outside Python’s garbage collector, so long-lived prefork children accumulate memory the interpreter never frees. Recycling each child after 50 tasks caps that growth and turns a slow leak into a bounded sawtooth, which pairs with a hard time_limit to keep any single job from pinning a core indefinitely.
Related
- Async Execution for Heavy Spatial Queries — parent reference for the async worker-pool pattern.
- Tuning Redis Idempotency Cache TTL for Spatial Jobs — how the
SHA-256claim key each task writes is sized. - Optimizing Async Execution for Spatial Joins — the join workload that fills the
cpu-heavyqueue. - Building Fallback Chains for Routing APIs — where synchronous requests offload heavy work to these queues.
- Federated Ownership & Routing Architecture — up one level to the routing architecture overview.