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.
Files changed (24) hide show
  1. {python_pq-0.7.1 → python_pq-0.7.2}/PKG-INFO +1 -1
  2. {python_pq-0.7.1 → python_pq-0.7.2}/pyproject.toml +1 -1
  3. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/__init__.py +1 -1
  4. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/client.py +134 -15
  5. python_pq-0.7.2/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +50 -0
  6. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/models.py +20 -0
  7. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/worker.py +53 -6
  8. {python_pq-0.7.1 → python_pq-0.7.2}/LICENSE +0 -0
  9. {python_pq-0.7.1 → python_pq-0.7.2}/README.md +0 -0
  10. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/config.py +0 -0
  11. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/logging.py +0 -0
  12. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/README +0 -0
  13. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/__init__.py +0 -0
  14. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/env.py +0 -0
  15. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/script.py.mako +0 -0
  16. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  17. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  18. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  19. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  20. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  21. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/migrations/versions/__init__.py +0 -0
  22. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/priority.py +0 -0
  23. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/registry.py +0 -0
  24. {python_pq-0.7.1 → python_pq-0.7.2}/src/pq/serialization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-pq
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Postgres-backed job queue for Python
5
5
  Keywords: postgres,job-queue,task-queue,background-jobs,worker
6
6
  Author: ricwo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,7 +5,7 @@ from pq.models import Periodic, Task, TaskStatus
5
5
  from pq.priority import Priority
6
6
  from pq.worker import PostExecuteHook, PreExecuteHook, TaskTimeoutError
7
7
 
8
- __version__ = "0.7.1"
8
+ __version__ = "0.7.2"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -1,6 +1,7 @@
1
1
  """PQ client - main interface for task queue."""
2
2
 
3
3
  import importlib.resources
4
+ import math
4
5
  from collections.abc import Callable, Set
5
6
  from contextlib import contextmanager
6
7
  from datetime import UTC, datetime, timedelta
@@ -10,6 +11,7 @@ from typing import Any, Self
10
11
  from croniter import croniter
11
12
  from croniter.croniter import CroniterBadCronError
12
13
  from loguru import logger
14
+ import sqlalchemy as sa
13
15
  from sqlalchemy import create_engine, delete, func, select, update
14
16
  from sqlalchemy.dialects.postgresql import insert
15
17
  from sqlalchemy.engine import Engine
@@ -21,6 +23,45 @@ from pq.registry import get_function_path
21
23
  from pq.serialization import serialize
22
24
 
23
25
 
26
+ def _validate_max_runtime(max_runtime_seconds: float | None) -> None:
27
+ """Reject obviously-degenerate per-task ``max_runtime_seconds`` values.
28
+
29
+ ``None`` means "use the worker's configured default" and is the
30
+ common case — nothing to validate. ``0``, negative, NaN, and
31
+ infinity are all rejected because the downstream enforcement is
32
+ undefined for those:
33
+
34
+ - ``signal.alarm(int(max_runtime_seconds) + 1)`` would fire at 1 s for
35
+ ``0``, at 0 s (which disables the alarm entirely) for any
36
+ negative integer ≥ -1, and raise ``ValueError`` from inside
37
+ the worker for NaN / infinity.
38
+ - ``max_runtime_seconds * 2`` in the stale-reaper SQL would be ≤ 0 or
39
+ NaN, making the per-row threshold a no-op or NULL (the global
40
+ default would apply) — silent surprise vs. the call site's
41
+ intent.
42
+ - Periodic ``lock_duration`` already guards with a 3600 s
43
+ fallback for ``≤ 0`` (``worker.py``), but that fallback exists
44
+ for the worker-level default, not as a contract for callers.
45
+
46
+ None of these are useful behaviours to expose. Failing fast at
47
+ enqueue/upsert/schedule time keeps the bug at the call site,
48
+ where it's trivially fixable, instead of producing a confusing
49
+ runtime surprise hours later.
50
+ """
51
+ if max_runtime_seconds is None:
52
+ return
53
+ if math.isnan(max_runtime_seconds) or math.isinf(max_runtime_seconds):
54
+ raise ValueError(
55
+ f"max_runtime_seconds must be a finite positive number (got {max_runtime_seconds!r}); "
56
+ f"pass None to use the worker's configured default."
57
+ )
58
+ if max_runtime_seconds <= 0:
59
+ raise ValueError(
60
+ f"max_runtime_seconds must be > 0 (got {max_runtime_seconds!r}); pass None to "
61
+ f"use the worker's configured default."
62
+ )
63
+
64
+
24
65
  class PQ:
