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/worker/result.py
ADDED
|
@@ -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
|