Deployment Modes
Pyrula supports inline execution, an embedded worker, and separate worker processes.
| Runtime | Execution | Background loops | Use for |
|---|---|---|---|
InlineWorkflowRunner | Calling process | None | Tests and scripts that do not suspend |
Embedded WorkflowWorker | Calling process | Timers, schedules, recovery | Applications that host their own worker |
Separate WorkflowWorker | Worker tier | Timers, schedules, recovery | Independent scaling and request-path isolation |
Embedded
Section titled “Embedded”EMBEDDED runs execution in the same process that submits the run.
InlineWorkflowRunner executes a run directly for a test or script:
import asyncio
from pyrula.workflows import InlineWorkflowRunner, MemoryStore
async def main() -> None: runner = InlineWorkflowRunner(store=MemoryStore()) run = await runner.client.submit("my_workflow", {"x": 1}) events = await runner.run(run.name, run.run_id)
asyncio.run(main())InlineWorkflowRunner has no background loops. run() advances the run until it
suspends or finishes. It does not resume the run later:
- A
ctx.sleep()suspends the run and it stays suspended. There is no timer loop, so nothing ever callswake_runand the sleep never ends. - After a signal, an interrupt resume, or a wake, the store re-queues the run to
continue past that point. One
run()call advances one frontier, so execution stalls at the step after the thing you just resolved. - Schedules never fire. Only the worker’s supervisor loop calls
fire_due_schedules.
Use WorkflowWorker if a run can sleep, wait for a signal or interrupt, or execute on
a schedule.
Embedding a worker in your own app
Section titled “Embedding a worker in your own app”An embedded WorkflowWorker runs the production worker loops in the application
process. The following FastAPI lifespan starts and stops one with the application:
import asynciofrom contextlib import asynccontextmanager
from fastapi import FastAPIfrom pyrula.workflows import ValkeyStore, WorkflowWorker
# Import your @workflow modules before constructing the worker: it snapshots the# registry, so anything registered later is never claimed.import myapp.workflows # noqa: F401
@asynccontextmanagerasync def lifespan(app: FastAPI): worker = WorkflowWorker(store=ValkeyStore(url="redis://localhost:6379")) task = asyncio.create_task(worker.run()) try: yield finally: worker.stop() await asyncio.gather(task, return_exceptions=True)
app = FastAPI(lifespan=lifespan)worker.run() blocks until worker.stop(), so it belongs in a task rather than
awaited inline. Submit runs with a WorkflowClient or worker.client.
The timer and schedule loops only start if the store supports them. MemoryStore and
ValkeyStore both do; the check is capability-based, not a type check, so a wrapping
store that delegates still works. The default timer poll is 5 seconds, so a
short ctx.sleep(2) takes longer than two seconds to come back. Lower
timer_poll_interval_s in WorkerOptions for local work.
Separate
Section titled “Separate”SEPARATE splits the two halves. One tier submits and follows runs. Worker processes
claim and execute them against the same shared store. Use it when you want to scale
execution independently or isolate it from the request path.
Either way, durability comes from the run’s event stream in the shared store. The mode only changes where execution happens, not the recovery guarantees.
pyrula.workflows ships WorkflowWorker for the worker tier, with no agent
runtime required. Point it at a durable store and call run():
import asynciofrom pyrula.workflows import WorkflowWorker, ValkeyStore
worker = WorkflowWorker(store=ValkeyStore(url="redis://localhost:6379"))
asyncio.run(worker.run())The worker discovers all @workflow-decorated functions automatically. Pass
workflows=[my_workflow] to restrict it to a specific set.
Stores and the durability boundary
Section titled “Stores and the durability boundary”MemoryStore is in-process only - for dev and tests. The two production stores
differ in exactly one way that matters for durability:
-
ValkeyStoreholds the entire live run in Valkey: the event stream plus run lifecycle/owner, idempotency keys, signals, timers, and KV. Everything is Valkey-durable, and completed runs expire aftercompleted_ttl. If you lose Valkey, you lose runs - so run Valkey with persistence (AOF) and/or replication. -
ArchivingStorewraps aValkeyStorewith aPostgresArchive(build the pair withmake_archiving_postgres(dsn=..., valkey_url=...)) and adds one thing: when a run reaches a terminal event, its full event stream is archived to Postgres, so a completed run survives beyond Valkey. Everything else lives only in Valkey: in-flight runs, and (even after completion) lifecycle and owner state, idempotency keys, signals, timers and KV. Postgres is a completed-run archive, not a second live copy.
Here, “durable” means Valkey-durable for live execution and all auxiliary state, plus a Postgres archive of completed event logs. Size Valkey’s durability (persistence/replication) for your in-flight runs accordingly; Postgres protects history, not work in progress. Archival is reconciled in the background, so a transient Postgres outage during completion is retried rather than lost.
Completed-run retention in Valkey
Section titled “Completed-run retention in Valkey”A plain ValkeyStore with no archive keeps a completed run’s full history for
completed_ttl (default a day) and then deletes it - that TTL is your retention
policy. Once you add an archive, retention becomes two independent numbers rather
than one: completed_ttl stops applying to archived runs, and instead
live_ttl_after_archive (a constructor argument on ArchivingStore, default 300
seconds) controls how long a completed run’s live copy sticks around once its
history is safely archived. Every read - load_run, replay, and listings - falls
back to the archive automatically, so this window only matters for readers who want
a just-finished run served from Valkey rather than Postgres; it does not affect
whether the run’s history is retained at all.
live_ttl_after_archive is a memory knob, not a durability one: every completed
run occupies live-tier memory until this window closes, at roughly 825 bytes per
event (measured). Size it against how fast the archive can actually drain
completed runs, not how fast you’d like it to.
Sizing Valkey for the archive window
Section titled “Sizing Valkey for the archive window”The worker that writes a run’s terminal event also archives it to Postgres, so the drain
rate is your fleet’s terminal-append rate. Finished runs then stay in Valkey for
live_ttl_after_archive seconds (default 300).
Measured at 100 events per run (Docker Desktop VM, co-located services):
| Configuration | Runs/s |
|---|---|
| one worker | 171 |
| sixteen threads, one process | 247 |
| four worker processes | 444 |
One process plateaus, so scale drain with more worker processes, not threads. At 444 runs/s and the 300 s window, about 130,000 finished runs (~11 GB at ~825 bytes per event) sit in Valkey; they’re already archived, so this is memory, not durability. Lower the TTL to save memory, or raise it to serve just-finished runs from Valkey. Bigger runs drain slower (1,000 events per run: 18.5 runs/s from one worker, 42.8 from four threads), so size against your own event counts.
Serve runs over HTTP with RunRouter.