Checkpointing and Restarting Long Watershed Pipelines

A continental delineation runs for hours, and the failures that interrupt it — a preempted node, an exhausted disk, an unhandled projection error on tile 4 219 — are all recoverable if the run kept enough state. Most do not, and the cost is a full restart. This guide covers making one resumable, as part of the pipeline orchestration topic within watershed delineation and catchment synchronization.

Prerequisites

  • A pipeline whose stages are already separable — see the parent topic for the task decomposition.
  • A filesystem where the checkpoint directory and the final outputs share a mount, so renames are atomic.
  • Deterministic stages. A stage whose output varies between runs cannot be checkpointed meaningfully.

Core Technique: Key on Inputs, Write Atomically

A checkpoint has two properties that matter, and both are easy to get wrong.

What the Checkpoint Key Decides Keying on the step name alone means a re-run after the DEM changes loads the old conditioned surface and finishes with the wrong data. Keying on a hash of the inputs and parameters means the changed DEM produces a different key, the cache misses, and the stage recomputes. keyed on step name key = "condition_dem" DEM is corrected and the run restarts → cache HITS on the old surface the run completes, using the wrong data keyed on an input hash key = sha256(dem bytes + params) DEM is corrected and the run restarts → cache MISSES, the stage recomputes every later stage misses too, correctly The second design also gives cache invalidation for free: changing a conditioning parameter changes the key, so nothing has to be deleted by hand between experiments.

Annotated Code Example

python
import hashlib
import json
import logging
import os
import shutil
import tempfile
from contextlib import contextmanager
from pathlib import Path

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


class Checkpoint:
    """
    A content-addressed checkpoint store for expensive pipeline stages.

    Keys are derived from the stage name, the checksums of its input files and
    the parameters it was called with, so a changed input misses automatically.
    """

    def __init__(self, root: str, enabled: bool = True):
        self.root = Path(root)
        self.root.mkdir(parents=True, exist_ok=True)
        self.enabled = enabled
        self.hits = self.misses = 0

    def key(self, stage: str, inputs: list[str], params: dict) -> str:
        h = hashlib.sha256()
        h.update(stage.encode())
        for path in sorted(inputs):
            # Hash the file CONTENT, not its path or mtime. A pipeline that
            # keys on mtime re-runs everything after a copy, and one that keys
            # on path reuses results from a file that has since changed.
            h.update(_file_sha256(path).encode())
        h.update(json.dumps(params, sort_keys=True, default=str).encode())
        return h.hexdigest()[:16]

    def path_for(self, stage: str, key: str, suffix: str = ".tif") -> Path:
        return self.root / f"{stage}__{key}{suffix}"

    def get(self, stage: str, inputs: list[str], params: dict,
            suffix: str = ".tif") -> Path | None:
        if not self.enabled:
            return None
        key = self.key(stage, inputs, params)
        candidate = self.path_for(stage, key, suffix)
        marker = candidate.with_suffix(candidate.suffix + ".done")
        # The marker is written AFTER the payload lands, so a half-written
        # output is never treated as a hit even if the process died mid-write.
        if candidate.exists() and marker.exists():
            self.hits += 1
            log.info("checkpoint HIT  %s (%s) — skipping", stage, key)
            return candidate
        self.misses += 1
        log.info("checkpoint MISS %s (%s) — computing", stage, key)
        return None

    @contextmanager
    def stage(self, stage: str, inputs: list[str], params: dict,
              suffix: str = ".tif"):
        """
        Run a stage under the checkpoint, writing atomically.

        Yields (cached_path_or_None, write_path). When the first is not None
        the body should skip its work entirely.
        """
        cached = self.get(stage, inputs, params, suffix)
        if cached is not None:
            yield cached, None
            return

        key = self.key(stage, inputs, params)
        final = self.path_for(stage, key, suffix)
        # Temporary file in the SAME directory, so the rename is a rename and
        # not a cross-device copy — only a same-filesystem rename is atomic.
        fd, tmp = tempfile.mkstemp(dir=str(self.root), suffix=suffix + ".part")
        os.close(fd)
        try:
            yield None, Path(tmp)
            if not os.path.getsize(tmp):
                raise RuntimeError(f"stage {stage} produced an empty output")
            os.replace(tmp, final)
            final.with_suffix(final.suffix + ".done").write_text(
                json.dumps({"stage": stage, "key": key, "params": params,
                            "inputs": inputs}, default=str))
            log.info("checkpoint SAVE %s (%s) → %s",
                     stage, key, final.name)
        except BaseException:
            # Leave nothing behind on failure: a partial file with no marker is
            # harmless, but cleaning up keeps the directory readable.
            if os.path.exists(tmp):
                os.unlink(tmp)
            raise

    def report(self) -> dict:
        total = self.hits + self.misses
        log.info("checkpoint summary: %d hit(s), %d miss(es) of %d stage(s)",
                 self.hits, self.misses, total)
        if total and self.hits == total:
            log.warning("Every stage was a cache hit — the pipeline produced no "
                        "new work. Is this the run you meant?")
        return {"hits": self.hits, "misses": self.misses}


