Media Validation & Error Routing

Within the Media Ingestion & Format Architecture pipeline, media validation and error routing is the first executable checkpoint between raw storage and the compute workers — the gate that protects the transcode SLA by guaranteeing that every asset reaching a GPU is already structurally sound. Automated processing systems fail predictably when validation is treated as an afterthought rather than a deterministic routing layer: malformed inputs saturate queues, exhaust file descriptors, and trigger silent dropouts deep inside FFmpeg batch processing for podcasts. This component inspects incoming assets, classifies structural anomalies, and routes each payload along a predefined execution path before any expensive transcoding or normalization resource is consumed, turning unpredictable ingestion patterns into reproducible, fault-tolerant streams.

Prerequisites & Environment

This gate runs as a CPU-only worker pool — it never allocates a GPU, because its entire purpose is to decide whether a GPU should ever be allocated. Pin the toolchain explicitly so staging and production apply identical structural thresholds:

  • Python: 3.10+ (the implementation uses match statements and X | None union syntax).
  • FFmpeg / ffprobe: 6.0 or newer, built with the demuxers you accept. ffprobe -version must report the same build hash across every worker image.
  • Libraries (pinned): av==12.0.0 (PyAV, bound to FFmpeg’s libav* for header parsing without a subprocess), redis==5.0.1 for lease and queue state, celery==5.3.6 for task routing, and pydantic==2.6.0 to validate probe payloads against a schema.
  • Hardware: 2 vCPU and 512 MB RAM per worker is sufficient; probing is I/O-bound, not compute-bound. Provision local scratch on fast storage for the byte windows ffprobe reads.
  • Environment variables: PROBE_TIMEOUT_S=5, PROBE_SIZE=50M, ANALYZE_DURATION_US=30000000, MAX_DURATION_S=14400, DLQ_NAME=media.dlq, and BROKER_URL for the Celery transport.

Validation configurations are version-controlled alongside the pipeline manifest so that memory ceilings, file-descriptor limits, and concurrent-probe thresholds are declarative rather than ad-hoc. Rather than relying on superficial extension checks or MIME sniffing, this stage performs deep stream enumeration and codec-compliance scanning, which is why a known FFmpeg build is a hard prerequisite rather than a convenience.

Architecture & Queue Topology

The validator consumes from an ingress queue, runs a single bounded probe, and fans the result out to one of three terminal queues plus the transcode queue. A probe never decodes the payload; it reads container headers, stream durations, and bitrate profiles to establish a deterministic baseline for the routing decision. The classifier is pure — given identical probe output it always emits the same routing verdict — which is what makes the topology auditable.

Validation gate queue topology: one bounded probe fanned out to four terminal queues A left-to-right data-flow diagram. An ingress queue feeds a CPU-only validation worker that runs ffprobe under a five-second timeout reading headers only. Its output passes to a pure classifier whose verdict fans out along four colour-coded edges: READY routes to the transcode queue (the only edge that spends GPU compute), RECOVERABLE routes to the enrichment queue for metadata repair, POLICY routes to a rejection webhook and compliance archive, and FATAL routes to the 400-class dead-letter queue carrying stderr and return code. Ingress queue media.ingest Validation worker ffprobe · 5s timeout headers only · no decode Classifier pure verdict READY Transcode queue media.transcode · only GPU edge RECOVERABLE Enrichment queue media.enrich · metadata repair POLICY Rejection webhook media.reject · compliance archive FATAL Dead-letter queue media.dlq · 400-class · stderr + rc

The dead-letter target holds assets a human or replay job must inspect; the enrichment target feeds metadata repair before re-entry; the rejection path emits a webhook and archives the asset for compliance auditing. Only the compliant edge reaches a worker that costs GPU minutes — every other edge isolates the asset cheaply.

Step-by-Step Implementation

1. Probe without decoding

