Determinism and Replay Safety
Replay starts a workflow body from the beginning and skips steps that already wrote
step:done. The remaining body must make the same control-flow decisions from the
recorded values.
Run non-deterministic or side-effecting operations through ctx. Pyrula records each
result and returns it during replay.
| Call | On replay |
|---|---|
@step call | Returns the recorded result and restores its declared type. |
ctx.step(id, fn, ...) | Returns the recorded result after step:done exists. |
ctx.once(key, fn, ...) | Returns the result stored under key, across runs. |
ctx.run_child(name, params) | Returns the recorded child result; the child isn’t resubmitted. |
ctx.now() | Recorded timestamp. |
ctx.uuid() | Recorded UUID. |
ctx.random() | Recorded float in [0.0, 1.0). |
ctx.sleep() / ctx.sleep_until() | Skips the wait. |
ctx.wait_for_signal() | Returns the recorded signal. |
ctx.interrupt() | Returns the recorded response. |
ctx.emit() | Replays from the stream. Not re-emitted. |
Unsafe operations
Section titled “Unsafe operations”Reaching past ctx for time, randomness, or IO diverges on replay:
import time, uuid, random
@workflowasync def unsafe(ctx): now = time.time() # different every run uid = uuid.uuid4() # different every run data = await fetch(url) # different response on replayUse the ctx versions instead:
@workflowasync def safe(ctx): now = ctx.now() uid = ctx.uuid() data = await ctx.step("fetch", fetch, url) # runs once, replays the resultThe engine has a backstop here: it inspects a step’s bytecode and rejects a direct
time, random, uuid, or datetime call inside ctx.step. It only catches direct
module.attr() calls though, so an aliased import or a call buried in a helper slips
through. Keep non-deterministic operations behind ctx.
Idempotent side effects
Section titled “Idempotent side effects”Pyrula never re-runs a step whose step:done is recorded, but a crash between the
side effect and that record runs it again. Decorated steps expose a stable
StepContext.idempotency_key; see the step guide and
the crash window.
For effects you can’t make idempotent, gate them with
ctx.interrupt().
Version skew
Section titled “Version skew”By default a run is pinned to the code version that started it, and only a worker on that
version replays it (see Versioning and deploys). These
rules matter when an old run is replayed by new code: with version_behavior="auto_upgrade",
or an explicit version= you didn’t change. The engine detects a divergence and fails the
run rather than producing a wrong result.
| Safe while runs are in flight | Not safe |
|---|---|
| Changing a step’s implementation, keeping its id and position | Renaming or reordering steps (moves their replay keys) |
| Adding workflows, or steps in-flight runs don’t reach | Removing a step an in-flight run already recorded |
Deploy-safe versioning
Section titled “Deploy-safe versioning”For a structural change you can’t make backward-compatible - a new step, a
reordered branch, a behavior change mid-workflow - gate it behind ctx.patched
instead of just shipping it:
@workflowasync def charge(ctx, amount_cents: int): await ctx.step("charge", lambda: payment_client.charge(amount_cents)) if await ctx.patched("refund-flow"): await ctx.step("refund-hold", lambda: payment_client.hold_refund_window()) return "done"ctx.patched(change_id) is decided per call site from the replay cursor: a run replaying
past this point without a marker takes the old branch; a run reaching it live takes the new
branch and records a marker.
Prove it in CI with pyrula.workflows.testing (which also has WorkflowTestEnv for
driving a workflow in tests, and the *Contract mixins for custom stores, which need the
testing extra):
from pyrula.workflows.testing import assert_replay_compatible, export_run_history
# 1. Pull a real in-flight run's journal from your live store (or Postgres archive).history = export_run_history(store, "charge", run_id)
# 2. Assert the *currently deployed* workflow code can still replay it. Run this# in CI against your candidate deploy, before it ships.assert_replay_compatible(charge, history) # raises AssertionError if it can'tassert_replay_compatible shadow-replays the history against the registered code and
raises with the divergence. export_run_history returns JSON-serializable dicts, so a
history can live in your repo as a fixture.
The full loop:
- Guard the change with
if await ctx.patched("refund-flow"): .... - Capture history for a real (or representative) in-flight run with
export_run_history. - Run
assert_replay_compatible(workflow, history)in CI. Deploy only if it’s green. - Once old runs have drained past the patch point, switch the call site to
await ctx.deprecate_patch("refund-flow"). It keeps writing the marker, so a straggler still replays safely. - When no run needs the old branch, delete it and the guard.