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.

Celery routing of spatial task kinds into dedicated queues and worker pools A router on the left inspects each task's routing key. Heavy CPU-bound tasks (tiling, spatial join, reprojection) go to a cpu-heavy queue with prefetch one and a hard time limit, drained by a solo-pool worker. Light IO-bound tasks (metadata lookup, catalog write) go to an io-light queue with higher prefetch, drained by a threaded worker. A separate priority queue carries interactive requests. Each worker claims a SHA-256 idempotency key in Redis before executing. Task router by routing key task_routes cpu-heavy queue prefetch 1 · priority 5 io-light queue prefetch 16 · priority 3 priority queue prefetch 1 · priority 9 solo-pool worker time_limit 600s threaded worker concurrency 16 interactive worker soft_time_limit 30s Redis idempotency claim SHA-256(bbox+crs+params)

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.

python
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:

bash
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.

python
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:

bash
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.

python
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:

bash
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.

bash
# 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:

bash
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.

bash
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-light via task_routes and set --prefetch-multiplier=1 on the CPU worker.
  • Symptom: a job runs to completion twice after a worker restart. Root cause: acks_late requeued the task but it was not idempotent. Fix: claim the SHA-256 key 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=50 and cap MAX_HEAP_MB so each child recycles before it bloats.
  • Symptom: the priority queue never preempts. Root cause: the broker queue was declared without x-max-priority. Fix: redeclare with queue_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: set acks_late=True with reject_on_worker_lost=True so 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.