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.

The re-drive loopEntries are selected under the attempt cap, claimed with a row lock, rebuilt into work objects, run through the original task, and then marked resolved, incremented on failure, or abandoned at the cap.select batchunresolved, attempts < 5claimSKIP LOCKEDrebuild work objectgeom + params + work_keyrun the ORIGINAL tasksame code path, same keysuccessset resolved_atfailureattempts += 1at the capabandon with a reason
The only edge that leaves the loop is the cap. Without it, a payload that can never succeed is re-driven nightly forever, and the queue depth never falls.

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.

Three drains, no coordinatorEach of three concurrent drain workers claims a distinct block of rows from the same queue. SKIP LOCKED makes the claims disjoint without any external coordination.geometry_dlqrows 1–200rows 201–400rows 401–600ordered by failed_atdrain worker Adrain worker Bdrain worker Cdisjoint claimsno coordinator needed
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;
Resolution rate is the signal a drain producesUpstream timeouts resolve at ninety-six per cent, CRS mismatches at sixty-one per cent after a fix, and invalid geometries at four per cent, indicating the cause was never addressed.share of claimed entries resolved by one drainupstream_timeout96%crs_mismatch61% — fix landed, partlyinvalid_geometry4% — nothing was actually fixedA low rate is not a reason to drain again; it is a reason to go back and read the entries.
Draining is a measurement as much as a repair. The rate tells you whether the fix you shipped addressed the failure you had.

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_at and keep the row until the retention job archives it.
  • Draining without a batch limit. SELECT … WHERE resolved_at IS NULL with no LIMIT will 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_at can 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.

Dead-Letter Queues for Failed Geotasks