def _file_sha256(path: str, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for block in iter(lambda: fh.read(chunk), b""):
            h.update(block)
    return h.hexdigest()


# --- Example usage inside a pipeline ---
# ck = Checkpoint("./checkpoints")
#
# params = {"epsilon": True, "breach_dist": 40}
# with ck.stage("condition", ["raw_dem.tif"], params) as (cached, out):
#     if cached is None:
#         condition_dem("raw_dem.tif", str(out), **params)
#         conditioned = out
#     else:
#         conditioned = cached
#
# with ck.stage("flowdir", [str(conditioned)], {"method": "D8"}) as (cached, out):
#     if cached is None:
#         compute_flowdir(str(conditioned), str(out))
#         fdir = out
#     else:
#         fdir = cached
# ck.report()

Parameter Reference

Decision Recommendation Why
Key material Stage name + input checksums + params A changed input must miss
Hash of content vs mtime Content mtime changes on copy and misses spuriously
Write strategy Temp file plus os.replace Atomic within one filesystem
Completion marker Separate .done file A payload without a marker is never a hit
What to checkpoint Stages slower than their own write Everything else is cheaper to recompute
Cache scope Per DEM version, not per run The point is reuse across runs
What a Restart Costs at Each Failure Point A full run takes 214 minutes. Failing after conditioning and resuming costs 158 minutes. Failing after flow direction costs 121. Failing during the final merge costs 9 minutes. Without checkpoints every case costs the full 214. no checkpoints 214 min resume after conditioning 158 min resume after flow direction 121 min resume at the final merge 9 min wall-clock time to a finished result after a failure The later the failure, the more a checkpoint saves — and late failures are the common ones.

Worked Example: Reading the Checkpoint Log

A resumed run over a 96-tile domain:

text
INFO checkpoint HIT  condition (a41f9c2e7b3d8105) — skipping
INFO checkpoint HIT  flowdir   (7c02b8ea19f4d663) — skipping
INFO checkpoint MISS accum     (5e8d1a04c9b27f3a) — computing
INFO checkpoint SAVE accum     (5e8d1a04c9b27f3a) → accum__5e8d1a04c9b27f3a.tif
INFO checkpoint MISS delineate (b93c705fa1284de6) — computing
INFO checkpoint summary: 2 hit(s), 2 miss(es) of 4 stage(s)

Two things are worth reading here. The first two stages hit, so the conditioned DEM and direction grid were reused — that is the resume working. The accumulation stage missed, which is correct if it was the stage that failed, and a warning sign if it was not: a miss on a stage whose inputs did not change means one of its inputs did change, and finding out which is more urgent than the run finishing.

The summary’s other function is the all-hits warning. A run where every stage hits produced nothing new, which usually means someone re-ran a completed pipeline expecting it to pick up a change that never reached the inputs.

A content-addressed store keeps one entry per input version, which is the point and also the disk-consumption pattern. Pruning is a policy decision, not an afterthought.

The Cache Grows One Entry per Input Version Without pruning the checkpoint directory reaches 640 gigabytes after eight DEM versions. Keeping the two most recent versions holds it near 160. Keeping only versions referenced by a live manifest holds it near 80. no pruning — 640 GB keep the last two versions — 160 GB keep what a manifest references — 80 GB v1 v4 v8 successive DEM versions through a season of re-runs cache size

Gotchas and Edge Cases

  • Keying on the step name. Serves stale results after any input changes, silently.
  • Hashing mtime instead of content. Copying the DEM to a new machine invalidates the whole cache for no reason.
  • Writing directly to the final path. A crash mid-write leaves a truncated raster that the next run treats as valid.
  • Temp file on a different filesystem. os.replace falls back to a copy, which is not atomic, and the window for corruption returns.
  • No completion marker. A file that exists is not a file that finished. The marker is what distinguishes them.
  • Checkpointing cheap stages. Writing a 4 GB intermediate to save 30 seconds of compute is a net loss and fills the disk that the run later needs.
  • Never clearing the cache. Content-addressed checkpoints accumulate one copy per input version. Prune by age or by a manifest of the versions still in use.