Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

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.

CallOn replay
@step callReturns 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.

Reaching past ctx for time, randomness, or IO diverges on replay:

import time, uuid, random
@workflow
async def unsafe(ctx):
now = time.time() # different every run
uid = uuid.uuid4() # different every run
data = await fetch(url) # different response on replay

Use the ctx versions instead:

@workflow
async def safe(ctx):
now = ctx.now()
uid = ctx.uuid()
data = await ctx.step("fetch", fetch, url) # runs once, replays the result

The 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.

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().

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 flightNot safe
Changing a step’s implementation, keeping its id and positionRenaming or reordering steps (moves their replay keys)
Adding workflows, or steps in-flight runs don’t reachRemoving a step an in-flight run already recorded

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:

@workflow
async 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't

assert_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:

  1. Guard the change with if await ctx.patched("refund-flow"): ....
  2. Capture history for a real (or representative) in-flight run with export_run_history.
  3. Run assert_replay_compatible(workflow, history) in CI. Deploy only if it’s green.
  4. 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.
  5. When no run needs the old branch, delete it and the guard.