python-pq 0.6.3__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 +50 -1
- pq/worker.py +606 -80
- {python_pq-0.6.3.dist-info → python_pq-0.7.1.dist-info}/METADATA +1 -1
- {python_pq-0.6.3.dist-info → python_pq-0.7.1.dist-info}/RECORD +8 -8
- {python_pq-0.6.3.dist-info → python_pq-0.7.1.dist-info}/WHEEL +0 -0
- {python_pq-0.6.3.dist-info → python_pq-0.7.1.dist-info}/entry_points.txt +0 -0
- {python_pq-0.6.3.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,9 +508,54 @@ 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
|
*,
|
|
558
|
+
concurrency: int = 1,
|
|
513
559
|
poll_interval: float = 1.0,
|
|
514
560
|
max_runtime: float = 30 * 60,
|
|
515
561
|
priorities: Set[Priority] | None = None,
|
|
@@ -519,6 +565,8 @@ class PQ:
|
|
|
519
565
|
Each task executes in a forked child process for memory isolation.
|
|
520
566
|
|
|
521
567
|
Args:
|
|
568
|
+
concurrency: Maximum number of tasks to process simultaneously.
|
|
569
|
+
Default: 1 (sequential processing).
|
|
522
570
|
poll_interval: Seconds to sleep between polls when idle.
|
|
523
571
|
max_runtime: Maximum execution time per task in seconds. Default: 30 min.
|
|
524
572
|
priorities: If set, only process tasks with these priority levels.
|
|
@@ -528,6 +576,7 @@ class PQ:
|
|
|
528
576
|
|
|
529
577
|
run_worker(
|
|
530
578
|
self,
|
|
579
|
+
concurrency=concurrency,
|
|
531
580
|
poll_interval=poll_interval,
|
|
532
581
|
max_runtime=max_runtime,
|
|
533
582
|
priorities=priorities,
|
pq/worker.py
CHANGED
|
@@ -9,12 +9,14 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import inspect
|
|
11
11
|
import os
|
|
12
|
+
import select as select_module
|
|
12
13
|
import signal
|
|
13
14
|
import sys
|
|
14
15
|
import time
|
|
15
16
|
import traceback
|
|
17
|
+
from dataclasses import dataclass
|
|
16
18
|
from datetime import UTC, datetime, timedelta
|
|
17
|
-
from typing import TYPE_CHECKING, Any, Protocol
|
|
19
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
|
|
18
20
|
|
|
19
21
|
from croniter import croniter
|
|
20
22
|
from loguru import logger
|
|
@@ -73,12 +75,41 @@ DEFAULT_RETENTION_DAYS: int = 7
|
|
|
73
75
|
# Default cleanup interval: 1 hour
|
|
74
76
|
DEFAULT_CLEANUP_INTERVAL: float = 3600
|
|
75
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
|
+
|
|
76
86
|
# Exit codes for child process
|
|
77
87
|
EXIT_SUCCESS = 0
|
|
78
88
|
EXIT_FAILURE = 1
|
|
79
89
|
EXIT_TIMEOUT = 124 # Like GNU timeout
|
|
80
90
|
|
|
81
91
|
|
|
92
|
+
class _ChildResult(NamedTuple):
|
|
93
|
+
"""Result from waiting for a forked child process."""
|
|
94
|
+
|
|
95
|
+
task_status: TaskStatus
|
|
96
|
+
error_msg: str | None
|
|
97
|
+
exit_kind: str # "success", "timeout", "oom", "killed", "error"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class _ChildSlot:
|
|
102
|
+
"""Tracks a running forked child in concurrent mode."""
|
|
103
|
+
|
|
104
|
+
pid: int
|
|
105
|
+
read_fd: int
|
|
106
|
+
task_id: int
|
|
107
|
+
name: str
|
|
108
|
+
start_time: float
|
|
109
|
+
is_periodic: bool
|
|
110
|
+
periodic_max_concurrent: int | None = None
|
|
111
|
+
|
|
112
|
+
|
|
82
113
|
class WorkerError(Exception):
|
|
83
114
|
"""Base class for worker errors."""
|
|
84
115
|
|
|
@@ -169,6 +200,107 @@ def _run_in_child(
|
|
|
169
200
|
os._exit(EXIT_FAILURE)
|
|
170
201
|
|
|
171
202
|
|
|
203
|
+
def _fork_child(
|
|
204
|
+
handler: Callable[..., Any],
|
|
205
|
+
args: tuple[Any, ...],
|
|
206
|
+
kwargs: dict[str, Any],
|
|
207
|
+
*,
|
|
208
|
+
max_runtime: float,
|
|
209
|
+
task: Task | Periodic,
|
|
210
|
+
pre_execute: PreExecuteHook | None = None,
|
|
211
|
+
post_execute: PostExecuteHook | None = None,
|
|
212
|
+
) -> tuple[int, int]:
|
|
213
|
+
"""Fork a child process to execute the handler.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Tuple of (child_pid, read_fd) for monitoring the child.
|
|
217
|
+
"""
|
|
218
|
+
read_fd, write_fd = os.pipe()
|
|
219
|
+
child_pid = os.fork()
|
|
220
|
+
|
|
221
|
+
if child_pid == 0:
|
|
222
|
+
# === CHILD PROCESS ===
|
|
223
|
+
os.close(read_fd)
|
|
224
|
+
_run_in_child(
|
|
225
|
+
handler,
|
|
226
|
+
args,
|
|
227
|
+
kwargs,
|
|
228
|
+
max_runtime,
|
|
229
|
+
write_fd,
|
|
230
|
+
task,
|
|
231
|
+
pre_execute,
|
|
232
|
+
post_execute,
|
|
233
|
+
)
|
|
234
|
+
os._exit(EXIT_FAILURE) # _run_in_child never returns, but just in case
|
|
235
|
+
|
|
236
|
+
# === PARENT PROCESS ===
|
|
237
|
+
os.close(write_fd)
|
|
238
|
+
return child_pid, read_fd
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
242
|
+
"""Wait for a forked child process and return the result.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
child_pid: PID of the child process.
|
|
246
|
+
read_fd: Read end of the error pipe.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
_ChildResult with task status, error message, and exit kind.
|
|
250
|
+
"""
|
|
251
|
+
_, raw_status, rusage = os.wait4(child_pid, 0)
|
|
252
|
+
|
|
253
|
+
# Read error message from pipe
|
|
254
|
+
error_bytes = b""
|
|
255
|
+
try:
|
|
256
|
+
while True:
|
|
257
|
+
chunk = os.read(read_fd, 4096)
|
|
258
|
+
if not chunk:
|
|
259
|
+
break
|
|
260
|
+
error_bytes += chunk
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
finally:
|
|
264
|
+
os.close(read_fd)
|
|
265
|
+
|
|
266
|
+
error_msg = error_bytes.decode("utf-8", errors="replace") if error_bytes else ""
|
|
267
|
+
|
|
268
|
+
if os.WIFSIGNALED(raw_status):
|
|
269
|
+
signal_num = os.WTERMSIG(raw_status)
|
|
270
|
+
if signal_num == signal.SIGKILL:
|
|
271
|
+
max_rss_kb = rusage.ru_maxrss
|
|
272
|
+
if sys.platform == "darwin":
|
|
273
|
+
max_rss_kb = max_rss_kb // 1024
|
|
274
|
+
return _ChildResult(
|
|
275
|
+
TaskStatus.FAILED,
|
|
276
|
+
f"Task killed (likely OOM). Max RSS: {max_rss_kb} KB",
|
|
277
|
+
"oom",
|
|
278
|
+
)
|
|
279
|
+
return _ChildResult(
|
|
280
|
+
TaskStatus.FAILED,
|
|
281
|
+
f"Task killed by signal {signal_num}",
|
|
282
|
+
"killed",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if os.WIFEXITED(raw_status):
|
|
286
|
+
exit_code = os.WEXITSTATUS(raw_status)
|
|
287
|
+
if exit_code == EXIT_SUCCESS:
|
|
288
|
+
return _ChildResult(TaskStatus.COMPLETED, None, "success")
|
|
289
|
+
if exit_code == EXIT_TIMEOUT:
|
|
290
|
+
return _ChildResult(
|
|
291
|
+
TaskStatus.FAILED, "Task exceeded max runtime", "timeout"
|
|
292
|
+
)
|
|
293
|
+
if error_msg:
|
|
294
|
+
return _ChildResult(TaskStatus.FAILED, error_msg.rstrip(), "error")
|
|
295
|
+
return _ChildResult(
|
|
296
|
+
TaskStatus.FAILED,
|
|
297
|
+
f"Task failed with exit code {exit_code}",
|
|
298
|
+
"error",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return _ChildResult(TaskStatus.FAILED, "Unknown child exit status", "error")
|
|
302
|
+
|
|
303
|
+
|
|
172
304
|
def _execute_in_fork(
|
|
173
305
|
handler: Callable[..., Any],
|
|
174
306
|
args: tuple[Any, ...],
|
|
@@ -200,76 +332,26 @@ def _execute_in_fork(
|
|
|
200
332
|
TaskKilledError: If task is killed by another signal.
|
|
201
333
|
Exception: If task raises an exception.
|
|
202
334
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
args,
|
|
214
|
-
kwargs,
|
|
215
|
-
max_runtime,
|
|
216
|
-
write_fd,
|
|
217
|
-
task,
|
|
218
|
-
pre_execute,
|
|
219
|
-
post_execute,
|
|
220
|
-
)
|
|
221
|
-
# _run_in_child never returns, but just in case:
|
|
222
|
-
os._exit(EXIT_FAILURE)
|
|
223
|
-
|
|
224
|
-
else:
|
|
225
|
-
# === PARENT PROCESS ===
|
|
226
|
-
os.close(write_fd)
|
|
227
|
-
|
|
228
|
-
# Wait for child to finish
|
|
229
|
-
_, status, rusage = os.wait4(child_pid, 0)
|
|
335
|
+
child_pid, read_fd = _fork_child(
|
|
336
|
+
handler,
|
|
337
|
+
args,
|
|
338
|
+
kwargs,
|
|
339
|
+
max_runtime=max_runtime,
|
|
340
|
+
task=task,
|
|
341
|
+
pre_execute=pre_execute,
|
|
342
|
+
post_execute=post_execute,
|
|
343
|
+
)
|
|
344
|
+
result = _wait_for_child(child_pid, read_fd)
|
|
230
345
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
pass
|
|
241
|
-
finally:
|
|
242
|
-
os.close(read_fd)
|
|
243
|
-
|
|
244
|
-
error_msg = error_bytes.decode("utf-8", errors="replace") if error_bytes else ""
|
|
245
|
-
|
|
246
|
-
# Check how child exited
|
|
247
|
-
if os.WIFSIGNALED(status):
|
|
248
|
-
signal_num = os.WTERMSIG(status)
|
|
249
|
-
if signal_num == signal.SIGKILL:
|
|
250
|
-
# SIGKILL (9) often means OOM killer
|
|
251
|
-
# ru_maxrss is in KB on Linux, bytes on macOS
|
|
252
|
-
max_rss_kb = rusage.ru_maxrss
|
|
253
|
-
if sys.platform == "darwin":
|
|
254
|
-
max_rss_kb = max_rss_kb // 1024
|
|
255
|
-
raise TaskOOMError(
|
|
256
|
-
f"Task killed (likely OOM). Max RSS: {max_rss_kb} KB"
|
|
257
|
-
)
|
|
258
|
-
else:
|
|
259
|
-
raise TaskKilledError(f"Task killed by signal {signal_num}")
|
|
260
|
-
|
|
261
|
-
elif os.WIFEXITED(status):
|
|
262
|
-
exit_code = os.WEXITSTATUS(status)
|
|
263
|
-
if exit_code == EXIT_SUCCESS:
|
|
264
|
-
return # Success!
|
|
265
|
-
elif exit_code == EXIT_TIMEOUT:
|
|
266
|
-
raise TaskTimeoutError("Task exceeded max runtime")
|
|
267
|
-
else:
|
|
268
|
-
# Task raised an exception
|
|
269
|
-
if error_msg:
|
|
270
|
-
raise Exception(error_msg.split("\n")[0]) # First line
|
|
271
|
-
else:
|
|
272
|
-
raise Exception(f"Task failed with exit code {exit_code}")
|
|
346
|
+
if result.exit_kind == "success":
|
|
347
|
+
return
|
|
348
|
+
if result.exit_kind == "timeout":
|
|
349
|
+
raise TaskTimeoutError(result.error_msg or "Task exceeded max runtime")
|
|
350
|
+
if result.exit_kind == "oom":
|
|
351
|
+
raise TaskOOMError(result.error_msg or "Task killed (likely OOM)")
|
|
352
|
+
if result.exit_kind == "killed":
|
|
353
|
+
raise TaskKilledError(result.error_msg or "Task killed by signal")
|
|
354
|
+
raise Exception(result.error_msg or "Task failed")
|
|
273
355
|
|
|
274
356
|
|
|
275
357
|
def _maybe_run_cleanup(
|
|
@@ -303,14 +385,45 @@ def _maybe_run_cleanup(
|
|
|
303
385
|
last_cleanup[0] = now
|
|
304
386
|
|
|
305
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
|
+
|
|
306
417
|
def run_worker(
|
|
307
418
|
pq: PQ,
|
|
308
419
|
*,
|
|
420
|
+
concurrency: int = 1,
|
|
309
421
|
poll_interval: float = 1.0,
|
|
310
422
|
max_runtime: float = DEFAULT_MAX_RUNTIME,
|
|
311
423
|
priorities: Set[Priority] | None = None,
|
|
312
424
|
retention_days: int = DEFAULT_RETENTION_DAYS,
|
|
313
425
|
cleanup_interval: float = DEFAULT_CLEANUP_INTERVAL,
|
|
426
|
+
stale_task_timeout: timedelta | None = DEFAULT_STALE_TASK_TIMEOUT,
|
|
314
427
|
pre_execute: PreExecuteHook | None = None,
|
|
315
428
|
post_execute: PostExecuteHook | None = None,
|
|
316
429
|
) -> None:
|
|
@@ -320,6 +433,8 @@ def run_worker(
|
|
|
320
433
|
|
|
321
434
|
Args:
|
|
322
435
|
pq: PQ client instance.
|
|
436
|
+
concurrency: Maximum number of tasks to process simultaneously.
|
|
437
|
+
Default: 1 (sequential processing).
|
|
323
438
|
poll_interval: Seconds to sleep between polls when idle.
|
|
324
439
|
max_runtime: Maximum execution time per task in seconds. Default: 30 min.
|
|
325
440
|
priorities: If set, only process tasks with these priority levels.
|
|
@@ -327,6 +442,9 @@ def run_worker(
|
|
|
327
442
|
retention_days: Days to keep completed/failed tasks. Default: 7.
|
|
328
443
|
Set to 0 to disable automatic cleanup.
|
|
329
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.
|
|
330
448
|
pre_execute: Called in forked child BEFORE task execution.
|
|
331
449
|
Use for initializing fork-unsafe resources (OTel, DB connections).
|
|
332
450
|
post_execute: Called in forked child AFTER task execution (success or failure).
|
|
@@ -334,11 +452,33 @@ def run_worker(
|
|
|
334
452
|
"""
|
|
335
453
|
if priorities:
|
|
336
454
|
priority_names = ", ".join(p.name for p in sorted(priorities, reverse=True))
|
|
337
|
-
logger.info(
|
|
455
|
+
logger.info(
|
|
456
|
+
f"Starting PQ worker (priorities: {priority_names},"
|
|
457
|
+
f" concurrency: {concurrency})..."
|
|
458
|
+
)
|
|
338
459
|
else:
|
|
339
|
-
logger.info(
|
|
460
|
+
logger.info(
|
|
461
|
+
f"Starting PQ worker (concurrency: {concurrency},"
|
|
462
|
+
" fork isolation enabled)..."
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if concurrency > 1:
|
|
466
|
+
_run_concurrent(
|
|
467
|
+
pq,
|
|
468
|
+
concurrency=concurrency,
|
|
469
|
+
poll_interval=poll_interval,
|
|
470
|
+
max_runtime=max_runtime,
|
|
471
|
+
priorities=priorities,
|
|
472
|
+
pre_execute=pre_execute,
|
|
473
|
+
post_execute=post_execute,
|
|
474
|
+
retention_days=retention_days,
|
|
475
|
+
cleanup_interval=cleanup_interval,
|
|
476
|
+
stale_task_timeout=stale_task_timeout,
|
|
477
|
+
)
|
|
478
|
+
return
|
|
340
479
|
|
|
341
|
-
last_cleanup: list[float] = [0.0]
|
|
480
|
+
last_cleanup: list[float] = [0.0]
|
|
481
|
+
last_reap: list[float] = [0.0]
|
|
342
482
|
|
|
343
483
|
try:
|
|
344
484
|
while True:
|
|
@@ -350,6 +490,9 @@ def run_worker(
|
|
|
350
490
|
post_execute=post_execute,
|
|
351
491
|
):
|
|
352
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
|
+
)
|
|
353
496
|
time.sleep(poll_interval)
|
|
354
497
|
except KeyboardInterrupt:
|
|
355
498
|
logger.info("Worker stopped.")
|
|
@@ -498,15 +641,26 @@ def _process_one_off_task(
|
|
|
498
641
|
|
|
499
642
|
elapsed = time.perf_counter() - start
|
|
500
643
|
|
|
501
|
-
# 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)
|
|
502
646
|
try:
|
|
503
647
|
with pq.session() as session:
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
+
)
|
|
510
664
|
except Exception as e:
|
|
511
665
|
logger.error(f"Error updating task status: {e}")
|
|
512
666
|
|
|
@@ -653,3 +807,375 @@ def _process_periodic_task(
|
|
|
653
807
|
logger.error(f"Failed to clear lock for periodic task '{name}': {e}")
|
|
654
808
|
|
|
655
809
|
return True
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# --- Concurrent worker functions ---
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _claim_and_fork_one_off(
|
|
816
|
+
pq: PQ,
|
|
817
|
+
*,
|
|
818
|
+
max_runtime: float,
|
|
819
|
+
priorities: Set[Priority] | None = None,
|
|
820
|
+
pre_execute: PreExecuteHook | None = None,
|
|
821
|
+
post_execute: PostExecuteHook | None = None,
|
|
822
|
+
) -> _ChildSlot | None:
|
|
823
|
+
"""Claim a one-off task and fork a child to execute it.
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
827
|
+
"""
|
|
828
|
+
try:
|
|
829
|
+
with pq.session() as session:
|
|
830
|
+
stmt = (
|
|
831
|
+
select(Task)
|
|
832
|
+
.where(Task.status == TaskStatus.PENDING)
|
|
833
|
+
.where(Task.run_at <= func.now())
|
|
834
|
+
)
|
|
835
|
+
if priorities:
|
|
836
|
+
stmt = stmt.where(Task.priority.in_([p.value for p in priorities]))
|
|
837
|
+
stmt = (
|
|
838
|
+
stmt.order_by(Task.priority.desc(), Task.run_at)
|
|
839
|
+
.with_for_update(skip_locked=True)
|
|
840
|
+
.limit(1)
|
|
841
|
+
)
|
|
842
|
+
task = session.execute(stmt).scalar_one_or_none()
|
|
843
|
+
|
|
844
|
+
if task is None:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
task.status = TaskStatus.RUNNING
|
|
848
|
+
task.started_at = datetime.now(UTC)
|
|
849
|
+
task.attempts += 1
|
|
850
|
+
|
|
851
|
+
name = task.name
|
|
852
|
+
payload = task.payload
|
|
853
|
+
task_id = task.id
|
|
854
|
+
|
|
855
|
+
session.flush()
|
|
856
|
+
session.expunge(task)
|
|
857
|
+
|
|
858
|
+
except Exception as e:
|
|
859
|
+
logger.error(f"Error claiming task: {e}")
|
|
860
|
+
return None
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
handler = resolve_function_path(name)
|
|
864
|
+
args, kwargs = deserialize(payload)
|
|
865
|
+
child_pid, read_fd = _fork_child(
|
|
866
|
+
handler,
|
|
867
|
+
args,
|
|
868
|
+
kwargs,
|
|
869
|
+
max_runtime=max_runtime,
|
|
870
|
+
task=task,
|
|
871
|
+
pre_execute=pre_execute,
|
|
872
|
+
post_execute=post_execute,
|
|
873
|
+
)
|
|
874
|
+
except Exception as e:
|
|
875
|
+
logger.error(f"Error starting task '{name}': {e}")
|
|
876
|
+
try:
|
|
877
|
+
with pq.session() as session:
|
|
878
|
+
t = session.get(Task, task_id)
|
|
879
|
+
if t:
|
|
880
|
+
t.status = TaskStatus.FAILED
|
|
881
|
+
t.completed_at = datetime.now(UTC)
|
|
882
|
+
t.error = str(e)
|
|
883
|
+
except Exception as update_err:
|
|
884
|
+
logger.error(f"Error updating task status: {update_err}")
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
return _ChildSlot(
|
|
888
|
+
pid=child_pid,
|
|
889
|
+
read_fd=read_fd,
|
|
890
|
+
task_id=task_id,
|
|
891
|
+
name=name,
|
|
892
|
+
start_time=time.perf_counter(),
|
|
893
|
+
is_periodic=False,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _claim_and_fork_periodic(
|
|
898
|
+
pq: PQ,
|
|
899
|
+
*,
|
|
900
|
+
max_runtime: float,
|
|
901
|
+
priorities: Set[Priority] | None = None,
|
|
902
|
+
pre_execute: PreExecuteHook | None = None,
|
|
903
|
+
post_execute: PostExecuteHook | None = None,
|
|
904
|
+
) -> _ChildSlot | None:
|
|
905
|
+
"""Claim a periodic task and fork a child to execute it.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
with pq.session() as session:
|
|
912
|
+
stmt = select(Periodic).where(
|
|
913
|
+
Periodic.active.is_(True),
|
|
914
|
+
Periodic.next_run <= func.now(),
|
|
915
|
+
or_(
|
|
916
|
+
Periodic.max_concurrent.is_(None),
|
|
917
|
+
Periodic.locked_until.is_(None),
|
|
918
|
+
Periodic.locked_until <= func.now(),
|
|
919
|
+
),
|
|
920
|
+
)
|
|
921
|
+
if priorities:
|
|
922
|
+
stmt = stmt.where(Periodic.priority.in_([p.value for p in priorities]))
|
|
923
|
+
stmt = (
|
|
924
|
+
stmt.order_by(Periodic.priority.desc(), Periodic.next_run)
|
|
925
|
+
.with_for_update(skip_locked=True)
|
|
926
|
+
.limit(1)
|
|
927
|
+
)
|
|
928
|
+
periodic = session.execute(stmt).scalar_one_or_none()
|
|
929
|
+
|
|
930
|
+
if periodic is None:
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
name = periodic.name
|
|
934
|
+
payload = periodic.payload
|
|
935
|
+
periodic_id = periodic.id
|
|
936
|
+
periodic_max_concurrent = periodic.max_concurrent
|
|
937
|
+
|
|
938
|
+
if periodic.max_concurrent is not None:
|
|
939
|
+
lock_duration = max_runtime if max_runtime > 0 else 3600
|
|
940
|
+
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
941
|
+
|
|
942
|
+
periodic.last_run = func.now()
|
|
943
|
+
if periodic.cron:
|
|
944
|
+
periodic.next_run = _calculate_next_run_cron(periodic.cron)
|
|
945
|
+
else:
|
|
946
|
+
periodic.next_run = func.now() + periodic.run_every
|
|
947
|
+
|
|
948
|
+
session.flush()
|
|
949
|
+
session.expunge(periodic)
|
|
950
|
+
|
|
951
|
+
except Exception as e:
|
|
952
|
+
logger.error(f"Error claiming periodic task: {e}")
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
try:
|
|
956
|
+
handler = resolve_function_path(name)
|
|
957
|
+
args, kwargs = deserialize(payload)
|
|
958
|
+
child_pid, read_fd = _fork_child(
|
|
959
|
+
handler,
|
|
960
|
+
args,
|
|
961
|
+
kwargs,
|
|
962
|
+
max_runtime=max_runtime,
|
|
963
|
+
task=periodic,
|
|
964
|
+
pre_execute=pre_execute,
|
|
965
|
+
post_execute=post_execute,
|
|
966
|
+
)
|
|
967
|
+
except Exception as e:
|
|
968
|
+
logger.error(f"Error starting periodic task '{name}': {e}")
|
|
969
|
+
if periodic_max_concurrent is not None:
|
|
970
|
+
try:
|
|
971
|
+
with pq.session() as session:
|
|
972
|
+
session.execute(
|
|
973
|
+
update(Periodic)
|
|
974
|
+
.where(Periodic.id == periodic_id)
|
|
975
|
+
.values(locked_until=None)
|
|
976
|
+
)
|
|
977
|
+
except Exception as lock_err:
|
|
978
|
+
logger.error(
|
|
979
|
+
f"Failed to clear lock for periodic task '{name}': {lock_err}"
|
|
980
|
+
)
|
|
981
|
+
return None
|
|
982
|
+
|
|
983
|
+
return _ChildSlot(
|
|
984
|
+
pid=child_pid,
|
|
985
|
+
read_fd=read_fd,
|
|
986
|
+
task_id=periodic_id,
|
|
987
|
+
name=name,
|
|
988
|
+
start_time=time.perf_counter(),
|
|
989
|
+
is_periodic=True,
|
|
990
|
+
periodic_max_concurrent=periodic_max_concurrent,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _try_claim_and_fork(
|
|
995
|
+
pq: PQ,
|
|
996
|
+
*,
|
|
997
|
+
max_runtime: float,
|
|
998
|
+
priorities: Set[Priority] | None = None,
|
|
999
|
+
pre_execute: PreExecuteHook | None = None,
|
|
1000
|
+
post_execute: PostExecuteHook | None = None,
|
|
1001
|
+
) -> _ChildSlot | None:
|
|
1002
|
+
"""Try to claim any available task (one-off first, then periodic) and fork.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
1006
|
+
"""
|
|
1007
|
+
slot = _claim_and_fork_one_off(
|
|
1008
|
+
pq,
|
|
1009
|
+
max_runtime=max_runtime,
|
|
1010
|
+
priorities=priorities,
|
|
1011
|
+
pre_execute=pre_execute,
|
|
1012
|
+
post_execute=post_execute,
|
|
1013
|
+
)
|
|
1014
|
+
if slot is not None:
|
|
1015
|
+
return slot
|
|
1016
|
+
|
|
1017
|
+
return _claim_and_fork_periodic(
|
|
1018
|
+
pq,
|
|
1019
|
+
max_runtime=max_runtime,
|
|
1020
|
+
priorities=priorities,
|
|
1021
|
+
pre_execute=pre_execute,
|
|
1022
|
+
post_execute=post_execute,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
|
|
1027
|
+
"""Wait for a forked child to finish and update task status.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
pq: PQ client instance.
|
|
1031
|
+
slot: The child slot to reap.
|
|
1032
|
+
"""
|
|
1033
|
+
result = _wait_for_child(slot.pid, slot.read_fd)
|
|
1034
|
+
elapsed = time.perf_counter() - slot.start_time
|
|
1035
|
+
|
|
1036
|
+
if slot.is_periodic:
|
|
1037
|
+
if result.exit_kind == "success":
|
|
1038
|
+
logger.debug(f"Periodic task '{slot.name}' completed in {elapsed:.3f} s")
|
|
1039
|
+
elif result.exit_kind == "timeout":
|
|
1040
|
+
logger.error(f"Periodic task '{slot.name}' timed out after {elapsed:.3f} s")
|
|
1041
|
+
elif result.exit_kind == "oom":
|
|
1042
|
+
logger.error(
|
|
1043
|
+
f"Periodic task '{slot.name}' OOM after {elapsed:.3f} s:"
|
|
1044
|
+
f" {result.error_msg}"
|
|
1045
|
+
)
|
|
1046
|
+
elif result.exit_kind == "killed":
|
|
1047
|
+
logger.error(
|
|
1048
|
+
f"Periodic task '{slot.name}' killed after {elapsed:.3f} s:"
|
|
1049
|
+
f" {result.error_msg}"
|
|
1050
|
+
)
|
|
1051
|
+
else:
|
|
1052
|
+
logger.error(
|
|
1053
|
+
f"Periodic task '{slot.name}' failed after {elapsed:.3f} s:"
|
|
1054
|
+
f" {result.error_msg}"
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
if slot.periodic_max_concurrent is not None:
|
|
1058
|
+
try:
|
|
1059
|
+
with pq.session() as session:
|
|
1060
|
+
session.execute(
|
|
1061
|
+
update(Periodic)
|
|
1062
|
+
.where(Periodic.id == slot.task_id)
|
|
1063
|
+
.values(locked_until=None)
|
|
1064
|
+
)
|
|
1065
|
+
except Exception as e:
|
|
1066
|
+
logger.error(
|
|
1067
|
+
f"Failed to clear lock for periodic task '{slot.name}': {e}"
|
|
1068
|
+
)
|
|
1069
|
+
else:
|
|
1070
|
+
error_msg = result.error_msg
|
|
1071
|
+
if result.exit_kind == "timeout":
|
|
1072
|
+
error_msg = f"Timed out after {elapsed:.3f} s"
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
with pq.session() as session:
|
|
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
|
+
)
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
logger.error(f"Error updating task status: {e}")
|
|
1097
|
+
|
|
1098
|
+
if result.task_status == TaskStatus.COMPLETED:
|
|
1099
|
+
logger.debug(f"Task '{slot.name}' completed in {elapsed:.3f} s")
|
|
1100
|
+
else:
|
|
1101
|
+
logger.error(
|
|
1102
|
+
f"Task '{slot.name}' failed after {elapsed:.3f} s: {error_msg}"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _run_concurrent(
|
|
1107
|
+
pq: PQ,
|
|
1108
|
+
*,
|
|
1109
|
+
concurrency: int,
|
|
1110
|
+
poll_interval: float,
|
|
1111
|
+
max_runtime: float,
|
|
1112
|
+
priorities: Set[Priority] | None,
|
|
1113
|
+
pre_execute: PreExecuteHook | None,
|
|
1114
|
+
post_execute: PostExecuteHook | None,
|
|
1115
|
+
retention_days: int,
|
|
1116
|
+
cleanup_interval: float,
|
|
1117
|
+
stale_task_timeout: timedelta | None = None,
|
|
1118
|
+
) -> None:
|
|
1119
|
+
"""Run the concurrent worker loop.
|
|
1120
|
+
|
|
1121
|
+
Manages up to ``concurrency`` forked children simultaneously using
|
|
1122
|
+
select() on error pipes to detect child completion.
|
|
1123
|
+
|
|
1124
|
+
Args:
|
|
1125
|
+
pq: PQ client instance.
|
|
1126
|
+
concurrency: Maximum number of concurrent tasks.
|
|
1127
|
+
poll_interval: Seconds between polls when idle.
|
|
1128
|
+
max_runtime: Maximum execution time per task in seconds.
|
|
1129
|
+
priorities: If set, only process tasks with these priority levels.
|
|
1130
|
+
pre_execute: Called in forked child BEFORE task execution.
|
|
1131
|
+
post_execute: Called in forked child AFTER task execution.
|
|
1132
|
+
retention_days: Days to keep completed/failed tasks.
|
|
1133
|
+
cleanup_interval: Seconds between cleanup runs.
|
|
1134
|
+
stale_task_timeout: If set, reap RUNNING tasks older than this.
|
|
1135
|
+
"""
|
|
1136
|
+
children: dict[int, _ChildSlot] = {} # pid -> slot
|
|
1137
|
+
fd_to_pid: dict[int, int] = {} # read_fd -> pid
|
|
1138
|
+
last_cleanup: list[float] = [0.0]
|
|
1139
|
+
last_reap: list[float] = [0.0]
|
|
1140
|
+
|
|
1141
|
+
try:
|
|
1142
|
+
while True:
|
|
1143
|
+
# Step 1: Fill empty slots with new tasks
|
|
1144
|
+
while len(children) < concurrency:
|
|
1145
|
+
slot = _try_claim_and_fork(
|
|
1146
|
+
pq,
|
|
1147
|
+
max_runtime=max_runtime,
|
|
1148
|
+
priorities=priorities,
|
|
1149
|
+
pre_execute=pre_execute,
|
|
1150
|
+
post_execute=post_execute,
|
|
1151
|
+
)
|
|
1152
|
+
if slot is None:
|
|
1153
|
+
break
|
|
1154
|
+
children[slot.pid] = slot
|
|
1155
|
+
fd_to_pid[slot.read_fd] = slot.pid
|
|
1156
|
+
|
|
1157
|
+
# Step 2: Wait for events
|
|
1158
|
+
if children:
|
|
1159
|
+
read_fds = list(fd_to_pid.keys())
|
|
1160
|
+
ready, _, _ = select_module.select(read_fds, [], [], poll_interval)
|
|
1161
|
+
for fd in ready:
|
|
1162
|
+
pid = fd_to_pid.pop(fd)
|
|
1163
|
+
slot = children.pop(pid)
|
|
1164
|
+
_reap_and_update(pq, slot)
|
|
1165
|
+
else:
|
|
1166
|
+
time.sleep(poll_interval)
|
|
1167
|
+
|
|
1168
|
+
# Maintenance (each rate-limited independently)
|
|
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
|
+
)
|
|
1173
|
+
|
|
1174
|
+
except KeyboardInterrupt:
|
|
1175
|
+
if children:
|
|
1176
|
+
logger.info(
|
|
1177
|
+
f"Shutting down, waiting for {len(children)} task(s) to finish..."
|
|
1178
|
+
)
|
|
1179
|
+
for slot in list(children.values()):
|
|
1180
|
+
_reap_and_update(pq, slot)
|
|
1181
|
+
logger.info("Worker stopped.")
|
|
@@ -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.
|
|
21
|
-
python_pq-0.
|
|
22
|
-
python_pq-0.
|
|
23
|
-
python_pq-0.
|
|
24
|
-
python_pq-0.
|
|
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
|