durable-worker (Python)
Run nestjs-durable workflow steps in Python. A TypeScript workflow dispatches
a step by name with ctx.step("payments.charge-card", input) (or by reference for a same-runtime
@Step); the orchestrator dispatches it over the transport; a Python worker registered for the
same step name runs it and returns the result. One workflow, steps split across languages.
from durable_worker import Worker, FatalError
worker = Worker()
@worker.step("payments.charge-card")
async def charge(data):
res = await stripe.charge(data["orderId"], data["amountCents"])
return {"chargeId": res.id}
# worker.run(transport=...) # see "Transports" below
The handler's argument is the step input (already schema-validated by the engine); its
return value is the step output. Raise FatalError for a non-retryable failure (e.g. a
declined card); any other exception is treated as retryable and the engine applies the step's
retry policy.
Authoring workflows in Python (coordinator-driven)
The inverse of the above: instead of Python implementing a step a TypeScript workflow calls, Python can author the whole workflow and call back into NestJS. The NestJS engine stays the sole owner of durable state, recovery and timers — it advances a run one turn at a time by sending this worker the run's history; the worker replays the workflow function locally and returns the commands it produced (call / record-step / sleep …), which the engine persists and dispatches. The worker never touches a store, so it stays a pure function of the task (Temporal-style coordinator).
from durable_worker import WorkflowWorker, redis_url_from_env
workflows = WorkflowWorker(group="py-workflows")
@workflows.workflow("pipeline")
def pipeline(ctx, base_id):
started_at = ctx.now() # replay-stable capture
rows = ctx.step("ingestion", {"key": f"/{base_id}/data.csv"}, group="pipeline") # dispatched
ctx.sleep(60_000) # durable timer
return {"rows": rows, "startedAt": started_at}
workflows.run(redis=redis_url_from_env()) # owns the loop, SIGTERM graceful close, Redis connection
The WorkflowContext ops are deterministic — same code + same history ⇒ same seqs ⇒ same
decisions:
| Op | Meaning |
|---|---|
ctx.step(name, input, group=...) |
Dispatch a step (routed by handler name, any language) and await its result. ALWAYS durable, ALWAYS engine-scheduled — one step primitive, no local/remote placement choice. |
ctx.now() |
A replay-stable wall-clock timestamp in epoch milliseconds (a number, like JS Date.now()) — captured once, replayed thereafter (instead of forcing a trivial capture through a dispatched step). |
ctx.side_effect(fn) |
The general deterministic-capture primitive (Temporal's sideEffect): run fn once, checkpoint its result, and replay the same value without re-running fn. Use for ids/random/env reads, e.g. ctx.side_effect(lambda: str(uuid7())). |
ctx.sleep(ms) |
Durable timer — the run suspends and the engine resumes it when the timer fires. |
ctx.wait_signal(name) |
Block until a signal name is delivered to the run (via engine.signal); returns its payload. |
ctx.start_child(workflow, input) |
Start a child run and await its output (a failed child raises StepFailed). |
A step that fails raises StepFailed in the workflow — catch it to compensate (just like an
awaited rejection), or let it propagate to fail the run. Changing the workflow's op sequence under a
run already in flight raises NondeterminismError rather than silently diverging.
On the NestJS side, start pipeline by its name — the queue name is the routing, so whichever live
worker group serves pipeline picks it up:
await engine.start('pipeline', input); // or `ctx.child('pipeline', input)` from another workflow
These ops are wired end-to-end — the engine executes the commands they emit (dispatched step,
replay-stable capture, durable timer, signal waiter, child run). WorkflowWorker.process_task(task) -> decision is the pure, broker-free core (fully tested). The workflow-task/decision wire is
specified in
docs/plans/2026-06-15-polyglot-workflows-protocol.md.
Wire protocol
The contract between the orchestrator and a worker is plain JSON — language-agnostic, so a Go or Rust worker can implement the same thing. The orchestrator dispatches a task:
{
"runId": "wrun_8Kb2", // the workflow run
"seq": 1, // deterministic step position
"name": "payments.charge-card", // handler name (the contract)
"stepId": "wrun_8Kb2:1", // stable id — use it to dedupe re-delivery
"group": "payments", // worker group expected to handle it
"input": { "orderId": "o1", "amountCents": 4200 },
"attempt": 1,
"traceparent": "00-..." // optional W3C trace context to continue the span
}
The worker replies with a result:
// success
{ "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "completed", "output": { "chargeId": "ch_1" } }
// failure
{ "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "failed",
"error": { "message": "card declined", "code": "declined", "retryable": false } }
Worker.process_task(task) -> result is the pure core (no transport, fully tested). Idempotency
note: if the worker dies after running but before the result is recorded, the engine may
re-dispatch the same stepId — make handlers idempotent or dedupe on stepId.
Transports
process_task is transport-agnostic. A transport adapter consumes tasks from the broker and
ships results back:
-
Redis / BullMQ (
pip install durable-worker[redis]) —durable_worker.redis_runnerconsumes the same Redis queues@dudousxd/nestjs-durable-transport-bullmqdispatches to:import asyncio from durable_worker import Worker from durable_worker.redis_runner import run_redis_worker worker = Worker() @worker.step("payments.charge-card") async def charge(data): return {"chargeId": f"ch_{data['amount']}"} async def main(): await run_redis_worker(worker) await asyncio.Event().wait() asyncio.run(main())
This is wired end-to-end in
scripts/py-e2e.sh: a TypeScript workflow'sctx.stepruns this Python handler over Redis and gets the result back. -
AWS SQS (
pip install durable-worker[sqs]) —durable_worker.sqs_runner.run_sqs_workerlong-polls the same SQS queues the TSSqsTransportuses. Blocking loop; pass athreading.Eventasstopto stop it. -
SQL / Postgres / MySQL (
pip install durable-worker[postgres]or[mysql]) —durable_worker.db_runner.run_db_workeris broker-less: it claims task rows withSELECT … FOR UPDATE SKIP LOCKEDfrom the same tables the TSDbTransportwrites, runs the handler, and writes a result row. Implements the documented table + claim contract, so the two libraries share the schema. Requires Postgres 9.5+ or MySQL 8+. -
Bring your own: anything that can deliver a task dict and accept a result dict.
Tests
python -m unittest discover -s tests
Metadata
Release files for durable-worker 0.23.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| durable_worker-0.23.1.tar.gz | 89.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| durable_worker-0.23.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 152.7 kB
Release files / durable_worker-0.23.1.tar.gz
| Download URL | durable_worker-0.23.1.tar.gz |
|---|---|
| Size | 89.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b95ba0fb4afc1dd8d00ec0fd28ba910a13eb1e1d80032b1779ff14429ac68994
|
|
BLAKE2b-256 checksum How to use checksums |
666e60723764320d4804573c221279f68eb55938a4afbdbbbbfa0f7e9e251724
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency logRelease files / durable_worker-0.23.1-py3-none-any.whl
| Download URL | durable_worker-0.23.1-py3-none-any.whl |
|---|---|
| Size | 63.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9b9df458a5345c7c3e3ad74550e50f5e15891eda85d6054019cbd50038bc4f0
|
|
BLAKE2b-256 checksum How to use checksums |
f516cd8af6a20e125387b1a7d6d3d4bea6f709853371f31d8bc66a35ba97fd89
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency logSecurity for durable-worker 0.23.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for durable_worker-0.23.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.