Dead-Letter Queue Patterns for CDC Events

A dead-letter queue for CDC events is the difference between a single poison record parking itself for later inspection and that same record silently halting…

A dead-letter queue for CDC events is the difference between a single poison record parking itself for later inspection and that same record silently halting an entire partition of change data. This page, part of event routing and Kafka integration, covers dead-letter design for a change-data pipeline: the Kafka Connect error-tolerance settings that redirect failures, the header enrichment that makes a dead-lettered record diagnosable, how to separate transient from permanent failures, and the redrive tooling and growth alerting that keep a schema break from quietly dropping rows.

Without a dead-letter path, a single unreadable record forces a false choice. Leave errors.tolerance at its default none and the connector task dies on the first bad record, freezing the replication slot it feeds so restart_lsn stops advancing and pg_wal grows until the primary refuses writes. Set errors.tolerance=all without a dead-letter topic and the connector does the opposite — it discards every failing record and logs a line nobody reads, so a serialization break after an ALTER TABLE deletes production changes with no trace. The correct posture is neither halt nor drop: capture the failure with enough context to replay it, alert on the capture rate, and distinguish a failure worth retrying from one that will fail identically forever.

Main topic to sink, with transient retry and permanent dead-letter and redrive Records flow from the main CDC topic into a converter and single-message-transform stage. On success they are written to the sink with an idempotent upsert. On failure the record enters a classifier that decides transient versus permanent. Transient failures — broker timeouts, sink deadlocks — return to the converter through a bounded retry with backoff and jitter. Permanent failures — deserialization errors, schema-incompatible records, constraint violations — are written to a dead-letter topic together with enriched headers carrying the original topic, partition, offset, exception class, and message. The dead-letter topic feeds a growth alert that pages when its record rate rises, and a redrive tool that, once the underlying defect is fixed, inspects the headers and replays the captured records back onto the main topic for reprocessing. Main topic orders.events.v1 Converter + SMT deserialize · transform errors.tolerance = all Sink idempotent upsert Classify failure transient vs permanent retriable exception? Retry backoff + jitter retries ≤ N Dead-letter topic deadletterqueue.topic.name + enriched headers Growth alert rate > 0 pages schema-break guard Redrive tool inspect headers replay after fix Enriched DLQ headers __connect.errors.topic __connect.errors.partition __connect.errors.offset __connect.errors.exception.class __connect.errors.exception.message __connect.errors.exception.stacktrace context.headers.enable = true → every replay knows where it came from and why it failed success exception transient reprocess permanent / exhausted replay after fix → main topic
Success flows to the sink; transient failures retry with backoff; permanent failures land in the dead-letter topic with enriched headers, which drives both a growth alert and a header-aware redrive that replays records once the defect is fixed.

Failure Classification Semantics

The core decision a CDC dead-letter design encodes is transient versus permanent, because retrying a permanent failure wastes the retry budget and dead-lettering a transient one manufactures false incidents. Transient failures resolve on their own given time; permanent ones fail identically on every replay until code, schema, or data changes. The raw change envelope that these records carry is produced by Python CDC parser development and normalized by JSON to Avro transformation; most permanent failures are a mismatch between that normalized contract and what a consumer expects.

Failure Class Typical cause Correct action
Broker timeout / NotLeaderForPartition Transient Leader election, network blip Retry with backoff; never dead-letter
Sink deadlock / serialization failure Transient Concurrent writers on the sink DB Retry the batch; jitter to de-correlate
Schema Registry unreachable Transient Registry restart, DNS Retry; a hard dead-letter here loses valid records
Deserialization / SerializationException Permanent Producer wrote a schema the consumer cannot read Dead-letter with headers; fix compatibility, then redrive
Schema-incompatible field Permanent Breaking ALTER TABLE not registered as BACKWARD_TRANSITIVE Dead-letter; register schema, redrive
Constraint / not-null violation at sink Permanent Source data violates a sink invariant Dead-letter; correct the mapping or data contract
Business-rule rejection Permanent Record fails a domain check Dead-letter to a quarantine topic for manual review

The dangerous middle is a permanent failure misclassified as transient: a deserialization error retried forever pins its partition, and consumer-group lag climbs on that partition alone while its siblings drain — the hot-partition signature covered in alerting on Kafka consumer lag for CDC. Bound retries with a finite count so exhaustion converts a mis-labeled transient into a dead-letter rather than an infinite loop.

Diagnostic Patterns

The dead-letter topic is only useful if its size and rate are alerted, because a schema break dead-letters records silently — the pipeline stays RUNNING while rows are diverted. Alert on the topic’s production rate, not just its total, so a fresh break pages within minutes.