Invoke ffprobe through subprocess with an explicit timeout boundary and full stderr capture, then parse the JSON payload. Bounding -probesize and -analyzeduration prevents an unbounded scan from hanging the worker on a truncated file.

# python 3.10+
# ffmpeg 6.0 (ffprobe on PATH)
import json
import subprocess
from dataclasses import dataclass

@dataclass(frozen=True)
class Probe:
    ok: bool
    data: dict
    stderr: str
    returncode: int

def probe(path: str, timeout_s: int = 5) -> Probe:
    cmd = [
        "ffprobe", "-v", "error", "-show_error",
        "-show_format", "-show_streams",
        "-probesize", "50M", "-analyzeduration", "30000000",
        "-of", "json", path,
    ]
    try:
        proc = subprocess.run(
            cmd, capture_output=True, text=True,
            timeout=timeout_s, check=False,
        )
    except subprocess.TimeoutExpired:
        return Probe(False, {}, "probe timeout", returncode=124)
    payload = json.loads(proc.stdout or "{}")
    return Probe(proc.returncode == 0, payload, proc.stderr, proc.returncode)

Verify: ffprobe -v error -show_format -show_streams -of json sample.mp4 | jq '.format.duration' should return a numeric duration in under 200 ms for a healthy file and fail fast (exit 124 from the wrapper) on a truncated one.

2. Classify the result into deterministic classes

A robust strategy sorts every anomaly into exactly one of three classes. The classifier reads the probe output and returns a routing verdict; it never mutates state.

# python 3.10+
from enum import Enum

class Verdict(str, Enum):
    READY = "ready"            # passes all gates
    FATAL = "fatal"            # missing/zero-byte/unrecognized container
    RECOVERABLE = "recoverable"  # metadata gaps, enrichable
    POLICY = "policy"          # breaches an SLA contract

MAX_DURATION_S = 14_400
SUPPORTED_CODECS = {"aac", "mp3", "pcm_s16le", "opus", "h264", "hevc"}

def classify(p: Probe, *, max_duration_s: int = MAX_DURATION_S) -> Verdict:
    if not p.ok or not p.data.get("streams"):
        return Verdict.FATAL  # missing headers, zero-byte, bad signature
    fmt = p.data.get("format", {})
    streams = p.data["streams"]
    codecs = {s.get("codec_name") for s in streams}
    if codecs - SUPPORTED_CODECS:
        return Verdict.POLICY  # unsupported codec breaches the transcode matrix
    duration = float(fmt.get("duration", 0) or 0)
    if duration > max_duration_s:
        return Verdict.POLICY  # exceeds the SLA duration ceiling
    if not fmt.get("tags") or "title" not in fmt.get("tags", {}):
        return Verdict.RECOVERABLE  # missing ID3/metadata, enrichable
    return Verdict.READY

The three classes map to concrete failure handling. Fatal structural defects — missing stream headers, zero-byte payloads, unrecognized container signatures — route immediately to a dead-letter queue with a 400-class status. Recoverable metadata gaps — absent ID3 tags, missing chapter markers, non-standard loudness metadata — are tagged for post-process enrichment. Policy violations — excessive duration, unsupported codecs, bitrate ceilings that breach an SLA — trigger an automated rejection webhook and are archived for audit.

Verify: python -m pytest tests/test_classify.py -k "fatal or policy or recoverable" — drive the classifier with a fixture set of one healthy file and one synthetic example per class and assert the verdict.

3. Route the verdict to a queue

Dispatch each verdict to its terminal queue using Celery task routing. The dead-letter and rejection paths carry the structured diagnostics so a replay or compliance job can reconstruct exactly why the asset was held.

# python 3.10+
# celery==5.3.6
from celery import Celery

app = Celery("validation", broker="redis://localhost:6379/0")

ROUTES = {
    Verdict.READY: "media.transcode",
    Verdict.FATAL: "media.dlq",
    Verdict.RECOVERABLE: "media.enrich",
    Verdict.POLICY: "media.reject",
}

