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.
- {durable_worker-0.3.0 → durable_worker-0.4.0}/PKG-INFO +1 -1
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/__init__.py +19 -2
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/redis_runner.py +14 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/worker.py +117 -15
- {durable_worker-0.3.0 → durable_worker-0.4.0}/pyproject.toml +1 -1
- durable_worker-0.4.0/tests/test_redis_url.py +51 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/tests/test_worker.py +59 -1
- {durable_worker-0.3.0 → durable_worker-0.4.0}/.gitignore +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/README.md +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/cancellation.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/db_runner.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/routing.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/durable_worker/sqs_runner.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/examples/run_worker.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/tests/test_cancellation.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/tests/test_db_runner.py +0 -0
- {durable_worker-0.3.0 → durable_worker-0.4.0}/tests/test_routing.py +0 -0
- {durable_worker-0.3.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
|
|
@@ -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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
output = await
|
|
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:
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|