Replaying Dead-Letter Media Jobs Safely

This page sits beneath Retry Logic & Dead Letter Queues within the broader Pipeline Automation & Batch Processing layer, and it addresses the moment after the incident is over: a dead-letter queue holds three thousand failed media jobs, the underlying bug is fixed, and now you have to put those jobs back into the pipeline without republishing episodes that already went out, without re-triggering the same poison message a hundred times, and without a thundering herd that knocks the fixed service straight back over.

Problem Framing

A dead-letter queue (DLQ) is where a message lands after its delivery and retry budget is exhausted. That is the easy part; the broker does it for you. The hard part is the replay, because a DLQ replay is a re-injection of side effects. A naive for msg in dlq: publish(msg) is dangerous for three independent reasons, and a safe drain has to defend against all three at once.

First, duplicates. Many dead-lettered media jobs failed after doing partial work — an encode finished and the asset was uploaded, but the ACK never reached the broker, so the job was retried and eventually dead-lettered. Blindly replaying it publishes the same episode to the RSS feed twice. Second, poison messages: a subset of jobs fail deterministically because the payload itself is malformed, so replaying them just walks them straight back into the DLQ, burning worker time on every lap. Third, load: draining three thousand jobs at line rate slams the downstream service that just recovered.

You can see all three in the operator’s log the instant a naive replay starts:

INFO  replay     drained=3000  reinjected=3000  rate=unbounded
WARN  duplicate  asset=ep214  action=rss_publish  note="episode already live; second publish created dup GUID"
WARN  poison     asset=ep209  dlq_returns=4  reason="unparseable chapter JSON; will never succeed"
ERROR downstream  service=packager  status=503  note="recovered service re-saturated by replay burst"

The fix is a replay controller that decides per message whether to quarantine or replay, makes every re-publish idempotent, drains under a rate limit, and records an audit row for each decision so the operation is reconstructable after the fact.

Solution Architecture

The controller treats the DLQ as read-input, never as a work queue to blindly forward. Each message passes a poison gate (has it already bounced too many times, or does its payload fail a schema check?). Poison messages divert to a quarantine queue for human triage; they are not replayed. Survivors go through an idempotency gate backed by a durable key store: if the job’s idempotency key is already marked complete, the message is dropped as a confirmed duplicate rather than republished. Only genuinely-unfinished jobs are re-published, and even those go out through a token-bucket rate limiter. Every branch writes an audit record.

Safe dead-letter replay controller with poison, idempotency, and rate gates Messages flow from the dead-letter queue into a replay controller, then into a poison-detection decision. Messages that have exceeded their dead-letter return count or fail schema validation divert to a quarantine queue for manual triage and are not replayed. Survivors reach an idempotency gate backed by a key store: jobs whose idempotency key is already marked complete are dropped as confirmed duplicates, while genuinely unfinished jobs pass to a token-bucket rate limiter that re-publishes them to the work queue at a bounded rate. Quarantine, duplicate-drop, and re-publish outcomes all write to a durable audit trail. Dead-letter queue 3000 exhausted jobs Replay controller read-only drain poison? returns>3 · bad schema Idempotency gate key store lookup done? drop : pass Rate limiter token bucket 50 msg/s cap no yes Quarantine queue manual triage · not replayed Drop (dedupe) confirmed duplicate Work queue re-published Durable audit trail msg_id · decision · idempotency_key · operator · timestamp

Because the controller only ever reads the DLQ and re-publishes to the live work queue, the original dead-lettered messages remain in place until each is explicitly ACKed after its decision is durably recorded — so a crash mid-drain resumes exactly where it stopped, with no lost and no double-processed jobs.

Implementation

1. Detect poison messages before anything else

Every dead-lettered message carries a delivery history in its headers. The x-death header that RabbitMQ dead-letter exchanges stamp on a message records how many times it has been dead-lettered; a message that has bounced more than a small threshold is poison by definition and must not re-enter the pipeline. Combine that count with a schema check on the payload so malformed jobs are caught even on their first appearance.

# celery==5.4.0  kombu==5.4.0  redis==5.0.7  pika==1.3.2  (broker: RabbitMQ 3.13)
import hashlib
import json
import logging
from typing import Optional

logger = logging.getLogger("dlq.replay")