25
66
  """Postgres-backed task queue client."""
26
67
 
@@ -125,6 +166,7 @@ class PQ:
125
166
  run_at: datetime | None = None,
126
167
  priority: Priority = Priority.NORMAL,
127
168
  client_id: str | None = None,
169
+ max_runtime_seconds: float | None = None,
128
170
  **kwargs: Any,
129
171
  ) -> int:
130
172
  """Enqueue a one-off task.
@@ -135,15 +177,24 @@ class PQ:
135
177
  run_at: When to run the task. Defaults to now.
136
178
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
137
179
  client_id: Optional client-provided identifier. Must be unique if provided.
180
+ max_runtime_seconds: Per-task wall-clock cap in seconds. When ``None`` (the
181
+ common case), the worker that picks up this task uses its
182
+ configured default. When set, this specific task gets the
183
+ supplied ceiling AND the stale-reaper threshold scales with
184
+ it (``max_runtime_seconds * 2`` if larger than the reaper's global
185
+ default). Use this for occasionally-long tasks in a fleet
186
+ whose worker default is sized for typical short work.
138
187
  **kwargs: Keyword arguments to pass to the handler.
139
188
 
140
189
  Returns:
141
190
  Task ID.
142
191
 
143
192
  Raises:
144
- ValueError: If task is a lambda, closure, or cannot be imported.
193
+ ValueError: If task is a lambda, closure, or cannot be imported,
194
+ or if ``max_runtime_seconds`` is not None and ``<= 0``.
145
195
  IntegrityError: If client_id already exists.
146
196
  """
197
+ _validate_max_runtime(max_runtime_seconds)
147
198
  name = get_function_path(task)
148
199
  payload = serialize(args, kwargs)
149
200
 
@@ -156,6 +207,7 @@ class PQ:
156
207
  run_at=run_at,
157
208
  priority=priority,
158
209
  client_id=client_id,
210
+ max_runtime_seconds=max_runtime_seconds,
159
211
  )
160
212
 
161
213
  with self.session() as session:
@@ -170,6 +222,7 @@ class PQ:
170
222
  run_at: datetime | None = None,
171
223
  priority: Priority = Priority.NORMAL,
172
224
  client_id: str,
225
+ max_runtime_seconds: float | None = None,
173
226
  **kwargs: Any,
174
227
  ) -> int:
175
228
  """Enqueue a task, updating if client_id already exists.
@@ -183,14 +236,19 @@ class PQ:
183
236
  run_at: When to run the task. Defaults to now.
184
237
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
185
238
  client_id: Client-provided identifier. Required for conflict resolution.
239
+ max_runtime_seconds: Per-task wall-clock cap in seconds. See ``enqueue`` for
240
+ details. ``None`` (the default) leaves the worker's own
241
+ configured ``max_runtime_seconds`` in effect.
186
242
  **kwargs: Keyword arguments to pass to the handler.
187
243
 
188
244
  Returns:
189
245
  Task ID.
190
246
 
191
247
  Raises:
192
- ValueError: If task is a lambda, closure, or cannot be imported.
248
+ ValueError: If task is a lambda, closure, or cannot be imported,
249
+ or if ``max_runtime_seconds`` is not None and ``<= 0``.
193
250
  """
251
+ _validate_max_runtime(max_runtime_seconds)
194
252
  name = get_function_path(task)
195
253
  payload = serialize(args, kwargs)
196
254
 
@@ -206,6 +264,7 @@ class PQ:
206
264
  priority=priority,
207
265
  status=TaskStatus.PENDING,
