queuerPy 0.20.0__tar.gz → 0.22.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 (54) hide show
  1. {queuerpy-0.20.0/queuerPy.egg-info → queuerpy-0.22.0}/PKG-INFO +3 -2
  2. {queuerpy-0.20.0 → queuerpy-0.22.0}/README.md +2 -1
  3. {queuerpy-0.20.0 → queuerpy-0.22.0}/_version.py +1 -1
  4. queuerpy-0.22.0/core/context.py +11 -0
  5. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/runner.py +135 -66
  6. {queuerpy-0.20.0 → queuerpy-0.22.0}/database/db_job.py +112 -66
  7. {queuerpy-0.20.0 → queuerpy-0.22.0}/database/db_worker.py +44 -35
  8. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/database.py +2 -1
  9. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/options.py +8 -2
  10. {queuerpy-0.20.0 → queuerpy-0.22.0}/pyproject.toml +2 -2
  11. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer.py +36 -7
  12. {queuerpy-0.20.0 → queuerpy-0.22.0/queuerPy.egg-info}/PKG-INFO +3 -2
  13. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuerPy.egg-info/SOURCES.txt +2 -0
  14. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_global.py +5 -0
  15. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_job.py +28 -5
  16. {queuerpy-0.20.0 → queuerpy-0.22.0}/sql/job.sql +4 -0
  17. {queuerpy-0.20.0 → queuerpy-0.22.0}/LICENSE +0 -0
  18. {queuerpy-0.20.0 → queuerpy-0.22.0}/MANIFEST.in +0 -0
  19. {queuerpy-0.20.0 → queuerpy-0.22.0}/__init__.py +0 -0
  20. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/__init__.py +0 -0
  21. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/broadcaster.py +0 -0
  22. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/listener.py +0 -0
  23. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/retryer.py +0 -0
  24. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/scheduler.py +0 -0
  25. {queuerpy-0.20.0 → queuerpy-0.22.0}/core/ticker.py +0 -0
  26. {queuerpy-0.20.0 → queuerpy-0.22.0}/database/__init__.py +0 -0
  27. {queuerpy-0.20.0 → queuerpy-0.22.0}/database/db_listener.py +0 -0
  28. {queuerpy-0.20.0 → queuerpy-0.22.0}/database/db_master.py +0 -0
  29. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/__init__.py +0 -0
  30. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/error.py +0 -0
  31. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/logging.py +0 -0
  32. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/sql.py +0 -0
  33. {queuerpy-0.20.0 → queuerpy-0.22.0}/helper/task.py +0 -0
  34. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/__init__.py +0 -0
  35. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/batch_job.py +0 -0
  36. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/connection.py +0 -0
  37. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/job.py +0 -0
  38. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/master.py +0 -0
  39. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/options_on_error.py +0 -0
  40. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/task.py +0 -0
  41. {queuerpy-0.20.0 → queuerpy-0.22.0}/model/worker.py +0 -0
  42. {queuerpy-0.20.0 → queuerpy-0.22.0}/py.typed +0 -0
  43. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuerPy.egg-info/dependency_links.txt +0 -0
  44. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuerPy.egg-info/requires.txt +0 -0
  45. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuerPy.egg-info/top_level.txt +0 -0
  46. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_listener.py +0 -0
  47. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_master.py +0 -0
  48. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_next_interval.py +0 -0
  49. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_task.py +0 -0
  50. {queuerpy-0.20.0 → queuerpy-0.22.0}/queuer_worker.py +0 -0
  51. {queuerpy-0.20.0 → queuerpy-0.22.0}/setup.cfg +0 -0
  52. {queuerpy-0.20.0 → queuerpy-0.22.0}/sql/master.sql +0 -0
  53. {queuerpy-0.20.0 → queuerpy-0.22.0}/sql/notify.sql +0 -0
  54. {queuerpy-0.20.0 → queuerpy-0.22.0}/sql/worker.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: A Python implementation of the queuer system - a job queuing and processing system with PostgreSQL backend
5
5
  Author-email: Simon Herrmann <siherrmann@users.noreply.github.com>
6
6
  Maintainer-email: Simon Herrmann <siherrmann@users.noreply.github.com>
@@ -43,7 +43,7 @@ Dynamic: license-file
43
43
 
44
44
  # queuerPy
45
45
 
