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 +1 -1
- pq/client.py +46 -1
- pq/worker.py +91 -15
- {python_pq-0.7.0.dist-info → python_pq-0.7.1.dist-info}/METADATA +1 -1
- {python_pq-0.7.0.dist-info → python_pq-0.7.1.dist-info}/RECORD +8 -8
- {python_pq-0.7.0.dist-info → python_pq-0.7.1.dist-info}/WHEEL +0 -0
- {python_pq-0.7.0.dist-info → python_pq-0.7.1.dist-info}/entry_points.txt +0 -0
- {python_pq-0.7.0.dist-info → python_pq-0.7.1.dist-info}/licenses/LICENSE +0 -0
pq/__init__.py
CHANGED
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
|
|
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]
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
#
|
|
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,5 +1,5 @@
|
|
|
1
|
-
pq/__init__.py,sha256=
|
|
2
|
-
pq/client.py,sha256=
|
|
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=
|
|
20
|
-
python_pq-0.7.
|
|
21
|
-
python_pq-0.7.
|
|
22
|
-
python_pq-0.7.
|
|
23
|
-
python_pq-0.7.
|
|
24
|
-
python_pq-0.7.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|