python-pq 0.6.2__tar.gz → 0.7.0__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.
- {python_pq-0.6.2 → python_pq-0.7.0}/PKG-INFO +1 -1
- {python_pq-0.6.2 → python_pq-0.7.0}/pyproject.toml +1 -1
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/__init__.py +1 -1
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/client.py +4 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/worker.py +522 -72
- {python_pq-0.6.2 → python_pq-0.7.0}/LICENSE +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/README.md +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/config.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/logging.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/README +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/env.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/models.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/priority.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/registry.py +0 -0
- {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/serialization.py +0 -0
|
@@ -510,6 +510,7 @@ class PQ:
|
|
|
510
510
|
def run_worker(
|
|
511
511
|
self,
|
|
512
512
|
*,
|
|
513
|
+
concurrency: int = 1,
|
|
513
514
|
poll_interval: float = 1.0,
|
|
514
515
|
max_runtime: float = 30 * 60,
|
|
515
516
|
priorities: Set[Priority] | None = None,
|
|
@@ -519,6 +520,8 @@ class PQ:
|
|
|
519
520
|
Each task executes in a forked child process for memory isolation.
|
|
520
521
|
|
|
521
522
|
Args:
|
|
523
|
+
concurrency: Maximum number of tasks to process simultaneously.
|
|
524
|
+
Default: 1 (sequential processing).
|
|
522
525
|
poll_interval: Seconds to sleep between polls when idle.
|
|
523
526
|
max_runtime: Maximum execution time per task in seconds. Default: 30 min.
|
|
524
527
|
priorities: If set, only process tasks with these priority levels.
|
|
@@ -528,6 +531,7 @@ class PQ:
|
|
|
528
531
|
|
|
529
532
|
run_worker(
|
|
530
533
|
self,
|
|
534
|
+
concurrency=concurrency,
|
|
531
535
|
poll_interval=poll_interval,
|
|
532
536
|
max_runtime=max_runtime,
|
|
533
537
|
priorities=priorities,
|
|
@@ -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
|
|
@@ -79,6 +81,27 @@ EXIT_FAILURE = 1
|
|
|
79
81
|
EXIT_TIMEOUT = 124 # Like GNU timeout
|
|
80
82
|
|
|
81
83
|
|
|
84
|
+
class _ChildResult(NamedTuple):
|
|
85
|
+
"""Result from waiting for a forked child process."""
|
|
86
|
+
|
|
87
|
+
task_status: TaskStatus
|
|
88
|
+
error_msg: str | None
|
|
89
|
+
exit_kind: str # "success", "timeout", "oom", "killed", "error"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class _ChildSlot:
|
|
94
|
+
"""Tracks a running forked child in concurrent mode."""
|
|
95
|
+
|
|
96
|
+
pid: int
|
|
97
|
+
read_fd: int
|
|
98
|
+
task_id: int
|
|
99
|
+
name: str
|
|
100
|
+
start_time: float
|
|
101
|
+
is_periodic: bool
|
|
102
|
+
periodic_max_concurrent: int | None = None
|
|
103
|
+
|
|
104
|
+
|
|
82
105
|
class WorkerError(Exception):
|
|
83
106
|
"""Base class for worker errors."""
|
|
84
107
|
|
|
@@ -169,6 +192,107 @@ def _run_in_child(
|
|
|
169
192
|
os._exit(EXIT_FAILURE)
|
|
170
193
|
|
|
171
194
|
|
|
195
|
+
def _fork_child(
|
|
196
|
+
handler: Callable[..., Any],
|
|
197
|
+
args: tuple[Any, ...],
|
|
198
|
+
kwargs: dict[str, Any],
|
|
199
|
+
*,
|
|
200
|
+
max_runtime: float,
|
|
201
|
+
task: Task | Periodic,
|
|
202
|
+
pre_execute: PreExecuteHook | None = None,
|
|
203
|
+
post_execute: PostExecuteHook | None = None,
|
|
204
|
+
) -> tuple[int, int]:
|
|
205
|
+
"""Fork a child process to execute the handler.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Tuple of (child_pid, read_fd) for monitoring the child.
|
|
209
|
+
"""
|
|
210
|
+
read_fd, write_fd = os.pipe()
|
|
211
|
+
child_pid = os.fork()
|
|
212
|
+
|
|
213
|
+
if child_pid == 0:
|
|
214
|
+
# === CHILD PROCESS ===
|
|
215
|
+
os.close(read_fd)
|
|
216
|
+
_run_in_child(
|
|
217
|
+
handler,
|
|
218
|
+
args,
|
|
219
|
+
kwargs,
|
|
220
|
+
max_runtime,
|
|
221
|
+
write_fd,
|
|
222
|
+
task,
|
|
223
|
+
pre_execute,
|
|
224
|
+
post_execute,
|
|
225
|
+
)
|
|
226
|
+
os._exit(EXIT_FAILURE) # _run_in_child never returns, but just in case
|
|
227
|
+
|
|
228
|
+
# === PARENT PROCESS ===
|
|
229
|
+
os.close(write_fd)
|
|
230
|
+
return child_pid, read_fd
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
234
|
+
"""Wait for a forked child process and return the result.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
child_pid: PID of the child process.
|
|
238
|
+
read_fd: Read end of the error pipe.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
_ChildResult with task status, error message, and exit kind.
|
|
242
|
+
"""
|
|
243
|
+
_, raw_status, rusage = os.wait4(child_pid, 0)
|
|
244
|
+
|
|
245
|
+
# Read error message from pipe
|
|
246
|
+
error_bytes = b""
|
|
247
|
+
try:
|
|
248
|
+
while True:
|
|
249
|
+
chunk = os.read(read_fd, 4096)
|
|
250
|
+
if not chunk:
|
|
251
|
+
break
|
|
252
|
+
error_bytes += chunk
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
finally:
|
|
256
|
+
os.close(read_fd)
|
|
257
|
+
|
|
258
|
+
error_msg = error_bytes.decode("utf-8", errors="replace") if error_bytes else ""
|
|
259
|
+
|
|
260
|
+
if os.WIFSIGNALED(raw_status):
|
|
261
|
+
signal_num = os.WTERMSIG(raw_status)
|
|
262
|
+
if signal_num == signal.SIGKILL:
|
|
263
|
+
max_rss_kb = rusage.ru_maxrss
|
|
264
|
+
if sys.platform == "darwin":
|
|
265
|
+
max_rss_kb = max_rss_kb // 1024
|
|
266
|
+
return _ChildResult(
|
|
267
|
+
TaskStatus.FAILED,
|
|
268
|
+
f"Task killed (likely OOM). Max RSS: {max_rss_kb} KB",
|
|
269
|
+
"oom",
|
|
270
|
+
)
|
|
271
|
+
return _ChildResult(
|
|
272
|
+
TaskStatus.FAILED,
|
|
273
|
+
f"Task killed by signal {signal_num}",
|
|
274
|
+
"killed",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if os.WIFEXITED(raw_status):
|
|
278
|
+
exit_code = os.WEXITSTATUS(raw_status)
|
|
279
|
+
if exit_code == EXIT_SUCCESS:
|
|
280
|
+
return _ChildResult(TaskStatus.COMPLETED, None, "success")
|
|
281
|
+
if exit_code == EXIT_TIMEOUT:
|
|
282
|
+
return _ChildResult(
|
|
283
|
+
TaskStatus.FAILED, "Task exceeded max runtime", "timeout"
|
|
284
|
+
)
|
|
285
|
+
if error_msg:
|
|
286
|
+
return _ChildResult(TaskStatus.FAILED, error_msg.rstrip(), "error")
|
|
287
|
+
return _ChildResult(
|
|
288
|
+
TaskStatus.FAILED,
|
|
289
|
+
f"Task failed with exit code {exit_code}",
|
|
290
|
+
"error",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return _ChildResult(TaskStatus.FAILED, "Unknown child exit status", "error")
|
|
294
|
+
|
|
295
|
+
|
|
172
296
|
def _execute_in_fork(
|
|
173
297
|
handler: Callable[..., Any],
|
|
174
298
|
args: tuple[Any, ...],
|
|
@@ -200,76 +324,26 @@ def _execute_in_fork(
|
|
|
200
324
|
TaskKilledError: If task is killed by another signal.
|
|
201
325
|
Exception: If task raises an exception.
|
|
202
326
|
"""
|
|
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)
|
|
327
|
+
child_pid, read_fd = _fork_child(
|
|
328
|
+
handler,
|
|
329
|
+
args,
|
|
330
|
+
kwargs,
|
|
331
|
+
max_runtime=max_runtime,
|
|
332
|
+
task=task,
|
|
333
|
+
pre_execute=pre_execute,
|
|
334
|
+
post_execute=post_execute,
|
|
335
|
+
)
|
|
336
|
+
result = _wait_for_child(child_pid, read_fd)
|
|
230
337
|
|
|
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}")
|
|
338
|
+
if result.exit_kind == "success":
|
|
339
|
+
return
|
|
340
|
+
if result.exit_kind == "timeout":
|
|
341
|
+
raise TaskTimeoutError(result.error_msg or "Task exceeded max runtime")
|
|
342
|
+
if result.exit_kind == "oom":
|
|
343
|
+
raise TaskOOMError(result.error_msg or "Task killed (likely OOM)")
|
|
344
|
+
if result.exit_kind == "killed":
|
|
345
|
+
raise TaskKilledError(result.error_msg or "Task killed by signal")
|
|
346
|
+
raise Exception(result.error_msg or "Task failed")
|
|
273
347
|
|
|
274
348
|
|
|
275
349
|
def _maybe_run_cleanup(
|
|
@@ -306,6 +380,7 @@ def _maybe_run_cleanup(
|
|
|
306
380
|
def run_worker(
|
|
307
381
|
pq: PQ,
|
|
308
382
|
*,
|
|
383
|
+
concurrency: int = 1,
|
|
309
384
|
poll_interval: float = 1.0,
|
|
310
385
|
max_runtime: float = DEFAULT_MAX_RUNTIME,
|
|
311
386
|
priorities: Set[Priority] | None = None,
|
|
@@ -320,6 +395,8 @@ def run_worker(
|
|
|
320
395
|
|
|
321
396
|
Args:
|
|
322
397
|
pq: PQ client instance.
|
|
398
|
+
concurrency: Maximum number of tasks to process simultaneously.
|
|
399
|
+
Default: 1 (sequential processing).
|
|
323
400
|
poll_interval: Seconds to sleep between polls when idle.
|
|
324
401
|
max_runtime: Maximum execution time per task in seconds. Default: 30 min.
|
|
325
402
|
priorities: If set, only process tasks with these priority levels.
|
|
@@ -334,9 +411,29 @@ def run_worker(
|
|
|
334
411
|
"""
|
|
335
412
|
if priorities:
|
|
336
413
|
priority_names = ", ".join(p.name for p in sorted(priorities, reverse=True))
|
|
337
|
-
logger.info(
|
|
414
|
+
logger.info(
|
|
415
|
+
f"Starting PQ worker (priorities: {priority_names},"
|
|
416
|
+
f" concurrency: {concurrency})..."
|
|
417
|
+
)
|
|
338
418
|
else:
|
|
339
|
-
logger.info(
|
|
419
|
+
logger.info(
|
|
420
|
+
f"Starting PQ worker (concurrency: {concurrency},"
|
|
421
|
+
" fork isolation enabled)..."
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if concurrency > 1:
|
|
425
|
+
_run_concurrent(
|
|
426
|
+
pq,
|
|
427
|
+
concurrency=concurrency,
|
|
428
|
+
poll_interval=poll_interval,
|
|
429
|
+
max_runtime=max_runtime,
|
|
430
|
+
priorities=priorities,
|
|
431
|
+
pre_execute=pre_execute,
|
|
432
|
+
post_execute=post_execute,
|
|
433
|
+
retention_days=retention_days,
|
|
434
|
+
cleanup_interval=cleanup_interval,
|
|
435
|
+
)
|
|
436
|
+
return
|
|
340
437
|
|
|
341
438
|
last_cleanup: list[float] = [0.0] # Mutable container for tracking
|
|
342
439
|
|
|
@@ -653,3 +750,356 @@ def _process_periodic_task(
|
|
|
653
750
|
logger.error(f"Failed to clear lock for periodic task '{name}': {e}")
|
|
654
751
|
|
|
655
752
|
return True
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# --- Concurrent worker functions ---
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _claim_and_fork_one_off(
|
|
759
|
+
pq: PQ,
|
|
760
|
+
*,
|
|
761
|
+
max_runtime: float,
|
|
762
|
+
priorities: Set[Priority] | None = None,
|
|
763
|
+
pre_execute: PreExecuteHook | None = None,
|
|
764
|
+
post_execute: PostExecuteHook | None = None,
|
|
765
|
+
) -> _ChildSlot | None:
|
|
766
|
+
"""Claim a one-off task and fork a child to execute it.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
770
|
+
"""
|
|
771
|
+
try:
|
|
772
|
+
with pq.session() as session:
|
|
773
|
+
stmt = (
|
|
774
|
+
select(Task)
|
|
775
|
+
.where(Task.status == TaskStatus.PENDING)
|
|
776
|
+
.where(Task.run_at <= func.now())
|
|
777
|
+
)
|
|
778
|
+
if priorities:
|
|
779
|
+
stmt = stmt.where(Task.priority.in_([p.value for p in priorities]))
|
|
780
|
+
stmt = (
|
|
781
|
+
stmt.order_by(Task.priority.desc(), Task.run_at)
|
|
782
|
+
.with_for_update(skip_locked=True)
|
|
783
|
+
.limit(1)
|
|
784
|
+
)
|
|
785
|
+
task = session.execute(stmt).scalar_one_or_none()
|
|
786
|
+
|
|
787
|
+
if task is None:
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
task.status = TaskStatus.RUNNING
|
|
791
|
+
task.started_at = datetime.now(UTC)
|
|
792
|
+
task.attempts += 1
|
|
793
|
+
|
|
794
|
+
name = task.name
|
|
795
|
+
payload = task.payload
|
|
796
|
+
task_id = task.id
|
|
797
|
+
|
|
798
|
+
session.flush()
|
|
799
|
+
session.expunge(task)
|
|
800
|
+
|
|
801
|
+
except Exception as e:
|
|
802
|
+
logger.error(f"Error claiming task: {e}")
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
handler = resolve_function_path(name)
|
|
807
|
+
args, kwargs = deserialize(payload)
|
|
808
|
+
child_pid, read_fd = _fork_child(
|
|
809
|
+
handler,
|
|
810
|
+
args,
|
|
811
|
+
kwargs,
|
|
812
|
+
max_runtime=max_runtime,
|
|
813
|
+
task=task,
|
|
814
|
+
pre_execute=pre_execute,
|
|
815
|
+
post_execute=post_execute,
|
|
816
|
+
)
|
|
817
|
+
except Exception as e:
|
|
818
|
+
logger.error(f"Error starting task '{name}': {e}")
|
|
819
|
+
try:
|
|
820
|
+
with pq.session() as session:
|
|
821
|
+
t = session.get(Task, task_id)
|
|
822
|
+
if t:
|
|
823
|
+
t.status = TaskStatus.FAILED
|
|
824
|
+
t.completed_at = datetime.now(UTC)
|
|
825
|
+
t.error = str(e)
|
|
826
|
+
except Exception as update_err:
|
|
827
|
+
logger.error(f"Error updating task status: {update_err}")
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
return _ChildSlot(
|
|
831
|
+
pid=child_pid,
|
|
832
|
+
read_fd=read_fd,
|
|
833
|
+
task_id=task_id,
|
|
834
|
+
name=name,
|
|
835
|
+
start_time=time.perf_counter(),
|
|
836
|
+
is_periodic=False,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _claim_and_fork_periodic(
|
|
841
|
+
pq: PQ,
|
|
842
|
+
*,
|
|
843
|
+
max_runtime: float,
|
|
844
|
+
priorities: Set[Priority] | None = None,
|
|
845
|
+
pre_execute: PreExecuteHook | None = None,
|
|
846
|
+
post_execute: PostExecuteHook | None = None,
|
|
847
|
+
) -> _ChildSlot | None:
|
|
848
|
+
"""Claim a periodic task and fork a child to execute it.
|
|
849
|
+
|
|
850
|
+
Returns:
|
|
851
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
852
|
+
"""
|
|
853
|
+
try:
|
|
854
|
+
with pq.session() as session:
|
|
855
|
+
stmt = select(Periodic).where(
|
|
856
|
+
Periodic.active.is_(True),
|
|
857
|
+
Periodic.next_run <= func.now(),
|
|
858
|
+
or_(
|
|
859
|
+
Periodic.max_concurrent.is_(None),
|
|
860
|
+
Periodic.locked_until.is_(None),
|
|
861
|
+
Periodic.locked_until <= func.now(),
|
|
862
|
+
),
|
|
863
|
+
)
|
|
864
|
+
if priorities:
|
|
865
|
+
stmt = stmt.where(Periodic.priority.in_([p.value for p in priorities]))
|
|
866
|
+
stmt = (
|
|
867
|
+
stmt.order_by(Periodic.priority.desc(), Periodic.next_run)
|
|
868
|
+
.with_for_update(skip_locked=True)
|
|
869
|
+
.limit(1)
|
|
870
|
+
)
|
|
871
|
+
periodic = session.execute(stmt).scalar_one_or_none()
|
|
872
|
+
|
|
873
|
+
if periodic is None:
|
|
874
|
+
return None
|
|
875
|
+
|
|
876
|
+
name = periodic.name
|
|
877
|
+
payload = periodic.payload
|
|
878
|
+
periodic_id = periodic.id
|
|
879
|
+
periodic_max_concurrent = periodic.max_concurrent
|
|
880
|
+
|
|
881
|
+
if periodic.max_concurrent is not None:
|
|
882
|
+
lock_duration = max_runtime if max_runtime > 0 else 3600
|
|
883
|
+
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
884
|
+
|
|
885
|
+
periodic.last_run = func.now()
|
|
886
|
+
if periodic.cron:
|
|
887
|
+
periodic.next_run = _calculate_next_run_cron(periodic.cron)
|
|
888
|
+
else:
|
|
889
|
+
periodic.next_run = func.now() + periodic.run_every
|
|
890
|
+
|
|
891
|
+
session.flush()
|
|
892
|
+
session.expunge(periodic)
|
|
893
|
+
|
|
894
|
+
except Exception as e:
|
|
895
|
+
logger.error(f"Error claiming periodic task: {e}")
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
handler = resolve_function_path(name)
|
|
900
|
+
args, kwargs = deserialize(payload)
|
|
901
|
+
child_pid, read_fd = _fork_child(
|
|
902
|
+
handler,
|
|
903
|
+
args,
|
|
904
|
+
kwargs,
|
|
905
|
+
max_runtime=max_runtime,
|
|
906
|
+
task=periodic,
|
|
907
|
+
pre_execute=pre_execute,
|
|
908
|
+
post_execute=post_execute,
|
|
909
|
+
)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
logger.error(f"Error starting periodic task '{name}': {e}")
|
|
912
|
+
if periodic_max_concurrent is not None:
|
|
913
|
+
try:
|
|
914
|
+
with pq.session() as session:
|
|
915
|
+
session.execute(
|
|
916
|
+
update(Periodic)
|
|
917
|
+
.where(Periodic.id == periodic_id)
|
|
918
|
+
.values(locked_until=None)
|
|
919
|
+
)
|
|
920
|
+
except Exception as lock_err:
|
|
921
|
+
logger.error(
|
|
922
|
+
f"Failed to clear lock for periodic task '{name}': {lock_err}"
|
|
923
|
+
)
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
return _ChildSlot(
|
|
927
|
+
pid=child_pid,
|
|
928
|
+
read_fd=read_fd,
|
|
929
|
+
task_id=periodic_id,
|
|
930
|
+
name=name,
|
|
931
|
+
start_time=time.perf_counter(),
|
|
932
|
+
is_periodic=True,
|
|
933
|
+
periodic_max_concurrent=periodic_max_concurrent,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _try_claim_and_fork(
|
|
938
|
+
pq: PQ,
|
|
939
|
+
*,
|
|
940
|
+
max_runtime: float,
|
|
941
|
+
priorities: Set[Priority] | None = None,
|
|
942
|
+
pre_execute: PreExecuteHook | None = None,
|
|
943
|
+
post_execute: PostExecuteHook | None = None,
|
|
944
|
+
) -> _ChildSlot | None:
|
|
945
|
+
"""Try to claim any available task (one-off first, then periodic) and fork.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
_ChildSlot if a task was claimed and forked, None if no tasks available.
|
|
949
|
+
"""
|
|
950
|
+
slot = _claim_and_fork_one_off(
|
|
951
|
+
pq,
|
|
952
|
+
max_runtime=max_runtime,
|
|
953
|
+
priorities=priorities,
|
|
954
|
+
pre_execute=pre_execute,
|
|
955
|
+
post_execute=post_execute,
|
|
956
|
+
)
|
|
957
|
+
if slot is not None:
|
|
958
|
+
return slot
|
|
959
|
+
|
|
960
|
+
return _claim_and_fork_periodic(
|
|
961
|
+
pq,
|
|
962
|
+
max_runtime=max_runtime,
|
|
963
|
+
priorities=priorities,
|
|
964
|
+
pre_execute=pre_execute,
|
|
965
|
+
post_execute=post_execute,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _reap_and_update(pq: PQ, slot: _ChildSlot) -> None:
|
|
970
|
+
"""Wait for a forked child to finish and update task status.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
pq: PQ client instance.
|
|
974
|
+
slot: The child slot to reap.
|
|
975
|
+
"""
|
|
976
|
+
result = _wait_for_child(slot.pid, slot.read_fd)
|
|
977
|
+
elapsed = time.perf_counter() - slot.start_time
|
|
978
|
+
|
|
979
|
+
if slot.is_periodic:
|
|
980
|
+
if result.exit_kind == "success":
|
|
981
|
+
logger.debug(f"Periodic task '{slot.name}' completed in {elapsed:.3f} s")
|
|
982
|
+
elif result.exit_kind == "timeout":
|
|
983
|
+
logger.error(f"Periodic task '{slot.name}' timed out after {elapsed:.3f} s")
|
|
984
|
+
elif result.exit_kind == "oom":
|
|
985
|
+
logger.error(
|
|
986
|
+
f"Periodic task '{slot.name}' OOM after {elapsed:.3f} s:"
|
|
987
|
+
f" {result.error_msg}"
|
|
988
|
+
)
|
|
989
|
+
elif result.exit_kind == "killed":
|
|
990
|
+
logger.error(
|
|
991
|
+
f"Periodic task '{slot.name}' killed after {elapsed:.3f} s:"
|
|
992
|
+
f" {result.error_msg}"
|
|
993
|
+
)
|
|
994
|
+
else:
|
|
995
|
+
logger.error(
|
|
996
|
+
f"Periodic task '{slot.name}' failed after {elapsed:.3f} s:"
|
|
997
|
+
f" {result.error_msg}"
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
if slot.periodic_max_concurrent is not None:
|
|
1001
|
+
try:
|
|
1002
|
+
with pq.session() as session:
|
|
1003
|
+
session.execute(
|
|
1004
|
+
update(Periodic)
|
|
1005
|
+
.where(Periodic.id == slot.task_id)
|
|
1006
|
+
.values(locked_until=None)
|
|
1007
|
+
)
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
logger.error(
|
|
1010
|
+
f"Failed to clear lock for periodic task '{slot.name}': {e}"
|
|
1011
|
+
)
|
|
1012
|
+
else:
|
|
1013
|
+
error_msg = result.error_msg
|
|
1014
|
+
if result.exit_kind == "timeout":
|
|
1015
|
+
error_msg = f"Timed out after {elapsed:.3f} s"
|
|
1016
|
+
|
|
1017
|
+
try:
|
|
1018
|
+
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
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
logger.error(f"Error updating task status: {e}")
|
|
1027
|
+
|
|
1028
|
+
if result.task_status == TaskStatus.COMPLETED:
|
|
1029
|
+
logger.debug(f"Task '{slot.name}' completed in {elapsed:.3f} s")
|
|
1030
|
+
else:
|
|
1031
|
+
logger.error(
|
|
1032
|
+
f"Task '{slot.name}' failed after {elapsed:.3f} s: {error_msg}"
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _run_concurrent(
|
|
1037
|
+
pq: PQ,
|
|
1038
|
+
*,
|
|
1039
|
+
concurrency: int,
|
|
1040
|
+
poll_interval: float,
|
|
1041
|
+
max_runtime: float,
|
|
1042
|
+
priorities: Set[Priority] | None,
|
|
1043
|
+
pre_execute: PreExecuteHook | None,
|
|
1044
|
+
post_execute: PostExecuteHook | None,
|
|
1045
|
+
retention_days: int,
|
|
1046
|
+
cleanup_interval: float,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
"""Run the concurrent worker loop.
|
|
1049
|
+
|
|
1050
|
+
Manages up to ``concurrency`` forked children simultaneously using
|
|
1051
|
+
select() on error pipes to detect child completion.
|
|
1052
|
+
|
|
1053
|
+
Args:
|
|
1054
|
+
pq: PQ client instance.
|
|
1055
|
+
concurrency: Maximum number of concurrent tasks.
|
|
1056
|
+
poll_interval: Seconds between polls when idle.
|
|
1057
|
+
max_runtime: Maximum execution time per task in seconds.
|
|
1058
|
+
priorities: If set, only process tasks with these priority levels.
|
|
1059
|
+
pre_execute: Called in forked child BEFORE task execution.
|
|
1060
|
+
post_execute: Called in forked child AFTER task execution.
|
|
1061
|
+
retention_days: Days to keep completed/failed tasks.
|
|
1062
|
+
cleanup_interval: Seconds between cleanup runs.
|
|
1063
|
+
"""
|
|
1064
|
+
children: dict[int, _ChildSlot] = {} # pid -> slot
|
|
1065
|
+
fd_to_pid: dict[int, int] = {} # read_fd -> pid
|
|
1066
|
+
last_cleanup: list[float] = [0.0]
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
while True:
|
|
1070
|
+
# Step 1: Fill empty slots with new tasks
|
|
1071
|
+
while len(children) < concurrency:
|
|
1072
|
+
slot = _try_claim_and_fork(
|
|
1073
|
+
pq,
|
|
1074
|
+
max_runtime=max_runtime,
|
|
1075
|
+
priorities=priorities,
|
|
1076
|
+
pre_execute=pre_execute,
|
|
1077
|
+
post_execute=post_execute,
|
|
1078
|
+
)
|
|
1079
|
+
if slot is None:
|
|
1080
|
+
break
|
|
1081
|
+
children[slot.pid] = slot
|
|
1082
|
+
fd_to_pid[slot.read_fd] = slot.pid
|
|
1083
|
+
|
|
1084
|
+
# Step 2: Wait for events
|
|
1085
|
+
if children:
|
|
1086
|
+
read_fds = list(fd_to_pid.keys())
|
|
1087
|
+
ready, _, _ = select_module.select(read_fds, [], [], poll_interval)
|
|
1088
|
+
for fd in ready:
|
|
1089
|
+
pid = fd_to_pid.pop(fd)
|
|
1090
|
+
slot = children.pop(pid)
|
|
1091
|
+
_reap_and_update(pq, slot)
|
|
1092
|
+
else:
|
|
1093
|
+
time.sleep(poll_interval)
|
|
1094
|
+
|
|
1095
|
+
# Cleanup runs on every iteration (rate-limited internally)
|
|
1096
|
+
_maybe_run_cleanup(pq, retention_days, cleanup_interval, last_cleanup)
|
|
1097
|
+
|
|
1098
|
+
except KeyboardInterrupt:
|
|
1099
|
+
if children:
|
|
1100
|
+
logger.info(
|
|
1101
|
+
f"Shutting down, waiting for {len(children)} task(s) to finish..."
|
|
1102
|
+
)
|
|
1103
|
+
for slot in list(children.values()):
|
|
1104
|
+
_reap_and_update(pq, slot)
|
|
1105
|
+
logger.info("Worker stopped.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|