durable-worker 0.1.0__py3-none-any.whl
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,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})
|
durable_worker/worker.py
ADDED
|
@@ -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,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,6 @@
|
|
|
1
|
+
durable_worker/__init__.py,sha256=yXpxnDgkqnuIgw88S02sqLoSY6g4BBcpLnLDj0dbLXo,452
|
|
2
|
+
durable_worker/redis_runner.py,sha256=-LshlQjSzsAOK61Bwg8XTYGsVIOBPHXP2rPLtWawTIk,1514
|
|
3
|
+
durable_worker/worker.py,sha256=wVE3An0fimJIwsdAXiyMAEkZ8duzcnmjtJIIHALBJ88,3947
|
|
4
|
+
durable_worker-0.1.0.dist-info/METADATA,sha256=cOgqrKvsCVU4NFvXF_imYxb9aPP13K9RQ6hAfMfxODQ,4339
|
|
5
|
+
durable_worker-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
durable_worker-0.1.0.dist-info/RECORD,,
|