208
266
  run_at=run_at,
267
+ max_runtime_seconds=max_runtime_seconds,
209
268
  )
210
269
  .on_conflict_do_update(
211
270
  index_elements=["client_id"],
@@ -215,6 +274,7 @@ class PQ:
215
274
  "priority": priority,
216
275
  "status": TaskStatus.PENDING,
217
276
  "run_at": run_at,
277
+ "max_runtime_seconds": max_runtime_seconds,
218
278
  "attempts": 0,
219
279
  "started_at": None,
220
280
  "completed_at": None,
@@ -239,6 +299,7 @@ class PQ:
239
299
  max_concurrent: int | None = 1,
240
300
  key: str = "",
241
301
  active: bool = True,
302
+ max_runtime_seconds: float | None = None,
242
303
  **kwargs: Any,
243
304
  ) -> int:
244
305
  """Schedule a periodic task.
@@ -260,6 +321,14 @@ class PQ:
260
321
  Defaults to "" (empty string).
261
322
  active: Whether the task is active. Inactive tasks are not executed.
262
323
  Defaults to True.
324
+ max_runtime_seconds: Per-execution wall-clock cap in seconds. When ``None``
325
+ (the common case), each execution uses the worker's configured
326
+ default. When set, this schedule's executions get the
327
+ supplied ceiling, and the ``locked_until`` window (used when
328
+ ``max_concurrent`` is in effect) is extended to match so the
329
+ lock doesn't expire while the execution is legitimately still
330
+ running. Periodic tasks are not subject to the stale-task
331
+ reaper, so this knob is purely about the wall-clock cap.
263
332
  **kwargs: Keyword arguments to pass to the handler.
264
333
 
265
334
  Returns:
@@ -270,6 +339,7 @@ class PQ:
270
339
  ValueError: If cron expression is invalid.
271
340
  ValueError: If max_concurrent is greater than 1.
272
341
  ValueError: If task is a lambda, closure, or cannot be imported.
342
+ ValueError: If ``max_runtime_seconds`` is not None and ``<= 0``.
273
343
  IntegrityError: If client_id already exists.
274
344
  """
275
345
  if run_every is None and cron is None:
@@ -281,6 +351,7 @@ class PQ:
281
351
  f"max_concurrent must be 1 or None, got {max_concurrent} "
282
352
  "(values > 1 reserved for future use)"
283
353
  )
354
+ _validate_max_runtime(max_runtime_seconds)
284
355
 
285
356
  # Validate and normalize cron expression
286
357
  cron_expr: str | None = None
@@ -321,6 +392,7 @@ class PQ:
321
392
  client_id=client_id,
322
393
  max_concurrent=max_concurrent,
323
394
  active=active,
395
+ max_runtime_seconds=max_runtime_seconds,
324
396
  )
325
397
  .on_conflict_do_update(
326
398
  index_elements=["name", "key"],
@@ -332,6 +404,7 @@ class PQ:
332
404
  "next_run": next_run,
333
405
  "max_concurrent": max_concurrent,
334
406
  "active": active,
407
+ "max_runtime_seconds": max_runtime_seconds,
335
408
  },
336
409
  )
337
410
  .returning(Periodic.id)
@@ -516,39 +589,85 @@ class PQ:
516
589
  process remains to update their status. This method detects those
517
590
  orphaned rows and transitions them to FAILED.
518
591
 
592
+ Tasks enqueued with a per-task ``max_runtime_seconds`` override get a
593
+ proportionally larger reaper window: the effective threshold for
594
+ a row is ``max(threshold, max_runtime_seconds * 2)``. This avoids
595
+ reaping a legitimately long-running task whose declared budget
596
+ exceeds the global default. NULL ``max_runtime_seconds`` (the
597
+ common case) falls back to the supplied ``threshold`` unchanged.
598
+
519
599
  Args:
520
- threshold: A task is considered stale when
521
- ``started_at + threshold < now()``. Should be at least
522
- 2x ``max_runtime`` to avoid reaping legitimately running
523
- tasks, e.g. ``timedelta(seconds=max_runtime * 2)``.
600
+ threshold: Default per-task stale window for tasks that don't
601
+ have a ``max_runtime_seconds`` override (or whose override
602
+ is smaller than this default). A task is stale when
603
+ ``started_at + effective_threshold < now()``. Should be
604
+ at least 2x the worker's own ``max_runtime_seconds``, e.g.
605
+ ``timedelta(seconds=max_runtime_seconds * 2)``.
524
606
 
525
607
  Returns:
526
608
  Number of tasks reaped.
527
609
  """
528
610
  now = datetime.now(UTC)
529
- cutoff = now - threshold
611
+ threshold_seconds = threshold.total_seconds()
612
+ # Per-row effective stale window: the larger of the supplied
613
+ # default and 2x the row's per-task override (NULL → just the
614
+ # default). Computed in SQL with ``GREATEST`` + ``COALESCE``
615
+ # so the reaper stays a single round-trip even when the row
616
+ # set is heterogeneous in declared budget.
617
+ effective_threshold_seconds = sa.func.greatest(
618
+ threshold_seconds,
619
+ sa.func.coalesce(Task.max_runtime_seconds * 2, threshold_seconds),
620
+ )
621
+ stale_cutoff = sa.func.now() - sa.func.make_interval(
622
+ sa.literal(0), # years
623
+ sa.literal(0), # months
624
+ sa.literal(0), # weeks
625
+ sa.literal(0), # days
626
+ sa.literal(0), # hours
627
+ sa.literal(0), # mins
628
+ effective_threshold_seconds, # secs
629
+ )
630
+ # Per-row ``error`` message includes the values that actually
631
+ # decided this row's fate: the row's own
632
+ # ``max_runtime_seconds`` (or ``NULL`` if unset) and the
633
+ # effective threshold the reaper applied. This avoids the
634
+ # operator needing a separate query to figure out "why did MY
635
+ # long-budget task get reaped?" — the answer is in the row.
636
+ # Built with Postgres ``format()`` so the message is computed
637
+ # per row in the same UPDATE round-trip.
638
+ per_row_error = sa.func.format(
639
+ "Reaped: task still RUNNING past its stale window "
640
+ "(default threshold=%s s, per-task max_runtime_seconds=%s, "
641
+ "effective threshold=%s s). Worker likely died.",
642
+ threshold_seconds,
643
+ sa.func.coalesce(sa.cast(Task.max_runtime_seconds, sa.String), "NULL"),
644
+ effective_threshold_seconds,
645
+ )
530
646
  with self.session() as session:
531
647
  stmt = (
532
648
  update(Task)
533
649
  .where(
534
650
  Task.status == TaskStatus.RUNNING,
535
- Task.started_at < cutoff,
651
+ Task.started_at < stale_cutoff,
536
652
  )
537
653
  .values(
538
654
  status=TaskStatus.FAILED,
539
655
  completed_at=now,
540
- error=(
541
- f"Reaped: task still RUNNING after {threshold} "
542
- f"(cutoff={cutoff.isoformat()}). Worker likely died."
543
- ),
656
+ error=per_row_error,
657
+ )
658
+ .returning(
659
+ Task.id,
660
+ Task.name,
661
+ Task.started_at,
662
+ Task.max_runtime_seconds,
544
663
  )
545
- .returning(Task.id, Task.name, Task.started_at)
546
664
  )
547
665
  reaped = list(session.execute(stmt).all())
548
- for task_id, name, started_at in reaped:
666
+ for task_id, name, started_at, max_runtime_seconds in reaped:
549
667
  logger.warning(
550
668
  f"Reaped stale task '{name}' (id={task_id},"
551
- f" started_at={started_at})"
669
+ f" started_at={started_at},"
670
+ f" max_runtime_seconds={max_runtime_seconds})"
552
671
  )
553
672
  return len(reaped)
554
673
 
@@ -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=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 = max_runtime if max_runtime > 0 else 3600
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=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=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 = max_runtime if max_runtime > 0 else 3600
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=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