Async Transcription Queue Management
Within the Transcription & Speaker Diarization pipeline, the async transcription queue is the boundary that decouples ingestion from GPU-bound inference, and it protects the layer’s most fragile SLA: bounded end-to-end latency under variable audio durations and fluctuating compute availability. Synchronous transcription collapses the moment a four-hour archival recording lands behind a queue of two-minute voice memos; the queue layer is what keeps throughput deterministic, degradation graceful, and resource allocation precise. This component orchestrates task distribution, enforces concurrency boundaries, and maintains pipeline integrity across distributed workers so that the acoustic models downstream never starve and never thrash.
Prerequisites & Environment
The queue layer assumes Python 3.10+ (for structural pattern matching on event types and precise type hints on payload models) and a broker pinned to the container image rather than the host package manager. Two brokers dominate media pipelines, and the choice is a durability-versus-latency trade-off: Redis Streams gives sub-millisecond enqueue latency, consumer groups, and lightweight pub/sub for high-throughput preprocessing; RabbitMQ gives stronger delivery guarantees, complex routing topologies, and a built-in dead-letter exchange (DLX) that matters when long-form audio must be processed exactly once. Most production deployments run both — Redis Streams for the hot preprocessing path, RabbitMQ where guaranteed delivery is non-negotiable.
# Python 3.10+
# celery==5.3.6 # distributed task routing + worker pools
# redis==5.0.3 # Redis Streams broker + state ledger
# kombu==5.3.5 # transport abstraction (RabbitMQ / Redis)
# pydantic==2.6.4 # payload-contract validation
# structlog==24.1.0 # JSON structured logging
# prometheus-client==0.20.0 # queue-depth and consumer-lag metrics
Worker provisioning is hardware-affinity sensitive: GPU-backed transcription workers must be isolated from CPU-bound preprocessing workers so that audio chunking and timestamp normalization never compete with inference for VRAM. The environment variables below govern everything dynamic; static routing keys and codec parameters stay version-controlled in the worker config, not the shell.
| Variable | Purpose | Example |
|---|---|---|
BROKER_URL |
Primary broker connection | redis://broker:6379/0 |
GPU_WORKER_CONCURRENCY |
Inference tasks per GPU worker | 1 |
CPU_WORKER_CONCURRENCY |
Preprocess threads per CPU worker | 4 |
QUEUE_DEPTH_HIGH_WATER |
Backpressure trip point | 512 |
PRESIGN_TTL_SECONDS |
Object-storage URL lifetime | 900 |
MAX_TASK_ATTEMPTS |
Retries before DLQ routing | 3 |
This component consumes codec-normalized audio guaranteed by the Media Ingestion & Format Architecture boundary, so the queue can assume every task references a real, fetchable object and spend its cycles on routing rather than validation.
Architecture & Queue Topology
A robust queue is partitioned logically so that compute-heavy inference is isolated from lightweight preprocessing and postprocessing. Dedicated routing keys (RabbitMQ) or stream consumer groups (Redis Streams) per stage let each stage scale and fail independently — a backlog of normalization tasks must never block GPU inference, and a stalled GPU worker must never stall metadata extraction. An ingestion gateway validates and enqueues; a dispatcher leases tasks under a visibility timeout; bounded worker pools consume per stage; and a completion event fans work out to the alignment, diarization, and editorial consumers.
Heavy audio payloads never traverse the broker. Workers fetch directly from object storage using presigned URLs carried in the task body, which preserves broker throughput and keeps message serialization cheap. The dispatcher owns idempotency by keying every task on a stable task_id and checking a centralized state ledger before dispatch, so a redelivered message under at-least-once semantics can never trigger a second inference run.
Step-by-Step Implementation
Each step ends with a verification command you can run before wiring the next one.
1. Pin the runtime and partition the broker. Declare one routing lane per stage so consumers subscribe only to the work they own:
# Python 3.10+
# celery==5.3.6
from celery import Celery
from kombu import Queue
app = Celery("transcription", broker="redis://broker:6379/0",
backend="redis://broker:6379/1")
app.conf.task_queues = (
Queue("preprocess", routing_key="stage.preprocess"),
Queue("inference", routing_key="stage.inference"),
Queue("postprocess", routing_key="stage.postprocess"),
)
app.conf.task_default_queue = "preprocess"
app.conf.task_acks_late = True # ack only after the task returns
app.conf.worker_prefetch_multiplier = 1 # never hoard inference tasks
Verify the lanes are declared and reachable:
celery -A app inspect active_queues | grep -E 'preprocess|inference|postprocess'
2. Enforce the minimal payload contract at the gateway. Task bodies stay small — immutable identifiers, a storage URI, a time-bound presigned URL, and routing metadata — and are validated before they reach the broker:
# Python 3.10+
# pydantic==2.6.4
from pydantic import BaseModel, Field
from typing import Literal
class RoutingMeta(BaseModel):
priority: Literal["high", "standard", "low"] = "standard"
codec: Literal["opus", "aac", "wav"]
duration_ms: int = Field(gt=0)
class RetryMeta(BaseModel):
attempt: int = 0
max_attempts: int = 3
backoff_strategy: Literal["exponential"] = "exponential"
class TranscriptionTask(BaseModel):
task_id: str # UUID v4, the idempotency key
source_uri: str # immutable s3:// or gs:// path
presigned_url: str # TTL <= 900s, fetched by the worker
routing: RoutingMeta
retry: RetryMeta
Verify the contract rejects a bloated or malformed payload before enqueue:
python -c "from app import TranscriptionTask; \
TranscriptionTask.model_validate_json(open('task.json').read()); print('OK')"
3. Bound worker concurrency to hardware affinity. A GPU worker processes exactly one transcription at a time; CPU workers handling chunking and normalization run several threads. Pin each worker class to its lane:
# GPU inference worker — one task in flight, never oversubscribe VRAM
celery -A app worker -Q inference --concurrency=1 --pool=solo -n gpu@%h
# CPU preprocess worker — safe to parallelize
celery -A app worker -Q preprocess --concurrency=4 --pool=prefork -n cpu@%h
Verify each worker registered against the right lane with the right concurrency:
celery -A app inspect stats | grep -E 'pool|max-concurrency'
4. Wire backpressure from queue depth. The gateway pauses new submissions when the inference lane crosses its high-water mark, so an upload spike grows queue depth instead of exhausting ephemeral worker storage:
# Python 3.10+
import redis # redis==5.0.3
r = redis.from_url("redis://broker:6379/0")
def admit(task_json: str, high_water: int = 512) -> bool:
depth = r.xlen("stage.inference")
if depth >= high_water:
return False # signal the uploader to retry later
r.xadd("stage.inference", {"task": task_json})
return True
Verify the depth gauge reflects real backlog:
redis-cli XLEN stage.inference
5. Emit completion events for downstream stages. When transcription finishes, the worker publishes a structured event keyed for fan-out — no downstream consumer polls:
# Python 3.10+
import json
def emit_complete(r, task_id: str, transcript_uri: str, conf: float) -> None:
event = {
"task_id": task_id,
"transcript_uri": transcript_uri,
"confidence": conf,
"pipeline_stage": "transcribed", # consumers route off this flag
}
r.xadd("events.transcription", {"event": json.dumps(event)})
Verify the event lands on the bus the consumers subscribe to:
redis-cli XREVRANGE events.transcription + - COUNT 1
Data Contracts
The handoff between the gateway, the workers, and the downstream consumers is governed by an explicit schema. Validating it at the boundary is what makes retries idempotent and routing predictable regardless of upstream media variation.
| Field | Type | Validation rule | Example value |
|---|---|---|---|
task_id |
string | UUID v4; idempotency key | 7c9e6679-…-fa2b |
source_uri |
string | immutable s3:// / gs:// path |
s3://media/ep142.opus |
presigned_url |
string | TTL ≤ 900 s at enqueue time | https://…?X-Amz-Expires=900 |
routing.priority |
enum | high / standard / low |
high |
routing.codec |
enum | opus / aac / wav |
opus |
routing.duration_ms |
int | > 0 |
2742180 |
retry.attempt |
int | >= 0, <= max_attempts |
0 |
retry.max_attempts |
int | 1–5 |
3 |
pipeline_stage |
enum | queued / transcribed / failed |
transcribed |
The task_id is the anchor for everything: the dispatcher checks it against the state ledger before dispatch, the worker writes outputs to a content-addressed path keyed on it, and the completion event carries it so consumers can correlate across stages. A presigned URL whose TTL has lapsed is treated as a deterministic fetch failure, not a transient one.
Resilience Patterns
Every task is a side-effecting subprocess that can fail wholly but never partially. Idempotency is anchored on task_id: downstream services check task state against a centralized ledger before reprocessing, so duplicate completion events are absorbed rather than acted on twice. This makes at-least-once delivery from both Redis Streams and RabbitMQ safe.
Retries use bounded exponential backoff with jitter — min(cap, base * 2**attempt) + random_jitter — and only for transient failure classes. The classifier splits failures before deciding: transient (object-storage timeout, OOM-killed worker, broker disconnect) retries up to max_attempts; deterministic (expired presigned URL, unsupported codec, truncated stream) routes straight to the dead-letter queue with the manifest preserved for forensic replay. The shared retry-and-DLQ machinery — visibility timeouts, poison-message detection, replay tooling — is specified once in the retry logic and dead-letter queues patterns and reused here rather than reinvented per worker. The cross-host orchestration of these worker pools, including autoscaling off queue depth, belongs to Celery task routing for video jobs, which this queue feeds.
Routing adapts to content and cost. High-fidelity podcast episodes route through the Whisper Large V3 integration for maximum accuracy, while low-bandwidth voice memos take a cost-optimized API path. Once raw text exists, the completion event triggers speaker diarization with Pyannote and timestamp alignment and correction in parallel — the pipeline_stage flag, not a hardcoded dependency, is what routes each event to the right consumer group.
Observability & Debugging
Operational visibility comes from structured logs correlated by task_id and a small set of high-signal metrics, not from scraping worker stdout. Emit one JSON log line per task at ingestion, dispatch, and completion, and instrument these metrics:
queue_depth{stage}— gauge of un-leased tasks per lane (the backpressure signal)consumer_lag_seconds{stage}— age of the oldest un-acked task; alert above 2× the average processing windowtask_duration_seconds— histogram of inference wall-clock timedlq_volume_total— counter of tasks routed to the dead-letter queuepool_saturation{stage}— in-flight workers ÷ configured concurrency
For stuck tasks, deploy a sidecar health checker that validates presigned-URL reachability and object-storage availability before a worker begins inference — most “hung” GPU tasks are actually blocked on an expired fetch token. The error messages you will actually see map to concrete root causes:
| Symptom | Root cause | Action |
|---|---|---|
consumer_lag_seconds climbing on inference |
GPU pool saturated or a poisoned long-form file | check pool_saturation; trip backpressure |
403 SignatureDoesNotMatch on fetch |
presigned URL TTL expired before lease | DLQ; do not retry — re-enqueue from source |
CUDA out of memory mid-task |
concurrency > 1 on a GPU worker | force --concurrency=1, raise VRAM ceiling |
| Duplicate transcript published | idempotency ledger not consulted | gate dispatch on task_id lookup |
Performance Tuning
Throughput is governed by lane partitioning, per-worker memory ceilings, and prefetch discipline. Keep worker_prefetch_multiplier=1 on the inference lane so a single worker never hoards tasks it cannot start — prefetch hoarding is the most common cause of phantom queue depth where work appears stuck but is merely reserved. Cap each GPU worker’s VRAM and each CPU worker’s RSS with a cgroup limit so a pathological long-form file is OOM-killed and DLQ’d instead of swapping the host.
Tie dynamic scaling to queue_depth{stage} rather than CPU utilization alone: transient upload spikes should grow queue depth and trip backpressure, not auto-provision GPUs that idle minutes later. Deploy queue consumers blue-green — drain a worker by disabling new task consumption, let in-flight tasks finish, then terminate the idle process — and version payload schemas with backward compatibility enforced at the broker so a rolling deploy can never crash a consumer on deserialization. For the implementation that exercises every one of these knobs end to end, see batch processing transcripts with Celery, and consult the official Celery documentation for prefetch and heartbeat tuning specifics.
Frequently Asked Questions
Redis Streams or RabbitMQ for a transcription queue?
Use Redis Streams for the high-throughput preprocessing path where sub-millisecond enqueue latency and lightweight consumer groups matter, and RabbitMQ where guaranteed exactly-once-ish delivery and a built-in dead-letter exchange are non-negotiable — typically the long-form inference lane. Many pipelines run both, partitioned by lane, rather than forcing one broker to serve both latency and durability profiles.
Why keep audio out of the task payload?
Heavy payloads inflate broker memory, slow serialization, and cap throughput. The task carries only a task_id, the immutable source_uri, and a short-lived presigned URL; the worker fetches the audio directly from object storage. This keeps messages small and uniform regardless of whether the source is a two-minute Opus memo or a four-hour WAV archive.
How does the queue stay idempotent under at-least-once delivery?
Every task is keyed on a UUID v4 task_id, and the dispatcher checks that key against a centralized state ledger before dispatch while outputs are written to a content-addressed path. A redelivered message that finds a completed result re-acknowledges instead of re-running inference, so duplicate broker deliveries can never double-publish a transcript.
What should a single GPU worker's concurrency be?
Exactly one. Long-form transcription is VRAM-bound, and a second concurrent task on the same GPU triggers CUDA out of memory failures that OOM-kill the worker mid-encode. Run GPU workers with --concurrency=1 --pool=solo and scale horizontally by adding workers, not by raising per-worker concurrency. CPU preprocessing workers are the opposite — parallelize them freely.
When should a task go straight to the dead-letter queue instead of retrying?
Deterministic failures — an expired presigned URL, an unsupported codec, a truncated stream — will fail identically on every attempt, so retrying just burns three identical runs. Route those to the DLQ immediately with the manifest preserved. Reserve retries for transient classes: object-storage timeouts, broker disconnects, and OOM-killed workers.
Related
- Up to the parent overview: Transcription & Speaker Diarization
- Implementation walkthrough of these workers: batch processing transcripts with Celery
- The inference stage the queue feeds: Whisper Large V3 integration
- Parallel consumer triggered on completion: speaker diarization with Pyannote
- Where failed tasks are quarantined: retry logic and dead-letter queues
- Cross-host orchestration of these pools: Celery task routing for video jobs