durable-worker 0.2.0__tar.gz → 0.4.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.
- {durable_worker-0.2.0 → durable_worker-0.4.0}/PKG-INFO +1 -1
- durable_worker-0.4.0/durable_worker/__init__.py +37 -0
- durable_worker-0.4.0/durable_worker/cancellation.py +49 -0
- durable_worker-0.4.0/durable_worker/redis_runner.py +173 -0
- durable_worker-0.4.0/durable_worker/routing.py +27 -0
- durable_worker-0.4.0/durable_worker/worker.py +388 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/pyproject.toml +1 -1
- durable_worker-0.4.0/tests/test_cancellation.py +68 -0
- durable_worker-0.4.0/tests/test_redis_url.py +51 -0
- durable_worker-0.4.0/tests/test_routing.py +23 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/tests/test_worker.py +101 -1
- durable_worker-0.2.0/durable_worker/__init__.py +0 -11
- durable_worker-0.2.0/durable_worker/redis_runner.py +0 -42
- durable_worker-0.2.0/durable_worker/worker.py +0 -212
- {durable_worker-0.2.0 → durable_worker-0.4.0}/.gitignore +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/README.md +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/durable_worker/db_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/durable_worker/sqs_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/examples/run_worker.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/tests/test_db_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.4.0}/tests/test_sqs_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: durable-worker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python worker SDK for nestjs-durable — run durable workflow steps in Python.
|
|
5
5
|
Project-URL: Homepage, https://github.com/DavideCarvalho/nestjs-durable
|
|
6
6
|
Project-URL: Repository, https://github.com/DavideCarvalho/nestjs-durable
|
|
@@ -0,0 +1,37 @@
|
|
|
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 .cancellation import Cancelled, CancellationRegistry
|
|
9
|
+
from .redis_runner import redis_url_from_env, run_redis_worker
|
|
10
|
+
from .routing import reply_target
|
|
11
|
+
from .worker import (
|
|
12
|
+
FatalError,
|
|
13
|
+
StepContext,
|
|
14
|
+
Worker,
|
|
15
|
+
current_step,
|
|
16
|
+
log,
|
|
17
|
+
set_process,
|
|
18
|
+
sub,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Worker",
|
|
23
|
+
"FatalError",
|
|
24
|
+
"StepContext",
|
|
25
|
+
"Cancelled",
|
|
26
|
+
"CancellationRegistry",
|
|
27
|
+
"reply_target",
|
|
28
|
+
# Context-local step access — record events from anywhere inside a handler without threading ctx.
|
|
29
|
+
"current_step",
|
|
30
|
+
"log",
|
|
31
|
+
"sub",
|
|
32
|
+
"set_process",
|
|
33
|
+
# Transport bootstrap helpers.
|
|
34
|
+
"run_redis_worker",
|
|
35
|
+
"redis_url_from_env",
|
|
36
|
+
]
|
|
37
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Cooperative cancellation for the Python worker.
|
|
2
|
+
|
|
3
|
+
The orchestrator broadcasts a ``{"kind": "cancel", "runId": ...}`` control message across every
|
|
4
|
+
instance (Redis pub/sub, same channel the TypeScript control plane uses). A long-running handler
|
|
5
|
+
can't be force-killed, so cancellation is *cooperative*: a runner subscribes to that channel and
|
|
6
|
+
records the cancelled run ids here, and the handler checks ``ctx.cancelled`` (or calls
|
|
7
|
+
``ctx.raise_if_cancelled()``) at safe points to bail out early instead of finishing work whose
|
|
8
|
+
result will be discarded.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any, Dict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cancelled(Exception):
|
|
18
|
+
"""Raised by ``StepContext.raise_if_cancelled`` when the run has been cancelled."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, run_id: str) -> None:
|
|
21
|
+
super().__init__(f"run {run_id} was cancelled")
|
|
22
|
+
self.run_id = run_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CancellationRegistry:
|
|
26
|
+
"""Thread-safe set of cancelled run ids, fed by a control-channel subscription."""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._cancelled: set[str] = set()
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def cancel(self, run_id: str) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._cancelled.add(run_id)
|
|
35
|
+
|
|
36
|
+
def is_cancelled(self, run_id: str) -> bool:
|
|
37
|
+
with self._lock:
|
|
38
|
+
return run_id in self._cancelled
|
|
39
|
+
|
|
40
|
+
def clear(self, run_id: str) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._cancelled.discard(run_id)
|
|
43
|
+
|
|
44
|
+
def on_control_message(self, msg: Dict[str, Any]) -> None:
|
|
45
|
+
"""Feed a decoded control message; records the run id when it's a cancel."""
|
|
46
|
+
if isinstance(msg, dict) and msg.get("kind") == "cancel":
|
|
47
|
+
run_id = msg.get("runId")
|
|
48
|
+
if isinstance(run_id, str):
|
|
49
|
+
self.cancel(run_id)
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
It also subscribes to the orchestrator's control channel (``<prefix>-control``) so a long handler
|
|
8
|
+
can observe cooperative cancellation via ``ctx.cancelled`` — the cross-language half of
|
|
9
|
+
``engine.cancel``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
from typing import Any, Callable, Dict, Optional
|
|
19
|
+
|
|
20
|
+
from .cancellation import CancellationRegistry
|
|
21
|
+
from .worker import Worker
|
|
22
|
+
|
|
23
|
+
# Stable-ish id for the `from` field of control messages this worker publishes. It only has to
|
|
24
|
+
# DIFFER from the engine instanceIds (so a dashboard engine doesn't treat our progress events as its
|
|
25
|
+
# own echo and drop them) — host + pid is plenty and avoids importing a uuid/random dependency.
|
|
26
|
+
_INSTANCE_ID = f"py-{socket.gethostname()}-{os.getpid()}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def redis_url_from_env(prefix: str = "REDIS") -> str:
|
|
30
|
+
"""Build a ``redis://`` URL from ``{prefix}_HOST/_PORT/_USERNAME/_PASSWORD`` env vars. The
|
|
31
|
+
credentials are URL-encoded — a generated password often contains ``@ : /`` which would corrupt
|
|
32
|
+
the netloc if left raw. Defaults to ``localhost:6379`` with no auth. Handy for ``Worker.run``."""
|
|
33
|
+
from urllib.parse import quote
|
|
34
|
+
|
|
35
|
+
host = os.getenv(f"{prefix}_HOST", "localhost")
|
|
36
|
+
port = os.getenv(f"{prefix}_PORT", "6379")
|
|
37
|
+
user = os.getenv(f"{prefix}_USERNAME") or ""
|
|
38
|
+
password = os.getenv(f"{prefix}_PASSWORD") or ""
|
|
39
|
+
auth = f"{quote(user, safe='')}:{quote(password, safe='')}@" if (user or password) else ""
|
|
40
|
+
return f"redis://{auth}{host}:{port}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _names(prefix: str, group: str) -> tuple[str, str]:
|
|
44
|
+
# Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
|
|
45
|
+
return f"{prefix}-tasks-{group}", f"{prefix}-results"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _control_channel(prefix: str) -> str:
|
|
49
|
+
# Mirrors BullMQTransport.controlChannel(): '<prefix>-control'.
|
|
50
|
+
return f"{prefix}-control"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _progress_message(task: Dict[str, Any], event: Dict[str, Any]) -> str:
|
|
54
|
+
"""A control-plane `{kind:'event'}` carrying a `step.progress` EngineEvent — the same envelope a
|
|
55
|
+
TS engine publishes, so a dashboard engine re-delivers it to its subscribers (live-tail). Carries
|
|
56
|
+
the single just-emitted step event; the `at` mirrors EngineEvent's (ms; the TS side `new Date()`s it)."""
|
|
57
|
+
return json.dumps(
|
|
58
|
+
{
|
|
59
|
+
"kind": "event",
|
|
60
|
+
"from": _INSTANCE_ID,
|
|
61
|
+
"event": {
|
|
62
|
+
"type": "step.progress",
|
|
63
|
+
"runId": task.get("runId"),
|
|
64
|
+
"seq": task.get("seq"),
|
|
65
|
+
"name": task.get("name"),
|
|
66
|
+
"event": event,
|
|
67
|
+
"at": event.get("at"),
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def run_redis_worker(
|
|
74
|
+
worker: Worker,
|
|
75
|
+
*,
|
|
76
|
+
group: str,
|
|
77
|
+
connection: str = "redis://localhost:6379",
|
|
78
|
+
prefix: str = "durable",
|
|
79
|
+
cancellation: Optional[CancellationRegistry] = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
"""Start a BullMQ worker that runs ``worker``'s handlers. Returns the bullmq Worker.
|
|
82
|
+
|
|
83
|
+
The returned worker runs in the background; ``await worker.close()`` to stop it. When a
|
|
84
|
+
:class:`CancellationRegistry` is given (one is created otherwise), the runner subscribes to the
|
|
85
|
+
control channel and feeds it, so handlers see ``ctx.cancelled``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from bullmq import Queue as BullQueue # imported lazily so the SDK works without bullmq
|
|
89
|
+
from bullmq import Worker as BullWorker
|
|
90
|
+
|
|
91
|
+
tasks_name, results_name = _names(prefix, group)
|
|
92
|
+
results = BullQueue(results_name, {"connection": connection})
|
|
93
|
+
registry = cancellation or CancellationRegistry()
|
|
94
|
+
await _subscribe_control(connection, prefix, registry)
|
|
95
|
+
publish_progress = await _progress_publisher(connection, prefix)
|
|
96
|
+
|
|
97
|
+
async def process(job: Any, _token: str) -> None:
|
|
98
|
+
on_event = _make_on_event(job.data, publish_progress) if publish_progress else None
|
|
99
|
+
result = await worker.aprocess_task(
|
|
100
|
+
job.data, is_cancelled=registry.is_cancelled, on_event=on_event
|
|
101
|
+
)
|
|
102
|
+
await results.add("result", result, {"removeOnComplete": True, "removeOnFail": True})
|
|
103
|
+
|
|
104
|
+
return BullWorker(tasks_name, process, {"connection": connection})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _progress_publisher(
|
|
108
|
+
connection: str, prefix: str
|
|
109
|
+
) -> Optional[Callable[[str], None]]:
|
|
110
|
+
"""Build a thread-safe `publish(message)` that PUBLISHes on the control channel from the running
|
|
111
|
+
loop. Returns None if redis pub/sub isn't available (then `step.progress` streaming is simply off
|
|
112
|
+
— the events still ride back on the final result). The returned callable is safe to call from a
|
|
113
|
+
handler's executor thread: it hops back onto the loop via ``call_soon_threadsafe`` (the aioredis
|
|
114
|
+
client is bound to this loop, so publishing must happen there, not from the worker thread)."""
|
|
115
|
+
try:
|
|
116
|
+
import redis.asyncio as aioredis # lazy: only needed when streaming progress
|
|
117
|
+
except ImportError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
client = aioredis.from_url(connection)
|
|
121
|
+
channel = _control_channel(prefix)
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
|
|
124
|
+
def publish(message: str) -> None:
|
|
125
|
+
async def _send() -> None:
|
|
126
|
+
try:
|
|
127
|
+
await client.publish(channel, message)
|
|
128
|
+
except Exception: # noqa: BLE001 — live-tail is best-effort; never fail the step
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
loop.call_soon_threadsafe(lambda: loop.create_task(_send()))
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
pass # loop is closing/closed — drop the live event (the result still carries it)
|
|
135
|
+
|
|
136
|
+
return publish
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _make_on_event(
|
|
140
|
+
task: Dict[str, Any], publish: Callable[[str], None]
|
|
141
|
+
) -> Callable[[Dict[str, Any]], None]:
|
|
142
|
+
"""Per-task sink: turn each step event into a `step.progress` control message and publish it."""
|
|
143
|
+
|
|
144
|
+
def on_event(event: Dict[str, Any]) -> None:
|
|
145
|
+
publish(_progress_message(task, event))
|
|
146
|
+
|
|
147
|
+
return on_event
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _subscribe_control(
|
|
151
|
+
connection: str, prefix: str, registry: CancellationRegistry
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Best-effort: subscribe to the control channel and feed cancellations into ``registry``.
|
|
154
|
+
No-op (logged) if redis pub/sub isn't available — cancellation just won't be observed."""
|
|
155
|
+
try:
|
|
156
|
+
import redis.asyncio as aioredis # lazy: only needed for cooperative cancellation
|
|
157
|
+
except ImportError:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
client = aioredis.from_url(connection)
|
|
161
|
+
pubsub = client.pubsub()
|
|
162
|
+
await pubsub.subscribe(_control_channel(prefix))
|
|
163
|
+
|
|
164
|
+
async def listen() -> None:
|
|
165
|
+
async for message in pubsub.listen():
|
|
166
|
+
if message.get("type") != "message":
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
registry.on_control_message(json.loads(message["data"]))
|
|
170
|
+
except (ValueError, TypeError):
|
|
171
|
+
pass # ignore malformed control messages
|
|
172
|
+
|
|
173
|
+
asyncio.create_task(listen())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Reply routing for a worker that consumes several transports (multi-broker failover).
|
|
2
|
+
|
|
3
|
+
The orchestrator stamps each task with the id of the transport it was dispatched on
|
|
4
|
+
(``task["transport"]``). A worker that consumes more than one broker must send the *result* back on
|
|
5
|
+
the same broker — so failover is symmetric and the worker never picks a transport itself, it just
|
|
6
|
+
replies where it was told. This is a pure lookup over a ``{id: reply_target}`` mapping with a sane
|
|
7
|
+
fallback for the single-transport / unknown-id case.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Optional, TypeVar
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def reply_target(task: Dict[str, Any], targets: Dict[str, T]) -> Optional[T]:
|
|
18
|
+
"""The reply target for ``task`` by its ``transport`` id; falls back to the lone target when the
|
|
19
|
+
id is absent (single transport) or unknown. Returns ``None`` only if ``targets`` is empty."""
|
|
20
|
+
if not targets:
|
|
21
|
+
return None
|
|
22
|
+
transport_id = task.get("transport")
|
|
23
|
+
if isinstance(transport_id, str) and transport_id in targets:
|
|
24
|
+
return targets[transport_id]
|
|
25
|
+
if len(targets) == 1:
|
|
26
|
+
return next(iter(targets.values()))
|
|
27
|
+
return None
|
|
@@ -0,0 +1,388 @@
|
|
|
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 contextvars
|
|
12
|
+
import inspect
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
Handler = Callable[..., Union[Any, Awaitable[Any]]]
|
|
17
|
+
|
|
18
|
+
# The step a handler is currently running, bound for the duration of the call (in the loop for an
|
|
19
|
+
# async handler, in the executor thread for a blocking one). Lets code DEEP inside a handler — that
|
|
20
|
+
# never received `ctx` — record events via the module-level helpers below, without threading the
|
|
21
|
+
# context through every call. Outside a step it's None and the helpers are no-ops.
|
|
22
|
+
_current_step: "contextvars.ContextVar[Optional[StepContext]]" = contextvars.ContextVar(
|
|
23
|
+
"durable_current_step", default=None
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_step() -> "Optional[StepContext]":
|
|
28
|
+
"""The :class:`StepContext` of the step running on this task/thread, or None outside a step."""
|
|
29
|
+
return _current_step.get()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def log(level: str, message: str, data: Any = None) -> None:
|
|
33
|
+
"""Record a log line on the current step (level: debug/info/warn/error). No-op outside a step."""
|
|
34
|
+
ctx = _current_step.get()
|
|
35
|
+
if ctx is not None:
|
|
36
|
+
getattr(ctx, level, ctx.info)(message, data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sub(name: str, status: str, message: Optional[str] = None, data: Any = None) -> None:
|
|
40
|
+
"""Record a sub-process outcome (ok/failed/skipped) on the current step. No-op outside a step."""
|
|
41
|
+
ctx = _current_step.get()
|
|
42
|
+
if ctx is not None:
|
|
43
|
+
ctx.sub(name, status, message, data)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_process(name: Optional[str]) -> None:
|
|
47
|
+
"""Tag the current step's subsequent log lines with sub-process ``name`` (None clears). No-op
|
|
48
|
+
outside a step — so the same business code runs on a non-durable path untouched."""
|
|
49
|
+
ctx = _current_step.get()
|
|
50
|
+
if ctx is not None:
|
|
51
|
+
ctx.process(name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FatalError(Exception):
|
|
55
|
+
"""Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
|
|
56
|
+
|
|
57
|
+
The engine will not retry the step regardless of its ``retries`` setting.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str, code: str | None = None) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.code = code
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StepContext:
|
|
66
|
+
"""Lets a handler record what happened inside a step — debug/info/warn/error lines and
|
|
67
|
+
per-sub-process outcomes. The events ride back on the result as ``events`` and the engine
|
|
68
|
+
checkpoints them, so the dashboard shows them under the step. The TypeScript counterpart is
|
|
69
|
+
the ``StepLogger`` handed to ``ctx.step`` — same ``StepEvent`` shape, so observability is
|
|
70
|
+
symmetric regardless of which language ran the step.
|
|
71
|
+
|
|
72
|
+
A handler opts in by declaring a second parameter::
|
|
73
|
+
|
|
74
|
+
@worker.step("processing")
|
|
75
|
+
def run(data, ctx):
|
|
76
|
+
for proc in data["procs"]:
|
|
77
|
+
ok = run_proc(proc)
|
|
78
|
+
ctx.sub(proc["name"], "ok" if ok else "failed")
|
|
79
|
+
return {"context": {...}}
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
run_id: Optional[str] = None,
|
|
85
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
86
|
+
seq: Optional[int] = None,
|
|
87
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.events: List[Dict[str, Any]] = []
|
|
90
|
+
self._run_id = run_id
|
|
91
|
+
self._is_cancelled = is_cancelled
|
|
92
|
+
# Step seq + a sink for live progress: when a transport supplies ``on_event``, every event is
|
|
93
|
+
# ALSO handed to it as it happens (e.g. published on the control plane as a ``step.progress``),
|
|
94
|
+
# so a dashboard tails a long step line-by-line instead of waiting for the final result.
|
|
95
|
+
self._seq = seq
|
|
96
|
+
self._on_event = on_event
|
|
97
|
+
# The sub-process a handler is currently inside (set via ``process``), stamped onto log lines
|
|
98
|
+
# so the dashboard groups a fan-out step's trail per sub-process.
|
|
99
|
+
self._process: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
def process(self, name: Optional[str]) -> None:
|
|
102
|
+
"""Mark the sub-process whose work is now running; subsequent log lines are tagged with it
|
|
103
|
+
(until changed or cleared with ``None``). A no-op for ``sub()`` outcome rows, which name
|
|
104
|
+
themselves. Lets a fan-out handler attribute its log trail to each sub-process."""
|
|
105
|
+
self._process = name
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def cancelled(self) -> bool:
|
|
109
|
+
"""True once the run has been cancelled (a runner subscribed to the control channel marks
|
|
110
|
+
it). Check this at safe points in a long handler and return/raise to bail out early."""
|
|
111
|
+
return bool(
|
|
112
|
+
self._is_cancelled is not None
|
|
113
|
+
and self._run_id is not None
|
|
114
|
+
and self._is_cancelled(self._run_id)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def raise_if_cancelled(self) -> None:
|
|
118
|
+
"""Raise :class:`Cancelled` if the run has been cancelled — a one-liner abort for handlers."""
|
|
119
|
+
if self.cancelled:
|
|
120
|
+
from .cancellation import Cancelled
|
|
121
|
+
|
|
122
|
+
raise Cancelled(self._run_id or "")
|
|
123
|
+
|
|
124
|
+
def _emit(
|
|
125
|
+
self,
|
|
126
|
+
level: str,
|
|
127
|
+
message: str,
|
|
128
|
+
name: Optional[str] = None,
|
|
129
|
+
status: Optional[str] = None,
|
|
130
|
+
data: Any = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
event: Dict[str, Any] = {"at": int(time.time() * 1000), "level": level, "message": message}
|
|
133
|
+
if name is not None:
|
|
134
|
+
event["name"] = name
|
|
135
|
+
if status is not None:
|
|
136
|
+
event["status"] = status
|
|
137
|
+
# Tag a log line (no outcome status) with the sub-process it was emitted inside, if any.
|
|
138
|
+
elif self._process is not None:
|
|
139
|
+
event["process"] = self._process
|
|
140
|
+
if data is not None:
|
|
141
|
+
event["data"] = data
|
|
142
|
+
self.events.append(event)
|
|
143
|
+
# Live progress: hand the event to the transport's sink so it streams now (best-effort — a
|
|
144
|
+
# broken sink must never fail the handler; the event is already captured for the result).
|
|
145
|
+
if self._on_event is not None:
|
|
146
|
+
try:
|
|
147
|
+
self._on_event(event)
|
|
148
|
+
except Exception: # noqa: BLE001 — live-tail is best-effort observability
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def debug(self, message: str, data: Any = None) -> None:
|
|
152
|
+
self._emit("debug", message, data=data)
|
|
153
|
+
|
|
154
|
+
def info(self, message: str, data: Any = None) -> None:
|
|
155
|
+
self._emit("info", message, data=data)
|
|
156
|
+
|
|
157
|
+
def warn(self, message: str, data: Any = None) -> None:
|
|
158
|
+
self._emit("warn", message, data=data)
|
|
159
|
+
|
|
160
|
+
def error(self, message: str, data: Any = None) -> None:
|
|
161
|
+
self._emit("error", message, data=data)
|
|
162
|
+
|
|
163
|
+
def sub(
|
|
164
|
+
self, name: str, status: str, message: Optional[str] = None, data: Any = None
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Record a sub-step / sub-process outcome (e.g. one of N parallel p-processes)."""
|
|
167
|
+
level = "error" if status == "failed" else "warn" if status == "skipped" else "info"
|
|
168
|
+
self._emit(level, message or name, name=name, status=status, data=data)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Worker:
|
|
172
|
+
"""Registers step handlers by name and turns a dispatched task into a result.
|
|
173
|
+
|
|
174
|
+
Example::
|
|
175
|
+
|
|
176
|
+
worker = Worker(group="payments")
|
|
177
|
+
|
|
178
|
+
@worker.step("payments.charge-card")
|
|
179
|
+
async def charge(data):
|
|
180
|
+
res = await stripe.charge(data["orderId"], data["amountCents"])
|
|
181
|
+
return {"chargeId": res.id}
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, group: str = "default") -> None:
|
|
185
|
+
self.group = group
|
|
186
|
+
self._handlers: Dict[str, Handler] = {}
|
|
187
|
+
self._blocking: Dict[str, bool] = {}
|
|
188
|
+
|
|
189
|
+
def step(self, name: str, *, blocking: bool = False) -> Callable[[Handler], Handler]:
|
|
190
|
+
"""Decorator registering ``fn`` as the handler for step ``name``.
|
|
191
|
+
|
|
192
|
+
Set ``blocking=True`` for a synchronous handler that does CPU/DB work: the worker runs it in
|
|
193
|
+
a thread so the event loop stays free to renew the broker's job lock (otherwise a long step
|
|
194
|
+
looks stalled and gets redelivered). The step context is bound inside that thread too, so the
|
|
195
|
+
module-level :func:`log`/:func:`sub`/:func:`set_process` work from anywhere in the call.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def register(fn: Handler) -> Handler:
|
|
199
|
+
self._handlers[name] = fn
|
|
200
|
+
self._blocking[name] = blocking
|
|
201
|
+
return fn
|
|
202
|
+
|
|
203
|
+
return register
|
|
204
|
+
|
|
205
|
+
def handles(self, name: str) -> bool:
|
|
206
|
+
return name in self._handlers
|
|
207
|
+
|
|
208
|
+
def run(self, *, redis: str = "redis://localhost:6379", prefix: str = "durable") -> None:
|
|
209
|
+
"""Run this worker against the Redis/BullMQ transport until the process is signalled, then
|
|
210
|
+
close gracefully. Owns the whole long-running bootstrap a worker process needs — the event
|
|
211
|
+
loop, SIGTERM/SIGINT handling, and the broker connection — so the entrypoint is one call::
|
|
212
|
+
|
|
213
|
+
worker = Worker(group="processing")
|
|
214
|
+
|
|
215
|
+
@worker.step("processing", blocking=True)
|
|
216
|
+
def handle(data, ctx): ...
|
|
217
|
+
|
|
218
|
+
worker.run(redis=redis_url_from_env())
|
|
219
|
+
|
|
220
|
+
Blocks until a shutdown signal closes the worker (which finishes the active job and releases
|
|
221
|
+
its lock). For finer control (your own loop/signals), use :func:`run_redis_worker` directly.
|
|
222
|
+
"""
|
|
223
|
+
import signal as _signal
|
|
224
|
+
|
|
225
|
+
from .redis_runner import run_redis_worker
|
|
226
|
+
|
|
227
|
+
async def _main() -> None:
|
|
228
|
+
bull_worker = await run_redis_worker(
|
|
229
|
+
self, group=self.group, connection=redis, prefix=prefix
|
|
230
|
+
)
|
|
231
|
+
stop = asyncio.Event()
|
|
232
|
+
loop = asyncio.get_running_loop()
|
|
233
|
+
|
|
234
|
+
async def _graceful() -> None:
|
|
235
|
+
try:
|
|
236
|
+
await bull_worker.close()
|
|
237
|
+
finally:
|
|
238
|
+
stop.set()
|
|
239
|
+
|
|
240
|
+
for sig in (_signal.SIGTERM, _signal.SIGINT):
|
|
241
|
+
try:
|
|
242
|
+
loop.add_signal_handler(sig, lambda: asyncio.ensure_future(_graceful()))
|
|
243
|
+
except (NotImplementedError, RuntimeError):
|
|
244
|
+
pass # no signal support here (e.g. not the main thread) — rely on redelivery
|
|
245
|
+
await stop.wait()
|
|
246
|
+
|
|
247
|
+
asyncio.run(_main())
|
|
248
|
+
|
|
249
|
+
def process_task(
|
|
250
|
+
self,
|
|
251
|
+
task: Dict[str, Any],
|
|
252
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
253
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
254
|
+
) -> Dict[str, Any]:
|
|
255
|
+
"""Run the handler for ``task`` and return a wire-format result.
|
|
256
|
+
|
|
257
|
+
Pure and synchronous from the caller's view (async handlers are awaited internally), so a
|
|
258
|
+
transport can simply ``result = worker.process_task(task); send(result)``. Pass
|
|
259
|
+
``is_cancelled`` so the handler's ``ctx.cancelled`` reflects cooperative cancellation, and
|
|
260
|
+
``on_event`` to receive each step event live (for ``step.progress`` streaming).
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
base = self._base(task)
|
|
264
|
+
handler = self._handlers.get(task["name"])
|
|
265
|
+
if handler is None:
|
|
266
|
+
return self._no_handler(base, task["name"])
|
|
267
|
+
ctx = self._ctx(task, is_cancelled, on_event)
|
|
268
|
+
try:
|
|
269
|
+
output = _run_bound(handler, task.get("input"), ctx)
|
|
270
|
+
if inspect.isawaitable(output):
|
|
271
|
+
output = asyncio.run(output)
|
|
272
|
+
return self._completed(base, output, ctx)
|
|
273
|
+
except Exception as err: # noqa: BLE001
|
|
274
|
+
return self._failure(base, err, ctx)
|
|
275
|
+
|
|
276
|
+
async def aprocess_task(
|
|
277
|
+
self,
|
|
278
|
+
task: Dict[str, Any],
|
|
279
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
280
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
281
|
+
) -> Dict[str, Any]:
|
|
282
|
+
"""Async variant — awaits async handlers in the current loop. Use from a transport that
|
|
283
|
+
already runs inside an event loop (e.g. the BullMQ runner). A handler registered
|
|
284
|
+
``blocking=True`` runs in a thread so the loop stays free to renew the job lock."""
|
|
285
|
+
|
|
286
|
+
base = self._base(task)
|
|
287
|
+
handler = self._handlers.get(task["name"])
|
|
288
|
+
if handler is None:
|
|
289
|
+
return self._no_handler(base, task["name"])
|
|
290
|
+
ctx = self._ctx(task, is_cancelled, on_event)
|
|
291
|
+
try:
|
|
292
|
+
if self._blocking.get(task["name"]):
|
|
293
|
+
loop = asyncio.get_running_loop()
|
|
294
|
+
output = await loop.run_in_executor(
|
|
295
|
+
None, _run_bound, handler, task.get("input"), ctx
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
output = _run_bound(handler, task.get("input"), ctx)
|
|
299
|
+
if inspect.isawaitable(output):
|
|
300
|
+
# The context var was reset by _run_bound after the sync part returned the
|
|
301
|
+
# coroutine; re-bind it for the duration of the await so deep `log()` calls work.
|
|
302
|
+
token = _current_step.set(ctx)
|
|
303
|
+
try:
|
|
304
|
+
output = await output
|
|
305
|
+
finally:
|
|
306
|
+
_current_step.reset(token)
|
|
307
|
+
return self._completed(base, output, ctx)
|
|
308
|
+
except Exception as err: # noqa: BLE001
|
|
309
|
+
return self._failure(base, err, ctx)
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _ctx(
|
|
313
|
+
task: Dict[str, Any],
|
|
314
|
+
is_cancelled: Optional[Callable[[str], bool]],
|
|
315
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]],
|
|
316
|
+
) -> StepContext:
|
|
317
|
+
return StepContext(
|
|
318
|
+
run_id=task.get("runId"),
|
|
319
|
+
is_cancelled=is_cancelled,
|
|
320
|
+
seq=task.get("seq"),
|
|
321
|
+
on_event=on_event,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _base(task: Dict[str, Any]) -> Dict[str, Any]:
|
|
326
|
+
# Stamp the worker's pickup time so the engine can report queue-wait (startedAt − enqueuedAt),
|
|
327
|
+
# mirroring the TypeScript ``runStepHandler``. Epoch ms keeps it language-neutral on the wire.
|
|
328
|
+
return {
|
|
329
|
+
"runId": task["runId"],
|
|
330
|
+
"seq": task["seq"],
|
|
331
|
+
"stepId": task["stepId"],
|
|
332
|
+
"startedAt": int(time.time() * 1000),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _completed(base: Dict[str, Any], output: Any, ctx: StepContext) -> Dict[str, Any]:
|
|
337
|
+
return _with_events({**base, "status": "completed", "output": output}, ctx)
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _no_handler(base: Dict[str, Any], name: str) -> Dict[str, Any]:
|
|
341
|
+
return {
|
|
342
|
+
**base,
|
|
343
|
+
"status": "failed",
|
|
344
|
+
"error": {"message": f"no handler for {name}", "retryable": False},
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _failure(base: Dict[str, Any], err: Exception, ctx: StepContext) -> Dict[str, Any]:
|
|
349
|
+
# Keep whatever the handler logged before it threw — a partial p-process run is exactly
|
|
350
|
+
# the case where the sub-process outcomes matter most.
|
|
351
|
+
if isinstance(err, FatalError):
|
|
352
|
+
error = {"message": str(err), "code": err.code, "retryable": False}
|
|
353
|
+
else:
|
|
354
|
+
error = {"message": str(err)}
|
|
355
|
+
return _with_events({**base, "status": "failed", "error": error}, ctx)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _run_bound(handler: Handler, input_: Any, ctx: "StepContext") -> Any:
|
|
359
|
+
"""Call ``handler`` with ``ctx`` bound as the current step for the duration of the call, so the
|
|
360
|
+
module-level :func:`log`/:func:`sub`/:func:`set_process` reach it from deep in the call tree.
|
|
361
|
+
Passes ``ctx`` as a second arg only if the handler declares one (plain ``def h(data)`` still
|
|
362
|
+
works). Also the target run in the executor thread for a ``blocking=True`` handler."""
|
|
363
|
+
token = _current_step.set(ctx)
|
|
364
|
+
try:
|
|
365
|
+
return handler(input_, ctx) if _wants_ctx(handler) else handler(input_)
|
|
366
|
+
finally:
|
|
367
|
+
_current_step.reset(token)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _wants_ctx(handler: Handler) -> bool:
|
|
371
|
+
"""True if ``handler`` can accept the step context as a second positional argument."""
|
|
372
|
+
try:
|
|
373
|
+
params = list(inspect.signature(handler).parameters.values())
|
|
374
|
+
except (ValueError, TypeError): # builtins / C functions without a signature
|
|
375
|
+
return False
|
|
376
|
+
positional = [
|
|
377
|
+
p
|
|
378
|
+
for p in params
|
|
379
|
+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
380
|
+
]
|
|
381
|
+
has_var_positional = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params)
|
|
382
|
+
return len(positional) >= 2 or has_var_positional
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _with_events(result: Dict[str, Any], ctx: StepContext) -> Dict[str, Any]:
|
|
386
|
+
if ctx.events:
|
|
387
|
+
result["events"] = ctx.events
|
|
388
|
+
return result
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from durable_worker import CancellationRegistry, Cancelled, Worker
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def task(name="long.job", **over):
|
|
7
|
+
base = {"runId": "r1", "seq": 0, "name": name, "stepId": "r1:0", "input": {}, "attempt": 1}
|
|
8
|
+
base.update(over)
|
|
9
|
+
return base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CancellationRegistryTest(unittest.TestCase):
|
|
13
|
+
def test_records_a_cancel_control_message(self):
|
|
14
|
+
reg = CancellationRegistry()
|
|
15
|
+
self.assertFalse(reg.is_cancelled("r1"))
|
|
16
|
+
reg.on_control_message({"kind": "cancel", "runId": "r1", "from": "x"})
|
|
17
|
+
self.assertTrue(reg.is_cancelled("r1"))
|
|
18
|
+
|
|
19
|
+
def test_ignores_non_cancel_messages(self):
|
|
20
|
+
reg = CancellationRegistry()
|
|
21
|
+
reg.on_control_message({"kind": "event", "event": {}})
|
|
22
|
+
reg.on_control_message({"nonsense": True})
|
|
23
|
+
self.assertFalse(reg.is_cancelled("r1"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StepContextCancellationTest(unittest.TestCase):
|
|
27
|
+
def test_handler_sees_cancellation_via_ctx(self):
|
|
28
|
+
reg = CancellationRegistry()
|
|
29
|
+
worker = Worker()
|
|
30
|
+
observed = {}
|
|
31
|
+
|
|
32
|
+
@worker.step("long.job")
|
|
33
|
+
def run(_data, ctx):
|
|
34
|
+
observed["before"] = ctx.cancelled
|
|
35
|
+
reg.cancel("r1")
|
|
36
|
+
observed["after"] = ctx.cancelled
|
|
37
|
+
return "done"
|
|
38
|
+
|
|
39
|
+
worker.process_task(task(), is_cancelled=reg.is_cancelled)
|
|
40
|
+
self.assertEqual(observed, {"before": False, "after": True})
|
|
41
|
+
|
|
42
|
+
def test_raise_if_cancelled_aborts_the_step(self):
|
|
43
|
+
reg = CancellationRegistry()
|
|
44
|
+
reg.cancel("r1")
|
|
45
|
+
worker = Worker()
|
|
46
|
+
|
|
47
|
+
@worker.step("long.job")
|
|
48
|
+
def run(_data, ctx):
|
|
49
|
+
ctx.raise_if_cancelled()
|
|
50
|
+
return "should not get here"
|
|
51
|
+
|
|
52
|
+
result = worker.process_task(task(), is_cancelled=reg.is_cancelled)
|
|
53
|
+
self.assertEqual(result["status"], "failed")
|
|
54
|
+
self.assertIn("cancelled", result["error"]["message"])
|
|
55
|
+
|
|
56
|
+
def test_cancelled_is_false_without_a_source(self):
|
|
57
|
+
worker = Worker()
|
|
58
|
+
|
|
59
|
+
@worker.step("long.job")
|
|
60
|
+
def run(_data, ctx):
|
|
61
|
+
return ctx.cancelled
|
|
62
|
+
|
|
63
|
+
result = worker.process_task(task())
|
|
64
|
+
self.assertEqual(result["output"], False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from durable_worker import redis_url_from_env
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RedisUrlFromEnvTest(unittest.TestCase):
|
|
7
|
+
def setUp(self):
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
self._saved = {
|
|
11
|
+
k: os.environ.pop(k, None)
|
|
12
|
+
for k in ("REDIS_HOST", "REDIS_PORT", "REDIS_USERNAME", "REDIS_PASSWORD")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def tearDown(self):
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
for k, v in self._saved.items():
|
|
19
|
+
if v is None:
|
|
20
|
+
os.environ.pop(k, None)
|
|
21
|
+
else:
|
|
22
|
+
os.environ[k] = v
|
|
23
|
+
|
|
24
|
+
def test_defaults_to_localhost_without_auth(self):
|
|
25
|
+
self.assertEqual(redis_url_from_env(), "redis://localhost:6379")
|
|
26
|
+
|
|
27
|
+
def test_host_port_and_auth(self):
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
os.environ["REDIS_HOST"] = "redis.internal"
|
|
31
|
+
os.environ["REDIS_PORT"] = "6380"
|
|
32
|
+
self.assertEqual(redis_url_from_env(), "redis://redis.internal:6380")
|
|
33
|
+
os.environ["REDIS_USERNAME"] = "default"
|
|
34
|
+
os.environ["REDIS_PASSWORD"] = "secret"
|
|
35
|
+
self.assertEqual(redis_url_from_env(), "redis://default:secret@redis.internal:6380")
|
|
36
|
+
|
|
37
|
+
def test_url_encodes_credentials(self):
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
os.environ["REDIS_HOST"] = "redis.internal"
|
|
41
|
+
os.environ["REDIS_PORT"] = "6380"
|
|
42
|
+
os.environ["REDIS_USERNAME"] = "default"
|
|
43
|
+
# A password with @ : / would corrupt the netloc if left raw.
|
|
44
|
+
os.environ["REDIS_PASSWORD"] = "p@ss:w/rd"
|
|
45
|
+
self.assertEqual(
|
|
46
|
+
redis_url_from_env(), "redis://default:p%40ss%3Aw%2Frd@redis.internal:6380"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from durable_worker import reply_target
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReplyTargetTest(unittest.TestCase):
|
|
7
|
+
def test_routes_by_task_transport_id(self):
|
|
8
|
+
targets = {"redis": "redis-results", "sqs": "sqs-results"}
|
|
9
|
+
self.assertEqual(reply_target({"transport": "sqs"}, targets), "sqs-results")
|
|
10
|
+
self.assertEqual(reply_target({"transport": "redis"}, targets), "redis-results")
|
|
11
|
+
|
|
12
|
+
def test_falls_back_to_the_lone_target_when_id_is_absent(self):
|
|
13
|
+
self.assertEqual(reply_target({}, {"only": "x"}), "x")
|
|
14
|
+
|
|
15
|
+
def test_unknown_id_with_multiple_targets_is_unroutable(self):
|
|
16
|
+
self.assertIsNone(reply_target({"transport": "nats"}, {"redis": "a", "sqs": "b"}))
|
|
17
|
+
|
|
18
|
+
def test_empty_targets_is_none(self):
|
|
19
|
+
self.assertIsNone(reply_target({"transport": "redis"}, {}))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
unittest.main()
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import unittest
|
|
2
3
|
|
|
3
|
-
from durable_worker import
|
|
4
|
+
from durable_worker import (
|
|
5
|
+
FatalError,
|
|
6
|
+
StepContext,
|
|
7
|
+
Worker,
|
|
8
|
+
current_step,
|
|
9
|
+
log,
|
|
10
|
+
set_process,
|
|
11
|
+
sub,
|
|
12
|
+
)
|
|
4
13
|
|
|
5
14
|
|
|
6
15
|
def task(name="payments.charge-card", **over):
|
|
@@ -136,6 +145,97 @@ class WorkerTest(unittest.TestCase):
|
|
|
136
145
|
result = worker.process_task(task())
|
|
137
146
|
self.assertEqual(result["events"][0]["message"], "async")
|
|
138
147
|
|
|
148
|
+
def test_logs_are_tagged_with_the_current_process(self):
|
|
149
|
+
worker = Worker()
|
|
150
|
+
|
|
151
|
+
@worker.step("payments.charge-card")
|
|
152
|
+
def charge(_data, ctx: StepContext):
|
|
153
|
+
ctx.process("proc-a")
|
|
154
|
+
ctx.info("inside a")
|
|
155
|
+
ctx.sub("proc-a", "ok")
|
|
156
|
+
ctx.process("proc-b")
|
|
157
|
+
ctx.debug("inside b")
|
|
158
|
+
ctx.process(None)
|
|
159
|
+
ctx.info("step-level")
|
|
160
|
+
return {"ok": True}
|
|
161
|
+
|
|
162
|
+
# process() itself emits nothing, so the events are: [info a, sub a, debug b, info step-level]
|
|
163
|
+
events = worker.process_task(task())["events"]
|
|
164
|
+
# log lines carry the sub-process that was running when they fired...
|
|
165
|
+
self.assertEqual(events[0]["process"], "proc-a")
|
|
166
|
+
self.assertEqual(events[2]["process"], "proc-b")
|
|
167
|
+
# ...the sub-process OUTCOME row names itself (no `process` tag)...
|
|
168
|
+
self.assertNotIn("process", events[1])
|
|
169
|
+
self.assertEqual(events[1]["status"], "ok")
|
|
170
|
+
# ...and a log after process(None) is untagged (step-level).
|
|
171
|
+
self.assertNotIn("process", events[3])
|
|
172
|
+
|
|
173
|
+
def test_on_event_streams_each_event_as_it_is_emitted(self):
|
|
174
|
+
worker = Worker()
|
|
175
|
+
live = []
|
|
176
|
+
|
|
177
|
+
@worker.step("payments.charge-card")
|
|
178
|
+
def charge(_data, ctx: StepContext):
|
|
179
|
+
ctx.info("first")
|
|
180
|
+
# the live sink saw the event the instant it was emitted, before the step returned
|
|
181
|
+
self.assertEqual([e["message"] for e in live], ["first"])
|
|
182
|
+
ctx.sub("proc-a", "ok")
|
|
183
|
+
return {"ok": True}
|
|
184
|
+
|
|
185
|
+
result = worker.process_task(task(), on_event=live.append)
|
|
186
|
+
self.assertEqual([e["message"] for e in live], ["first", "proc-a"])
|
|
187
|
+
# the same events still ride back on the result (live-tail doesn't replace the final record)
|
|
188
|
+
self.assertEqual(len(result["events"]), 2)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_module_level_helpers_reach_the_current_step(self):
|
|
192
|
+
worker = Worker()
|
|
193
|
+
|
|
194
|
+
def deep_business_logic():
|
|
195
|
+
# No `ctx` in scope here — the context-local helpers find it.
|
|
196
|
+
set_process("proc-a")
|
|
197
|
+
log("debug", "querying")
|
|
198
|
+
sub("proc-a", "ok")
|
|
199
|
+
|
|
200
|
+
@worker.step("payments.charge-card")
|
|
201
|
+
def charge(_data): # note: no ctx param — handler doesn't even take it
|
|
202
|
+
deep_business_logic()
|
|
203
|
+
return {"ok": True}
|
|
204
|
+
|
|
205
|
+
events = worker.process_task(task())["events"]
|
|
206
|
+
self.assertEqual(events[0]["message"], "querying")
|
|
207
|
+
self.assertEqual(events[0]["process"], "proc-a")
|
|
208
|
+
self.assertEqual(events[1]["name"], "proc-a")
|
|
209
|
+
self.assertEqual(events[1]["status"], "ok")
|
|
210
|
+
|
|
211
|
+
def test_module_level_helpers_are_noops_outside_a_step(self):
|
|
212
|
+
# Same business code on a non-durable path must not blow up.
|
|
213
|
+
self.assertIsNone(current_step())
|
|
214
|
+
log("info", "no step here")
|
|
215
|
+
sub("x", "ok")
|
|
216
|
+
set_process("x")
|
|
217
|
+
|
|
218
|
+
def test_blocking_handler_runs_in_a_thread_with_the_step_bound(self):
|
|
219
|
+
import threading
|
|
220
|
+
|
|
221
|
+
worker = Worker()
|
|
222
|
+
ran_on = {}
|
|
223
|
+
|
|
224
|
+
@worker.step("payments.charge-card", blocking=True)
|
|
225
|
+
def charge(_data):
|
|
226
|
+
ran_on["thread"] = threading.current_thread().name
|
|
227
|
+
log("info", "from the worker thread")
|
|
228
|
+
return {"ok": True}
|
|
229
|
+
|
|
230
|
+
async def drive():
|
|
231
|
+
return await worker.aprocess_task(task())
|
|
232
|
+
|
|
233
|
+
loop_thread = threading.current_thread().name
|
|
234
|
+
result = asyncio.run(drive())
|
|
235
|
+
self.assertEqual(result["events"][0]["message"], "from the worker thread")
|
|
236
|
+
# it really ran off the event-loop thread (so the lock keeps renewing)
|
|
237
|
+
self.assertNotEqual(ran_on["thread"], loop_thread)
|
|
238
|
+
|
|
139
239
|
|
|
140
240
|
if __name__ == "__main__":
|
|
141
241
|
unittest.main()
|
|
@@ -1,11 +0,0 @@
|
|
|
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, StepContext, Worker
|
|
9
|
-
|
|
10
|
-
__all__ = ["Worker", "FatalError", "StepContext"]
|
|
11
|
-
__version__ = "0.2.0"
|
|
@@ -1,42 +0,0 @@
|
|
|
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})
|
|
@@ -1,212 +0,0 @@
|
|
|
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
|
-
import time
|
|
13
|
-
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
14
|
-
|
|
15
|
-
Handler = Callable[..., Union[Any, Awaitable[Any]]]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class FatalError(Exception):
|
|
19
|
-
"""Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
|
|
20
|
-
|
|
21
|
-
The engine will not retry the step regardless of its ``retries`` setting.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
def __init__(self, message: str, code: str | None = None) -> None:
|
|
25
|
-
super().__init__(message)
|
|
26
|
-
self.code = code
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class StepContext:
|
|
30
|
-
"""Lets a handler record what happened inside a step — debug/info/warn/error lines and
|
|
31
|
-
per-sub-process outcomes. The events ride back on the result as ``events`` and the engine
|
|
32
|
-
checkpoints them, so the dashboard shows them under the step. The TypeScript counterpart is
|
|
33
|
-
the ``StepLogger`` handed to ``ctx.step`` — same ``StepEvent`` shape, so observability is
|
|
34
|
-
symmetric regardless of which language ran the step.
|
|
35
|
-
|
|
36
|
-
A handler opts in by declaring a second parameter::
|
|
37
|
-
|
|
38
|
-
@worker.step("processing")
|
|
39
|
-
def run(data, ctx):
|
|
40
|
-
for proc in data["procs"]:
|
|
41
|
-
ok = run_proc(proc)
|
|
42
|
-
ctx.sub(proc["name"], "ok" if ok else "failed")
|
|
43
|
-
return {"context": {...}}
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
def __init__(self) -> None:
|
|
47
|
-
self.events: List[Dict[str, Any]] = []
|
|
48
|
-
|
|
49
|
-
def _emit(
|
|
50
|
-
self,
|
|
51
|
-
level: str,
|
|
52
|
-
message: str,
|
|
53
|
-
name: Optional[str] = None,
|
|
54
|
-
status: Optional[str] = None,
|
|
55
|
-
data: Any = None,
|
|
56
|
-
) -> None:
|
|
57
|
-
event: Dict[str, Any] = {"at": int(time.time() * 1000), "level": level, "message": message}
|
|
58
|
-
if name is not None:
|
|
59
|
-
event["name"] = name
|
|
60
|
-
if status is not None:
|
|
61
|
-
event["status"] = status
|
|
62
|
-
if data is not None:
|
|
63
|
-
event["data"] = data
|
|
64
|
-
self.events.append(event)
|
|
65
|
-
|
|
66
|
-
def debug(self, message: str, data: Any = None) -> None:
|
|
67
|
-
self._emit("debug", message, data=data)
|
|
68
|
-
|
|
69
|
-
def info(self, message: str, data: Any = None) -> None:
|
|
70
|
-
self._emit("info", message, data=data)
|
|
71
|
-
|
|
72
|
-
def warn(self, message: str, data: Any = None) -> None:
|
|
73
|
-
self._emit("warn", message, data=data)
|
|
74
|
-
|
|
75
|
-
def error(self, message: str, data: Any = None) -> None:
|
|
76
|
-
self._emit("error", message, data=data)
|
|
77
|
-
|
|
78
|
-
def sub(
|
|
79
|
-
self, name: str, status: str, message: Optional[str] = None, data: Any = None
|
|
80
|
-
) -> None:
|
|
81
|
-
"""Record a sub-step / sub-process outcome (e.g. one of N parallel p-processes)."""
|
|
82
|
-
level = "error" if status == "failed" else "warn" if status == "skipped" else "info"
|
|
83
|
-
self._emit(level, message or name, name=name, status=status, data=data)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class Worker:
|
|
87
|
-
"""Registers step handlers by name and turns a dispatched task into a result.
|
|
88
|
-
|
|
89
|
-
Example::
|
|
90
|
-
|
|
91
|
-
worker = Worker(group="payments")
|
|
92
|
-
|
|
93
|
-
@worker.step("payments.charge-card")
|
|
94
|
-
async def charge(data):
|
|
95
|
-
res = await stripe.charge(data["orderId"], data["amountCents"])
|
|
96
|
-
return {"chargeId": res.id}
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
def __init__(self, group: str = "default") -> None:
|
|
100
|
-
self.group = group
|
|
101
|
-
self._handlers: Dict[str, Handler] = {}
|
|
102
|
-
|
|
103
|
-
def step(self, name: str) -> Callable[[Handler], Handler]:
|
|
104
|
-
"""Decorator registering ``fn`` as the handler for step ``name``."""
|
|
105
|
-
|
|
106
|
-
def register(fn: Handler) -> Handler:
|
|
107
|
-
self._handlers[name] = fn
|
|
108
|
-
return fn
|
|
109
|
-
|
|
110
|
-
return register
|
|
111
|
-
|
|
112
|
-
def handles(self, name: str) -> bool:
|
|
113
|
-
return name in self._handlers
|
|
114
|
-
|
|
115
|
-
def process_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
|
116
|
-
"""Run the handler for ``task`` and return a wire-format result.
|
|
117
|
-
|
|
118
|
-
Pure and synchronous from the caller's view (async handlers are awaited internally), so a
|
|
119
|
-
transport can simply ``result = worker.process_task(task); send(result)``.
|
|
120
|
-
"""
|
|
121
|
-
|
|
122
|
-
base = self._base(task)
|
|
123
|
-
handler = self._handlers.get(task["name"])
|
|
124
|
-
if handler is None:
|
|
125
|
-
return self._no_handler(base, task["name"])
|
|
126
|
-
ctx = StepContext()
|
|
127
|
-
try:
|
|
128
|
-
output = self._invoke(handler, task.get("input"), ctx)
|
|
129
|
-
if inspect.isawaitable(output):
|
|
130
|
-
output = asyncio.run(output)
|
|
131
|
-
return self._completed(base, output, ctx)
|
|
132
|
-
except Exception as err: # noqa: BLE001
|
|
133
|
-
return self._failure(base, err, ctx)
|
|
134
|
-
|
|
135
|
-
async def aprocess_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
|
-
"""Async variant — awaits async handlers in the current loop. Use from a transport that
|
|
137
|
-
already runs inside an event loop (e.g. the BullMQ runner)."""
|
|
138
|
-
|
|
139
|
-
base = self._base(task)
|
|
140
|
-
handler = self._handlers.get(task["name"])
|
|
141
|
-
if handler is None:
|
|
142
|
-
return self._no_handler(base, task["name"])
|
|
143
|
-
ctx = StepContext()
|
|
144
|
-
try:
|
|
145
|
-
output = self._invoke(handler, task.get("input"), ctx)
|
|
146
|
-
if inspect.isawaitable(output):
|
|
147
|
-
output = await output
|
|
148
|
-
return self._completed(base, output, ctx)
|
|
149
|
-
except Exception as err: # noqa: BLE001
|
|
150
|
-
return self._failure(base, err, ctx)
|
|
151
|
-
|
|
152
|
-
@staticmethod
|
|
153
|
-
def _invoke(handler: Handler, input_: Any, ctx: StepContext) -> Any:
|
|
154
|
-
"""Call ``handler`` with the step input, passing ``ctx`` only if it declares a second
|
|
155
|
-
parameter — so plain ``def handler(data)`` handlers keep working unchanged."""
|
|
156
|
-
if _wants_ctx(handler):
|
|
157
|
-
return handler(input_, ctx)
|
|
158
|
-
return handler(input_)
|
|
159
|
-
|
|
160
|
-
@staticmethod
|
|
161
|
-
def _base(task: Dict[str, Any]) -> Dict[str, Any]:
|
|
162
|
-
# Stamp the worker's pickup time so the engine can report queue-wait (startedAt − enqueuedAt),
|
|
163
|
-
# mirroring the TypeScript ``runStepHandler``. Epoch ms keeps it language-neutral on the wire.
|
|
164
|
-
return {
|
|
165
|
-
"runId": task["runId"],
|
|
166
|
-
"seq": task["seq"],
|
|
167
|
-
"stepId": task["stepId"],
|
|
168
|
-
"startedAt": int(time.time() * 1000),
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
@staticmethod
|
|
172
|
-
def _completed(base: Dict[str, Any], output: Any, ctx: StepContext) -> Dict[str, Any]:
|
|
173
|
-
return _with_events({**base, "status": "completed", "output": output}, ctx)
|
|
174
|
-
|
|
175
|
-
@staticmethod
|
|
176
|
-
def _no_handler(base: Dict[str, Any], name: str) -> Dict[str, Any]:
|
|
177
|
-
return {
|
|
178
|
-
**base,
|
|
179
|
-
"status": "failed",
|
|
180
|
-
"error": {"message": f"no handler for {name}", "retryable": False},
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
@staticmethod
|
|
184
|
-
def _failure(base: Dict[str, Any], err: Exception, ctx: StepContext) -> Dict[str, Any]:
|
|
185
|
-
# Keep whatever the handler logged before it threw — a partial p-process run is exactly
|
|
186
|
-
# the case where the sub-process outcomes matter most.
|
|
187
|
-
if isinstance(err, FatalError):
|
|
188
|
-
error = {"message": str(err), "code": err.code, "retryable": False}
|
|
189
|
-
else:
|
|
190
|
-
error = {"message": str(err)}
|
|
191
|
-
return _with_events({**base, "status": "failed", "error": error}, ctx)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _wants_ctx(handler: Handler) -> bool:
|
|
195
|
-
"""True if ``handler`` can accept the step context as a second positional argument."""
|
|
196
|
-
try:
|
|
197
|
-
params = list(inspect.signature(handler).parameters.values())
|
|
198
|
-
except (ValueError, TypeError): # builtins / C functions without a signature
|
|
199
|
-
return False
|
|
200
|
-
positional = [
|
|
201
|
-
p
|
|
202
|
-
for p in params
|
|
203
|
-
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
204
|
-
]
|
|
205
|
-
has_var_positional = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params)
|
|
206
|
-
return len(positional) >= 2 or has_var_positional
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _with_events(result: Dict[str, Any], ctx: StepContext) -> Dict[str, Any]:
|
|
210
|
-
if ctx.events:
|
|
211
|
-
result["events"] = ctx.events
|
|
212
|
-
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|