Reprocessing Dead-Letter Geotasks Safely
A re-drive flow reads a bounded batch of unresolved dead letters, reconstructs each one’s original task input from the stored payload, runs it through the same task the pipeline uses, and records the outcome on the queue row rather than deleting it. Three properties make it safe: it claims rows before working so two re-drives cannot collide, it re-enters the pipeline at the original idempotency key so a partially-completed unit is not duplicated, and it caps attempts so a permanently broken payload stops consuming capacity. Everything else is detail.
When to Use This Pattern
- The queue has entries and someone has fixed the cause — a source republished, a geometry repair rule added, an upstream service restored.
- Failures are known to be transient in aggregate, so a scheduled nightly drain recovers them without anyone looking.
- You need an audit trail. A re-drive that updates rows in place leaves a record of what was retried, when, and whether it worked; a script that deletes on success leaves nothing.
- The batch is large enough that manual replay is impractical — a hundred entries is a flow, five is a console command.
Do not re-drive blindly after a deploy “just in case”. A drain that runs before the cause is understood converts a diagnosable pile of failures into the same pile with the attempt counters incremented, and it consumes real capacity doing it.
Complete Working Example
The flow below claims a batch with FOR UPDATE SKIP LOCKED — the standard PostgreSQL way to hand disjoint work to concurrent consumers — reconstructs each entry, and calls the same task function the pipeline uses.
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Optional, Sequence
import psycopg
import shapely
from prefect import flow, task, get_run_logger
ATTEMPT_CAP = 5
CLAIM_BATCH = """
UPDATE geometry_dlq
SET attempts = attempts + 1, redriven_at = now()
WHERE id IN (
SELECT id FROM geometry_dlq
WHERE resolved_at IS NULL
AND attempts < %(cap)s
AND (%(failure_class)s IS NULL OR failure_class = %(failure_class)s)
ORDER BY failed_at
LIMIT %(batch)s
FOR UPDATE SKIP LOCKED) -- two drains never take the same row
RETURNING id, work_key, source_uri, feature_id, geom_wkb,
declared_crs, params, attempts
"""
@dataclass(frozen=True)
class RedriveItem:
row_id: int
work_key: str
source_uri: str
feature_id: Optional[str]
geometry: Any
declared_crs: str
params: dict
attempts: int
def claim_batch(
conn: psycopg.Connection, batch: int = 200, failure_class: Optional[str] = None
) -> list[RedriveItem]:
with conn.cursor() as cur:
cur.execute(CLAIM_BATCH, {"cap": ATTEMPT_CAP, "batch": batch,
"failure_class": failure_class})
return [
RedriveItem(
row_id=r[0], work_key=r[1], source_uri=r[2], feature_id=r[3],
# from_wkb, not from_wkt: the stored bytes are the exact geometry
# that failed, down to the last coordinate digit.
geometry=shapely.from_wkb(r[4]) if r[4] else None,
declared_crs=r[5], params=r[6], attempts=r[7],
)
for r in cur.fetchall()
]
@task
def redrive_one(item: RedriveItem, conn: psycopg.Connection) -> bool:
"""Run the ORIGINAL task for one entry. Returns True when it finally succeeds."""
logger = get_run_logger()
try:
# The same callable the pipeline uses — never a re-implementation. A second
# code path is a second set of bugs, and it will drift.
load_feature(
geometry=item.geometry,
declared_crs=item.declared_crs,
work_key=item.work_key, # same identity: the ledger de-duplicates
**item.params,
)
except Exception as exc:
logger.warning("redrive failed for %s (attempt %d): %s",
item.work_key, item.attempts, exc)
with conn.cursor() as cur:
if item.attempts >= ATTEMPT_CAP:
cur.execute(
"UPDATE geometry_dlq SET resolved_at = now(), "
"error_text = %s WHERE id = %s",
(f"abandoned at attempt cap: {exc}"[:2000], item.row_id),
)
return False
with conn.cursor() as cur:
cur.execute("UPDATE geometry_dlq SET resolved_at = now() WHERE id = %s",
(item.row_id,))
return True
@flow(name="drain-geometry-dlq")
def drain(batch: int = 200, failure_class: Optional[str] = None) -> dict[str, int]:
with psycopg.connect(DSN) as conn:
items = claim_batch(conn, batch=batch, failure_class=failure_class)
results = [redrive_one(item, conn) for item in items]
conn.commit()
return {"claimed": len(items), "resolved": sum(results),
"still_failing": len(items) - sum(results)}
The attempts increment happens in the claim, not after the run. That ordering matters: a worker killed mid-re-drive has already consumed an attempt, so a payload that reliably crashes the worker cannot loop forever. It costs the occasional wasted attempt on a genuinely transient infrastructure failure, which is the right trade.
FOR UPDATE SKIP LOCKED is what makes the drain horizontally scalable — three workers, no lock contention, no row processed twice.Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
batch |
int |
200 |
Small enough that one drain cannot saturate the pipeline. A raster re-drive with minute-long tasks wants 20, not 200. |
failure_class |
str |
None |
Drain one class at a time after fixing its cause. Draining everything conflates “we fixed the CRS bug” with “we hope the timeouts went away”. |
ATTEMPT_CAP |
int |
5 |
The point past which an entry is abandoned rather than retried. Abandonment is a decision the queue records, not a deletion. |
| claim lock | SKIP LOCKED |
— | Lets several drains run concurrently over disjoint rows without a coordinating service. |
| ordering | failed_at |
— | Oldest first, so a long-standing entry is not starved by a flood of new ones. |
| task callable | the original | — | Never a re-implementation. A parallel “replay” function drifts from the real task within a release or two. |
Verification & Testing
The properties worth asserting are that a re-drive does not duplicate, and that the cap actually stops.
def test_redrive_does_not_duplicate(conn, ledger, failing_feature) -> None:
# First pass fails and dead-letters.
with pytest.raises(GEOSException):
load_feature(**failing_feature, work_key="wk-1")
assert dlq_depth(conn) == 1
# Repair the cause, then drain. The ledger's work_key stops a double write.
install_geometry_repair_rule()
outcome = drain(batch=10)
assert outcome == {"claimed": 1, "resolved": 1, "still_failing": 0}
assert feature_count(conn, "wk-1") == 1 # not 2
# A second drain finds nothing: the row is resolved, not merely quiet.
assert drain(batch=10)["claimed"] == 0
def test_cap_abandons_rather_than_looping(conn, poison_feature) -> None:
for _ in range(ATTEMPT_CAP + 2):
drain(batch=10)
with conn.cursor() as cur:
cur.execute("SELECT attempts, resolved_at, error_text FROM geometry_dlq")
attempts, resolved_at, error_text = cur.fetchone()
assert attempts == ATTEMPT_CAP
assert resolved_at is not None
assert "abandoned" in error_text
After a real drain, the number to look at is the resolution rate by class. A class that resolves at 95% was genuinely transient; one that resolves at 4% was not fixed, and re-driving it again tomorrow will produce the same 4%:
SELECT failure_class,
count(*) FILTER (WHERE resolved_at IS NOT NULL) AS resolved,
count(*) AS total,
round(100.0 * count(*) FILTER (WHERE resolved_at IS NOT NULL) / count(*), 1)
AS pct_resolved
FROM geometry_dlq
WHERE redriven_at > now() - interval '1 day'
GROUP BY failure_class
ORDER BY 4;
Common Pitfalls
- Re-implementing the task in the drain flow. The replay path must call the same function the pipeline calls. A separate implementation drifts — a parameter default changes, a validation step is added — and then the drain “succeeds” while writing something subtly different from what the pipeline would have written.
- Dropping the original work key. Re-driving under a fresh key defeats idempotency entirely: a unit that half-completed before failing will be done again from scratch, and any partial output it left behind is now orphaned rather than overwritten.
- Deleting rows on success. The resolution history is what tells you a fix worked. Set
resolved_atand keep the row until the retention job archives it. - Draining without a batch limit.
SELECT … WHERE resolved_at IS NULLwith noLIMITwill happily claim 40 000 rows and run them all, saturating the pipeline that the live traffic needs. Bound the batch and let the schedule do the rest. - Ignoring the extent. A drain that re-runs 5 000 tiles scattered across a continent will re-read many more source scenes than the same 5 000 tiles clustered in one region. Ordering the batch by tile index rather than by
failed_atcan cut the source reads dramatically, at the cost of starving old entries — pick deliberately.
Frequently Asked Questions
Should the drain run on a schedule or on demand?
Both, for different classes. Transient classes — timeouts, throttling — deserve an hourly scheduled drain, because they usually resolve themselves and nobody needs to be involved. Data classes — invalid geometry, CRS mismatch — should be on demand, after a human has established what changed, since an unattended drain of those just increments counters.
How do I re-drive only one municipality or one tile range?
Add the spatial predicate to the claim query. The queue is a PostGIS table, so AND ST_Intersects(geom, ST_MakeEnvelope(…)) or AND tile_z = 12 AND tile_x BETWEEN … narrows the batch precisely. This is the most common ad-hoc use of the queue and it is worth having as a parameter on the flow.
What if the re-drive itself needs to dead-letter?
Let it. The attempts increment plus the cap already encodes that; a failed re-drive is not a special case, it is the same entry with a higher counter. What you should avoid is a second queue for failed re-drives, which splits the truth in two.
Does draining interfere with the live pipeline?
It can, which is why the batch limit and a separate concurrency tag matter. Give the drain flow its own work pool or its own concurrency limit so a large re-drive cannot starve the scheduled runs — the same reasoning behind limiting DAG fan-out with concurrency groups.
Related
- Dead-letter queues for failed geotasks — the pattern and its schema
- Storing failed geometries in a PostGIS dead-letter queue — the write path this drains
- Alerting on dead-letter queue growth — knowing when a drain is overdue
- Idempotency keys in spatial ETL — why re-entering at the same key is safe