Celery Task Routing for Video Jobs

As part of the Pipeline Automation & Batch Processing layer, Celery task routing is the control plane that decides where each video job runs, while the orchestrator decides when. This component protects a throughput-and-isolation SLA: a 4K HDR transcode must land only on a worker with NVENC and sufficient VRAM, a thumbnail extraction must never block behind it, and no malformed payload may poison a shared queue. Transcoding 4K masters, extracting podcast stems, and generating adaptive-bitrate ladders each impose distinct compute profiles, memory ceilings, and I/O patterns; when they share one queue, resource contention is inevitable. Routing turns a flat worker pool into a specialized, fault-tolerant fabric where hardware affinity, payload contracts, and predictable throughput are enforced as infrastructure code.

Prerequisites & Environment

Reproducible routing starts with a pinned runtime and an externalized configuration manifest. The layer assumes Python 3.10+ (for match statements and precise type hints), a broker that supports priority and per-queue routing, and a result backend that survives worker restarts. Routing rules are never hard-coded in source — they load from a version-controlled YAML manifest so the same topology validates across development, staging, and production. Configuration drift is treated as a critical failure vector, so the manifest, not the shell, owns queue names and concurrency caps.

# Python 3.10+
# celery==5.3.6           # task routing, retries, autoscale
# kombu==5.3.5            # Queue/Exchange/binding primitives
# redis==5.0.3            # broker + result backend (or RabbitMQ via amqp)
# pydantic==2.6.4         # payload + result-contract validation
# prometheus-client==0.20.0  # per-queue metrics exporter
# structlog==24.1.0       # JSON structured logging

GPU transcode workers need a host with an NVENC-capable card and the driver stack baked into the image; CPU workers for thumbnailing and waveform generation need only fast local scratch. Budget per-worker memory against the peak frame buffer of the largest job class a queue accepts — a 4K HDR encode can hold 4–6 GB of VRAM plus host RSS, while a 1080p thumbnail extraction stays under 300 MB. The following environment variables govern everything dynamic; static routing rules stay in the manifest:

Variable Purpose Example
CELERY_BROKER_URL Broker connection (Redis or RabbitMQ) redis://broker:6379/0
CELERY_RESULT_BACKEND Durable result store redis://broker:6379/1
ROUTING_MANIFEST Path to version-controlled queue/route YAML /etc/pipeline/routes.yml
GPU_QUEUE_CONCURRENCY Workers per GPU host (often 1) 1
CPU_QUEUE_CONCURRENCY Workers on CPU thumbnail/audio hosts 4
WORKER_PREFETCH_MULTIPLIER Tasks reserved per worker (1 for long jobs) 1

This component consumes already-validated assets. Container integrity, stream topology, and codec identification are settled upstream in the Media Ingestion & Format Architecture pipeline, and the worker images themselves are built per the Dockerizing Media Processing Containers standard, so the routing layer can assume each job points at a real, probeable asset and spend its cycles deciding placement rather than re-inspecting media.

Architecture & Queue Topology

The routing layer is a deterministic dispatcher sitting between an orchestrator and a set of hardware-segregated worker pools. Producers validate a payload, resolve it to exactly one queue through the task_routes map, and publish; consumers are launched with -Q bound to specific queues so a job can only ever execute on matching hardware. A production topology separates video.transcode.gpu, video.thumbnail.cpu, audio.extract, and metadata.parse into distinct logical channels, with a dedicated dead-letter queue catching anything that exhausts its retries. This isolation prevents a runaway FFmpeg process from starving lightweight database updates or webhook dispatches.

Celery hardware-aware routing topology for video jobs A producer validates each job against a Pydantic payload contract, then a router resolves it through the task_routes map and a dynamic VideoRouter class. The broker acts as a traffic controller, fanning each task to exactly one of four hardware-segregated queues: video.transcode.gpu bound to an NVENC GPU pool at concurrency one, video.thumbnail.cpu on a four-way CPU prefork pool, audio.extract on a CPU pool, and metadata.parse as the default CPU queue. Any queue whose task exhausts its retries branches down a dashed dead-letter path into video.dlq, where the payload, error metadata, and attempt count are preserved. retries exhausted Producer Pydantic payload contract Router task_routes · VideoRouter broker · traffic controller video.transcode.gpu NVENC GPU · -c 1 · --pool=solo video.thumbnail.cpu CPU prefork · -c 4 audio.extract CPU prefork metadata.parse CPU · default queue video.dlq payload · error metadata · attempt count task hand-off · one queue per job retries exhausted → dead-letter

