Making a Spatial Pipeline Idempotent and Restartable
This guide, part of Orchestrating Spatial Surveillance Pipelines, takes the property the parent guide asks for and examines the places it quietly fails in real spatial pipelines.
Problem Context & Constraints
Idempotency means running a stage twice leaves the system in the same state as running it once. It is the property that makes restart mechanical, makes an accidental double-trigger harmless, and makes a backfill predictable. Most stages are idempotent by accident when they are written as a read-transform-write of a whole output file, and most lose it as soon as someone optimises.
Five operations break it, all of them common in spatial work.
Appending to a shared output. Writing new rows into an existing table rather than replacing a partition means a second run doubles them. This is the most frequent cause and the easiest to introduce while making a stage “incremental”.
Unseeded randomness. Geomasking, differential privacy noise, bootstrap resampling and any sampling step produce different output on a second run unless the seed is fixed and derived from the run context rather than from the clock.
External service calls with side effects. A geocoder that charges per call, a routing service with a quota, a notification that emails a partner — none of these are undone by a restart, and a naive re-run repeats them.
Order-dependent results. Spatial joins that break ties by input order, nearest-neighbour queries with equidistant candidates, and dissolve operations that depend on row order all produce different output from the same data in a different order.
Accumulating auxiliary state. A geocode cache that is written during the stage, a spatial index persisted between runs, a deduplication table — each is a store whose contents differ on the second run, which can change the result even when the main inputs did not.
Prerequisites
python3.11pandas2.2.2,geopandas0.14.4,numpy1.26.4- The run context and atomic write helpers from the parent guide
- A storage layout that supports replacing a partition atomically
- A ledger table for external calls, keyed by an idempotency key
Step-by-Step Solution
Apply the five repairs. Each is a small change to how a stage is written, and together they make restart a mechanical operation.
# Idempotency helpers: partition replace, derived seeds, call ledger, stable ordering.
# Pinned: pandas==2.2.2, geopandas==0.14.4, numpy==1.26.4
import hashlib
import logging
import os
import shutil
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("pipeline.idempotent")
def replace_partition(df: pd.DataFrame, partition_dir: str) -> None:
"""Write a partition by building it aside and swapping — never by appending."""
staging = partition_dir + ".staging"
if os.path.exists(staging):
shutil.rmtree(staging)
os.makedirs(staging, exist_ok=True)
df.to_parquet(os.path.join(staging, "part-0.parquet"), index=False)
if os.path.exists(partition_dir):
shutil.rmtree(partition_dir) # previous contents are fully discarded
os.replace(staging, partition_dir)
log.info("partition replaced: %s rows=%d", partition_dir, len(df))
def derived_seed(run_id: str, stage: str, extra: str = "") -> int:
"""Deterministic seed from the run context, so a re-run reproduces the draw.
Never seed from the clock, and never share one seed across stages: two stages
drawing from the same seeded generator become order-dependent on each other.
"""
h = hashlib.sha256(f"{run_id}|{stage}|{extra}".encode()).digest()
seed = int.from_bytes(h[:8], "big") % (2 ** 32 - 1)
log.info("seed for stage=%s run=%s -> %d", stage, run_id, seed)
return seed
def call_once(keys: pd.Series, ledger_path: str, call_fn) -> pd.DataFrame:
"""Call an external service only for keys not already in the ledger.
The ledger is the record of side effects. A restart consults it and skips
anything already done, which is what makes a paid or rate-limited call safe
to re-run.
"""
if os.path.exists(ledger_path):
ledger = pd.read_parquet(ledger_path)
else:
ledger = pd.DataFrame({"key": pd.Series(dtype="string")})
todo = pd.Index(keys.dropna().unique()).difference(pd.Index(ledger["key"]))
log.info("external calls: %d keys, %d already in ledger, %d to call",
len(keys.unique()), len(ledger), len(todo))
if len(todo) == 0:
return ledger
fresh = call_fn(list(todo))
out = pd.concat([ledger, fresh], ignore_index=True).drop_duplicates("key")
tmp = ledger_path + ".tmp"
out.to_parquet(tmp, index=False)
os.replace(tmp, ledger_path)
return out
def stable_sjoin_nearest(left: gpd.GeoDataFrame, right: gpd.GeoDataFrame,
left_key: str, right_key: str) -> gpd.GeoDataFrame:
"""Nearest join with deterministic tie-breaking.
Equidistant candidates otherwise resolve by whatever order the spatial index
yielded, which can differ between library versions and between runs.
"""
left = left.sort_values(left_key).reset_index(drop=True)
right = right.sort_values(right_key).reset_index(drop=True)
joined = gpd.sjoin_nearest(left, right, how="left", distance_col="_d")
joined = (joined.sort_values([left_key, "_d", right_key])
.groupby(left_key, as_index=False).first())
return joined
def assert_idempotent(stage_fn, ctx, out_path: str) -> None:
"""Run a stage twice into separate paths and assert the outputs match."""
a = stage_fn(ctx)
first = pd.read_parquet(a)
os.remove(a)
b = stage_fn(ctx)
second = pd.read_parquet(b)
if not first.equals(second):
raise AssertionError(f"stage output differs between runs: {out_path}")
log.info("idempotency asserted for %s (%d rows)", out_path, len(first))
The assert_idempotent helper is worth running in continuous integration on every stage. Idempotency is a property people believe they have and frequently do not, and the only reliable way to know is to execute the stage twice and compare. It is a cheap test that catches the appended row, the unseeded draw and the unstable tie in one go.
Deriving the seed from the run identifier and the stage name, rather than from a single pipeline-level seed, keeps stages independent. With one shared generator, adding a stage that consumes random numbers changes the draws every later stage receives, and the pipeline’s output shifts for a reason that has nothing to do with the data.
Validation & Edge Cases
Incremental stages that must append. Some outputs genuinely accumulate, such as a geocode cache. Make the accumulation itself idempotent by writing with a deduplicating key and treating the cache as an input whose version is recorded, so a re-run with the same inputs produces the same cache state.
Floating-point non-determinism. Parallel reductions can sum in different orders and produce results differing in the last bits. For most surveillance output this is irrelevant; for an equality assertion it fails the test. Compare with a tolerance, or force a deterministic reduction order where exact equality matters.
Library-version-dependent geometry. A projection library upgrade can shift coordinates slightly, so a stage that was idempotent across runs is not idempotent across an upgrade. Pin versions and treat an upgrade as a deliberate change with a regression comparison.
Partial external calls. A batch geocode that fails halfway has charged for the completed portion. Writing the ledger incrementally, rather than once at the end, means a restart resumes rather than repeats.
Restart after a partial partition replace. The staging-then-swap pattern leaves a .staging directory if the process dies mid-write. The next run must remove it rather than reusing it, or a half-built partition becomes the output.
INFO pipeline.idempotent seed for stage=geomask run=20260918-4f2a1c8e -> 2381044917
INFO pipeline.idempotent external calls: 1102 keys, 1006 already in ledger, 96 to call
INFO pipeline.idempotent partition replaced: /data/runs/as_of=2026-09-17/geocoded rows=1184
INFO pipeline.idempotent idempotency asserted for geocoded (1184 rows)
The staging-then-swap pattern is what makes the partition replace safe to interrupt, and its states are worth laying out:
Compliance Notes
Record the derived seed for every stochastic stage alongside the run identifier, because a disclosure control step’s reproducibility depends on it — a reviewer regenerating a geomasked release needs the exact seed the run used, and a seed derived from the run identifier and stage name is reconstructable from the provenance row alone.
The external-call ledger deserves attention as a disclosure surface: a geocode ledger keyed by normalised address is a table of addresses, which belongs inside the protected environment under the case file’s controls and retention schedule. Where a ledger must be shared between environments — a cache reused across projects, for instance — it should be keyed by a salted hash of the address rather than the address itself, with the salt held with the same care as any other key.
Related Topics
- Orchestrating Spatial Surveillance Pipelines — the parent guide and the run context these helpers consume.
- Backfilling Historical Case Geographies Safely — where idempotency is the difference between a safe backfill and a corrupted archive.
- Property-Based Tests for Spatial Transforms — the testing style that catches order-dependence before deployment.