Durable execution
Some work outlives the request that starts it: a multi-step fulfilment that runs for days, retries a flaky payment, waits on a human approval, and must survive a deploy or a crash in the middle. In-process sagas coordinate steps within one process; durable execution runs the orchestration against a store that persists every step — so a crash resumes exactly where it left off, not from the top. That store can be an external engine (Temporal / Inngest) or the self-hosted tier on the database you already operate — Postgres or MongoDB.
The mental model: journaled progress¶
A durable workflow is ordinary code whose progress the engine journals. Each step's result is recorded, so after a crash the engine replays the workflow and skips the steps already done — the slow external calls, the timers, the waits resume rather than repeat. You write the orchestration; the engine owns the durability, retries, and timers. That's the difference from a queue task, which runs once with basic redelivery and no memory of where it was.
Three forms¶
- Workflows — multi-step, long-running, and observable: a
startreturns a handle immediately, and a query port reads coarse status, the typed result, or in-flight state. Signals and updates push messages into a running workflow. - Schedules — fire a workflow on a cron or interval; the durable counterpart to a queue's delayed jobs.
- Functions — event-triggered work composed of individually-retried, memoized steps (the Inngest model).
A workflow start returns a handle you observe through the query port:
handle = await workflows.start(FulfilOrder(order_id=order_id), workflow_id=f"fulfil-{order_id}")
run = await queries.describe(handle) # coarse status: RUNNING / COMPLETED / FAILED / …
result = await queries.result(handle) # the typed return value, once complete
A stable workflow_id makes start idempotent — the same id won't launch a second run.
Self-hosted on your own database¶
The external engines are operational dependencies — a Temporal cluster or the Inngest service. For the common deployment that runs one database and no engine, the self-hosted tier gives you the functions form (memoized steps + crash recovery) and crash-resumable sagas on that database, with nothing to stand up. Postgres and MongoDB both implement it, behind the same ports and the same runner.
It reuses the journaled-progress model, backed by two app-provided relations: a
durable_step memo journal (each step's result recorded so a replay skips it) and a
durable_run run store (run instances, claimed for recovery — under
FOR UPDATE SKIP LOCKED on Postgres, under a token-stamped batch claim on Mongo, which has
no row locks to take). Wire both on your database's module and drive them with the
forze_kits runner:
deps = PostgresDepsModule(
client=client,
durable_step=PostgresDurableStepConfig(relation=("public", "durable_step")),
durable_run=PostgresDurableRunConfig(relation=("public", "durable_run")),
)
registry = DurableFunctionRegistry()
registry.register("fulfil-order", fulfil_order) # async (ctx, input) -> output
runner = DurableFunctionRunner(registry=registry)
await runner.enqueue(ctx, "fulfil-order", {"order_id": str(order_id)})
On MongoDB the wiring is the same shape, over collections instead of tables:
from forze_mongo.execution.deps import MongoDurableRunConfig, MongoDurableStepConfig
deps = MongoDepsModule(
client=client,
durable_step=MongoDurableStepConfig(collection=("app", "durable_step")),
durable_run=MongoDurableRunConfig(collection=("app", "durable_run")),
)
The run collection needs one index — a partial unique index on idempotency_key — or two
simultaneous submits of one key can both insert. See
MongoDB → durable execution for it and the two optional ones.
A registered function does its work in steps via the step port; each step memoizes, so a re-invocation after a crash replays completed steps and resumes at the first incomplete one:
async def fulfil_order(ctx, input):
step = resolve_durable_step(ctx)
charge = await step.run("charge", lambda: charge_card(ctx, input)) # journaled once
await step.run("ship", lambda: ship(ctx, charge))
return {"shipped": True}
A background scanner re-claims runs abandoned by a crash and re-invokes them —
durable_recovery_background_lifecycle_step(runner=runner). It is multi-worker-safe:
concurrent scanners never claim the same run (FOR UPDATE SKIP LOCKED) and a terminal
write is fenced against a reclaimed lease, so a stalled worker whose lease expired can't
finish a run the new owner already took over. A still-executing run heartbeats its lease
alive, so a long body is never reclaimed mid-flight — bounded by the runner's
max_run_duration (default 1 hour): past the cap the body is cancelled while the lease is
still held (nothing double-executes) and the run lands TIMED_OUT with the deadline reason,
so a body hung on a dead peer can't pin a recovery slot forever. TIMED_OUT is its own
terminal rather than a flavour of FAILED because the two send you to different places —
FAILED to the body's code, TIMED_OUT to the cap, the workload's size, or a hung peer. Set
it above your longest body, or None to remove the cap; re-enqueue a timed-out run to retry
it. Run the scanner on every replica, or pair it with the singleton lifecycle guard to elect one.
max_concurrency bounds how many runs a sweep recovers at once. Enqueue with
run_at=<when> for a delayed run — the scan skips it until it's due.
Multi-tenant. The stores resolve their table under the bound tenant. On a tagged
shared table (a tenant_id column), an unbound scanner recovers every tenant's runs and the
runner re-binds each run's tenant to execute it. On a namespace store (a per-tenant
relation resolver, one table per tenant schema) pass tenants=… to the lifecycle step: each
sweep binds every assigned tenant in turn and recovers its table — shard the tenant set across
instances to parallelize.
Each operation resolves one tenant and uses it for everything it decides — the table, the
row's tag and the scoped id together. enqueue(tenant_id=…) and a schedule record carrying a
tenant_id are therefore honoured in full, including which table the row lands in, so a
control plane bound to nothing can register work for a tenant and that tenant's scanner will
find it. Naming a tenant that contradicts a bound one is refused
(authentication / tenant_mismatch) rather than applied to some of the three.
What a bound caller reaches splits by what the verb does. The verbs taking a run id —
begin, renew, load and every terminal write — reach that tenant's runs and untagged
ones; the untagged arm is what keeps a run belonging to no tenant completable, since a
terminal write that matched nothing would leave it reclaimed and re-run forever. The verbs
that enumerate or control — the recovery scan, list_runs, request_cancel, refuse_cancel
— match the tenant exactly. Unbound reaches everything, which is what lets one sweep serve
every tenant.
Declaring required_tenant_isolation covers the durable routes: a durable_step,
durable_run or durable_schedule wired below the declared floor now fails at wiring rather
than passing on the strength of the other planes. Note the coupling that creates —
tenant_aware=True on a run store fails closed when nothing is bound, which is how the
cross-tenant sweep runs, so a deployment declaring tagged runs per-tenant recovery
(tenants=…) instead.
Stopping a run¶
A "Stop" button on a self-hosted run goes through request_cancel:
await runner.request_cancel(ctx, run_id) # True if the ask was recorded
It is cooperative, and the framework will not pretend otherwise. There is no in-process
red button in Python: a thread can't be killed, asyncio cancellation lands only at await
points, and a body blocked inside a C extension notices nothing until it returns. So this
requests a stop. A PENDING run lands CANCELLED at once — nothing is executing. A
RUNNING one keeps going until its lease holder sees the stamp on the next heartbeat
(lease_for / heartbeat_divisor, 100 s at defaults — tune those two if you need tighter),
at which point the body is cancelled at its next await and the run lands CANCELLED. A body
that never awaits is bounded only by max_run_duration; if you need bounded-latency stop,
structure the body for it (await regularly, run_cpu with checkpoints at chunk boundaries).
True hard kill exists only at process/container granularity and is a deployment concern.
CANCELLED is deliberately not FAILED: nothing is wrong with the code, so it should not
page anyone. Completed steps stay journaled, so re-enqueueing a cancelled run replays them
rather than redoing the work.
The ask is unfenced and the landing is fenced: anyone may ask, but only the run's current claim holder may write the terminal state, so a stalled replica can't cancel a run its new owner is midway through. If the holder dies with the stamp down, the recovery scan claims the run and lands it without invoking the body.
Cancelling a saga is decided by the pivot. Before it, compensations run (journaled, as
always) and the run lands CANCELLED — stopping mid-flight is safe precisely because
compensation exists. At or after it, the request is refused and the saga completes
forward: honouring it there would manufacture a FORWARD_INCOMPLETE by operator request.
The refusal is recorded on the run (cancel_refused_at) so the operator who pressed Stop and
watched it finish anyway gets the reason.
A run paused waiting on user input is a different case: its durable run already completed
cleanly (it succeeded at producing the question), so there is nothing to request_cancel.
Cancelling there is a domain action on your own task record, not a run-control call.
Engine-backed tiers advertise whether they can do any of this
(durable_run_control_capabilities(port).supports_cancel); runner.request_cancel fails
closed against one that can't, rather than accepting a request it would silently drop.
Observability. Pass DurableTelemetry.create() to the runner and scheduler for
OpenTelemetry: a durable.run span per execution plus forze.durable.runs /
forze.durable.run.duration (by name + outcome — completed / failed /
forward_incomplete / cancelled / timed_out / reclaimed / interrupted (the body never
finished — a drain or shutdown) / unrecorded (the
body finished but its terminal write went unacknowledged — the row may already be terminal or may
still await recovery, and the worker cannot tell),
forze.durable.recovered, and
forze.durable.schedule.fires metrics. Emits via the global OTel providers — configure the
SDK in your app.
The step journal is exactly-once for a step's recorded result — a completed step replays
from the journal instead of re-running. It is not exactly-once for arbitrary side
effects: a body can run more than once if a worker is reclaimed (its run lease expired
mid-body) or crashes before the result is journaled — the same at-least-once step guarantee
as Temporal / DBOS / Inngest. Keep step bodies idempotent (use an idempotency key on
external calls) for exactly-once effects. The replay guarantee is checked by
deterministic simulation: a seeded crash fault kills a run mid-step, the
recovery scanner re-invokes it, and the oracle asserts every completed step replays from its
journal instead of re-executing. Keep durable bodies deterministic too — read time / ids
through utcnow / uuid7 and do work in steps — and the simulator explores the crash-point
space for you.
Crash-resumable sagas¶
The self-hosted tier closes the "an in-process saga is not crash-resumable" gap. Swap the saga executor for the durable one and run the saga as a durable function — each step and each compensation is journaled, so a crash mid-saga (or mid-rollback) resumes instead of leaving committed steps un-compensated:
deps = SagaDepsModule(executor=DurableSagaExecutor()) # swap the seam
registry.register(str(saga.name), durable_saga_handler(saga, OrderCtx))
await runner.run_now(ctx, str(saga.name), initial.model_dump(mode="json"))
The saga context must be a serializable pydantic.BaseModel (it is journaled between
steps). A step failure classified retryable (infrastructure, throttled, concurrency) is
retried in place with backoff before it is journaled — compensation only runs on genuine
failures or exhausted retries, never a one-off blip. This tier is self-hosted only
(Postgres or MongoDB); a full workflow engine (timers, signals, versioning) is still
Temporal/Inngest. The relations come from your migrations; their schema — and the one index
the Mongo run store needs — is documented on the adapter classes.
Recurring schedules¶
A durable_schedule table + the DurableScheduler fire a run on a cron cadence. Put a
schedule and run the scheduler step (alongside the recovery step, which executes the runs it
enqueues):
deps = PostgresDepsModule(client=client, durable_run=…, durable_schedule=…)
# …or MongoDepsModule(client=client, durable_run=…, durable_schedule=…)
await scheduler.put(ctx, "nightly-report", "report", "0 3 * * *", tz="Europe/Berlin")
lifecycle = [
durable_scheduler_background_lifecycle_step(scheduler=scheduler),
durable_recovery_background_lifecycle_step(runner=runner),
]
await scheduler.remove(ctx, "nightly-report") unregisters a schedule so it never fires
again — distinct from pausing it (put it back with enabled=False, which keeps the row).
Or declare the cadence on the function and let it wire itself: a DurableFunctionSpec
with a DurableFunctionCronTrigger auto-registers its schedule when you pass the specs to
the step — no manual put:
spec = DurableFunctionSpec(
name="report",
run=DurableFunctionInvokeSpec(args_type=ReportArgs),
triggers=(DurableFunctionCronTrigger(expression="0 3 * * *"),),
)
durable_scheduler_background_lifecycle_step(scheduler=scheduler, specs=[spec])
Auto-registration is idempotent — a restart re-uses an unchanged schedule (so it never
resets next_fire_at and skips a due fire) and re-registers only when the cron changes.
Firing is fire-once / skip-missed: if the scheduler was down across several occurrences
it fires once and advances to the next future one (no backfill). It's exactly-once across
replicas — each fire enqueues a run keyed {schedule_id}:{fire_epoch} and the next fire is a
compare-and-set — so, like recovery, it's safe to run on every node. "Now" comes from the
TimeSource seam, so schedules are deterministic under simulation. Recurring schedules
are self-hosted here; a full engine's timers/signals/versioning are still Temporal/Inngest.
When to reach for it¶
| You need | Use |
|---|---|
| Multi-step work that must survive crashes, with status / retries / timers | durable execution |
| A single fire-and-forget task | a queue |
| Step coordination within one process or transaction | a saga |
To start a workflow reliably from a request — only if the write commits — stage it through the outbox instead of starting it directly.
The ports and dep keys are the durable reference; the worked flows are the background work and scheduled jobs recipes.