The execution model
How Flux executes a workflow — events, checkpoints, replay, and the contract between workflow code and the engine.
A Flux workflow is a Python async function, but the runtime treats it as something more specific: a deterministic reduction over an append-only event log. The function is the recipe; the events are the ledger of what actually happened. Every guarantee Flux makes about resume, retry, and crash recovery comes from keeping those two things in sync.
This page walks through how that works. The previous page, Why durable execution, argued that durable replay is necessary for long-running work. This one explains how Flux implements it.
The event log is the source of truth
Each workflow execution has an ExecutionContext. The context carries the workflow’s input, its current state, and — most importantly — a list of ExecutionEvent objects. The list is append-only during a run. Every meaningful transition produces one event.
The full set of event types lives in flux/domain/events.py. The workflow-level events are:
WORKFLOW_SCHEDULED,WORKFLOW_CLAIMED— a worker has picked up the execution.WORKFLOW_STARTED— the workflow function has been called.WORKFLOW_COMPLETED,WORKFLOW_FAILED,WORKFLOW_CANCELLED— terminal outcomes.WORKFLOW_PAUSED,WORKFLOW_RESUMING,WORKFLOW_RESUMED— the pause/resume cycle.WORKFLOW_RESUME_SCHEDULED,WORKFLOW_RESUME_CLAIMED— the worker handshake for a resume.WORKFLOW_CANCELLING— cancel was requested but has not yet taken effect.
The task-level events are:
TASK_STARTED,TASK_COMPLETED,TASK_FAILED— the core task lifecycle.TASK_PAUSED,TASK_RESUMED,TASK_PROGRESS— pause from inside a task, plus user-emitted progress updates.TASK_RETRY_STARTED,TASK_RETRY_COMPLETED,TASK_RETRY_FAILED— one event per retry attempt.TASK_FALLBACK_STARTED,TASK_FALLBACK_COMPLETED,TASK_FALLBACK_FAILED— fallback handler lifecycle.TASK_ROLLBACK_STARTED,TASK_ROLLBACK_COMPLETED,TASK_ROLLBACK_FAILED— compensation lifecycle.
A successful first run of a tiny workflow with two tasks looks like this:
time
│ WORKFLOW_SCHEDULED (worker picks up the execution)
│ WORKFLOW_CLAIMED
│ WORKFLOW_STARTED input = {"order_id": "A-123"}
│ TASK_STARTED fetch_order args={"order_id":"A-123"}
│ TASK_COMPLETED fetch_order value={"total": 49.00, ...}
│ TASK_STARTED charge_card args={"total": 49.00, ...}
│ TASK_COMPLETED charge_card value={"receipt": "r_abc"}
▼ WORKFLOW_COMPLETED value={"receipt": "r_abc"}
Every line in that diagram is one row that will be persisted before the workflow is considered done. That persistence is the entire mechanism — there is no separate “checkpoint file” or “state snapshot.” The act of recording a TASK_COMPLETED event to durable storage is the checkpoint.
The workflow function is a deterministic reduction
When a workflow is scheduled, Flux constructs an ExecutionContext and invokes the workflow function with it. The function calls one task after another. Each await task(...) does two things, in this order:
- Compute a stable
task_idfrom the task name and its arguments. - Scan
ctx.eventsfor any priorTASK_COMPLETEDorTASK_FAILEDevent matching thattask_id.
On a fresh run, the scan finds nothing. The task body runs, emits a TASK_STARTED event, runs to completion, and emits TASK_COMPLETED carrying the task’s output. The output is also returned to the workflow function, which then proceeds to the next await. Once the workflow function returns, the runtime appends WORKFLOW_COMPLETED.
On a resume, the scan finds something. The task body is not re-entered. Flux deserializes the stored output, returns it directly, and the workflow function moves on as if the task had just run. From the workflow’s perspective, there is no observable difference between “the task just ran” and “the task ran two days ago on a different worker.” That is the whole trick.
This is why we call the workflow a deterministic reduction over the event log. The events are the facts. The workflow function is the rule for combining those facts into the next step. Given the same event prefix and the same workflow code, the function will always reach the same next await — and that next await is what determines what gets recorded next.
First run vs. replay
The same code path handles both. On every await task(...), Flux looks for a matching completion event before running the task. The lookup happens in task.__call__:
events_for_this_task_id = [
e for e in ctx.events
if e.source_id == task_id
and e.type in (TASK_COMPLETED, TASK_FAILED)
]
if events_for_this_task_id:
return events_for_this_task_id[0].value # replay
# otherwise, run the task body
The difference between “first run” and “replay” is just whether the lookup hits. There is no separate replay mode and no TASK_REPLAYED event — replay is silent at the task level. The only thing that distinguishes a resumed run in the log is the WORKFLOW_RESUMING and WORKFLOW_RESUMED events that bracket the resume itself.
Here is what a resume looks like for the same workflow above, where the worker crashed after fetch_order completed but before charge_card ran. Events from the first run are still in the log; resume appends the new ones:
time
│ WORKFLOW_SCHEDULED ← first run starts
│ WORKFLOW_CLAIMED
│ WORKFLOW_STARTED input = {"order_id": "A-123"}
│ TASK_STARTED fetch_order
│ TASK_COMPLETED fetch_order value={"total": 49.00, ...}
│ ... ← worker dies here, before charge_card runs
│
│ WORKFLOW_RESUMING ← second worker picks up the execution
│ WORKFLOW_RESUME_SCHEDULED
│ WORKFLOW_RESUME_CLAIMED
│ WORKFLOW_RESUMED
│ TASK_STARTED charge_card ← fetch_order is NOT re-run; its TASK_COMPLETED
│ TASK_COMPLETED charge_card is already in the log and Flux returns it
▼ WORKFLOW_COMPLETED
The function runs from the top on resume. It hits await fetch_order(...), finds the existing TASK_COMPLETED, gets the recorded output back, and moves on. It hits await charge_card(...), finds nothing, and actually runs the task. The function does not skip forward. It re-executes everything between the awaits — the variable assignments, the conditionals, the loops, the helper calls — because that is what produces the next await correctly.
Determinism matters because of that re-execution between awaits.
Why determinism is load-bearing
Consider this workflow:
import random
from flux import ExecutionContext
from flux.task import task
from flux.workflow import workflow
@task
async def variant_a(): ...
@task
async def variant_b(): ...
@workflow
async def pick_a_path(ctx: ExecutionContext):
if random.random() < 0.5:
return await variant_a()
else:
return await variant_b()
On the first run, suppose the branch picks variant_a. A TASK_COMPLETED event is recorded with source_id derived from the name variant_a and its arguments.
On resume, the workflow function runs from the top again. It calls random.random() again, which returns a different number. This time the branch picks variant_b. Flux looks for a TASK_COMPLETED event matching variant_b’s task_id — and finds none. So it runs variant_b from scratch. The recorded TASK_COMPLETED for variant_a is now an orphan: an event in the log that no path through the code is asking about anymore.
The workflow’s behavior is now non-replayable. Worse, the user gets one outcome on the first run and a different outcome on resume.
The fix is not to outlaw randomness — Flux ships flux.tasks.choice, flux.tasks.randint, and flux.tasks.uuid4 for exactly this reason. The fix is to put the non-deterministic step inside a task, because task outputs are recorded:
from flux.tasks import choice
from flux import ExecutionContext
from flux.workflow import workflow
@workflow
async def pick_a_path(ctx: ExecutionContext):
path = await choice(["a", "b"])
if path == "a":
return await variant_a()
else:
return await variant_b()
Now choice is a task. Its output is captured in a TASK_COMPLETED event on the first run. On resume, the cached output is returned — so the branch picks the same path it picked the first time, and variant_a (or variant_b) is found in the log exactly where it was recorded.
The same rule applies to datetime.now(), reading environment variables, hitting a network resource, or anything else whose value can shift between runs. The next page, Determinism, goes through the full constraint and the standard escape hatches.
What happens when a task fails
A task body can throw. The runtime catches the exception in task.__call__ and runs through three escape hatches in order, each of which produces its own events:
-
Retry. If the task was decorated with
retry_max_attempts > 0, Flux retries the body up to that many times with exponential backoff. Each attempt emitsTASK_RETRY_STARTEDand then eitherTASK_RETRY_COMPLETED(with the output) orTASK_RETRY_FAILED. If a retry succeeds, the surrounding lookup-and-return path still produces a finalTASK_COMPLETEDfor the task. -
Fallback. If retries are exhausted (or there were none) and the task has a
fallback=handler, Flux invokes the handler and emitsTASK_FALLBACK_STARTEDfollowed by eitherTASK_FALLBACK_COMPLETEDorTASK_FALLBACK_FAILED. A successful fallback also produces aTASK_COMPLETEDfor the original task — the workflow function gets the fallback’s return value as if nothing had gone wrong. -
Rollback. If there is no fallback and the task has a
rollback=handler, Flux invokes it and emitsTASK_ROLLBACK_STARTEDfollowed by eitherTASK_ROLLBACK_COMPLETEDorTASK_ROLLBACK_FAILED. Rollback is meant for cleanup — it runs after the task has already failed terminally, and aTASK_FAILEDevent is emitted in addition.
The chain — retry, then fallback, then rollback — is recorded in the event log just like everything else. That means a resumed workflow can pick up mid-recovery: if the worker died after TASK_RETRY_COMPLETED but before WORKFLOW_COMPLETED, the resume sees the retry success and never re-enters the task. See Retry policy, Fallback, and Rollback and compensation for the API and the patterns.
The checkpoint is not a separate thing
It is tempting to imagine a “checkpoint” as a snapshot file written out alongside the events. There isn’t one. A checkpoint, in Flux, is just the moment a TASK_COMPLETED (or TASK_FAILED, or any other event) is durably persisted to the event store. The ctx.checkpoint() call that runs at the end of every task is exactly the I/O that flushes new events through the registered checkpoint callable — over HTTP to the server in distributed mode, or directly to the database when running inline.
That is the entire durability mechanism: append an event, fsync it. On the next start, the event is there. If the process crashed before the fsync, the event is not there and Flux will treat that task as not-yet-completed when the workflow resumes. This is why idempotency matters for any task that touches an external system: the narrow window between “task body returned” and “event is fsynced” is exactly where a duplicate side effect can hide.
Where the events live
In dev, the event store is SQLite at sqlite:///.flux/flux.db (the default database_url in flux/config.py). In production, you would point Flux at PostgreSQL — the same schema works under either backend, and flux/models.py defines the tables for both. The ExecutionEventModel row is what gets written when ctx.checkpoint() runs.
We will not labor over the storage layout here; the Storage model page covers the schema, the indexes, and how the worker queries the event store on claim.
What to remember
- The event log is the source of truth; the workflow function is a deterministic reduction over it.
- There is no separate checkpoint artifact; persisting a
TASK_COMPLETEDevent serves as the checkpoint. - Replay reuses cached task outputs and silently skips task bodies; there is no
TASK_REPLAYEDevent because replay is just a lookup hit onTASK_COMPLETED. - On resume, the workflow function runs from the top and re-executes everything between the awaits — so anything that produces a different value between runs must live inside a task.
- Retry, fallback, and rollback are first-class chains, each emitting their own events; a resumed workflow can pick up partway through any of them.
Where this shows up
- Determinism — the constraint on workflow code that makes replay work, with the full list of escape hatches.
- Defining workflows — the API surface for writing the functions this page describes.
- Idempotency — what happens when a task’s side effect is replayed; how to keep it safe.
- Storage model — where events are persisted and how the worker reads them back.