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.

Three ways to handle a failing feature, and what each costs Three panels. Stopping the run on the first failure guarantees correctness but means a single malformed feature prevents a continental extract from ever completing. Logging and continuing lets the run finish but loses the evidence, since a log line cannot be replayed and the input is gone by the time anybody reads it. Quarantining lets the run finish and keeps the input, the error and the code version, so a fix can be replayed against exactly what broke. Three responses to a bad feature Stop the run Correct, and useless One bad feature blocks all Never completes at scale Nobody does this twice Log and continue Run completes Evidence is lost A log cannot be replayed Input gone when read Quarantine Run completes Input preserved Replayable after a fix Needs somebody to look The third option's only weakness is human: a quarantine nobody examines becomes a place data goes to disappear quietly.
The middle option is the common default and it is the one that cannot be recovered from.

Runnable solution Jump to heading

python
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

  1. Store the input as it arrived. A partially transformed feature may not reproduce the failure, and reconstructing the original later is usually impossible.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Promote what now passes. A record that succeeds on replay becomes resolved, so the open count reflects outstanding problems rather than historical ones.
  8. 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.
What each stored field is for, and what replay loses without it A grid of five stored fields against their purpose and what becomes impossible without them. The raw input allows the failure to be reproduced, without which a fix cannot be verified. The stage groups records into distinct problems, without which the queue is an undifferentiated wall. The error type groups them further and distinguishes a parse failure from a constraint violation. The code version separates records predating a fix from those after it. The status prevents resolved records from inflating the open count indefinitely. Five fields, five things replay depends on For Without it Raw input reproducing the failure no verification possible Stage grouping into problems an undifferentiated wall Error type distinguishing causes parse and constraint merge Code version separating before and after fixes look unverified Status closing resolved records open count never falls The first row is the one that makes this a quarantine rather than a log, and it is the field most often omitted for size.
Storing the input costs bytes and is the only reason the store is worth having at all.
The quarantine lifecycle, from failure to resolution Four stages. A feature fails inside a wrapped stage, and instead of stopping the run the wrapper writes a record holding the input, the error, the stage and the code version. A triage step groups the open records by stage and error type, turning a large count into a handful of distinct problems. A fix addresses one group. A replay re-runs the stored inputs for that group against the current code, promoting the records that now pass and leaving the rest open with a newer code version recorded. Fail, triage, fix, replay fail record, do not stop input preserved triage group by stage and error many become few fix address one group the smallest first replay promote what passes rest stay open Without the second step the queue is a number rather than a work list, which is why most quarantines are never worked through.
The loop only closes because replay uses the stored input; against fresh data it would prove nothing.

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.

Up one level: Error Handling in Large OSM Extracts.