Retrying Failed Photogrammetry Jobs Idempotently

The symptom is unusually unpleasant: a job that failed once, was retried automatically, reported success, and produced an orthomosaic with a visible seam through the middle or a point cloud missing a strip. Nothing errored. The retry did not fail — it succeeded against a project directory that still held partial output from the attempt before it, and the engine, finding those files present, skipped the stages that would have replaced them. This page is about making a retry mean “run this job again” rather than “resume whatever state was left behind”.

The orchestration layer that decides when to retry is covered in orchestrating photogrammetry jobs with Python schedulers. This page is about the operation itself.

Why a naive retry corrupts the output

Reconstruction engines are deliberately resumable. Each stage checks whether its output already exists and skips itself if so — the behaviour that makes resuming a long run cheap. That check asks whether a file is present, not whether it is complete, and not whether it was produced under the same configuration.

Three distinct problems follow from that.

A truncated output looks finished. A process killed while writing a dense point cloud leaves a file on disk. The next attempt sees it, skips the stage, and every subsequent stage consumes a cloud that is missing its tail. The result is geometrically self-consistent and wrong, which is the worst combination available.

A configuration change is ignored. Retry a failed job with --split reduced from 800 to 400, and any stage whose output already exists keeps the results computed at 800. The run reports the new configuration and delivers a mixture of both.

Stale intermediates outlive their inputs. If the retry follows a fix to the input imagery — a corrupted frame removed, EXIF repaired — the stages that already ran used the old inputs. Nothing revalidates them.

None of the three announces itself. The engine’s log records the stages it skipped, which reads as efficiency.

What a retry inherits from a killed attempt A project directory after a job was killed during the dense-matching stage. Three earlier stage outputs are complete and valid. The dense cloud is present but truncated, having been killed mid-write. Later stage directories are absent. A naive retry is shown consuming all four existing outputs, including the truncated one, and proceeding to completion — producing a final orthomosaic derived from an incomplete cloud, with no error at any point. Beside it, the correct behaviour is shown: the truncated output and everything after it are removed before the retry begins. state after the kill dataset — complete opensfm — complete dense cloud — truncated, present meshing — absent orthophoto — absent naive retry sees four outputs, skips three stages, consumes the truncated cloud, finishes cleanly no error, wrong output idempotent retry deletes the truncated output and everything downstream of it, then runs same result as a first run The engine's skip logic asks whether a file exists, never whether it is complete. So the retry's first job is to make the directory honest about what it actually contains. Deleting downstream outputs too is not caution — a mesh built from a partial cloud is equally invalid.

Figure 1 — A killed job leaves a directory that looks resumable and is not. Everything from the interrupted stage onward has to go, because everything after it was derived from the file that was being written.

Minimal reproducible solution

The retry has three jobs: decide whether retrying is appropriate at all, truncate the project state back to a known-good boundary, and record what it did. The first is a classification, the second is a deletion, and the third is what makes the whole thing auditable.

import shutil
from pathlib import Path

# Stage outputs in execution order. Deleting stage N means deleting N..end.
STAGE_OUTPUTS = [
    ("dataset", "images.json"),
    ("opensfm", "opensfm"),
    ("openmvs", "opensfm/undistorted/openmvs"),
    ("odm_filterpoints", "odm_filterpoints"),
    ("odm_meshing", "odm_meshing"),
    ("mvs_texturing", "odm_texturing"),
    ("odm_georeferencing", "odm_georeferencing"),
    ("odm_orthophoto", "odm_orthophoto"),
]


def _is_complete(path: Path) -> bool:
    """A stage output counts as complete only if it opens, not if it exists."""
    if not path.exists():
        return False
    if path.is_dir():
        return any(path.iterdir())
    if path.suffix in {".tif", ".tiff"}:
        import rasterio                       # openability is the real test
        try:
            with rasterio.open(path) as ds:
                return ds.count > 0
        except Exception:
            return False
    return path.stat().st_size > 0


def rewind_project(project_dir: Path, from_stage: str | None = None) -> str:
    """Delete the first incomplete stage and everything after it.

    Returns the stage the next attempt will start from. With from_stage given,
    rewinds to that stage explicitly — used when the inputs or configuration
    changed and earlier outputs are stale even though they are complete.
    """
    names = [n for n, _ in STAGE_OUTPUTS]
    if from_stage is not None:
        start = names.index(from_stage)
    else:
        start = len(names)
        for i, (_, rel) in enumerate(STAGE_OUTPUTS):
            if not _is_complete(project_dir / rel):
                start = i
                break

    for _, rel in STAGE_OUTPUTS[start:]:
        target = project_dir / rel
        if target.is_dir():
            shutil.rmtree(target, ignore_errors=True)
        elif target.exists():
            target.unlink()

    return names[start] if start < len(names) else "complete"

The from_stage parameter is the part that is easy to omit and expensive to omit. When a retry follows a configuration change or an input fix, the completed stages are complete but wrong, and only the caller knows which stage the change invalidates. A lower --split invalidates from opensfm; a repaired image file invalidates from dataset; a changed orthophoto resolution invalidates only odm_orthophoto.

# Which stage a configuration change invalidates. Anything not listed is
# assumed to invalidate everything, because guessing low is the unsafe error.
INVALIDATES_FROM = {
    "images_dir": "dataset",
    "gcp_file": "odm_georeferencing",
    "target_epsg": "odm_georeferencing",
    "split": "opensfm",
    "pc_quality": "openmvs",
    "ortho_resolution_cm": "odm_orthophoto",
    "engine_digest": "dataset",
}