46
- [![PyPi](https://pythonico.leapcell.app/pypi/queuerPy.svg?style=shields&data=n,v,d)](https://pypi.org/project/queuerPy/)
46
+ [![PyPi](https://img.shields.io/pypi/v/queuerPy.svg)](https://pypi.org/project/queuerPy)
47
47
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
48
48
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
49
49
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
@@ -454,6 +454,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
454
454
 
455
455
  - **Keyword Arguments Support**: Python functions can use both positional arguments (`*args`) and keyword arguments (`**kwargs`), while maintaining compatibility with Go jobs that use only positional parameters.
456
456
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
457
+ - **Multiprocessing pool**: Usage of a multiprocessing pool to reuse memory so global variables can be reused after first load.
457
458
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
458
459
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
459
460
  - **Panic Recovery**: Automatic recovery for all running jobs in case of unexpected failures.
@@ -1,6 +1,6 @@
1
1
  # queuerPy
2
2
 
3
- [![PyPi](https://pythonico.leapcell.app/pypi/queuerPy.svg?style=shields&data=n,v,d)](https://pypi.org/project/queuerPy/)
3
+ [![PyPi](https://img.shields.io/pypi/v/queuerPy.svg)](https://pypi.org/project/queuerPy)
4
4
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
5
5
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
@@ -411,6 +411,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
411
411
 
412
412
  - **Keyword Arguments Support**: Python functions can use both positional arguments (`*args`) and keyword arguments (`**kwargs`), while maintaining compatibility with Go jobs that use only positional parameters.
413
413
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
414
+ - **Multiprocessing pool**: Usage of a multiprocessing pool to reuse memory so global variables can be reused after first load.
414
415
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
415
416
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
416
417
  - **Panic Recovery**: Automatic recovery for all running jobs in case of unexpected failures.
@@ -1,6 +1,6 @@
1
1
  """Version information for queuerPy package."""
2
2
 
3
- __version__ = "0.20.0"
3
+ __version__ = "0.22.0"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # For compatibility
@@ -0,0 +1,11 @@
1
+ """
2
+ Context management for the queuer implementation.
3
+ Provides a way to store tracking information across asynchronous and concurrent flows.
4
+ """
5
+
6
+ from contextvars import ContextVar
7
+ from typing import Optional
8
+ from uuid import UUID
9
+
10
+ # Context variable to track the RID of the currently executing job.
11
+ current_job_rid: ContextVar[Optional[UUID]] = ContextVar("current_job_rid", default=None)
@@ -15,115 +15,177 @@ from ..helper.logging import get_logger
15
15
  logger = get_logger(__name__)
16
16
 
17
17
 
18
- class Runner(multiprocessing.Process):
18
+ class Runner:
19
19
  """
20
20
  The core execution environment. Manages the execution of a single task
21
- in a separate, reliably killable multiprocessing context, and retrieves its result.
21
+ in a multiprocessing pool for efficient process reuse.
22
+
23
+ Supports cancellation with automatic periodic checking:
24
+ - Async tasks are monitored every 0.1s and can be cancelled mid-execution
25
+ - Sync tasks can only be cancelled before they start (Python limitation)
22
26
 
23
27
  :param task: The synchronous or asynchronous function to execute.
24
28
  :param args: Arguments to pass to the task function.
25
- :param name_prefix: Prefix for the process name (e.g., "Job-Runner").
29
+ :param pool: Multiprocessing.Pool for process reuse (required).
30
+ :param setup_func: Optional callable to run before the task (e.g., for setting ContextVars).
31
+ :param kwargs: Keyword arguments to pass to the task function.
26
32
  """
27
33
 
28
34
  def __init__(
29
35
  self,
30
36
  task: Callable[..., Any],
31
37
  *args: Any,
38
+ pool: Any,
39
+ setup_func: Optional[Callable[[], None]] = None,
32
40
  **kwargs: Any,
33
41
  ):
34
42
  """
35
- Initializes the Runner process.
43
+ Initializes the Runner.
36
44
 
37
45
  :param task: The synchronous or asynchronous function to execute.
38
46
  :param args: Arguments to pass to the task function.
47
+ :param pool: Multiprocessing.Pool for process reuse (required).
48
+ :param kwargs: Keyword arguments to pass to the task function.
39
49
  """
40
- super().__init__(name=task.__name__, daemon=True)
41
50
  self.task = task
42
51
  self.args = args
43
52
  self.kwargs = kwargs
44
53
  self.is_async = inspect.iscoroutinefunction(task)
45
- self._result_queue: multiprocessing.Queue[Any] = multiprocessing.Queue(1)
54
+ self.name = task.__name__
55
+ self._pool = pool
56
+ self._setup_func = setup_func
57
+ self._async_result: Optional[Any] = None
58
+ # Use Manager Event for cross-process sharing (pickleable)
59
+ manager = multiprocessing.Manager()
60
+ self._cancel_event = manager.Event()
61
+
62
+ @property
63
+ def pid(self) -> Optional[int]:
64
+ """Process ID (not available in pool mode)."""
65
+ return None
66
+
67
+ @property
68
+ def daemon(self) -> bool:
69
+ """Daemon status (always True for pool workers)."""
70
+ return True
71
+
72
+ @property
73
+ def exitcode(self) -> Optional[int]:
74
+ """Exit code (not available in pool mode)."""
75
+ return None
76
+
77
+ @staticmethod
78
+ def _execute_in_pool_worker(
79
+ task: Callable[..., Any],
80
+ args: tuple,
81
+ kwargs: dict,
82
+ is_async: bool,
83
+ cancel_event: Any,
84
+ setup_func: Optional[Callable[[], None]] = None,
85
+ ) -> Any:
86
+ """
87
+ Static method to execute tasks in pool workers.
88
+ Handles both sync and async tasks, returns result directly.
89
+ Checks for cancellation before and during execution.
90
+ """
91
+ # Check if task was cancelled before execution
92
+ if cancel_event.is_set():
93
+ raise asyncio.CancelledError("Task was cancelled before execution")
46
94
 
47
- def go(self) -> None:
48
- """Starts the Runner process in the background."""
49
- self.start()
95
+ if setup_func is not None:
96
+ setup_func()
50
97
 
51
- def run(self) -> None:
98
+ if is_async:
99
+ # For async tasks, monitor cancellation during execution
100
+ result = asyncio.run(
101
+ Runner._run_async_with_cancellation(task, args, kwargs, cancel_event)
102
+ )
103
+ else:
104
+ # For sync tasks, just execute (can't interrupt mid-execution easily)
105
+ result = task(*args, **kwargs)
106
+ return result
107
+
108
+ @staticmethod
109
+ async def _run_async_with_cancellation(
110
+ task: Callable[..., Any],
111
+ args: tuple,
112
+ kwargs: dict,
113
+ cancel_event: Any,
114
+ ) -> Any:
52
115
  """
53
- The main execution method, executed in the child process.
54
- It executes the assigned task, sends the result to the queue, and exits.
116
+ Run async task with periodic cancellation checking.
117
+ Monitors cancel_event and cancels the task if it's set.
55
118
  """
119
+ # Create the task
120
+ task_coro = task(*args, **kwargs)
121
+ task_obj = asyncio.create_task(task_coro)
122
+
123
+ # Monitor for cancellation every second
124
+ async def monitor_cancellation():
125
+ while not task_obj.done():
126
+ if cancel_event.is_set():
127
+ task_obj.cancel()
128
+ break
129
+ await asyncio.sleep(1)
130
+
131
+ monitor = asyncio.create_task(monitor_cancellation())
56
132
  try:
57
- if self.is_async:
58
- result = asyncio.run(self.task(*self.args, **self.kwargs))
59
- else:
60
- result = self.task(*self.args, **self.kwargs)
61
-
62
- try:
63
- self._result_queue.put(result)
64
- except BrokenPipeError as e:
65
- pass
66
- except Exception as e:
67
- logger.debug(traceback.format_exc())
133
+ result = await task_obj
134
+ monitor.cancel()
135
+ return result
136
+ except asyncio.CancelledError:
137
+ monitor.cancel()
138
+ raise asyncio.CancelledError("Task was cancelled during execution")
139
+ finally:
68
140
  try:
69
- self._result_queue.put(e)
70
- except BrokenPipeError as e:
141
+ await monitor
142
+ except asyncio.CancelledError:
71
143
  pass
72
144
 
145
+ def go(self) -> None:
146
+ """Starts the Runner in the background using the pool."""
147
+ self._async_result = self._pool.apply_async(
148
+ Runner._execute_in_pool_worker,
149
+ (self.task, self.args, self.kwargs, self.is_async, self._cancel_event, self._setup_func),
150
+ )
151
+
73
152
  def get_results(self, timeout: Optional[float] = None) -> Any:
74
153
  """
75
- Waits for the runner process to complete and returns the result.
154
+ Waits for the runner to complete and returns the result.
76
155
 
77
156
  :param timeout: Time in seconds to wait for the result. If None, waits indefinitely.
78
157
  :returns: The result returned by the executed task function.
79
158
  :raises TimeoutError: If the result is not available within the specified timeout.
80
- :raises Exception: If the task execution in the runner process failed.
159
+ :raises asyncio.CancelledError: If the task was cancelled.
160
+ :raises Exception: If the task execution failed.
81
161
  """
82
162
  logger.debug(f"Runner.get_results() called for {self.name}, timeout={timeout}")
163
+ assert (
164
+ self._async_result is not None
165
+ ), "go() must be called before get_results()"
83
166
  try:
84
- self.join(timeout=timeout)
85
-
86
- if self.is_alive():
87
- raise TimeoutError(f"runner timed out after {timeout} seconds.")
88
-
89
- if self._result_queue.empty():
90
- if self.exitcode == 0:
91
- return None
92
- else:
93
- raise Exception(f"runner failed with code {self.exitcode}")
94
-
95
- try:
96
- result = self._result_queue.get_nowait()
97
- except Exception as e:
98
- raise
99
-
100
- if isinstance(result, Exception):
101
- raise result
102
-
167
+ result = self._async_result.get(timeout=timeout)
103
168
  return result
104
- except Exception as e:
105
- raise e
169
+ except multiprocessing.TimeoutError:
170
+ raise TimeoutError(f"runner timed out after {timeout} seconds.")
171
+ except asyncio.CancelledError:
172
+ logger.debug(f"Runner {self.name} was cancelled")
173
+ raise
106
174
 
107
175
  def cancel(self) -> bool:
108
176
  """
109
- Attempts to cancel the running process.
177
+ Attempts to cancel the running task by setting the cancellation event.
110
178
 
111
- :returns: True if the process was successfully terminated, False otherwise.
112
- :raises: Exception if an error occurs during termination.
113
- """
114
- try:
115
- if not self.is_alive():
116
- logger.debug(f"Runner {self.name}: Already finished")
117
- return True
179
+ Cancellation behavior:
180
+ - Async tasks: Checked every second during execution, task will be cancelled
181
+ - Sync tasks: Only checked before execution starts (cannot interrupt mid-execution)
182
+ - Queued tasks: Cancelled before they begin executing
118
183
 
119
- logger.debug(f"Cancelling runner {self.name}")
120
- self.terminate()
121
- self.join(timeout=5.0) # Wait up to 5 seconds for clean termination
122
- result = not self.is_alive()
123
- return result
124
- except Exception as e:
125
- logger.warning(f"Error cancelling runner {self.name}: {e}")
126
- return False
184
+ :returns: True if cancellation signal was sent successfully.
185
+ """
186
+ logger.debug(f"Cancelling runner {self.name}")
187
+ self._cancel_event.set()
188
+ return True
127
189
 
128
190
 
129
191
  class SmallRunner(threading.Thread):
@@ -253,7 +315,11 @@ class SmallRunner(threading.Thread):
253
315
 
254
316
 
255
317
  def go_func(
256
- func: Callable[..., Any], use_mp: bool = True, *args: Any, **kwargs: Any
318
+ func: Callable[..., Any],
319
+ use_mp: bool = True,
320
+ *args: Any,
321
+ pool: Any = None,
322
+ **kwargs: Any,
257
323
  ) -> Union[Runner, SmallRunner]:
258
324
  """
259
325
  Go-like async function execution factory. Selects the appropriate runner.
@@ -262,11 +328,14 @@ def go_func(
262
328
  :param use_mp: If True (default), uses the multiprocessing Runner (isolated).
263
329
  If False, uses the threading SmallRunner (shared state).
264
330
  :param args: Positional arguments for the function.
331
+ :param pool: Multiprocessing.Pool for process reuse (required if use_mp=True).
265
332
  :param kwargs: Keyword arguments for the function.
266
333
  :returns: A running Runner or SmallRunner instance.
267
334
  """
268
335
  if use_mp:
269
- runner = Runner(func, *args, **kwargs)
336
+ if pool is None:
337
+ raise ValueError("pool is required when use_mp=True")
338
+ runner = Runner(func, *args, pool=pool, **kwargs)
270
339
  else:
271
340
  runner = SmallRunner(func, *args, **kwargs)
272
341
 
@@ -186,19 +186,22 @@ class JobDBHandler:
186
186
  if self.db.instance is None:
187
187
  raise ValueError("Database connection is not established")
188
188
 
189
- with self.db.instance.cursor(row_factory=dict_row) as cur:
190
- cur.execute(
191
- """
192
- SELECT * FROM update_job_initial(%s);
193
- """,
194
- (worker.id,),
195
- )
196
-
197
- jobs: List[Job] = []
198
- for row in cur.fetchall():
199
- jobs.append(Job.from_row(row))
200
-
201
- return jobs
189
+ try:
190
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
191
+ cur.execute(
192
+ """
193
+ SELECT * FROM update_job_initial(%s);
194
+ """,
195
+ (worker.id,),
196
+ )
197
+
198
+ jobs: List[Job] = []
199
+ for row in cur.fetchall():
200
+ jobs.append(Job.from_row(row))
201
+
202
+ return jobs
203
+ except Exception as e:
204
+ raise QueuerError("update jobs initial", e)
202
205
 
203
206
  def update_job_final(self, job: Job) -> Job:
204
207
  """
@@ -303,17 +306,20 @@ class JobDBHandler:
303
306
  if self.db.instance is None:
304
307
  raise ValueError("Database connection is not established")
305
308
 
306
- with self.db.instance.cursor(row_factory=dict_row) as cur:
307
- cur.execute(
308
- "SELECT * FROM select_all_jobs(%s, %s, %s);",
309
- (self.encryption_key or "", last_id, entries),
310
- )
311
-
312
- jobs: List[Job] = []
313
- for row in cur.fetchall():
314
- jobs.append(Job.from_row(row))
315
-
316
- return jobs
309
+ try:
310
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
311
+ cur.execute(
312
+ "SELECT * FROM select_all_jobs(%s, %s, %s);",
313
+ (self.encryption_key or "", last_id, entries),
314
+ )
315
+
316
+ jobs: List[Job] = []
317
+ for row in cur.fetchall():
318
+ jobs.append(Job.from_row(row))
319
+
320
+ return jobs
321
+ except Exception as e:
322
+ raise QueuerError("select all jobs", e)
317
323
 
318
324
  def select_all_jobs_by_worker_rid(
319
325
  self, worker_rid: UUID, last_id: int = 0, entries: int = 100
@@ -330,17 +336,20 @@ class JobDBHandler:
330
336
  if self.db.instance is None:
331
337
  raise ValueError("Database connection is not established")
332
338
 
333
- with self.db.instance.cursor(row_factory=dict_row) as cur:
334
- cur.execute(
335
- "SELECT * FROM select_all_jobs_by_worker_rid(%s, %s, %s, %s);",
336
- (self.encryption_key or "", worker_rid, last_id, entries),
337
- )
338
-
339
- jobs: List[Job] = []
340
- for row in cur.fetchall():
341
- jobs.append(Job.from_row(row))
342
-
343
- return jobs
339
+ try:
340
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
341
+ cur.execute(
342
+ "SELECT * FROM select_all_jobs_by_worker_rid(%s, %s, %s, %s);",
343
+ (self.encryption_key or "", str(worker_rid), last_id, entries),
344
+ )
345
+
346
+ jobs: List[Job] = []
347
+ for row in cur.fetchall():
348
+ jobs.append(Job.from_row(row))
349
+
350
+ return jobs
351
+ except Exception as e:
352
+ raise QueuerError("select all jobs by worker rid", e)
344
353
 
345
354
  def select_all_jobs_by_search(
346
355
  self, search: str, last_id: int = 0, entries: int = 100
@@ -358,17 +367,48 @@ class JobDBHandler:
358
367
  if self.db.instance is None:
359
368
  raise ValueError("Database connection is not established")
360
369
 
361
- with self.db.instance.cursor(row_factory=dict_row) as cur:
362
- cur.execute(
363
- "SELECT * FROM select_all_jobs_by_search(%s, %s, %s, %s);",
364
- (self.encryption_key or "", search, last_id, entries),
365
- )
370
+ try:
371
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
372
+ cur.execute(
373
+ "SELECT * FROM select_all_jobs_by_search(%s, %s, %s, %s);",
374
+ (self.encryption_key or "", search, last_id, entries),
375
+ )
366
376
 
367
- jobs: List[Job] = []
368
- for row in cur.fetchall():
369
- jobs.append(Job.from_row(row))
377
+ jobs: List[Job] = []
378
+ for row in cur.fetchall():
379
+ jobs.append(Job.from_row(row))
370
380
 
371
- return jobs
381
+ return jobs
382
+ except Exception as e:
383
+ raise QueuerError("select all jobs by search", e)
384
+
385
+ def select_jobs_without_worker(
386
+ self, last_id: int = 0, entries: int = 100
387
+ ) -> List[Job]:
388
+ """
389
+ Select all jobs that are not assigned to any worker.
390
+
391
+ :param last_id: Last job ID from previous page.
392
+ :param entries: Number of entries to retrieve.
393
+ :return: List of Job instances.
394
+ """
395
+ if self.db.instance is None:
396
+ raise ValueError("Database connection is not established")
397
+
398
+ try:
399
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
400
+ cur.execute(
401
+ "SELECT * FROM select_jobs_without_worker(%s, %s, %s);",
402
+ (self.encryption_key or "", last_id, entries),
403
+ )
404
+
405
+ jobs: List[Job] = []
406
+ for row in cur.fetchall():
407
+ jobs.append(Job.from_row(row))
408
+
409
+ return jobs
410
+ except Exception as e:
411
+ raise QueuerError("select jobs without worker", e)
372
412
 
373
413
  def add_retention_archive(self, days: int) -> None:
374
414
  """
@@ -441,17 +481,20 @@ class JobDBHandler:
441
481
  if self.db.instance is None:
442
482
  raise ValueError("Database connection is not established")
443
483
 
444
- with self.db.instance.cursor(row_factory=dict_row) as cur:
445
- cur.execute(
446
- "SELECT * FROM select_all_jobs_from_archive(%s, %s, %s);",
447
- (self.encryption_key or "", last_id, entries),
448
- )
449
-
450
- jobs: List[Job] = []
451
- for row in cur.fetchall():
452
- jobs.append(Job.from_row(row))
453
-
454
- return jobs
484
+ try:
485
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
486
+ cur.execute(
487
+ "SELECT * FROM select_all_jobs_from_archive(%s, %s, %s);",
488
+ (self.encryption_key or "", last_id, entries),
489
+ )
490
+
491
+ jobs: List[Job] = []
492
+ for row in cur.fetchall():
493
+ jobs.append(Job.from_row(row))
494
+
495
+ return jobs
496
+ except Exception as e:
497
+ raise QueuerError("select all jobs from archive", e)
455
498
 
456
499
  def select_all_jobs_from_archive_by_search(
457
500
  self, search: str, last_id: int = 0, entries: int = 100
@@ -468,14 +511,17 @@ class JobDBHandler:
468
511
  if self.db.instance is None:
469
512
  raise ValueError("Database connection is not established")
470
513
 
471
- with self.db.instance.cursor(row_factory=dict_row) as cur:
472
- cur.execute(
473
- "SELECT * FROM select_all_jobs_from_archive_by_search(%s, %s, %s, %s);",
474
- (self.encryption_key or "", search, last_id, entries),
475
- )
476
-
477
- jobs: List[Job] = []
478
- for row in cur.fetchall():
479
- jobs.append(Job.from_row(row))
480
-
481
- return jobs
514
+ try:
515
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
516
+ cur.execute(
517
+ "SELECT * FROM select_all_jobs_from_archive_by_search(%s, %s, %s, %s);",
518
+ (self.encryption_key or "", search, last_id, entries),
519
+ )
520
+
521
+ jobs: List[Job] = []
522
+ for row in cur.fetchall():
523
+ jobs.append(Job.from_row(row))
524
+
525
+ return jobs
526
+ except Exception as e:
527
+ raise QueuerError("select all jobs from archive by search", e)
@@ -195,14 +195,17 @@ class WorkerDBHandler:
195
195
  if self.db.instance is None:
196
196
  raise ValueError("Database connection is not established")
197
197
 
198
- with self.db.instance.cursor(row_factory=dict_row) as cur:
199
- cur.execute("SELECT * FROM select_all_workers(%s, %s);", (last_id, entries))
200
-
201
- workers: List[Worker] = []
202
- for row in cur.fetchall():
203
- workers.append(Worker.from_row(row))
204
-
205
- return workers
198
+ try:
199
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
200
+ cur.execute("SELECT * FROM select_all_workers(%s, %s);", (last_id, entries))
201
+
202
+ workers: List[Worker] = []
203
+ for row in cur.fetchall():
204
+ workers.append(Worker.from_row(row))
205
+
206
+ return workers
207
+ except Exception as e:
208
+ raise QueuerError("select all workers", e)
206
209
 
207
210
  def select_all_workers_by_search(
208
211
  self, search: str, last_id: int = 0, entries: int = 100
@@ -215,17 +218,20 @@ class WorkerDBHandler:
215
218
  if self.db.instance is None:
216
219
  raise ValueError("Database connection is not established")
217
220
 
218
- with self.db.instance.cursor(row_factory=dict_row) as cur:
219
- cur.execute(
220
- "SELECT * FROM select_all_workers_by_search(%s, %s, %s);",
221
- (search, last_id, entries),
222
- )
223
-
224
- workers: List[Worker] = []
225
- for row in cur.fetchall():
226
- workers.append(Worker.from_row(row))
227
-
228
- return workers
221
+ try:
222
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
223
+ cur.execute(
224
+ "SELECT * FROM select_all_workers_by_search(%s, %s, %s);",
225
+ (search, last_id, entries),
226
+ )
227
+
228
+ workers: List[Worker] = []
229
+ for row in cur.fetchall():
230
+ workers.append(Worker.from_row(row))
231
+
232
+ return workers
233
+ except Exception as e:
234
+ raise QueuerError("select all workers by search", e)
229
235
 
230
236
  def select_all_connections(self) -> List[ConnectionModel]:
231
237
  """
@@ -235,19 +241,22 @@ class WorkerDBHandler:
235
241
  if self.db.instance is None:
236
242
  raise ValueError("Database connection is not established")
237
243
 
238
- with self.db.instance.cursor(row_factory=dict_row) as cur:
239
- cur.execute("SELECT * FROM select_all_connections();")
240
-
241
- connections: List[ConnectionModel] = []
242
- for row in cur.fetchall():
243
- connection = ConnectionModel(
244
- pid=row["output_pid"],
245
- database=row["output_datname"],
246
- username=row["output_usename"],
247
- application_name=row["output_application_name"],
248
- query=row["output_query"],
249
- state=row["output_state"],
250
- )
251
- connections.append(connection)
252
-
253
- return connections
244
+ try:
245
+ with self.db.instance.cursor(row_factory=dict_row) as cur:
246
+ cur.execute("SELECT * FROM select_all_connections();")
247
+
248
+ connections: List[ConnectionModel] = []
249
+ for row in cur.fetchall():
250
+ connection = ConnectionModel(
251
+ pid=row["output_pid"],
252
+ database=row["output_datname"],
253
+ username=row["output_usename"],
254
+ application_name=row["output_application_name"],
255
+ query=row["output_query"],
256
+ state=row["output_state"],
257
+ )
258
+ connections.append(connection)
259
+
260
+ return connections
261
+ except Exception as e:
262
+ raise QueuerError("select all connections", e)
@@ -88,8 +88,9 @@ class DatabaseConfiguration:
88
88
  f"user={self.username} "
89
89
  f"password={self.password} "
90
90
  f"sslmode={self.sslmode} "
91
+ f"connect_timeout=10 "
91
92
  f"application_name=queuer "
92
- f"options='-c search_path={self.schema}'"
93
+ f"options='-c search_path={self.schema} -c statement_timeout=30000'"
93
94
  )
94
95
 
95
96
 
@@ -78,6 +78,7 @@ class Options:
78
78
 
79
79
  on_error: Optional[OnError] = None
80
80
  schedule: Optional[Schedule] = None
81
+ parent_rid: Optional[str] = None
81
82
 
82
83
  def is_valid(self) -> bool:
83
84
  """
@@ -100,6 +101,7 @@ class Options:
100
101
  return {
101
102
  "on_error": self.on_error.to_dict() if self.on_error else None,
102
103
  "schedule": self.schedule.to_dict() if self.schedule else None,
104
+ "parent_rid": self.parent_rid,
103
105
  }
104
106
 
105
107
  @classmethod
@@ -115,11 +117,15 @@ class Options:
115
117
  options.on_error = OnError.from_dict(data["on_error"])
116
118
  if data.get("schedule"):
117
119
  options.schedule = Schedule.from_dict(data["schedule"])
120
+ if data.get("parent_rid"):
121
+ options.parent_rid = data["parent_rid"]
118
122
  return options
119
123
 
120
124
 
121
125
  def new_options(
122
- on_error: Optional[OnError] = None, schedule: Optional[Schedule] = None
126
+ on_error: Optional[OnError] = None,
127
+ schedule: Optional[Schedule] = None,
128
+ parent_rid: Optional[str] = None,
123
129
  ) -> Options:
124
130
  """
125
131
  Create new options. Mirrors Go's pattern.
@@ -128,4 +134,4 @@ def new_options(
128
134
  :param schedule: Schedule options.
129
135
  :return: Options instance.
130
136
  """
131
- return Options(on_error=on_error, schedule=schedule)
137
+ return Options(on_error=on_error, schedule=schedule, parent_rid=parent_rid)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "queuerPy"
7
- version = "0.20.0"
7
+ version = "0.22.0"
8
8
  description = "A Python implementation of the queuer system - a job queuing and processing system with PostgreSQL backend"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -92,7 +92,7 @@ pythonVersion = "3.8"
92
92
  pythonPlatform = "All"
93
93
 
94
94
  [tool.mypy]
95
- python_version = "0.20.0"
95
+ python_version = "0.22.0"
96
96
  warn_return_any = true
97
97
  warn_unused_configs = true
98
98
  disallow_untyped_defs = true
@@ -6,6 +6,7 @@ Mirrors the Go Queuer struct with Python async patterns.
6
6
  # Standard library imports
7
7
  import asyncio
8
8
  import json
9
+ import multiprocessing
9
10
  import os
10
11
  import threading
11
12
  import time
@@ -121,6 +122,9 @@ class Queuer(
121
122
  # Active runners (jobs currently executing)
122
123
  self.active_runners: Dict[UUID, Runner] = {}
123
124
 
125
+ # Set pool size based on max_concurrency
126
+ self._pool_size = max_concurrency if max_concurrency > 0 else 4
127
+
124
128
  # Database listeners (will be initialized in start())
125
129
  self.job_db_listener: Optional[QueuerListener] = None
126
130
  self.job_archive_db_listener: Optional[QueuerListener] = None
@@ -170,6 +174,17 @@ class Queuer(
170
174
  self._stopped.clear()
171
175
  self._cancel_event = asyncio.Event()
172
176
 
177
+ # Create process pool for job execution
178
+ try:
179
+ self._process_pool = multiprocessing.Pool(
180
+ processes=self._pool_size,
181
+ maxtasksperchild=100,
182
+ )
183
+ logger.info(f"Created process pool with {self._pool_size} workers")
184
+ except Exception as e:
185
+ logger.error(f"Failed to create process pool: {e}")
186
+ raise
187
+
173
188
  # Set up database listeners
174
189
  try:
175
190
  self.job_db_listener = new_queuer_db_listener(
@@ -293,9 +308,24 @@ class Queuer(
293
308
 
294
309
  # Cancel all active runners
295
310
  for runner_id, runner in list(self.active_runners.items()):
296
- runner.cancel()
311
+ cancelled = runner.cancel()
312
+ if not cancelled:
313
+ # Pool-based runner, need to restart pool
314
+ logger.warning("Pool-based runner cannot be individually cancelled")
297
315
  del self.active_runners[runner_id]
298
316
 
317
+ # Clean up process pool
318
+ if self._process_pool:
319
+ logger.info("Closing process pool")
320
+ try:
321
+ self._process_pool.close()
322
+ # Force terminate if workers didn't finish gracefully
323
+ self._process_pool.terminate()
324
+ self._process_pool.join()
325
+ self._process_pool = None
326
+ except Exception as e:
327
+ logger.warning(f"Error closing process pool: {e}")
328
+
299
329
  # Cancel the context to stop the queuer
300
330
  if self._cancel_event:
301
331
  self._cancel_event.set()
@@ -304,12 +334,11 @@ class Queuer(
304
334
  time.sleep(0.1)
305
335
 
306
336
  # Close database connection
307
- if self.database:
308
- logger.info("Closing database connection")
309
- try:
310
- self.database.close()
311
- except Exception as e:
312
- logger.error(f"Error closing database connection: {e}")
337
+ logger.info("Closing database connection")
338
+ try:
339
+ self.database.close()
340
+ except Exception as e:
341
+ logger.error(f"Error closing database connection: {e}")
313
342
 
314
343
  logger.info("Queuer stopped")
315
344
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: A Python implementation of the queuer system - a job queuing and processing system with PostgreSQL backend
5
5
  Author-email: Simon Herrmann <siherrmann@users.noreply.github.com>
6
6
  Maintainer-email: Simon Herrmann <siherrmann@users.noreply.github.com>
@@ -43,7 +43,7 @@ Dynamic: license-file
43
43
 
44
44
  # queuerPy
45
45
 
46
- [![PyPi](https://pythonico.leapcell.app/pypi/queuerPy.svg?style=shields&data=n,v,d)](https://pypi.org/project/queuerPy/)
46
+ [![PyPi](https://img.shields.io/pypi/v/queuerPy.svg)](https://pypi.org/project/queuerPy)
47
47
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
48
48
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
49
49
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
@@ -454,6 +454,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
454
454
 
455
455
  - **Keyword Arguments Support**: Python functions can use both positional arguments (`*args`) and keyword arguments (`**kwargs`), while maintaining compatibility with Go jobs that use only positional parameters.
456
456
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
457
+ - **Multiprocessing pool**: Usage of a multiprocessing pool to reuse memory so global variables can be reused after first load.
457
458
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
458
459
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
459
460
  - **Panic Recovery**: Automatic recovery for all running jobs in case of unexpected failures.
@@ -26,6 +26,7 @@ queuer_worker.py
26
26
  ./queuer_worker.py
27
27
  ./core/__init__.py
28
28
  ./core/broadcaster.py
29
+ ./core/context.py
29
30
  ./core/listener.py
30
31
  ./core/retryer.py
31
32
  ./core/runner.py
@@ -57,6 +58,7 @@ queuer_worker.py
57
58
  ./sql/worker.sql
58
59
  core/__init__.py
59
60
  core/broadcaster.py
61
+ core/context.py
60
62
  core/listener.py
61
63
  core/retryer.py
62
64
  core/runner.py
@@ -1,3 +1,4 @@
1
+ import multiprocessing
1
2
  import threading
2
3
  from typing import Any, Callable, Dict, Optional
3
4
 
@@ -45,6 +46,10 @@ class QueuerGlobalMixin:
45
46
  self.tasks: Dict[str, Task] = {}
46
47
  self.next_interval_funcs: Dict[str, Callable[..., Any]] = {}
47
48
 
49
+ # Process pool for runner reuse (initialized in start())
50
+ self._process_pool: Optional[Any] = None
51
+ self._pool_size: int = 4 # Default, will be set based on max_concurrency
52
+
48
53
  def initialise(
49
54
  self,
50
55
  db_config: DatabaseConfiguration,
@@ -20,11 +20,19 @@ from .model.job import Job, JobStatus, new_job as create_job
20
20
  from .model.options import Options
21
21
  from .model.worker import Worker
22
22
  from .queuer_global import QueuerGlobalMixin
23
+ from .core.context import current_job_rid
24
+ from functools import partial
23
25
 
24
26
  # Set up logger
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
29
 
30
+ def _setup_job_context(job_rid: UUID) -> None:
31
+ """Helper to set the context variable in the worker process."""
32
+ current_job_rid.set(job_rid)
33
+
34
+
35
+
28
36
  class QueuerJobMixin(QueuerGlobalMixin):
29
37
  """
30
38
  Mixin class containing job-related methods for the Queuer.
@@ -50,6 +58,12 @@ class QueuerJobMixin(QueuerGlobalMixin):
50
58
  :raises Exception: If something goes wrong
51
59
  """
52
60
  options: Optional[Options] = self._merge_options(None)
61
+ current_rid = current_job_rid.get()
62
+ if current_rid:
63
+ if not options:
64
+ options = Options()
65
+ options.parent_rid = str(current_rid)
66
+
53
67
  job: Job = self._add_job(task, options, *parameters, **parameters_keyed)
54
68
 
55
69
  logger.info(f"Job added: {job.rid}")
@@ -67,6 +81,12 @@ class QueuerJobMixin(QueuerGlobalMixin):
67
81
  # Merge default options with provided options
68
82
  options_merged: Optional[Options] = self._merge_options(options)
69
83
 
84
+ current_rid = current_job_rid.get()
85
+ if current_rid:
86
+ if not options_merged:
87
+ options_merged = Options()
88
+ options_merged.parent_rid = str(current_rid)
89
+
70
90
  try:
71
91
  # Create new job
72
92
  new_job: Job = create_job(
@@ -110,6 +130,7 @@ class QueuerJobMixin(QueuerGlobalMixin):
110
130
  runner = Runner(
111
131
  task=self._wait_for_job_added_inner,
112
132
  args=(timeout_seconds,),
133
+ pool=self._process_pool,
113
134
  kwargs={},
114
135
  )
115
136
  runner.go()
@@ -421,7 +442,10 @@ class QueuerJobMixin(QueuerGlobalMixin):
421
442
  try:
422
443
  parameters = getattr(job, "parameters", [])
423
444
  parameters_keyed = getattr(job, "parameters_keyed", {})
424
- runner = Runner(task, *parameters, **parameters_keyed)
445
+ setup_func = partial(_setup_job_context, job.rid)
446
+ runner = Runner(
447
+ task, *parameters, pool=self._process_pool, setup_func=setup_func, **parameters_keyed
448
+ )
425
449
 
426
450
  logger.info(f"Created runner for job {job.rid}")
427
451
 
@@ -436,8 +460,7 @@ class QueuerJobMixin(QueuerGlobalMixin):
436
460
  return [results], False, None
437
461
 
438
462
  except asyncio.CancelledError:
439
- if runner:
440
- runner.cancel()
463
+ runner.cancel()
441
464
  return [], True, None
442
465
  except Exception as e:
443
466
  logger.error(f"Process runner task {job.task_name} failed: {e}")
@@ -507,9 +530,9 @@ class QueuerJobMixin(QueuerGlobalMixin):
507
530
  """
508
531
  try:
509
532
  # Create a Runner process for the async job
510
- runner = Runner(self._run_job, job)
533
+ runner = Runner(self._run_job, job, pool=self._process_pool)
511
534
  runner.go()
512
- logger.info(f"Scheduled job {job.rid} started in process {runner.pid}")
535
+ logger.info(f"Scheduled job {job.rid} started")
513
536
 
514
537
  except Exception as e:
515
538
  logger.error(f"Error executing scheduled job {job.rid}: {e}")
@@ -57,6 +57,10 @@ BEGIN
57
57
  ON job
58
58
  USING HASH ((options->'schedule'->>'next_interval'));
59
59
 
60
+ CREATE INDEX IF NOT EXISTS idx_job_parent_rid
61
+ ON job
62
+ USING BTREE ((options->>'parent_rid'));
63
+
60
64
  CREATE INDEX IF NOT EXISTS idx_job_worker_id ON job (worker_id);
61
65
  CREATE INDEX IF NOT EXISTS idx_job_worker_rid ON job (worker_rid);
62
66
  CREATE INDEX IF NOT EXISTS idx_job_status ON job (status);
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
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