python-pq 0.7.1__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.1 → python_pq-0.8.0}/PKG-INFO +1 -1
  2. {python_pq-0.7.1 → python_pq-0.8.0}/pyproject.toml +1 -1
  3. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/__init__.py +1 -1
  4. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/client.py +145 -15
  5. python_pq-0.8.0/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +50 -0
  6. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/models.py +20 -0
  7. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/worker.py +327 -47
  8. {python_pq-0.7.1 → python_pq-0.8.0}/LICENSE +0 -0
  9. {python_pq-0.7.1 → python_pq-0.8.0}/README.md +0 -0
  10. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/config.py +0 -0
  11. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/logging.py +0 -0
  12. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/README +0 -0
  13. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/__init__.py +0 -0
  14. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/env.py +0 -0
  15. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/script.py.mako +0 -0
  16. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  17. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  18. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  19. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  20. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  21. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/__init__.py +0 -0
  22. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/priority.py +0 -0
  23. {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/registry.py +0 -0
  24. {python_pq-0.7.1 → 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.1
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.1"
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.1"
8
+ __version__ = "0.8.0"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -1,6 +1,7 @@
1
1
  """PQ client - main interface for task queue."""
2
2
 
3
3
  import importlib.resources
4
+ import math
4
5
  from collections.abc import Callable, Set
5
6
  from contextlib import contextmanager
6
7
  from datetime import UTC, datetime, timedelta
@@ -10,6 +11,7 @@ from typing import Any, Self
10
11
  from croniter import croniter
11
12
  from croniter.croniter import CroniterBadCronError
12
13
  from loguru import logger
14
+ import sqlalchemy as sa
13
15
  from sqlalchemy import create_engine, delete, func, select, update
14
16
  from sqlalchemy.dialects.postgresql import insert
15
17
  from sqlalchemy.engine import Engine
@@ -21,6 +23,45 @@ from pq.registry import get_function_path
21
23
  from pq.serialization import serialize
22
24
 
23
25
 
26
+ def _validate_max_runtime(max_runtime_seconds: float | None) -> None:
27
+ """Reject obviously-degenerate per-task ``max_runtime_seconds`` values.
28
+
29
+ ``None`` means "use the worker's configured default" and is the
30
+ common case — nothing to validate. ``0``, negative, NaN, and
31
+ infinity are all rejected because the downstream enforcement is
32
+ undefined for those:
33
+
34
+ - ``signal.alarm(int(max_runtime_seconds) + 1)`` would fire at 1 s for
35
+ ``0``, at 0 s (which disables the alarm entirely) for any
36
+ negative integer ≥ -1, and raise ``ValueError`` from inside
37
+ the worker for NaN / infinity.
38
+ - ``max_runtime_seconds * 2`` in the stale-reaper SQL would be ≤ 0 or
39
+ NaN, making the per-row threshold a no-op or NULL (the global
40
+ default would apply) — silent surprise vs. the call site's
41
+ intent.
42
+ - Periodic ``lock_duration`` already guards with a 3600 s
43
+ fallback for ``≤ 0`` (``worker.py``), but that fallback exists
44
+ for the worker-level default, not as a contract for callers.
45
+
46
+ None of these are useful behaviours to expose. Failing fast at
47
+ enqueue/upsert/schedule time keeps the bug at the call site,
48
+ where it's trivially fixable, instead of producing a confusing
49
+ runtime surprise hours later.
50
+ """
51
+ if max_runtime_seconds is None:
52
+ return
53
+ if math.isnan(max_runtime_seconds) or math.isinf(max_runtime_seconds):
54
+ raise ValueError(
55
+ f"max_runtime_seconds must be a finite positive number (got {max_runtime_seconds!r}); "
56
+ f"pass None to use the worker's configured default."
57
+ )
58
+ if max_runtime_seconds <= 0:
59
+ raise ValueError(
60
+ f"max_runtime_seconds must be > 0 (got {max_runtime_seconds!r}); pass None to "
61
+ f"use the worker's configured default."
62
+ )
63
+
64
+
24
65
  class PQ:
25
66
  """Postgres-backed task queue client."""
26
67
 
@@ -125,6 +166,7 @@ class PQ:
125
166
  run_at: datetime | None = None,
126
167
  priority: Priority = Priority.NORMAL,
127
168
  client_id: str | None = None,
169
+ max_runtime_seconds: float | None = None,
128
170
  **kwargs: Any,
129
171
  ) -> int:
130
172
  """Enqueue a one-off task.
@@ -135,15 +177,24 @@ class PQ:
135
177
  run_at: When to run the task. Defaults to now.
136
178
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
137
179
  client_id: Optional client-provided identifier. Must be unique if provided.
180
+ max_runtime_seconds: Per-task wall-clock cap in seconds. When ``None`` (the
181
+ common case), the worker that picks up this task uses its
182
+ configured default. When set, this specific task gets the
183
+ supplied ceiling AND the stale-reaper threshold scales with
184
+ it (``max_runtime_seconds * 2`` if larger than the reaper's global
185
+ default). Use this for occasionally-long tasks in a fleet
186
+ whose worker default is sized for typical short work.
138
187
  **kwargs: Keyword arguments to pass to the handler.
139
188
 
140
189
  Returns:
141
190
  Task ID.
142
191
 
143
192
  Raises:
144
- ValueError: If task is a lambda, closure, or cannot be imported.
193
+ ValueError: If task is a lambda, closure, or cannot be imported,
194
+ or if ``max_runtime_seconds`` is not None and ``<= 0``.
145
195
  IntegrityError: If client_id already exists.
146
196
  """
197
+ _validate_max_runtime(max_runtime_seconds)
147
198
  name = get_function_path(task)
148
199
  payload = serialize(args, kwargs)
149
200
 
@@ -156,6 +207,7 @@ class PQ:
156
207
  run_at=run_at,
157
208
  priority=priority,
158
209
  client_id=client_id,
210
+ max_runtime_seconds=max_runtime_seconds,
159
211
  )
