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.
Files changed (42) hide show
  1. offwork/__init__.py +167 -0
  2. offwork/__main__.py +770 -0
  3. offwork/_venv.py +174 -0
  4. offwork/core/__init__.py +15 -0
  5. offwork/core/errors.py +83 -0
  6. offwork/core/models.py +174 -0
  7. offwork/core/pairing.py +389 -0
  8. offwork/core/progress.py +91 -0
  9. offwork/core/signing.py +91 -0
  10. offwork/core/task.py +520 -0
  11. offwork/core/token.py +184 -0
  12. offwork/core/version.py +10 -0
  13. offwork/graph/__init__.py +5 -0
  14. offwork/graph/analyzer.py +637 -0
  15. offwork/graph/decorator.py +87 -0
  16. offwork/graph/graph.py +995 -0
  17. offwork/graph/store.py +500 -0
  18. offwork/graph/tracing.py +429 -0
  19. offwork/py.typed +0 -0
  20. offwork/typing.py +48 -0
  21. offwork/worker/__init__.py +18 -0
  22. offwork/worker/backends/__init__.py +3 -0
  23. offwork/worker/backends/base.py +149 -0
  24. offwork/worker/backends/http.py +237 -0
  25. offwork/worker/backends/local.py +452 -0
  26. offwork/worker/backends/rabbitmq.py +410 -0
  27. offwork/worker/backends/redis.py +175 -0
  28. offwork/worker/deps.py +365 -0
  29. offwork/worker/remote.py +793 -0
  30. offwork/worker/result.py +276 -0
  31. offwork/worker/sandbox/Dockerfile +24 -0
  32. offwork/worker/sandbox/__init__.py +18 -0
  33. offwork/worker/sandbox/_protocol.py +50 -0
  34. offwork/worker/sandbox/docker.py +438 -0
  35. offwork/worker/sandbox/guest_agent.py +622 -0
  36. offwork/worker/schedule.py +26 -0
  37. offwork/worker/worker.py +263 -0
  38. offwork-0.4.0.dist-info/METADATA +143 -0
  39. offwork-0.4.0.dist-info/RECORD +42 -0
  40. offwork-0.4.0.dist-info/WHEEL +4 -0
  41. offwork-0.4.0.dist-info/entry_points.txt +3 -0
  42. offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
@@ -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,3 @@
1
+ from offwork.worker.backends.base import Backend
2
+
3
+ __all__ = ["Backend"]
@@ -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."""