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.

Deployment Modes

Pyrula supports inline execution, an embedded worker, and separate worker processes.

RuntimeExecutionBackground loopsUse for
InlineWorkflowRunnerCalling processNoneTests and scripts that do not suspend
Embedded WorkflowWorkerCalling processTimers, schedules, recoveryApplications that host their own worker
Separate WorkflowWorkerWorker tierTimers, schedules, recoveryIndependent scaling and request-path isolation

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 calls wake_run and 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.

An embedded WorkflowWorker runs the production worker loops in the application process. The following FastAPI lifespan starts and stops one with the application:

import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from 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
@asynccontextmanager
async 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 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 asyncio
from 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.

MemoryStore is in-process only - for dev and tests. The two production stores differ in exactly one way that matters for durability:

  • ValkeyStore holds 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 after completed_ttl. If you lose Valkey, you lose runs - so run Valkey with persistence (AOF) and/or replication.

  • ArchivingStore wraps a ValkeyStore with a PostgresArchive (build the pair with make_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.

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.

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

ConfigurationRuns/s
one worker171
sixteen threads, one process247
four worker processes444

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.