160
212
 
161
213
  with self.session() as session:
@@ -170,6 +222,7 @@ class PQ:
170
222
  run_at: datetime | None = None,
171
223
  priority: Priority = Priority.NORMAL,
172
224
  client_id: str,
225
+ max_runtime_seconds: float | None = None,
173
226
  **kwargs: Any,
174
227
  ) -> int:
175
228
  """Enqueue a task, updating if client_id already exists.
@@ -183,14 +236,19 @@ class PQ:
183
236
  run_at: When to run the task. Defaults to now.
184
237
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
185
238
  client_id: Client-provided identifier. Required for conflict resolution.
239
+ max_runtime_seconds: Per-task wall-clock cap in seconds. See ``enqueue`` for
240
+ details. ``None`` (the default) leaves the worker's own
241
+ configured ``max_runtime_seconds`` in effect.
186
242
  **kwargs: Keyword arguments to pass to the handler.
187
243
 
188
244
  Returns:
189
245
  Task ID.
190
246
 
191
247
  Raises:
192
- ValueError: If task is a lambda, closure, or cannot be imported.
248
+ ValueError: If task is a lambda, closure, or cannot be imported,
249
+ or if ``max_runtime_seconds`` is not None and ``<= 0``.
193
250
  """
251
+ _validate_max_runtime(max_runtime_seconds)
194
252
  name = get_function_path(task)
195
253
  payload = serialize(args, kwargs)
196
254
 
@@ -206,6 +264,7 @@ class PQ:
206
264
  priority=priority,
207
265
  status=TaskStatus.PENDING,
208
266
  run_at=run_at,
267
+ max_runtime_seconds=max_runtime_seconds,
209
268
  )
210
269
  .on_conflict_do_update(
211
270
  index_elements=["client_id"],
@@ -215,6 +274,7 @@ class PQ:
215
274
  "priority": priority,
216
275
  "status": TaskStatus.PENDING,
217
276
  "run_at": run_at,
277
+ "max_runtime_seconds": max_runtime_seconds,
218
278
  "attempts": 0,
219
279
  "started_at": None,
220
280
  "completed_at": None,
@@ -239,6 +299,7 @@ class PQ:
239
299
  max_concurrent: int | None = 1,
240
300
  key: str = "",
241
301
  active: bool = True,
302
+ max_runtime_seconds: float | None = None,
242
303
  **kwargs: Any,
243
304
  ) -> int:
244
305
  """Schedule a periodic task.
@@ -260,6 +321,14 @@ class PQ:
260
321
  Defaults to "" (empty string).
261
322
  active: Whether the task is active. Inactive tasks are not executed.
262
323
  Defaults to True.
324
+ max_runtime_seconds: Per-execution wall-clock cap in seconds. When ``None``
325
+ (the common case), each execution uses the worker's configured
326
+ default. When set, this schedule's executions get the
327
+ supplied ceiling, and the ``locked_until`` window (used when
328
+ ``max_concurrent`` is in effect) is extended to match so the
329
+ lock doesn't expire while the execution is legitimately still
330
+ running. Periodic tasks are not subject to the stale-task
331
+ reaper, so this knob is purely about the wall-clock cap.
263
332
  **kwargs: Keyword arguments to pass to the handler.
264
333
 
265
334
  Returns:
@@ -270,6 +339,7 @@ class PQ:
270
339
  ValueError: If cron expression is invalid.
271
340
  ValueError: If max_concurrent is greater than 1.
272
341
  ValueError: If task is a lambda, closure, or cannot be imported.
342
+ ValueError: If ``max_runtime_seconds`` is not None and ``<= 0``.
273
343
  IntegrityError: If client_id already exists.
274
344
  """
275
345
  if run_every is None and cron is None:
@@ -281,6 +351,7 @@ class PQ:
281
351
  f"max_concurrent must be 1 or None, got {max_concurrent} "
282
352
  "(values > 1 reserved for future use)"
283
353
  )
354
+ _validate_max_runtime(max_runtime_seconds)
284
355
 
285
356
  # Validate and normalize cron expression
286
357
  cron_expr: str | None = None
@@ -321,6 +392,7 @@ class PQ:
321
392
  client_id=client_id,
322
393
  max_concurrent=max_concurrent,
323
394
  active=active,
395
+ max_runtime_seconds=max_runtime_seconds,
324
396
  )
325
397
  .on_conflict_do_update(
326
398
  index_elements=["name", "key"],
@@ -332,6 +404,7 @@ class PQ:
332
404
  "next_run": next_run,
333
405
  "max_concurrent": max_concurrent,
334
406
  "active": active,
407
+ "max_runtime_seconds": max_runtime_seconds,
335
408
  },
336
409
  )
337
410
  .returning(Periodic.id)
@@ -516,39 +589,85 @@ class PQ:
516
589
  process remains to update their status. This method detects those
517
590
  orphaned rows and transitions them to FAILED.
518
591
 
592
+ Tasks enqueued with a per-task ``max_runtime_seconds`` override get a
593
+ proportionally larger reaper window: the effective threshold for
594
+ a row is ``max(threshold, max_runtime_seconds * 2)``. This avoids
595
+ reaping a legitimately long-running task whose declared budget
596
+ exceeds the global default. NULL ``max_runtime_seconds`` (the
597
+ common case) falls back to the supplied ``threshold`` unchanged.
598
+
519
599
  Args:
520
- threshold: A task is considered stale when
521
- ``started_at + threshold < now()``. Should be at least
522
- 2x ``max_runtime`` to avoid reaping legitimately running
523
- tasks, e.g. ``timedelta(seconds=max_runtime * 2)``.
600
+ threshold: Default per-task stale window for tasks that don't
601
+ have a ``max_runtime_seconds`` override (or whose override
602
+ is smaller than this default). A task is stale when
603
+ ``started_at + effective_threshold < now()``. Should be
604
+ at least 2x the worker's own ``max_runtime_seconds``, e.g.
605
+ ``timedelta(seconds=max_runtime_seconds * 2)``.
524
606
 
525
607
  Returns:
526
608
  Number of tasks reaped.
527
609
  """
528
610
  now = datetime.now(UTC)
529
- cutoff = now - threshold
611
+ threshold_seconds = threshold.total_seconds()
612
+ # Per-row effective stale window: the larger of the supplied
613
+ # default and 2x the row's per-task override (NULL → just the
614
+ # default). Computed in SQL with ``GREATEST`` + ``COALESCE``
615
+ # so the reaper stays a single round-trip even when the row
616
+ # set is heterogeneous in declared budget.
617
+ effective_threshold_seconds = sa.func.greatest(
618
+ threshold_seconds,
619
+ sa.func.coalesce(Task.max_runtime_seconds * 2, threshold_seconds),
620
+ )
621
+ stale_cutoff = sa.func.now() - sa.func.make_interval(
622
+ sa.literal(0), # years
623
+ sa.literal(0), # months
624
+ sa.literal(0), # weeks
625
+ sa.literal(0), # days
626
+ sa.literal(0), # hours
627
+ sa.literal(0), # mins
628
+ effective_threshold_seconds, # secs
629
+ )
630
+ # Per-row ``error`` message includes the values that actually
631
+ # decided this row's fate: the row's own
632
+ # ``max_runtime_seconds`` (or ``NULL`` if unset) and the
633
+ # effective threshold the reaper applied. This avoids the
634
+ # operator needing a separate query to figure out "why did MY
635
+ # long-budget task get reaped?" — the answer is in the row.
636
+ # Built with Postgres ``format()`` so the message is computed
637
+ # per row in the same UPDATE round-trip.
638
+ per_row_error = sa.func.format(
639
+ "Reaped: task still RUNNING past its stale window "
640
+ "(default threshold=%s s, per-task max_runtime_seconds=%s, "
641
+ "effective threshold=%s s). Worker likely died.",
642
+ threshold_seconds,
643
+ sa.func.coalesce(sa.cast(Task.max_runtime_seconds, sa.String), "NULL"),
644
+ effective_threshold_seconds,
645
+ )
530
646
  with self.session() as session:
531
647
  stmt = (
532
648
  update(Task)
533
649
  .where(
534
650
  Task.status == TaskStatus.RUNNING,
535
- Task.started_at < cutoff,
651
+ Task.started_at < stale_cutoff,
536
652
  )
537
653
  .values(
538
654
  status=TaskStatus.FAILED,
539
655
  completed_at=now,
540
- error=(
541
- f"Reaped: task still RUNNING after {threshold} "
542
- f"(cutoff={cutoff.isoformat()}). Worker likely died."
543
- ),
656
+ error=per_row_error,
657
+ )
658
+ .returning(
659
+ Task.id,
660
+ Task.name,
661
+ Task.started_at,
662
+ Task.max_runtime_seconds,
544
663
  )
545
- .returning(Task.id, Task.name, Task.started_at)
546
664
  )
547
665
  reaped = list(session.execute(stmt).all())
548
- for task_id, name, started_at in reaped:
666
+ for task_id, name, started_at, max_runtime_seconds in reaped:
549
667
  logger.warning(
550
668
  f"Reaped stale task '{name}' (id={task_id},"
551
- f" started_at={started_at})"
669
+ f" started_at={started_at},"
670
+ f" max_runtime_seconds={max_runtime_seconds})"
552
671
  )
553
672
  return len(reaped)
554
673
 
@@ -559,11 +678,17 @@ class PQ:
559
678
  poll_interval: float = 1.0,
560
679
  max_runtime: float = 30 * 60,
561
680
  priorities: Set[Priority] | None = None,
681
+ drain_timeout: float = 20.0,
562
682
  ) -> None:
563
683
  """Run the worker loop (blocking).
564
684
 
565
685
  Each task executes in a forked child process for memory isolation.
566
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
+
567
692
  Args:
568
693
  concurrency: Maximum number of tasks to process simultaneously.
569
694
  Default: 1 (sequential processing).
@@ -571,6 +696,10 @@ class PQ:
571
696
  max_runtime: Maximum execution time per task in seconds. Default: 30 min.
572
697
  priorities: If set, only process tasks with these priority levels.
