Capture Failed Feature Transforms in a Dead-Letter Queue Jump to heading
A retry policy answers “what do we do when a transform fails transiently?” It says nothing about the feature that fails permanently — the polygon with a corrupt ring, the record whose parcel_id is null under a NOT NULL contract, the geometry that no CRS repair will fix. Left unhandled, that one feature either aborts a ten-million-row batch or gets swallowed and never accounted for. A dead-letter queue (DLQ) is the durable, structured landing zone for exactly those features: everything that exhausts its retries lands there with enough context to diagnose and replay, and nothing disappears silently. This procedure operates inside Error Handling & Retry Logic, which owns the retry policy and backoff contract; the DLQ is the terminal sink that catches whatever the retries could not.
The design goal is auditability under load. Every dead-lettered feature must be attributable (which batch, which rule, which error), replayable (re-run it after a fix without re-running the whole job), and bounded (a “poison message” that fails every replay must not loop forever). Those three properties drive the schema, the storage layout, and the replay guard below.
Prerequisites checklist Jump to heading
Step 1: Define the DLQ record schema Jump to heading
The record is the contract. It must be self-describing enough that someone six months later can diagnose a failure without the original code, and versioned so the schema can evolve without invalidating old entries. Store the original feature verbatim (as GeoJSON, preserving geometry) plus structured error context. Never store only an error string — you cannot replay a string.
# pyarrow >=14 — Python 3.10+
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
import uuid
DLQ_SCHEMA_VERSION = "1.0"
@dataclass(slots=True)
class DeadLetter:
dlq_id: str # unique per dead-letter event
feature_id: str # stable key of the source feature
batch_id: str # which run produced it
schema_version: str # DLQ_SCHEMA_VERSION at write time
failed_rule: str # e.g. "coerce:built_year->int32"
error_type: str # exception class name
error_message: str # str(exc), truncated
attempts: int # how many retries were spent
feature_geojson: str # verbatim source feature (geometry intact)
source_crs: str | None # e.g. "EPSG:26910" — needed to replay
first_seen: str # ISO-8601 UTC
replay_count: int = 0 # incremented each replay attempt
quarantined: bool = False # poison flag
@classmethod
def create(cls, **kw) -> "DeadLetter":
return cls(
dlq_id=str(uuid.uuid4()),
schema_version=DLQ_SCHEMA_VERSION,
first_seen=datetime.now(timezone.utc).isoformat(),
**kw,
)
Keeping source_crs on the record is not optional for geospatial DLQs: a feature replayed without its original CRS reprojects from the wrong datum and “succeeds” with silently wrong coordinates. The full geometry-preservation rules are covered in preserving geometry during attribute flattening.
Step 2: Write failed features to durable storage Jump to heading
Append dead letters to a Parquet dataset partitioned by batch_id and ingest date. Parquet gives you a typed, compressed, queryable store the DLQ can grow into without a database, and it aligns with the append-only lineage manifests format so the two share tooling. Write atomically — a partial file is worse than a missing one because it corrupts every scan of the partition.
# pyarrow >=14 — Python 3.10+
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
def append_dead_letters(records: list[DeadLetter], root: str) -> Path:
"""Atomically append a batch of dead letters to the partitioned DLQ."""
if not records:
raise ValueError("refusing to write an empty DLQ batch")
table = pa.Table.from_pylist([asdict(r) for r in records])
batch_id = records[0].batch_id
part_dir = Path(root) / f"batch_id={batch_id}"
part_dir.mkdir(parents=True, exist_ok=True)
final = part_dir / f"{records[0].dlq_id}.parquet"
tmp = final.with_suffix(".parquet.tmp")
pq.write_table(table, tmp, compression="zstd")
tmp.replace(final) # atomic rename on POSIX
return final
Do not write one file per feature at scale — that produces the small-files problem and destroys read performance. Buffer dead letters per batch (or per N records) and flush in groups, which is why append_dead_letters takes a list.
Step 3: Quarantine poison messages and replay survivors Jump to heading
Replay re-runs a dead letter through the same transform after an operator has (presumably) fixed the underlying rule or data. The danger is the poison message: a feature that fails every single time and, if replayed unconditionally, loops forever consuming the queue. Guard with a hard replay cap. When a record crosses it, set quarantined=True and stop replaying it automatically — it now needs a human.
# geopandas >=0.14 — Python 3.10+
import json
import logging
log = logging.getLogger("dlq.replay")
MAX_REPLAYS = 3
def replay(record: DeadLetter, transform) -> DeadLetter | None:
"""Re-run one dead letter. Return None on success, updated record on failure."""
if record.quarantined:
log.info("skip quarantined %s", record.dlq_id)
return record
if record.replay_count >= MAX_REPLAYS:
record.quarantined = True
log.warning("poison message quarantined: %s (%s)",
record.feature_id, record.failed_rule)
return record
record.replay_count += 1
feature = json.loads(record.feature_geojson)
try:
transform(feature, source_crs=record.source_crs)
log.info("replay ok: %s after %d attempt(s)",
record.feature_id, record.replay_count)
return None # cleared — drop from active DLQ
except Exception as exc: # noqa: BLE001
record.error_type = type(exc).__name__
record.error_message = str(exc)[:500]
return record # still failing — keep in DLQ
The replay function is intentionally idempotent per feature: because it keys on feature_id and returns None only on genuine success, running replay twice cannot double-process a feature or lose one that is still broken.
Step 4: Emit metrics and lineage Jump to heading
A DLQ you cannot see is a data-loss bug in waiting. Emit at least three signals: arrivals (features entering the DLQ), replay outcomes (cleared vs still-failing), and poison rate (fraction quarantined). A rising poison rate means a systemic problem — a broken rule or an upstream schema change — not scattered bad rows. Forward the same counters to the lineage manifest so failures are part of the audit trail, not a side channel.
# Python 3.10+
import json, logging
log = logging.getLogger("dlq.metrics")
def emit_dlq_metrics(batch_id: str, arrived: int, cleared: int,
quarantined: int) -> dict:
active = arrived - cleared - quarantined
poison_rate = round(quarantined / arrived, 4) if arrived else 0.0
metrics = {
"batch_id": batch_id,
"dlq_arrived": arrived,
"dlq_cleared": cleared,
"dlq_quarantined": quarantined,
"dlq_active": active,
"poison_rate": poison_rate,
}
log.info("%s", json.dumps(metrics))
if poison_rate > 0.05: # 5% quarantine = systemic alarm
log.error("poison rate %.1f%% exceeds threshold for %s",
poison_rate * 100, batch_id)
return metrics
Verification Jump to heading
Prove the DLQ round-trips: a failing feature lands durably, replays cleanly after a fix, and a genuine poison message quarantines instead of looping.
# 1. A failed feature is captured with replayable context.
dl = DeadLetter.create(feature_id="parcel-42", batch_id="run-2026-07-13",
failed_rule="coerce:built_year->int32",
error_type="ValueError", error_message="not an int",
attempts=3, feature_geojson=feature_json,
source_crs="EPSG:26910")
path = append_dead_letters([dl], root="/data/dlq")
assert path.exists() and path.stat().st_size > 0, "DLQ write did not persist"
# 2. Replay clears a fixed feature (transform now returns cleanly).
assert replay(dl, good_transform) is None, "fixed feature failed to clear"
# 3. A poison message quarantines at the cap, never loops.
poison = DeadLetter.create(feature_id="bad-ring", batch_id="run-2026-07-13",
failed_rule="repair:geometry", error_type="TopologyError",
error_message="self-intersection", attempts=3,
feature_geojson=bad_json, source_crs="EPSG:4326")
for _ in range(MAX_REPLAYS + 2):
poison = replay(poison, always_fails)
assert poison.quarantined, "poison message never quarantined — infinite loop risk"
A healthy emit_dlq_metrics line shows dlq_active trending to zero as replays clear the backlog, with a poison_rate below your alarm threshold.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Replay loops forever on the same feature | No replay cap, or replay_count not persisted between runs |
Enforce MAX_REPLAYS; write the updated record back so the counter survives. |
| Replayed feature succeeds but coordinates are wrong | source_crs dropped from the record; replay reprojected from the wrong datum |
Always persist source_crs; reproject from it, never from a default. |
| DLQ reads are slow / scans fail | One tiny Parquet file per feature (small-files problem) or a partial .tmp left behind |
Batch writes per run; write to .tmp then atomic replace. |
| Poison rate spikes across a whole batch | Upstream schema change broke a rule, not scattered bad data | Alert on poison_rate; fix the rule, then bulk-replay the batch partition. |
| Features silently missing downstream | Failures swallowed instead of dead-lettered | Route every exhausted-retry feature to append_dead_letters; assert counts reconcile. |
Related Jump to heading
- Error Handling & Retry Logic — the parent policy that decides when a feature has exhausted its retries
- Implementing Exponential Backoff in Schema Mapping Jobs — the backoff that runs before a feature is dead-lettered
- Preserving Geometry During Attribute Flattening — keeping the stored feature replayable without corrupting geometry
- Dask vs multiprocessing for Parallel Schema Transforms — how each engine surfaces the failing partitions that feed the DLQ
- Lineage Manifest Generation — turning DLQ arrivals and replays into regulator-ready audit evidence