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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-worker
3
- Version: 0.2.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__ = ["Worker", "FatalError", "StepContext"]
11
- __version__ = "0.2.0"
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__(self) -> None:
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(self, task: Dict[str, Any]) -> Dict[str, Any]:
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 = StepContext()
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(self, task: Dict[str, Any]) -> Dict[str, Any]:
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 = StepContext()
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "durable-worker"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Python worker SDK for nestjs-durable — run durable workflow steps in Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -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