The orchestrator emits Celery tasks only when upstream validation or storage provisioning completes; integrating routing with the Orchestrating Pipelines with Airflow DAG keeps queue topology, retry policy, and concurrency caps identical across environments. The broker functions purely as a traffic controller — it guarantees an H.264 encode never lands on a worker lacking NVENC, but it owns no business logic. Idempotency is anchored on the input SHA-256 so a redelivered message can never double-publish a rendition.

Step-by-Step Implementation

Each step ends with a verification command you can run before wiring the next one.

1. Declare queues and the task_routes map. Define every queue explicitly through Kombu so the broker provisions durable bindings, and route static task names to their hardware tier. Loading from the manifest keeps this declarative:

# Python 3.10+
from celery import Celery
from kombu import Queue

app = Celery("media", broker="redis://broker:6379/0",
             backend="redis://broker:6379/1")

app.conf.task_queues = (
    Queue("video.transcode.gpu"),
    Queue("video.thumbnail.cpu"),
    Queue("audio.extract"),
    Queue("metadata.parse"),
    Queue("video.dlq"),               # terminal sink for exhausted retries
)

app.conf.task_routes = {
    "tasks.transcode_hls":   {"queue": "video.transcode.gpu"},
    "tasks.make_thumbnails": {"queue": "video.thumbnail.cpu"},
    "tasks.extract_audio":   {"queue": "audio.extract"},
    "tasks.parse_metadata":  {"queue": "metadata.parse"},
}
app.conf.task_default_queue = "metadata.parse"     # never the GPU pool
app.conf.worker_prefetch_multiplier = 1            # long jobs: no hoarding

Verify the broker registered all five queues:

celery -A tasks inspect active_queues | grep -E 'video\.|audio\.|metadata\.'

2. Validate the job payload before dispatch. Reject poison messages at the producer boundary so workers only ever receive structurally sound instructions. A Pydantic model validates codec, resolution, and storage URI before the task is published:

# Python 3.10+
from pydantic import BaseModel, field_validator

ACCEPTED_CODECS = {"h264", "hevc", "prores", "vp9"}

class VideoJob(BaseModel):
    asset_sha256: str          # idempotency key
    source_uri: str            # s3://… or gs://…
    codec: str
    width: int
    height: int
    hdr: bool = False

    @field_validator("codec")
    @classmethod
    def known_codec(cls, v: str) -> str:
        if v not in ACCEPTED_CODECS:
            raise ValueError(f"unsupported codec: {v}")
        return v

Verify a malformed payload is rejected before it reaches the broker:

python -c "from jobs import VideoJob; VideoJob(asset_sha256='x', source_uri='s3://b/k', codec='mjpeg', width=3840, height=2160)" 2>&1 | grep -q "unsupported codec" && echo REJECTED

3. Route dynamically by codec and resolution. Static names cover most tasks, but a single transcode task whose target hardware depends on resolution needs a Router class. Celery consults routers before the static map, so this is where hardware affinity is computed:

# Python 3.10+
class VideoRouter:
    def route_for_task(self, name, args=None, kwargs=None, **opts):
        if name != "tasks.transcode_hls":
            return None                       # fall through to task_routes
        job = kwargs or {}
        # 4K / HDR demands NVENC + VRAM; SD can run on CPU libx264
        if job.get("hdr") or job.get("width", 0) >= 3840:
            return {"queue": "video.transcode.gpu", "priority": 9}
        if job.get("width", 0) >= 1920:
            return {"queue": "video.transcode.gpu", "priority": 5}
        return {"queue": "video.thumbnail.cpu", "priority": 1}

app.conf.task_routes = (VideoRouter(), app.conf.task_routes)

Verify a 4K job resolves to the GPU queue without publishing it:

python -c "from router import VideoRouter; print(VideoRouter().route_for_task('tasks.transcode_hls', kwargs={'width':3840,'hdr':True}))"

4. Bind workers to queues with hardware affinity. A GPU host runs a single-concurrency worker consuming only the transcode queue; CPU hosts run higher concurrency across the light queues. The -Q flag is the enforcement mechanism — a worker physically cannot pull from a queue it is not bound to:

# GPU host — one NVENC job at a time, never oversubscribe VRAM
celery -A tasks worker -Q video.transcode.gpu -c 1 \
       --pool=solo -n gpu@%h --max-tasks-per-child 50