573
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.
574
703
  """
575
704
  from pq.worker import run_worker
576
705
 
@@ -580,6 +709,7 @@ class PQ:
580
709
  poll_interval=poll_interval,
581
710
  max_runtime=max_runtime,
582
711
  priorities=priorities,
712
+ drain_timeout=drain_timeout,
583
713
  )
584
714
 
585
715
  def run_worker_once(
@@ -0,0 +1,50 @@
1
+ """add max_runtime_seconds to pq_tasks and pq_periodic
2
+
3
+ Revision ID: aee3e8e7e647
4
+ Revises: c3d4e5f6a7b8
5
+ Create Date: 2026-05-27 17:01:36 Z
6
+
7
+ Per-task override for the worker's wall-clock ``max_runtime`` cap, on
8
+ both one-off tasks (``pq_tasks``) and periodic schedules
9
+ (``pq_periodic``). Nullable on both so every existing row (and every
10
+ existing enqueue / schedule call site that doesn't pass the new
11
+ parameter) keeps the previous behaviour exactly — a NULL value tells
12
+ the worker to use its own default.
13
+
14
+ Schema impact on populated tables is intentionally minimal: PostgreSQL
15
+ 11+ treats ``ADD COLUMN ... DEFAULT NULL`` as a catalog-only operation
16
+ (no table rewrite, brief ACCESS EXCLUSIVE lock on the system catalog
17
+ row, typically < 100 ms even for tables with millions of rows). No
18
+ backfill — NULL is the intended default.
19
+
20
+ """
21
+
22
+ from typing import Sequence, Union
23
+
24
+ from alembic import op
25
+ import sqlalchemy as sa
26
+
27
+
28
+ # revision identifiers, used by Alembic.
29
+ revision: str = "aee3e8e7e647"
30
+ down_revision: Union[str, Sequence[str], None] = "c3d4e5f6a7b8"
31
+ branch_labels: Union[str, Sequence[str], None] = None
32
+ depends_on: Union[str, Sequence[str], None] = None
33
+
34
+
35
+ def upgrade() -> None:
36
+ """Add the nullable ``max_runtime_seconds`` column to both task tables."""
37
+ op.add_column(
38
+ "pq_tasks",
39
+ sa.Column("max_runtime_seconds", sa.Float(), nullable=True),
40
+ )
41
+ op.add_column(
42
+ "pq_periodic",
43
+ sa.Column("max_runtime_seconds", sa.Float(), nullable=True),
44
+ )
45
+
46
+
47
+ def downgrade() -> None:
48
+ """Remove the ``max_runtime_seconds`` column from both task tables."""
49
+ op.drop_column("pq_periodic", "max_runtime_seconds")
50
+ op.drop_column("pq_tasks", "max_runtime_seconds")
@@ -9,6 +9,7 @@ from sqlalchemy import (
9
9
  Boolean,
10
10
  DateTime,
11
11
  Enum,
12
+ Float,
12
13
  Identity,
13
14
  Index,
14
15
  Integer,
@@ -70,6 +71,14 @@ class Task(Base):
70
71
  )
71
72
  error: Mapped[str | None] = mapped_column(Text, nullable=True)
72
73
  attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
74
+ # Per-task override of the worker-level ``max_runtime``. When NULL
75
+ # (the common case), the worker uses its configured default. When
76
+ # set, it caps this specific task's wall-clock at the given number
77
+ # of seconds AND extends the stale-reaper threshold proportionally
78
+ # (the reaper picks the larger of its global default and
79
+ # ``max_runtime_seconds * 2``). Useful for occasionally-long tasks
80
+ # in a fleet whose default is sized for typical short work.
81
+ max_runtime_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
73
82
 
74
83
 
75
84
  class Periodic(Base):
@@ -94,6 +103,17 @@ class Periodic(Base):
94
103
  next_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
95
104
  max_concurrent: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
96
105
  active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
106
+ # Per-task override of the worker-level ``max_runtime``. Same semantics
107
+ # as ``Task.max_runtime_seconds`` — NULL means "use the worker's
108
+ # configured default", a set value caps this specific schedule's
109
+ # wall-clock at the given number of seconds and also extends the
110
+ # ``locked_until`` window (when ``max_concurrent`` is in effect) so
111
+ # the lock doesn't expire while the task is legitimately still
112
+ # running. Periodic tasks are not subject to the stale-task reaper
113
+ # (they're guarded by ``locked_until`` and the natural re-fire of
114
+ # the schedule), so this knob is purely about the per-execution
115
+ # wall-clock cap.
116
+ max_runtime_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
97
117
  last_run: Mapped[datetime | None] = mapped_column(
98
118
  DateTime(timezone=True), nullable=True
99
119
  )
@@ -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(
@@ -596,6 +807,10 @@ def _process_one_off_task(
596
807
  name = task.name
597
808
  payload = task.payload
598
809
  task_id = task.id
810
+ # Per-task ``max_runtime_seconds`` (NULL → use worker default).
811
+ # Read while still in the session because the row is expunged
812
+ # below for the forked-child handoff.
813
+ task_max_runtime = task.max_runtime_seconds
599
814
 
600
815
  # Flush to commit status changes, then expunge for forked process
601
816
  session.flush()
@@ -605,6 +820,14 @@ def _process_one_off_task(
605
820
  logger.error(f"Error claiming task: {e}")
606
821
  return False
607
822
 
823
+ # Effective per-task wall-clock ceiling: per-task value when set,
824
+ # otherwise the worker's configured default. The reaper applies the
825
+ # same rule on its own (see ``Client.reap_stale_tasks``), so the
826
+ # two stay in sync without the worker having to coordinate.
827
+ effective_max_runtime = (
828
+ task_max_runtime if task_max_runtime is not None else max_runtime
829
+ )
830
+
608
831
  # Phase 2: Execute handler in forked process
609
832
  start = time.perf_counter()
610
833
  status = TaskStatus.COMPLETED
@@ -617,7 +840,7 @@ def _process_one_off_task(
617
840
  handler,
618
841
  args,
619
842
  kwargs,
620
- max_runtime=max_runtime,
843
+ max_runtime=effective_max_runtime,
621
844
  task=task,
622
845
  pre_execute=pre_execute,
623
846
  post_execute=post_execute,
@@ -635,6 +858,10 @@ def _process_one_off_task(
635
858
  status = TaskStatus.FAILED
636
859
  error_msg = str(e)
637
860
 
861
+ except TaskInterruptedError as e:
862
+ status = TaskStatus.FAILED
863
+ error_msg = str(e)
864
+
638
865
  except Exception as e:
639
866
  status = TaskStatus.FAILED
640
867
  error_msg = str(e)
@@ -739,10 +966,27 @@ def _process_periodic_task(
739
966
  payload = periodic.payload
740
967
  periodic_id = periodic.id
741
968
  periodic_max_concurrent = periodic.max_concurrent
969
+ # Per-schedule ``max_runtime_seconds`` (NULL → use worker default).
970
+ # Read while still in the session because the row is expunged
971
+ # below for the forked-child handoff.
972
+ periodic_max_runtime = periodic.max_runtime_seconds
973
+
974
+ # Effective per-execution wall-clock ceiling: per-schedule value
975
+ # when set, otherwise the worker's configured default. Drives
976
+ # both the in-child timeout enforcement AND the
977
+ # ``locked_until`` window so the lock doesn't expire while a
978
+ # legitimately-long execution is still running.
979
+ effective_max_runtime = (
980
+ periodic_max_runtime
981
+ if periodic_max_runtime is not None
982
+ else max_runtime
983
+ )
742
984
 
743
985
  # Set lock before execution if concurrency is limited
744
986
  if periodic.max_concurrent is not None:
745
- lock_duration = max_runtime if max_runtime > 0 else 3600
987
+ lock_duration = (
988
+ effective_max_runtime if effective_max_runtime > 0 else 3600
989
+ )
746
990
  periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
747
991
 
748
992
  # Advance schedule BEFORE execution
@@ -769,7 +1013,7 @@ def _process_periodic_task(
769
1013
  handler,
770
1014
  args,
771
1015
  kwargs,
772
- max_runtime=max_runtime,
1016
+ max_runtime=effective_max_runtime,
773
1017
  task=periodic,
774
1018
  pre_execute=pre_execute,
775
1019
  post_execute=post_execute,
@@ -789,6 +1033,10 @@ def _process_periodic_task(
789
1033
  elapsed = time.perf_counter() - start
790
1034
  logger.error(f"Periodic task '{name}' killed after {elapsed:.3f} s: {e}")
791
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
+
792
1040
  except Exception as e:
793
1041
  elapsed = time.perf_counter() - start
794
1042
  logger.error(f"Periodic task '{name}' failed after {elapsed:.3f} s: {e}")
@@ -851,6 +1099,9 @@ def _claim_and_fork_one_off(
851
1099
  name = task.name
852
1100
  payload = task.payload
853
1101
  task_id = task.id
1102
+ # Per-task ``max_runtime_seconds`` (NULL → use worker default).
1103
+ # Same handling as the sequential ``_process_one_off_task`` path.
1104
+ task_max_runtime = task.max_runtime_seconds
854
1105
 
855
1106
  session.flush()
856
1107
  session.expunge(task)
@@ -859,6 +1110,10 @@ def _claim_and_fork_one_off(
859
1110
  logger.error(f"Error claiming task: {e}")
860
1111
  return None
861
1112
 
1113
+ effective_max_runtime = (
1114
+ task_max_runtime if task_max_runtime is not None else max_runtime
1115
+ )
1116
+
862
1117
  try:
863
1118
  handler = resolve_function_path(name)
864
1119
  args, kwargs = deserialize(payload)
@@ -866,7 +1121,7 @@ def _claim_and_fork_one_off(
866
1121
  handler,
867
1122
  args,
868
1123
  kwargs,
869
- max_runtime=max_runtime,
1124
+ max_runtime=effective_max_runtime,
870
1125
  task=task,
871
1126
  pre_execute=pre_execute,
872
1127
  post_execute=post_execute,
@@ -934,9 +1189,20 @@ def _claim_and_fork_periodic(
934
1189
  payload = periodic.payload
935
1190
  periodic_id = periodic.id
936
1191
  periodic_max_concurrent = periodic.max_concurrent
1192
+ # Per-schedule ``max_runtime_seconds`` (NULL → use worker default).
1193
+ # Same handling as the sequential ``_process_periodic_task`` path.
1194
+ periodic_max_runtime = periodic.max_runtime_seconds
1195
+
1196
+ effective_max_runtime = (
1197
+ periodic_max_runtime
1198
+ if periodic_max_runtime is not None
1199
+ else max_runtime
1200
+ )
937
1201
 
938
1202
  if periodic.max_concurrent is not None:
939
- lock_duration = max_runtime if max_runtime > 0 else 3600
1203
+ lock_duration = (
1204
+ effective_max_runtime if effective_max_runtime > 0 else 3600
1205
+ )
940
1206
  periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
941
1207
 
942
1208
  periodic.last_run = func.now()
@@ -959,7 +1225,7 @@ def _claim_and_fork_periodic(
959
1225
  handler,
960
1226
  args,
961
1227
  kwargs,
962
- max_runtime=max_runtime,
1228
+ max_runtime=effective_max_runtime,
963
1229
  task=periodic,
964
1230
  pre_execute=pre_execute,
965
1231
  post_execute=post_execute,
@@ -1048,6 +1314,11 @@ def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
1048
1314
  f"Periodic task '{slot.name}' killed after {elapsed:.3f} s:"
1049
1315
  f" {result.error_msg}"
1050
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
+ )
1051
1322
  else:
1052
1323
  logger.error(
1053
1324
  f"Periodic task '{slot.name}' failed after {elapsed:.3f} s:"
@@ -1139,9 +1410,9 @@ def _run_concurrent(
1139
1410
  last_reap: list[float] = [0.0]
1140
1411
 
1141
1412
  try:
1142
- while True:
1413
+ while not _shutdown.requested:
1143
1414
  # Step 1: Fill empty slots with new tasks
1144
- while len(children) < concurrency:
1415
+ while len(children) < concurrency and not _shutdown.requested:
1145
1416
  slot = _try_claim_and_fork(
1146
1417
  pq,
1147
1418
  max_runtime=max_runtime,
@@ -1163,7 +1434,10 @@ def _run_concurrent(
1163
1434
  slot = children.pop(pid)
1164
1435
  _reap_and_update(pq, slot)
1165
1436
  else:
1166
- time.sleep(poll_interval)
1437
+ _interruptible_sleep(poll_interval)
1438
+
1439
+ if _shutdown.requested:
1440
+ break
1167
1441
 
1168
1442
  # Maintenance (each rate-limited independently)
1169
1443
  _maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
@@ -1172,10 +1446,16 @@ def _run_concurrent(
1172
1446
  )
1173
1447
 
1174
1448
  except KeyboardInterrupt:
1175
- if children:
1176
- logger.info(
1177
- f"Shutting down, waiting for {len(children)} task(s) to finish..."
1178
- )
1179
- for slot in list(children.values()):
1180
- _reap_and_update(pq, slot)
1181
- 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