Multi-step work that outlives the process (experimental)¶
Tier: experimental. xyberos.durable is the one surface in this repository at the experimental tier
(the stability guide), and its contract terms are unusually precise, because
they are what a reader usually gets wrong:
- At-least-once effects. A step that ran may run again after a crash. Every effect a step performs has to be safe to repeat, or guarded by something that is.
- One host, one journal file. Workers share a journal; there is no multi-host ownership, no distributed lock, and no consensus.
- No exactly-once external effects, and no arbitrary Python checkpointing. A payload that can be journaled is a payload you can describe; a live object graph is not.
- The experimental boundary is visible in the API. Entry points emit an
ExperimentalWarningand the package metadata carries the label, so a deployment cannot adopt it by accident.
What it does provide is the thing the workflow engine explicitly does not: persisted progress, fenced single-owner claims, recovery after a crash, cancellation, and inspection of a run that is still going.
What it is for¶
An ordinary HTTP handler does not need this. Neither does a request that calls two services. Reach for it when the work is multi-step and must survive the process that started it — an approval that outlives a deploy, a sequence of external calls that must resume where it stopped, a unit of work somebody will ask about tomorrow.
| Mode | Guarantee | What it does not promise |
|---|---|---|
await direct(...) |
deadline and cancellation for one call | retries, inspection, branching |
await workflow.run(...) |
a validated graph, bounded retries, structured events | persistence, recovery |
xyberos.durable |
persisted progress, fenced claims, recovery, inspection | exactly-once effects, multi-host ownership |
What it looks like¶
examples/durable_workflow/app.py is the repository's worked example, exercised by the crash-recovery
suite:
"""Durable workflows: progress that outlives the worker that was running it.
Run it with ``python -m durable_workflow.app`` from the ``examples`` directory. This needs the
``durable`` extra: ``pip install "xyberos[durable]"``.
Two scenarios, both deterministic and both read from the journal rather than from memory:
1. **A worker dies between steps.** Everything it finished is in the journal, so the next worker
picks up the next step and no effect happens twice.
2. **A worker dies mid-step.** Its effect may already have happened, and nothing can prove whether
it did. Recovery refuses to guess: the step is recorded as ``outcome_unknown`` and the run is
parked until a reconciler — the caller's own way of asking the outside world — decides.
Each step appends a line to a side-effect log, so the printed counts show exactly how many times
each external effect actually happened.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from xyberos import Backoff, Retry, StepContext, Workflow
from xyberos.durable import (
ExecutionStatus,
ExecutionView,
Journal,
JournalSettings,
JournalStore,
Reconciliation,
StepState,
open_journal,
)
from xyberos.durable.recovery import recover
from xyberos.durable.testing import expire_leases
from xyberos.durable.worker import DurableWorker, WorkerSettings
WORK_DIR = Path("var/durable_workflow")
CHARGED = "charged"
RECEIPTED = "receipted"
NOTIFIED = "notified"
def effects_path(journal_path: Path) -> Path:
"""Return the side-effect log this example appends to."""
return journal_path.with_suffix(".effects")
def record(journal_path: Path, name: str) -> None:
"""Append one line: the external effect, which the journal does not own."""
with effects_path(journal_path).open("a", encoding="utf-8") as handle:
handle.write(f"{name}\n")
def count(journal_path: Path, name: str) -> int:
"""Return how many times an effect was recorded."""
path = effects_path(journal_path)
if not path.exists():
return 0
return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line == name)
def charge_output(value: dict[str, str]) -> dict[str, str]:
"""Return the value ``charge`` produces.
The step and its reconciler both call this, and that is the point: a reconciler must produce the
*complete* output the step would have returned, because downstream steps consume it. A partial
output is accepted by the journal and then fails later in some unrelated step, which is a
miserable thing to debug.
"""
return {"invoice": value["invoice"], "journal": value["journal"], "status": CHARGED}
def build_invoice_workflow(journal_path: Path, *, gate: asyncio.Event | None = None) -> Workflow:
"""Build the three-step invoice definition.
``gate`` exists only so the second scenario can hold a step open while the worker is abandoned
mid-flight; a real step would be waiting on a payment provider instead.
"""
def mark(value: dict[str, str], name: str) -> dict[str, str]:
record(Path(value["journal"]), name)
return {"invoice": value["invoice"], "journal": value["journal"], "status": name}
async def charge(value: dict[str, str], context: StepContext) -> dict[str, str]:
# The provider call happens first: this is the "crashed after the external call but before
# the result was persisted" case, which is the one that cannot be resolved locally.
record(Path(value["journal"]), CHARGED)
if gate is not None:
await gate.wait()
return charge_output(value)
async def receipt(value: dict[str, str], context: StepContext) -> dict[str, str]:
return mark(value, RECEIPTED)
async def notify(value: dict[str, str], context: StepContext) -> str:
record(Path(value["journal"]), NOTIFIED)
return f"invoice {value['invoice']} charged, receipted, and the customer notified"
return (
Workflow("invoice", version="1")
.step("charge", charge, retry=Retry(max_attempts=3, backoff=Backoff(initial=0.05)))
.step("receipt", receipt)
.step("notify", notify)
.edge("charge", "receipt")
.edge("receipt", "notify")
)
def worker_for(store: JournalStore, workflow: Workflow, owner: str) -> DurableWorker:
"""Build a worker with the definition it needs, and no activity policy at all."""
return DurableWorker(
store,
{f"{workflow.name}:{workflow.version}": workflow},
settings=WorkerSettings(owner=owner),
)
def render(view: ExecutionView) -> None:
"""Print an execution's status, steps, and timeline."""
print(f" status: {view.status.value}")
print(f" result: {view.result}")
for step in view.steps:
detail = f" ({step.state_detail})" if step.state_detail else ""
print(f" step {step.step_id:<8} {step.state.value:<15} attempts={step.attempts}{detail}")
for event in view.events:
where = f" step={event.step_id}" if event.step_id else ""
note = f" {event.detail}" if event.detail else ""
print(f" {event.sequence:>3} {event.kind.value}{where}{note}")
async def wait_until(
condition: Callable[[], bool], *, description: str, rounds: int = 5000
) -> None:
"""Yield until ``condition`` holds, or fail loudly instead of hanging.
Polling with ``asyncio.sleep(0)`` only hands control back to the loop, so this never depends on
wall-clock time; it depends on the other task making progress.
"""
for _ in range(rounds):
if condition():
return
await asyncio.sleep(0)
raise AssertionError(f"timed out waiting for {description}")
async def wait_for_step_to_start(store: JournalStore, execution_id: str, step_id: str) -> None:
"""Yield until the journal records ``step_id`` as running."""
async def started() -> bool:
return step_id in await store.running_steps(execution_id)
for _ in range(5000):
if await started():
return
await asyncio.sleep(0)
raise AssertionError(f"step {step_id!r} never started in execution {execution_id}")
async def run_to_completion(
store: JournalStore, worker: DurableWorker, execution_id: str, *, steps: int
) -> ExecutionView:
"""Drive a worker until the execution is finished, then return its final view."""
for _ in range(steps + 1):
view = await store.inspect(execution_id)
if view.status in (ExecutionStatus.COMPLETED, ExecutionStatus.FAILED):
return view
if await worker.run_once() is None:
break
return await store.inspect(execution_id)
async def fresh_journal(journal_path: Path) -> tuple[Journal, JournalStore]:
"""Open a clean journal for one scenario, so its counters and effects stand alone."""
for stale in (journal_path, effects_path(journal_path)):
for suffix in ("", "-wal", "-shm"):
Path(f"{stale}{suffix}").unlink(missing_ok=True)
journal = await open_journal(JournalSettings(path=journal_path, lease_ttl_seconds=30.0))
return journal, JournalStore(journal)
def counts(journal_path: Path) -> str:
"""Render the effect counts for a printed summary."""
return (
f"charged={count(journal_path, CHARGED)} "
f"receipted={count(journal_path, RECEIPTED)} "
f"notified={count(journal_path, NOTIFIED)}"
)
def counts_are_exactly_once(journal_path: Path) -> bool:
"""Whether every external effect happened exactly once."""
return all(count(journal_path, name) == 1 for name in (CHARGED, RECEIPTED, NOTIFIED))
async def print_stats(store: JournalStore) -> None:
"""Print the queue counters an operator would watch."""
stats = await store.stats()
location = Path(str(stats.detail["paths"]))
print(" queue counters:")
print(
f" completed={stats.completed} running={stats.running} queued={stats.queued} "
f"waiting={stats.waiting} expired_leases={stats.expired_leases}"
)
print(f" journal file: {location}")
print(f" effect log: {effects_path(location)}")
def provider_reconciler(journal_path: Path) -> Callable[[str, object], Reconciliation]:
"""Return the reconciler this example uses: it asks the provider what happened.
A real reconciler calls the provider; this one reads the effect log, which is the same evidence
in a form the example can print. It returns exactly what the step would have returned -- see
:func:`charge_output` for why that matters.
"""
def reconcile(step_id: str, step_input: object) -> Reconciliation:
if not isinstance(step_input, dict): # pragma: no cover - the input is the run input
raise AssertionError(f"the reconciler needs the step input, got {step_input!r}")
proof = count(journal_path, CHARGED)
return Reconciliation(
state=StepState.SUCCEEDED,
output=charge_output(step_input),
detail=f"the payment provider confirms one charge (the local log shows {proof})",
)
return reconcile
async def scenario_one(journal_path: Path) -> None:
"""A worker dies between steps: the finished work is reused."""
print("scenario 1 - the worker dies between steps")
print()
journal, store = await fresh_journal(journal_path)
try:
workflow = build_invoice_workflow(journal_path)
value = {"invoice": "INV-1001", "journal": str(journal_path)}
handle = await store.submit(workflow, input=value)
print(f"1. submitted {handle.execution_id} ({handle.status.value})")
worker_a = worker_for(store, workflow, owner="invoice-worker-a")
first = await worker_a.run_once()
print(f"2. worker A ran one step: {first.action if first else 'nothing to do'}")
print(f" effects: charged={count(journal_path, CHARGED)}")
del worker_a
print("3. worker A is gone. It had already yielded, so nobody holds a lease for this")
print(" execution, and nothing was cleaned up on its way out.")
print()
print(" what the journal kept:")
render(await store.inspect(handle.execution_id))
worker_b = worker_for(store, workflow, owner="invoice-worker-b")
print()
print("4. worker B takes over; it has never seen this execution before")
report = await worker_b.recover()
print(f" recovery examined: {report.examined} (the lease had already been released)")
final = await run_to_completion(
store, worker_b, handle.execution_id, steps=len(workflow.steps)
)
render(final)
print()
print(" every step ran exactly once, because none of them was ever in doubt:")
print(f" {counts(journal_path)}")
print()
await print_stats(store)
assert final.status is ExecutionStatus.COMPLETED, final.status
assert counts_are_exactly_once(journal_path)
finally:
await journal.aclose()
async def scenario_two(journal_path: Path) -> None:
"""A worker dies mid-step: nothing may be repeated until someone says what happened."""
print()
print("scenario 2 - the worker dies in the middle of a step that takes money")
print()
journal, store = await fresh_journal(journal_path)
try:
gate = asyncio.Event()
workflow = build_invoice_workflow(journal_path, gate=gate)
value = {"invoice": "INV-1002", "journal": str(journal_path)}
handle = await store.submit(workflow, input=value, execution_id="invoice-mid-step")
print(f"1. submitted {handle.execution_id} ({handle.status.value})")
worker_a = worker_for(store, workflow, owner="invoice-worker-a")
task = asyncio.ensure_future(worker_a.run_once())
await wait_for_step_to_start(store, handle.execution_id, "charge")
# The interesting crash is the one where the effect has already landed but its result has
# not, so wait for the effect itself before killing the worker.
await wait_until(
lambda: count(journal_path, CHARGED) >= 1,
description="the charge to be recorded by the in-flight step",
)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
print("2. the worker is killed while the step is in flight")
charged = count(journal_path, CHARGED)
print(f" effects: charged={charged} - the charge may have gone through")
print()
print(" what the journal kept:")
render(await store.inspect(handle.execution_id))
worker_b = worker_for(store, workflow, owner="invoice-worker-b")
print()
print("3. worker B cannot take the work: the dead worker's lease is still live")
idle = await worker_b.run_once()
print(f" worker B: {idle.action if idle else 'nothing to claim'}")
print()
print("4. the lease runs out. (In a real deployment the clock does this; the example")
print(" backdates the lease so the outcome is deterministic rather than dependent on a")
print(" sleep in a test suite.)")
await expire_leases(journal)
report = await recover(
store,
definitions={f"{workflow.name}:{workflow.version}": workflow},
owner="recovery",
)
print(f" recovery examined: {report.examined}")
for execution_id, decision in report.decisions.items():
print(f" decision for {execution_id}: {decision}")
parked = await store.inspect(handle.execution_id)
render(parked)
print()
print(" nothing was re-run: the journal says the charge's outcome is unknown, not that")
print(" failed. That is the difference between at-least-once and a wrong answer.")
assert count(journal_path, CHARGED) == 1
assert parked.status is ExecutionStatus.WAITING
assert parked.steps[0].state is StepState.OUTCOME_UNKNOWN
print()
print("5. the operator supplies a reconciler: ask the payment provider what happened")
await store.resume_waiting(handle.execution_id, reason="the operator resolved the attempt")
claim = await store.claim_next("operator-session")
if claim is None: # pragma: no cover - the resume above makes the execution claimable
raise AssertionError("the resumed execution could not be claimed")
reconciled = provider_reconciler(journal_path)("charge", claim.input)
await store.resolve_step(
claim,
"charge",
state=reconciled.state,
output=reconciled.output,
detail=reconciled.detail,
)
print(f" reconciled: {reconciled.detail}")
print()
print("6. the worker finishes the run from the record that was just written")
final = await run_to_completion(
store, worker_b, handle.execution_id, steps=len(workflow.steps)
)
render(final)
print()
print(" the uncertain effect happened exactly once, and the run still finished:")
print(f" {counts(journal_path)}")
print()
await print_stats(store)
assert final.status is ExecutionStatus.COMPLETED, final.status
assert counts_are_exactly_once(journal_path)
finally:
await journal.aclose()
async def main() -> None:
WORK_DIR.mkdir(parents=True, exist_ok=True)
await scenario_one(WORK_DIR / "between-steps.db")
await scenario_two(WORK_DIR / "mid-step.db")
print()
print(f"the journals and effect logs are in {WORK_DIR}")
print("Remember the scope: single host, one journal file, at-least-once effects.")
if __name__ == "__main__":
asyncio.run(main())
The shape to notice is the guard, because it is the honest consequence of at-least-once: a step runs again after a crash, so the effect it performs is checked against what the journal says already happened. Recovery is not a retry of the whole run — the executor inspects the journal, decides the next action, and claims the run with a fence. That is why the recovery decisions have their own tests, including a real subprocess killed at five different boundaries.
What to read next¶
- Durable execution — the guide to the store, the worker, and the recovery rules.
- ADR 0011 — the contract this page summarises.
- Workflow — the engine to use when the work does not need to survive the process.