# CPU host — light, parallel jobs
celery -A tasks worker -Q video.thumbnail.cpu,audio.extract,metadata.parse \
       -c 4 --pool=prefork -n cpu@%h

Verify each worker is bound to exactly the queues you intend:

celery -A tasks inspect active_queues | grep -A1 'gpu@'

5. Verify routing with a mock-publish integration test. Routing correctness must be asserted in CI, not discovered under production load. Publish a representative payload with task_always_eager disabled and assert the resolved queue:

# Python 3.10+  (pytest)
def test_4k_hdr_routes_to_gpu():
    opts = app.amqp.router.route(
        {}, "tasks.transcode_hls",
        kwargs={"width": 3840, "height": 2160, "hdr": True},
    )
    assert opts["queue"] == "video.transcode.gpu"

def test_sd_never_hits_gpu():
    opts = app.amqp.router.route({}, "tasks.transcode_hls",
                                 kwargs={"width": 640, "height": 360})
    assert opts["queue"] != "video.transcode.gpu"

Verify the routing suite is green before promoting the manifest:

pytest -q tests/test_routing.py && echo ROUTING_OK

Data Contracts

The handoff between producer and worker is governed by an explicit schema. Validating this contract at the ingress boundary is what makes retries idempotent and quarantine precise — a worker that trusts the contract never re-probes the asset.

Field Type Validation rule Example value
asset_sha256 string 64-char hex; idempotency key 9f2c…a1
source_uri string s3:// or gs:// scheme; reachable s3://masters/ep42.mov
codec string one of h264, hevc, prores, vp9 hevc
width int > 0, <= 7680 3840
height int > 0, <= 4320 2160
hdr bool true forces GPU queue true
target_queue string resolved queue name (echoed in result) video.transcode.gpu
priority int 09; higher preempts 9
status enum queued / processing / success / failed success
error string|null present only when status == failed null

The target_queue echoed back in the result is what lets you detect routing drift across deployments: if two environments resolve the same payload to different queues, their routers have diverged. Assets whose codec or resolution falls outside the accepted set are not transcoded here — they are rejected at validation and returned upstream rather than dispatched to a worker that would only fail.

Resilience Patterns

A video task is a side-effecting subprocess, so every invocation is treated as potentially failing and never partially. Idempotency is anchored on asset_sha256: renditions are written to a content-addressed path, and a redelivered message that finds a completed output re-acknowledges instead of re-encoding, making at-least-once broker delivery safe.

Retries use bounded exponential backoff with jitter so a storage-API blip never triggers a thundering herd. The decision to retry hinges on classifying the failure first — retrying a permanently broken asset just burns the same cycles three times:

# Python 3.10+
TRANSIENT = (ConnectionError, TimeoutError)

@app.task(bind=True, max_retries=3, acks_late=True,
          autoretry_for=TRANSIENT, retry_backoff=True,
          retry_backoff_max=600, retry_jitter=True)
def transcode_hls(self, **payload):
    try:
        return run_transcode(payload)
    except (FileNotFoundError, UnsupportedCodecError) as exc:
        # deterministic failure: do not retry, quarantine immediately
        send_to_dlq(payload, error=str(exc))
        raise self.replace(dlq_marker(payload))

Transient errors — ConnectionError, HTTP 5xx, temporary codec-lock contention — warrant retries; permanent errors — FileNotFoundError, invalid container format, unsupported pixel format — route straight to video.dlq with the payload and structured error metadata preserved. DLQ consumers trigger automated triage: re-download a source asset, notify editorial, or fall back to a software encoder. The shared retry-and-DLQ machinery — visibility timeouts, poison-message detection, replay tooling — is specified once in the Retry Logic & Dead Letter Queues patterns and reused here rather than reinvented per worker. The Celery task retry documentation covers the autoretry_for and max_retries semantics in full.

Observability & Debugging

Routing complexity demands proportional observability. Export per-queue metrics with a Prometheus client running alongside the workers, and standardize structured log fields so a stalled job can be traced to its queue and host in one query. Run with acks_late=True and worker_prefetch_multiplier=1 so a crash mid-transcode redelivers the job instead of losing it.

Instrument these metrics:

  • celery_queue_depth{queue} — gauge of un-leased tasks; the backpressure signal
  • celery_task_latency_seconds{task,queue} — histogram of execution wall-clock
  • celery_retries_total{task} — counter; a spike on audio.extract flags upstream rate limiting
  • celery_dlq_depth — gauge of quarantined payloads; should trend to zero
  • gpu_worker_saturation — in-flight transcodes ÷ GPU_QUEUE_CONCURRENCY

