A worker died eleven hours into a fourteen-hour grid build. Most of the output is on disk. The
question is whether any of it can be trusted, and the honest answer at that moment is usually no
— not because the data is wrong, but because nothing recorded which tiles finished.
Part of Async Execution for Large Grids: parallel execution makes partial failure the normal case rather than the exceptional one, and a build with no resume story converts every transient fault into a full restart.
The natural resume check is “does the output file exist?” and it is wrong in a specific,
damaging way: a worker killed mid-write leaves a file that exists, is readable, has plausible
dimensions, and is missing its last rows. The resume skips it, the build reports success, and the
defect surfaces months later as a band of nodata in a downstream product.
The problem compounds under parallelism. A crashed worker may have been partway through several
tiles; the scheduler may have retried a task whose first attempt is still flushing; and two
processes writing the same path concurrently produce a file that is neither attempt.
Three properties are needed, and they are cheap:
Atomic publication. A tile appears at its final path only when it is complete. Write to a
temporary name and rename, because rename within a filesystem is atomic and a partially written
temporary file is obviously garbage.
Independent completion evidence. A record, written after the rename, saying what was
produced and what it hashes to. The file’s own existence cannot be that evidence, because the
file is what is in question.
Deterministic task identity. Tile n must be the same work on the resume as it was on the
original run, or the resumed output is a mixture of two different builds.
Four resume strategies against what each one accepts: only a post-rename manifest entry rejects every partial-write case.
Before designing a resume, establish what evidence the existing build actually produces, because
the answer is often “less than assumed”.
python
defaudit_partial_output(tile_dir, expected_tiles)->dict:"""Classify what is on disk after a failed run."""
present ={p.stem for p in tile_dir.glob("*.tif")}
temp ={p.stem for p in tile_dir.glob("*.tmp*")}return{"expected":len(expected_tiles),"present":len(present),"temporaries_left":len(temp),# a nonzero count means non-atomic writes"missing":sorted(set(expected_tiles)- present)[:20],"unverifiable":len(present),# until there is a manifest, all of them}
The unverifiable field is deliberately blunt. Without a manifest, every present file is a
candidate truncation, and the only sound recovery from that state is to rebuild everything —
which is exactly the cost the resume was meant to avoid, and the argument for adding the manifest
before the next long run rather than after the next failure.
import os
import tempfile
defwrite_tile(tile_id:str, array, out_dir, profile):"""Write to a temporary name in the same directory, then rename into place."""
fd, tmp = tempfile.mkstemp(dir=out_dir, prefix=f".{tile_id}.", suffix=".tmp")
os.close(fd)try:
save_raster(tmp, array, profile)
os.replace(tmp, out_dir /f"{tile_id}.tif")# atomic within one filesystemexcept BaseException:
os.unlink(tmp)raise
Same directory, so the rename stays within one filesystem — a rename across filesystems is a copy
followed by a delete, and is not atomic. The BaseException catch rather than Exception is
deliberate: a worker killed by a signal that raises should still clean up its temporary.
defrecord(manifest_path, tile_id:str, path, seed:int, code_version:str):"""Append-only completion evidence, written after the file is in place."""
entry ={"tile": tile_id,"sha256": sha256_of(path),"bytes": path.stat().st_size,"seed": seed,"code_version": code_version,}withopen(manifest_path,"a")as fh:# append is atomic for small lines
fh.write(json.dumps(entry, sort_keys=True)+"\n")
fh.flush()
os.fsync(fh.fileno())
Order matters absolutely: rename first, then record. Recording first and crashing before the
rename produces a manifest claiming a tile that is not there, which is a worse failure than the
one being fixed because it is trusted.
deftile_seed(root_seed:int, tile_id:str)->int:"""Same tile, same seed, on the original run and on every resume."""returnint.from_bytes(
hashlib.sha256(f"{root_seed}:{tile_id}".encode()).digest()[:8],"big")
Without this the resume is not a resume. A counter-based seed assigns different values when tasks
run in a different order, so the recovered tiles come from a different realisation than their
neighbours and the seams show — the same derivation-tree argument, with a failure mode that only appears after a crash.
A crash mid-build under the two write orders: what is on disk, what the manifest claims, and which tiles the resume re-runs.
deftest_resume_matches_clean_run(tmp_path, tiles):
clean = build_all(tmp_path /"clean", tiles, root_seed=7)
partial = build_all(tmp_path /"resumed", tiles[:len(tiles)//2], root_seed=7)
resumed = build_all(tmp_path /"resumed", tiles, root_seed=7, resume=True)for tile in tiles:assert sha256_of(clean[tile])== sha256_of(resumed[tile]),f"{tile} differs"deftest_truncated_tile_is_rebuilt(tmp_path, tiles):
out = build_all(tmp_path, tiles, root_seed=7)
truncate(out[tiles[3]], fraction=0.6)# simulate a killed writer
rebuilt = build_all(tmp_path, tiles, root_seed=7, resume=True)assert verify_against_manifest(rebuilt),"a truncated tile survived the resume"deftest_manifest_entry_implies_a_complete_file(manifest_path, tile_dir):for entry in read_manifest(manifest_path):
path = tile_dir /f"{entry['tile']}.tif"assert path.exists(),f"manifest claims {entry['tile']} which is absent"assert sha256_of(path)== entry["sha256"],f"{entry['tile']} does not match its hash"deftest_no_temporaries_survive(tile_dir):assertnotlist(tile_dir.glob("*.tmp*")),"non-atomic write path"
The first test is the one that makes the feature trustworthy, and it is the one most often
skipped because it needs a full small build run twice. It is worth the seconds: a resume that
produces plausible output rather than identical output is a silent correctness bug with a
long latency.
Expected total build time against per-task failure probability, for full restart versus tile-level resume.
Object storage has no atomic rename. On S3-like stores a rename is a copy plus a delete.
Publication by multipart upload completion is atomic and is the equivalent primitive; a manifest
entry written after the completion call is still the evidence.
The manifest itself is a single point of contention. Appending from hundreds of workers to
one file works on a POSIX filesystem for short lines and does not work on object storage. One
small manifest file per tile, listed at resume time, is slower to enumerate and much harder to
corrupt.
Code version in the manifest is not optional. A resume run with different code produces tiles
that differ from their neighbours for reasons no hash check will surface. If the recorded
code_version does not match, the correct behaviour is to refuse the resume rather than to warn.
Partial tiles inside a single output file. A build writing all tiles into one large file
cannot use rename at all, and needs either a sidecar recording which windows are complete or a
write-ahead approach. This is the strongest practical argument for tiled output on any build long
enough to fail.
The manifest is introduced here to make a resume safe, and once it exists it turns out to be the
artifact several other things were missing.
It is the completeness proof. A consumer asking “is this release whole?” currently has to
enumerate files and trust that the expected set is known. A manifest listing every tile with its
hash answers the question directly, and answers it the same way months later when the expected
set has been forgotten.
It is the diff between two releases. Two manifests compared by hash immediately give the set
of tiles that changed, which is both a useful release note and the cheapest possible input to
incremental downstream processing. Without it, “what changed” requires reading every tile of both
releases.
It records the code version at tile granularity. A build that spans a deployment — long
builds do — produces tiles from two code versions, and the manifest is the only place that fact
survives. This is the same information lineage tracking needs at the release level, recorded one level finer because the failure mode demanded it.
It makes corruption detectable rather than inferable. Storage bit-rot, an interrupted copy, a
partial sync to another bucket: all of them produce a file whose hash no longer matches its
recorded value, and none of them are visible any other way.
None of this justifies building a manifest on its own — but it is worth knowing that the cost is
already paid once the resume exists, and that the format should be chosen with these uses in mind
rather than as a minimal resume marker.