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
Section titled “Events”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| Group | Kinds |
|---|---|
| Lifecycle | run:init, run:complete, run:error, run:interrupted, run:resumed, run:sleeping, run:woken, run:signal_waiting, run:signal_received |
| Step | step:pending, step:done, step:error |
| Internal | heartbeat, 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_42Why append-only
Section titled “Why append-only”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.