python-pq 0.7.0__py3-none-any.whl → 0.7.1__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 CHANGED
@@ -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.1"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
pq/client.py CHANGED
@@ -9,7 +9,8 @@ from typing import Any, Self
9
9
 
10
10
  from croniter import croniter
11
11
  from croniter.croniter import CroniterBadCronError
12
- from sqlalchemy import create_engine, delete, func, select
12
+ from loguru import logger
13
+ from sqlalchemy import create_engine, delete, func, select, update
13
14
  from sqlalchemy.dialects.postgresql import insert
14
15
  from sqlalchemy.engine import Engine
15
16
  from sqlalchemy.orm import sessionmaker
@@ -507,6 +508,50 @@ class PQ:
507
508
  result = session.execute(stmt)
508
509
  return result.rowcount
509
510
 
511
+ def reap_stale_tasks(self, threshold: timedelta) -> int:
512
+ """Mark stale RUNNING tasks as FAILED.
513
+
514
+ When a worker dies mid-execution (e.g. pod restart, OOM on the worker
515
+ process), in-flight tasks stay RUNNING forever because no parent
516
+ process remains to update their status. This method detects those
517
+ orphaned rows and transitions them to FAILED.
518
+
519
+ 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)``.
524
+
525
+ Returns:
526
+ Number of tasks reaped.
527
+ """
528
+ now = datetime.now(UTC)
529
+ cutoff = now - threshold
530
+ with self.session() as session:
531
+ stmt = (
532
+ update(Task)
533
+ .where(
534
+ Task.status == TaskStatus.RUNNING,
535
+ Task.started_at < cutoff,
536
+ )
537
+ .values(
538
+ status=TaskStatus.FAILED,
539
+ completed_at=now,
540
+ error=(
541
+ f"Reaped: task still RUNNING after {threshold} "
542
+ f"(cutoff={cutoff.isoformat()}). Worker likely died."
543
+ ),
544
+ )
545
+ .returning(Task.id, Task.name, Task.started_at)
546
+ )
547
+ reaped = list(session.execute(stmt).all())
548
+ for task_id, name, started_at in reaped:
549
+ logger.warning(
550
+ f"Reaped stale task '{name}' (id={task_id},"
551
+ f" started_at={started_at})"
552
+ )
553
+ return len(reaped)
554
+
510
555
  def run_worker(
511
556
  self,
512
557
  *,
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] # 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.")
@@ -595,15 +641,26 @@ def _process_one_off_task(
595
641
 
596
642
  elapsed = time.perf_counter() - start
597
643
 
598
- # Phase 3: Update task status
644
+ # Phase 3: Update task status (guarded — only if still RUNNING, so we
645
+ # don't overwrite a reaper's FAILED verdict for an orphaned task)
599
646
  try:
600
647
  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
648
+ values: dict[str, object] = {
649
+ "status": status,
650
+ "completed_at": datetime.now(UTC),
651
+ }
652
+ if error_msg:
653
+ values["error"] = error_msg
654
+ result = session.execute(
655
+ update(Task)
656
+ .where(Task.id == task_id, Task.status == TaskStatus.RUNNING)
657
+ .values(**values)
658
+ )
659
+ if result.rowcount == 0:
660
+ logger.warning(
661
+ f"Task '{name}' (id={task_id}) was no longer RUNNING"
662
+ " when Phase 3 tried to update — likely reaped"
663
+ )
607
664
  except Exception as e:
608
665
  logger.error(f"Error updating task status: {e}")
609
666
 
@@ -1016,12 +1073,25 @@ def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
1016
1073
 
1017
1074
  try:
1018
1075
  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
1076
+ values: dict[str, object] = {
1077
+ "status": result.task_status,
1078
+ "completed_at": datetime.now(UTC),
1079
+ }
1080
+ if error_msg:
1081
+ values["error"] = error_msg
1082
+ row_result = session.execute(
1083
+ update(Task)
1084
+ .where(
1085
+ Task.id == slot.task_id,
1086
+ Task.status == TaskStatus.RUNNING,
1087
+ )
1088
+ .values(**values)
1089
+ )
1090
+ if row_result.rowcount == 0:
1091
+ logger.warning(
1092
+ f"Task '{slot.name}' (id={slot.task_id}) was no longer"
1093
+ " RUNNING when reap tried to update — likely reaped"
1094
+ )
1025
1095
  except Exception as e:
1026
1096
  logger.error(f"Error updating task status: {e}")
1027
1097
 
@@ -1044,6 +1114,7 @@ def _run_concurrent(
1044
1114
  post_execute: PostExecuteHook | None,
1045
1115
  retention_days: int,
1046
1116
  cleanup_interval: float,
1117
+ stale_task_timeout: timedelta | None = None,
1047
1118
  ) -> None:
1048
1119
  """Run the concurrent worker loop.
1049
1120
 
@@ -1060,10 +1131,12 @@ def _run_concurrent(
1060
1131
  post_execute: Called in forked child AFTER task execution.
1061
1132
  retention_days: Days to keep completed/failed tasks.
1062
1133
  cleanup_interval: Seconds between cleanup runs.
1134
+ stale_task_timeout: If set, reap RUNNING tasks older than this.
1063
1135
  """
1064
1136
  children: dict[int, _ChildSlot] = {} # pid -> slot
1065
1137
  fd_to_pid: dict[int, int] = {} # read_fd -> pid
1066
1138
  last_cleanup: list[float] = [0.0]
1139
+ last_reap: list[float] = [0.0]
1067
1140
 
1068
1141
  try:
1069
1142
  while True:
@@ -1092,8 +1165,11 @@ def _run_concurrent(
1092
1165
  else:
1093
1166
  time.sleep(poll_interval)
1094
1167
 
1095
- # Cleanup runs on every iteration (rate-limited internally)
1168
+ # Maintenance (each rate-limited independently)
1096
1169
  _maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
1170
+ _maybe_reap_stale(
1171
+ pq, stale_task_timeout, DEFAULT_REAPER_INTERVAL, last_reap
1172
+ )
1097
1173
 
1098
1174
  except KeyboardInterrupt:
1099
1175
  if children:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-pq
3
- Version: 0.7.0
3
+ Version: 0.7.1
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,5 +1,5 @@
1
- pq/__init__.py,sha256=MzrVLKualSjzTg3nVlpt9ooWEe-g8Vpc65H0zzorkBU,398
2
- pq/client.py,sha256=zZhcjq9dEHpB3elxJQDqylIhNlVY8BlUWc74YqyLkX8,19184
1
+ pq/__init__.py,sha256=ztUW0nM6NoSSObEXmy-TF13ViO9jEptPDGtyDj2KS0o,398
2
+ pq/client.py,sha256=v5pTOT78EYEUUxlPjdQ1snNkrOe0rMUV2Htfdnw4nRs,20963
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
@@ -16,9 +16,9 @@ pq/models.py,sha256=yoDf64MOLDzICF52AWI-oyTAIWj4hUWfR-ytTKehnlE,3659
16
16
  pq/priority.py,sha256=edqpQeGiJrxcmpJ5MZCvwTD9F6sIZbRoI1mx_LmUxAs,264
17
17
  pq/registry.py,sha256=1RFfB-dXXM2qKwLF6XeYeUm9buca1_fQvKQDnpfsXKs,2306
18
18
  pq/serialization.py,sha256=ZspG9qO4YYi-xCrtPGCg8yrzqRI2CGYnO_Ewjh8lE1E,3291
19
- pq/worker.py,sha256=Jf68AbTGCIo8vyAnEygd8Pakc2O-Pug9bd3SE3BaJX0,34664
20
- python_pq-0.7.0.dist-info/licenses/LICENSE,sha256=Bx9dUtjCpaluWGalMqkI3SGkpPxx_AYdJdmV5ptCM4g,1062
21
- python_pq-0.7.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
22
- python_pq-0.7.0.dist-info/entry_points.txt,sha256=fpK4uA3gQaFpcDmdy5GFbP4VP7gaJcn4H4GJyMzRIM8,36
23
- python_pq-0.7.0.dist-info/METADATA,sha256=gk8x6hnGYVd7-k3tP1K6eQDsXfc7tldi7IcD8xCSc2Q,10141
24
- python_pq-0.7.0.dist-info/RECORD,,
19
+ pq/worker.py,sha256=7mW_IUJ7XV4-K4d2vjupYIY8OIKsbqDYTPTNXnid5JE,37530
20
+ python_pq-0.7.1.dist-info/licenses/LICENSE,sha256=Bx9dUtjCpaluWGalMqkI3SGkpPxx_AYdJdmV5ptCM4g,1062
21
+ python_pq-0.7.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
22
+ python_pq-0.7.1.dist-info/entry_points.txt,sha256=fpK4uA3gQaFpcDmdy5GFbP4VP7gaJcn4H4GJyMzRIM8,36
23
+ python_pq-0.7.1.dist-info/METADATA,sha256=yAXvazs4U_psfbbkwiEvGzATksM-bW-BN2Ol_nJPEnA,10141
24
+ python_pq-0.7.1.dist-info/RECORD,,