Deployment Modes
| 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”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 callswake_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.
Embedding a worker in your own app
Section titled “Embedding a worker in your own app”Run the worker loops in your own process:
import asynciofrom contextlib import asynccontextmanager
from fastapi import FastAPIfrom pyrula.workflows import ValkeyStore, WorkflowWorker
# Import @workflow modules before constructing the worker; it snapshots the registry.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(), 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.
Separate
Section titled “Separate”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 asynciofrom 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.
Stores and the durability boundary
Section titled “Stores and the durability boundary”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:
ValkeyStoreholds the entire live run in Valkey. Lose Valkey and you lose runs, so run it with persistence (AOF) or replication, andmaxmemory-policy noeviction. Against anallkeys-*policy withmaxmemoryabove zero it raisesConfigurationErrorat startup (and warns otherwise), because eviction deletes live journals. Avolatile-*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"(orPYRULA_VALKEY_EVICTION_CHECK) accepts the risk.ArchivingStorewraps aValkeyStorewith aPostgresArchive(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.
Completed-run retention in Valkey
Section titled “Completed-run retention in Valkey”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.
Chains and what stays behind
Section titled “Chains and what stays behind”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.
Archive drain
Section titled “Archive drain”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.