Recovering Dead-Letter Queue Messages in Catalog Ingestion
This page answers one narrow operational question: why do catalog-update messages that repeatedly fail simply disappear — the record never lands in the ILS, no exception reaches an operator, and the only trace is a dead-letter queue (DLQ) whose depth keeps climbing — and how do you build a recovery process that replays the recoverable ones and quarantines the rest? It sits under Async Batch Processing for Catalog Updates, which covers the broker-backed ingestion pipeline end to end, and within the broader Catalog Ingestion & ILS Sync Pipelines architecture. If you run any Celery- or RQ-backed catalog worker, a growing DLQ with nobody consuming it is a silent data-loss bug you will eventually discover the hard way — during a collection audit that finds records that should exist but do not.
Problem Framing
The symptom is silence. The catalog ingestion pipeline reports healthy — worker concurrency is nominal, the main queue drains, throughput graphs look fine — yet a subset of records that publishers or the ILS export layer emitted never appear in the catalog. There is no failed task alert because, from the broker’s perspective, nothing failed: the messages were retried the configured number of times, exhausted their attempts, and were routed to the dead-letter queue exactly as designed. The design just forgot to consume it.
The one metric that betrays the leak is DLQ depth. Left unmonitored it climbs monotonically, one poison message at a time. A quick check on the broker makes the accumulation visible:
$ redis-cli LLEN catalog_ingest.dlq
(integer) 4127
$ celery -A catalog inspect stats | grep -A2 dead_letter
"dead_letter_routed": 4127,
"dead_letter_consumed": 0 # nothing has ever drained this queue
$ redis-cli LINDEX catalog_ingest.dlq 0
{"task":"ingest_marc_record","bib_id":"991000482","args":["<base64 MARC>"],
"failure":{"exc_type":"MarcLeaderError","attempts":5,"last_seen":"2026-07-14T02:11Z"}}
Four thousand records have been quietly dropped over months. Some are genuinely broken and belong in a review queue; many are victims of a database failover or an ILS API outage that lasted ninety seconds and has long since resolved. Both cases sit in the same undifferentiated pile, and neither is being processed.
Root Cause
There are two independent faults, and both have to be fixed.
The first is that there is no consumer for the dead-letter queue at all. Most Celery and RQ setups configure a DLQ as a safety net — task_reject_on_worker_lost, a retry ceiling, a dead-letter exchange — and then treat reaching it as the end of the message’s life. Routing to the DLQ is where the pipeline’s responsibility silently ends, so the queue becomes a write-only sink. Nothing replays it, nothing alerts on its depth, and the records inside it are, operationally, lost.
The second fault is that transient and permanent failures are not distinguished. A message lands in the DLQ for one of two fundamentally different reasons. It may carry a poison payload — malformed MARC that fails leader parsing, a record that violates the ingest schema validation contract, a foreign key that references a branch that does not exist — and no amount of retrying will ever make it succeed. Or it may be a perfectly valid message that happened to run during a transient dependency outage: a database failover, an ILS REST API returning 503, a broker connection reset. The retry ceiling treats both identically. Once the same five attempts are burned — whether against a genuinely broken record or against a momentarily unreachable database — the message is dead-lettered with no record of which kind of failure it was.
Blindly replaying the whole DLQ is therefore not a fix: it would re-run thousands of poison messages that will only fail and dead-letter again, an expensive loop that never terminates. The recovery process must read the failure metadata each message already carries, decide which class of failure it was, and act differently on each — replay the transient ones with backoff, quarantine the permanent ones for a human.
Solution
Build a dedicated recovery worker that drains the DLQ on a schedule, classifies each message by the exception recorded when it died, and routes it. Transient failures are re-enqueued onto the normal ingest queue with bounded exponential backoff and a hard replay ceiling; permanent failures are written to a durable quarantine store for human triage. Crucially, the replay is idempotent — a message that already committed on an earlier attempt must not be double-applied — and the original payload plus its full failure metadata are preserved so nothing is destroyed by the recovery step itself. This is the same quarantine discipline the parent Async Batch Processing for Catalog Updates pipeline applies to live failures, extended to the messages that already fell through.
from __future__ import annotations
import enum
import logging
from dataclasses import dataclass
logger = logging.getLogger("catalog.dlq.recovery")
# Exceptions that indicate a momentary dependency problem, not a bad payload.
# Replaying these after the dependency recovers is expected to succeed.
TRANSIENT_EXC: frozenset[str] = frozenset({
"OperationalError", # DB failover / connection reset
"TimeoutError",
"ConnectionResetError",
"ILSServiceUnavailable", # ILS REST API 5xx
"BrokerConnectionError",
})
# Exceptions that mean the payload itself is invalid. Retrying is pointless.
PERMANENT_EXC: frozenset[str] = frozenset({
"MarcLeaderError",
"SchemaValidationError",
"RecordDecodeError",
"ForeignKeyViolation",
})
MAX_REPLAYS = 3 # bounded: a replayed message may fail at most this many more times
class Route(enum.Enum):
REPLAY = "replay"
QUARANTINE = "quarantine"
@dataclass(frozen=True)
class DeadLetter:
bib_id: str
payload: bytes
exc_type: str
attempts: int
replays: int # how many times the recovery worker has already re-enqueued it
def classify(dead: DeadLetter) -> Route:
"""Decide whether a dead-lettered message is worth replaying.
Unknown exception types are treated as permanent and quarantined, so an
unclassified failure fails closed into human review rather than looping.
"""
if dead.replays >= MAX_REPLAYS:
logger.warning(
"replay_ceiling_reached",
extra={"bib_id": dead.bib_id, "exc_type": dead.exc_type,
"replays": dead.replays},
)
return Route.QUARANTINE
if dead.exc_type in TRANSIENT_EXC:
return Route.REPLAY
if dead.exc_type not in PERMANENT_EXC:
logger.warning(
"unclassified_failure_quarantined",
extra={"bib_id": dead.bib_id, "exc_type": dead.exc_type},
)
return Route.QUARANTINE
The dispatch loop reads each dead letter, classifies it, and takes the routed action inside an explicit try/except so a failure to recover a message never itself loses the message:
from __future__ import annotations
import contextlib
from collections.abc import Iterator
from celery import Celery
app = Celery("catalog")
def recover_dead_letters(
letters: Iterator[DeadLetter],
*,
quarantine: "QuarantineStore",
backoff_base_s: float = 30.0,
) -> None:
"""Drain the DLQ once: replay transient failures, quarantine the rest."""
for dead in letters:
route = classify(dead)
try:
if route is Route.REPLAY:
# Bounded exponential backoff keyed on prior replay count.
countdown = backoff_base_s * (2 ** dead.replays)
app.send_task(
"ingest_marc_record",
args=[dead.payload],
countdown=countdown,
headers={"bib_id": dead.bib_id, "replays": dead.replays + 1},
)
logger.info(
"dead_letter_replayed",
extra={"bib_id": dead.bib_id, "exc_type": dead.exc_type,
"countdown_s": countdown, "replay": dead.replays + 1},
)
else:
with contextlib.closing(quarantine.begin()) as tx:
tx.store(dead) # preserves payload + failure metadata verbatim
logger.info(
"dead_letter_quarantined",
extra={"bib_id": dead.bib_id, "exc_type": dead.exc_type,
"attempts": dead.attempts},
)
except Exception:
# Never drop a message because recovery failed; leave it on the DLQ.
logger.exception(
"recovery_failed_message_retained",
extra={"bib_id": dead.bib_id, "exc_type": dead.exc_type},
)
raise
Two properties make this safe to run against a real backlog. First, ingest_marc_record must already be idempotent — keyed on bib_id with an upsert, so replaying a message whose earlier attempt actually committed (the failure came after the write) is a no-op rather than a duplicate record. Second, replay is bounded: the replays counter rides in the task headers, classify quarantines once it reaches MAX_REPLAYS, and the backoff widens each round, so a message that keeps failing a transient check cannot loop forever — it graduates to quarantine and a human sees it.
Compliance or Privacy Impact
A recovery worker touches full catalog payloads, and dead-lettered messages are the ones most likely to contain the odd record that carries patron-linked data — a hold note, a circulation-derived field, an acquisition record with a requester. The quarantine store therefore inherits the same handling obligations as any other persistent copy of ingest data. Two points matter in practice:
- Quarantined payloads persist, so they must respect masking and retention. A message parked for human review may sit for days. If any field in it is patron-identifying, it must be masked on the same terms as an export, following the PII Masking in Patron Data Exports contract, and the quarantine table must carry a bounded retention window rather than growing without limit.
- The recovery log is an audit surface, not a debug dump. Log the
bib_id, exception type, and routing decision — never the raw payload — so the append-only recovery trail proves what happened to every dead letter without becoming a second uncontrolled copy of the record content.
Handled this way, recovery narrows the risk surface rather than widening it: instead of thousands of records sitting indefinitely in an unmonitored broker queue, each is either committed, replayed, or held in a governed store with a retention clock.
Verification
Confirm classification is correct and that replay is both bounded and idempotent. The test asserts a transient exception is replayed, a permanent one is quarantined, an unknown one fails closed to quarantine, and a message at the replay ceiling stops replaying.
def _dead(exc_type: str, replays: int = 0) -> DeadLetter:
return DeadLetter(
bib_id="991000482", payload=b"<marc>", exc_type=exc_type,
attempts=5, replays=replays,
)
def test_transient_is_replayed() -> None:
assert classify(_dead("OperationalError")) is Route.REPLAY
def test_permanent_is_quarantined() -> None:
assert classify(_dead("MarcLeaderError")) is Route.QUARANTINE
def test_unknown_exception_fails_closed() -> None:
# An exception type in neither set must never be replayed blindly.
assert classify(_dead("SomeNewError")) is Route.QUARANTINE
def test_replay_ceiling_forces_quarantine() -> None:
# Even a transient failure stops replaying once the ceiling is hit.
assert classify(_dead("TimeoutError", replays=MAX_REPLAYS)) is Route.QUARANTINE
For the running pipeline, add a monitor on DLQ depth and on the ratio of dead_letter_consumed to dead_letter_routed: a DLQ whose depth trends upward while consumption stays flat is the exact signature this page exists to eliminate, and it should page rather than accumulate. After the first full drain, verify idempotency directly — replay a batch you know already committed and assert the catalog row count is unchanged, proving the upsert absorbed the duplicates rather than creating them.
Related
- Async Batch Processing for Catalog Updates — the parent guide to the broker-backed ingestion pipeline whose failure handling this recovery worker completes.
- Catalog Ingestion & ILS Sync Pipelines — the ingestion and sync architecture this fits within.
- Using Celery for Distributed Catalog Ingestion — the retry and dead-letter routing that produces the messages recovered here.
- Tuning Backpressure for Celery and RQ Catalog Workers — controlling load so replays do not overwhelm the ingest queue.