Python
Python is a first-class durable runtime for Agora too. Implement steps an AdonisJS workflow calls, OR author whole workflows in Python that the Adonis engine drives — both over the same Redis wire, with the engine owning durable state.
Python is a first-class durable runtime, not just a place to run a step. The durable-worker
SDK (the same one the aviary/nestjs-durable ecosystem ships) speaks the wire your Adonis engine
speaks byte-for-byte, so one Python worker can do either — or both:
- Implement steps an AdonisJS workflow calls — a
ctx.step('payments.charge-card', …)reaches a handler written in Python. - Author whole workflows in Python — the coordinator logic (
ctx.step,ctx.sleep,ctx.gather_calls, …) lives in Python, and the Adonis engine drives it turn by turn.
Either way the engine stays the sole owner of durable state, recovery, and timers — the Python worker is a stateless consumer on the broker. Everything rides the same Redis queues the BullMQ transport uses.
pip install "durable-worker[redis]"A Python step worker
import asyncio
from durable_worker import Worker, FatalError
from durable_worker.redis_runner import run_redis_worker
worker = Worker()
@worker.step("payments.charge-card")
async def charge(data):
res = await stripe.charge(data["orderId"], data["amountCents"])
return {"chargeId": res.id}
async def main():
await run_redis_worker(worker, connection="redis://localhost:6379")
await asyncio.Event().wait()
asyncio.run(main())The handler's argument is the step input (schema-validated by the engine); its return value is
the output. Raise FatalError for a non-retryable failure (e.g. a declined card); any other
exception is retryable and the engine applies the step's retry policy.
Connecting to the control plane
There is no direct connection between the Python worker and your AdonisJS app — everything goes
through the broker (Redis). Point the worker at the same Redis your engine's
bullmq transport uses, and register a handler under the exact
same name. Two things have to line up:
| Must match | Engine side (transports.bullmq) | Worker side (run_redis_worker) | Why |
|---|---|---|---|
| connection | connection: { host, port, … } | connection="redis://host:port" | Same Redis instance — the meeting point. |
| prefix | prefix (default durable) | prefix (default durable) | Namespaces the queue keys: <prefix>-tasks-<name>, <prefix>-results. |
import { defineConfig, transports, stores } from '@adonis-agora/durable'
export default defineConfig({
role: 'control-plane',
transport: 'bullmq',
transports: { bullmq: transports.bullmq({ connection: { host: '127.0.0.1', port: 6379 } }) },
store: 'lucid',
stores: { lucid: stores.lucid({ connection: 'pg' }) },
})Each registered step name gets its own queue — no separate group to declare. Sharing one broker
across isolated deployments? Set the same partition on both sides (transports.bullmq({ partition })
engine-side, run_redis_worker(worker, partition=...) worker-side) and every queue is suffixed
<name>@<partition>.
Authoring workflows in Python
A Python worker can own the whole workflow function, not just a step. The Adonis engine stays the sole owner of durable state, recovery, and timers: it advances a run one turn at a time by sending the run's history as a workflow task, the Python runtime replays the workflow function locally, and returns the commands the replay produced (a step dispatch, a sleep, a signal wait) for the engine to persist and act on — a pure, store-free coordinator.
The simplest deployment registers the workflow and its step handlers on one Worker (each
registered name gets its own queue — route-by-handler), run with run_workers:
from durable_worker import Worker, WorkflowContext, redis_url_from_env, run_workers
worker = Worker()
@worker.step("ingestion")
async def ingestion(data):
return {"rows": load(data["key"])}
@worker.workflow("pipeline")
def pipeline(ctx: WorkflowContext, data):
started_at = ctx.now() # replay-stable capture
results = ctx.gather_calls([ # fan out parallel remote steps in this run
{"name": "ingestion", "input": {"key": f"/{data['id']}/a.csv"}},
{"name": "ingestion", "input": {"key": f"/{data['id']}/b.csv"}},
])
ctx.sleep(60_000) # durable timer
return {"rows": sum(r["rows"] for r in results), "startedAt": started_at}
run_workers([worker], redis=redis_url_from_env()) # one loop, one Redis pool, graceful shutdownctx mirrors the TypeScript WorkflowCtx — each op is keyed by a deterministic seq, so a completed
op replays its recorded result instead of re-running:
| Op | Meaning |
|---|---|
ctx.step(name, input, group=...) | Dispatch a step (routed by handler name, any language) and await its result. |
ctx.now() | A replay-stable epoch-ms timestamp — captured once, replayed thereafter. |
ctx.side_effect(fn) | Run fn once, checkpoint its result, replay the same value (ids/random/env). |
ctx.sleep(ms) | Durable timer — the run suspends and the engine resumes it. |
ctx.wait_signal(name) | Block until a signal name is delivered; returns its payload. |
ctx.start_child(workflow, input) | Start a child run and await its output. |
ctx.gather_calls([{name, input}, …]) | Dispatch several steps in parallel within one run (flat, no child runs). |
ctx.gather_children([…]) | Start several child runs in parallel. |
Changing the op sequence under a run already in flight raises NondeterminismError. A failed step
raises StepFailed; the gather* fan-outs default to wait_all (raising GatherFailed), with an
opt-in fail_fast.
Dispatching a Python workflow from Adonis
Start the workflow by its name — whichever live Python worker is serving that name picks up the turns. From an AdonisJS service:
import engine from '@adonis-agora/durable/services/main'
await engine.start('pipeline', { id: baseId }, `pipeline-${baseId}`)or, from inside another workflow, await ctx.child('pipeline', { id: baseId }). The queue name is
the routing: the worker serving pipeline runs it. If none is live, the start fails fast so a
typo'd name never silently hangs.
The workflow-task/decision wire is language-neutral, so the same Python workflow runs unchanged against an Adonis, a NestJS, or any other conformant control plane.
Cross-ecosystem interop
Run Adonis, NestJS, and Python workers on one durable control plane. The BullMQ transport speaks the aviary wire byte-for-byte, so a step dispatched by an Adonis engine can execute on a Python worker and flow back.
Linting for non-determinism
Catch Date.now(), Math.random(), process.env, swallowed control-flow signals and direct I/O inside a workflow body at author time with @adonis-agora/durable-eslint-plugin — AST-scoped ESLint rules that know both the engine.register() function form and a BaseWorkflow class.