durable-worker 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-worker
3
- Version: 0.3.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
@@ -6,8 +6,17 @@ same step name implemented here is callable from a TypeScript workflow via ``ctx
6
6
  """
7
7
 
8
8
  from .cancellation import Cancelled, CancellationRegistry
9
+ from .redis_runner import redis_url_from_env, run_redis_worker
9
10
  from .routing import reply_target
10
- from .worker import FatalError, StepContext, Worker
11
+ from .worker import (
12
+ FatalError,
13
+ StepContext,
14
+ Worker,
15
+ current_step,
16
+ log,
17
+ set_process,
18
+ sub,
19
+ )
11
20
 
12
21
  __all__ = [
13
22
  "Worker",
@@ -16,5 +25,13 @@ __all__ = [
16
25
  "Cancelled",
17
26
  "CancellationRegistry",
18
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",
19
36
  ]
20
- __version__ = "0.3.0"
37
+ __version__ = "0.4.0"
@@ -26,6 +26,20 @@ from .worker import Worker
26
26
  _INSTANCE_ID = f"py-{socket.gethostname()}-{os.getpid()}"
27
27
 
28
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
+
29
43
  def _names(prefix: str, group: str) -> tuple[str, str]:
30
44
  # Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
31
45
  return f"{prefix}-tasks-{group}", f"{prefix}-results"
@@ -8,12 +8,48 @@ and ships results out.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import contextvars
11
12
  import inspect
12
13
  import time
13
14
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
14
15
 
15
16
  Handler = Callable[..., Union[Any, Awaitable[Any]]]
16
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
+
17
53
 
18
54
  class FatalError(Exception):
19
55
  """Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
@@ -148,12 +184,20 @@ class Worker:
148
184
  def __init__(self, group: str = "default") -> None:
149
185
  self.group = group
150
186
  self._handlers: Dict[str, Handler] = {}
187
+ self._blocking: Dict[str, bool] = {}
151
188
 
152
- def step(self, name: str) -> Callable[[Handler], Handler]:
153
- """Decorator registering ``fn`` as the handler for step ``name``."""
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
+ """
154
197
 
155
198
  def register(fn: Handler) -> Handler:
156
199
  self._handlers[name] = fn
200
+ self._blocking[name] = blocking
157
201
  return fn
158
202
 
159
203
  return register
@@ -161,6 +205,47 @@ class Worker:
161
205
  def handles(self, name: str) -> bool:
162
206
  return name in self._handlers
163
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
+
164
249
  def process_task(
165
250
  self,
166
251
  task: Dict[str, Any],
@@ -181,7 +266,7 @@ class Worker:
181
266
  return self._no_handler(base, task["name"])
182
267
  ctx = self._ctx(task, is_cancelled, on_event)
183
268
  try:
184
- output = self._invoke(handler, task.get("input"), ctx)
269
+ output = _run_bound(handler, task.get("input"), ctx)
185
270
  if inspect.isawaitable(output):
186
271
  output = asyncio.run(output)
187
272
  return self._completed(base, output, ctx)
@@ -195,7 +280,8 @@ class Worker:
195
280
  on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
196
281
  ) -> Dict[str, Any]:
197
282
  """Async variant — awaits async handlers in the current loop. Use from a transport that
198
- already runs inside an event loop (e.g. the BullMQ runner)."""
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."""
199
285
 
200
286
  base = self._base(task)
201
287
  handler = self._handlers.get(task["name"])
@@ -203,9 +289,21 @@ class Worker:
203
289
  return self._no_handler(base, task["name"])
204
290
  ctx = self._ctx(task, is_cancelled, on_event)
205
291
  try:
206
- output = self._invoke(handler, task.get("input"), ctx)
207
- if inspect.isawaitable(output):
208
- output = await output
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)
209
307
  return self._completed(base, output, ctx)
210
308
  except Exception as err: # noqa: BLE001
211
309
  return self._failure(base, err, ctx)
@@ -223,14 +321,6 @@ class Worker:
223
321
  on_event=on_event,
224
322
  )
225
323
 
226
- @staticmethod
227
- def _invoke(handler: Handler, input_: Any, ctx: StepContext) -> Any:
228
- """Call ``handler`` with the step input, passing ``ctx`` only if it declares a second
229
- parameter — so plain ``def handler(data)`` handlers keep working unchanged."""
230
- if _wants_ctx(handler):
231
- return handler(input_, ctx)
232
- return handler(input_)
233
-
234
324
  @staticmethod
235
325
  def _base(task: Dict[str, Any]) -> Dict[str, Any]:
236
326
  # Stamp the worker's pickup time so the engine can report queue-wait (startedAt − enqueuedAt),
@@ -265,6 +355,18 @@ class Worker:
265
355
  return _with_events({**base, "status": "failed", "error": error}, ctx)
266
356
 
267
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
+
268
370
  def _wants_ctx(handler: Handler) -> bool:
269
371
  """True if ``handler`` can accept the step context as a second positional argument."""
270
372
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "durable-worker"
3
- version = "0.3.0"
3
+ version = "0.4.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,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()
@@ -1,6 +1,15 @@
1
+ import asyncio
1
2
  import unittest
2
3
 
3
- from durable_worker import FatalError, StepContext, Worker
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):
@@ -179,5 +188,54 @@ class WorkerTest(unittest.TestCase):
179
188
  self.assertEqual(len(result["events"]), 2)
180
189
 
181
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
+
239
+
182
240
  if __name__ == "__main__":
183
241
  unittest.main()
File without changes