offwork 0.1.3__tar.gz → 0.1.4__tar.gz

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 (46) hide show
  1. {offwork-0.1.3 → offwork-0.1.4}/PKG-INFO +1 -1
  2. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/envelope.py +2 -0
  3. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/task.py +8 -0
  4. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/tracing.py +12 -0
  5. {offwork-0.1.3 → offwork-0.1.4}/offwork/typing.py +2 -0
  6. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/base.py +11 -0
  7. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/http.py +6 -0
  8. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/deps.py +8 -0
  9. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/remote.py +31 -13
  10. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/result.py +60 -24
  11. offwork-0.1.4/offwork/worker/schedule.py +53 -0
  12. {offwork-0.1.3 → offwork-0.1.4}/pyproject.toml +1 -1
  13. offwork-0.1.3/offwork/worker/schedule.py +0 -26
  14. {offwork-0.1.3 → offwork-0.1.4}/LICENSE +0 -0
  15. {offwork-0.1.3 → offwork-0.1.4}/README.md +0 -0
  16. {offwork-0.1.3 → offwork-0.1.4}/offwork/__init__.py +0 -0
  17. {offwork-0.1.3 → offwork-0.1.4}/offwork/__main__.py +0 -0
  18. {offwork-0.1.3 → offwork-0.1.4}/offwork/_venv.py +0 -0
  19. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/__init__.py +0 -0
  20. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/clients.py +0 -0
  21. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/ed25519.py +0 -0
  22. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/errors.py +0 -0
  23. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/identity.py +0 -0
  24. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/models.py +0 -0
  25. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/pairing.py +0 -0
  26. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/progress.py +0 -0
  27. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/signing.py +0 -0
  28. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/token.py +0 -0
  29. {offwork-0.1.3 → offwork-0.1.4}/offwork/core/version.py +0 -0
  30. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/__init__.py +0 -0
  31. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/analyzer.py +0 -0
  32. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/decorator.py +0 -0
  33. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/graph.py +0 -0
  34. {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/store.py +0 -0
  35. {offwork-0.1.3 → offwork-0.1.4}/offwork/py.typed +0 -0
  36. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/__init__.py +0 -0
  37. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/__init__.py +0 -0
  38. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/local.py +0 -0
  39. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/rabbitmq.py +0 -0
  40. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/redis.py +0 -0
  41. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/Dockerfile +0 -0
  42. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/__init__.py +0 -0
  43. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/_protocol.py +0 -0
  44. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/docker.py +0 -0
  45. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/guest_agent.py +0 -0
  46. {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: offwork
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Distributed Python task execution via automatic function serialization
5
5
  License: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -171,6 +171,8 @@ def verify_task_envelope(
171
171
  retry_delay=data.get("retry_delay", 1.0),
172
172
  scheduled_at=data.get("scheduled_at"),
173
173
  recur_interval=data.get("recur_interval"),
174
+ recur_deadline=data.get("recur_deadline"),
175
+ recur_remaining=data.get("recur_remaining"),
174
176
  schedule_id=data.get("schedule_id"),
175
177
  throttle=data.get("throttle"),
176
178
  )
@@ -418,6 +418,8 @@ class Task:
418
418
  retry_delay: float = 1.0
419
419
  scheduled_at: float | None = None
420
420
  recur_interval: float | None = None
421
+ recur_deadline: float | None = None
422
+ recur_remaining: int | None = None
421
423
  schedule_id: str | None = None
422
424
  throttle: float | None = None
423
425
 
@@ -442,6 +444,10 @@ class Task:
442
444
  d["scheduled_at"] = self.scheduled_at
443
445
  if self.recur_interval is not None:
444
446
  d["recur_interval"] = self.recur_interval
447
+ if self.recur_deadline is not None:
448
+ d["recur_deadline"] = self.recur_deadline
449
+ if self.recur_remaining is not None:
450
+ d["recur_remaining"] = self.recur_remaining
445
451
  if self.schedule_id is not None:
446
452
  d["schedule_id"] = self.schedule_id
447
453
  if self.throttle is not None:
@@ -467,6 +473,8 @@ class Task:
467
473
  retry_delay=data.get("retry_delay", 1.0),
468
474
  scheduled_at=data.get("scheduled_at"),
469
475
  recur_interval=data.get("recur_interval"),
476
+ recur_deadline=data.get("recur_deadline"),
477
+ recur_remaining=data.get("recur_remaining"),
470
478
  schedule_id=data.get("schedule_id"),
471
479
  throttle=data.get("throttle"),
472
480
  )
@@ -137,6 +137,8 @@ def _make_run_every_method(
137
137
  frequency: Any,
138
138
  *args: Any,
139
139
  _start_at: Any = None,
140
+ run_for: Any = None,
141
+ max_runs: int | None = None,
140
142
  backend: str | Backend | None = None,
141
143
  **kwargs: Any,
142
144
  ) -> object:
@@ -146,9 +148,19 @@ def _make_run_every_method(
146
148
  start_ts: float | None = None
147
149
  if _start_at is not None:
148
150
  start_ts = _start_at.timestamp() if isinstance(_start_at, datetime) else float(_start_at)
151
+ if run_for is None and max_runs is None:
152
+ run_for = timedelta(hours=1)
153
+ run_for_seconds: float | None = None
154
+ if run_for is not None:
155
+ run_for_seconds = run_for.total_seconds() if isinstance(run_for, timedelta) else float(run_for)
156
+ if run_for_seconds <= 0:
157
+ raise ValueError(f"run_for must be positive, got {run_for}")
158
+ if max_runs is not None and max_runs <= 0:
159
+ raise ValueError(f"max_runs must be positive, got {max_runs}")
149
160
  return await submit_recurring(
150
161
  func, wrapper, *args,
151
162
  _backend=backend, _interval=interval, _start_at=start_ts,
163
+ _run_for=run_for_seconds, _max_runs=max_runs,
152
164
  **kwargs,
153
165
  )
154
166
 
@@ -38,6 +38,8 @@ class TracedFunction(Protocol[P, R]):
38
38
  frequency: timedelta | float,
39
39
  *args: Any,
40
40
  _start_at: datetime | None = ...,
41
+ run_for: timedelta | float | None = ...,
42
+ max_runs: int | None = ...,
41
43
  **kwargs: Any,
42
44
  ) -> ScheduleHandle: ...
43
45
 
@@ -41,6 +41,17 @@ class Backend(abc.ABC):
41
41
  heartbeat-based stall detection should override this.
42
42
  """
43
43
 
44
+ async def heartbeat_and_check_cancel(self, task_id: str) -> bool:
45
+ """Send a heartbeat and return whether the task is cancelled.
46
+
47
+ Backends with a single round-trip combining both (e.g. an
48
+ HTTP POST whose response carries the cancel flag) should
49
+ override this to halve worker→broker chatter while a task is
50
+ running. The default implementation issues two calls.
51
+ """
52
+ await self.send_heartbeat(task_id)
53
+ return await self.is_cancelled(task_id)
54
+
44
55
  async def get_heartbeat(self, task_id: str) -> float | None:
45
56
  """Return the timestamp of the last heartbeat for *task_id*.
46
57
 
@@ -171,6 +171,12 @@ class HttpBackend(Backend):
171
171
  async def send_heartbeat(self, task_id: str) -> None:
172
172
  await self._request("POST", f"/tasks/{task_id}/heartbeat")
173
173
 
174
+ async def heartbeat_and_check_cancel(self, task_id: str) -> bool:
175
+ _status, body = await self._request(
176
+ "POST", f"/tasks/{task_id}/heartbeat", allow_not_found=True,
177
+ )
178
+ return bool(body and body.get("cancelled"))
179
+
174
180
  async def get_heartbeat(self, task_id: str) -> float | None:
175
181
  _status, body = await self._request(
176
182
  "GET", f"/tasks/{task_id}/heartbeat", allow_not_found=True,
@@ -325,6 +325,14 @@ async def install_packages(
325
325
  result.failed[module] = stderr.strip()
326
326
  logger.error("Failed to install %s: %s", package, stderr.strip()[:200])
327
327
 
328
+ if result.installed:
329
+ # Drop importlib's cached "this directory doesn't exist / has no such
330
+ # module" entries so the next ``find_spec``/``import`` picks up files
331
+ # pip just wrote. Required when the target site-packages directory
332
+ # was created at install time (e.g. an emptyDir volume mounted over
333
+ # an empty user-site).
334
+ importlib.invalidate_caches()
335
+
328
336
  _raise_on_failures(result.failed)
329
337
  return result
330
338
 
@@ -280,6 +280,8 @@ async def submit_recurring(
280
280
  _root_token: bytes | None = None,
281
281
  _interval: float = 0,
282
282
  _start_at: float | None = None,
283
+ _run_for: float | None = None,
284
+ _max_runs: int | None = None,
283
285
  **kwargs: Any,
284
286
  ) -> ScheduleHandle:
285
287
  """Submit a recurring task and return a :class:`ScheduleHandle`."""
@@ -302,6 +304,7 @@ async def submit_recurring(
302
304
 
303
305
  schedule_id = uuid.uuid4().hex[:12]
304
306
  scheduled_at = _start_at or time.time()
307
+ recur_deadline = scheduled_at + _run_for if _run_for is not None else None
305
308
 
306
309
  opts = getattr(wrapper, "__offwork_options__", {})
307
310
  task = Task(
@@ -315,6 +318,8 @@ async def submit_recurring(
315
318
  throttle=opts.get("throttle"),
316
319
  scheduled_at=scheduled_at,
317
320
  recur_interval=_interval,
321
+ recur_deadline=recur_deadline,
322
+ recur_remaining=_max_runs,
318
323
  schedule_id=schedule_id,
319
324
  )
320
325
 
@@ -342,7 +347,7 @@ def _build_detail_tags(worker: Worker) -> str:
342
347
  return ", ".join(parts)
343
348
 
344
349
 
345
- _HEARTBEAT_INTERVAL = 1.0
350
+ _HEARTBEAT_INTERVAL = 5.0
346
351
 
347
352
 
348
353
  async def _heartbeat_loop(
@@ -353,23 +358,23 @@ async def _heartbeat_loop(
353
358
  ) -> None:
354
359
  """Send periodic heartbeats and check for cancellation.
355
360
 
356
- When *exec_task* is provided and the backend reports the task as
357
- cancelled, the execution task is cancelled via
361
+ Backends override ``heartbeat_and_check_cancel`` to combine both
362
+ into a single round-trip (e.g. an HTTP POST whose response carries
363
+ the cancel flag). When *exec_task* is provided and the backend
364
+ reports the task as cancelled, the execution task is cancelled via
358
365
  :meth:`asyncio.Task.cancel`, which raises :class:`CancelledError`
359
366
  at the next ``await`` in async user functions.
360
367
  """
361
368
  while not cancel_event.is_set():
362
369
  try:
363
- await backend.send_heartbeat(task_id)
364
- except Exception:
365
- logger.debug("Heartbeat send failed for task %s", task_id, exc_info=True)
366
- if exec_task is not None:
367
- try:
368
- if await backend.is_cancelled(task_id):
370
+ if exec_task is not None:
371
+ if await backend.heartbeat_and_check_cancel(task_id):
369
372
  exec_task.cancel()
370
373
  return
371
- except Exception:
372
- logger.debug("Cancellation check failed for task %s", task_id, exc_info=True)
374
+ else:
375
+ await backend.send_heartbeat(task_id)
376
+ except Exception:
377
+ logger.debug("Heartbeat send failed for task %s", task_id, exc_info=True)
373
378
  try:
374
379
  await asyncio.wait_for(cancel_event.wait(), timeout=_HEARTBEAT_INTERVAL)
375
380
  except asyncio.TimeoutError:
@@ -634,7 +639,18 @@ async def _handle_task(
634
639
 
635
640
  # Re-enqueue recurring task — worker re-signs with its own identity.
636
641
  if task.recur_interval is not None and task.schedule_id is not None:
637
- if not await backend.is_schedule_cancelled(task.schedule_id):
642
+ next_at = time.time() + task.recur_interval
643
+ remaining = task.recur_remaining
644
+ deadline_exceeded = task.recur_deadline is not None and next_at > task.recur_deadline
645
+ runs_exhausted = remaining is not None and remaining <= 1
646
+ if deadline_exceeded or runs_exhausted:
647
+ await backend.cancel_schedule(task.schedule_id)
648
+ logger.info(
649
+ "Recurring schedule %s exhausted (%s)",
650
+ task.schedule_id,
651
+ "deadline" if deadline_exceeded else "max_runs",
652
+ )
653
+ elif not await backend.is_schedule_cancelled(task.schedule_id):
638
654
  next_task = Task(
639
655
  graph_json=task.graph_json,
640
656
  function_name=task.function_name,
@@ -644,8 +660,10 @@ async def _handle_task(
644
660
  retries=task.retries,
645
661
  retry_delay=task.retry_delay,
646
662
  throttle=task.throttle,
647
- scheduled_at=time.time() + task.recur_interval,
663
+ scheduled_at=next_at,
648
664
  recur_interval=task.recur_interval,
665
+ recur_deadline=task.recur_deadline,
666
+ recur_remaining=remaining - 1 if remaining is not None else None,
649
667
  schedule_id=task.schedule_id,
650
668
  )
651
669
  await backend.submit(_encode_task(next_task, root_token))
@@ -134,7 +134,7 @@ class Result:
134
134
  async def result(
135
135
  self,
136
136
  timeout: float | None = None,
137
- stall_timeout: float | None = 10.0,
137
+ stall_timeout: float | None = 600.0,
138
138
  ) -> Any:
139
139
  """Await the result.
140
140
 
@@ -171,13 +171,29 @@ class Result:
171
171
  timeout: float | None,
172
172
  stall_timeout: float,
173
173
  ) -> None:
174
- """Poll for result with heartbeat-based stall detection."""
174
+ """Wait for the result with heartbeat-based stall detection.
175
+
176
+ Uses the backend's blocking ``get_result`` (long-poll on HTTP
177
+ backends) in bounded slices, with a heartbeat check between
178
+ slices. The slice length is derived from ``stall_timeout`` so
179
+ we still detect a silent worker in time.
180
+ """
175
181
  deadline = None if timeout is None else time.monotonic() + timeout
182
+ slice_seconds = max(1.0, min(stall_timeout / 2, 30.0))
176
183
  last_hb_value: float | None = None
177
184
  last_hb_change: float | None = None
178
185
 
179
186
  while True:
180
- raw = await self._backend.try_get_result(self._task_id)
187
+ remaining = None if deadline is None else deadline - time.monotonic()
188
+ if remaining is not None and remaining <= 0:
189
+ raise TimeoutError(
190
+ f"Timed out waiting for result of task {self._task_id}"
191
+ )
192
+ wait_for = slice_seconds if remaining is None else min(slice_seconds, remaining)
193
+ try:
194
+ raw = await self._backend.get_result(self._task_id, timeout=wait_for)
195
+ except TimeoutError:
196
+ raw = None
181
197
  if raw is not None:
182
198
  self._envelope = ResultEnvelope.from_json(raw)
183
199
  logger.debug(
@@ -186,14 +202,6 @@ class Result:
186
202
  )
187
203
  return
188
204
 
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
205
  hb = await self._backend.get_heartbeat(self._task_id)
198
206
  now = time.monotonic()
199
207
  if hb is not None and hb != last_hb_value:
@@ -206,30 +214,58 @@ class Result:
206
214
  f"{elapsed:.1f}s (threshold: {stall_timeout}s)"
207
215
  )
208
216
 
209
- await asyncio.sleep(1.0)
210
-
211
217
  def __await__(self) -> Generator[Any, None, Any]:
212
218
  """Allow ``await result`` as shorthand for ``await result.result()``."""
213
219
  return self.result().__await__()
214
220
 
215
221
  # -- cancellation ----------------------------------------------------------
216
222
 
217
- async def cancel(self) -> None:
223
+ async def cancel(self, wait: bool | float = False) -> bool:
218
224
  """Cancel the task.
219
225
 
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.
226
+ Marks the task as cancelled in the backend. The worker
227
+ observes the flag via its heartbeat loop and aborts execution
228
+ cooperatively.
224
229
 
225
- Awaiting the result after cancellation raises
226
- :class:`TaskCancelled`.
230
+ Parameters
231
+ ----------
232
+ wait
233
+ If ``False`` (default), return immediately after signalling
234
+ cancellation. If ``True``, block until the worker confirms
235
+ (default 30s timeout). If a number, wait that many seconds
236
+ for confirmation.
237
+
238
+ Returns
239
+ -------
240
+ bool
241
+ ``True`` if cancellation was confirmed by the worker (or
242
+ ``wait=False``). ``False`` if the wait timed out.
227
243
  """
228
244
  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
- )
245
+ if wait is False:
246
+ # Pre-seed a cancelled envelope so a client that never awaits
247
+ # confirmation still gets TaskCancelled when it reads.
248
+ await self._backend.send_result(
249
+ self._task_id,
250
+ ResultEnvelope.cancelled(self._task_id).to_json(),
251
+ )
252
+ return True
253
+ timeout = 30.0 if wait is True else float(wait)
254
+ deadline = time.monotonic() + timeout
255
+ while True:
256
+ raw = await self._backend.try_get_result(self._task_id)
257
+ if raw is not None:
258
+ self._envelope = ResultEnvelope.from_json(raw)
259
+ return True
260
+ if time.monotonic() >= deadline:
261
+ # Fall back: seed a cancelled envelope so subsequent reads
262
+ # don't hang forever, and tell the caller we timed out.
263
+ await self._backend.send_result(
264
+ self._task_id,
265
+ ResultEnvelope.cancelled(self._task_id).to_json(),
266
+ )
267
+ return False
268
+ await asyncio.sleep(0.5)
233
269
 
234
270
  # -- progress --------------------------------------------------------------
235
271
 
@@ -0,0 +1,53 @@
1
+ """Schedule handle for recurring task management."""
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ from offwork.worker.backends.base import Backend
7
+
8
+
9
+ class ScheduleHandle:
10
+ """Handle for a recurring schedule, allowing cancellation."""
11
+
12
+ def __init__(self, schedule_id: str, backend: Backend) -> None:
13
+ self._schedule_id = schedule_id
14
+ self._backend = backend
15
+
16
+ @property
17
+ def schedule_id(self) -> str:
18
+ return self._schedule_id
19
+
20
+ async def cancel(self, wait: bool | float = False) -> bool:
21
+ """Cancel this recurring schedule.
22
+
23
+ The worker stops re-enqueuing new occurrences after the
24
+ current one completes.
25
+
26
+ Parameters
27
+ ----------
28
+ wait
29
+ If ``False`` (default), return immediately. If ``True``,
30
+ block until the backend confirms the schedule is marked
31
+ cancelled (default 30s timeout). If a number, wait that
32
+ many seconds.
33
+
34
+ Returns
35
+ -------
36
+ bool
37
+ ``True`` if cancellation was acknowledged (or
38
+ ``wait=False``). ``False`` on timeout.
39
+ """
40
+ await self._backend.cancel_schedule(self._schedule_id)
41
+ if wait is False:
42
+ return True
43
+ timeout = 30.0 if wait is True else float(wait)
44
+ deadline = time.monotonic() + timeout
45
+ while True:
46
+ if await self._backend.is_schedule_cancelled(self._schedule_id):
47
+ return True
48
+ if time.monotonic() >= deadline:
49
+ return False
50
+ await asyncio.sleep(0.5)
51
+
52
+ def __repr__(self) -> str:
53
+ return f"ScheduleHandle(schedule_id={self._schedule_id!r})"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "offwork"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Distributed Python task execution via automatic function serialization"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,26 +0,0 @@
1
- """Schedule handle for recurring task management."""
2
-
3
- from offwork.worker.backends.base import Backend
4
-
5
-
6
- class ScheduleHandle:
7
- """Handle for a recurring schedule, allowing cancellation."""
8
-
9
- def __init__(self, schedule_id: str, backend: Backend) -> None:
10
- self._schedule_id = schedule_id
11
- self._backend = backend
12
-
13
- @property
14
- def schedule_id(self) -> str:
15
- return self._schedule_id
16
-
17
- async def cancel(self) -> None:
18
- """Cancel this recurring schedule.
19
-
20
- The worker will stop re-enqueuing new occurrences after the
21
- current one completes.
22
- """
23
- await self._backend.cancel_schedule(self._schedule_id)
24
-
25
- def __repr__(self) -> str:
26
- return f"ScheduleHandle(schedule_id={self._schedule_id!r})"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes