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,438 @@
1
+ """Docker-based sandbox for isolated function execution.
2
+
3
+ Runs the guest agent (:mod:`~offwork.worker.sandbox.guest_agent`) inside
4
+ a Docker container, communicating over TCP with a length-prefixed JSON
5
+ protocol.
6
+
7
+ Requirements
8
+ ~~~~~~~~~~~~
9
+ * Docker (or a compatible runtime such as colima / Podman) installed
10
+ and the ``docker`` CLI available on ``PATH``
11
+ * The current user must be able to run ``docker`` commands (i.e. be in
12
+ the ``docker`` group or use rootless Docker)
13
+
14
+ The image is built automatically from the bundled ``Dockerfile`` on
15
+ first use, so ``offwork sandbox setup`` is optional (but recommended in
16
+ CI to avoid a cold-start build).
17
+ """
18
+
19
+ import os
20
+ import shutil
21
+ import asyncio
22
+ import hashlib
23
+ import logging
24
+ import contextlib
25
+ from typing import Any
26
+ from pathlib import Path
27
+ from collections.abc import Callable
28
+
29
+ from offwork.core.errors import WorkerError
30
+ from offwork.core.task import _resolve, _to_jsonable
31
+ from offwork.core.progress import _progress_callback
32
+ from offwork.worker.sandbox._protocol import async_recv, async_send
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ _DOCKERFILE_DIR = Path(__file__).resolve().parent # contains Dockerfile + guest_agent.py
37
+ _IMAGE_ASSETS = ("Dockerfile", "guest_agent.py", "_protocol.py")
38
+
39
+
40
+ def _assets_digest() -> str:
41
+ """Short hash of the files baked into the sandbox image.
42
+
43
+ Used as the default image tag so a code change in the guest agent
44
+ or Dockerfile produces a fresh image instead of silently reusing a
45
+ stale one whose ``guest_agent.py`` no longer matches the host.
46
+ """
47
+ h = hashlib.sha256()
48
+ for name in _IMAGE_ASSETS:
49
+ path = _DOCKERFILE_DIR / name
50
+ if path.exists():
51
+ h.update(name.encode())
52
+ h.update(b"\0")
53
+ h.update(path.read_bytes())
54
+ h.update(b"\0")
55
+ return h.hexdigest()[:12]
56
+
57
+
58
+ _DEFAULT_IMAGE = f"offwork-sandbox:{_assets_digest()}"
59
+ _DEFAULT_CONTAINER = "offwork-sandbox"
60
+
61
+
62
+ class DockerSandbox:
63
+ """Execute functions inside a Docker container.
64
+
65
+ The sandbox lazily starts a container on first use and keeps it
66
+ running for the lifetime of the worker so that subsequent task
67
+ executions reuse the same guest agent connection.
68
+
69
+ Parameters
70
+ ----------
71
+ image
72
+ Docker image name. Built automatically from the bundled
73
+ ``Dockerfile`` if it doesn't exist locally.
74
+ container_name
75
+ Name assigned to the running container.
76
+ guest_port
77
+ TCP port the guest agent listens on inside the container.
78
+ cpus
79
+ Number of vCPUs allocated to the container.
80
+ memory_gb
81
+ Gigabytes of RAM allocated to the container.
82
+ timeout
83
+ Maximum seconds for a single function execution.
84
+ boot_timeout
85
+ Maximum seconds to wait for the container to become reachable.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ image: str = _DEFAULT_IMAGE,
92
+ container_name: str = _DEFAULT_CONTAINER,
93
+ guest_port: int = 9749,
94
+ cpus: int = 2,
95
+ memory_gb: int = 2,
96
+ timeout: float = 60.0,
97
+ boot_timeout: float = 30.0,
98
+ ) -> None:
99
+ if cpus < 1:
100
+ raise ValueError(f"cpus must be at least 1, got {cpus}")
101
+ if memory_gb < 1:
102
+ raise ValueError(f"memory_gb must be at least 1, got {memory_gb}")
103
+ if timeout <= 0:
104
+ raise ValueError(f"timeout must be positive, got {timeout}")
105
+ if boot_timeout <= 0:
106
+ raise ValueError(f"boot_timeout must be positive, got {boot_timeout}")
107
+
108
+ self.image = image
109
+ self.container_name = container_name
110
+ self.guest_port = guest_port
111
+ self.cpus = cpus
112
+ self.memory_gb = memory_gb
113
+ self.timeout = timeout
114
+ self.boot_timeout = boot_timeout
115
+
116
+ self._host_port: int | None = None
117
+ self._reader: asyncio.StreamReader | None = None
118
+ self._writer: asyncio.StreamWriter | None = None
119
+ self._lock = asyncio.Lock()
120
+ self._started = False
121
+
122
+ # -- public API ----------------------------------------------------------
123
+
124
+ async def execute(
125
+ self,
126
+ source: str,
127
+ function_name: str,
128
+ args: tuple[Any, ...],
129
+ kwargs: dict[str, Any],
130
+ *,
131
+ owner_class: str | None = None,
132
+ ) -> Any:
133
+ """Send *source* + *function_name* to the guest agent and return the result."""
134
+ if not self._started:
135
+ await self.start()
136
+
137
+ request: dict[str, Any] = {
138
+ "source": source,
139
+ "function_name": function_name,
140
+ "args": [_to_jsonable(a) for a in args],
141
+ "kwargs": {k: _to_jsonable(v) for k, v in kwargs.items()},
142
+ }
143
+ if owner_class is not None:
144
+ request["owner_class"] = owner_class
145
+
146
+ # Pick up the host-side progress callback (set by _handle_task)
147
+ # so we can forward progress messages from the container.
148
+ progress_cb = _progress_callback.get(None)
149
+
150
+ try:
151
+ response = await asyncio.wait_for(
152
+ self._send_request(request, progress_cb=progress_cb),
153
+ timeout=self.timeout,
154
+ )
155
+ except asyncio.TimeoutError:
156
+ raise WorkerError(
157
+ f"Sandbox execution of '{function_name}' timed out "
158
+ f"after {self.timeout}s"
159
+ ) from None
160
+ except (asyncio.IncompleteReadError, ConnectionError, OSError) as exc:
161
+ raise WorkerError(
162
+ f"Sandbox connection lost while executing '{function_name}': "
163
+ f"{type(exc).__name__}: {exc}"
164
+ ) from exc
165
+
166
+ if response["status"] == "error":
167
+ raise WorkerError(
168
+ f"Sandbox error — {response.get('error_type', 'Unknown')}: "
169
+ f"{response.get('error_message', '')}"
170
+ )
171
+ # Return values from the guest agent travel through the same
172
+ # sentinel encoding so non-JSON-native types (tuples, datetimes,
173
+ # custom classes, etc.) round-trip transparently.
174
+ return _resolve(response.get("result"), {})
175
+
176
+ async def start(self) -> None:
177
+ """Build the image (if needed), start the container, connect."""
178
+ _check_docker_available()
179
+
180
+ if not await _image_exists(self.image):
181
+ logger.info("Building Docker image '%s' …", self.image)
182
+ await _build_image(self.image)
183
+
184
+ container = self.container_name
185
+ if await _container_exists(container):
186
+ current_image = await _container_image(container)
187
+ if current_image != self.image:
188
+ logger.info(
189
+ "Container '%s' was built from a different image (%s); "
190
+ "recreating from '%s'",
191
+ container, current_image or "<unknown>", self.image,
192
+ )
193
+ await _docker_wait("rm", "-f", container)
194
+
195
+ if not await _container_running(container):
196
+ if await _container_exists(container):
197
+ await _docker_wait("rm", "-f", container)
198
+ logger.info("Starting container '%s' …", container)
199
+ await self._run_container()
200
+
201
+ self._host_port = await _mapped_port(container, self.guest_port)
202
+ await self._wait_for_agent()
203
+ await self._connect()
204
+ self._started = True
205
+ logger.info(
206
+ "Docker sandbox ready (localhost:%d → container:%d)",
207
+ self._host_port,
208
+ self.guest_port,
209
+ )
210
+
211
+ async def stop(self) -> None:
212
+ """Disconnect and stop the container."""
213
+ if self._writer is not None:
214
+ self._writer.close()
215
+ self._reader = self._writer = None
216
+ container = self.container_name
217
+ if await _container_running(container):
218
+ logger.info("Stopping container '%s' …", container)
219
+ await _docker_wait("stop", container)
220
+ self._started = False
221
+
222
+ async def __aenter__(self) -> "DockerSandbox":
223
+ await self.start()
224
+ return self
225
+
226
+ async def __aexit__(self, *exc: object) -> None:
227
+ await self.stop()
228
+
229
+ # -- internals -----------------------------------------------------------
230
+
231
+ async def _run_container(self) -> None:
232
+ cmd = [
233
+ "docker", "run", "-d",
234
+ "--name", self.container_name,
235
+ "-p", f"0:{self.guest_port}",
236
+ ]
237
+ if self.cpus:
238
+ cmd += ["--cpus", str(self.cpus)]
239
+ if self.memory_gb:
240
+ cmd += ["--memory", f"{self.memory_gb}g"]
241
+ cmd.append(self.image)
242
+
243
+ rc, _stdout, stderr = await _docker_wait(*cmd[1:])
244
+ if rc != 0:
245
+ raise WorkerError(f"Failed to start Docker container: {stderr.strip()}")
246
+
247
+ async def _wait_for_agent(self) -> None:
248
+ """Block until the guest agent is actually answering on the wire.
249
+
250
+ A bare TCP ``open_connection`` is not sufficient on Linux: when
251
+ a container port is published, ``docker-proxy`` accepts host
252
+ connections *before* the in-container process is listening,
253
+ which causes the very first request to race with agent startup
254
+ and come back as ``IncompleteReadError``. Performing an actual
255
+ ping/pong exchange guarantees end-to-end readiness.
256
+ """
257
+ assert self._host_port is not None
258
+ deadline = asyncio.get_running_loop().time() + self.boot_timeout
259
+ last_err: Exception | None = None
260
+ while asyncio.get_running_loop().time() < deadline:
261
+ try:
262
+ reader, writer = await asyncio.wait_for(
263
+ asyncio.open_connection("127.0.0.1", self._host_port),
264
+ timeout=2.0,
265
+ )
266
+ try:
267
+ await asyncio.wait_for(
268
+ async_send(writer, {"op": "ping"}), timeout=2.0,
269
+ )
270
+ msg = await asyncio.wait_for(async_recv(reader), timeout=2.0)
271
+ finally:
272
+ writer.close()
273
+ with contextlib.suppress(ConnectionError, OSError):
274
+ await writer.wait_closed()
275
+ if msg.get("status") == "pong":
276
+ return
277
+ last_err = WorkerError(f"Unexpected handshake reply: {msg!r}")
278
+ except (
279
+ OSError,
280
+ asyncio.TimeoutError,
281
+ asyncio.IncompleteReadError,
282
+ ConnectionError,
283
+ ) as exc:
284
+ last_err = exc
285
+ await asyncio.sleep(0.5)
286
+ raise WorkerError(
287
+ f"Docker guest agent did not become reachable within "
288
+ f"{self.boot_timeout}s: {last_err!r}"
289
+ )
290
+
291
+ async def _connect(self) -> None:
292
+ assert self._host_port is not None
293
+ self._reader, self._writer = await asyncio.open_connection(
294
+ "127.0.0.1", self._host_port,
295
+ )
296
+
297
+ async def _send_request(
298
+ self,
299
+ request: dict[str, Any],
300
+ *,
301
+ progress_cb: Callable[..., Any] | None = None,
302
+ ) -> dict[str, Any]:
303
+ async with self._lock:
304
+ if self._reader is None or self._writer is None:
305
+ await self._connect()
306
+ assert self._reader is not None and self._writer is not None
307
+ success = False
308
+ try:
309
+ try:
310
+ await async_send(self._writer, request)
311
+ result = await self._read_response(progress_cb)
312
+ success = True
313
+ return result
314
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
315
+ self._reader = self._writer = None
316
+ await self._connect()
317
+ assert self._reader is not None and self._writer is not None
318
+ await async_send(self._writer, request)
319
+ result = await self._read_response(progress_cb)
320
+ success = True
321
+ return result
322
+ finally:
323
+ if not success:
324
+ # On cancellation or timeout the guest agent may still
325
+ # be processing and will eventually write a stale
326
+ # response. Reset the connection so the next request
327
+ # doesn't read that leftover data.
328
+ if self._writer is not None:
329
+ self._writer.close()
330
+ self._reader = self._writer = None
331
+
332
+ async def _read_response(
333
+ self,
334
+ progress_cb: Callable[..., Any] | None = None,
335
+ ) -> dict[str, Any]:
336
+ """Read messages until a terminal (ok/error) response arrives.
337
+
338
+ Intermediate ``{"status": "progress", ...}`` frames are forwarded
339
+ to *progress_cb* so that ``offwork.progress()`` calls inside the
340
+ container surface on the host in real time.
341
+ """
342
+ assert self._reader is not None
343
+ while True:
344
+ msg = await async_recv(self._reader)
345
+ if msg.get("status") == "progress":
346
+ if progress_cb is not None:
347
+ progress_cb(
348
+ msg.get("current", 0),
349
+ msg.get("total"),
350
+ msg.get("message"),
351
+ )
352
+ continue
353
+ return msg
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Docker CLI helpers
358
+ # ---------------------------------------------------------------------------
359
+
360
+
361
+ def _check_docker_available() -> None:
362
+ if shutil.which("docker") is None:
363
+ raise WorkerError(
364
+ "'docker' command not found. "
365
+ "Install Docker from https://docs.docker.com/get-docker/"
366
+ )
367
+
368
+
369
+ async def _docker_wait(*args: str) -> tuple[int, str, str]:
370
+ """Run ``docker <args>`` and wait for it to finish."""
371
+ proc = await asyncio.create_subprocess_exec(
372
+ "docker", *args,
373
+ stdout=asyncio.subprocess.PIPE,
374
+ stderr=asyncio.subprocess.PIPE,
375
+ )
376
+ stdout_bytes, stderr_bytes = await proc.communicate()
377
+ return (
378
+ proc.returncode or 0,
379
+ stdout_bytes.decode() if stdout_bytes else "",
380
+ stderr_bytes.decode() if stderr_bytes else "",
381
+ )
382
+
383
+
384
+ async def _image_exists(image: str) -> bool:
385
+ rc, _, _ = await _docker_wait("image", "inspect", image)
386
+ return rc == 0
387
+
388
+
389
+ async def _build_image(image: str) -> None:
390
+ rc, _stdout, stderr = await _docker_wait(
391
+ "build", "-t", image, str(_DOCKERFILE_DIR),
392
+ )
393
+ if rc != 0:
394
+ raise WorkerError(f"Failed to build Docker image '{image}':\n{stderr.strip()}")
395
+ logger.info("Docker image '%s' built successfully.", image)
396
+
397
+
398
+ async def _container_exists(name: str) -> bool:
399
+ rc, _, _ = await _docker_wait("container", "inspect", name)
400
+ return rc == 0
401
+
402
+
403
+ async def _container_running(name: str) -> bool:
404
+ rc, stdout, _ = await _docker_wait(
405
+ "inspect", "-f", "{{.State.Running}}", name,
406
+ )
407
+ return rc == 0 and stdout.strip().lower() == "true"
408
+
409
+
410
+ async def _container_image(name: str) -> str | None:
411
+ """Return the image (with tag) the container was created from, or None."""
412
+ rc, stdout, _ = await _docker_wait(
413
+ "inspect", "-f", "{{.Config.Image}}", name,
414
+ )
415
+ if rc != 0:
416
+ return None
417
+ image = stdout.strip()
418
+ return image or None
419
+
420
+
421
+ async def _mapped_port(container: str, guest_port: int) -> int:
422
+ rc, stdout, stderr = await _docker_wait("port", container, str(guest_port))
423
+ if rc != 0:
424
+ raise WorkerError(
425
+ f"Could not determine mapped port for {container}:{guest_port}: "
426
+ f"{stderr.strip()}"
427
+ )
428
+ for line in stdout.strip().splitlines():
429
+ parts = line.rsplit(":", 1)
430
+ if len(parts) == 2:
431
+ try:
432
+ return int(parts[1])
433
+ except ValueError:
434
+ continue
435
+ raise WorkerError(
436
+ f"Unexpected 'docker port' output for {container}:{guest_port}: "
437
+ f"{stdout.strip()!r}"
438
+ )