How to Structure a DAG for Raster Processing
A raster DAG has five stages and only one of them fans out. Inspect reads headers and decides; plan writes a manifest; process runs one task per tile under a concurrency limit; reduce assembles by reference; publish makes the result visible. Every stage has a different failure mode and a different memory profile, which is why they are five nodes rather than one, and the shape holds whether the job is one scene or three hundred.
When to Use This Pattern
- A raster job is larger than one worker’s memory, so it must be tiled rather than processed whole.
- The pipeline runs on a schedule and has to be resumable when a night goes badly.
- Sources vary in size by orders of magnitude, which makes a fixed structure unworkable.
- More than one person operates it, so the run needs to be legible without reading the code.
Complete Working Example
The five stages, with the guards that stop each one from surprising its operator.
from __future__ import annotations
import json
from dataclasses import asdict
from pathlib import Path
from typing import Optional
from prefect import flow, task, get_run_logger
from prefect.concurrency.sync import concurrency
@task(retries=2, timeout_seconds=300)
def inspect(scene_uri: str, dst_crs: str) -> SceneFacts:
"""Stage 1 — headers only. Nothing here reads a pixel."""
facts = read_scene_facts(scene_uri, dst_crs) # megapixels, footprint, CRS, bands
get_run_logger().info(
"scene inspected: %.0f Mpx, %s → %s", facts.megapixels, facts.src_crs, dst_crs
)
return facts
@task(timeout_seconds=300)
def plan(facts: SceneFacts, zoom: int, manifest_path: Path) -> list[TileItem]:
"""Stage 2 — the graph's width, decided from the footprint and recorded."""
items = [
TileItem(zoom, t.x, t.y, facts.uri, facts.digest)
for t in tiles_intersecting(facts.footprint, zoom) # footprint, not bbox
]
manifest_path.write_text(json.dumps([asdict(i) for i in items]))
if not items:
raise ValueError("empty manifest — the footprint and the zoom disagree")
if len(items) > 20_000:
raise ValueError(f"{len(items)} tiles is implausible — check the zoom")
return items
@task(retries=2, retry_delay_seconds=[15, 60], timeout_seconds=900, tags=["tile"])
def process(item: TileItem, scratch: Path) -> Optional[str]:
"""Stage 3 — the only stage that fans out. Idempotent and bounded."""
dest = scratch / f"{item.z}_{item.x}_{item.y}.tif"
if ledger_has(item.work_key):
return str(dest)
with concurrency("scene-reads", occupy=1):
warp_window(item, dest)
ledger_mark(item.work_key)
return str(dest)
@task(retries=1, timeout_seconds=3600)
def reduce(paths: list[Optional[str]], expected: int, dest: Path) -> Path:
"""Stage 4 — many to one, by reference. Never holds the mosaic in memory."""
present = [p for p in paths if p]
coverage = len(present) / expected
if coverage < 0.995:
raise RuntimeError(f"coverage {coverage:.3f} below threshold — not assembling")
build_vrt(present, dest.with_suffix(".vrt"))
translate(dest.with_suffix(".vrt"), dest) # one sequential pass
add_overviews(dest)
return dest
@task(retries=2, timeout_seconds=1800)
def publish(mosaic: Path, coverage: float, key: str) -> str:
"""Stage 5 — the commit point, with provenance attached to the artefact."""
upload_with_metadata(mosaic, key, metadata={"tile-coverage": f"{coverage:.4f}"})
invalidate_downstream(key)
return key
@flow(name="raster-scene", timeout_seconds=7200)
def raster_scene(scene_uri: str, zoom: int, scratch: Path,
dst_crs: str = "EPSG:3857") -> str:
facts = inspect(scene_uri, dst_crs)
items = plan(facts, zoom, scratch / "manifest.json")
states = process.map(items, scratch=scratch, return_state=True)
paths = [s.result() if s.is_completed() else None for s in states]
mosaic = reduce(paths, expected=len(items), dest=scratch / "mosaic.tif")
return publish(mosaic, coverage=sum(p is not None for p in paths) / len(items),
key=f"mosaics/{facts.digest}.tif")
The retry settings differ per stage for a reason worth stating, because a uniform retries=2 across a flow is the default almost everybody starts with. Inspect talks to a network and its failures are overwhelmingly transient, so retrying is nearly free and usually works. Plan is pure computation over values already in hand, so a failure is a bug or a bad footprint and a retry reproduces it exactly — the only effect is to delay a legitimate error by a minute. Reduce is expensive and idempotent, so one retry is worth having and two is a long time to spend on a run that is probably wrong. Matching the retry policy to what each stage can actually fail on is a five-minute exercise that removes a surprising amount of noise from a pipeline’s error rate.
Parameter & Option Reference
| Stage | Retries | Timeout | Spatial notes |
|---|---|---|---|
| inspect | 2 | 5 min | Header reads over a network; transient failures are normal and retrying is free. |
| plan | 0 | 5 min | Deterministic. A failure here is a bug or a bad footprint, and a retry repeats it. |
| process | 2 | 15 min | Per tile. Combined with the fan-out this is the real load multiplier on the source. |
| reduce | 1 | 60 min | One long sequential pass. Retrying is safe because it writes to a temporary path. |
| publish | 2 | 30 min | Object-store writes fail transiently; the operation is idempotent by key. |
| fan-out guard | — | — | Empty and implausible manifests both raise, and both are cheap to check. |
| coverage floor | 0.995 | — | The reduction refuses rather than assembling a mosaic with holes. |
Verification & Testing
The tests worth writing are about the graph’s shape and its guards, not about the warping.
from prefect.testing.utilities import prefect_test_harness
def test_reduce_waits_for_every_tile(monkeypatch, tmp_path) -> None:
order: list[str] = []
monkeypatch.setattr("mymodule.warp_window", lambda *a: order.append("process"))
monkeypatch.setattr("mymodule.build_vrt", lambda *a: order.append("reduce"))
with prefect_test_harness():
raster_scene("fixtures/scene.tif", zoom=12, scratch=tmp_path)
assert order.index("reduce") > order.rindex("process")
def test_implausible_fanout_is_refused(tmp_path) -> None:
facts = SceneFacts(uri="x", footprint=world_footprint(), digest="d")
with pytest.raises(ValueError, match="implausible"):
plan.fn(facts, zoom=18, manifest_path=tmp_path / "m.json")
def test_low_coverage_does_not_publish(tmp_path) -> None:
paths = [str(tmp_path / f"{i}.tif") for i in range(300)] + [None] * 112
with pytest.raises(RuntimeError, match="coverage"):
reduce.fn(paths, expected=412, dest=tmp_path / "mosaic.tif")
Against a real run, three log lines tell an operator everything about the shape without opening the UI. Emitting them deliberately — rather than relying on whatever the orchestrator prints — is what makes the run legible in a terminal:
scene inspected: 8420 Mpx, EPSG:32633 → EPSG:3857
manifest: 412 tiles at z12 (footprint 61% of bbox)
reduce: 412/412 present, coverage 1.0000 → mosaics/sha256-ab12….tif
Those three lines also make the run comparable across nights, which is worth more than it first appears. The megapixel figure catches a source that changed resolution; the footprint-to-bounding-box ratio catches a footprint that has quietly become a rectangle because the metadata lost its geometry; and the coverage figure catches everything else. Diffing last night’s three lines against tonight’s takes seconds and detects a category of change that no threshold was set for, because it is the numbers themselves rather than an alert on them.
Common Pitfalls
- Collapsing inspect and plan into the fan-out. Without a separate planning stage the manifest is never written, the width is never logged, and a wrong zoom becomes a hundred thousand scheduled tasks rather than a raised exception.
- A reduce stage that reads everything into memory. It reintroduces exactly the peak the tiling removed, on the one stage nobody sized. Build a VRT and translate once.
- Retrying the plan stage. It is deterministic, so a retry reproduces the failure and delays the error by a minute. Retries belong on the stages that touch a network.
- No coverage check before assembling. A mosaic built from 300 of 412 tiles is a valid raster with a hole, and every consumer downstream will treat the hole as genuinely empty ground.
- Publishing before the mosaic is complete. The publish stage must depend on the reduce stage’s return value, not merely follow it in the source, or the two will race on anything larger than a test fixture.
- One timeout for all five stages. A five-minute inspect and a sixty-minute reduce need different limits, and a single flow-level number either kills the reduce or lets a hung inspect run for an hour.
Frequently Asked Questions
Should the five stages be five flows or five tasks?
Five tasks in one flow, with the flow being the scene. That keeps the run inspectable as a unit and makes the scene the thing an operator re-runs. Splitting into five flows adds orchestration between stages that share a scratch directory and a lifetime, which buys nothing and costs a coordination problem.
Where does caching fit?
At the process stage, keyed on the content of the inputs, which is what makes an unchanged scene almost free to re-run. The inspect stage’s output is worth caching too — a footprint intersection for an unchanged scene is deterministic — but the win there is seconds rather than hours. See caching strategies for spatial tasks.
How does this change for a multi-scene mosaic?
Add a level: a flow per mosaic that runs this flow per scene and then reduces across scenes. Two levels of reduction is normal for a national product, and keeping them separate means a single bad scene does not force the whole mosaic to reassemble — the outer reduce reads whatever the inner ones produced.
What if the scene is small enough not to need tiling?
Then the process stage has one item and everything above still works, at a cost of a few hundred milliseconds of orchestration. That is the argument for keeping the shape rather than branching: a single-tile fan-out is nearly free, and having one code path for both sizes removes a whole class of “it works on the small ones” surprises. Where the difference genuinely matters, branching on spatial extent makes the choice explicit.
Related
- DAG design principles for spatial ETL — why the shape looks like this
- Parametrizing spatial DAGs by tile index — the partition key the fan-out uses
- Collecting mapped results into a single mosaic — the reduce stage in detail
- Limiting DAG fan-out with concurrency groups — bounding stage three