python-pq 0.7.1__tar.gz → 0.7.2__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.7.2}/PKG-INFO +1 -1
- {python_pq-0.7.1 → python_pq-0.7.2}/pyproject.toml +1 -1
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/__init__.py +1 -1
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/client.py +134 -15
- python_pq-0.7.2/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +50 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/models.py +20 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/worker.py +53 -6
- {python_pq-0.7.1 → python_pq-0.7.2}/LICENSE +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/README.md +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/config.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/logging.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/README +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/env.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/priority.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/registry.py +0 -0
- {python_pq-0.7.1 → python_pq-0.7.2}/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
|
|
|
@@ -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
|
)
|
|
@@ -596,6 +596,10 @@ def _process_one_off_task(
|
|
|
596
596
|
name = task.name
|
|
597
597
|
payload = task.payload
|
|
598
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
|
|
599
603
|
|
|
600
604
|
# Flush to commit status changes, then expunge for forked process
|
|
601
605
|
session.flush()
|
|
@@ -605,6 +609,14 @@ def _process_one_off_task(
|
|
|
605
609
|
logger.error(f"Error claiming task: {e}")
|
|
606
610
|
return False
|
|
607
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
|
+
|
|
608
620
|
# Phase 2: Execute handler in forked process
|
|
609
621
|
start = time.perf_counter()
|
|
610
622
|
status = TaskStatus.COMPLETED
|
|
@@ -617,7 +629,7 @@ def _process_one_off_task(
|
|
|
617
629
|
handler,
|
|
618
630
|
args,
|
|
619
631
|
kwargs,
|
|
620
|
-
max_runtime=
|
|
632
|
+
max_runtime=effective_max_runtime,
|
|
621
633
|
task=task,
|
|
622
634
|
pre_execute=pre_execute,
|
|
623
635
|
post_execute=post_execute,
|
|
@@ -739,10 +751,27 @@ def _process_periodic_task(
|
|
|
739
751
|
payload = periodic.payload
|
|
740
752
|
periodic_id = periodic.id
|
|
741
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
|
+
)
|
|
742
769
|
|
|
743
770
|
# Set lock before execution if concurrency is limited
|
|
744
771
|
if periodic.max_concurrent is not None:
|
|
745
|
-
lock_duration =
|
|
772
|
+
lock_duration = (
|
|
773
|
+
effective_max_runtime if effective_max_runtime > 0 else 3600
|
|
774
|
+
)
|
|
746
775
|
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
747
776
|
|
|
748
777
|
# Advance schedule BEFORE execution
|
|
@@ -769,7 +798,7 @@ def _process_periodic_task(
|
|
|
769
798
|
handler,
|
|
770
799
|
args,
|
|
771
800
|
kwargs,
|
|
772
|
-
max_runtime=
|
|
801
|
+
max_runtime=effective_max_runtime,
|
|
773
802
|
task=periodic,
|
|
774
803
|
pre_execute=pre_execute,
|
|
775
804
|
post_execute=post_execute,
|
|
@@ -851,6 +880,9 @@ def _claim_and_fork_one_off(
|
|
|
851
880
|
name = task.name
|
|
852
881
|
payload = task.payload
|
|
853
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
|
|
854
886
|
|
|
855
887
|
session.flush()
|
|
856
888
|
session.expunge(task)
|
|
@@ -859,6 +891,10 @@ def _claim_and_fork_one_off(
|
|
|
859
891
|
logger.error(f"Error claiming task: {e}")
|
|
860
892
|
return None
|
|
861
893
|
|
|
894
|
+
effective_max_runtime = (
|
|
895
|
+
task_max_runtime if task_max_runtime is not None else max_runtime
|
|
896
|
+
)
|
|
897
|
+
|
|
862
898
|
try:
|
|
863
899
|
handler = resolve_function_path(name)
|
|
864
900
|
args, kwargs = deserialize(payload)
|
|
@@ -866,7 +902,7 @@ def _claim_and_fork_one_off(
|
|
|
866
902
|
handler,
|
|
867
903
|
args,
|
|
868
904
|
kwargs,
|
|
869
|
-
max_runtime=
|
|
905
|
+
max_runtime=effective_max_runtime,
|
|
870
906
|
task=task,
|
|
871
907
|
pre_execute=pre_execute,
|
|
872
908
|
post_execute=post_execute,
|
|
@@ -934,9 +970,20 @@ def _claim_and_fork_periodic(
|
|
|
934
970
|
payload = periodic.payload
|
|
935
971
|
periodic_id = periodic.id
|
|
936
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
|
+
)
|
|
937
982
|
|
|
938
983
|
if periodic.max_concurrent is not None:
|
|
939
|
-
lock_duration =
|
|
984
|
+
lock_duration = (
|
|
985
|
+
effective_max_runtime if effective_max_runtime > 0 else 3600
|
|
986
|
+
)
|
|
940
987
|
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
941
988
|
|
|
942
989
|
periodic.last_run = func.now()
|
|
@@ -959,7 +1006,7 @@ def _claim_and_fork_periodic(
|
|
|
959
1006
|
handler,
|
|
960
1007
|
args,
|
|
961
1008
|
kwargs,
|
|
962
|
-
max_runtime=
|
|
1009
|
+
max_runtime=effective_max_runtime,
|
|
963
1010
|
task=periodic,
|
|
964
1011
|
pre_execute=pre_execute,
|
|
965
1012
|
post_execute=post_execute,
|
|
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
|