# Deployment Modes

> embedded runs execution in-process. separate runs workers against a shared store.

Source: https://docs.pyrula.io/workflows/concepts/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

`InlineWorkflowRunner` executes a run directly, for a test or script:

```python
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.

### Embedding a worker in your own app

Run the worker loops in your own process:

```python
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`](https://docs.pyrula.io/workflows/guides/client.md) 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

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

```python
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.

## Stores and the durability boundary

`MemoryStore` is in-process, for dev and tests. `SQLiteStore` is one file on one host ([Choosing a store](https://docs.pyrula.io/getting-started/choosing-a-store.md)). 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](https://docs.pyrula.io/getting-started/managed-valkey.md#what-a-volatile-policy-can-evict)). `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.

### 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

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

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`](https://docs.pyrula.io/workflows/guides/http.md).