def route(asset_id: str, path: str) -> str:
    p = probe(path)
    verdict = classify(p)
    queue = ROUTES[verdict]
    app.send_task(
        f"{queue}.handle",
        kwargs={
            "asset_id": asset_id,
            "verdict": verdict.value,
            "returncode": p.returncode,
            "stderr": p.stderr[:2000],
        },
        queue=queue,
    )
    return queue

Before a compliant payload reaches audio codec normalization workflows, this routing step also stamps it with the verified channel topology, sample-rate alignment, and codec profile so the normalizer never re-probes. Mismatched channel layouts (for example 5.1 downmixed to stereo without explicit instructions) or unsupported PCM bit depths would otherwise cause silent failures in the downstream DSP chain.

Verify: redis-cli -n 0 LLEN media.dlq and redis-cli -n 0 LLEN media.transcode should move in lock-step with the verdicts your fixtures produce; nothing should land in media.transcode for a fatal fixture.

4. Confirm isolation

The whole gate exists to keep malformed inputs away from compute. Assert it: replay a batch that is deliberately half-corrupt and confirm the transcode queue only ever saw the healthy half.

Verify: redis-cli -n 0 LLEN media.transcode after a 50/50 batch must equal exactly the count of healthy fixtures, and the GPU worker’s job log must contain zero asset_ids that the classifier marked fatal or policy.

Data Contracts

Every payload crossing this boundary is validated against an explicit schema before it enters the broker; a probe output that does not match the contract is itself a fatal event. Use a pydantic model (or JSON Schema) so a malformed probe payload is rejected at the boundary rather than several stages later.

Field Type Validation rule Example value
asset_id string (UUID) uuid5 of object key + content digest; required 7c9e...
container string must be in the accepted-format set mov,mp4,m4a
duration_s float 0 < duration_s <= MAX_DURATION_S 3187.4
streams[].codec_name string must be in SUPPORTED_CODECS or route POLICY aac
streams[].sample_rate int in {44100, 48000} for audio 48000
streams[].channels int in {1, 2, 6}; declared topology only 2
verdict enum one of ready/fatal/recoverable/policy ready
returncode int ffprobe exit; 124 reserved for timeout 0
stderr string truncated to 2000 chars, UTF-8 ""

A contract miss is never silently coerced past tolerance — it is serialized as a structured error and routed, never dropped. For video and complex audio that clears the contract, the routing manifest also records the hardware-acceleration flags the target transcoder supports, which prevents driver-level crashes when the asset later reaches a GPU encoder.

Resilience Patterns

Container-level corruption is one of the most frequent failure modes in automated workflows: malformed moov atoms, truncated index tables, and mismatched stream timestamps demand parsing logic that distinguishes recoverable structural defects from fatal corruption. When a probe returns only a partial stream map, the gate must decide whether the defect lives in the metadata layer or the actual payload — the deep remediation patterns for that case live in handling corrupt MP4 files in automated pipelines.

  • Retry policy: wrap every probe in a bounded retry — at most two attempts — and never retry a fatal verdict, only transient transport errors (broker unavailable, lease lost). A structurally broken file will not become valid on the second read.
  • Backoff with jitter: apply exponential backoff with jitter on transient failures, capped at 30 s, to avoid a thundering herd of probes after a shared storage blip.
  • Idempotency: keying each job on uuid5(object_key + content_digest) makes a redelivered message a safe no-op; the classifier is pure, so re-running it yields the same verdict and the same routing target.
  • DLQ routing: fatal assets land in the dead-letter queue with their stderr and returncode attached, so a replay job can reconstruct the failure without re-probing the (possibly deleted) source.
  • Fallback tier: even validated assets can fail later — a transient worker crash or an edge-case codec bug — so the orchestration layer captures the exact exit code and stderr and degrades to a secondary processing tier, as documented in setting up fallback routing for failed transcodes.

These guardrails are what let the broader Pipeline Automation & Batch Processing layer assume its inputs are clean, and they keep worker-pool availability reserved for healthy payloads instead of being burned on inputs that were always going to fail.

Observability & Debugging

Instrument the gate so drift is visible before it becomes an incident. Track the error-classification distribution as a first-class signal — a sudden rise in the policy share usually means upstream content shifted, not that the validator broke.

  • Metrics: validation_probe_seconds (histogram, watch p95/p99), validation_verdict_total{verdict=...} (counter, the class distribution), validation_queue_depth{queue=...} (gauge), and validation_fd_open (gauge — open file descriptors per worker).
  • Structured log fields: asset_id, verdict, returncode, container, duration_s, and the first line of stderr. Log one structured record per decision so every routing verdict is reconstructable.
  • Common error messages and root causes: moov atom not found → metadata atom trails mdat, needs faststart relocation; Invalid data found when processing input → truncated or non-media payload, classify fatal; probe timeout (exit 124) → unbounded scan on a corrupt file, confirm -probesize/-analyzeduration are applied; Too many open files → file descriptors leaking, enforce cleanup in a finally block.

When probing through PyAV instead of a subprocess, always close the container in a finally block and cap concurrent probes — orphaned av handles exhaust the worker’s descriptor table far faster than the OS limit suggests.

Performance Tuning

Probing is I/O-bound, so the tuning levers are about bounding work, not adding cores.

  • Concurrency: size the worker pool to min(2 × vCPU, descriptor_budget / 8); each probe can hold several descriptors transiently. Start at --concurrency 4 per 2-vCPU worker and raise only while p99 validation_probe_seconds stays flat.
  • Bounded reads: keep -probesize 50M and -analyzeduration 30000000 (30 s in microseconds). Raising them only helps for genuinely long files with late stream changes and otherwise just lengthens the worst case.
  • Memory ceiling: cap each worker at 512 MB; a probe that approaches that is reading too large a window — lower -probesize rather than adding RAM.
  • Timeout boundary: keep PROBE_TIMEOUT_S tight (5 s). The timeout is a routing signal, not a failure to avoid — a file that cannot be probed in 5 s belongs in the dead-letter queue, not in a longer retry loop.
  • Hardware affinity: pin this pool to CPU nodes and keep it off GPU hosts entirely; it should never compete with transcode or with the downstream Transcription & Speaker Diarization workers for accelerator memory.

Frequently Asked Questions

Why probe with ffprobe instead of trusting the file extension or MIME type?

Extensions and MIME types describe what an uploader claims, not what the bytes contain. A .mp4 can carry a truncated moov atom or an unsupported codec that only surfaces when a decoder touches it. Probing reads the actual container headers and stream table, which is the only way to make a deterministic routing decision before compute is allocated.

Should a fatal asset ever be retried?

No. A retry is only justified for transient transport failures — the broker was briefly unavailable, or a lease was lost mid-flight. A missing header or zero-byte payload is deterministic: re-reading it produces the same result, so retrying only burns worker time. Route fatal verdicts straight to the dead-letter queue and let a replay job re-submit if the source is later repaired.

How do recoverable and policy verdicts differ in practice?

A recoverable verdict means the media is sound but its metadata is incomplete — a missing ID3 title or absent chapter markers — so it is enrichable and re-enters the pipeline. A policy verdict means the media itself violates a contract (too long, unsupported codec, bitrate over the SLA ceiling); it is rejected via webhook and archived for audit rather than repaired.

What stops the validation pool from becoming the bottleneck during an upload spike?

Three things: bounded probes (-probesize/-analyzeduration) so no single file can hang a worker, a tight PROBE_TIMEOUT_S that converts a slow file into a routing decision, and a declared concurrency ceiling tied to the file-descriptor budget. Because probing is I/O-bound and never decodes, a CPU-only pool scales horizontally without touching GPU capacity.