Durable execution guide¶
Status: Phase 6 — experimental. Preview API in the optional
durableextra (pip install "xyberos[durable]"). Importing a module of the package does not warn; theExperimentalWarningis emitted bywarn_experimentalfrom unstable entry points, and the limits below are part of the contract rather than a disclaimer.
Durable execution persists Workflow progress in a SQLite journal, so work survives the process that was running it. Read the limits first, because they decide whether this is the right tool:
| Single host | Workers coordinate through one SQLite journal file. No quorum, no distributed lock, no multi-host ownership. |
| At-least-once | A persisted step result proves the step completed, not that its external effect happened exactly once. |
| One step per claim | A worker advances one step, then yields. That is what makes a crash boundary always a committed transaction. |
| No arbitrary checkpointing | Workflow code is never persisted. A resumed execution is handed its definition again and checked against the record. |
If you need exactly-once effects, safe multi-host execution, or arbitrary Python replay, this is not that, and the documentation says so wherever a reader might assume otherwise.
When to use it¶
Reach for durable execution when all three hold:
- the work is multi-step, so a partial result is worth keeping;
- the work outlives a process — a restart, a deploy, a crash, or a long wait;
- a step's side effects are recoverable: you can either repeat them safely, ask the outside world what happened, or accept that the run stops for a human.
Use an in-process Workflow when the work fits in one process and one lifetime, and a
plain await when there is nothing to resume.
The three moving parts¶
from xyberos.durable import JournalSettings, JournalStore, open_journal
from xyberos.durable.worker import DurableWorker, WorkerSettings
journal = await open_journal(JournalSettings(path="var/journal.db", lease_ttl_seconds=30.0))
store = JournalStore(journal)
handle = await store.submit(
workflow, # any validated xyberos Workflow
input={"invoice": "INV-1001"},
timeout=3600.0, # optional whole-run budget
execution_id="invoice-1001", # optional; a replay with the same work is idempotent
)
worker = DurableWorker(
store,
{"invoice:1": workflow}, # the definitions this worker can run
activities={"charge": ActivityPolicy()},
settings=WorkerSettings(owner="worker-1"),
)
await worker.serve() # or run_once() in a test
JournalStoreowns submission, claims, guarded transitions, and read-only inspection. It is the only thing that writes.DurableWorkerclaims work, runs one step, commits the outcome, and lets go. It holds no plan in memory, so a crash costs nothing but a claim.recover(...)(alsoworker.recover()) decides what an interrupted attempt means, and is the first thing a starting worker does.
The state machine¶
Execution states: PENDING, RUNNING, RETRYING, WAITING, and the terminals COMPLETED,
FAILED, CANCELLED, TIMED_OUT.
| Transition | Meaning |
|---|---|
PENDING/RETRYING → RUNNING |
a worker claimed the execution |
RUNNING → RUNNING |
recovery: an expired lease re-claimed under a strictly higher fence |
RUNNING → PENDING |
yield: the worker finished one step and returned the execution to the queue |
RUNNING → RETRYING |
a failed attempt was rescheduled |
RUNNING/PENDING/RETRYING → WAITING |
the execution is parked until something outside the worker acts |
WAITING is not claimable by accident: a parked execution is nobody's to take. RETRYING is: its
retry becomes claimable when it is due.
Step states: PENDING, RUNNING, SUCCEEDED, SKIPPED, FAILED, OUTCOME_UNKNOWN. The durable
vocabulary is deliberately separate from the in-process engine's StepStatus: durable states answer
"what survives a crash", not "what happened during one call".
What happens after a crash¶
Recovery reads the journal and decides, in this order:
| The journal says | What happens | Effect repeated? |
|---|---|---|
| no step was running | the execution is ready again; nothing is repeated | no |
| a step succeeded | its result is reused; the next step runs | no |
a step was running, and the step declares idempotent=True |
the attempt is retried | yes, by declaration |
| a step was running, and the step has a reconciler | the reconciler decides the step's state | only if it says so |
| a step was running, and the step declares neither | the step is OUTCOME_UNKNOWN and the execution is parked in WAITING |
no — nobody guesses |
| the definition is missing or changed | the execution fails as INCOMPATIBLE_DEFINITION |
no |
This is the failure table from PLAN.md section 5.3 expressed as behavior, and it is covered by a
process-kill harness: a real child process dies at each boundary and the parent asserts what the
journal kept.
Declaring what the executor may do¶
from xyberos.durable import ActivityPolicy, Reconciliation, StepState
# The effect is safe to repeat: an interrupted attempt is retried.
ActivityPolicy(idempotent=True)
# The effect can be checked with the outside world: it decides what happened.
def reconcile(step_id: str, step_input: object) -> Reconciliation:
if provider.charge_succeeded(step_input["invoice"]):
return Reconciliation(state=StepState.SUCCEEDED, output=charge_output(step_input))
return Reconciliation(state=StepState.PENDING, detail="nothing was charged; safe to retry")
ActivityPolicy(reconcile=reconcile)
# Nothing declared: an interrupted attempt parks the execution. This is the default, and the
# default is deliberately the safe one.
ActivityPolicy()
A reconciler must return the complete output the step would have returned. Downstream steps consume it, and the journal accepts a partial output without complaint — so a partial output is accepted now and fails later in an unrelated step. Build the output with the same function the step uses; the example does exactly that.
DurableOutcomeUnknownError deliberately does not derive from TransientError, so a Workflow retry
policy cannot silently repeat an effect of unknown status.
Cancellation¶
The request is persisted before anyone acts on it. Then:
- an execution nobody owns is cancelled immediately;
- a running execution is cancelled by its owner, which sees the request through a heartbeat and cancels the step's cancellation token, so cooperating code stops;
- once a request is persisted,
COMPLETEDcannot be recorded. A worker that raced the operator does not get to overwrite the request, and a stale worker cannot acknowledge it either.
Cooperation is required: a step that ignores its cancellation token keeps running until it returns.
Operating the journal¶
It is a separate file, and it has to be. Never point the journal at the application database, and
never at a network filesystem: the locking this design relies on is not dependable there. The
defaults differ from the application provider on purpose — synchronous="FULL", because a committed
transition surviving a crash is the whole point, and process death is not recoverable.
JournalSettings(
path="var/journal.db", # a file; an in-memory journal is refused
lease_ttl_seconds=30.0,
max_payload_bytes=64 * 1024,
synchronous="FULL",
)
Backup and restore¶
- Stop the workers, or accept that a copy taken under load may be missing the last commits.
- Copy the journal with
journal.db-walandjournal.db-shm, or runPRAGMA wal_checkpoint(TRUNCATE)first and copy the single file. A WAL file copied without its database is not a backup. - Restore means replacing the journal while no worker is running. Workers cache nothing, so no other
state has to be reset — but executions that were
RUNNINGwhen the copy was taken will be recovered from the copy, exactly as they would be after a crash.
Growth and retention¶
The journal grows with every step and every event, and it is designed to keep history. Decide retention per deployment:
execution_eventsis the fastest-growing table. Keep it if you need incident triage; delete rows for executions in a terminal state if you do not.executions,execution_steps, andworker_leasesare bounded by the number of executions; deleting a completed execution cascades to its steps, events, lease, and scheduled retry.- Deleting terminal executions is the supported way to bound the file, and it destroys the audit trail. Do it deliberately, and take a backup first if the history matters.
Inspection¶
view = await store.inspect("invoice-1001")
view.status # ExecutionStatus
view.current_step # the step a worker is running, if any
view.result # the persisted result, once completed
view.failure_kind # why it stopped, if it did
view.steps # per-step state, attempts, output, error type and message
view.events # the ordered timeline
view.retry_due_at # when a scheduled retry becomes claimable
stats = await store.stats()
stats.queued, stats.running, stats.retrying, stats.waiting
stats.completed, stats.failed, stats.cancelled, stats.timed_out
stats.expired_leases # > 0 means a worker is gone and its work is waiting to be recovered
stats.incompatible # executions stopped because the definition did not match
stats.oldest_lease_age_seconds
expired_leases counts only running executions a worker abandoned. A ready, retrying, or parked
execution carries a spent lease because the lease row holds the fencing token, and that is not an
orphan.
Incident triage¶
| Symptom | Look at | Likely cause |
|---|---|---|
work is not moving, expired_leases > 0 |
stats.expired_leases, then worker.recover() |
a worker died; recovery was never run, or the lease has not expired yet |
work is not moving, expired_leases == 0 |
view.retry_due_at, view.current_step |
a retry is not due yet, or a worker is holding the lease |
waiting > 0 |
view.steps[].state == OUTCOME_UNKNOWN |
an interrupted attempt needs a decision. Reconcile it, or resume it if you decide to repeat it |
an execution failed with incompatible_definition |
view.version, the registered fingerprint |
the worker does not have the definition the execution was submitted with |
| a step's effect happened twice | view.steps[].attempts, activity_policy |
the step was declared idempotent, or a retry policy covered it. Both are explicit choices |
DurableSchemaError at startup |
journal.version() |
the journal was written by a newer package. Upgrade; do not open it with an older binary |
Data sensitivity¶
Step inputs, outputs, and results are stored verbatim as JSON, because a resumed execution has to be replayable from its records alone. The journal therefore holds whatever a step produced.
- Redaction is yours: filter a step's output before returning it.
- This is where the AI and durability policies meet. If a step returns a model completion, the journal stores that completion, because a step's output is persisted verbatim. The redaction boundary is the step function, not the journal: filter the payload before returning it. See the AI guide's retention note.
max_payload_bytesis the only structural guard, and it prevents accidents rather than protecting data.- Events never carry step input, output, or exception messages, so the event table is safe to keep for longer than the payload columns.
- The failure table's
outcome_unknownpath exists so that an uncertain effect never turns into a silent retry. Treat the journal as production data with the same care as the application database.
Deployment topology¶
One journal file, one host, any number of worker processes on that host. Two hosts sharing a journal over a network filesystem is unsupported, and the framework does not detect it for you.
Workers are interchangeable: WorkerSettings(owner=...) only labels them for triage. Start workers
with worker.serve(), which recovers first, then loops with a bounded backoff, and stop them by
setting an asyncio.Event.
Deliberate limitations¶
- No exactly-once effects. Persisting a result is not proof an external effect was applied once.
- No multi-host ownership, quorum, or high availability.
- No arbitrary Python checkpointing or transparent replay. Workflow code is not persisted.
- No parallel steps. A claim advances one step; the graph rules are the in-process engine's.
- A required
versionper definition. Changing a definition in place is refused, so a change needs a new version. - Polling, not notifications. An idle worker wakes on its own backoff, bounded by
max_poll_interval.