State Management in Geospatial Flows

In short: a spatial pipeline has three kinds of state — what has been built, how far a long job got, and how current the output is — and they need different homes. Conflating them is why a re-run either redoes everything or skips work it should have redone. Keep the ledger, the checkpoint and the watermark separate, derive each from content rather than from time, and every recovery question becomes a query rather than an investigation.

The orchestrator’s own state is about task runs: this one succeeded, that one is retrying, the flow finished at 03:14. That is exactly the wrong granularity for a pipeline whose unit of work is a tile or a municipality, because a task run is ephemeral and a tile is not. When the question is “does tile 12/2147/1398 need rebuilding”, the orchestrator can only answer “a task with that parameter succeeded in a run last Tuesday”, which is not the same thing at all — the source may have changed since, or the recipe, or the tile may have been rebuilt by a manual intervention the orchestrator never saw.

So spatial pipelines end up maintaining state of their own, and the mistake that follows is to maintain one blob of it. A single last_run.json holding a timestamp, a partial file list and a set of completed identifiers looks tidy and fails in three directions at once: it cannot be read concurrently by a fan-out, it cannot answer a coverage question, and it makes a checkpoint and a completeness record share a lifetime when they should not. The three kinds of state have genuinely different shapes, and separating them costs one afternoon and saves the same afternoon every quarter thereafter.

There is a diagnostic worth applying to any state a pipeline keeps: ask what breaks if it is deleted. If the answer is “nothing, the next run is slower”, it is a checkpoint. If it is “the pipeline rebuilds work it did not need to”, it is a ledger. If it is “the pipeline re-reads a source from the beginning of time”, it is a watermark. Anything whose deletion produces two of those answers is holding two kinds of state in one place, and it will eventually be split under pressure at an inconvenient hour.

Prerequisites & Architecture Baseline

Core Principles

1. The ledger records what exists, not what ran. One row per unit of work, keyed by the unit and the inputs it was built from. It survives cache eviction, it answers coverage questions directly, and it is the only state a re-run consults before deciding to skip.

2. A checkpoint is scratch, not truth. It exists so a four-hour mosaic does not restart from zero, and it should be deletable at any moment without affecting correctness. If deleting a checkpoint loses information, it has quietly become a ledger.

3. The watermark is about the source, not about the run. “We have ingested everything published up to 2026-08-06T22:00Z” is a statement about the publisher’s timeline. Storing the run’s own start time instead is how a pipeline that ran late skips a delivery permanently.

4. State is keyed by content, not by time. A timestamp answers “when” and the question is always “what from”. Two runs an hour apart over an unchanged source should produce identical keys and do no work.

5. Writers must be safe to run twice. A fan-out of four hundred tasks writing the same ledger will collide; an upsert keyed on the unit makes the collision a no-op rather than a duplicate.

6. Every piece of state must be reconstructible. If the ledger is lost, a scan of the output store should rebuild it. State that cannot be rebuilt is a single point of failure wearing a database’s clothing.

Production Implementation

The ledger is a table, not a file. PostGIS is the natural home because the pipeline is already connected to it and because the unit of work usually has a geometry worth indexing.

CREATE TABLE tile_ledger (
    layer          text        NOT NULL,
    z              smallint    NOT NULL,
    x              integer     NOT NULL,
    y              integer     NOT NULL,
    work_key       text        NOT NULL,   -- sha256(tile | source digest | recipe)
    source_digest  text        NOT NULL,
    recipe_version text        NOT NULL,
    built_at       timestamptz NOT NULL DEFAULT now(),
    valid_px_pct   real,
    geom           geometry(Polygon, 3857) NOT NULL,
    PRIMARY KEY (layer, z, x, y)
);

CREATE INDEX tile_ledger_geom_idx ON tile_ledger USING gist (geom);
CREATE INDEX tile_ledger_key_idx  ON tile_ledger (work_key);

Writing to it is an upsert, so a retried task and a manual re-run land in the same place:

UPSERT = """
INSERT INTO tile_ledger
    (layer, z, x, y, work_key, source_digest, recipe_version, valid_px_pct, geom)
VALUES (%(layer)s, %(z)s, %(x)s, %(y)s, %(key)s, %(digest)s, %(recipe)s,
        %(valid_pct)s, ST_TileEnvelope(%(z)s, %(x)s, %(y)s))
ON CONFLICT (layer, z, x, y) DO UPDATE
SET work_key       = EXCLUDED.work_key,
    source_digest  = EXCLUDED.source_digest,
    recipe_version = EXCLUDED.recipe_version,
    valid_px_pct   = EXCLUDED.valid_px_pct,
    built_at       = now()
-- Only when something actually changed: an unchanged rebuild must not move built_at,
-- or the freshness figure becomes a record of when the pipeline last ran.
WHERE tile_ledger.work_key IS DISTINCT FROM EXCLUDED.work_key;
"""


