python-pq 0.7.0__py3-none-any.whl → 0.7.2__py3-none-any.whl
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.
- pq/__init__.py +1 -1
- pq/client.py +167 -3
- pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +50 -0
- pq/models.py +20 -0
- pq/worker.py +144 -21
- {python_pq-0.7.0.dist-info → python_pq-0.7.2.dist-info}/METADATA +1 -1
- {python_pq-0.7.0.dist-info → python_pq-0.7.2.dist-info}/RECORD +10 -9
- {python_pq-0.7.0.dist-info → python_pq-0.7.2.dist-info}/WHEEL +0 -0
- {python_pq-0.7.0.dist-info → python_pq-0.7.2.dist-info}/entry_points.txt +0 -0
- {python_pq-0.7.0.dist-info → python_pq-0.7.2.dist-info}/licenses/LICENSE +0 -0
pq/__init__.py
CHANGED
pq/client.py
CHANGED
|
@@ -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
|
|
@@ -9,7 +10,9 @@ from typing import Any, Self
|
|
|
9
10
|
|
|
10
11
|
from croniter import croniter
|
|
11
12
|
from croniter.croniter import CroniterBadCronError
|
|
12
|
-
from
|
|
13
|
+
from loguru import logger
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
from sqlalchemy import create_engine, delete, func, select, update
|
|
13
16
|
from sqlalchemy.dialects.postgresql import insert
|
|
14
17
|
from sqlalchemy.engine import Engine
|
|
15
18
|
from sqlalchemy.orm import sessionmaker
|
|
@@ -20,6 +23,45 @@ from pq.registry import get_function_path
|
|
|
20
23
|
from pq.serialization import serialize
|
|
21
24
|
|
|
22
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
|
+
|
|
23
65
|
class PQ:
|
|
24
66
|
"""Postgres-backed task queue client."""
|
|
25
67
|
|
|
@@ -124,6 +166,7 @@ class PQ:
|
|
|
124
166
|
run_at: datetime | None = None,
|
|
125
167
|
priority: Priority = Priority.NORMAL,
|
|
126
168
|
client_id: str | None = None,
|
|
169
|
+
max_runtime_seconds: float | None = None,
|
|
127
170
|
**kwargs: Any,
|
|
128
171
|
) -> int:
|
|
129
172
|
"""Enqueue a one-off task.
|
|
@@ -134,15 +177,24 @@ class PQ:
|
|
|
134
177
|
run_at: When to run the task. Defaults to now.
|
|
135
178
|
priority: Task priority. Higher = higher priority. Defaults to NORMAL.
|
|
136
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.
|
|
137
187
|
**kwargs: Keyword arguments to pass to the handler.
|
|
138
188
|
|
|
139
189
|
Returns:
|
|
140
190
|
Task ID.
|
|
141
191
|
|
|
142
192
|
Raises:
|
|
143
|
-
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``.
|
|
144
195
|
IntegrityError: If client_id already exists.
|
|
145
196
|
"""
|
|
197
|
+
_validate_max_runtime(max_runtime_seconds)
|
|
146
198
|
name = get_function_path(task)
|
|
147
199
|
payload = serialize(args, kwargs)
|
|
148
200
|
|
|
@@ -155,6 +207,7 @@ class PQ:
|
|
|
155
207
|
run_at=run_at,
|
|
156
208
|
priority=priority,
|
|
157
209
|
client_id=client_id,
|
|
210
|
+
max_runtime_seconds=max_runtime_seconds,
|
|
158
211
|
)
|
|
159
212
|
|
|
160
213
|
with self.session() as session:
|
|
@@ -169,6 +222,7 @@ class PQ:
|
|
|
169
222
|
run_at: datetime | None = None,
|
|
170
223
|
priority: Priority = Priority.NORMAL,
|
|
171
224
|
client_id: str,
|
|
225
|
+
max_runtime_seconds: float | None = None,
|
|
172
226
|
**kwargs: Any,
|
|
173
227
|
) -> int:
|
|
174
228
|
"""Enqueue a task, updating if client_id already exists.
|
|
@@ -182,14 +236,19 @@ class PQ:
|
|
|
182
236
|
run_at: When to run the task. Defaults to now.
|
|
183
237
|
priority: Task priority. Higher = higher priority. Defaults to NORMAL.
|
|
184
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.
|
|
185
242
|
**kwargs: Keyword arguments to pass to the handler.
|
|
186
243
|
|
|
187
244
|
Returns:
|
|
188
245
|
Task ID.
|
|
189
246
|
|
|
190
247
|
Raises:
|
|
191
|
-
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``.
|
|
192
250
|
"""
|
|
251
|
+
_validate_max_runtime(max_runtime_seconds)
|
|
193
252
|
name = get_function_path(task)
|
|
194
253
|
payload = serialize(args, kwargs)
|
|
195
254
|
|
|
@@ -205,6 +264,7 @@ class PQ:
|
|
|
205
264
|
priority=priority,
|
|
206
265
|
status=TaskStatus.PENDING,
|
|
207
266
|
run_at=run_at,
|
|
267
|
+
max_runtime_seconds=max_runtime_seconds,
|
|
208
268
|
)
|
|
209
269
|
.on_conflict_do_update(
|
|
210
270
|
index_elements=["client_id"],
|
|
@@ -214,6 +274,7 @@ class PQ:
|
|
|
214
274
|
"priority": priority,
|
|
215
275
|
"status": TaskStatus.PENDING,
|
|
216
276
|
"run_at": run_at,
|
|
277
|
+
"max_runtime_seconds": max_runtime_seconds,
|
|
217
278
|
"attempts": 0,
|
|
218
279
|
"started_at": None,
|
|
219
280
|
"completed_at": None,
|
|
@@ -238,6 +299,7 @@ class PQ:
|
|
|
238
299
|
max_concurrent: int | None = 1,
|
|
239
300
|
key: str = "",
|
|
240
301
|
active: bool = True,
|
|
302
|
+
max_runtime_seconds: float | None = None,
|
|
241
303
|
**kwargs: Any,
|
|
242
304
|
) -> int:
|
|
243
305
|
"""Schedule a periodic task.
|
|
@@ -259,6 +321,14 @@ class PQ:
|
|
|
259
321
|
Defaults to "" (empty string).
|
|
260
322
|
active: Whether the task is active. Inactive tasks are not executed.
|
|
261
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.
|
|
262
332
|
**kwargs: Keyword arguments to pass to the handler.
|
|
263
333
|
|
|
264
334
|
Returns:
|
|
@@ -269,6 +339,7 @@ class PQ:
|
|
|
269
339
|
ValueError: If cron expression is invalid.
|
|
270
340
|
ValueError: If max_concurrent is greater than 1.
|
|
271
341
|
ValueError: If task is a lambda, closure, or cannot be imported.
|
|
342
|
+
ValueError: If ``max_runtime_seconds`` is not None and ``<= 0``.
|
|
272
343
|
IntegrityError: If client_id already exists.
|
|
273
344
|
"""
|
|
274
345
|
if run_every is None and cron is None:
|
|
@@ -280,6 +351,7 @@ class PQ:
|
|
|
280
351
|
f"max_concurrent must be 1 or None, got {max_concurrent} "
|
|
281
352
|
"(values > 1 reserved for future use)"
|
|
282
353
|
)
|
|
354
|
+
_validate_max_runtime(max_runtime_seconds)
|
|
283
355
|
|
|
284
356
|
# Validate and normalize cron expression
|
|
285
357
|
cron_expr: str | None = None
|
|
@@ -320,6 +392,7 @@ class PQ:
|
|
|
320
392
|
client_id=client_id,
|
|
321
393
|
max_concurrent=max_concurrent,
|
|
322
394
|
active=active,
|
|
395
|
+
max_runtime_seconds=max_runtime_seconds,
|
|
323
396
|
)
|
|
324
397
|
.on_conflict_do_update(
|
|
325
398
|
index_elements=["name", "key"],
|
|
@@ -331,6 +404,7 @@ class PQ:
|
|
|
331
404
|
"next_run": next_run,
|
|
332
405
|
"max_concurrent": max_concurrent,
|
|
333
406
|
"active": active,
|
|
407
|
+
"max_runtime_seconds": max_runtime_seconds,
|
|
334
408
|
},
|
|
335
409
|
)
|
|
336
410
|
.returning(Periodic.id)
|
|
@@ -507,6 +581,96 @@ class PQ:
|
|
|
507
581
|
result = session.execute(stmt)
|
|
508
582
|
return result.rowcount
|
|
509
583
|
|
|
584
|
+
def reap_stale_tasks(self, threshold: timedelta) -> int:
|
|
585
|
+
"""Mark stale RUNNING tasks as FAILED.
|
|
586
|
+
|
|
587
|
+
When a worker dies mid-execution (e.g. pod restart, OOM on the worker
|
|
588
|
+
process), in-flight tasks stay RUNNING forever because no parent
|
|
589
|
+
process remains to update their status. This method detects those
|
|
590
|
+
orphaned rows and transitions them to FAILED.
|
|
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
|
+
|
|
599
|
+
Args:
|
|
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)``.
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
Number of tasks reaped.
|
|
609
|
+
"""
|
|
610
|
+
now = datetime.now(UTC)
|
|
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
|
+
)
|
|
646
|
+
with self.session() as session:
|
|
647
|
+
stmt = (
|
|
648
|
+
update(Task)
|
|
649
|
+
.where(
|
|
650
|
+
Task.status == TaskStatus.RUNNING,
|
|
651
|
+
Task.started_at < stale_cutoff,
|
|
652
|
+
)
|
|
653
|
+
.values(
|
|
654
|
+
status=TaskStatus.FAILED,
|
|
655
|
+
completed_at=now,
|
|
656
|
+
error=per_row_error,
|
|
657
|
+
)
|
|
658
|
+
.returning(
|
|
659
|
+
Task.id,
|
|
660
|
+
Task.name,
|
|
661
|
+
Task.started_at,
|
|
662
|
+
Task.max_runtime_seconds,
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
reaped = list(session.execute(stmt).all())
|
|
666
|
+
for task_id, name, started_at, max_runtime_seconds in reaped:
|
|
667
|
+
logger.warning(
|
|
668
|
+
f"Reaped stale task '{name}' (id={task_id},"
|
|
669
|
+
f" started_at={started_at},"
|
|
670
|
+
f" max_runtime_seconds={max_runtime_seconds})"
|
|
671
|
+
)
|
|
672
|
+
return len(reaped)
|
|
673
|
+
|
|
510
674
|
def run_worker(
|
|
511
675
|
self,
|
|
512
676
|
*,
|
|
@@ -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")
|
pq/models.py
CHANGED
|
@@ -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
|
)
|
pq/worker.py
CHANGED
|
@@ -75,6 +75,14 @@ DEFAULT_RETENTION_DAYS: int = 7
|
|
|
75
75
|
# Default cleanup interval: 1 hour
|
|
76
76
|
DEFAULT_CLEANUP_INTERVAL: float = 3600
|
|
77
77
|
|
|
78
|
+
# Default stale task timeout: 1 hour
|
|
79
|
+
# Tasks RUNNING longer than this are assumed orphaned (worker died) and reaped.
|
|
80
|
+
DEFAULT_STALE_TASK_TIMEOUT: timedelta = timedelta(hours=1)
|
|
81
|
+
|
|
82
|
+
# Default reaper check interval: 5 minutes
|
|
83
|
+
# How often the worker checks for stale RUNNING tasks.
|
|
84
|
+
DEFAULT_REAPER_INTERVAL: float = 300
|
|
85
|
+
|
|
78
86
|
# Exit codes for child process
|
|
79
87
|
EXIT_SUCCESS = 0
|
|
80
88
|
EXIT_FAILURE = 1
|
|
@@ -377,6 +385,35 @@ def _maybe_run_cleanup(
|
|
|
377
385
|
last_cleanup[0] = now
|
|
378
386
|
|
|
379
387
|
|
|
388
|
+
def _maybe_reap_stale(
|
|
389
|
+
pq: PQ,
|
|
390
|
+
stale_task_timeout: timedelta | None,
|
|
391
|
+
reaper_interval: float,
|
|
392
|
+
last_reap: list[float],
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Reap stale RUNNING tasks if enabled and interval has passed.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
pq: PQ client instance.
|
|
398
|
+
stale_task_timeout: RUNNING tasks older than this are marked FAILED.
|
|
399
|
+
``None`` disables reaping.
|
|
400
|
+
reaper_interval: Seconds between reaper checks.
|
|
401
|
+
last_reap: Mutable list containing last reap timestamp.
|
|
402
|
+
"""
|
|
403
|
+
if stale_task_timeout is None:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
now = time.time()
|
|
407
|
+
if now - last_reap[0] < reaper_interval:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
reaped = pq.reap_stale_tasks(stale_task_timeout)
|
|
411
|
+
if reaped:
|
|
412
|
+
logger.info(f"Reaped {reaped} stale RUNNING task(s)")
|
|
413
|
+
|
|
414
|
+
last_reap[0] = now
|
|
415
|
+
|
|
416
|
+
|
|
380
417
|
def run_worker(
|
|
381
418
|
pq: PQ,
|
|
382
419
|
*,
|
|
@@ -386,6 +423,7 @@ def run_worker(
|
|
|
386
423
|
priorities: Set[Priority] | None = None,
|
|
387
424
|
retention_days: int = DEFAULT_RETENTION_DAYS,
|
|
388
425
|
cleanup_interval: float = DEFAULT_CLEANUP_INTERVAL,
|
|
426
|
+
stale_task_timeout: timedelta | None = DEFAULT_STALE_TASK_TIMEOUT,
|
|
389
427
|
pre_execute: PreExecuteHook | None = None,
|
|
390
428
|
post_execute: PostExecuteHook | None = None,
|
|
391
429
|
) -> None:
|
|
@@ -404,6 +442,9 @@ def run_worker(
|
|
|
404
442
|
retention_days: Days to keep completed/failed tasks. Default: 7.
|
|
405
443
|
Set to 0 to disable automatic cleanup.
|
|
406
444
|
cleanup_interval: Seconds between cleanup runs. Default: 3600 (1 hour).
|
|
445
|
+
stale_task_timeout: Mark RUNNING tasks older than this as FAILED.
|
|
446
|
+
Catches orphaned tasks whose worker died mid-execution.
|
|
447
|
+
Default: 1 hour. Set to ``None`` to disable.
|
|
407
448
|
pre_execute: Called in forked child BEFORE task execution.
|
|
408
449
|
Use for initializing fork-unsafe resources (OTel, DB connections).
|
|
409
450
|
post_execute: Called in forked child AFTER task execution (success or failure).
|
|
@@ -432,10 +473,12 @@ def run_worker(
|
|
|
432
473
|
post_execute=post_execute,
|
|
433
474
|
retention_days=retention_days,
|
|
434
475
|
cleanup_interval=cleanup_interval,
|
|
476
|
+
stale_task_timeout=stale_task_timeout,
|
|
435
477
|
)
|
|
436
478
|
return
|
|
437
479
|
|
|
438
|
-
last_cleanup: list[float] = [0.0]
|
|
480
|
+
last_cleanup: list[float] = [0.0]
|
|
481
|
+
last_reap: list[float] = [0.0]
|
|
439
482
|
|
|
440
483
|
try:
|
|
441
484
|
while True:
|
|
@@ -447,6 +490,9 @@ def run_worker(
|
|
|
447
490
|
post_execute=post_execute,
|
|
448
491
|
):
|
|
449
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
|
+
)
|
|
450
496
|
time.sleep(poll_interval)
|
|
451
497
|
except KeyboardInterrupt:
|
|
452
498
|
logger.info("Worker stopped.")
|
|
@@ -550,6 +596,10 @@ def _process_one_off_task(
|
|
|
550
596
|
name = task.name
|
|
551
597
|
payload = task.payload
|
|
552
598
|
task_id = task.id
|
|
599
|
+
# Per-task ``max_runtime_seconds`` (NULL → use worker default).
|
|
600
|
+
# Read while still in the session because the row is expunged
|
|
601
|
+
# below for the forked-child handoff.
|
|
602
|
+
task_max_runtime = task.max_runtime_seconds
|
|
553
603
|
|
|
554
604
|
# Flush to commit status changes, then expunge for forked process
|
|
555
605
|
session.flush()
|
|
@@ -559,6 +609,14 @@ def _process_one_off_task(
|
|
|
559
609
|
logger.error(f"Error claiming task: {e}")
|
|
560
610
|
return False
|
|
561
611
|
|
|
612
|
+
# Effective per-task wall-clock ceiling: per-task value when set,
|
|
613
|
+
# otherwise the worker's configured default. The reaper applies the
|
|
614
|
+
# same rule on its own (see ``Client.reap_stale_tasks``), so the
|
|
615
|
+
# two stay in sync without the worker having to coordinate.
|
|
616
|
+
effective_max_runtime = (
|
|
617
|
+
task_max_runtime if task_max_runtime is not None else max_runtime
|
|
618
|
+
)
|
|
619
|
+
|
|
562
620
|
# Phase 2: Execute handler in forked process
|
|
563
621
|
start = time.perf_counter()
|
|
564
622
|
status = TaskStatus.COMPLETED
|
|
@@ -571,7 +629,7 @@ def _process_one_off_task(
|
|
|
571
629
|
handler,
|
|
572
630
|
args,
|
|
573
631
|
kwargs,
|
|
574
|
-
max_runtime=
|
|
632
|
+
max_runtime=effective_max_runtime,
|
|
575
633
|
task=task,
|
|
576
634
|
pre_execute=pre_execute,
|
|
577
635
|
post_execute=post_execute,
|
|
@@ -595,15 +653,26 @@ def _process_one_off_task(
|
|
|
595
653
|
|
|
596
654
|
elapsed = time.perf_counter() - start
|
|
597
655
|
|
|
598
|
-
# Phase 3: Update task status
|
|
656
|
+
# Phase 3: Update task status (guarded — only if still RUNNING, so we
|
|
657
|
+
# don't overwrite a reaper's FAILED verdict for an orphaned task)
|
|
599
658
|
try:
|
|
600
659
|
with pq.session() as session:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
660
|
+
values: dict[str, object] = {
|
|
661
|
+
"status": status,
|
|
662
|
+
"completed_at": datetime.now(UTC),
|
|
663
|
+
}
|
|
664
|
+
if error_msg:
|
|
665
|
+
values["error"] = error_msg
|
|
666
|
+
result = session.execute(
|
|
667
|
+
update(Task)
|
|
668
|
+
.where(Task.id == task_id, Task.status == TaskStatus.RUNNING)
|
|
669
|
+
.values(**values)
|
|
670
|
+
)
|
|
671
|
+
if result.rowcount == 0:
|
|
672
|
+
logger.warning(
|
|
673
|
+
f"Task '{name}' (id={task_id}) was no longer RUNNING"
|
|
674
|
+
" when Phase 3 tried to update — likely reaped"
|
|
675
|
+
)
|
|
607
676
|
except Exception as e:
|
|
608
677
|
logger.error(f"Error updating task status: {e}")
|
|
609
678
|
|
|
@@ -682,10 +751,27 @@ def _process_periodic_task(
|
|
|
682
751
|
payload = periodic.payload
|
|
683
752
|
periodic_id = periodic.id
|
|
684
753
|
periodic_max_concurrent = periodic.max_concurrent
|
|
754
|
+
# Per-schedule ``max_runtime_seconds`` (NULL → use worker default).
|
|
755
|
+
# Read while still in the session because the row is expunged
|
|
756
|
+
# below for the forked-child handoff.
|
|
757
|
+
periodic_max_runtime = periodic.max_runtime_seconds
|
|
758
|
+
|
|
759
|
+
# Effective per-execution wall-clock ceiling: per-schedule value
|
|
760
|
+
# when set, otherwise the worker's configured default. Drives
|
|
761
|
+
# both the in-child timeout enforcement AND the
|
|
762
|
+
# ``locked_until`` window so the lock doesn't expire while a
|
|
763
|
+
# legitimately-long execution is still running.
|
|
764
|
+
effective_max_runtime = (
|
|
765
|
+
periodic_max_runtime
|
|
766
|
+
if periodic_max_runtime is not None
|
|
767
|
+
else max_runtime
|
|
768
|
+
)
|
|
685
769
|
|
|
686
770
|
# Set lock before execution if concurrency is limited
|
|
687
771
|
if periodic.max_concurrent is not None:
|
|
688
|
-
lock_duration =
|
|
772
|
+
lock_duration = (
|
|
773
|
+
effective_max_runtime if effective_max_runtime > 0 else 3600
|
|
774
|
+
)
|
|
689
775
|
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
690
776
|
|
|
691
777
|
# Advance schedule BEFORE execution
|
|
@@ -712,7 +798,7 @@ def _process_periodic_task(
|
|
|
712
798
|
handler,
|
|
713
799
|
args,
|
|
714
800
|
kwargs,
|
|
715
|
-
max_runtime=
|
|
801
|
+
max_runtime=effective_max_runtime,
|
|
716
802
|
task=periodic,
|
|
717
803
|
pre_execute=pre_execute,
|
|
718
804
|
post_execute=post_execute,
|
|
@@ -794,6 +880,9 @@ def _claim_and_fork_one_off(
|
|
|
794
880
|
name = task.name
|
|
795
881
|
payload = task.payload
|
|
796
882
|
task_id = task.id
|
|
883
|
+
# Per-task ``max_runtime_seconds`` (NULL → use worker default).
|
|
884
|
+
# Same handling as the sequential ``_process_one_off_task`` path.
|
|
885
|
+
task_max_runtime = task.max_runtime_seconds
|
|
797
886
|
|
|
798
887
|
session.flush()
|
|
799
888
|
session.expunge(task)
|
|
@@ -802,6 +891,10 @@ def _claim_and_fork_one_off(
|
|
|
802
891
|
logger.error(f"Error claiming task: {e}")
|
|
803
892
|
return None
|
|
804
893
|
|
|
894
|
+
effective_max_runtime = (
|
|
895
|
+
task_max_runtime if task_max_runtime is not None else max_runtime
|
|
896
|
+
)
|
|
897
|
+
|
|
805
898
|
try:
|
|
806
899
|
handler = resolve_function_path(name)
|
|
807
900
|
args, kwargs = deserialize(payload)
|
|
@@ -809,7 +902,7 @@ def _claim_and_fork_one_off(
|
|
|
809
902
|
handler,
|
|
810
903
|
args,
|
|
811
904
|
kwargs,
|
|
812
|
-
max_runtime=
|
|
905
|
+
max_runtime=effective_max_runtime,
|
|
813
906
|
task=task,
|
|
814
907
|
pre_execute=pre_execute,
|
|
815
908
|
post_execute=post_execute,
|
|
@@ -877,9 +970,20 @@ def _claim_and_fork_periodic(
|
|
|
877
970
|
payload = periodic.payload
|
|
878
971
|
periodic_id = periodic.id
|
|
879
972
|
periodic_max_concurrent = periodic.max_concurrent
|
|
973
|
+
# Per-schedule ``max_runtime_seconds`` (NULL → use worker default).
|
|
974
|
+
# Same handling as the sequential ``_process_periodic_task`` path.
|
|
975
|
+
periodic_max_runtime = periodic.max_runtime_seconds
|
|
976
|
+
|
|
977
|
+
effective_max_runtime = (
|
|
978
|
+
periodic_max_runtime
|
|
979
|
+
if periodic_max_runtime is not None
|
|
980
|
+
else max_runtime
|
|
981
|
+
)
|
|
880
982
|
|
|
881
983
|
if periodic.max_concurrent is not None:
|
|
882
|
-
lock_duration =
|
|
984
|
+
lock_duration = (
|
|
985
|
+
effective_max_runtime if effective_max_runtime > 0 else 3600
|
|
986
|
+
)
|
|
883
987
|
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
884
988
|
|
|
885
989
|
periodic.last_run = func.now()
|
|
@@ -902,7 +1006,7 @@ def _claim_and_fork_periodic(
|
|
|
902
1006
|
handler,
|
|
903
1007
|
args,
|
|
904
1008
|
kwargs,
|
|
905
|
-
max_runtime=
|
|
1009
|
+
max_runtime=effective_max_runtime,
|
|
906
1010
|
task=periodic,
|
|
907
1011
|
pre_execute=pre_execute,
|
|
908
1012
|
post_execute=post_execute,
|
|
@@ -1016,12 +1120,25 @@ def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
|
|
|
1016
1120
|
|
|
1017
1121
|
try:
|
|
1018
1122
|
with pq.session() as session:
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1123
|
+
values: dict[str, object] = {
|
|
1124
|
+
"status": result.task_status,
|
|
1125
|
+
"completed_at": datetime.now(UTC),
|
|
1126
|
+
}
|
|
1127
|
+
if error_msg:
|
|
1128
|
+
values["error"] = error_msg
|
|
1129
|
+
row_result = session.execute(
|
|
1130
|
+
update(Task)
|
|
1131
|
+
.where(
|
|
1132
|
+
Task.id == slot.task_id,
|
|
1133
|
+
Task.status == TaskStatus.RUNNING,
|
|
1134
|
+
)
|
|
1135
|
+
.values(**values)
|
|
1136
|
+
)
|
|
1137
|
+
if row_result.rowcount == 0:
|
|
1138
|
+
logger.warning(
|
|
1139
|
+
f"Task '{slot.name}' (id={slot.task_id}) was no longer"
|
|
1140
|
+
" RUNNING when reap tried to update — likely reaped"
|
|
1141
|
+
)
|
|
1025
1142
|
except Exception as e:
|
|
1026
1143
|
logger.error(f"Error updating task status: {e}")
|
|
1027
1144
|
|
|
@@ -1044,6 +1161,7 @@ def _run_concurrent(
|
|
|
1044
1161
|
post_execute: PostExecuteHook | None,
|
|
1045
1162
|
retention_days: int,
|
|
1046
1163
|
cleanup_interval: float,
|
|
1164
|
+
stale_task_timeout: timedelta | None = None,
|
|
1047
1165
|
) -> None:
|
|
1048
1166
|
"""Run the concurrent worker loop.
|
|
1049
1167
|
|
|
@@ -1060,10 +1178,12 @@ def _run_concurrent(
|
|
|
1060
1178
|
post_execute: Called in forked child AFTER task execution.
|
|
1061
1179
|
retention_days: Days to keep completed/failed tasks.
|
|
1062
1180
|
cleanup_interval: Seconds between cleanup runs.
|
|
1181
|
+
stale_task_timeout: If set, reap RUNNING tasks older than this.
|
|
1063
1182
|
"""
|
|
1064
1183
|
children: dict[int, _ChildSlot] = {} # pid -> slot
|
|
1065
1184
|
fd_to_pid: dict[int, int] = {} # read_fd -> pid
|
|
1066
1185
|
last_cleanup: list[float] = [0.0]
|
|
1186
|
+
last_reap: list[float] = [0.0]
|
|
1067
1187
|
|
|
1068
1188
|
try:
|
|
1069
1189
|
while True:
|
|
@@ -1092,8 +1212,11 @@ def _run_concurrent(
|
|
|
1092
1212
|
else:
|
|
1093
1213
|
time.sleep(poll_interval)
|
|
1094
1214
|
|
|
1095
|
-
#
|
|
1215
|
+
# Maintenance (each rate-limited independently)
|
|
1096
1216
|
_maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
|
|
1217
|
+
_maybe_reap_stale(
|
|
1218
|
+
pq, stale_task_timeout, DEFAULT_REAPER_INTERVAL, last_reap
|
|
1219
|
+
)
|
|
1097
1220
|
|
|
1098
1221
|
except KeyboardInterrupt:
|
|
1099
1222
|
if children:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
pq/__init__.py,sha256=
|
|
2
|
-
pq/client.py,sha256=
|
|
1
|
+
pq/__init__.py,sha256=7j0ZYwlHBgi2fysJuYDAkNrFir6WC2m7irpoFtmu_lk,398
|
|
2
|
+
pq/client.py,sha256=DBXZFEhgZ9Ynh554adtdvYK3vcFqHfwSHpb-s-h8j7U,27468
|
|
3
3
|
pq/config.py,sha256=U7dW-LVHy4feLUDQAmxKSF8bavtwxMY4thYCNMR7nDc,293
|
|
4
4
|
pq/logging.py,sha256=41kft5saLMLzzhHSstx27MSAHdFcd02_jxNGm8c-aoI,789
|
|
5
5
|
pq/migrations/README,sha256=heMzebYwlGhnE8_4CWJ4LS74WoEZjBy-S-mIJRxAEKI,39
|
|
@@ -11,14 +11,15 @@ pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py,sha256=53h
|
|
|
11
11
|
pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py,sha256=gfhpPbcNb6JHnfPh3_K6An7EY5P2blYduQrp4khjJJg,1128
|
|
12
12
|
pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py,sha256=W_sXMji-s6QObxxghDZf4nIeROWclc3QLpFaOIzDtH8,1137
|
|
13
13
|
pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py,sha256=o5Wft7OkeXg6bAbLKwVSuwoIPBscuJr0ERcA1pVu_lI,758
|
|
14
|
+
pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py,sha256=DUAdNbVCR83eeZKIvqRg7nyT-fEU_HS7pR81yM5CMew,1708
|
|
14
15
|
pq/migrations/versions/__init__.py,sha256=HtcCnjmL67QdG8QvzZXVOii80Q1cKNkuzNT3emD11oU,26
|
|
15
|
-
pq/models.py,sha256=
|
|
16
|
+
pq/models.py,sha256=rEboUf3hNPsf6acsIp_HU3xb5JeciVoLBAcfn-KmNLE,4972
|
|
16
17
|
pq/priority.py,sha256=edqpQeGiJrxcmpJ5MZCvwTD9F6sIZbRoI1mx_LmUxAs,264
|
|
17
18
|
pq/registry.py,sha256=1RFfB-dXXM2qKwLF6XeYeUm9buca1_fQvKQDnpfsXKs,2306
|
|
18
19
|
pq/serialization.py,sha256=ZspG9qO4YYi-xCrtPGCg8yrzqRI2CGYnO_Ewjh8lE1E,3291
|
|
19
|
-
pq/worker.py,sha256=
|
|
20
|
-
python_pq-0.7.
|
|
21
|
-
python_pq-0.7.
|
|
22
|
-
python_pq-0.7.
|
|
23
|
-
python_pq-0.7.
|
|
24
|
-
python_pq-0.7.
|
|
20
|
+
pq/worker.py,sha256=7TK27Du3elX1kHozNRG5-BUn2qOfz_W9X4t1YJDo4t0,39846
|
|
21
|
+
python_pq-0.7.2.dist-info/licenses/LICENSE,sha256=Bx9dUtjCpaluWGalMqkI3SGkpPxx_AYdJdmV5ptCM4g,1062
|
|
22
|
+
python_pq-0.7.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
23
|
+
python_pq-0.7.2.dist-info/entry_points.txt,sha256=fpK4uA3gQaFpcDmdy5GFbP4VP7gaJcn4H4GJyMzRIM8,36
|
|
24
|
+
python_pq-0.7.2.dist-info/METADATA,sha256=PNIqmOY6EVbXSTvEF7Yd-c1IqWKh6NX1GgxIBaKWqoE,10141
|
|
25
|
+
python_pq-0.7.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|