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, for a deployment that runs only Postgres, the self-hosted tier on the database you already operate.
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 Postgres¶
The external engines are operational dependencies — a Temporal cluster or the Inngest service. For the common deployment that runs only Postgres, the self-hosted tier gives you the functions form (memoized steps + crash recovery) and crash-resumable sagas on the same database, with no engine to stand up.
It reuses the journaled-progress model, backed by two app-provided tables: 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 with FOR UPDATE SKIP LOCKED). Wire both
on the Postgres 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)})
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 FAILED with the deadline reason, so
a body hung on a dead peer can't pin a recovery slot forever. Set it above your longest
body, or None to remove the cap; re-enqueue a deadline-failed 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.
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), 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-Postgres only;
a full workflow engine (timers, signals, versioning) is still Temporal/Inngest. The two
tables come from your migrations; their schema 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=…)
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.