offwork 0.4.0__py3-none-any.whl
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.
- offwork/__init__.py +167 -0
- offwork/__main__.py +770 -0
- offwork/_venv.py +174 -0
- offwork/core/__init__.py +15 -0
- offwork/core/errors.py +83 -0
- offwork/core/models.py +174 -0
- offwork/core/pairing.py +389 -0
- offwork/core/progress.py +91 -0
- offwork/core/signing.py +91 -0
- offwork/core/task.py +520 -0
- offwork/core/token.py +184 -0
- offwork/core/version.py +10 -0
- offwork/graph/__init__.py +5 -0
- offwork/graph/analyzer.py +637 -0
- offwork/graph/decorator.py +87 -0
- offwork/graph/graph.py +995 -0
- offwork/graph/store.py +500 -0
- offwork/graph/tracing.py +429 -0
- offwork/py.typed +0 -0
- offwork/typing.py +48 -0
- offwork/worker/__init__.py +18 -0
- offwork/worker/backends/__init__.py +3 -0
- offwork/worker/backends/base.py +149 -0
- offwork/worker/backends/http.py +237 -0
- offwork/worker/backends/local.py +452 -0
- offwork/worker/backends/rabbitmq.py +410 -0
- offwork/worker/backends/redis.py +175 -0
- offwork/worker/deps.py +365 -0
- offwork/worker/remote.py +793 -0
- offwork/worker/result.py +276 -0
- offwork/worker/sandbox/Dockerfile +24 -0
- offwork/worker/sandbox/__init__.py +18 -0
- offwork/worker/sandbox/_protocol.py +50 -0
- offwork/worker/sandbox/docker.py +438 -0
- offwork/worker/sandbox/guest_agent.py +622 -0
- offwork/worker/schedule.py +26 -0
- offwork/worker/worker.py +263 -0
- offwork-0.4.0.dist-info/METADATA +143 -0
- offwork-0.4.0.dist-info/RECORD +42 -0
- offwork-0.4.0.dist-info/WHEEL +4 -0
- offwork-0.4.0.dist-info/entry_points.txt +3 -0
- offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
offwork/graph/tracing.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Runtime call-stack tracing for dependency edge recording via contextvars."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time as _time
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
import builtins
|
|
10
|
+
import functools
|
|
11
|
+
import sysconfig
|
|
12
|
+
import threading
|
|
13
|
+
import contextvars
|
|
14
|
+
from typing import Any, TypeVar, ParamSpec, cast
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from collections.abc import Callable, Awaitable, Generator, AsyncGenerator
|
|
18
|
+
|
|
19
|
+
from offwork.typing import TracedFunction
|
|
20
|
+
from offwork.worker.backends.base import Backend
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_F = TypeVar("_F", bound=Callable[..., object])
|
|
25
|
+
_P = ParamSpec("_P")
|
|
26
|
+
_R = TypeVar("_R")
|
|
27
|
+
|
|
28
|
+
_BUILTIN_NAMES = set(dir(builtins))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_start_method(
|
|
32
|
+
wrapper: Callable[..., object], func: Callable[..., object]
|
|
33
|
+
) -> Callable[..., object]:
|
|
34
|
+
"""Create the ``.start()`` async method that submits and returns a Result."""
|
|
35
|
+
|
|
36
|
+
async def start(*args: Any, backend: str | Backend | None = None, **kwargs: Any) -> object:
|
|
37
|
+
from offwork.worker.remote import submit_remote # circular
|
|
38
|
+
|
|
39
|
+
return await submit_remote(func, wrapper, *args, _backend=backend, **kwargs)
|
|
40
|
+
|
|
41
|
+
return start
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_run_method(
|
|
45
|
+
start_method: Callable[..., object],
|
|
46
|
+
) -> Callable[..., object]:
|
|
47
|
+
"""Create the ``.run()`` async method that submits and awaits the result."""
|
|
48
|
+
|
|
49
|
+
async def run(*args: object, **kwargs: object) -> object:
|
|
50
|
+
result = await start_method(*args, **kwargs) # type: ignore[misc]
|
|
51
|
+
return await result
|
|
52
|
+
|
|
53
|
+
return run
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_map_method(
|
|
57
|
+
start_method: Callable[..., object],
|
|
58
|
+
) -> Callable[..., object]:
|
|
59
|
+
"""Create the ``.map()`` async method for batch submission and collection."""
|
|
60
|
+
|
|
61
|
+
async def map(args_list: list[tuple[object, ...]], **kwargs: object) -> list[object]:
|
|
62
|
+
coros: list[Awaitable[object]] = [
|
|
63
|
+
cast(Awaitable[object], start_method(*args, **kwargs))
|
|
64
|
+
for args in args_list
|
|
65
|
+
]
|
|
66
|
+
results = await asyncio.gather(*coros)
|
|
67
|
+
awaitables: list[Awaitable[object]] = [
|
|
68
|
+
cast(Awaitable[object], r) for r in results
|
|
69
|
+
]
|
|
70
|
+
return list(await asyncio.gather(*awaitables))
|
|
71
|
+
|
|
72
|
+
return map
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _make_start_at_method(
|
|
76
|
+
wrapper: Callable[..., object], func: Callable[..., object]
|
|
77
|
+
) -> Callable[..., object]:
|
|
78
|
+
"""Create the ``.start_at()`` method that submits a task scheduled for a specific time."""
|
|
79
|
+
|
|
80
|
+
async def start_at(dt: Any, *args: Any, backend: str | Backend | None = None, **kwargs: Any) -> object:
|
|
81
|
+
from offwork.worker.remote import submit_remote_scheduled # circular
|
|
82
|
+
|
|
83
|
+
ts = dt.timestamp() if isinstance(dt, datetime) else float(dt)
|
|
84
|
+
return await submit_remote_scheduled(
|
|
85
|
+
func, wrapper, *args, _backend=backend, _scheduled_at=ts, **kwargs,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return start_at
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _make_run_at_method(
|
|
92
|
+
start_at_method: Callable[..., object],
|
|
93
|
+
) -> Callable[..., object]:
|
|
94
|
+
"""Create the ``.run_at()`` method that submits at a time and awaits the result."""
|
|
95
|
+
|
|
96
|
+
async def run_at(dt: Any, *args: object, **kwargs: object) -> object:
|
|
97
|
+
result = await start_at_method(dt, *args, **kwargs) # type: ignore[misc]
|
|
98
|
+
return await result
|
|
99
|
+
|
|
100
|
+
return run_at
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _make_start_in_method(
|
|
104
|
+
wrapper: Callable[..., object], func: Callable[..., object]
|
|
105
|
+
) -> Callable[..., object]:
|
|
106
|
+
"""Create the ``.start_in()`` method that submits a task after a delay."""
|
|
107
|
+
|
|
108
|
+
async def start_in(delay: Any, *args: Any, backend: str | Backend | None = None, **kwargs: Any) -> object:
|
|
109
|
+
from offwork.worker.remote import submit_remote_scheduled # circular
|
|
110
|
+
|
|
111
|
+
seconds = delay.total_seconds() if isinstance(delay, timedelta) else float(delay)
|
|
112
|
+
return await submit_remote_scheduled(
|
|
113
|
+
func, wrapper, *args, _backend=backend, _scheduled_at=_time.time() + seconds, **kwargs,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return start_in
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _make_run_in_method(
|
|
120
|
+
start_in_method: Callable[..., object],
|
|
121
|
+
) -> Callable[..., object]:
|
|
122
|
+
"""Create the ``.run_in()`` method that submits after a delay and awaits."""
|
|
123
|
+
|
|
124
|
+
async def run_in(delay: Any, *args: object, **kwargs: object) -> object:
|
|
125
|
+
result = await start_in_method(delay, *args, **kwargs) # type: ignore[misc]
|
|
126
|
+
return await result
|
|
127
|
+
|
|
128
|
+
return run_in
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _make_run_every_method(
|
|
132
|
+
wrapper: Callable[..., object], func: Callable[..., object]
|
|
133
|
+
) -> Callable[..., object]:
|
|
134
|
+
"""Create the ``.run_every()`` method for recurring execution."""
|
|
135
|
+
|
|
136
|
+
async def run_every(
|
|
137
|
+
frequency: Any,
|
|
138
|
+
*args: Any,
|
|
139
|
+
_start_at: Any = None,
|
|
140
|
+
backend: str | Backend | None = None,
|
|
141
|
+
**kwargs: Any,
|
|
142
|
+
) -> object:
|
|
143
|
+
from offwork.worker.remote import submit_recurring # circular
|
|
144
|
+
|
|
145
|
+
interval = frequency.total_seconds() if isinstance(frequency, timedelta) else float(frequency)
|
|
146
|
+
start_ts: float | None = None
|
|
147
|
+
if _start_at is not None:
|
|
148
|
+
start_ts = _start_at.timestamp() if isinstance(_start_at, datetime) else float(_start_at)
|
|
149
|
+
return await submit_recurring(
|
|
150
|
+
func, wrapper, *args,
|
|
151
|
+
_backend=backend, _interval=interval, _start_at=start_ts,
|
|
152
|
+
**kwargs,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return run_every
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attach_traced_attrs(
|
|
159
|
+
wrapper: Callable[..., object], func: Callable[..., object]
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Attach offwork metadata and .start()/.run()/.map() to a traced wrapper."""
|
|
162
|
+
wrapper.__offwork_traced__ = True # type: ignore[attr-defined]
|
|
163
|
+
start = _make_start_method(wrapper, func)
|
|
164
|
+
wrapper.start = start # type: ignore[attr-defined]
|
|
165
|
+
wrapper.run = _make_run_method(start) # type: ignore[attr-defined]
|
|
166
|
+
wrapper.map = _make_map_method(start) # type: ignore[attr-defined]
|
|
167
|
+
|
|
168
|
+
start_at = _make_start_at_method(wrapper, func)
|
|
169
|
+
wrapper.start_at = start_at # type: ignore[attr-defined]
|
|
170
|
+
wrapper.run_at = _make_run_at_method(start_at) # type: ignore[attr-defined]
|
|
171
|
+
|
|
172
|
+
start_in = _make_start_in_method(wrapper, func)
|
|
173
|
+
wrapper.start_in = start_in # type: ignore[attr-defined]
|
|
174
|
+
wrapper.run_in = _make_run_in_method(start_in) # type: ignore[attr-defined]
|
|
175
|
+
|
|
176
|
+
wrapper.run_every = _make_run_every_method(wrapper, func) # type: ignore[attr-defined]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_stdlib_dirs() -> list[str]:
|
|
180
|
+
dirs: list[str] = []
|
|
181
|
+
for key in ("stdlib", "platstdlib"):
|
|
182
|
+
val = sysconfig.get_path(key)
|
|
183
|
+
if val:
|
|
184
|
+
dirs.append(str(Path(val).resolve()))
|
|
185
|
+
return dirs
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_STDLIB_DIRS = _get_stdlib_dirs()
|
|
189
|
+
|
|
190
|
+
# Derive our own top-level package name so we never inline offwork internals.
|
|
191
|
+
_SELF_TOP_PACKAGE = (__package__ or __name__).split(".")[0]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_stdlib_module(module: str) -> bool:
|
|
195
|
+
"""Return True if *module* belongs to the standard library."""
|
|
196
|
+
top_module = module.split(".")[0]
|
|
197
|
+
return hasattr(sys, "stdlib_module_names") and top_module in sys.stdlib_module_names
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_user_source_file(source_file: str) -> bool:
|
|
201
|
+
"""Return True if *source_file* is user code (not stdlib/site-packages)."""
|
|
202
|
+
resolved = str(Path(source_file).resolve())
|
|
203
|
+
if any(resolved.startswith(d) for d in _STDLIB_DIRS):
|
|
204
|
+
return False
|
|
205
|
+
return f"{os.sep}site-packages{os.sep}" not in resolved
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_user_defined(obj: object) -> bool:
|
|
209
|
+
"""Return True if *obj* (function or class) is user-defined."""
|
|
210
|
+
if inspect.isfunction(obj):
|
|
211
|
+
module = obj.__module__
|
|
212
|
+
elif inspect.isclass(obj):
|
|
213
|
+
module = obj.__module__
|
|
214
|
+
else:
|
|
215
|
+
return False
|
|
216
|
+
if _is_stdlib_module(module):
|
|
217
|
+
return False
|
|
218
|
+
if module.split(".")[0] == _SELF_TOP_PACKAGE:
|
|
219
|
+
return False
|
|
220
|
+
try:
|
|
221
|
+
source_file = inspect.getfile(obj)
|
|
222
|
+
except (TypeError, OSError):
|
|
223
|
+
return False
|
|
224
|
+
return _is_user_source_file(source_file)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _is_user_function(func: Callable[..., object]) -> bool:
|
|
228
|
+
"""Return True if func is user-defined (not stdlib or third-party)."""
|
|
229
|
+
return inspect.isfunction(func) and _is_user_defined(func)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _is_user_class(cls: type) -> bool:
|
|
233
|
+
"""Return True if cls is user-defined (not stdlib or third-party)."""
|
|
234
|
+
return inspect.isclass(cls) and _is_user_defined(cls)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TracingMixin:
|
|
238
|
+
"""Runtime call-stack tracing for dependency edge recording.
|
|
239
|
+
|
|
240
|
+
Expects the host class to provide:
|
|
241
|
+
- ``self._call_stack``: ``contextvars.ContextVar[list[str]]``
|
|
242
|
+
- ``self._runtime_deps``: ``dict[str, set[str]]``
|
|
243
|
+
- ``self._lock``: ``threading.Lock``
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
_call_stack: contextvars.ContextVar[list[str]]
|
|
247
|
+
_runtime_deps: dict[str, set[str]]
|
|
248
|
+
_lock: threading.Lock
|
|
249
|
+
|
|
250
|
+
def _get_call_stack(self) -> list[str]:
|
|
251
|
+
try:
|
|
252
|
+
return self._call_stack.get()
|
|
253
|
+
except LookupError:
|
|
254
|
+
stack: list[str] = []
|
|
255
|
+
self._call_stack.set(stack)
|
|
256
|
+
return stack
|
|
257
|
+
|
|
258
|
+
def _ensure_isolated_stack(self) -> list[str]:
|
|
259
|
+
"""Return a call stack isolated for the current async context.
|
|
260
|
+
|
|
261
|
+
ContextVar copies the reference to the list when a new Task is created,
|
|
262
|
+
so async wrappers must call this to get a fresh copy, preventing
|
|
263
|
+
mutations from leaking across tasks.
|
|
264
|
+
"""
|
|
265
|
+
stack = list(self._get_call_stack())
|
|
266
|
+
self._call_stack.set(stack)
|
|
267
|
+
return stack
|
|
268
|
+
|
|
269
|
+
def _record_edge(self, stack: list[str], qualified_name: str) -> None:
|
|
270
|
+
"""Record a runtime dependency edge if a caller is on the stack."""
|
|
271
|
+
if stack and stack[-1] != qualified_name:
|
|
272
|
+
with self._lock:
|
|
273
|
+
self._runtime_deps.setdefault(stack[-1], set()).add(qualified_name)
|
|
274
|
+
|
|
275
|
+
def create_wrapper(self, func: Callable[_P, _R]) -> TracedFunction[_P, _R]:
|
|
276
|
+
"""Wrap func to record runtime caller-callee edges.
|
|
277
|
+
|
|
278
|
+
The wrapper preserves the original function signature via
|
|
279
|
+
``functools.wraps`` and adds ``.run()`` / ``.arun()`` /
|
|
280
|
+
``.map()`` / ``.amap()`` methods for remote submission.
|
|
281
|
+
"""
|
|
282
|
+
qualified_name = f"{func.__module__}.{func.__qualname__}"
|
|
283
|
+
# Each _wrap_* method returns Any because functools.wraps erases
|
|
284
|
+
# the precise callable type. The outer signature guarantees _F.
|
|
285
|
+
if inspect.isasyncgenfunction(func):
|
|
286
|
+
wrapper = self._wrap_async_generator(func, qualified_name)
|
|
287
|
+
elif inspect.iscoroutinefunction(func):
|
|
288
|
+
wrapper = self._wrap_coroutine(func, qualified_name)
|
|
289
|
+
elif inspect.isgeneratorfunction(func):
|
|
290
|
+
wrapper = self._wrap_generator(func, qualified_name)
|
|
291
|
+
else:
|
|
292
|
+
wrapper = self._wrap_sync(func, qualified_name)
|
|
293
|
+
return cast(TracedFunction[_P, _R], wrapper)
|
|
294
|
+
|
|
295
|
+
def _wrap_async_generator(self, func: Any, qualified_name: str) -> Any:
|
|
296
|
+
logger.debug("Creating async generator wrapper for %s", qualified_name)
|
|
297
|
+
|
|
298
|
+
@functools.wraps(func)
|
|
299
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
300
|
+
self._record_edge(self._get_call_stack(), qualified_name)
|
|
301
|
+
return self._proxy_async_generator(func(*args, **kwargs), qualified_name)
|
|
302
|
+
|
|
303
|
+
_attach_traced_attrs(wrapper, func)
|
|
304
|
+
return wrapper
|
|
305
|
+
|
|
306
|
+
def _wrap_coroutine(self, func: Any, qualified_name: str) -> Any:
|
|
307
|
+
logger.debug("Creating async wrapper for %s", qualified_name)
|
|
308
|
+
|
|
309
|
+
@functools.wraps(func)
|
|
310
|
+
async def wrapper(*args: object, **kwargs: object) -> object:
|
|
311
|
+
stack = self._ensure_isolated_stack()
|
|
312
|
+
self._record_edge(stack, qualified_name)
|
|
313
|
+
stack.append(qualified_name)
|
|
314
|
+
try:
|
|
315
|
+
return await func(*args, **kwargs)
|
|
316
|
+
finally:
|
|
317
|
+
stack.pop()
|
|
318
|
+
|
|
319
|
+
_attach_traced_attrs(wrapper, func)
|
|
320
|
+
return wrapper
|
|
321
|
+
|
|
322
|
+
def _wrap_generator(self, func: Any, qualified_name: str) -> Any:
|
|
323
|
+
logger.debug("Creating generator wrapper for %s", qualified_name)
|
|
324
|
+
|
|
325
|
+
@functools.wraps(func)
|
|
326
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
327
|
+
self._record_edge(self._get_call_stack(), qualified_name)
|
|
328
|
+
return self._proxy_generator(func(*args, **kwargs), qualified_name)
|
|
329
|
+
|
|
330
|
+
_attach_traced_attrs(wrapper, func)
|
|
331
|
+
return wrapper
|
|
332
|
+
|
|
333
|
+
def _wrap_sync(self, func: Any, qualified_name: str) -> Any:
|
|
334
|
+
logger.debug("Creating sync wrapper for %s", qualified_name)
|
|
335
|
+
|
|
336
|
+
@functools.wraps(func)
|
|
337
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
338
|
+
stack = self._get_call_stack()
|
|
339
|
+
self._record_edge(stack, qualified_name)
|
|
340
|
+
stack.append(qualified_name)
|
|
341
|
+
try:
|
|
342
|
+
return func(*args, **kwargs)
|
|
343
|
+
finally:
|
|
344
|
+
stack.pop()
|
|
345
|
+
|
|
346
|
+
_attach_traced_attrs(wrapper, func)
|
|
347
|
+
return wrapper
|
|
348
|
+
|
|
349
|
+
def _proxy_generator(
|
|
350
|
+
self,
|
|
351
|
+
gen: Generator[object, object, object],
|
|
352
|
+
qualified_name: str,
|
|
353
|
+
) -> Generator[object, object, object]:
|
|
354
|
+
"""Wrap a generator to maintain call stack context during iteration."""
|
|
355
|
+
stack = self._get_call_stack()
|
|
356
|
+
stack.append(qualified_name)
|
|
357
|
+
try:
|
|
358
|
+
value = next(gen)
|
|
359
|
+
except StopIteration as e:
|
|
360
|
+
return e.value
|
|
361
|
+
finally:
|
|
362
|
+
stack.pop()
|
|
363
|
+
|
|
364
|
+
while True:
|
|
365
|
+
try:
|
|
366
|
+
sent = yield value
|
|
367
|
+
except GeneratorExit:
|
|
368
|
+
gen.close()
|
|
369
|
+
return # type: ignore[return-value]
|
|
370
|
+
except BaseException as exc:
|
|
371
|
+
stack = self._get_call_stack()
|
|
372
|
+
stack.append(qualified_name)
|
|
373
|
+
try:
|
|
374
|
+
value = gen.throw(exc)
|
|
375
|
+
except StopIteration as e:
|
|
376
|
+
return e.value
|
|
377
|
+
finally:
|
|
378
|
+
stack.pop()
|
|
379
|
+
else:
|
|
380
|
+
stack = self._get_call_stack()
|
|
381
|
+
stack.append(qualified_name)
|
|
382
|
+
try:
|
|
383
|
+
value = gen.send(sent)
|
|
384
|
+
except StopIteration as e:
|
|
385
|
+
return e.value
|
|
386
|
+
finally:
|
|
387
|
+
stack.pop()
|
|
388
|
+
|
|
389
|
+
async def _proxy_async_generator(
|
|
390
|
+
self,
|
|
391
|
+
async_gen: AsyncGenerator[object, object],
|
|
392
|
+
qualified_name: str,
|
|
393
|
+
) -> AsyncGenerator[object, object]:
|
|
394
|
+
"""Wrap an async generator to maintain call stack context during iteration."""
|
|
395
|
+
# Isolate once at entry; subsequent calls reuse the same isolated stack.
|
|
396
|
+
self._ensure_isolated_stack()
|
|
397
|
+
stack = self._get_call_stack()
|
|
398
|
+
stack.append(qualified_name)
|
|
399
|
+
try:
|
|
400
|
+
value = await async_gen.__anext__()
|
|
401
|
+
except StopAsyncIteration:
|
|
402
|
+
return
|
|
403
|
+
finally:
|
|
404
|
+
stack.pop()
|
|
405
|
+
|
|
406
|
+
while True:
|
|
407
|
+
try:
|
|
408
|
+
sent = yield value
|
|
409
|
+
except GeneratorExit:
|
|
410
|
+
await async_gen.aclose()
|
|
411
|
+
return
|
|
412
|
+
except BaseException as exc:
|
|
413
|
+
stack = self._get_call_stack()
|
|
414
|
+
stack.append(qualified_name)
|
|
415
|
+
try:
|
|
416
|
+
value = await async_gen.athrow(exc)
|
|
417
|
+
except StopAsyncIteration:
|
|
418
|
+
return
|
|
419
|
+
finally:
|
|
420
|
+
stack.pop()
|
|
421
|
+
else:
|
|
422
|
+
stack = self._get_call_stack()
|
|
423
|
+
stack.append(qualified_name)
|
|
424
|
+
try:
|
|
425
|
+
value = await async_gen.asend(sent)
|
|
426
|
+
except StopAsyncIteration:
|
|
427
|
+
return
|
|
428
|
+
finally:
|
|
429
|
+
stack.pop()
|
offwork/py.typed
ADDED
|
File without changes
|
offwork/typing.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Protocol types for ``@trace``-decorated functions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Any, TypeVar, Protocol, ParamSpec
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from offwork.worker.result import Result
|
|
8
|
+
from offwork.worker.schedule import ScheduleHandle
|
|
9
|
+
|
|
10
|
+
P = ParamSpec("P")
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TracedFunction(Protocol[P, R]):
|
|
15
|
+
"""A function decorated with ``@trace``, with remote execution methods."""
|
|
16
|
+
|
|
17
|
+
__offwork_traced__: bool
|
|
18
|
+
__wrapped__: Callable[P, R]
|
|
19
|
+
|
|
20
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
|
|
21
|
+
|
|
22
|
+
async def start(self, *args: P.args, **kwargs: P.kwargs) -> Result: ...
|
|
23
|
+
|
|
24
|
+
async def run(self, *args: P.args, **kwargs: P.kwargs) -> Any: ...
|
|
25
|
+
|
|
26
|
+
async def map(self, args_list: list[tuple[Any, ...]], **kwargs: Any) -> list[Any]: ...
|
|
27
|
+
|
|
28
|
+
async def start_at(self, dt: datetime, *args: P.args, **kwargs: P.kwargs) -> Result: ...
|
|
29
|
+
|
|
30
|
+
async def run_at(self, dt: datetime, *args: P.args, **kwargs: P.kwargs) -> Any: ...
|
|
31
|
+
|
|
32
|
+
async def start_in(self, delay: timedelta | float, *args: P.args, **kwargs: P.kwargs) -> Result: ...
|
|
33
|
+
|
|
34
|
+
async def run_in(self, delay: timedelta | float, *args: P.args, **kwargs: P.kwargs) -> Any: ...
|
|
35
|
+
|
|
36
|
+
async def run_every(
|
|
37
|
+
self,
|
|
38
|
+
frequency: timedelta | float,
|
|
39
|
+
*args: Any,
|
|
40
|
+
_start_at: datetime | None = ...,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> ScheduleHandle: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TraceDecorator(Protocol):
|
|
46
|
+
"""The ``@trace`` decorator when called with keyword arguments."""
|
|
47
|
+
|
|
48
|
+
def __call__(self, func: Callable[P, R]) -> TracedFunction[P, R]: ...
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from offwork.worker.deps import install_package_as, worker_only_import, ensure_dependencies
|
|
2
|
+
from offwork.worker.remote import serve, connect, disconnect, submit_remote
|
|
3
|
+
from offwork.worker.result import Result, ResultEnvelope
|
|
4
|
+
from offwork.worker.worker import Worker, execute
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"install_package_as",
|
|
8
|
+
"worker_only_import",
|
|
9
|
+
"ensure_dependencies",
|
|
10
|
+
"serve",
|
|
11
|
+
"connect",
|
|
12
|
+
"disconnect",
|
|
13
|
+
"submit_remote",
|
|
14
|
+
"Result",
|
|
15
|
+
"ResultEnvelope",
|
|
16
|
+
"Worker",
|
|
17
|
+
"execute",
|
|
18
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Abstract base class for transport backends."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Backend(abc.ABC):
|
|
8
|
+
"""Abstract transport backend for remote task execution.
|
|
9
|
+
|
|
10
|
+
Subclass this to implement custom transports (Redis, RabbitMQ,
|
|
11
|
+
TCP, etc.).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
async def submit(self, task_json: str) -> None:
|
|
16
|
+
"""Enqueue a serialized task for a worker to pick up."""
|
|
17
|
+
|
|
18
|
+
@abc.abstractmethod
|
|
19
|
+
def listen(self) -> AsyncIterator[str]:
|
|
20
|
+
"""Async iterator that yields serialized task JSON strings."""
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
async def send_result(self, task_id: str, result_json: str) -> None:
|
|
24
|
+
"""Store a result envelope for the given task."""
|
|
25
|
+
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
async def get_result(self, task_id: str, timeout: float | None = None) -> str:
|
|
28
|
+
"""Await until result for *task_id* is available. Returns raw JSON."""
|
|
29
|
+
|
|
30
|
+
@abc.abstractmethod
|
|
31
|
+
async def try_get_result(self, task_id: str) -> str | None:
|
|
32
|
+
"""Non-blocking result fetch. Returns ``None`` if not ready."""
|
|
33
|
+
|
|
34
|
+
# -- Heartbeat -------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
async def send_heartbeat(self, task_id: str) -> None:
|
|
37
|
+
"""Signal that a worker is actively processing *task_id*.
|
|
38
|
+
|
|
39
|
+
Called periodically by the worker while a task is running.
|
|
40
|
+
The default implementation is a no-op; backends that support
|
|
41
|
+
heartbeat-based stall detection should override this.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
async def get_heartbeat(self, task_id: str) -> float | None:
|
|
45
|
+
"""Return the timestamp of the last heartbeat for *task_id*.
|
|
46
|
+
|
|
47
|
+
Returns ``None`` if no heartbeat has been recorded. The
|
|
48
|
+
timestamp is a ``time.time()`` value written by the worker.
|
|
49
|
+
"""
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
async def get_heartbeats(self, task_ids: list[str]) -> dict[str, float | None]:
|
|
53
|
+
"""Batch-fetch heartbeats for multiple tasks.
|
|
54
|
+
|
|
55
|
+
Default implementation loops over :meth:`get_heartbeat`.
|
|
56
|
+
Backends can override for efficiency (e.g. Redis ``MGET``).
|
|
57
|
+
"""
|
|
58
|
+
return {tid: await self.get_heartbeat(tid) for tid in task_ids}
|
|
59
|
+
|
|
60
|
+
# -- Cancellation ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def cancel_task(self, task_id: str) -> None:
|
|
63
|
+
"""Mark a task as cancelled.
|
|
64
|
+
|
|
65
|
+
The worker checks this flag before starting execution.
|
|
66
|
+
The default implementation is a no-op.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
async def is_cancelled(self, task_id: str) -> bool:
|
|
70
|
+
"""Return whether a task has been cancelled.
|
|
71
|
+
|
|
72
|
+
The default implementation always returns ``False``.
|
|
73
|
+
"""
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# -- Progress --------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def send_progress(self, task_id: str, progress_json: str) -> None:
|
|
79
|
+
"""Store the latest progress data for a task.
|
|
80
|
+
|
|
81
|
+
Called by the worker when user code calls :func:`offwork.progress`.
|
|
82
|
+
The default implementation is a no-op.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
async def get_progress(self, task_id: str) -> str | None:
|
|
86
|
+
"""Return the latest progress JSON for a task, or ``None``.
|
|
87
|
+
|
|
88
|
+
The default returns ``None``.
|
|
89
|
+
"""
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# -- Schedule cancellation ------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def cancel_schedule(self, schedule_id: str) -> None:
|
|
95
|
+
"""Mark a recurring schedule as cancelled.
|
|
96
|
+
|
|
97
|
+
The worker checks this before re-enqueuing the next occurrence.
|
|
98
|
+
The default implementation is a no-op.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
async def is_schedule_cancelled(self, schedule_id: str) -> bool:
|
|
102
|
+
"""Return whether a recurring schedule has been cancelled."""
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# -- Throttle --------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async def check_throttle(self, function_name: str) -> bool:
|
|
108
|
+
"""Return ``True`` if the function is allowed to execute.
|
|
109
|
+
|
|
110
|
+
Returns ``False`` when the cooldown period from a previous
|
|
111
|
+
execution has not elapsed. The default always returns ``True``.
|
|
112
|
+
"""
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
async def record_throttle(
|
|
116
|
+
self, function_name: str, throttle_seconds: float,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Record that a function was just executed, starting a cooldown.
|
|
119
|
+
|
|
120
|
+
Subsequent :meth:`check_throttle` calls within *throttle_seconds*
|
|
121
|
+
should return ``False``. The default is a no-op.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# -- Result notifications --------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async def notify_result(self, task_id: str) -> None:
|
|
127
|
+
"""Publish a push notification that a result is ready.
|
|
128
|
+
|
|
129
|
+
Called by the worker after :meth:`send_result`. The default
|
|
130
|
+
is a no-op; backends that support push notifications should
|
|
131
|
+
override this together with :meth:`subscribe_results`.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def subscribe_results(self) -> AsyncIterator[str]:
|
|
135
|
+
"""Async iterator yielding *task_id* strings as results arrive.
|
|
136
|
+
|
|
137
|
+
Used by the client-side :class:`Result` to receive push
|
|
138
|
+
notifications. The default raises ``NotImplementedError``;
|
|
139
|
+
the result falls back to polling in that case.
|
|
140
|
+
"""
|
|
141
|
+
raise NotImplementedError(
|
|
142
|
+
"This backend does not support result subscriptions."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# -- Lifecycle -------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
@abc.abstractmethod
|
|
148
|
+
async def close(self) -> None:
|
|
149
|
+
"""Release resources."""
|