python-pq 0.7.0__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.0 → python_pq-0.7.2}/PKG-INFO +1 -1
  2. {python_pq-0.7.0 → python_pq-0.7.2}/pyproject.toml +1 -1
  3. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/__init__.py +1 -1
  4. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/client.py +167 -3
  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.0 → python_pq-0.7.2}/src/pq/models.py +20 -0
  7. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/worker.py +144 -21
  8. {python_pq-0.7.0 → python_pq-0.7.2}/LICENSE +0 -0
  9. {python_pq-0.7.0 → python_pq-0.7.2}/README.md +0 -0
  10. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/config.py +0 -0
  11. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/logging.py +0 -0
  12. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/README +0 -0
  13. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/__init__.py +0 -0
  14. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/env.py +0 -0
  15. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/script.py.mako +0 -0
  16. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  17. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  18. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  19. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  20. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  21. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/migrations/versions/__init__.py +0 -0
  22. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/priority.py +0 -0
  23. {python_pq-0.7.0 → python_pq-0.7.2}/src/pq/registry.py +0 -0
  24. {python_pq-0.7.0 → 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.0
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.0"
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.0"
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
@@ -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 sqlalchemy import create_engine, delete, func, select
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")
@@ -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
  )
@@ -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] # Mutable container for tracking
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=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
- task = session.get(Task, task_id)
602
- if task:
603
- task.status = status
604
- task.completed_at = datetime.now(UTC)
605
- if error_msg:
606
- task.error = error_msg
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 = max_runtime if max_runtime > 0 else 3600
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=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=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 = max_runtime if max_runtime > 0 else 3600
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=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
- task = session.get(Task, slot.task_id)
1020
- if task:
1021
- task.status = result.task_status
1022
- task.completed_at = datetime.now(UTC)
1023
- if error_msg:
1024
- task.error = error_msg
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
- # Cleanup runs on every iteration (rate-limited internally)
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:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes