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.
- {offwork-0.1.3 → offwork-0.1.4}/PKG-INFO +1 -1
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/envelope.py +2 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/task.py +8 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/tracing.py +12 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/typing.py +2 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/base.py +11 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/http.py +6 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/deps.py +8 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/remote.py +31 -13
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/result.py +60 -24
- offwork-0.1.4/offwork/worker/schedule.py +53 -0
- {offwork-0.1.3 → offwork-0.1.4}/pyproject.toml +1 -1
- offwork-0.1.3/offwork/worker/schedule.py +0 -26
- {offwork-0.1.3 → offwork-0.1.4}/LICENSE +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/README.md +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/__main__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/_venv.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/clients.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/ed25519.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/errors.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/identity.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/models.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/pairing.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/progress.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/signing.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/token.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/core/version.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/analyzer.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/decorator.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/graph.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/graph/store.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/py.typed +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/local.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/rabbitmq.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/backends/redis.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/Dockerfile +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/__init__.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/_protocol.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/docker.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/sandbox/guest_agent.py +0 -0
- {offwork-0.1.3 → offwork-0.1.4}/offwork/worker/worker.py +0 -0
|
@@ -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
|
|
|
@@ -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 =
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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=
|
|
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 =
|
|
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
|
-
"""
|
|
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
|
-
|
|
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) ->
|
|
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.
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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,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
|
|
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
|
|
File without changes
|