Quarantining Bad OSM Features to a Dead-Letter Store Jump to heading
A pipeline that stops on the first bad feature never finishes, and one that logs and continues loses the evidence. A dead-letter store is the third option: the run completes, and everything that failed is still there in a form you can replay.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A quarantine record has to answer four questions, and dropping any one of them makes replay impossible.
What failed? The feature’s identity, and the raw input as it arrived — not the partially transformed version, because that is what you will need to reproduce the failure.
Why? The exception type and message, and the stage that raised it. A message alone is rarely enough to group failures, and the stage is what tells you where to look.
When, and with what code? The run identifier and the code version. After a fix, the records from before it are the ones worth replaying, and the ones from after it are a new problem.
Has it been resolved? A status, so a replayed record that now succeeds is promoted rather than sitting in quarantine forever looking like an outstanding fault.
The design rule that makes the store useful is that the stored input must be sufficient to reproduce the failure alone. A record referencing a feature by identifier, expecting the extract still to exist, is a note rather than a quarantine.
Runnable solution Jump to heading
from __future__ import annotations
import json
import logging
import sqlite3
import traceback
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.errors.quarantine")
SCHEMA = """
CREATE TABLE IF NOT EXISTS dead_letter (
id INTEGER PRIMARY KEY,
osm_type TEXT NOT NULL,
osm_id INTEGER NOT NULL,
stage TEXT NOT NULL,
error_type TEXT NOT NULL,
error_message TEXT NOT NULL,
traceback TEXT,
raw_input TEXT NOT NULL, -- sufficient to reproduce, alone
run_id TEXT NOT NULL,
code_version TEXT NOT NULL,
quarantined_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open | resolved | permanent
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_dl_open ON dead_letter (status, stage, error_type);
"""
@dataclass(frozen=True)
class Context:
run_id: str
code_version: str
class DeadLetterStore:
def __init__(self, path: Path, context: Context) -> None:
self.conn = sqlite3.connect(path)
self.conn.executescript(SCHEMA)
self.context = context
def quarantine(self, osm_type: str, osm_id: int, stage: str,
raw_input: dict, error: BaseException) -> None:
self.conn.execute("""
INSERT INTO dead_letter (osm_type, osm_id, stage, error_type,
error_message, traceback, raw_input, run_id, code_version,
quarantined_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (osm_type, osm_id, stage, type(error).__name__, str(error),
"".join(traceback.format_exception(error))[-4000:],
json.dumps(raw_input, default=str),
self.context.run_id, self.context.code_version,
datetime.now(timezone.utc).isoformat(timespec="seconds")))
self.conn.commit()
def summary(self) -> list[tuple]:
rows = self.conn.execute("""
SELECT stage, error_type, count(*) AS n
FROM dead_letter WHERE status = 'open'
GROUP BY stage, error_type ORDER BY n DESC
""").fetchall()
for stage, error_type, n in rows:
logger.info("%6d %-20s %s", n, stage, error_type)
return rows
def replay(self, process: Callable[[dict], None],
stage: str | None = None) -> tuple[int, int]:
"""Re-run quarantined records against the CURRENT code.
The stored raw input is replayed, not a fresh extract: the point is to
test the fix against exactly what broke, which a new extract may no
longer contain.
"""
query = "SELECT id, raw_input FROM dead_letter WHERE status = 'open'"
params: list = []
if stage:
query += " AND stage = ?"
params.append(stage)
fixed = still_failing = 0
for record_id, raw in self.conn.execute(query, params).fetchall():
try:
process(json.loads(raw))
except Exception:
still_failing += 1
continue
self.conn.execute(
"UPDATE dead_letter SET status = 'resolved', resolved_at = ? "
"WHERE id = ?",
(datetime.now(timezone.utc).isoformat(timespec="seconds"),
record_id))
fixed += 1
self.conn.commit()
logger.info("replay: %d resolved, %d still failing", fixed, still_failing)
return fixed, still_failing
def age_alert(self, days: int = 14) -> int:
"""An unexamined quarantine is a slow leak. Make it visible."""
stale, = self.conn.execute("""
SELECT count(*) FROM dead_letter WHERE status = 'open'
AND quarantined_at < datetime('now', ?)
""", (f"-{days} days",)).fetchone()
if stale:
logger.warning("%d record(s) have been quarantined for over %d days",
stale, days)
return stale
def guarded(store: DeadLetterStore, stage: str, process: Callable[[dict], None]
) -> Callable[[Iterable[dict]], int]:
"""Wrap a stage so one bad feature never stops the run."""
def run(features: Iterable[dict]) -> int:
processed = 0
for feature in features:
try:
process(feature)
processed += 1
except Exception as error:
store.quarantine(feature.get("type", "?"),
int(feature.get("id", 0)), stage,
feature, error)
return processed
return run
if __name__ == "__main__":
logger.info("store the raw input, replay after a fix, promote what passes")
Step-by-step walkthrough Jump to heading
- Store the input as it arrived. A partially transformed feature may not reproduce the failure, and reconstructing the original later is usually impossible.
- Record the stage as well as the exception. Grouping by stage and error type turns a thousand records into a handful of distinct problems, which is the difference between an actionable queue and a wall.
- Keep the code version. After a fix, records from earlier versions are candidates for replay and records from the current one are a new fault, and only the version distinguishes them.
- Truncate the traceback. A full traceback is useful and unbounded; keeping the last few kilobytes preserves the frames that matter without letting one record dominate the store.
- Commit per record. The store is small and the cost is negligible against the work that produced the record, and it means a crash mid-run does not lose the quarantine.
- Replay against stored input. Re-running a fresh extract tests the fix against data that may no longer contain the problem, which is how a fix gets declared successful without being verified.
- Promote what now passes. A record that succeeds on replay becomes resolved, so the open count reflects outstanding problems rather than historical ones.
- Alert on age. A quarantine nobody examines is a place data disappears into quietly, and an age alert is the cheapest possible defence against that.
Verification Jump to heading
- A failure is quarantined rather than fatal. Feed a deliberately broken feature and confirm the run completes with one record stored.
- The record replays. Replay without changing anything and confirm it still fails, proving the stored input reproduces the problem.
- A fix resolves it. Correct the code, replay, and confirm the record is promoted to resolved.
- Grouping is useful. The summary should collapse many records into few distinct stage and error pairs.
- Age alerting fires. Backdate a record and confirm the alert reports it.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Fix cannot be verified | Raw input not stored | Store the feature as it arrived, before transformation |
| Queue is an undifferentiated wall | Stage not recorded | Record the stage and group the summary by it |
| Resolved records inflate the count | No status field | Promote records that pass on replay |
| Replay passes but production fails | Replayed against a fresh extract | Replay the stored input, not new data |
| Store grows without bound | Full tracebacks kept | Truncate to the last few kilobytes |
| Quarantine loses records on a crash | Commit deferred to the end | Commit per record; the cost is negligible |
| Nobody notices a growing backlog | No age alerting | Warn on records older than a threshold |
Specification reference Jump to heading
A dead-letter queue holds messages a consumer could not process, preserving them for inspection and reprocessing rather than discarding them or blocking the consumer. Applied to a batch pipeline, the equivalent requirement is that the stored record contain enough of the original input to reproduce the failure independently of the source data. See Error Handling in Large OSM Extracts for the wider error strategy this implements.
Frequently Asked Questions Jump to heading
Why store the raw input rather than a reference to it?
Because a reference assumes the source still exists and still contains the problem, and neither is reliable. Extracts are replaced daily, and a feature that failed last week may have been edited since. Storing the input as it arrived makes the record self-contained, which is the entire difference between a quarantine you can replay and a log line describing something that is gone.
Should the pipeline stop when quarantine volume is high?
Yes, above a threshold. A handful of failures across millions of features is normal and worth reviewing at leisure; ten percent failing means something systemic changed and continuing produces an output whose gaps nobody has agreed to. Setting a proportional threshold turns that judgement into a rule, and it belongs in the same configuration as the quality gates.
Why replay the stored input rather than re-running the extract?
Because re-running tests the fix against current data, which may no longer contain the case that broke. A feature that has since been corrected upstream passes for reasons unrelated to your change, and the fix is declared successful without being verified. Replaying exactly what failed is the only way to know the code now handles it.
What should happen to a record that never resolves?
Mark it permanent and stop counting it as open. Some features are genuinely broken in ways your pipeline should not accommodate — a geometry that cannot be assembled, a value that means nothing — and leaving them open forever means the open count stops carrying information. Marking them explicitly preserves the record while keeping the queue honest about what is actually outstanding.
Related Jump to heading
- Error Handling in Large OSM Extracts — the parent topic and the wider strategy.
- Fixing Malformed OSM Tags During ETL Ingestion — the repairs that reduce quarantine volume.
- Resuming an Interrupted OSM Import — the neighbouring resilience concern.
- Recording OSM Data Provenance in a Pipeline — where the run identifier and code version come from.
- Setting Quality Thresholds That Fail a Build — turning quarantine volume into a gate.
Up one level: Error Handling in Large OSM Extracts.