durable-worker 0.2.0__tar.gz → 0.3.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.3.0}/PKG-INFO +1 -1
- {durable_worker-0.2.0 → durable_worker-0.3.0}/durable_worker/__init__.py +11 -2
- durable_worker-0.3.0/durable_worker/cancellation.py +49 -0
- durable_worker-0.3.0/durable_worker/redis_runner.py +159 -0
- durable_worker-0.3.0/durable_worker/routing.py +27 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/durable_worker/worker.py +80 -6
- {durable_worker-0.2.0 → durable_worker-0.3.0}/pyproject.toml +1 -1
- durable_worker-0.3.0/tests/test_cancellation.py +68 -0
- durable_worker-0.3.0/tests/test_routing.py +23 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/tests/test_worker.py +42 -0
- durable_worker-0.2.0/durable_worker/redis_runner.py +0 -42
- {durable_worker-0.2.0 → durable_worker-0.3.0}/.gitignore +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/README.md +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/durable_worker/db_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/durable_worker/sqs_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/examples/run_worker.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.0}/tests/test_db_runner.py +0 -0
- {durable_worker-0.2.0 → durable_worker-0.3.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.3.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
|
|
@@ -5,7 +5,16 @@ The wire protocol (task in, result out) is plain JSON and identical across langu
|
|
|
5
5
|
same step name implemented here is callable from a TypeScript workflow via ``ctx.call``.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from .cancellation import Cancelled, CancellationRegistry
|
|
9
|
+
from .routing import reply_target
|
|
8
10
|
from .worker import FatalError, StepContext, Worker
|
|
9
11
|
|
|
10
|
-
__all__ = [
|
|
11
|
-
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Worker",
|
|
14
|
+
"FatalError",
|
|
15
|
+
"StepContext",
|
|
16
|
+
"Cancelled",
|
|
17
|
+
"CancellationRegistry",
|
|
18
|
+
"reply_target",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.3.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,159 @@
|
|
|
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 _names(prefix: str, group: str) -> tuple[str, str]:
|
|
30
|
+
# Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
|
|
31
|
+
return f"{prefix}-tasks-{group}", f"{prefix}-results"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _control_channel(prefix: str) -> str:
|
|
35
|
+
# Mirrors BullMQTransport.controlChannel(): '<prefix>-control'.
|
|
36
|
+
return f"{prefix}-control"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _progress_message(task: Dict[str, Any], event: Dict[str, Any]) -> str:
|
|
40
|
+
"""A control-plane `{kind:'event'}` carrying a `step.progress` EngineEvent — the same envelope a
|
|
41
|
+
TS engine publishes, so a dashboard engine re-delivers it to its subscribers (live-tail). Carries
|
|
42
|
+
the single just-emitted step event; the `at` mirrors EngineEvent's (ms; the TS side `new Date()`s it)."""
|
|
43
|
+
return json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"kind": "event",
|
|
46
|
+
"from": _INSTANCE_ID,
|
|
47
|
+
"event": {
|
|
48
|
+
"type": "step.progress",
|
|
49
|
+
"runId": task.get("runId"),
|
|
50
|
+
"seq": task.get("seq"),
|
|
51
|
+
"name": task.get("name"),
|
|
52
|
+
"event": event,
|
|
53
|
+
"at": event.get("at"),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def run_redis_worker(
|
|
60
|
+
worker: Worker,
|
|
61
|
+
*,
|
|
62
|
+
group: str,
|
|
63
|
+
connection: str = "redis://localhost:6379",
|
|
64
|
+
prefix: str = "durable",
|
|
65
|
+
cancellation: Optional[CancellationRegistry] = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
"""Start a BullMQ worker that runs ``worker``'s handlers. Returns the bullmq Worker.
|
|
68
|
+
|
|
69
|
+
The returned worker runs in the background; ``await worker.close()`` to stop it. When a
|
|
70
|
+
:class:`CancellationRegistry` is given (one is created otherwise), the runner subscribes to the
|
|
71
|
+
control channel and feeds it, so handlers see ``ctx.cancelled``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from bullmq import Queue as BullQueue # imported lazily so the SDK works without bullmq
|
|
75
|
+
from bullmq import Worker as BullWorker
|
|
76
|
+
|
|
77
|
+
tasks_name, results_name = _names(prefix, group)
|
|
78
|
+
results = BullQueue(results_name, {"connection": connection})
|
|
79
|
+
registry = cancellation or CancellationRegistry()
|
|
80
|
+
await _subscribe_control(connection, prefix, registry)
|
|
81
|
+
publish_progress = await _progress_publisher(connection, prefix)
|
|
82
|
+
|
|
83
|
+
async def process(job: Any, _token: str) -> None:
|
|
84
|
+
on_event = _make_on_event(job.data, publish_progress) if publish_progress else None
|
|
85
|
+
result = await worker.aprocess_task(
|
|
86
|
+
job.data, is_cancelled=registry.is_cancelled, on_event=on_event
|
|
87
|
+
)
|
|
88
|
+
await results.add("result", result, {"removeOnComplete": True, "removeOnFail": True})
|
|
89
|
+
|
|
90
|
+
return BullWorker(tasks_name, process, {"connection": connection})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _progress_publisher(
|
|
94
|
+
connection: str, prefix: str
|
|
95
|
+
) -> Optional[Callable[[str], None]]:
|
|
96
|
+
"""Build a thread-safe `publish(message)` that PUBLISHes on the control channel from the running
|
|
97
|
+
loop. Returns None if redis pub/sub isn't available (then `step.progress` streaming is simply off
|
|
98
|
+
— the events still ride back on the final result). The returned callable is safe to call from a
|
|
99
|
+
handler's executor thread: it hops back onto the loop via ``call_soon_threadsafe`` (the aioredis
|
|
100
|
+
client is bound to this loop, so publishing must happen there, not from the worker thread)."""
|
|
101
|
+
try:
|
|
102
|
+
import redis.asyncio as aioredis # lazy: only needed when streaming progress
|
|
103
|
+
except ImportError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
client = aioredis.from_url(connection)
|
|
107
|
+
channel = _control_channel(prefix)
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
|
|
110
|
+
def publish(message: str) -> None:
|
|
111
|
+
async def _send() -> None:
|
|
112
|
+
try:
|
|
113
|
+
await client.publish(channel, message)
|
|
114
|
+
except Exception: # noqa: BLE001 — live-tail is best-effort; never fail the step
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
loop.call_soon_threadsafe(lambda: loop.create_task(_send()))
|
|
119
|
+
except RuntimeError:
|
|
120
|
+
pass # loop is closing/closed — drop the live event (the result still carries it)
|
|
121
|
+
|
|
122
|
+
return publish
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _make_on_event(
|
|
126
|
+
task: Dict[str, Any], publish: Callable[[str], None]
|
|
127
|
+
) -> Callable[[Dict[str, Any]], None]:
|
|
128
|
+
"""Per-task sink: turn each step event into a `step.progress` control message and publish it."""
|
|
129
|
+
|
|
130
|
+
def on_event(event: Dict[str, Any]) -> None:
|
|
131
|
+
publish(_progress_message(task, event))
|
|
132
|
+
|
|
133
|
+
return on_event
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _subscribe_control(
|
|
137
|
+
connection: str, prefix: str, registry: CancellationRegistry
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Best-effort: subscribe to the control channel and feed cancellations into ``registry``.
|
|
140
|
+
No-op (logged) if redis pub/sub isn't available — cancellation just won't be observed."""
|
|
141
|
+
try:
|
|
142
|
+
import redis.asyncio as aioredis # lazy: only needed for cooperative cancellation
|
|
143
|
+
except ImportError:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
client = aioredis.from_url(connection)
|
|
147
|
+
pubsub = client.pubsub()
|
|
148
|
+
await pubsub.subscribe(_control_channel(prefix))
|
|
149
|
+
|
|
150
|
+
async def listen() -> None:
|
|
151
|
+
async for message in pubsub.listen():
|
|
152
|
+
if message.get("type") != "message":
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
registry.on_control_message(json.loads(message["data"]))
|
|
156
|
+
except (ValueError, TypeError):
|
|
157
|
+
pass # ignore malformed control messages
|
|
158
|
+
|
|
159
|
+
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
|
|
@@ -43,8 +43,47 @@ class StepContext:
|
|
|
43
43
|
return {"context": {...}}
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
|
-
def __init__(
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
run_id: Optional[str] = None,
|
|
49
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
50
|
+
seq: Optional[int] = None,
|
|
51
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
52
|
+
) -> None:
|
|
47
53
|
self.events: List[Dict[str, Any]] = []
|
|
54
|
+
self._run_id = run_id
|
|
55
|
+
self._is_cancelled = is_cancelled
|
|
56
|
+
# Step seq + a sink for live progress: when a transport supplies ``on_event``, every event is
|
|
57
|
+
# ALSO handed to it as it happens (e.g. published on the control plane as a ``step.progress``),
|
|
58
|
+
# so a dashboard tails a long step line-by-line instead of waiting for the final result.
|
|
59
|
+
self._seq = seq
|
|
60
|
+
self._on_event = on_event
|
|
61
|
+
# The sub-process a handler is currently inside (set via ``process``), stamped onto log lines
|
|
62
|
+
# so the dashboard groups a fan-out step's trail per sub-process.
|
|
63
|
+
self._process: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
def process(self, name: Optional[str]) -> None:
|
|
66
|
+
"""Mark the sub-process whose work is now running; subsequent log lines are tagged with it
|
|
67
|
+
(until changed or cleared with ``None``). A no-op for ``sub()`` outcome rows, which name
|
|
68
|
+
themselves. Lets a fan-out handler attribute its log trail to each sub-process."""
|
|
69
|
+
self._process = name
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def cancelled(self) -> bool:
|
|
73
|
+
"""True once the run has been cancelled (a runner subscribed to the control channel marks
|
|
74
|
+
it). Check this at safe points in a long handler and return/raise to bail out early."""
|
|
75
|
+
return bool(
|
|
76
|
+
self._is_cancelled is not None
|
|
77
|
+
and self._run_id is not None
|
|
78
|
+
and self._is_cancelled(self._run_id)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def raise_if_cancelled(self) -> None:
|
|
82
|
+
"""Raise :class:`Cancelled` if the run has been cancelled — a one-liner abort for handlers."""
|
|
83
|
+
if self.cancelled:
|
|
84
|
+
from .cancellation import Cancelled
|
|
85
|
+
|
|
86
|
+
raise Cancelled(self._run_id or "")
|
|
48
87
|
|
|
49
88
|
def _emit(
|
|
50
89
|
self,
|
|
@@ -59,9 +98,19 @@ class StepContext:
|
|
|
59
98
|
event["name"] = name
|
|
60
99
|
if status is not None:
|
|
61
100
|
event["status"] = status
|
|
101
|
+
# Tag a log line (no outcome status) with the sub-process it was emitted inside, if any.
|
|
102
|
+
elif self._process is not None:
|
|
103
|
+
event["process"] = self._process
|
|
62
104
|
if data is not None:
|
|
63
105
|
event["data"] = data
|
|
64
106
|
self.events.append(event)
|
|
107
|
+
# Live progress: hand the event to the transport's sink so it streams now (best-effort — a
|
|
108
|
+
# broken sink must never fail the handler; the event is already captured for the result).
|
|
109
|
+
if self._on_event is not None:
|
|
110
|
+
try:
|
|
111
|
+
self._on_event(event)
|
|
112
|
+
except Exception: # noqa: BLE001 — live-tail is best-effort observability
|
|
113
|
+
pass
|
|
65
114
|
|
|
66
115
|
def debug(self, message: str, data: Any = None) -> None:
|
|
67
116
|
self._emit("debug", message, data=data)
|
|
@@ -112,18 +161,25 @@ class Worker:
|
|
|
112
161
|
def handles(self, name: str) -> bool:
|
|
113
162
|
return name in self._handlers
|
|
114
163
|
|
|
115
|
-
def process_task(
|
|
164
|
+
def process_task(
|
|
165
|
+
self,
|
|
166
|
+
task: Dict[str, Any],
|
|
167
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
168
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
169
|
+
) -> Dict[str, Any]:
|
|
116
170
|
"""Run the handler for ``task`` and return a wire-format result.
|
|
117
171
|
|
|
118
172
|
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)``.
|
|
173
|
+
transport can simply ``result = worker.process_task(task); send(result)``. Pass
|
|
174
|
+
``is_cancelled`` so the handler's ``ctx.cancelled`` reflects cooperative cancellation, and
|
|
175
|
+
``on_event`` to receive each step event live (for ``step.progress`` streaming).
|
|
120
176
|
"""
|
|
121
177
|
|
|
122
178
|
base = self._base(task)
|
|
123
179
|
handler = self._handlers.get(task["name"])
|
|
124
180
|
if handler is None:
|
|
125
181
|
return self._no_handler(base, task["name"])
|
|
126
|
-
ctx =
|
|
182
|
+
ctx = self._ctx(task, is_cancelled, on_event)
|
|
127
183
|
try:
|
|
128
184
|
output = self._invoke(handler, task.get("input"), ctx)
|
|
129
185
|
if inspect.isawaitable(output):
|
|
@@ -132,7 +188,12 @@ class Worker:
|
|
|
132
188
|
except Exception as err: # noqa: BLE001
|
|
133
189
|
return self._failure(base, err, ctx)
|
|
134
190
|
|
|
135
|
-
async def aprocess_task(
|
|
191
|
+
async def aprocess_task(
|
|
192
|
+
self,
|
|
193
|
+
task: Dict[str, Any],
|
|
194
|
+
is_cancelled: Optional[Callable[[str], bool]] = None,
|
|
195
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
196
|
+
) -> Dict[str, Any]:
|
|
136
197
|
"""Async variant — awaits async handlers in the current loop. Use from a transport that
|
|
137
198
|
already runs inside an event loop (e.g. the BullMQ runner)."""
|
|
138
199
|
|
|
@@ -140,7 +201,7 @@ class Worker:
|
|
|
140
201
|
handler = self._handlers.get(task["name"])
|
|
141
202
|
if handler is None:
|
|
142
203
|
return self._no_handler(base, task["name"])
|
|
143
|
-
ctx =
|
|
204
|
+
ctx = self._ctx(task, is_cancelled, on_event)
|
|
144
205
|
try:
|
|
145
206
|
output = self._invoke(handler, task.get("input"), ctx)
|
|
146
207
|
if inspect.isawaitable(output):
|
|
@@ -149,6 +210,19 @@ class Worker:
|
|
|
149
210
|
except Exception as err: # noqa: BLE001
|
|
150
211
|
return self._failure(base, err, ctx)
|
|
151
212
|
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _ctx(
|
|
215
|
+
task: Dict[str, Any],
|
|
216
|
+
is_cancelled: Optional[Callable[[str], bool]],
|
|
217
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]],
|
|
218
|
+
) -> StepContext:
|
|
219
|
+
return StepContext(
|
|
220
|
+
run_id=task.get("runId"),
|
|
221
|
+
is_cancelled=is_cancelled,
|
|
222
|
+
seq=task.get("seq"),
|
|
223
|
+
on_event=on_event,
|
|
224
|
+
)
|
|
225
|
+
|
|
152
226
|
@staticmethod
|
|
153
227
|
def _invoke(handler: Handler, input_: Any, ctx: StepContext) -> Any:
|
|
154
228
|
"""Call ``handler`` with the step input, passing ``ctx`` only if it declares a second
|
|
@@ -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,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()
|
|
@@ -136,6 +136,48 @@ class WorkerTest(unittest.TestCase):
|
|
|
136
136
|
result = worker.process_task(task())
|
|
137
137
|
self.assertEqual(result["events"][0]["message"], "async")
|
|
138
138
|
|
|
139
|
+
def test_logs_are_tagged_with_the_current_process(self):
|
|
140
|
+
worker = Worker()
|
|
141
|
+
|
|
142
|
+
@worker.step("payments.charge-card")
|
|
143
|
+
def charge(_data, ctx: StepContext):
|
|
144
|
+
ctx.process("proc-a")
|
|
145
|
+
ctx.info("inside a")
|
|
146
|
+
ctx.sub("proc-a", "ok")
|
|
147
|
+
ctx.process("proc-b")
|
|
148
|
+
ctx.debug("inside b")
|
|
149
|
+
ctx.process(None)
|
|
150
|
+
ctx.info("step-level")
|
|
151
|
+
return {"ok": True}
|
|
152
|
+
|
|
153
|
+
# process() itself emits nothing, so the events are: [info a, sub a, debug b, info step-level]
|
|
154
|
+
events = worker.process_task(task())["events"]
|
|
155
|
+
# log lines carry the sub-process that was running when they fired...
|
|
156
|
+
self.assertEqual(events[0]["process"], "proc-a")
|
|
157
|
+
self.assertEqual(events[2]["process"], "proc-b")
|
|
158
|
+
# ...the sub-process OUTCOME row names itself (no `process` tag)...
|
|
159
|
+
self.assertNotIn("process", events[1])
|
|
160
|
+
self.assertEqual(events[1]["status"], "ok")
|
|
161
|
+
# ...and a log after process(None) is untagged (step-level).
|
|
162
|
+
self.assertNotIn("process", events[3])
|
|
163
|
+
|
|
164
|
+
def test_on_event_streams_each_event_as_it_is_emitted(self):
|
|
165
|
+
worker = Worker()
|
|
166
|
+
live = []
|
|
167
|
+
|
|
168
|
+
@worker.step("payments.charge-card")
|
|
169
|
+
def charge(_data, ctx: StepContext):
|
|
170
|
+
ctx.info("first")
|
|
171
|
+
# the live sink saw the event the instant it was emitted, before the step returned
|
|
172
|
+
self.assertEqual([e["message"] for e in live], ["first"])
|
|
173
|
+
ctx.sub("proc-a", "ok")
|
|
174
|
+
return {"ok": True}
|
|
175
|
+
|
|
176
|
+
result = worker.process_task(task(), on_event=live.append)
|
|
177
|
+
self.assertEqual([e["message"] for e in live], ["first", "proc-a"])
|
|
178
|
+
# the same events still ride back on the result (live-tail doesn't replace the final record)
|
|
179
|
+
self.assertEqual(len(result["events"]), 2)
|
|
180
|
+
|
|
139
181
|
|
|
140
182
|
if __name__ == "__main__":
|
|
141
183
|
unittest.main()
|
|
@@ -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})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|