yaml
# Prometheus: page when records are entering the DLQ at all, and hard-page on a burst.
groups:
  - name: cdc-dlq
    rules:
      - alert: CDCDeadLetterGrowing
        expr: sum(rate(kafka_topic_partition_current_offset{topic="orders.events.v1.dlq"}[5m])) > 0
        for: 5m
        labels: {severity: page}
        annotations:
          summary: "Records entering the dead-letter topic — likely a schema or contract break"
      - alert: CDCDeadLetterBurst
        expr: sum(rate(kafka_topic_partition_current_offset{topic="orders.events.v1.dlq"}[5m])) > 100
        for: 2m
        labels: {severity: critical}

Read the enriched headers to triage without reprocessing. The exception.class groups failures so you know whether one schema break or many distinct defects are in play:

bash
# Group DLQ records by failure class from the enriched headers.
kcat -b "$BROKER" -t orders.events.v1.dlq -C -e -f '%h\n' \
  | grep -o '__connect.errors.exception.class=[^,]*' \
  | sort | uniq -c | sort -rn

Correlate the dead-letter onset against the source to prove a schema break rather than bad data. A dead-letter burst that starts exactly when the slot emitted a RELATION change points at an unregistered ALTER TABLE:

sql
-- Publisher: confirm the slot is healthy so a DLQ burst is a contract break, not a stall.
SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_type = 'logical';

Safe Deployment Sequence

Introduce the dead-letter path before you need it, and prove redrive works before an incident makes it load-bearing.

1. Configure tolerance and the dead-letter topic together. Never set errors.tolerance=all without a dead-letter destination, or failures vanish. Enable header context so every captured record is diagnosable:

json
{
  "errors.tolerance": "all",
  "errors.deadletterqueue.topic.name": "orders.events.v1.dlq",
  "errors.deadletterqueue.topic.replication.factor": "3",
  "errors.deadletterqueue.context.headers.enable": "true",
  "errors.retry.timeout": "60000",
  "errors.retry.delay.max.ms": "5000",
  "errors.log.enable": "true",
  "errors.log.include.messages": "true"
}

2. Pre-create the dead-letter topic. With broker auto-creation disabled, create the topic explicitly with the same replication factor as the main topic so a broker loss does not also lose the failures.

3. Fault-inject a poison record. Produce a deliberately malformed record and confirm it lands in the dead-letter topic with populated __connect.errors.* headers, that the connector stays RUNNING, and that valid records keep flowing. A dead-letter path never exercised is a dead-letter path that fails when it matters.

4. Rehearse a redrive. Fix the injected fault, run the redrive tool against the captured record, and confirm it reprocesses cleanly and idempotently onto the sink. Record the replay throughput so a real backlog has a known drain time.

5. Wire the growth alert to page. Only after redrive is proven, promote CDCDeadLetterGrowing to page. A dead-letter topic without a growth alert is a silent drop with extra steps.

Pipeline Integration

Redrive is the operation that turns a dead-letter topic from a graveyard into a recovery buffer, and it must be idempotent because a replayed record may have partially applied before it failed. The redrive tool reads the dead-letter topic, uses the enriched headers to reconstruct provenance, and re-produces to the original topic only after the defect is fixed — never blindly, or it re-triggers the same failure.

python
from confluent_kafka import Consumer, Producer

def redrive(dlq_topic: str, brokers: str, dry_run: bool = True):
    consumer = Consumer({"bootstrap.servers": brokers, "group.id": "dlq-redrive",
                         "enable.auto.commit": False, "auto.offset.reset": "earliest"})
    producer = Producer({"bootstrap.servers": brokers, "enable.idempotence": True})
    consumer.subscribe([dlq_topic])
    while True:
        msg = consumer.poll(2.0)
        if msg is None:
            break
        headers = dict(msg.headers() or [])
        origin = headers.get("__connect.errors.topic", b"").decode()
        exc = headers.get("__connect.errors.exception.class", b"").decode()
        # Replay only classes the fix actually addresses; leave the rest parked.
        if exc not in FIXED_CLASSES:
            continue
        if not dry_run:
            producer.produce(origin, key=msg.key(), value=msg.value())  # LWW sink absorbs replays
        consumer.commit(msg)                                            # advance only on handled records
    producer.flush()

The sink’s last-write-wins upsert from event routing and Kafka integration is what makes replay safe: a redriven record older than current state is absorbed rather than applied, so re-running the dead-letter backlog can never regress the sink. Keep the retry-with-jitter loop bounded — an unbounded retry on a permanent failure never reaches the dead-letter topic, so the growth alert never fires and the break stays invisible. On failover, records already dead-lettered are safe on the broker, but the slot resets; re-establish subscription sync procedures before redriving, so replayed records are not racing a resnapshot of the same rows.

Authoritative references