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 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.6.3"
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,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
- # Create pipe for error message communication
204
- read_fd, write_fd = os.pipe()
205
-
206
- child_pid = os.fork()
207
-
208
- if child_pid == 0:
209
- # === CHILD PROCESS ===
210
- os.close(read_fd)
211
- _run_in_child(
212
- handler,
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
- # Read any error message from child
232
- error_bytes = b""
233
- try:
234
- while True:
235
- chunk = os.read(read_fd, 4096)
236
- if not chunk:
237
- break
238
- error_bytes += chunk
239
- except Exception:
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(f"Starting PQ worker (priorities: {priority_names})...")
455
+ logger.info(
456
+ f"Starting PQ worker (priorities: {priority_names},"
457
+ f" concurrency: {concurrency})..."
458
+ )
338
459
  else:
339
- logger.info("Starting PQ worker (fork isolation enabled)...")
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] # Mutable container for tracking
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
- task = session.get(Task, task_id)
505
- if task:
506
- task.status = status
507
- task.completed_at = datetime.now(UTC)
508
- if error_msg:
509
- 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
+ )
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-pq
3
- Version: 0.6.3
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=SfFnu-zQx7PZeT2bALDsZMsW5X84L6G58oXjiA7WtUE,398
2
- pq/client.py,sha256=yVxYqssiIFHtnzj8ZEASLWHiX2mk7oPVI03bt9GRROY,18989
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=uBbsZAOhhjOCb2CG-32QlRIRJO51TNu8e9AF2BATAmI,20418
20
- python_pq-0.6.3.dist-info/licenses/LICENSE,sha256=Bx9dUtjCpaluWGalMqkI3SGkpPxx_AYdJdmV5ptCM4g,1062
21
- python_pq-0.6.3.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
22
- python_pq-0.6.3.dist-info/entry_points.txt,sha256=fpK4uA3gQaFpcDmdy5GFbP4VP7gaJcn4H4GJyMzRIM8,36
23
- python_pq-0.6.3.dist-info/METADATA,sha256=AUG4LOrjj2z__5BOiA8V4fMeNSlF4NXbQdvIu4dPwKc,10141
24
- python_pq-0.6.3.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,,