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.
@adonis-agora/durable and the aviary durable engine (nestjs-durable, plus its Python durable-worker client) speak the same wire protocol, byte-for-byte. That means a single control plane — in any of the three runtimes — can dispatch work to workers written in any of the others. An Adonis engine can run a workflow whose steps execute on a Python worker; a NestJS control plane can hand a step to an Adonis worker.
This is proven with live bidirectional interop tests, not just a shared spec: an Adonis control plane dispatching to a real Python durable-worker, and a NestJS control plane dispatching to a store-less Adonis worker — across BullMQ major versions (bullmq 5.x on Node, 2.x on Python), results flowing back correctly.
The BullMQ transport
Interop rides one transport: transports.bullmq(...), built on the real bullmq npm package with the identical job structure the Python raw-Redis runner mirrors.
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' }) },
})The transport's own options (connection, fixed and adaptive concurrency, resultConcurrency, priority, namespace and partition, crash recovery, shutdown) are covered on the BullMQ transport page. The parts that matter for a mixed fleet are summarized below.
Workers: concurrency
concurrency (a number, or 'adaptive') sets how many tasks each of an Adonis worker's task queues runs at once. The adaptive controller is the same one as the NestJS BullMQ transport's concurrency: 'adaptive', and the live status it stamps on the worker heartbeat uses the same WorkerStatus shape as the NestJS fleet, so the dashboard reads both the same way. It passes a worker's status through as-is and renders the fields it recognizes, so a worker that only writes { ts } still appears as live. See Concurrency.
Results: resultConcurrency
resultConcurrency raises how many step results an engine-side instance holds at once from the shared results queue (at least 512). Results from Python or NestJS workers land on the same queue and are settled the same way. See Result concurrency.
Shutdown: closeTimeoutMs
closeTimeoutMs (default 5000 ms) limits how long a graceful close() waits before the transport disconnects anyway. See Shutdown.
bullmq is the only transport all three runtimes share. The queue and db transports carry steps, remote workflow turns and tenant pods between Adonis processes only; a Python or NestJS worker cannot consume them (see Choosing a transport).
How the wire lines up
Every cross-process DTO matches the aviary field-for-field. The BullMQ transport carries the task, result and decision queues and the worker registry; the control broadcast comes from controlPlanes.redis, which you configure alongside it (the transport does not publish that channel):
| Purpose | Channel | Mechanism |
|---|---|---|
| Task dispatch (step + workflow) | ${P}-tasks-${token} | BullMQ queue |
| Step result | ${P}-results | queue |
| Workflow decision | ${P}-decisions | queue |
Control broadcast (controlPlanes.redis) | ${prefix}-control | Redis pub/sub |
| Worker liveness | ${P}-worker-heartbeat:${token}:${instance} | Redis key, EX 35 |
| Worker descriptor | ${P}-worker-descriptor:${token}:${instance} | Redis key |
where P is the effective prefix (durable, or durable-<namespace>), prefix is the control plane's own prefix option (default durable), and the routing token is <name>@<partition> when a partition is set. Serialization is plain JSON with the DTO as the job data — the worker discriminates step vs workflow by shape. Dates are epoch-ms on cross-process DTOs (ISO only where nested in an EngineEvent/WorkflowRun), and BullMQ priority is inverted the same way on every side. The handshake descriptor's descriptorHash even agrees three-way (Python folds UTF-16 code units to match JS charCodeAt), so negotiation is cross-SDK.
Running a polyglot fleet
-
Adonis control plane — configure the
bullmqtransport (above), addcontrolPlane: controlPlanes.redis({ connection: 'main' })on the same Redis if Python handlers should observe cancellation, and run the store-backed loop:node ace durable:work -
Python worker — install the aviary client (
pip install durable-worker[redis]) and register a handler under the same handler name your Adonis workflow calls withctx.step('py-echo', ...).run_redis_workerconsumes the same Redis queues the BullMQ transport dispatches to:import asyncio from durable_worker import Worker from durable_worker.redis_runner import run_redis_worker worker = Worker() @worker.step("py-echo") async def py_echo(data): return {"echoed": data} async def main(): await run_redis_worker(worker) await asyncio.Event().wait() asyncio.run(main()) -
Start a run on the Adonis side. The
ctx.step('py-echo')task lands on${P}-tasks-py-echo, the Python worker consumes and executes it, publishes on${P}-results, and the Adonis run resumes and completes — with the Python-computed output.
Version safety across runtimes
Because every worker advertises a descriptor and the control plane negotiates before dispatching, a polyglot fleet is version-safe: an incompatible protocol major is refused loudly, an optional capability a Python worker lacks routes that work only to workers that have it, and a run requiring a capability no live worker provides parks blocked until one appears — never a silent cross-language hang.
Learn more
- Python workers & workflows — the
durable-workerclient (PyPI): implement steps in Python, or author whole workflows the Adonis engine drives by convention (routed by name). - The aviary engine and its Python client live in the
nestjs-durablerepo. - Handshake & negotiation — the compatibility layer that makes a mixed-version fleet safe.
Handshake & negotiation
How a mixed fleet stays version-safe — workers advertise a capability descriptor, the control plane negotiates compatibility, and work routes only to workers that can run it. Runs park blocked instead of hanging.
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.