def rewind_for_change(project_dir: Path, changed_fields: set[str]) -> str:
    names = [n for n, _ in STAGE_OUTPUTS]
    stages = [INVALIDATES_FROM.get(f, "dataset") for f in changed_fields]
    earliest = min(stages, key=names.index) if stages else None
    return rewind_project(project_dir, from_stage=earliest)

Defaulting an unknown field to dataset is deliberate. A wrong guess in that direction costs a full re-run; a wrong guess in the other direction ships a mixed-configuration product that nobody can later explain.

Edge-case matrix

Situation Naive retry does Correct handling
Killed mid-write (signal 9) Reuses a truncated file Rewind from the incomplete stage
Failed with an engine error Reuses everything before the error Rewind from the failing stage
Config changed between attempts Silently mixes two configurations Rewind from the earliest invalidated stage
Input imagery corrected Reconstructs from the old frames Rewind from dataset
Engine version changed Mixes outputs across versions Rewind from dataset, always
Previous attempt actually succeeded Re-runs from scratch, wasting hours Detect completion first and return early
Disk filled mid-stage Reuses a zero-length file _is_complete rejects it on size
Two retries race on one directory Interleaved deletion and writing Hold the project lock across rewind and run

The last row is the one that turns an annoyance into corruption. Rewinding is destructive, so it must happen under the same exclusive lock that guards the run itself — otherwise a second worker can begin deleting stage outputs while the first is writing them.

The lock must cover the rewind, not just the run Two timelines of a project directory being retried by two workers. In the first, each worker locks only while running the engine, so worker B performs its destructive rewind while worker A is still writing stage outputs, and the directory ends in a mixed state. In the second, the lock is taken before the rewind and released after the run, so worker B waits and then performs its rewind against a directory nobody is writing to. A note states that the rewind is the destructive operation and therefore the one that defines where the lock must begin. lock held only during the run worker A rewind run (locked) worker B rewind deletes what A is writing lock taken before the rewind worker A rewind + run (locked) worker B blocked on the lock rewind + run The rewind is the destructive step, so it defines where the critical section starts. Locking only the engine invocation protects the cheap operation and leaves the dangerous one exposed.

Figure 2 — Where the critical section begins is not a detail. A lock that starts at the engine call leaves the deletion outside it, which is the one operation that can destroy another worker’s work.

Verification snippet

The property to assert is stronger than “it ran”: a retried job must produce what a fresh job would have produced. That is checkable directly, and it is worth doing once per pipeline change rather than never.

import hashlib
from pathlib import Path


def raster_fingerprint(path: Path) -> str:
    """Hash the pixels and the georeference, ignoring irrelevant metadata."""
    import rasterio
    h = hashlib.sha256()
    with rasterio.open(path) as ds:
        h.update(str(ds.crs).encode())
        h.update(str(tuple(round(v, 9) for v in ds.transform)).encode())
        for _, window in ds.block_windows(1):
            h.update(ds.read(window=window).tobytes())
    return h.hexdigest()


def assert_retry_is_idempotent(fresh_dir: Path, retried_dir: Path) -> None:
    """A retried run must match a clean run, pixel for pixel."""
    ortho = "odm_orthophoto/odm_orthophoto.tif"
    a = raster_fingerprint(fresh_dir / ortho)
    b = raster_fingerprint(retried_dir / ortho)
    assert a == b, (
        "retry produced a different orthomosaic than a clean run — "
        "the rewind did not remove everything it should have"
    )

Run it against a job deliberately killed at each stage boundary in turn. If the retry is correct, every one of those kills produces the same final fingerprint as an uninterrupted run. If any of them differs, the rewind list is missing a stage output — and the stage it is missing is named by which kill point diverges.

Note the exclusions in raster_fingerprint: creation timestamps and software tags differ between runs and are not part of the result. Including them turns a useful check into one that always fails.

Testing the rewind by killing the job at every stage boundary A test matrix. Each row is a stage at which the job is deliberately killed; each row then shows the retry running and the final orthomosaic fingerprint being compared against a reference produced by an uninterrupted run. Six rows match the reference and are marked as passing. One row, the kill during texturing, produces a different fingerprint and is marked as failing, which localises the missing entry in the rewind list to that stage. A note states that the failing row names the stage whose output the rewind does not remove. kill point → retry → compare against an uninterrupted reference killed during dataset killed during opensfm killed during dense matching killed during filtering killed during meshing killed during texturing killed during export fingerprint matches fingerprint matches fingerprint matches fingerprint matches fingerprint matches differs — rewind incomplete fingerprint matches The single failing row names the stage whose output the rewind list is missing — no further debugging is needed to locate it.

Figure 3 — Killing the job at each boundary in turn converts “is the retry correct?” into a table with one row per stage. A gap in the rewind list shows up as exactly one failing row, and that row identifies it.

When to escalate

  • The fingerprints differ at one specific stage boundary and nowhere else. The rewind list is incomplete for that stage — it writes something outside the directory the list names, typically a cache or a log the next stage reads. Find it by diffing the two project directories after a divergent retry, and add it to STAGE_OUTPUTS.
  • Retries succeed but each one produces a slightly different orthomosaic. The non-determinism is inside the engine, not in the retry logic — usually thread-count-dependent summation order, as discussed in the reproducibility section of the fundamentals overview. Pin the worker count before concluding the retry is at fault.
  • Every retry rewinds to dataset and the fleet never makes progress. The completeness check is rejecting a valid output, most often because the file is a format _is_complete does not know how to open and falls through to the size test on a directory. Add the format rather than loosening the check.

Orchestrating Photogrammetry Jobs with Python Schedulers