python-pq 0.7.2__tar.gz → 0.8.0__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.
- {python_pq-0.7.2 → python_pq-0.8.0}/PKG-INFO +1 -1
- {python_pq-0.7.2 → python_pq-0.8.0}/pyproject.toml +1 -1
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/__init__.py +1 -1
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/client.py +11 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/worker.py +274 -41
- {python_pq-0.7.2 → python_pq-0.8.0}/LICENSE +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/README.md +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/config.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/logging.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/README +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/env.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/models.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/priority.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/registry.py +0 -0
- {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/serialization.py +0 -0
|
@@ -678,11 +678,17 @@ class PQ:
|
|
|
678
678
|
poll_interval: float = 1.0,
|
|
679
679
|
max_runtime: float = 30 * 60,
|
|
680
680
|
priorities: Set[Priority] | None = None,
|
|
681
|
+
drain_timeout: float = 20.0,
|
|
681
682
|
) -> None:
|
|
682
683
|
"""Run the worker loop (blocking).
|
|
683
684
|
|
|
684
685
|
Each task executes in a forked child process for memory isolation.
|
|
685
686
|
|
|
687
|
+
On SIGTERM or SIGINT the worker stops claiming new tasks, waits up
|
|
688
|
+
to ``drain_timeout`` seconds for in-flight tasks to finish, then
|
|
689
|
+
kills any still-running task and marks its row FAILED with an
|
|
690
|
+
explicit shutdown error. Interrupted tasks are not re-queued.
|
|
691
|
+
|
|
686
692
|
Args:
|
|
687
693
|
concurrency: Maximum number of tasks to process simultaneously.
|
|
688
694
|
Default: 1 (sequential processing).
|
|
@@ -690,6 +696,10 @@ class PQ:
|
|
|
690
696
|
max_runtime: Maximum execution time per task in seconds. Default: 30 min.
|
|
691
697
|
priorities: If set, only process tasks with these priority levels.
|
|
692
698
|
Use this to dedicate workers to specific priority tiers.
|
|
699
|
+
drain_timeout: Seconds to wait for in-flight tasks on
|
|
700
|
+
SIGTERM/SIGINT. Default: 20. Set below the orchestrator's
|
|
701
|
+
termination grace period. ``0`` skips the wait: in-flight
|
|
702
|
+
tasks are killed as soon as the shutdown is noticed.
|
|
693
703
|
"""
|
|
694
704
|
from pq.worker import run_worker
|
|
695
705
|
|
|
@@ -699,6 +709,7 @@ class PQ:
|
|
|
699
709
|
poll_interval=poll_interval,
|
|
700
710
|
max_runtime=max_runtime,
|
|
701
711
|
priorities=priorities,
|
|
712
|
+
drain_timeout=drain_timeout,
|
|
702
713
|
)
|
|
703
714
|
|
|
704
715
|
def run_worker_once(
|
|
@@ -8,14 +8,18 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import inspect
|
|
11
|
+
import math
|
|
11
12
|
import os
|
|
12
13
|
import select as select_module
|
|
13
14
|
import signal
|
|
14
15
|
import sys
|
|
16
|
+
import threading
|
|
15
17
|
import time
|
|
16
18
|
import traceback
|
|
19
|
+
from collections.abc import Callable
|
|
17
20
|
from dataclasses import dataclass
|
|
18
21
|
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from types import FrameType
|
|
19
23
|
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
|
|
20
24
|
|
|
21
25
|
from croniter import croniter
|
|
@@ -27,11 +31,16 @@ from pq.registry import resolve_function_path
|
|
|
27
31
|
from pq.serialization import deserialize
|
|
28
32
|
|
|
29
33
|
if TYPE_CHECKING:
|
|
30
|
-
from collections.abc import
|
|
34
|
+
from collections.abc import Set
|
|
31
35
|
|
|
32
36
|
from pq.client import PQ
|
|
33
37
|
from pq.priority import Priority
|
|
34
38
|
|
|
39
|
+
# What ``signal.getsignal`` returns / ``signal.signal`` accepts.
|
|
40
|
+
_SignalHandler = (
|
|
41
|
+
Callable[[int, FrameType | None], object] | int | signal.Handlers | None
|
|
42
|
+
)
|
|
43
|
+
|
|
35
44
|
|
|
36
45
|
class PreExecuteHook(Protocol):
|
|
37
46
|
"""Protocol for pre-execution hooks called before task handler runs.
|
|
@@ -83,6 +92,13 @@ DEFAULT_STALE_TASK_TIMEOUT: timedelta = timedelta(hours=1)
|
|
|
83
92
|
# How often the worker checks for stale RUNNING tasks.
|
|
84
93
|
DEFAULT_REAPER_INTERVAL: float = 300
|
|
85
94
|
|
|
95
|
+
# Default drain timeout: 20 seconds
|
|
96
|
+
# On SIGTERM/SIGINT the worker stops claiming and waits up to this long for
|
|
97
|
+
# in-flight tasks to finish before SIGKILLing them and marking them FAILED.
|
|
98
|
+
# Sits under the common 30 s orchestrator termination grace period, leaving
|
|
99
|
+
# headroom for the final status writes.
|
|
100
|
+
DEFAULT_DRAIN_TIMEOUT: float = 20.0
|
|
101
|
+
|
|
86
102
|
# Exit codes for child process
|
|
87
103
|
EXIT_SUCCESS = 0
|
|
88
104
|
EXIT_FAILURE = 1
|
|
@@ -134,6 +150,103 @@ class TaskKilledError(WorkerError):
|
|
|
134
150
|
pass
|
|
135
151
|
|
|
136
152
|
|
|
153
|
+
class TaskInterruptedError(WorkerError):
|
|
154
|
+
"""Raised when a task is killed because the worker is shutting down."""
|
|
155
|
+
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class _ShutdownState:
|
|
161
|
+
"""Graceful-shutdown coordination for the worker loops.
|
|
162
|
+
|
|
163
|
+
Written by the signal handler (which runs in the main thread between
|
|
164
|
+
bytecodes, so no locking is needed) and read by the worker loops and
|
|
165
|
+
``_wait_for_child``. Module-level because a worker owns its process;
|
|
166
|
+
``run_worker`` resets it on entry.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
requested: bool = False
|
|
170
|
+
deadline: float | None = None # time.monotonic() deadline, set on signal
|
|
171
|
+
drain_timeout: float = DEFAULT_DRAIN_TIMEOUT
|
|
172
|
+
|
|
173
|
+
def reset(self, drain_timeout: float) -> None:
|
|
174
|
+
self.requested = False
|
|
175
|
+
self.deadline = None
|
|
176
|
+
self.drain_timeout = drain_timeout
|
|
177
|
+
|
|
178
|
+
def deadline_passed(self) -> bool:
|
|
179
|
+
"""True once shutdown was requested and the drain budget is spent."""
|
|
180
|
+
if not self.requested:
|
|
181
|
+
return False
|
|
182
|
+
return self.deadline is None or time.monotonic() >= self.deadline
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_shutdown = _ShutdownState()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _handle_shutdown_signal(signum: int, frame: FrameType | None) -> None:
|
|
189
|
+
"""SIGTERM/SIGINT handler: request a drain, never raise.
|
|
190
|
+
|
|
191
|
+
Raising from here would land at arbitrary bytecode and could orphan a
|
|
192
|
+
task (e.g. between claim-commit and child registration). Repeat signals
|
|
193
|
+
during the drain are ignored so they can't abandon remaining children.
|
|
194
|
+
"""
|
|
195
|
+
if _shutdown.requested:
|
|
196
|
+
return
|
|
197
|
+
_shutdown.deadline = time.monotonic() + _shutdown.drain_timeout
|
|
198
|
+
_shutdown.requested = True
|
|
199
|
+
logger.info(
|
|
200
|
+
f"Received signal {signum}, draining"
|
|
201
|
+
f" (drain_timeout={_shutdown.drain_timeout}s)..."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _install_shutdown_handlers() -> dict[int, _SignalHandler] | None:
|
|
206
|
+
"""Register the drain handler for SIGTERM/SIGINT.
|
|
207
|
+
|
|
208
|
+
Returns the previous handlers (for restoration), or ``None`` when not
|
|
209
|
+
running in the main thread — ``signal.signal`` is only allowed there, so
|
|
210
|
+
in that case the worker keeps the legacy KeyboardInterrupt-only behavior.
|
|
211
|
+
"""
|
|
212
|
+
if threading.current_thread() is not threading.main_thread():
|
|
213
|
+
logger.warning(
|
|
214
|
+
"Worker is not running in the main thread; SIGTERM graceful"
|
|
215
|
+
" shutdown is disabled (only KeyboardInterrupt is handled)."
|
|
216
|
+
)
|
|
217
|
+
return None
|
|
218
|
+
previous: dict[int, _SignalHandler] = {}
|
|
219
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
220
|
+
previous[signum] = signal.getsignal(signum)
|
|
221
|
+
signal.signal(signum, _handle_shutdown_signal)
|
|
222
|
+
return previous
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _restore_shutdown_handlers(previous: dict[int, _SignalHandler] | None) -> None:
|
|
226
|
+
"""Restore the signal handlers saved by ``_install_shutdown_handlers``."""
|
|
227
|
+
if previous is None:
|
|
228
|
+
return
|
|
229
|
+
for signum, handler in previous.items():
|
|
230
|
+
signal.signal(signum, handler)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _kill_child_group(child_pid: int) -> None:
|
|
234
|
+
"""SIGKILL a task child's process group (best effort).
|
|
235
|
+
|
|
236
|
+
The child calls ``os.setpgrp()`` first thing, so its pgid equals its
|
|
237
|
+
pid. If the signal lands before ``setpgrp()`` the process group doesn't
|
|
238
|
+
exist yet — fall back to killing the pid directly. ESRCH everywhere
|
|
239
|
+
means the child already exited; the caller reaps it normally.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
os.killpg(child_pid, signal.SIGKILL)
|
|
243
|
+
except ProcessLookupError:
|
|
244
|
+
try:
|
|
245
|
+
os.kill(child_pid, signal.SIGKILL)
|
|
246
|
+
except ProcessLookupError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
|
|
137
250
|
def _child_timeout_handler(signum: int, frame: Any) -> None:
|
|
138
251
|
"""Signal handler for timeout in child process."""
|
|
139
252
|
os._exit(EXIT_TIMEOUT)
|
|
@@ -156,6 +269,12 @@ def _run_in_child(
|
|
|
156
269
|
# Create new process group so we don't get parent's signals
|
|
157
270
|
os.setpgrp()
|
|
158
271
|
|
|
272
|
+
# Restore default signal disposition: the fork inherits the parent's
|
|
273
|
+
# drain handler, which in the child would silently swallow a direct
|
|
274
|
+
# SIGTERM/SIGINT (e.g. from systemd's cgroup-wide kill).
|
|
275
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
276
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
277
|
+
|
|
159
278
|
# Set up timeout
|
|
160
279
|
signal.signal(signal.SIGALRM, _child_timeout_handler)
|
|
161
280
|
signal.alarm(int(max_runtime) + 1) # +1 buffer for async timeout
|
|
@@ -241,6 +360,15 @@ def _fork_child(
|
|
|
241
360
|
def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
242
361
|
"""Wait for a forked child process and return the result.
|
|
243
362
|
|
|
363
|
+
The wait is drain-aware: once shutdown has been requested and the drain
|
|
364
|
+
deadline has passed, the child's process group is SIGKILLed and the
|
|
365
|
+
result reports the ``"shutdown"`` exit kind. A blocking ``os.wait4``
|
|
366
|
+
would never notice the shutdown flag (PEP 475 retries the syscall after
|
|
367
|
+
the non-raising signal handler), so this polls with ``WNOHANG`` and
|
|
368
|
+
sleeps in ``select`` on the child's error pipe — the pipe closes when
|
|
369
|
+
the child exits, so child completion wakes the wait immediately, while
|
|
370
|
+
the bounded timeout keeps flag/deadline checks prompt.
|
|
371
|
+
|
|
244
372
|
Args:
|
|
245
373
|
child_pid: PID of the child process.
|
|
246
374
|
read_fd: Read end of the error pipe.
|
|
@@ -248,10 +376,30 @@ def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
|
248
376
|
Returns:
|
|
249
377
|
_ChildResult with task status, error message, and exit kind.
|
|
250
378
|
"""
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# Read error message from pipe
|
|
379
|
+
killed_by_drain = False
|
|
254
380
|
error_bytes = b""
|
|
381
|
+
while True:
|
|
382
|
+
pid, raw_status, rusage = os.wait4(child_pid, os.WNOHANG)
|
|
383
|
+
if pid != 0:
|
|
384
|
+
break
|
|
385
|
+
if not killed_by_drain and _shutdown.deadline_passed():
|
|
386
|
+
_kill_child_group(child_pid)
|
|
387
|
+
killed_by_drain = True
|
|
388
|
+
timeout = 1.0
|
|
389
|
+
if _shutdown.requested and _shutdown.deadline is not None:
|
|
390
|
+
timeout = min(timeout, max(0.0, _shutdown.deadline - time.monotonic()))
|
|
391
|
+
timeout = max(timeout, 0.01) # killed child needs a moment to exit
|
|
392
|
+
ready, _, _ = select_module.select([read_fd], [], [], timeout)
|
|
393
|
+
if ready:
|
|
394
|
+
# Drain incrementally: an error message larger than the pipe
|
|
395
|
+
# buffer would otherwise block the child in write() while we
|
|
396
|
+
# spin on an always-readable fd.
|
|
397
|
+
try:
|
|
398
|
+
error_bytes += os.read(read_fd, 65536)
|
|
399
|
+
except OSError:
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
# Read any remaining error output from the pipe
|
|
255
403
|
try:
|
|
256
404
|
while True:
|
|
257
405
|
chunk = os.read(read_fd, 4096)
|
|
@@ -267,6 +415,13 @@ def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
|
267
415
|
|
|
268
416
|
if os.WIFSIGNALED(raw_status):
|
|
269
417
|
signal_num = os.WTERMSIG(raw_status)
|
|
418
|
+
if killed_by_drain and signal_num == signal.SIGKILL:
|
|
419
|
+
return _ChildResult(
|
|
420
|
+
TaskStatus.FAILED,
|
|
421
|
+
"Task interrupted by worker shutdown"
|
|
422
|
+
f" (exceeded drain_timeout={_shutdown.drain_timeout}s)",
|
|
423
|
+
"shutdown",
|
|
424
|
+
)
|
|
270
425
|
if signal_num == signal.SIGKILL:
|
|
271
426
|
max_rss_kb = rusage.ru_maxrss
|
|
272
427
|
if sys.platform == "darwin":
|
|
@@ -351,9 +506,28 @@ def _execute_in_fork(
|
|
|
351
506
|
raise TaskOOMError(result.error_msg or "Task killed (likely OOM)")
|
|
352
507
|
if result.exit_kind == "killed":
|
|
353
508
|
raise TaskKilledError(result.error_msg or "Task killed by signal")
|
|
509
|
+
if result.exit_kind == "shutdown":
|
|
510
|
+
raise TaskInterruptedError(
|
|
511
|
+
result.error_msg or "Task interrupted by worker shutdown"
|
|
512
|
+
)
|
|
354
513
|
raise Exception(result.error_msg or "Task failed")
|
|
355
514
|
|
|
356
515
|
|
|
516
|
+
def _interruptible_sleep(seconds: float) -> None:
|
|
517
|
+
"""Sleep up to ``seconds``, returning early once shutdown is requested.
|
|
518
|
+
|
|
519
|
+
A plain ``time.sleep`` would run to completion despite SIGTERM (PEP 475
|
|
520
|
+
retries it after the non-raising handler), delaying an idle worker's
|
|
521
|
+
shutdown by up to ``poll_interval``.
|
|
522
|
+
"""
|
|
523
|
+
deadline = time.monotonic() + seconds
|
|
524
|
+
while not _shutdown.requested:
|
|
525
|
+
remaining = deadline - time.monotonic()
|
|
526
|
+
if remaining <= 0:
|
|
527
|
+
return
|
|
528
|
+
time.sleep(min(0.1, remaining))
|
|
529
|
+
|
|
530
|
+
|
|
357
531
|
def _maybe_run_cleanup(
|
|
358
532
|
pq: PQ,
|
|
359
533
|
retention_days: int,
|
|
@@ -424,6 +598,7 @@ def run_worker(
|
|
|
424
598
|
retention_days: int = DEFAULT_RETENTION_DAYS,
|
|
425
599
|
cleanup_interval: float = DEFAULT_CLEANUP_INTERVAL,
|
|
426
600
|
stale_task_timeout: timedelta | None = DEFAULT_STALE_TASK_TIMEOUT,
|
|
601
|
+
drain_timeout: float = DEFAULT_DRAIN_TIMEOUT,
|
|
427
602
|
pre_execute: PreExecuteHook | None = None,
|
|
428
603
|
post_execute: PostExecuteHook | None = None,
|
|
429
604
|
) -> None:
|
|
@@ -431,6 +606,14 @@ def run_worker(
|
|
|
431
606
|
|
|
432
607
|
Each task executes in a forked child process for memory isolation.
|
|
433
608
|
|
|
609
|
+
On SIGTERM or SIGINT the worker shuts down gracefully: it stops
|
|
610
|
+
claiming new tasks, waits up to ``drain_timeout`` seconds for in-flight
|
|
611
|
+
tasks to finish (writing their statuses as usual), then SIGKILLs any
|
|
612
|
+
still-running task children and marks their rows FAILED with an explicit
|
|
613
|
+
shutdown error. Interrupted tasks are NOT re-queued — pq's at-most-once
|
|
614
|
+
semantics are preserved; applications that need redelivery must
|
|
615
|
+
re-enqueue such tasks themselves.
|
|
616
|
+
|
|
434
617
|
Args:
|
|
435
618
|
pq: PQ client instance.
|
|
436
619
|
concurrency: Maximum number of tasks to process simultaneously.
|
|
@@ -445,11 +628,24 @@ def run_worker(
|
|
|
445
628
|
stale_task_timeout: Mark RUNNING tasks older than this as FAILED.
|
|
446
629
|
Catches orphaned tasks whose worker died mid-execution.
|
|
447
630
|
Default: 1 hour. Set to ``None`` to disable.
|
|
631
|
+
drain_timeout: Seconds to wait for in-flight tasks when shutting
|
|
632
|
+
down on SIGTERM/SIGINT. Default: 20. Must be set below the
|
|
633
|
+
orchestrator's termination grace period, with headroom for the
|
|
634
|
+
final status writes. ``0`` skips the wait entirely: in-flight
|
|
635
|
+
tasks are killed as soon as the shutdown is noticed (within
|
|
636
|
+
about a second).
|
|
448
637
|
pre_execute: Called in forked child BEFORE task execution.
|
|
449
638
|
Use for initializing fork-unsafe resources (OTel, DB connections).
|
|
450
639
|
post_execute: Called in forked child AFTER task execution (success or failure).
|
|
451
640
|
Use for cleanup/flushing (OTel traces, etc.).
|
|
641
|
+
|
|
642
|
+
Raises:
|
|
643
|
+
ValueError: If ``drain_timeout`` is negative or not finite.
|
|
452
644
|
"""
|
|
645
|
+
if math.isnan(drain_timeout) or math.isinf(drain_timeout) or drain_timeout < 0:
|
|
646
|
+
raise ValueError(
|
|
647
|
+
f"drain_timeout must be a finite number >= 0 (got {drain_timeout!r})"
|
|
648
|
+
)
|
|
453
649
|
if priorities:
|
|
454
650
|
priority_names = ", ".join(p.name for p in sorted(priorities, reverse=True))
|
|
455
651
|
logger.info(
|
|
@@ -462,40 +658,55 @@ def run_worker(
|
|
|
462
658
|
" fork isolation enabled)..."
|
|
463
659
|
)
|
|
464
660
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
pq,
|
|
468
|
-
concurrency=concurrency,
|
|
469
|
-
poll_interval=poll_interval,
|
|
470
|
-
max_runtime=max_runtime,
|
|
471
|
-
priorities=priorities,
|
|
472
|
-
pre_execute=pre_execute,
|
|
473
|
-
post_execute=post_execute,
|
|
474
|
-
retention_days=retention_days,
|
|
475
|
-
cleanup_interval=cleanup_interval,
|
|
476
|
-
stale_task_timeout=stale_task_timeout,
|
|
477
|
-
)
|
|
478
|
-
return
|
|
479
|
-
|
|
480
|
-
last_cleanup: list[float] = [0.0]
|
|
481
|
-
last_reap: list[float] = [0.0]
|
|
482
|
-
|
|
661
|
+
_shutdown.reset(drain_timeout)
|
|
662
|
+
previous_handlers = _install_shutdown_handlers()
|
|
483
663
|
try:
|
|
484
|
-
|
|
485
|
-
|
|
664
|
+
if concurrency > 1:
|
|
665
|
+
_run_concurrent(
|
|
486
666
|
pq,
|
|
667
|
+
concurrency=concurrency,
|
|
668
|
+
poll_interval=poll_interval,
|
|
487
669
|
max_runtime=max_runtime,
|
|
488
670
|
priorities=priorities,
|
|
489
671
|
pre_execute=pre_execute,
|
|
490
672
|
post_execute=post_execute,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
673
|
+
retention_days=retention_days,
|
|
674
|
+
cleanup_interval=cleanup_interval,
|
|
675
|
+
stale_task_timeout=stale_task_timeout,
|
|
676
|
+
)
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
last_cleanup: list[float] = [0.0]
|
|
680
|
+
last_reap: list[float] = [0.0]
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
while not _shutdown.requested:
|
|
684
|
+
if not run_worker_once(
|
|
685
|
+
pq,
|
|
686
|
+
max_runtime=max_runtime,
|
|
687
|
+
priorities=priorities,
|
|
688
|
+
pre_execute=pre_execute,
|
|
689
|
+
post_execute=post_execute,
|
|
690
|
+
):
|
|
691
|
+
if _shutdown.requested:
|
|
692
|
+
break
|
|
693
|
+
_maybe_run_cleanup(
|
|
694
|
+
pq, retention_days, cleanup_interval, last_cleanup
|
|
695
|
+
)
|
|
696
|
+
_maybe_reap_stale(
|
|
697
|
+
pq, stale_task_timeout, DEFAULT_REAPER_INTERVAL, last_reap
|
|
698
|
+
)
|
|
699
|
+
_interruptible_sleep(poll_interval)
|
|
700
|
+
except KeyboardInterrupt:
|
|
701
|
+
# Fallback for non-main-thread workers (no handlers installed).
|
|
702
|
+
pass
|
|
498
703
|
logger.info("Worker stopped.")
|
|
704
|
+
finally:
|
|
705
|
+
_restore_shutdown_handlers(previous_handlers)
|
|
706
|
+
# Clear the drain state: a lingering past-deadline flag would make
|
|
707
|
+
# later run_worker_once calls in this process kill their children
|
|
708
|
+
# on the first _wait_for_child iteration.
|
|
709
|
+
_shutdown.reset(drain_timeout)
|
|
499
710
|
|
|
500
711
|
|
|
501
712
|
def run_worker_once(
|
|
@@ -647,6 +858,10 @@ def _process_one_off_task(
|
|
|
647
858
|
status = TaskStatus.FAILED
|
|
648
859
|
error_msg = str(e)
|
|
649
860
|
|
|
861
|
+
except TaskInterruptedError as e:
|
|
862
|
+
status = TaskStatus.FAILED
|
|
863
|
+
error_msg = str(e)
|
|
864
|
+
|
|
650
865
|
except Exception as e:
|
|
651
866
|
status = TaskStatus.FAILED
|
|
652
867
|
error_msg = str(e)
|
|
@@ -818,6 +1033,10 @@ def _process_periodic_task(
|
|
|
818
1033
|
elapsed = time.perf_counter() - start
|
|
819
1034
|
logger.error(f"Periodic task '{name}' killed after {elapsed:.3f} s: {e}")
|
|
820
1035
|
|
|
1036
|
+
except TaskInterruptedError as e:
|
|
1037
|
+
elapsed = time.perf_counter() - start
|
|
1038
|
+
logger.warning(f"Periodic task '{name}' interrupted after {elapsed:.3f} s: {e}")
|
|
1039
|
+
|
|
821
1040
|
except Exception as e:
|
|
822
1041
|
elapsed = time.perf_counter() - start
|
|
823
1042
|
logger.error(f"Periodic task '{name}' failed after {elapsed:.3f} s: {e}")
|
|
@@ -1095,6 +1314,11 @@ def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
|
|
|
1095
1314
|
f"Periodic task '{slot.name}' killed after {elapsed:.3f} s:"
|
|
1096
1315
|
f" {result.error_msg}"
|
|
1097
1316
|
)
|
|
1317
|
+
elif result.exit_kind == "shutdown":
|
|
1318
|
+
logger.warning(
|
|
1319
|
+
f"Periodic task '{slot.name}' interrupted after {elapsed:.3f} s:"
|
|
1320
|
+
f" {result.error_msg}"
|
|
1321
|
+
)
|
|
1098
1322
|
else:
|
|
1099
1323
|
logger.error(
|
|
1100
1324
|
f"Periodic task '{slot.name}' failed after {elapsed:.3f} s:"
|
|
@@ -1186,9 +1410,9 @@ def _run_concurrent(
|
|
|
1186
1410
|
last_reap: list[float] = [0.0]
|
|
1187
1411
|
|
|
1188
1412
|
try:
|
|
1189
|
-
while
|
|
1413
|
+
while not _shutdown.requested:
|
|
1190
1414
|
# Step 1: Fill empty slots with new tasks
|
|
1191
|
-
while len(children) < concurrency:
|
|
1415
|
+
while len(children) < concurrency and not _shutdown.requested:
|
|
1192
1416
|
slot = _try_claim_and_fork(
|
|
1193
1417
|
pq,
|
|
1194
1418
|
max_runtime=max_runtime,
|
|
@@ -1210,7 +1434,10 @@ def _run_concurrent(
|
|
|
1210
1434
|
slot = children.pop(pid)
|
|
1211
1435
|
_reap_and_update(pq, slot)
|
|
1212
1436
|
else:
|
|
1213
|
-
|
|
1437
|
+
_interruptible_sleep(poll_interval)
|
|
1438
|
+
|
|
1439
|
+
if _shutdown.requested:
|
|
1440
|
+
break
|
|
1214
1441
|
|
|
1215
1442
|
# Maintenance (each rate-limited independently)
|
|
1216
1443
|
_maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
|
|
@@ -1219,10 +1446,16 @@ def _run_concurrent(
|
|
|
1219
1446
|
)
|
|
1220
1447
|
|
|
1221
1448
|
except KeyboardInterrupt:
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1449
|
+
# Fallback for non-main-thread workers (no handlers installed); the
|
|
1450
|
+
# drain below still runs, just without a deadline.
|
|
1451
|
+
pass
|
|
1452
|
+
|
|
1453
|
+
if children:
|
|
1454
|
+
logger.info(f"Shutting down, draining {len(children)} in-flight task(s)...")
|
|
1455
|
+
# _wait_for_child inside _reap_and_update enforces the drain
|
|
1456
|
+
# deadline (absolute, shared by all children): each child either
|
|
1457
|
+
# finishes in time and gets its real status, or is SIGKILLed and
|
|
1458
|
+
# marked FAILED with the shutdown error.
|
|
1459
|
+
for slot in list(children.values()):
|
|
1460
|
+
_reap_and_update(pq, slot)
|
|
1461
|
+
logger.info("Worker stopped.")
|
|
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
|