MAX_DLQ_RETURNS = 3   # more bounces than this = deterministic failure = poison

def dlq_return_count(headers: dict) -> int:
    """Sum the RabbitMQ x-death counts across all dead-letter reasons."""
    return sum(entry.get("count", 0) for entry in headers.get("x-death", []))

def is_poison(payload: dict, headers: dict) -> Optional[str]:
    """Return a reason string if the message must be quarantined, else None."""
    if dlq_return_count(headers) > MAX_DLQ_RETURNS:
        return f"exceeded {MAX_DLQ_RETURNS} dead-letter returns"
    # Payload-level schema guard: a job with no asset_id or bad chapter JSON
    # will fail deterministically forever — quarantine it, do not replay.
    if not payload.get("asset_id"):
        return "missing asset_id"
    chapters = payload.get("chapters")
    if chapters is not None:
        try:
            json.loads(chapters) if isinstance(chapters, str) else chapters
        except (ValueError, TypeError):
            return "unparseable chapter JSON"
    return None

2. Guard re-publish with an idempotency key

The idempotency key must be derived from the job’s intended effect, not from the message id — two retries of the same publish carry different message ids but the same effect. For a media job, hash the stable inputs: asset id, target stage, and content version. Before re-publishing, check a durable store (here Redis SETNX, per the Redis command reference); if the key is already marked complete, the job’s side effect already happened and the message is a confirmed duplicate to drop.

import redis

kv = redis.Redis(host="redis", port=6379, db=3, decode_responses=True)

def idempotency_key(payload: dict) -> str:
    """Stable hash of the job's intended effect — identical across retries."""
    basis = f"{payload['asset_id']}:{payload['stage']}:{payload.get('version', 1)}"
    return "idem:" + hashlib.sha256(basis.encode()).hexdigest()[:32]

def already_applied(key: str) -> bool:
    """True if this effect was already committed by a prior (successful) run."""
    return kv.get(key) == "done"

def mark_applied(key: str, ttl_seconds: int = 604800) -> None:
    """Called by the WORKER after the side effect commits, not by the replayer."""
    kv.set(key, "done", ex=ttl_seconds)   # 7-day retention window

The critical discipline: the worker calls mark_applied only after the publish/upload actually commits. The replayer only ever reads the key. That split is what makes a replay safe — the record of “this episode is live” is written by the code that made it live, so the replayer can trust it.

3. Drain under a rate limit with a full audit trail

Now tie it together. Pull from the DLQ, classify, route, and — for genuine replays — re-publish through a token bucket so the recovered downstream is never re-saturated. Every decision writes an audit row before the DLQ message is ACKed, so the operation is fully reconstructable. Re-publishing itself uses ordinary Celery task dispatch, whose retry and acknowledgement semantics are covered in the Celery task retry documentation.

import time

RATE_TOKENS_PER_SEC = 50
_bucket = {"tokens": RATE_TOKENS_PER_SEC, "ts": time.monotonic()}

def take_token() -> None:
    """Simple token bucket: blocks until a re-publish slot is available."""
    while True:
        now = time.monotonic()
        _bucket["tokens"] = min(
            RATE_TOKENS_PER_SEC,
            _bucket["tokens"] + (now - _bucket["ts"]) * RATE_TOKENS_PER_SEC,
        )
        _bucket["ts"] = now
        if _bucket["tokens"] >= 1:
            _bucket["tokens"] -= 1
            return
        time.sleep(0.02)

def audit(msg_id: str, decision: str, key: str, reason: str = "") -> None:
    kv.rpush("dlq:audit", json.dumps({
        "msg_id": msg_id, "decision": decision, "idempotency_key": key,
        "reason": reason, "ts": time.time(),
    }))

def replay_one(msg_id: str, payload: dict, headers: dict, quarantine, work_queue) -> str:
    key = idempotency_key(payload)
    poison_reason = is_poison(payload, headers)
    if poison_reason:
        quarantine.publish(payload)
        audit(msg_id, "quarantine", key, poison_reason)
        return "quarantine"
    if already_applied(key):
        audit(msg_id, "drop_duplicate", key)
        return "drop_duplicate"
    take_token()                                   # rate-limited re-injection
    work_queue.publish(payload, headers={"idempotency_key": key})
    audit(msg_id, "replayed", key)
    return "replayed"

For a partial-batch replay — say, only the 400 jobs for one show that failed a specific stage — filter on the payload before calling replay_one, and the same idempotency and audit guarantees apply unchanged. The decision of whether a whole class of messages should be replayed at all, versus permanently quarantined, is the quarantine-versus-replay call the parent guide on Retry Logic & Dead Letter Queues frames in depth.

Verification

Prove the two guarantees operators actually care about: no duplicates, and a complete audit trail.

# 1. Dry-run classification: count how many messages WOULD quarantine vs replay,
#    without publishing anything. A high poison ratio means the bug isn't fixed.
python -c "
from replay import classify_dlq
counts = classify_dlq('media.dlq', dry_run=True)
print(counts)   # e.g. {'replayed': 2761, 'drop_duplicate': 205, 'quarantine': 34}
"

# 2. Duplicate check: replay, then assert no idempotency key was published twice.
python -c "
from replay import drain
report = drain('media.dlq', limit=500)
assert report['double_published'] == 0, report
print('no duplicates:', report)
"

# 3. Audit completeness: every drained message must have exactly one audit row.
redis-cli LLEN dlq:audit
rabbitmqctl list_queues name messages | grep -E 'media.dlq|media.quarantine'
# expected: audit count == messages drained; media.dlq drops to 0; quarantine holds only poison

A healthy drain shows the DLQ depth falling monotonically to zero, the quarantine queue holding only the genuinely-broken jobs, the duplicate-drop count matching the number of jobs that had already completed before dead-lettering, and one audit row per message. Export drain rate, quarantine ratio, and double_published (which must stay at 0) to Prometheus, and alert if the quarantine ratio spikes — that means you started the replay before the root cause was actually fixed.

Failure Modes & Edge Cases

Edge case Symptom Remediation
Idempotency key derived from msg_id Every retry looks unique, duplicates still published Derive the key from stable effect inputs (asset id + stage + version), never the message id
Worker marks mark_applied before commit Replay drops jobs that never actually finished Mark applied only after the side effect commits; on failure leave the key unset
No poison gate Malformed jobs loop DLQ→work→DLQ forever Cap x-death returns and schema-check payload; divert to quarantine
Unbounded drain Recovered downstream re-saturates, 503s return Rate-limit re-publish with a token bucket sized below downstream capacity
ACK DLQ message before audit write Crash mid-drain loses the decision record Write the audit row first, ACK the DLQ message only after it persists
Idempotency TTL shorter than replay window Old keys expire, duplicates slip through Set the key TTL longer than the longest expected replay delay (e.g. 7 days)
Quarantine treated as a retry queue Poison jobs silently re-enter the pipeline Quarantine is terminal until a human edits or discards the payload

Sizing the rate limiter and the retry budget that feeds the DLQ in the first place is a capacity question that overlaps with worker-pool design; when the replayed jobs are GPU-bound, route them back through the priority-aware topology in Priority Queues for Mixed GPU/CPU Media Jobs so a replay burst does not starve live traffic.

FAQ

What should the idempotency key be hashed from?

The job's intended effect, expressed through its stable inputs — for a media job, the asset id, the target stage, and a content version. Two retries of the same publish share those inputs, so they collapse to one key, which is exactly what lets the replayer recognise a duplicate. Never hash the message id or a timestamp: those differ across retries, so every duplicate would look brand new and get republished. The key is a fingerprint of the outcome, not of the delivery.

How do I tell a poison message from one that just needs another try?

Look at the delivery history and the payload, not the last error text. A message whose broker x-death return count has already exceeded a small threshold is failing deterministically — replaying it will not change the outcome, so it is poison and belongs in quarantine. A payload that fails a schema check is poison on its first appearance. Everything else — jobs that failed because a downstream service was briefly down — is a genuine replay candidate once the outage is resolved.

Can I replay only part of a dead-letter queue?

Yes, and you should when only one show or one stage was affected. Filter the drained messages on a payload attribute (show id, failed stage, time window) before the classify step, and process only the matching subset. Because the idempotency gate, poison gate, and audit trail all operate per message, a partial-batch replay carries the identical safety guarantees as a full drain — you are just narrowing which messages the controller considers.