Designing idempotent DAG tasks for parcel ingestion
Somebody re-ran last night’s ingest for Weld County because the first attempt died halfway through, and now the parcel count is 82 431 against an expected 41 216 — every parcel is in the table twice. Or the count is right and the change table has two identical rezone events, so two identical alerts went out. Or the re-run deleted parcels the first attempt had already written and the layer is now missing a subdivision. All three are the same defect: a task that is safe to run once and unsafe to run again. Since re-running is the normal recovery for every failure in a municipal pipeline, that defect turns every ordinary incident into a data-repair job. This guide makes the tasks re-runnable, which is the precondition the parent topic — scheduling & orchestration for municipal feed runs — assumes throughout.
Diagnosis: finding the parts that are not re-runnable jump to heading
Idempotency is a property of each write, so audit the writes rather than the pipeline. Four questions locate almost every violation.
Does anything use an auto-generated key as its identity? A SERIAL primary key means the second run cannot recognise the row the first run wrote, so it inserts a duplicate. The identity has to come from the data — (source_id, parcel_id, effective_date) — with the surrogate key kept for joins only.
Does anything append unconditionally? INSERT INTO parcel_history ... with no conflict clause is a duplicate factory. So is appending to a change log keyed on nothing but a timestamp.
Does anything delete by absence? “Delete every parcel not in this payload” is correct on a complete payload and catastrophic on a truncated one — and a re-run after a partial fetch is exactly when the payload is truncated.
Does anything have an external side effect? Sending an email, posting to a webhook, or writing to a queue cannot be undone by a rollback. A task that writes rows and notifies is not idempotent even if the rows are, because the second run re-notifies.
A quick way to find them all: run the pipeline twice against the same fixture and diff the database. Anything that differs between one run and two is a violation, and the diff names it precisely.
Step-by-step implementation jump to heading
1. Give every row a natural key jump to heading
-- The identity comes from the data. The surrogate id exists for joins, not identity.
CREATE TABLE parcel_version (
id bigserial PRIMARY KEY,
source_id text NOT NULL,
parcel_id text NOT NULL,
valid_from date NOT NULL,
zoning_code text NOT NULL,
overlay_codes text[] NOT NULL DEFAULT '{}',
geom geometry(MultiPolygon, 26913) NOT NULL,
content_digest text NOT NULL,
run_id uuid NOT NULL,
CONSTRAINT parcel_version_identity
UNIQUE (source_id, parcel_id, valid_from)
);
2. Upsert, and make the upsert a no-op when nothing changed jump to heading
UPSERT = """
INSERT INTO parcel_version (source_id, parcel_id, valid_from, zoning_code,
overlay_codes, geom, content_digest, run_id)
VALUES (%(source_id)s, %(parcel_id)s, %(valid_from)s, %(zoning_code)s,
%(overlay_codes)s, ST_GeomFromWKB(%(geom)s, 26913), %(digest)s, %(run_id)s)
ON CONFLICT (source_id, parcel_id, valid_from) DO UPDATE
SET zoning_code = EXCLUDED.zoning_code,
overlay_codes = EXCLUDED.overlay_codes,
geom = EXCLUDED.geom,
content_digest= EXCLUDED.content_digest,
run_id = EXCLUDED.run_id
WHERE parcel_version.content_digest <> EXCLUDED.content_digest
RETURNING (xmax = 0) AS inserted;
"""
def upsert_parcels(cur, rows, run_id):
"""Returns (inserted, updated, unchanged). The WHERE clause on DO UPDATE is what
makes a re-run cheap AND keeps run_id honest: a row whose content is identical is
not touched, so it still names the run that actually produced its value."""
inserted = updated = unchanged = 0
for row in rows:
cur.execute(UPSERT, {**row, "run_id": run_id})
res = cur.fetchone()
if res is None:
unchanged += 1 # DO UPDATE ... WHERE matched nothing
elif res["inserted"]:
inserted += 1
else:
updated += 1
return inserted, updated, unchanged
The WHERE parcel_version.content_digest <> EXCLUDED.content_digest clause is the detail that makes this genuinely idempotent rather than merely non-duplicating. Without it, a re-run rewrites every row with a new run_id, so the provenance now claims the re-run produced values it only re-confirmed — and every row looks freshly changed to anything watching run_id.
3. Stage per run, then swap jump to heading
For work that cannot be expressed as a single upsert — a full-snapshot load, a rebuilt derived table — write into a run-scoped staging table and swap atomically. A re-run rebuilds its own staging table from scratch, so a partial first attempt leaves nothing behind.
def load_snapshot(conn, source_id, payload, run_id):
staging = f"stg_parcel_{source_id}_{run_id.hex[:8]}"
with conn.transaction():
conn.execute(f"CREATE UNLOGGED TABLE {staging} (LIKE parcel_current INCLUDING ALL)")
copy_into(conn, staging, payload) # bulk COPY
assert_row_count_plausible(conn, staging, source_id) # before the swap, not after
conn.execute("DELETE FROM parcel_current WHERE source_id = %s", (source_id,))
conn.execute(f"INSERT INTO parcel_current SELECT * FROM {staging}")
conn.execute(f"DROP TABLE {staging}")
The DELETE is safe here only because it is inside the same transaction as the INSERT and behind a plausibility assertion. That assertion is doing the work that makes delete-by-absence acceptable at all.
4. Tombstone instead of deleting jump to heading
def apply_absences(cur, source_id, present_ids, run_id, payload_is_complete):
"""Mark parcels absent from a COMPLETE payload as retired. Never runs on a
partial payload, because absence then means 'not fetched yet', not 'gone'."""
if not payload_is_complete:
return 0
cur.execute("""
UPDATE parcel_current
SET retired_at = now(), retired_by_run = %(run_id)s
WHERE source_id = %(source_id)s
AND parcel_id <> ALL(%(present)s)
AND retired_at IS NULL
""", {"source_id": source_id, "present": list(present_ids), "run_id": run_id})
return cur.rowcount
A tombstone is re-runnable — the second run matches retired_at IS NULL and updates nothing — while a DELETE is destructive and, on a truncated payload, unrecoverable without a re-fetch.
5. Move side effects behind an outbox jump to heading
def record_changes(cur, changes, run_id):
"""Changes go to an outbox keyed on the change identity, not on the run. A
re-run inserts the same keys and conflicts, so nothing is notified twice."""
for ch in changes:
cur.execute("""
INSERT INTO notification_outbox
(idempotency_key, parcel_id, change_class, payload, run_id)
VALUES (%(key)s, %(parcel_id)s, %(cls)s, %(payload)s, %(run_id)s)
ON CONFLICT (idempotency_key) DO NOTHING
""", {"key": f"{ch.parcel_id}:{ch.change_digest}", "parcel_id": ch.parcel_id,
"cls": ch.change_class, "payload": ch.as_json(), "run_id": run_id})
The key is (parcel_id, change_digest) and deliberately not the run: the same change discovered by two runs is one notification. A separate delivery worker drains the outbox and marks rows sent, so the ingestion task never performs the un-undoable act itself. This is the same idempotency-key design that zoning change alerting relies on.
Verification & testing jump to heading
The test is mechanical and belongs in every pipeline: run the task twice, assert the database is identical.
def test_task_is_idempotent(db, fixture_payload):
run_ingest(db, fixture_payload, run_id=uuid4())
first = snapshot_all_tables(db)
run_ingest(db, fixture_payload, run_id=uuid4()) # a DIFFERENT run id
second = snapshot_all_tables(db)
# run_id columns are allowed to differ only where content actually changed —
# which for identical input is nowhere.
assert first == second
def test_partial_first_attempt_leaves_no_residue(db, fixture_payload):
with pytest.raises(InjectedFailure):
run_ingest(db, fixture_payload, fail_after_rows=500)
assert db.count("parcel_current") == 0 # staging was never swapped
assert db.tables_matching("stg_parcel_%") == [] # and cleaned up
def test_outbox_does_not_duplicate_notifications(db, fixture_payload):
run_ingest(db, fixture_payload, run_id=uuid4())
n = db.count("notification_outbox")
run_ingest(db, fixture_payload, run_id=uuid4())
assert db.count("notification_outbox") == n
The second assertion in the first test is the one people leave out and then regret: comparing full table snapshots rather than row counts catches the case where a re-run rewrote every run_id, which count-based tests pass happily.
Failure recovery jump to heading
Duplicates already in the table. De-duplicate on the natural key, keeping the row with the earliest run_id (the one that actually first produced the value), then add the unique constraint so it cannot recur. Adding the constraint first will fail while duplicates exist, which is a useful way to find out how many there are.
Rows deleted by a truncated payload. Restore from the last good snapshot, then re-run with the completeness assertion in place. If no snapshot exists, re-fetch — this is the scenario that makes the source archive worth keeping.
Duplicate notifications already sent. Send one correction naming the duplicates rather than several apologies, and add the outbox key before anything else. Subscribers tolerate one correction; they stop reading after the third duplicate.
Frequently asked questions jump to heading
Is ON CONFLICT DO UPDATE enough on its own?
It stops duplicates but does not make the task fully idempotent. Without the WHERE content_digest <> EXCLUDED.content_digest guard, a re-run touches every row and stamps it with the new run_id, so provenance claims that run produced values it merely re-confirmed, and anything watching run_id sees the whole county as freshly changed. The guard makes an unchanged row a genuine no-op.
Why tombstone instead of deleting?
Because absence from a payload is ambiguous: it means “retired” on a complete payload and “not fetched yet” on a truncated one, and a re-run after a failure is precisely when the payload is truncated. A tombstone is re-runnable and reversible; a delete is neither. Tombstones also keep the parcel’s history reachable, which a delete destroys along with the row.
Should the run id be part of the natural key?
No — that is the most common way to accidentally break idempotency. Including the run id makes every run’s rows distinct by construction, so nothing ever conflicts and every re-run duplicates the data. The run id belongs in a column that records which run last changed the value, not in the identity of the value itself.
What about tasks that call an external API?
Move the call out of the ingestion task and behind an outbox, so the task only writes rows and a separate worker performs the un-undoable act. Where the external system supports an idempotency key, pass the same key the outbox uses; where it does not, the outbox’s sent_at column is what stops a second delivery, and it must be written in the same transaction as the send is acknowledged.
Related jump to heading
- Parent topic: Scheduling & Orchestration for Municipal Feed Runs
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Zoning Change Alerting — the same idempotency key, one layer downstream
- Municipal Data Structures — why the natural key includes an effective date
- Testing Spatial Data Pipelines — where the run-it-twice assertion belongs