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,276 @@
1
+ """Result future and result envelope for remote task execution."""
2
+
3
+ import json
4
+ import time
5
+ import asyncio
6
+ import logging
7
+ import traceback as tb_mod
8
+ from typing import Any, Self
9
+ from dataclasses import dataclass
10
+ from collections.abc import Generator
11
+
12
+ from offwork.core.task import _TaskEncoder, _resolve
13
+ from offwork.core.errors import RemoteError, TaskStalled, TaskCancelled, ThrottleError
14
+ from offwork.core.progress import ProgressInfo
15
+ from offwork.worker.backends.base import Backend
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ResultEnvelope:
22
+ """Serializable envelope for a task result (success or error).
23
+
24
+ Use the :meth:`success` and :meth:`failure` class methods to create
25
+ instances.
26
+ """
27
+
28
+ task_id: str
29
+ status: str # "ok", "error", or "cancelled"
30
+ result: Any = None
31
+ error_type: str | None = None
32
+ error_message: str | None = None
33
+ error_traceback: str | None = None
34
+
35
+ @classmethod
36
+ def success(cls, task_id: str, result: Any) -> Self:
37
+ """Create an envelope for a successful result."""
38
+ return cls(task_id=task_id, status="ok", result=result)
39
+
40
+ @classmethod
41
+ def cancelled(cls, task_id: str) -> Self:
42
+ """Create an envelope for a cancelled task."""
43
+ return cls(task_id=task_id, status="cancelled")
44
+
45
+ @classmethod
46
+ def throttled(cls, task_id: str) -> Self:
47
+ """Create an envelope for a throttled (rate-limited) task."""
48
+ return cls(task_id=task_id, status="throttled")
49
+
50
+ @classmethod
51
+ def failure(cls, task_id: str, exc: BaseException) -> Self:
52
+ """Create an envelope from an exception, capturing its traceback."""
53
+ return cls(
54
+ task_id=task_id,
55
+ status="error",
56
+ error_type=type(exc).__name__,
57
+ error_message=str(exc),
58
+ error_traceback="".join(tb_mod.format_exception(exc)),
59
+ )
60
+
61
+ def to_json(self) -> str:
62
+ """Serialize to JSON string.
63
+
64
+ Result payloads are encoded with the same sentinel-based encoder
65
+ used for task arguments, so ``bytes``, ``datetime``, ``Decimal``,
66
+ ``Path`` etc. round-trip transparently.
67
+ """
68
+ d: dict[str, Any] = {
69
+ "task_id": self.task_id,
70
+ "status": self.status,
71
+ }
72
+ if self.status == "ok":
73
+ d["result"] = self.result
74
+ elif self.status == "error":
75
+ d["error_type"] = self.error_type
76
+ d["error_message"] = self.error_message
77
+ d["error_traceback"] = self.error_traceback
78
+ return json.dumps(d, cls=_TaskEncoder)
79
+
80
+ @classmethod
81
+ def from_json(cls, raw: str | bytes) -> Self:
82
+ """Deserialize from a JSON string or bytes."""
83
+ data = json.loads(raw)
84
+ result = _resolve(data.get("result"), {}) if data.get("status") == "ok" else None
85
+ return cls(
86
+ task_id=data["task_id"],
87
+ status=data["status"],
88
+ result=result,
89
+ error_type=data.get("error_type"),
90
+ error_message=data.get("error_message"),
91
+ error_traceback=data.get("error_traceback"),
92
+ )
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Result
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ class Result:
101
+ """Awaitable future-like handle for a remotely submitted task.
102
+
103
+ Returned by ``traced_func.run(...)``.
104
+ """
105
+
106
+ def __init__(self, task_id: str, backend: Backend) -> None:
107
+ self._task_id = task_id
108
+ self._backend = backend
109
+ self._envelope: ResultEnvelope | None = None
110
+
111
+ @property
112
+ def task_id(self) -> str:
113
+ return self._task_id
114
+
115
+ def _unwrap(self) -> Any:
116
+ """Unwrap a cached envelope, raising on error or cancellation."""
117
+ assert self._envelope is not None
118
+ if self._envelope.status == "cancelled":
119
+ raise TaskCancelled(
120
+ f"Task {self._task_id} was cancelled"
121
+ ) from None
122
+ if self._envelope.status == "throttled":
123
+ raise ThrottleError(
124
+ f"Task {self._task_id} was throttled (rate-limited)"
125
+ ) from None
126
+ if self._envelope.status == "error":
127
+ msg = (
128
+ f"{self._envelope.error_type}: "
129
+ f"{self._envelope.error_message}"
130
+ )
131
+ raise RemoteError(msg, self._envelope.error_traceback) from None
132
+ return self._envelope.result
133
+
134
+ async def result(
135
+ self,
136
+ timeout: float | None = None,
137
+ stall_timeout: float | None = 10.0,
138
+ ) -> Any:
139
+ """Await the result.
140
+
141
+ Parameters
142
+ ----------
143
+ timeout
144
+ Maximum seconds to wait for the result.
145
+ stall_timeout
146
+ Raises :class:`TaskStalled` if the worker stops sending
147
+ heartbeats for longer than this many seconds after at least
148
+ one heartbeat has been observed. Set to ``None`` to
149
+ disable stall detection.
150
+
151
+ Raises :class:`RemoteError` if the remote execution failed.
152
+ """
153
+ if self._envelope is not None:
154
+ return self._unwrap()
155
+
156
+ logger.debug("Waiting for result of task %s", self._task_id[:8])
157
+ if stall_timeout is None:
158
+ raw = await self._backend.get_result(self._task_id, timeout=timeout)
159
+ self._envelope = ResultEnvelope.from_json(raw)
160
+ logger.debug(
161
+ "Received result for task %s: status=%s",
162
+ self._task_id[:8], self._envelope.status,
163
+ )
164
+ return self._unwrap()
165
+
166
+ await self._wait_with_stall_detection(timeout, stall_timeout)
167
+ return self._unwrap()
168
+
169
+ async def _wait_with_stall_detection(
170
+ self,
171
+ timeout: float | None,
172
+ stall_timeout: float,
173
+ ) -> None:
174
+ """Poll for result with heartbeat-based stall detection."""
175
+ deadline = None if timeout is None else time.monotonic() + timeout
176
+ last_hb_value: float | None = None
177
+ last_hb_change: float | None = None
178
+
179
+ while True:
180
+ raw = await self._backend.try_get_result(self._task_id)
181
+ if raw is not None:
182
+ self._envelope = ResultEnvelope.from_json(raw)
183
+ logger.debug(
184
+ "Received result for task %s: status=%s",
185
+ self._task_id[:8], self._envelope.status,
186
+ )
187
+ return
188
+
189
+ if deadline is not None:
190
+ remaining = deadline - time.monotonic()
191
+ if remaining <= 0:
192
+ raise TimeoutError(
193
+ f"Timed out waiting for result of task {self._task_id}"
194
+ )
195
+
196
+ logger.debug("Polling heartbeat for task %s", self._task_id[:8])
197
+ hb = await self._backend.get_heartbeat(self._task_id)
198
+ now = time.monotonic()
199
+ if hb is not None and hb != last_hb_value:
200
+ last_hb_value = hb
201
+ last_hb_change = now
202
+ if last_hb_change is not None and (now - last_hb_change) > stall_timeout:
203
+ elapsed = now - last_hb_change
204
+ raise TaskStalled(
205
+ f"Task {self._task_id} stalled: no heartbeat for "
206
+ f"{elapsed:.1f}s (threshold: {stall_timeout}s)"
207
+ )
208
+
209
+ await asyncio.sleep(1.0)
210
+
211
+ def __await__(self) -> Generator[Any, None, Any]:
212
+ """Allow ``await result`` as shorthand for ``await result.result()``."""
213
+ return self.result().__await__()
214
+
215
+ # -- cancellation ----------------------------------------------------------
216
+
217
+ async def cancel(self) -> None:
218
+ """Cancel the task.
219
+
220
+ Marks the task as cancelled in the backend. If the worker
221
+ hasn't started execution yet, it will skip the task. If
222
+ execution is already in progress, it will continue but the
223
+ client will receive a :class:`TaskCancelled` error.
224
+
225
+ Awaiting the result after cancellation raises
226
+ :class:`TaskCancelled`.
227
+ """
228
+ await self._backend.cancel_task(self._task_id)
229
+ await self._backend.send_result(
230
+ self._task_id,
231
+ ResultEnvelope.cancelled(self._task_id).to_json(),
232
+ )
233
+
234
+ # -- progress --------------------------------------------------------------
235
+
236
+ async def progress(self) -> ProgressInfo | None:
237
+ """Return the latest progress reported by the task, or ``None``.
238
+
239
+ Progress is available when the task function calls
240
+ :func:`offwork.progress`.
241
+ """
242
+ raw = await self._backend.get_progress(self._task_id)
243
+ if raw is None:
244
+ return None
245
+ return ProgressInfo.from_json(raw)
246
+
247
+ # -- non-blocking queries --------------------------------------------------
248
+
249
+ async def done(self) -> bool:
250
+ """Check whether the result is available."""
251
+ if self._envelope is not None:
252
+ return True
253
+ raw = await self._backend.try_get_result(self._task_id)
254
+ if raw is not None:
255
+ self._envelope = ResultEnvelope.from_json(raw)
256
+ return True
257
+ return False
258
+
259
+ async def status(self) -> str:
260
+ """Return ``"pending"``, ``"success"``, ``"error"``, or ``"cancelled"``."""
261
+ if self._envelope is None:
262
+ raw = await self._backend.try_get_result(self._task_id)
263
+ if raw is None:
264
+ return "pending"
265
+ self._envelope = ResultEnvelope.from_json(raw)
266
+ if self._envelope.status == "ok":
267
+ return "success"
268
+ if self._envelope.status == "cancelled":
269
+ return "cancelled"
270
+ if self._envelope.status == "throttled":
271
+ return "throttled"
272
+ return "error"
273
+
274
+ def __repr__(self) -> str:
275
+ s = "pending" if self._envelope is None else self._envelope.status
276
+ return f"Result(task_id={self._task_id!r}, status={s!r})"
@@ -0,0 +1,24 @@
1
+ FROM python:3.13-slim
2
+
3
+ LABEL maintainer="offwork" \
4
+ description="offwork sandbox guest agent"
5
+
6
+ # Avoid interactive prompts during package installation
7
+ ENV DEBIAN_FRONTEND=noninteractive
8
+
9
+ # Create unprivileged user for the agent
10
+ RUN useradd --create-home --shell /bin/bash offwork
11
+
12
+ WORKDIR /home/offwork
13
+
14
+ # Copy the guest agent (stdlib-only, no pip install needed)
15
+ COPY guest_agent.py /home/offwork/guest_agent.py
16
+
17
+ RUN chown offwork:offwork /home/offwork/guest_agent.py
18
+
19
+ USER offwork
20
+
21
+ EXPOSE 9749
22
+
23
+ ENTRYPOINT ["python", "-u", "/home/offwork/guest_agent.py"]
24
+ CMD ["--host", "0.0.0.0", "--port", "9749"]
@@ -0,0 +1,18 @@
1
+ """Docker sandbox for isolating function execution.
2
+
3
+ When ``--sandbox`` is enabled, the :class:`~offwork.worker.worker.Worker`
4
+ delegates the ``exec → call`` step to a guest agent running inside a
5
+ Docker container. Everything else (caching, dependency resolution,
6
+ retry policy) stays on the host.
7
+
8
+ Quick start::
9
+
10
+ from offwork.worker.sandbox import DockerSandbox
11
+
12
+ async with DockerSandbox() as sandbox:
13
+ result = await sandbox.execute(source, "my_func", (arg1,), {})
14
+ """
15
+
16
+ from offwork.worker.sandbox.docker import DockerSandbox
17
+
18
+ __all__ = ["DockerSandbox"]
@@ -0,0 +1,50 @@
1
+ """Length-prefixed JSON wire protocol shared by host and guest agent.
2
+
3
+ The format is identical to the one used by the local backend
4
+ (:mod:`offwork.worker.backends.local`): a 4-byte big-endian length
5
+ header followed by a UTF-8 JSON payload.
6
+
7
+ This module is intentionally dependency-free (stdlib only) so it can be
8
+ shipped into the container without installing offwork there.
9
+ """
10
+
11
+ import json
12
+ import struct
13
+ from typing import Any
14
+
15
+ _HEADER = struct.Struct("!I") # 4-byte big-endian unsigned int
16
+
17
+
18
+ def encode(obj: dict[str, Any]) -> bytes:
19
+ """Serialise *obj* to a length-prefixed JSON frame."""
20
+ payload = json.dumps(obj, separators=(",", ":")).encode()
21
+ return _HEADER.pack(len(payload)) + payload
22
+
23
+
24
+ def decode_header(data: bytes) -> int:
25
+ """Return the payload length from a 4-byte header."""
26
+ length: int = _HEADER.unpack(data)[0]
27
+ return length
28
+
29
+
30
+ HEADER_SIZE: int = _HEADER.size
31
+
32
+
33
+ # -- asyncio helpers (used by both host and guest) --------------------------
34
+
35
+ async def async_send(writer: Any, obj: dict[str, Any]) -> None:
36
+ """Send a length-prefixed JSON message on an :class:`asyncio.StreamWriter`."""
37
+ writer.write(encode(obj))
38
+ await writer.drain()
39
+
40
+
41
+ async def async_recv(reader: Any) -> dict[str, Any]:
42
+ """Receive a length-prefixed JSON message from an :class:`asyncio.StreamReader`.
43
+
44
+ Raises :class:`asyncio.IncompleteReadError` on EOF.
45
+ """
46
+ raw = await reader.readexactly(HEADER_SIZE)
47
+ length = decode_header(raw)
48
+ data = await reader.readexactly(length)
49
+ result: dict[str, Any] = json.loads(data)
50
+ return result