Prefect Flow State Transitions Explained
A Prefect run’s state is a claim about execution, not about output, and for a spatial pipeline those come apart in a specific and expensive way: a flow can finish Completed having produced a mosaic with a hole in it. Understanding which transitions are possible — and which of them your fan-out can reach without failing — is what lets you make the final state mean “the product is right” rather than “the code returned”.
When to Use This Pattern
- A mapped fan-out can partially fail, which is every tile pipeline.
- Alerting is driven by run state, so the state has to be trustworthy.
- Retries and timeouts are in play, and their interaction with states is not obvious.
- Somebody outside the team reads the run history and takes the colour at face value.
Complete Working Example
The states that matter, and a final-state rule that reflects the product rather than the execution.
from __future__ import annotations
from prefect import flow, task, get_run_logger
from prefect.states import Completed, Failed
@task(retries=2, retry_delay_seconds=[15, 60], timeout_seconds=900, tags=["tile"])
def build_tile(tile: Tile) -> str:
return render(tile)
@flow(name="tile-build", timeout_seconds=7200)
def tile_build(tiles: list[Tile], coverage_floor: float = 0.995):
# return_state=True is the whole trick: without it, one failed mapped task
# raises and the flow ends before the reduction can judge the shortfall.
states = build_tile.map(tiles, return_state=True)
done = [s for s in states if s.is_completed()]
lost = [t for t, s in zip(tiles, states) if not s.is_completed()]
coverage = len(done) / len(tiles)
log = get_run_logger()
log.info("tiles: %d/%d built, coverage %.4f", len(done), len(tiles), coverage)
for t in lost[:20]:
log.warning("tile missing: %s", t.path)
if coverage < coverage_floor:
# An explicit Failed carries a message an operator can act on, which the
# default propagated exception does not.
return Failed(message=f"coverage {coverage:.4f} < {coverage_floor}: "
f"{len(lost)} tile(s) missing, first {lost[:5]}")
mosaic = assemble([s.result() for s in done])
return Completed(message=f"coverage {coverage:.4f}", data=mosaic)
Crashed is the state most worth distinguishing, and the one most alerting rules lump in with Failed. A crash means the process disappeared — an out-of-memory kill, a reclaimed spot instance, a node drain — so no exception handler ran, no cleanup happened and no message was recorded. That points at capacity or infrastructure, and the response is to resize, reschedule or resume. A Failed run raised something, which points at data or logic and needs a person to read the message. Routing both to the same channel with the same severity teaches everyone to ignore the channel.
Parameter & Option Reference
| State | What it means | Spatial response |
|---|---|---|
Scheduled |
waiting for its time or a free slot | A long dwell here means the work pool or a concurrency limit is the bottleneck. |
Running |
a process is executing it | With a flow-level timeout set, a stuck run leaves here on its own. |
Completed |
the callable returned | Says nothing about coverage. Attach a message with the number. |
Failed |
an exception, or an explicit Failed |
Where the coverage floor lands. Include the missing count in the message. |
Crashed |
the process died without returning | Infrastructure. Resume rather than debug; see the resume path. |
Cancelled |
somebody stopped it | Cleanup does not run, so scratch and slots may need collecting. |
AwaitingRetry |
a task run will run again | Repeated visits are the earliest sign an upstream source is degrading. |
Verification & Testing
from prefect.testing.utilities import prefect_test_harness
def test_partial_failure_fails_the_flow(monkeypatch) -> None:
monkeypatch.setattr("mymodule.render", flaky(fail_every=8))
with prefect_test_harness():
state = tile_build(make_tiles(412), return_state=True)
assert state.is_failed()
assert "coverage" in state.message and "missing" in state.message
def test_full_success_reports_the_number(monkeypatch) -> None:
monkeypatch.setattr("mymodule.render", lambda t: t.path)
with prefect_test_harness():
state = tile_build(make_tiles(50), return_state=True)
assert state.is_completed() and "1.0000" in state.message
def test_retries_do_not_hide_a_persistent_failure(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr("mymodule.render", lambda t: calls.append(t.path) or fail())
with prefect_test_harness():
state = tile_build(make_tiles(3), return_state=True)
assert state.is_failed()
assert len(calls) == 9, "each tile should be attempted three times, not more"
The third test is worth keeping because retry counts are easy to change and hard to notice. A retries=2 on a task inside a flow that is itself retried twice produces nine attempts per tile, not three, and against a rate-limited upstream that is the difference between a run that finishes and a run that gets everybody’s requests refused for an hour. Asserting the total attempt count pins the interaction that no single setting expresses.
There is a related asymmetry between task state and flow state that catches people out. A task run’s state is authoritative about that task: it retried twice, it eventually raised, here is the exception. A flow run’s state is a summary, and by default the summary is “did anything escape”. That default is right for a linear pipeline where any failure is fatal, and wrong for a fan-out where partial failure is the normal case and the interesting question is how partial. Returning an explicit state is how you replace a summary you did not choose with one you did.
The same reasoning applies to what goes in the message. Prefect will store whatever string you return, and it appears in the run list, in notifications and in the API — so it is the highest-leverage sentence in the whole pipeline. The habit worth forming is to write it for somebody who has not seen the code: a count, a threshold, and one concrete example of what is missing. That is three values the flow already has, and it turns a run history into something that can be scanned rather than investigated.
Common Pitfalls
- Mapping without
return_state=True. The first failed tile raises and the flow ends before any coverage judgement can be made. - Treating
Completedas “the product is right”. It means the callable returned; the coverage rule is what connects the two. - Alerting on
CrashedandFailedidentically. One is capacity, one is data, and they need different people. - Retrying at both levels. Attempts multiply, and the multiplication lands on somebody else’s server.
- No flow-level timeout. A run stuck in
Runningoccupies its slot indefinitely and never reaches a state anything can alert on. - Discarding the state message. A
Failedwith a bare traceback and aFailednaming twelve missing tiles cost the same to produce.
Frequently Asked Questions
Why not raise an exception instead of returning `Failed`?
Both fail the run; the returned state carries a message you wrote. In practice that message is what somebody reads first at three in the morning, and “coverage 0.9709 < 0.995, 12 tiles missing, first 12/2147/1398” is a much better opening line than a stack trace from the reduction.
Does `return_state=True` change how failures are recorded?
No. The task runs still record their own states; you are only choosing to inspect them rather than let the first failure propagate. The mapped tasks are identical either way, which is what makes this a reporting decision rather than an execution one.
How do I get the failed tiles out of the states?
Zip the states back against the inputs, as in the example. Prefect does not attach the input to the state, so the correspondence is positional — which is fine, and is a good reason not to filter or reorder the manifest between the map call and the inspection.
Does a cancelled run leave anything behind?
Usually yes, and it is worth knowing what. Cancellation stops the process without running cleanup, so scratch files remain, concurrency slots may take their decay interval to free, and any half-written output sits where it was. None of that breaks a well-built pipeline — checkpoints are verified on read and writes are idempotent — but it does mean a cancelled run should be followed by a resume rather than assumed to have left nothing.
Should the coverage floor differ by layer?
Usually. A basemap can tolerate a tile missing until the next run; a flood-extent layer cannot. Making the floor a parameter with a per-layer default puts the judgement where the layer is defined rather than in the flow, and see data quality SLOs for spatial pipelines for choosing the numbers.
How do subflows affect the final state?
A subflow’s state propagates like a task’s: if it fails and nothing catches it, the parent fails. The trap is that a subflow per scene, each with its own coverage rule, gives a parent that fails when any scene falls short — which is usually not what a multi-scene product wants. Collect the subflow states in the parent and apply a product-level rule over them, exactly as the example does over tasks.
What about Dagster's equivalent?
Materialisation rather than state: a partition is materialised or it is not, so the coverage question is a query against the record instead of an inspection of return values. Prefect vs Dagster for GIS workloads covers what that changes.
Related
- Prefect vs Dagster for GIS workloads — the model these states belong to
- How to structure a DAG for raster processing — where the coverage check sits in the graph
- Limiting DAG fan-out with concurrency groups — why multiplied retries matter
- Alerting on dead-letter queue growth — where the failed tiles go next
- Resuming a failed mosaic from its last checkpoint — the response to a crash