durable-worker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ .turbo/
5
+ *.log
6
+ .DS_Store
7
+ .env
8
+ *.tsbuildinfo
9
+ __pycache__/
10
+ *.pyc
11
+ .pytest_cache/
12
+
13
+ # prisma adapter generated client + sqlite test db
14
+ packages/store-prisma/generated/
15
+ packages/store-prisma/prisma/*.db
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: durable-worker
3
+ Version: 0.1.0
4
+ Summary: Python worker SDK for nestjs-durable — run durable workflow steps in Python.
5
+ Project-URL: Homepage, https://github.com/DavideCarvalho/nestjs-durable
6
+ Project-URL: Repository, https://github.com/DavideCarvalho/nestjs-durable
7
+ Project-URL: Issues, https://github.com/DavideCarvalho/nestjs-durable/issues
8
+ Author-email: Davide Carvalho <davi@goflip.ai>
9
+ License: MIT
10
+ Keywords: durable,nestjs,worker,workflow
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Provides-Extra: redis
21
+ Requires-Dist: bullmq>=2.0; extra == 'redis'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # durable-worker (Python)
25
+
26
+ Run [`nestjs-durable`](../../README.md) workflow steps in Python. A TypeScript workflow calls a
27
+ remote step with `ctx.call(chargeCard, input)`; the orchestrator dispatches it over the
28
+ transport; a Python worker registered for the same step **name** runs it and returns the result.
29
+ One workflow, steps split across languages.
30
+
31
+ ```python
32
+ from durable_worker import Worker, FatalError
33
+
34
+ worker = Worker(group="payments")
35
+
36
+ @worker.step("payments.charge-card")
37
+ async def charge(data):
38
+ res = await stripe.charge(data["orderId"], data["amountCents"])
39
+ return {"chargeId": res.id}
40
+
41
+ # worker.run(transport=...) # see "Transports" below
42
+ ```
43
+
44
+ The handler's argument is the step **input** (already schema-validated by the engine); its
45
+ return value is the step **output**. Raise `FatalError` for a non-retryable failure (e.g. a
46
+ declined card); any other exception is treated as retryable and the engine applies the step's
47
+ retry policy.
48
+
49
+ ## Wire protocol
50
+
51
+ The contract between the orchestrator and a worker is plain JSON — language-agnostic, so a Go or
52
+ Rust worker can implement the same thing. The orchestrator dispatches a **task**:
53
+
54
+ ```jsonc
55
+ {
56
+ "runId": "wrun_8Kb2", // the workflow run
57
+ "seq": 1, // deterministic step position
58
+ "name": "payments.charge-card", // handler name (the contract)
59
+ "stepId": "wrun_8Kb2:1", // stable id — use it to dedupe re-delivery
60
+ "group": "payments", // worker group expected to handle it
61
+ "input": { "orderId": "o1", "amountCents": 4200 },
62
+ "attempt": 1,
63
+ "traceparent": "00-..." // optional W3C trace context to continue the span
64
+ }
65
+ ```
66
+
67
+ The worker replies with a **result**:
68
+
69
+ ```jsonc
70
+ // success
71
+ { "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "completed", "output": { "chargeId": "ch_1" } }
72
+ // failure
73
+ { "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "failed",
74
+ "error": { "message": "card declined", "code": "declined", "retryable": false } }
75
+ ```
76
+
77
+ `Worker.process_task(task) -> result` is the pure core (no transport, fully tested). Idempotency
78
+ note: if the worker dies after running but before the result is recorded, the engine may
79
+ re-dispatch the same `stepId` — make handlers idempotent or dedupe on `stepId`.
80
+
81
+ ## Transports
82
+
83
+ `process_task` is transport-agnostic. A transport adapter consumes tasks from the broker and
84
+ ships results back:
85
+
86
+ - **Redis / BullMQ** (`pip install durable-worker[redis]`) — `durable_worker.redis_runner`
87
+ consumes the same Redis queues `@dudousxd/nestjs-durable-transport-bullmq` dispatches to:
88
+
89
+ ```python
90
+ import asyncio
91
+ from durable_worker import Worker
92
+ from durable_worker.redis_runner import run_redis_worker
93
+
94
+ worker = Worker(group="payments")
95
+
96
+ @worker.step("payments.charge-card")
97
+ async def charge(data):
98
+ return {"chargeId": f"ch_{data['amount']}"}
99
+
100
+ async def main():
101
+ await run_redis_worker(worker, group="payments")
102
+ await asyncio.Event().wait()
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ This is wired end-to-end in [`scripts/py-e2e.sh`](../../scripts/py-e2e.sh): a TypeScript
108
+ workflow's `ctx.call` runs this Python handler over Redis and gets the result back.
109
+ - Bring your own: anything that can deliver a task dict and accept a result dict.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ python -m unittest discover -s tests
115
+ ```
@@ -0,0 +1,92 @@
1
+ # durable-worker (Python)
2
+
3
+ Run [`nestjs-durable`](../../README.md) workflow steps in Python. A TypeScript workflow calls a
4
+ remote step with `ctx.call(chargeCard, input)`; the orchestrator dispatches it over the
5
+ transport; a Python worker registered for the same step **name** runs it and returns the result.
6
+ One workflow, steps split across languages.
7
+
8
+ ```python
9
+ from durable_worker import Worker, FatalError
10
+
11
+ worker = Worker(group="payments")
12
+
13
+ @worker.step("payments.charge-card")
14
+ async def charge(data):
15
+ res = await stripe.charge(data["orderId"], data["amountCents"])
16
+ return {"chargeId": res.id}
17
+
18
+ # worker.run(transport=...) # see "Transports" below
19
+ ```
20
+
21
+ The handler's argument is the step **input** (already schema-validated by the engine); its
22
+ return value is the step **output**. Raise `FatalError` for a non-retryable failure (e.g. a
23
+ declined card); any other exception is treated as retryable and the engine applies the step's
24
+ retry policy.
25
+
26
+ ## Wire protocol
27
+
28
+ The contract between the orchestrator and a worker is plain JSON — language-agnostic, so a Go or
29
+ Rust worker can implement the same thing. The orchestrator dispatches a **task**:
30
+
31
+ ```jsonc
32
+ {
33
+ "runId": "wrun_8Kb2", // the workflow run
34
+ "seq": 1, // deterministic step position
35
+ "name": "payments.charge-card", // handler name (the contract)
36
+ "stepId": "wrun_8Kb2:1", // stable id — use it to dedupe re-delivery
37
+ "group": "payments", // worker group expected to handle it
38
+ "input": { "orderId": "o1", "amountCents": 4200 },
39
+ "attempt": 1,
40
+ "traceparent": "00-..." // optional W3C trace context to continue the span
41
+ }
42
+ ```
43
+
44
+ The worker replies with a **result**:
45
+
46
+ ```jsonc
47
+ // success
48
+ { "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "completed", "output": { "chargeId": "ch_1" } }
49
+ // failure
50
+ { "runId": "wrun_8Kb2", "seq": 1, "stepId": "wrun_8Kb2:1", "status": "failed",
51
+ "error": { "message": "card declined", "code": "declined", "retryable": false } }
52
+ ```
53
+
54
+ `Worker.process_task(task) -> result` is the pure core (no transport, fully tested). Idempotency
55
+ note: if the worker dies after running but before the result is recorded, the engine may
56
+ re-dispatch the same `stepId` — make handlers idempotent or dedupe on `stepId`.
57
+
58
+ ## Transports
59
+
60
+ `process_task` is transport-agnostic. A transport adapter consumes tasks from the broker and
61
+ ships results back:
62
+
63
+ - **Redis / BullMQ** (`pip install durable-worker[redis]`) — `durable_worker.redis_runner`
64
+ consumes the same Redis queues `@dudousxd/nestjs-durable-transport-bullmq` dispatches to:
65
+
66
+ ```python
67
+ import asyncio
68
+ from durable_worker import Worker
69
+ from durable_worker.redis_runner import run_redis_worker
70
+
71
+ worker = Worker(group="payments")
72
+
73
+ @worker.step("payments.charge-card")
74
+ async def charge(data):
75
+ return {"chargeId": f"ch_{data['amount']}"}
76
+
77
+ async def main():
78
+ await run_redis_worker(worker, group="payments")
79
+ await asyncio.Event().wait()
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ This is wired end-to-end in [`scripts/py-e2e.sh`](../../scripts/py-e2e.sh): a TypeScript
85
+ workflow's `ctx.call` runs this Python handler over Redis and gets the result back.
86
+ - Bring your own: anything that can deliver a task dict and accept a result dict.
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ python -m unittest discover -s tests
92
+ ```
@@ -0,0 +1,11 @@
1
+ """durable-worker — Python SDK for running nestjs-durable remote steps.
2
+
3
+ A worker registers step handlers by name and processes tasks dispatched by the orchestrator.
4
+ The wire protocol (task in, result out) is plain JSON and identical across languages, so the
5
+ same step name implemented here is callable from a TypeScript workflow via ``ctx.call``.
6
+ """
7
+
8
+ from .worker import FatalError, Worker
9
+
10
+ __all__ = ["Worker", "FatalError"]
11
+ __version__ = "0.1.0"
@@ -0,0 +1,42 @@
1
+ """Run a :class:`Worker` against the BullMQ/Redis transport.
2
+
3
+ Consumes the orchestrator's per-group tasks queue and publishes results on the shared results
4
+ queue — the same queues a TypeScript ``BullMQTransport`` uses, so steps interoperate across
5
+ languages. Requires the optional ``bullmq`` extra: ``pip install durable-worker[redis]``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .worker import Worker
13
+
14
+
15
+ def _names(prefix: str, group: str) -> tuple[str, str]:
16
+ # Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
17
+ return f"{prefix}-tasks-{group}", f"{prefix}-results"
18
+
19
+
20
+ async def run_redis_worker(
21
+ worker: Worker,
22
+ *,
23
+ group: str,
24
+ connection: str = "redis://localhost:6379",
25
+ prefix: str = "durable",
26
+ ) -> Any:
27
+ """Start a BullMQ worker that runs ``worker``'s handlers. Returns the bullmq Worker.
28
+
29
+ The returned worker runs in the background; ``await worker.close()`` to stop it.
30
+ """
31
+
32
+ from bullmq import Queue as BullQueue # imported lazily so the SDK works without bullmq
33
+ from bullmq import Worker as BullWorker
34
+
35
+ tasks_name, results_name = _names(prefix, group)
36
+ results = BullQueue(results_name, {"connection": connection})
37
+
38
+ async def process(job: Any, _token: str) -> None:
39
+ result = await worker.aprocess_task(job.data)
40
+ await results.add("result", result, {"removeOnComplete": True, "removeOnFail": True})
41
+
42
+ return BullWorker(tasks_name, process, {"connection": connection})
@@ -0,0 +1,108 @@
1
+ """Core worker: a name->handler registry and the pure task->result dispatch.
2
+
3
+ Transport (Redis/BullMQ/NATS) is intentionally separate — `process_task` is a pure function of
4
+ the task, so it is fully testable without any broker. A transport adapter just feeds tasks in
5
+ and ships results out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ from typing import Any, Awaitable, Callable, Dict, Union
13
+
14
+ Handler = Callable[[Any], Union[Any, Awaitable[Any]]]
15
+
16
+
17
+ class FatalError(Exception):
18
+ """Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
19
+
20
+ The engine will not retry the step regardless of its ``retries`` setting.
21
+ """
22
+
23
+ def __init__(self, message: str, code: str | None = None) -> None:
24
+ super().__init__(message)
25
+ self.code = code
26
+
27
+
28
+ class Worker:
29
+ """Registers step handlers by name and turns a dispatched task into a result.
30
+
31
+ Example::
32
+
33
+ worker = Worker(group="payments")
34
+
35
+ @worker.step("payments.charge-card")
36
+ async def charge(data):
37
+ res = await stripe.charge(data["orderId"], data["amountCents"])
38
+ return {"chargeId": res.id}
39
+ """
40
+
41
+ def __init__(self, group: str = "default") -> None:
42
+ self.group = group
43
+ self._handlers: Dict[str, Handler] = {}
44
+
45
+ def step(self, name: str) -> Callable[[Handler], Handler]:
46
+ """Decorator registering ``fn`` as the handler for step ``name``."""
47
+
48
+ def register(fn: Handler) -> Handler:
49
+ self._handlers[name] = fn
50
+ return fn
51
+
52
+ return register
53
+
54
+ def handles(self, name: str) -> bool:
55
+ return name in self._handlers
56
+
57
+ def process_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
58
+ """Run the handler for ``task`` and return a wire-format result.
59
+
60
+ Pure and synchronous from the caller's view (async handlers are awaited internally), so a
61
+ transport can simply ``result = worker.process_task(task); send(result)``.
62
+ """
63
+
64
+ base = {"runId": task["runId"], "seq": task["seq"], "stepId": task["stepId"]}
65
+ handler = self._handlers.get(task["name"])
66
+ if handler is None:
67
+ return self._no_handler(base, task["name"])
68
+ try:
69
+ output = handler(task.get("input"))
70
+ if inspect.isawaitable(output):
71
+ output = asyncio.run(output)
72
+ return {**base, "status": "completed", "output": output}
73
+ except Exception as err: # noqa: BLE001
74
+ return self._failure(base, err)
75
+
76
+ async def aprocess_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
77
+ """Async variant — awaits async handlers in the current loop. Use from a transport that
78
+ already runs inside an event loop (e.g. the BullMQ runner)."""
79
+
80
+ base = {"runId": task["runId"], "seq": task["seq"], "stepId": task["stepId"]}
81
+ handler = self._handlers.get(task["name"])
82
+ if handler is None:
83
+ return self._no_handler(base, task["name"])
84
+ try:
85
+ output = handler(task.get("input"))
86
+ if inspect.isawaitable(output):
87
+ output = await output
88
+ return {**base, "status": "completed", "output": output}
89
+ except Exception as err: # noqa: BLE001
90
+ return self._failure(base, err)
91
+
92
+ @staticmethod
93
+ def _no_handler(base: Dict[str, Any], name: str) -> Dict[str, Any]:
94
+ return {
95
+ **base,
96
+ "status": "failed",
97
+ "error": {"message": f"no handler for {name}", "retryable": False},
98
+ }
99
+
100
+ @staticmethod
101
+ def _failure(base: Dict[str, Any], err: Exception) -> Dict[str, Any]:
102
+ if isinstance(err, FatalError):
103
+ return {
104
+ **base,
105
+ "status": "failed",
106
+ "error": {"message": str(err), "code": err.code, "retryable": False},
107
+ }
108
+ return {**base, "status": "failed", "error": {"message": str(err)}}
@@ -0,0 +1,31 @@
1
+ """Run a Python worker that handles `payments.charge-card` over BullMQ/Redis.
2
+
3
+ PYTHONPATH=clients/python python3 clients/python/examples/run_worker.py <prefix>
4
+
5
+ It consumes the same Redis queues a TypeScript `BullMQTransport` dispatches to, so a TS workflow
6
+ can `ctx.call` this Python step. Returns a chargeId prefixed `ch_py_` to prove it ran in Python.
7
+ """
8
+
9
+ import asyncio
10
+ import sys
11
+
12
+ from durable_worker import Worker
13
+ from durable_worker.redis_runner import run_redis_worker
14
+
15
+ worker = Worker(group="payments")
16
+
17
+
18
+ @worker.step("payments.charge-card")
19
+ async def charge(data):
20
+ return {"chargeId": f"ch_py_{data['amount']}"}
21
+
22
+
23
+ async def main():
24
+ prefix = sys.argv[1] if len(sys.argv) > 1 else "durable"
25
+ await run_redis_worker(worker, group="payments", prefix=prefix)
26
+ print(f"[python worker] consuming {prefix}-tasks-payments", flush=True)
27
+ await asyncio.Event().wait() # run until killed
28
+
29
+
30
+ if __name__ == "__main__":
31
+ asyncio.run(main())
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "durable-worker"
3
+ version = "0.1.0"
4
+ description = "Python worker SDK for nestjs-durable — run durable workflow steps in Python."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Davide Carvalho", email = "davi@goflip.ai" }]
9
+ keywords = ["workflow", "durable", "nestjs", "worker"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Libraries",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/DavideCarvalho/nestjs-durable"
23
+ Repository = "https://github.com/DavideCarvalho/nestjs-durable"
24
+ Issues = "https://github.com/DavideCarvalho/nestjs-durable/issues"
25
+
26
+ [project.optional-dependencies]
27
+ redis = ["bullmq>=2.0"]
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["durable_worker"]
@@ -0,0 +1,73 @@
1
+ import unittest
2
+
3
+ from durable_worker import FatalError, Worker
4
+
5
+
6
+ def task(name="payments.charge-card", **over):
7
+ base = {
8
+ "runId": "r1",
9
+ "seq": 0,
10
+ "name": name,
11
+ "stepId": "r1:0",
12
+ "group": "payments",
13
+ "input": {"orderId": "o1", "amountCents": 4200},
14
+ "attempt": 1,
15
+ }
16
+ base.update(over)
17
+ return base
18
+
19
+
20
+ class WorkerTest(unittest.TestCase):
21
+ def test_runs_a_registered_sync_handler(self):
22
+ worker = Worker(group="payments")
23
+
24
+ @worker.step("payments.charge-card")
25
+ def charge(data):
26
+ return {"chargeId": f"ch_{data['orderId']}"}
27
+
28
+ result = worker.process_task(task())
29
+ self.assertEqual(result["status"], "completed")
30
+ self.assertEqual(result["output"], {"chargeId": "ch_o1"})
31
+ self.assertEqual(result["stepId"], "r1:0")
32
+
33
+ def test_awaits_async_handlers(self):
34
+ worker = Worker()
35
+
36
+ @worker.step("payments.charge-card")
37
+ async def charge(data):
38
+ return {"chargeId": "ch_async"}
39
+
40
+ self.assertEqual(worker.process_task(task())["output"], {"chargeId": "ch_async"})
41
+
42
+ def test_unknown_step_is_a_non_retryable_failure(self):
43
+ result = Worker().process_task(task(name="missing"))
44
+ self.assertEqual(result["status"], "failed")
45
+ self.assertFalse(result["error"]["retryable"])
46
+
47
+ def test_handler_exception_is_a_retryable_failure(self):
48
+ worker = Worker()
49
+
50
+ @worker.step("payments.charge-card")
51
+ def charge(_data):
52
+ raise RuntimeError("network blip")
53
+
54
+ result = worker.process_task(task())
55
+ self.assertEqual(result["status"], "failed")
56
+ self.assertEqual(result["error"]["message"], "network blip")
57
+ self.assertNotIn("retryable", result["error"])
58
+
59
+ def test_fatal_error_is_not_retryable(self):
60
+ worker = Worker()
61
+
62
+ @worker.step("payments.charge-card")
63
+ def charge(_data):
64
+ raise FatalError("card declined", code="declined")
65
+
66
+ result = worker.process_task(task())
67
+ self.assertEqual(result["status"], "failed")
68
+ self.assertFalse(result["error"]["retryable"])
69
+ self.assertEqual(result["error"]["code"], "declined")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()