def needs_build(cur, layer: str, tile: Tile, key: str) -> bool:
    cur.execute(
        "SELECT 1 FROM tile_ledger WHERE layer=%s AND z=%s AND x=%s AND y=%s AND work_key=%s",
        (layer, tile.z, tile.x, tile.y, key),
    )
    return cur.fetchone() is None
Three kinds of state, three homesThe ledger lives in PostGIS and is permanent. The checkpoint lives in object storage and is disposable. The watermark is a single row and tracks the publisher's timeline.ledgerwhat existshome: PostGISlife: permanentkey: unit + inputsrebuildable: by scancheckpointhow far this job gothome: object storagelife: hourskey: run + unitdeletable: alwayswatermarkhow current we arehome: one rowlife: permanentkey: source streampublisher time, not oursOne file holding all three fails three ways at once: it cannot be written concurrently, cannot answer acoverage query, and ties a disposable checkpoint to a permanent record.
The middle column is the only one that may be deleted without consequence, and that is the test for whether a piece of state has drifted out of its column.

The WHERE work_key IS DISTINCT FROM clause on the upsert deserves a note, because it is the difference between a built_at column that means something and one that does not. Without it, a nightly run that rebuilds nothing still touches every row, and built_at becomes a record of when the pipeline last ran rather than when the tile last changed. Every freshness question then answers “everything is fresh”, including for tiles whose source has not been republished in two years — which is exactly the situation a freshness metric exists to reveal.

The watermark is the smallest of the three and the one most often got wrong, so it is worth writing out. It is a statement about the publisher’s timeline, advanced only when everything derived from that position is durable:

ADVANCE = """
INSERT INTO source_watermark (stream, position, advanced_at)
VALUES (%(stream)s, %(position)s, now())
ON CONFLICT (stream) DO UPDATE
SET position = GREATEST(source_watermark.position, EXCLUDED.position),
    advanced_at = now();
"""


def advance_watermark(conn, stream: str, position: datetime, expected_units: int) -> None:
    """Commit point for the run. Never called before the ledger is complete."""
    with conn.cursor() as cur:
        # An advisory lock, so two overlapping runs cannot both decide they are done.
        cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (stream,))
        cur.execute(
            "SELECT count(*) FROM tile_ledger WHERE layer = %s AND source_digest = %s",
            (stream, digest_for(stream, position)),
        )
        built, = cur.fetchone()
        if built < expected_units:
            raise RuntimeError(
                f"{built}/{expected_units} units in the ledger — not advancing {stream}"
            )
        cur.execute(ADVANCE, {"stream": stream, "position": position})

GREATEST in the upsert is not decoration. Watermark advances arrive out of order whenever a retried run finishes after a later one — a re-drive of Tuesday’s delivery completing on Thursday afternoon is entirely normal — and a plain assignment would walk the watermark backwards, causing the pipeline to re-ingest two days of published data it already holds. Making the column monotonic by construction removes a class of bug that is otherwise found only by noticing a suspiciously long run.

The count check before advancing is the other half. A watermark is a promise that everything up to that position has been processed, and a promise made on the basis of “the flow reached its last task” is worth very little — the flow reaches its last task whether four hundred and twelve units succeeded or three hundred did. Verifying the ledger before committing costs one query and converts the watermark from an assertion about control flow into an assertion about data.

Step-by-Step Walkthrough

  1. Compute the work key for each unit from the tile index, the source digest and the recipe version. Nothing else belongs in it.
  2. Query the ledger for units whose stored key differs or is absent. That set is the work.
  3. Write a checkpoint per unit as it completes, under a prefix scoped to the run, so a resumed run can skip finished units without consulting the ledger.
  4. Upsert the ledger as each unit’s output is durably written — after the object store confirms, never before.
  5. Advance the watermark only once every unit derived from that source position is in the ledger. This is the commit point for the run.
  6. Expire checkpoints on a lifecycle rule. If anything breaks when they vanish, something has been miscategorised.

Edge Cases & Failure Recovery

The ledger is lost. Scan the output store, recompute the key from each object’s stored metadata, and reinsert. This is why the source digest and recipe version belong in the object’s metadata as well as the ledger row — the ledger becomes an index over the outputs rather than the only record of them.

A run dies between the object write and the ledger upsert. The unit is rebuilt on the next run, which is harmless because the build is idempotent by key. Ordering it the other way round is not harmless: a ledger row for an object that does not exist makes a missing tile invisible.

A source is corrected retroactively. Its digest changes, so every key derived from it changes, so the affected units come back into the work set automatically. No manual invalidation list is needed, which is the main practical argument for content-keyed state.

Two runs overlap. The upsert makes concurrent writers safe, and the checkpoint prefix keeps their scratch apart. What is not safe is two runs advancing the same watermark, so that step belongs behind an advisory lock.