Standardize these log fields on every task: task_id, asset_sha256, queue_name, worker_hostname, attempt, and execution_time. Correlate them with distributed-tracing spans to find latency hotspots. The error messages you will actually see map to concrete causes:

Symptom Root cause Action
High depth on video.transcode.gpu insufficient GPU concurrency or storage I/O bottleneck add a GPU host or move scratch to local NVMe
WorkerLostError mid-transcode OOM-killed worker on 4K/HDR drop GPU concurrency to -c 1, raise VRAM ceiling
Elevated retries on audio.extract upstream media server rate limiting widen backoff, add request budget
Task ran on wrong hardware worker bound to extra queues via -Q re-bind worker; assert with inspect active_queues
video.dlq filling steadily systemic codec or storage failure audit DLQ payloads before SLA breach

When debugging stalled jobs, inspect the broker’s acknowledgment state and confirm bindings with celery -A tasks inspect active_queues; a worker silently subscribed to the GPU queue is the most common cause of an H.264 job landing on a host without NVENC.

Performance Tuning

Throughput is governed by three knobs: per-queue concurrency, prefetch, and pool type. Set GPU concurrency from VRAM, not core count — a 1080p transcode may comfortably run at -c 4, but 4K HDR often requires -c 1 to avoid OOM kills. Keep worker_prefetch_multiplier=1 for long jobs so a worker does not reserve a backlog it cannot start, which would otherwise starve idle peers. Choose --pool=prefork for CPU-bound thumbnailing and --pool=solo for GPU workers whose codec library is not thread-safe, and bound --max-tasks-per-child so a leaking FFmpeg subprocess is recycled before it degrades the host.

For Kubernetes, bind GPU workers to labeled nodes with node selectors and taints, and use NVIDIA device passthrough so a pod can only schedule where its hardware exists. Provision identical broker virtual hosts and routing exchanges across environments with infrastructure-as-code, then validate the topology in CI before deployment. Always benchmark concurrency against actual peak RSS and VRAM per task rather than nominal limits — measured ceilings are the only ones that survive a 4K HDR master.

Frequently Asked Questions

Should I route with the static task_routes map or a Router class?

Use the static task_routes map whenever the destination depends only on the task name — it is declarative and trivially testable. Reach for a Router class only when one task must land on different hardware depending on its arguments, such as a single transcode task whose queue depends on resolution or HDR. Celery consults routers before the static map, so you can layer a dynamic router on top and let everything else fall through to the map.

Why set worker_prefetch_multiplier to 1 for video jobs?

The default prefetch lets each worker reserve several tasks at once, which is efficient for short jobs but disastrous for long transcodes: one worker hoards a backlog of 4K encodes it cannot start in parallel while idle workers sit empty. Setting it to 1 reserves exactly one task per worker, so the broker spreads long jobs evenly and a crash redelivers a single unit of work rather than a hoarded batch.

How do I stop an H.264 job from landing on a worker without NVENC?

Enforcement is the -Q binding, not the payload. Launch GPU workers bound only to video.transcode.gpu and CPU workers bound only to the light queues — a worker physically cannot pull from a queue it is not subscribed to. Then assert hardware affinity in the router so HDR and 4K resolve to the GPU queue, and verify both with celery inspect active_queues. The most common cause of a misplaced job is a CPU worker accidentally listing the GPU queue in its -Q flag.

When should a failed transcode retry versus go straight to the DLQ?

Classify the exception before deciding. Transient failures — ConnectionError, HTTP 5xx from object storage, temporary codec-lock contention — warrant bounded exponential-backoff retries. Deterministic failures — FileNotFoundError, an unsupported pixel format, a truncated container — will fail identically every time, so route them straight to video.dlq with the payload preserved. Retrying a permanently broken asset only burns the same cycles three times and delays the alert.

How do I validate routing rules in CI before deploying?

Call app.amqp.router.route(...) with representative payloads in a pytest suite and assert the resolved queue, with no broker required. Cover the boundary cases — 4K and HDR must resolve to the GPU queue, SD must never reach it — and run the suite in CI against the exact routing manifest that will ship. This catches silent misconfigurations, like a swapped queue name, that would otherwise only surface as misplaced jobs under production load.