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

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

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

With PYRULA_CLOUD_API_KEY set, the runner connects to Cloud on its first run() or run_next(). A host that must be reachable earlier calls await runner.start() at startup and await runner.aclose() at shutdown.

run() advances one frontier and does not resume later:

  • A ctx.sleep() suspends the run for good; nothing calls wake_run.
  • After a signal, interrupt resume or wake, the run is re-queued but stalls until you call run() again.
  • Schedules never fire.

Use WorkflowWorker if a run can sleep, wait for a signal or interrupt, or run on a schedule.

Run the worker loops in your own process:

import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pyrula.workflows import ValkeyStore, WorkflowWorker
# Import @workflow modules before constructing the worker; it snapshots the registry.
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(), hence the task. Submit with a WorkflowClient or worker.client.

The default timer poll is 5 seconds, so a short ctx.sleep(2) returns later than two seconds; lower timer_poll_interval_s in WorkerOptions locally.

One tier submits and follows runs; worker processes claim and execute them against the same store. Point WorkflowWorker 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 every @workflow function; pass workflows=[my_workflow] to restrict it.

MemoryStore is in-process, for dev and tests. SQLiteStore is one file on one host (Choosing a store). The two Valkey-based stores differ in what survives:

  • ValkeyStore holds the entire live run in Valkey. Lose Valkey and you lose runs, so run it with persistence (AOF) or replication, and maxmemory-policy noeviction. Against an allkeys-* policy with maxmemory above zero it raises ConfigurationError at startup (and warns otherwise), because eviction deletes live journals. A volatile-* policy only warns: run locks, queues and dedupe keys carry no TTL, so it can drop only finished-run history and a few short-lived keys (which). eviction_policy_check="warn" or "off" (or PYRULA_VALKEY_EVICTION_CHECK) accepts the risk.
  • ArchivingStore wraps a ValkeyStore with a PostgresArchive (make_archiving_postgres(dsn=..., valkey_url=...), needs "pyrula[workflows,postgres]"). When a run reaches a terminal event its stream is archived to Postgres, so a completed run outlives Valkey. In-flight runs stay only in Valkey, and a transient Postgres outage is retried.

Postgres protects history, not in-flight work, so size Valkey’s durability for that.

A plain ValkeyStore keeps a completed run for completed_ttl (default a day), then deletes it. With an archive, live_ttl_after_archive (an ArchivingStore argument, default 300 seconds) sets how long the live copy lingers; reads fall back to the archive.

An open continue_as_new chain keeps a fixed amount in Valkey: the start id’s route, owner and one tail record. Each earlier run’s keys expire completed_ttl after the next run takes over its inbox and (with an archive) its history is archived. After the last run ends, the start id and tail record expire completed_ttl later.

The worker that writes a terminal event also archives it, so drain rate scales with the fleet. One process plateaus: add worker processes, not threads.

Serve runs over HTTP with RunRouter.

View this page as Markdown