Skip to content
Pyrula

Runs as Event Streams

A run of a @workflow is one append-only stream of events in the store. The same stream does two jobs: it’s the log a worker replays to recover, and it’s the feed a client reads to follow the run live.

There’s one source of truth. If a secondary index ever disagrees with the stream, the stream wins.

Events are RunEvent records tagged with an EventKind. Lifecycle kinds describe the run; step kinds describe individual durable operations.

from pyrula.workflows import RunEvent, EventKind, RunStatus
GroupKinds
Lifecyclerun:init, run:complete, run:error, run:interrupted, run:resumed, run:sleeping, run:woken, run:signal_waiting, run:signal_received
Stepstep:pending, step:done, step:error
Internalheartbeat, stream:overflow

You don’t track status by hand. RunStatus is derived from the stream: pending, in_progress, complete, cancelled, error, quarantined, interrupted, sleeping, signal_waiting.

The worker’s replay log and a client’s live feed are the same stream. Read it back with store.load_run:

@workflow(name="onboard_user")
async def onboard_user(ctx, email: str) -> str:
account_id = await ctx.step("create_account", lambda: create_account(email))
await ctx.step("send_welcome", lambda: send_welcome_email(account_id))
return account_id
store = MemoryStore()
runner = InlineWorkflowRunner(store=store)
run_id = runner.submit("onboard_user", params={"email": "ada@example.com"})
await runner.run("onboard_user", run_id)
for event in store.load_run("onboard_user", run_id):
print(event.kind, event.payload.get("result"))
# run:init None
# step:pending None
# step:done acct_42 <- create_account result
# step:pending None
# step:done None <- send_welcome result
# run:complete acct_42

An ordered, append-only log means any worker can rebuild the exact in-memory state of a run by reading it from the start. No separate snapshot, no checkpoint format to keep in sync. That property is what makes replay work.