Building Idempotent Spatial DAGs with Prefect

The failure this page prevents is concrete: a Prefect flow that reprojects and tiles a parcel layer is retried after an object-store timeout, and because its tasks are keyed on nothing more stable than run time, the retry re-runs the spatial join and writes a second, divergent tile set under the same product version. Building idempotent spatial DAGs with Prefect fixes this with cache_key_fn and result persistence keyed on SHA-256(bbox + crs + layer), plus bounded retries with retry_delay_seconds, so a re-run is a cache hit rather than a duplicate write. It is the Prefect-specific implementation of the patterns in Orchestrating Spatial Pipelines in Python, under Spatial Pipeline Orchestration & Observability; the Airflow equivalent is Automating CRS Reprojection in Airflow.

Prerequisites

Requirement Version / Assumption Notes
Prefect >= 2.14 cache_key_fn, persist_result, task retries
Python >= 3.10 Type hints and hashlib used throughout
PostGIS >= 3.3 on PostgreSQL >= 14 Source geometries and spatial-join input
Result store S3-compatible bucket or local block PREFECT_LOCAL_STORAGE_PATH or an S3 block
CRS assumption Source in any CRS; canonical output EPSG:4326 Web-mercator EPSG:3857 only for tiling
Env vars DOMAIN_PGDSN, RESULT_BUCKET, PREFECT_API_URL Domain-scoped DSN; no cross-domain credential
Role cadastral-pipeline (scoped) Rotated credential; zero-trust to other stores

Figure — A decision path: compute the cache key, and on a hit return the persisted artifact, otherwise compute, persist, and return.

Prefect idempotent task cache decision path A task run computes an idempotency key as the SHA-256 of the bounding box, CRS, and layer. The cache store is checked for that key. On a hit, the persisted artifact is returned with no recomputation. On a miss, the task computes the reprojection or tiling, persists the result under the key, and returns it. A failure during compute triggers a bounded retry with exponential backoff, which re-enters the same key check so a partially completed run converges rather than duplicating work. key = SHA-256(bbox+crs+layer) cache? key present return persisted artifact no recompute compute + persist reproject / tile, write by key return new artifact cached for next run hit miss retry w/ backoff

Step-by-Step Implementation

Each step is verifiable before you proceed. The controlling idea is that the cache key is computed from the task’s spatial inputs only, so retries and scheduled re-runs converge on one artifact.

1. Define the deterministic cache-key function

The key function reads the task arguments and hashes the bounding box, CRS, and layer. It must exclude anything non-deterministic — run id, timestamp, worker name — or caching silently breaks.

python
# cache.py
import hashlib
from prefect.context import TaskRunContext

def spatial_cache_key(context: TaskRunContext, arguments: dict) -> str:
    parts = f"{arguments['bbox']}|{arguments['crs']}|{arguments['layer']}"
    digest = hashlib.sha256(parts.encode()).hexdigest()
    return f"{arguments['layer']}-{digest}"

Verify: identical inputs must yield an identical key.

bash
python -c "from cache import spatial_cache_key; \
c=None; a={'bbox':'-89.7,39.7,-89.6,39.8','crs':'EPSG:4326','layer':'parcels'}; \
print(spatial_cache_key(c,a)); print(spatial_cache_key(c,a))"

2. Bind the key and result persistence to the reproject task

cache_key_fn reuses a prior result for a matching key; persist_result=True writes the artifact to the result store so a fresh process can still hit the cache. cache_expiration bounds staleness.

python
# tasks.py
from datetime import timedelta
from prefect import task
from cache import spatial_cache_key

@task(cache_key_fn=spatial_cache_key, cache_expiration=timedelta(days=7),
      retries=3, retry_delay_seconds=[10, 30, 90], persist_result=True)
def reproject(bbox: str, crs: str, layer: str) -> str:
    dst = f"s3://{__import__('os').environ['RESULT_BUCKET']}/reprojected/{layer}.parquet"
    run_ogr2ogr(src_layer=layer, dst=dst, t_srs="EPSG:4326")  # canonical CRS
    return dst

Verify: run the task twice; the second run reports a cached state.

bash
prefect flow-run logs <run-id> | grep -i "Finished in state Cached"

3. Add the topology gate with zero retries

Validation must fail closed, so it takes retries=0: a retried validation could mask a genuine topology failure and publish invalid geometry.

python
@task(cache_key_fn=spatial_cache_key, retries=0, persist_result=True)
def validate_topology(bbox: str, crs: str, layer: str, src: str) -> str:
    invalid = count_invalid(src)  # PostGIS ST_IsValid over the reprojected features
    if invalid:
        raise ValueError(f"{invalid} invalid geometries — blocking publication")
    return src

Verify: feed a known-invalid fixture and confirm the flow fails rather than publishing.

bash
prefect flow-run logs <run-id> | grep -i "invalid geometries"

