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.
Files changed (24) hide show
  1. {python_pq-0.7.2 → python_pq-0.8.0}/PKG-INFO +1 -1
  2. {python_pq-0.7.2 → python_pq-0.8.0}/pyproject.toml +1 -1
  3. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/__init__.py +1 -1
  4. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/client.py +11 -0
  5. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/worker.py +274 -41
  6. {python_pq-0.7.2 → python_pq-0.8.0}/LICENSE +0 -0
  7. {python_pq-0.7.2 → python_pq-0.8.0}/README.md +0 -0
  8. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/config.py +0 -0
  9. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/logging.py +0 -0
  10. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/README +0 -0
  11. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/__init__.py +0 -0
  12. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/env.py +0 -0
  13. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/script.py.mako +0 -0
  14. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  15. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  16. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  17. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  18. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  19. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +0 -0
  20. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/migrations/versions/__init__.py +0 -0
  21. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/models.py +0 -0
  22. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/priority.py +0 -0
  23. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/registry.py +0 -0
  24. {python_pq-0.7.2 → python_pq-0.8.0}/src/pq/serialization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-pq
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Postgres-backed job queue for Python
5
5
  Keywords: postgres,job-queue,task-queue,background-jobs,worker
6
6
  Author: ricwo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.7.2"
3
+ version = "0.8.0"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,7 +5,7 @@ from pq.models import Periodic, Task, TaskStatus
5
5
  from pq.priority import Priority
6
6
  from pq.worker import PostExecuteHook, PreExecuteHook, TaskTimeoutError
7
7
 
8
- __version__ = "0.7.2"
8
+ __version__ = "0.8.0"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -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 Callable, Set
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
- _, raw_status, rusage = os.wait4(child_pid, 0)
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
- if concurrency > 1:
466
- _run_concurrent(
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
- while True:
485
- if not run_worker_once(
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
- _maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
493
- _maybe_reap_stale(
494
- pq, stale_task_timeout, DEFAULT_REAPER_INTERVAL, last_reap
495
- )
496
- time.sleep(poll_interval)
497
- except KeyboardInterrupt:
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 True:
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
- time.sleep(poll_interval)
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
- if children:
1223
- logger.info(
1224
- f"Shutting down, waiting for {len(children)} task(s) to finish..."
1225
- )
1226
- for slot in list(children.values()):
1227
- _reap_and_update(pq, slot)
1228
- logger.info("Worker stopped.")
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