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.
- {python_pq-0.7.1 → python_pq-0.8.0}/PKG-INFO +1 -1
- {python_pq-0.7.1 → python_pq-0.8.0}/pyproject.toml +1 -1
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/__init__.py +1 -1
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/client.py +145 -15
- python_pq-0.8.0/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +50 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/models.py +20 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/worker.py +327 -47
- {python_pq-0.7.1 → python_pq-0.8.0}/LICENSE +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/README.md +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/config.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/logging.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/README +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/env.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/priority.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/registry.py +0 -0
- {python_pq-0.7.1 → python_pq-0.8.0}/src/pq/serialization.py +0 -0
|
@@ -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:
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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 <
|
|
651
|
+
Task.started_at < stale_cutoff,
|
|
536
652
|
)
|
|
537
653
|
.values(
|
|
538
654
|
status=TaskStatus.FAILED,
|
|
539
655
|
completed_at=now,
|
|
540
|
-
error=
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
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(
|
|
@@ -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=
|
|
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 =
|
|
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=
|
|
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=
|
|
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 =
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|