4. Compose the flow and persist tiles under the same key

The tile task inherits the same key strategy, so the tile write is idempotent: a retry after a partial write re-resolves to the cached tile set instead of appending a divergent one.

python
from prefect import flow

@flow(name="prefect-spatial-idempotent")
def pipeline(bbox: str, crs: str, layer: str, version: str):
    r = reproject(bbox, crs, layer)
    v = validate_topology(bbox, crs, layer, r)
    t = tile(bbox, crs, layer, v)          # @task with the same cache_key_fn
    publish(layer, version=version, artifact=t)

Verify: re-run the deployment with identical parameters and confirm no new artifact object appears.

bash
aws s3 ls s3://$RESULT_BUCKET/tiles/parcels/ --recursive | wc -l   # unchanged across runs

5. Deploy on a schedule and confirm replay idempotency

A scheduled deployment re-runs on cadence; with deterministic keys, unchanged inputs are cache hits and only new data recomputes.

bash
prefect deploy pipeline.py:pipeline -n nightly --cron "0 2 * * *"
prefect deployment run prefect-spatial-idempotent/nightly

Verify: two consecutive scheduled runs over unchanged data must both resolve every task to Cached.

bash
prefect flow-run logs <run-id> | grep -c "state Cached"   # equals the task count

Configuration Reference

Parameter Required Value Effect
cache_key_fn Yes spatial_cache_key Reuses results for matching SHA-256(bbox+crs+layer)
persist_result Yes True Writes artifact to the result store for cross-process cache hits
cache_expiration Yes timedelta(days=7) Upper bound on how long a cached result is reused
retries Yes 3 compute / 0 validation Bounded retries; validation fails closed
retry_delay_seconds Yes [10, 30, 90] Exponential backoff between attempts
RESULT_BUCKET Yes S3 bucket Domain-scoped artifact store; rotated credential
t_srs Yes EPSG:4326 Canonical output CRS for every reprojected layer

Common Failure Modes & Fixes

Every run recomputes; nothing is ever cached. Root cause: a non-deterministic value (run id, datetime.now()) leaked into cache_key_fn, so no two keys match. Fix: hash only bbox, crs, and layer, and assert key stability — python -c "from cache import spatial_cache_key; ..." must print the same digest twice.

Cache hits across genuinely different data. Root cause: the key omits an input that actually changes the output, such as the source snapshot date, so distinct inputs collide. Fix: include the source version or content hash in the digest, then invalidate the stale key with prefect result-store cleanup.

Retry duplicates a tile set. Root cause: the tile task wrote to a path not derived from the key, so the retry appended rather than overwrote. Fix: derive the output path from the layer and key, make the write atomic (aws s3 sync to a key-scoped prefix), and confirm object count is stable across runs.

Validation passes but invalid geometry reaches consumers. Root cause: validate_topology was given retries>0, so a transient failure was retried and eventually masked. Fix: set retries=0 on the gate and re-test against a known-invalid fixture.

Cross-process runs miss the cache. Root cause: persist_result is False, so the result lived only in the first worker’s memory. Fix: set persist_result=True and configure a shared result store block reachable by every worker.

FAQ

Why key the cache on bbox, CRS, and layer specifically?

Those three values uniquely identify the spatial work a task performs: which area, in which coordinate reference system, for which dataset. Time and run count do not change the output geometry, so including them only defeats caching. Hashing exactly bbox + crs + layer means a scheduled re-run over the same tile is a guaranteed cache hit, and only a changed bounding box, a new CRS target, or a different layer forces recomputation — which is precisely the idempotency contract the pipeline advertises.

Does persist_result=True slow the flow down?

It adds one write to the result store per task, which is negligible against a reprojection or tiling stage that already reads and writes large geometries. The payoff is that a retry in a fresh process, or a scheduled re-run on a different worker, still finds the cached artifact instead of recomputing — so the persistence cost is recovered many times over across the pipeline’s lifetime.

How does this interact with the topology gate?

The gate is a task like any other and shares the cache key, but it carries retries=0 so it fails closed. Because a cached result is only written on success, an invalid-geometry run raises before its result is persisted, and the invalid artifact never gets a valid cache key. The next run re-enters validation rather than serving a stale pass.

What happens when the source data actually changes?

Include a source snapshot identifier or content hash in the arguments that feed cache_key_fn. When the source changes, the digest changes, the cache misses, and the task recomputes and persists a new artifact under the new key. The previous artifact stays addressable under its own key, which preserves the immutability that a pinned product version depends on.

Can I share one result store across domains?

No. Each domain uses a scoped, rotated credential to its own store, per the zero-trust posture of the mesh. Sharing a result store would let one domain’s pipeline read or overwrite another’s artifacts, breaking both isolation and auditability. Configure a per-domain block and keep RESULT_BUCKET domain-specific.