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.
Files changed (23) hide show
  1. {python_pq-0.6.2 → python_pq-0.7.0}/PKG-INFO +1 -1
  2. {python_pq-0.6.2 → python_pq-0.7.0}/pyproject.toml +1 -1
  3. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/__init__.py +1 -1
  4. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/client.py +4 -0
  5. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/worker.py +522 -72
  6. {python_pq-0.6.2 → python_pq-0.7.0}/LICENSE +0 -0
  7. {python_pq-0.6.2 → python_pq-0.7.0}/README.md +0 -0
  8. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/config.py +0 -0
  9. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/logging.py +0 -0
  10. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/README +0 -0
  11. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/__init__.py +0 -0
  12. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/env.py +0 -0
  13. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/script.py.mako +0 -0
  14. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  15. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  16. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  17. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  18. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  19. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/migrations/versions/__init__.py +0 -0
  20. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/models.py +0 -0
  21. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/priority.py +0 -0
  22. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/registry.py +0 -0
  23. {python_pq-0.6.2 → python_pq-0.7.0}/src/pq/serialization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-pq
3
- Version: 0.6.2
3
+ Version: 0.7.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.6.2"
3
+ version = "0.7.0"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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.2"
8
+ __version__ = "0.7.0"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -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
- # 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)
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
- # 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}")
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(f"Starting PQ worker (priorities: {priority_names})...")
414
+ logger.info(
415
+ f"Starting PQ worker (priorities: {priority_names},"
416
+ f" concurrency: {concurrency})..."
417
+ )
338
418
  else:
339
- logger.info("Starting PQ worker (fork isolation enabled)...")
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