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.

Async transcription queue topology An ingestion gateway validates each payload, mints a presigned URL, and admits the task into a broker partitioned into three independent lanes: a stage.preprocess Redis Streams group, a stage.inference lane with guaranteed delivery, and a stage.postprocess lane. Each lane feeds a bounded worker pool — CPU preprocess at concurrency four, a GPU inference pool capped at concurrency one, and a CPU packaging pool. The pools converge on the events.transcription bus, which fans out to the diarization, timestamp-alignment, and editorial consumers. A dashed backpressure signal runs from the inference lane back to the gateway when queue depth crosses the high-water mark, and a dashed branch routes inference tasks to the dead-letter queue once retries are exhausted. backpressure — queue depth ≥ high-water retries exhausted Ingestion gateway validate · presign · admit stage.preprocess Redis Streams group stage.inference guaranteed delivery stage.postprocess metadata · packaging CPU pool prefork · --concurrency=4 GPU pool solo · --concurrency=1 CPU pool package · emit events.transcription diarization Pyannote timestamp alignment correction pass editorial publishing consumer dead-letter queue manifest preserved for replay GATEWAY BROKER LANES WORKER POOLS 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 15 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 window
  • task_duration_seconds — histogram of inference wall-clock time
  • dlq_volume_total — counter of tasks routed to the dead-letter queue
  • pool_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.