A unit is deleted upstream. A municipality is merged, a map sheet is withdrawn. The ledger row still exists and its output still sits in the store, so the layer keeps serving ground that no longer has a source. Reconciling the ledger against the current manifest — rather than only inserting into it — is what catches this, and it is a nightly query rather than a piece of machinery.

The recipe changes for one layer only. Bump that layer’s recipe version. Because the key includes it, exactly that layer rebuilds and the others do not — the granularity of invalidation follows the granularity of the version string, which is a good reason not to have one global version.

What a resumed run actually skipsOf four hundred and twelve tiles, two hundred and six were checkpointed before the interruption. The resumed run reads those from the checkpoint prefix and builds the remaining two hundred and six.first attempt — interrupted at 02:41206 tiles checkpointed206 never startedresumed run — with checkpointsskipped — 8 s to verify206 built — 31 minresumed run — without checkpoints412 built — 62 min, and the nightly window is gone
The checkpoint buys back exactly the work already done. It is worth having precisely when the job is longer than the window it must finish in.

Configuration Reference

Setting Typical Spatial notes
Ledger primary key (layer, z, x, y) One row per unit. The work key is an attribute, not part of the identity, so history stays queryable.
built_at update rule only when the key changes Otherwise freshness degrades to “when the pipeline last ran”, which is always now.
Checkpoint prefix run-<id>/<unit> Scoped to the run so overlapping runs cannot read each other’s partial work.
Checkpoint lifetime 24–72 h Long enough to resume, short enough that a forgotten prefix does not become a storage bill.
Watermark grain per source stream One per publisher feed. A single global watermark makes the slowest source govern all of them.
Digest algorithm sha256, first 16 hex Collision risk is negligible at this scale and short keys keep log lines readable.
Rebuild-from-scan supported The ledger must be an index over the outputs, never the only record that they exist.
What each key style rebuildsWith a timestamp key, a rerun over an unchanged source rebuilds all four hundred and twelve tiles. With a content key it rebuilds the nine whose source changed.a nightly rerun, 9 of 412 sources changedkeyed by timestamp412 rebuilt — 62 min, 411 of them identicalkeyed by content9 rebuilt — 84 sThe timestamp form is not merely slower. It also republishes 411 unchanged tiles, whichinvalidates every downstream cache for no reason at all.
The second-order cost is usually the larger one: a rebuild that changes nothing still looks like a change to everything downstream.

Frequently Asked Questions

Can the orchestrator's result persistence replace the ledger?

For a single flow, briefly. It is keyed by task run rather than by unit, it expires on the orchestrator’s schedule, and it cannot be queried spatially — so the first time somebody asks which tiles in a district are missing, you will write the ledger anyway. Starting with it costs an afternoon and removes that whole conversation.

Where do checkpoints belong for a very long single-unit job?

Inside the unit, as intermediate artefacts under the run prefix. Checkpointing large raster mosaics covers the shape; the rule that matters is that a checkpoint must be verifiable, because resuming from a half-written block is worse than restarting.

Should the ledger store the geometry?

Yes, when the unit has one. It costs a GiST index and it turns “which municipalities are stale” from a join against a separate table into a single spatial query — and it makes the coverage map in visualizing tile coverage gaps on a geomap a one-liner.

How does this interact with idempotency keys?

They are the same key seen from two sides. The idempotency key makes a write safe to repeat; the ledger records that the write happened. Idempotency keys in spatial ETL treats the write side in detail.

What if the source has no digest?

Derive one — ETag, Last-Modified plus size, or a hash of the first and last megabyte for a large file over HTTP range. Any stable function of the content is enough. Falling back to a timestamp should be a deliberate, documented decision, because it silently disables every skip in the pipeline.

Who is allowed to write the ledger?

Only the flow that produces the output, and only after the output is durable. A validation job, a dashboard or a manual repair script that writes ledger rows turns the ledger into a set of claims from several sources with no shared definition of what a row means. Where a repair genuinely needs to mark work as done, it should build the work rather than record it — which is a good constraint, because it keeps the record honest by making it expensive to lie.

Should state ever be versioned alongside the code?

The schema, yes; the contents, no. Ledger rows describe what exists in a store, so they belong to the environment rather than to a release, and a staging ledger restored into production would claim tiles exist that were never built there. Keeping the migration in the repository and the rows out of it is the split that survives a rollback.

Is PostGIS the only sensible home?

No, but it is usually the one already present. Object storage works when the ledger is small and queries are by prefix; it stops working the moment a question is spatial. Storing flow state in PostGIS versus object storage sets out the trade properly.

How large does the ledger get?

One row per unit per layer, so a national z12 pyramid with four layers is a few hundred thousand rows — trivial for PostgreSQL. Keeping history instead of upserting multiplies that by the number of rebuilds, which is why history belongs in a separate append-only table if it is wanted at all.

Geospatial Orchestration Architecture Fundamentals