Durable workflows
RunR runs durable workflows: code-first, step-based functions whose progress survives crashes, redeploys and sleeps of days. A workflow is an ordinary function in a project; every step in it is executed once, memoized, and retried with exponential backoff when it fails. Workflows sleep (step.sleep), wait for events (step.waitForEvent), call other workflows (step.invoke) and emit events (step.sendEvent).
@wf.workflow("process-order", triggers=[{"event": "order.created"}], retries=4)
def process_order(ctx, step):
charge = step.run("charge-card", charge_card, ctx.event["data"])
step.sleep("fraud-check-window", "10m")
shipped = step.wait_for_event("shipped", "order.shipped", match="data.order_id", timeout="3d")
if shipped is None:
step.run("refund", refund, charge)
return {"shipped": shipped is not None}
- SDKs:
sdk/python(runr-workflows),sdk/node(@runr/workflows, TypeScript). Any runtime can implement the language-neutral workflow protocol v1. - Examples:
examples/workflows. - CLI:
runr workflows(CLI); AI agents: theworkflows_list,workflow_run_start,workflow_runs_list,workflow_run_statusandevent_sendtools of the RunR MCP server (AI agents).
How it works#
Durable execution by deterministic replay (the model of Inngest and Temporal's workflow code):
- A run is created (API, event, cron tick, queue message,
step.invoke, replay). - An engine worker takes the run's lease and POSTs a signed execute request to the project's function: the trigger event plus the results of every step completed so far.
- The SDK runs the workflow from the top. A step with a stored result returns it at once; the first step without one runs (
step.run) or is reported (sleep, wait, invoke, send) — and the invocation ends with exactly one op describing what happened. - The engine persists the op and schedules what comes next: the next invocation right away (step completed), after a backoff (step failed), at a wake-up time (sleep), when an event arrives or a timeout passes (wait), when the child run ends (invoke).
- When the function returns, the run is completed with its output.
┌──────────┐ execute(run, event, steps) ┌────────────────┐
│ engine │ ────────────────────────────▶ │ function+SDK │
│ (RunR │ ◀──────────────────────────── │ replays code, │
│ API + │ {op: step_completed …} │ runs 1 step │
│ queue) │ └────────────────┘
└──────────┘
persists step → next invocation … until run_completed / run_failed
Consequences for workflow code:
- Steps are the unit of durability. Side effects belong inside
step.run; code between steps runs on every invocation and must be deterministic (no random branching, no reading the clock to decide which step comes next). - Step ids are stable names. A repeated id gets a counter suffix (
add,add:1, …) in call order. Renaming a step in a deployed workflow makes running instances execute it again. - Steps execute at least once. A result lost to a crash (worker or function) re-runs the step on the next invocation; make external effects idempotent (pass the run id or the step id as an idempotency key to the payment gateway, for example).
- One step per invocation. Steps passed to
Promise.allrun one after another, in call order. - Outputs are JSON. A step result goes through JSON; dates become strings.
Defining workflows#
Definitions come from the code (the SDK registry). The engine learns them by sync:
- automatically after a deployment of a
workflow-trigger project becomes active, - with Sync definitions in the Workflows tab or
POST /api/projects/{project}/workflows/sync(a signed introspect request to the function), - from
runr.workflows.jsonin the project's latest code version when the function cannot be reached during a sync (same format as the introspection answer: a list, or{"workflows": [...]}), - declared through the API:
PUT /api/projects/{project}/workflows {"workflows": [...]}(CLI, IaC).
A workflow that is no longer declared is archived: its runs stay visible, it starts no new runs. A workflow can be paused (PATCH …/workflows/{name} {"is_active": false}): triggers and the API start nothing; runs in progress continue. Pausing survives syncs.
Options#
| Option | Default | Meaning |
|---|---|---|
triggers |
[] (API only) |
{"event": "order.created", "if": "<expr>"}, {"cron": "0 9 * * *", "timezone": "Europe/Istanbul"}, {"queue": "<stream>"} |
retries |
3 | retries per step; attempts = retries + 1 (max 20) |
timeout |
30d |
run timeout (max 365d); an overdue run ends timed_out |
concurrency |
none | 5, {"limit": 5, "key": "event.data.customer_id"} or a list of one workflow-wide and one keyed limit |
idempotency |
none | expression of the trigger event; runs with the same value are deduplicated |
cancel_on |
[] |
{"event": "order.cancelled", "match": "data.order_id"} — cancels matching active runs |
description |
— | shown in the UI |
Durations: 500ms, 30s, 15m, 2h, 1d, 1w, combinations (1h30m), ISO-8601 (PT1H) or a number of seconds.
Expressions#
Trigger if, waitForEvent if, cancel_on if, concurrency key and idempotency use a small side-effect free language: paths (event.data.order_id, async.data.items[0], event.data["key"]), literals ('text', "text", numbers, true, false, null), comparisons (== != > >= < <=), && || ! and parentheses. event is the run's trigger event, async the incoming event. Values compare strictly ("1" != 1); a path that does not exist is null. match: "data.order_id" is shorthand for event.data.order_id == async.data.order_id.
Starting runs#
| Source | How |
|---|---|
| API | POST /api/projects/{project}/workflows/{name}/runs {"data": {...}, "idempotency_key": "...", "test": false} → 201 new run, 200 existing run with that key |
| Events | POST /api/events — every workflow with a matching event trigger starts a run |
| Cron | {"cron": ...} triggers: the engine tick starts one run per due minute (runr/cron event with cron, timezone, scheduled_at) |
| Queue | a project with trigger workflow and a queue stream: each Redis Stream message becomes the event queue/<stream> (queue bridge) |
| Invoke | step.invoke starts a child run (runr/invoke event with the given data) and waits for its output |
| Replay | a finished run replayed from the start or from a step |
Events API#
POST /api/events (Sanctum API token or session; the tenant is the one the request is identified for — X-Tenant-ID selects one of the user's tenants):
{"name": "order.created", "data": {"order_id": "o-1"}, "id": "order-o-1", "ts": 1760000000000}
A list of events or {"events": [...]} is accepted too (at most 100 per request). For every event the engine, in this order, cancels runs whose cancel_on matches, resumes waitForEvent steps whose match / if hold, and starts workflows triggered by the name.
name: 1–200 characters of letters, digits and. _ : / -; names underrunr/are reserved for the platform.data: a JSON object (default{}); name + data at most 256 KiB.id: deduplication — an event id received before is answered with the original event and not processed again.- answer:
{"ids": [...], "events": [{"id", "name", "duplicate", "runs": [...], "resumed", "cancelled"}]}.
GET /api/events?name=<prefix>&source=&project_id=&from=&to= lists events, GET /api/events/{id} shows one with the runs it started. ?test=1 on POST marks test events (the UI's Send test event). Events emitted by step.sendEvent carry source: workflow and the project.
Events wait for nobody: a waitForEvent matches events that arrive after the wait started.
Queue bridge#
A project with trigger Workflow and a queue stream gets the regular queue consumer (runtime/queue-consumer) with the engine as its sink: POST /api/internal/workflows/queue/{tenant|central}/{project}/{token} — the 40-hex token is derived from the project's signing key (rotating the key changes the URL; redeploy). Each message becomes the event queue/<stream> with the message fields as data (a single JSON field named data, payload, body or message is decoded); the stream entry id deduplicates redeliveries. The consumer's retry and dead-letter handling stay as they are: a non-2xx answer is retried, then dead-lettered. The sink URL base is WORKFLOWS_PUBLIC_API_URL, else the region's app_url, else APP_URL.
Steps#
| Step | Op | Run state | Result |
|---|---|---|---|
run(id, fn) |
step_completed / step_failed |
stays runnable | fn's value; retried on failure |
sleep(id, d) / sleepUntil(id, t) |
sleep |
sleeping until the time |
— |
waitForEvent(id, {event, match, if, timeout}) |
wait_for_event |
waiting |
the event {id, name, data, ts}, null on timeout |
invoke(id, {workflow, data, timeout}) |
invoke |
waiting |
the child's output; a failed / cancelled / timed-out child fails the step (InvokeFailed, InvokeTimeout) |
sendEvent(id, events) |
send_event |
stays runnable | {"ids": [...]} |
waitForEvent and invoke without a timeout wait 7d (workflows.defaults.wait_timeout); every timeout is capped at the run timeout.
Retries and errors#
- A failing
step.runis retried with exponential backoff: 2 s × 2^(n-1), at most 10 min, ±20 % jitter (config/workflows.php→backoff).RetryAfterErrorsets the delay (duration or time).NonRetriableErrorfails at once. - A step that failed for good is memoized as failed; the next invocation throws
StepErrorinto the workflow at that step — catch it to compensate. Uncaught, it fails the run. - An error outside any step, an unreachable function (non-2xx, timeout,
401signature rejection) or a malformed answer counts as a function attempt, retried with the same backoff up to the workflow's attempts; progress (a step result) resets the count. - A run whose worker vanished (lease expired) is recovered by the tick as a failed function attempt (
WorkerLost).
Status of a run#
queued → running (lease held, function invoked) → back to queued after each step, or sleeping / waiting; ends completed, failed, cancelled or timed_out. Steps: completed, failed, retrying (backoff), sleeping, waiting, cancelled.
Controlling runs#
| Action | API | Effect |
|---|---|---|
| Cancel | POST /api/projects/{p}/workflow-runs/{run}/cancel |
active run → cancelled; open sleeps / waits cancelled; invoked children cancelled; a result arriving later is discarded |
| Retry | POST …/{run}/retry |
a failed / cancelled / timed-out run in place: completed steps stay, failed and unfinished steps run again with fresh attempts |
| Replay | POST …/{run}/replay {"from_step": "ship"} |
a new run with the same event; without from_step every step runs again, with it the memoized steps before that step are copied |
| List / detail | GET /api/projects/{p}/workflow-runs?status=failed,cancelled&workflow=&source=&q=&from=&to=&per_page= · GET …/{run} |
detail carries the event, output, error and every step with input, output, error, attempt history, timings |
Concurrency and idempotency#
- Concurrency limits count runs that are executing (
running). Sleeping and waiting runs hold no slot. A run deferred by a full limit staysqueued; a run leavingrunninghands its slot to the oldest queued run, and the tick re-dispatches queued runs as a safety net. - Keyed concurrency (
key: "event.data.customer_id") applies the limit per value of the expression. - Idempotency: an explicit
idempotency_key(API) or the definition'sidempotencyexpression of the trigger event. A second start with the same key answers the existing run. Keys are unique per workflow for as long as the run is retained.
Security#
- Every engine request carries an HMAC-SHA256 signature over the request (protocol) with the project's signing key. RunR injects the key as
RUNR_WORKFLOW_SIGNING_KEYinto the Knative Service of aworkflowproject — from the project's Kubernetes Secret<slug>-secret-env(secretKeyRef), never as a value in the Service spec (security.md); the SDK answers unsigned, wrongly signed or stale (> 5 min) requests with401. Only the engine can drive steps, even though the function URL is public. - The key is stored encrypted and never returned by listings (fingerprint only).
POST …/workflows/app/rotate-keycreates a new one; the previous key keeps signing (as a second signature) for 24 h (WORKFLOWS_KEY_ROTATION_GRACE) so the running revision keeps working until the redeploy.GET …/workflows/app/signing-keyreveals it for functions served outside RunR. - Endpoint override (
PATCH …/workflows/app {"endpoint_url"}): http(s) only, no credentials, no loopback / private / link-local / CGNAT addresses (literal or resolved) unlessWORKFLOWS_ALLOW_PRIVATE_ENDPOINTS=true. Redirects are not followed.
Permissions#
Part of the role catalog (permissions.md, group Workflows):
| Permission | Scope | Grants |
|---|---|---|
workflows.view |
project | workflows, runs, steps and usage of the project; organization-wide also GET /api/events and GET /api/workflow-usage |
workflows.run |
project | start, cancel, retry, replay runs |
workflows.manage |
project | sync / declare definitions, pause / resume, endpoint override, rotate / reveal the signing key |
events.send |
organization | POST /api/events |
Built-in roles: Owner and Admin hold all four, Developer all four, Viewer workflows.view. The UI hides the actions a user may not take; the API enforces every rule (config/route_permissions.php). The queue bridge sink is authenticated by its signed URL, not by a user.
Limits#
config/workflows.php → limits (platform-wide):
| Limit | Default |
|---|---|
| event (name + data) | 256 KiB |
| events per request | 100 |
| step output / run output | 1 MiB each (OutputTooLarge, not retried) |
| request to the function (event + memoized state) | 8 MiB (StateTooLarge) |
| steps per run | 1000 (TooManySteps) |
| retries per step | 20 |
| concurrency limit | 1000 |
| run timeout | 365 d |
| workflows per project | 200 |
Data model#
Workflow tables live in the central database and in every tenant schema (tenancy: one schema per tenant), created by one shared schema (App\Workflows\Database\WorkflowSchema): central migration 2026_10_10_000001_create_workflow_tables, tenant migration 0040_create_workflow_tables. Everything a tenant sees is in its own schema.
| Table | Rows |
|---|---|
workflow_apps |
one per project: encrypted signing key (+ previous key in grace), endpoint override, last sync (status, error, SDK) |
workflows |
definitions (name unique per project, triggers, config, active / archived, source sdk / manifest / api) |
workflow_triggers |
denormalized event, cron (with last tick) and cancel triggers for lookups |
workflow_runs |
status, trigger event, output, error, function attempt, idempotency / concurrency keys, parent / replay links, lease, next attempt, timeout |
workflow_steps |
per run and step id: type, status, position, attempts and history, input, output, error, wake-up / timeout, waited event + compiled match expression, child run |
workflow_events |
received events with dedup id, source, effects (runs started, waits resumed, runs cancelled) |
workflow_usage |
step executions and started runs per workflow per day |
Operations#
- Queue: engine jobs (
ExecuteWorkflowRunJob,SyncWorkflowsJob) run onWORKFLOWS_QUEUE(defaultdefault, processed by Horizon;WORKFLOWS_QUEUE_CONNECTIONpicks another queue connection). Delivering a job twice is harmless: only the worker holding the run's lease invokes the function. Delays up to 15 min are queued; longer sleeps are woken by the tick. - Scheduler:
workflows:tickevery minute (run timeouts, due sleeps, wait / invoke timeouts, expired leases, lost dispatches, cron triggers) andworkflows:prunedaily, both over the central database and every active tenant. - Retention (
workflows:prune): finished runs and their steps after 30 days (WORKFLOWS_RUN_RETENTION_DAYS), events after 7 (WORKFLOWS_EVENT_RETENTION_DAYS), usage rows after 400 (WORKFLOWS_USAGE_RETENTION_DAYS). Active runs are never pruned; an idempotency key is free again once its run is pruned. - Engine → function:
WORKFLOWS_REQUEST_TIMEOUT(300 s, keep it at or below the Knative request timeout),WORKFLOWS_CONNECT_TIMEOUT(10 s),WORKFLOWS_VERIFY_TLS,WORKFLOWS_ENDPOINT_PATH(path appended to the function URL). The function URL ishttps://<project slug>.<region function domain>. - Configuration: the
WORKFLOWS_*variables are environment variables of the API (config/workflows.php); none of them has a Helm value of its own — set them viaextraEnv.
Usage#
Every step execution (each attempt of step.run, each sleep, wait, invoke and send) and every started run is counted per workflow and day in workflow_usage: GET /api/workflow-usage?from&to (tenant) and GET /api/projects/{p}/workflow-usage return totals, a daily series and per-workflow sums. The compute of a workflow project is metered like an HTTP project (its Knative Service sessions, entry point http).
Projects with the workflow trigger#
A project serves workflows when its trigger type is workflow — Workflow in the console's trigger picker, "config": {"trigger_type": "workflow"} in the projects API. The trigger types a project accepts are http, schedule, queue and workflow; the event trigger type is refused (422) — events reach workflows through POST /api/events. Projects that already store event still list, show and delete. A project that stored workflow before 0.5.0 (deployed as a plain HTTP function then) gets its signing key and a definition sync with its next deployment.
- Deployed as a Knative Service like an HTTP project (scale to zero, sizes, custom domains stay HTTP-only), with
RUNR_WORKFLOW_SIGNING_KEYandRUNR_WORKFLOW_PROTOCOL=v1in the environment. The runtime needs thehttptrigger in its manifest. - Optional queue stream: the queue consumer of the project delivers to the engine (queue bridge); requires Redis on the region.
- FaaS / XaaS: the runtime calls the SDK's
handler(event, context). Container projects serve the SDK themselves (wf.serve(), WSGI,node:http, fetch handler) on the container port; any path works.
UI#
- Project → Workflows: definitions with triggers and limits (pause / resume, start run with JSON data, idempotency key and the test flag), the function panel (endpoint, override, signing key fingerprint / reveal / rotation, sync status, queue bridge), the runs table (status / workflow / search filters, pagination, live refresh while runs are active) and the run detail: summary, error, trigger event, output and the step timeline with inputs, outputs, errors, attempt history, durations, wake-up and wait times, child runs — with cancel, retry, replay and replay-from-step.
- Settings → Events (tenant level): events with filters, effects and detail (data, started runs), usage of the last 30 days, Send test event.