queuerPy 0.19.0__tar.gz → 0.21.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 (53) hide show
  1. {queuerpy-0.19.0/queuerPy.egg-info → queuerpy-0.21.0}/PKG-INFO +2 -1
  2. {queuerpy-0.19.0 → queuerpy-0.21.0}/README.md +1 -0
  3. {queuerpy-0.19.0 → queuerpy-0.21.0}/_version.py +1 -1
  4. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/runner.py +129 -67
  5. {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_job.py +25 -12
  6. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/database.py +5 -0
  7. {queuerpy-0.19.0 → queuerpy-0.21.0}/pyproject.toml +2 -2
  8. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer.py +32 -1
  9. {queuerpy-0.19.0 → queuerpy-0.21.0/queuerPy.egg-info}/PKG-INFO +2 -1
  10. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_global.py +6 -1
  11. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_job.py +6 -3
  12. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_master.py +25 -14
  13. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_worker.py +13 -0
  14. {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/job.sql +58 -7
  15. {queuerpy-0.19.0 → queuerpy-0.21.0}/LICENSE +0 -0
  16. {queuerpy-0.19.0 → queuerpy-0.21.0}/MANIFEST.in +0 -0
  17. {queuerpy-0.19.0 → queuerpy-0.21.0}/__init__.py +0 -0
  18. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/__init__.py +0 -0
  19. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/broadcaster.py +0 -0
  20. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/listener.py +0 -0
  21. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/retryer.py +0 -0
  22. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/scheduler.py +0 -0
  23. {queuerpy-0.19.0 → queuerpy-0.21.0}/core/ticker.py +0 -0
  24. {queuerpy-0.19.0 → queuerpy-0.21.0}/database/__init__.py +0 -0
  25. {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_listener.py +0 -0
  26. {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_master.py +0 -0
  27. {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_worker.py +0 -0
  28. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/__init__.py +0 -0
  29. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/error.py +0 -0
  30. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/logging.py +0 -0
  31. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/sql.py +0 -0
  32. {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/task.py +0 -0
  33. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/__init__.py +0 -0
  34. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/batch_job.py +0 -0
  35. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/connection.py +0 -0
  36. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/job.py +0 -0
  37. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/master.py +0 -0
  38. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/options.py +0 -0
  39. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/options_on_error.py +0 -0
  40. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/task.py +0 -0
  41. {queuerpy-0.19.0 → queuerpy-0.21.0}/model/worker.py +0 -0
  42. {queuerpy-0.19.0 → queuerpy-0.21.0}/py.typed +0 -0
  43. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/SOURCES.txt +0 -0
  44. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/dependency_links.txt +0 -0
  45. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/requires.txt +0 -0
  46. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/top_level.txt +0 -0
  47. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_listener.py +0 -0
  48. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_next_interval.py +0 -0
  49. {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_task.py +0 -0
  50. {queuerpy-0.19.0 → queuerpy-0.21.0}/setup.cfg +0 -0
  51. {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/master.sql +0 -0
  52. {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/notify.sql +0 -0
  53. {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/worker.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.19.0
3
+ Version: 0.21.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>
@@ -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.
@@ -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.19.0"
3
+ __version__ = "0.21.0"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # For compatibility
@@ -15,115 +15,170 @@ 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 kwargs: Keyword arguments to pass to the task function.
26
31
  """
27
32
 
28
33
  def __init__(
29
34
  self,
30
35
  task: Callable[..., Any],
31
36
  *args: Any,
37
+ pool: Any,
32
38
  **kwargs: Any,
33
39
  ):
34
40
  """
35
- Initializes the Runner process.
41
+ Initializes the Runner.
36
42
 
37
43
  :param task: The synchronous or asynchronous function to execute.
38
44
  :param args: Arguments to pass to the task function.
45
+ :param pool: Multiprocessing.Pool for process reuse (required).
46
+ :param kwargs: Keyword arguments to pass to the task function.
39
47
  """
40
- super().__init__(name=task.__name__, daemon=True)
41
48
  self.task = task
42
49
  self.args = args
43
50
  self.kwargs = kwargs
44
51
  self.is_async = inspect.iscoroutinefunction(task)
45
- self._result_queue: multiprocessing.Queue[Any] = multiprocessing.Queue(1)
46
-
47
- def go(self) -> None:
48
- """Starts the Runner process in the background."""
49
- self.start()
52
+ self.name = task.__name__
53
+ self._pool = pool
54
+ self._async_result: Optional[Any] = None
55
+ # Use Manager Event for cross-process sharing (pickleable)
56
+ manager = multiprocessing.Manager()
57
+ self._cancel_event = manager.Event()
58
+
59
+ @property
60
+ def pid(self) -> Optional[int]:
61
+ """Process ID (not available in pool mode)."""
62
+ return None
63
+
64
+ @property
65
+ def daemon(self) -> bool:
66
+ """Daemon status (always True for pool workers)."""
67
+ return True
68
+
69
+ @property
70
+ def exitcode(self) -> Optional[int]:
71
+ """Exit code (not available in pool mode)."""
72
+ return None
73
+
74
+ @staticmethod
75
+ def _execute_in_pool_worker(
76
+ task: Callable[..., Any],
77
+ args: tuple,
78
+ kwargs: dict,
79
+ is_async: bool,
80
+ cancel_event: Any,
81
+ ) -> Any:
82
+ """
83
+ Static method to execute tasks in pool workers.
84
+ Handles both sync and async tasks, returns result directly.
85
+ Checks for cancellation before and during execution.
86
+ """
87
+ # Check if task was cancelled before execution
88
+ if cancel_event.is_set():
89
+ raise asyncio.CancelledError("Task was cancelled before execution")
90
+
91
+ if is_async:
92
+ # For async tasks, monitor cancellation during execution
93
+ result = asyncio.run(
94
+ Runner._run_async_with_cancellation(task, args, kwargs, cancel_event)
95
+ )
96
+ else:
97
+ # For sync tasks, just execute (can't interrupt mid-execution easily)
98
+ result = task(*args, **kwargs)
99
+ return result
50
100
 
51
- def run(self) -> None:
101
+ @staticmethod
102
+ async def _run_async_with_cancellation(
103
+ task: Callable[..., Any],
104
+ args: tuple,
105
+ kwargs: dict,
106
+ cancel_event: Any,
107
+ ) -> Any:
52
108
  """
53
- The main execution method, executed in the child process.
54
- It executes the assigned task, sends the result to the queue, and exits.
109
+ Run async task with periodic cancellation checking.
110
+ Monitors cancel_event and cancels the task if it's set.
55
111
  """
112
+ # Create the task
113
+ task_coro = task(*args, **kwargs)
114
+ task_obj = asyncio.create_task(task_coro)
115
+
116
+ # Monitor for cancellation every second
117
+ async def monitor_cancellation():
118
+ while not task_obj.done():
119
+ if cancel_event.is_set():
120
+ task_obj.cancel()
121
+ break
122
+ await asyncio.sleep(1)
123
+
124
+ monitor = asyncio.create_task(monitor_cancellation())
56
125
  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())
126
+ result = await task_obj
127
+ monitor.cancel()
128
+ return result
129
+ except asyncio.CancelledError:
130
+ monitor.cancel()
131
+ raise asyncio.CancelledError("Task was cancelled during execution")
132
+ finally:
68
133
  try:
69
- self._result_queue.put(e)
70
- except BrokenPipeError as e:
134
+ await monitor
135
+ except asyncio.CancelledError:
71
136
  pass
72
137
 
138
+ def go(self) -> None:
139
+ """Starts the Runner in the background using the pool."""
140
+ self._async_result = self._pool.apply_async(
141
+ Runner._execute_in_pool_worker,
142
+ (self.task, self.args, self.kwargs, self.is_async, self._cancel_event),
143
+ )
144
+
73
145
  def get_results(self, timeout: Optional[float] = None) -> Any:
74
146
  """
75
- Waits for the runner process to complete and returns the result.
147
+ Waits for the runner to complete and returns the result.
76
148
 
77
149
  :param timeout: Time in seconds to wait for the result. If None, waits indefinitely.
78
150
  :returns: The result returned by the executed task function.
79
151
  :raises TimeoutError: If the result is not available within the specified timeout.
80
- :raises Exception: If the task execution in the runner process failed.
152
+ :raises asyncio.CancelledError: If the task was cancelled.
153
+ :raises Exception: If the task execution failed.
81
154
  """
82
155
  logger.debug(f"Runner.get_results() called for {self.name}, timeout={timeout}")
156
+ assert (
157
+ self._async_result is not None
158
+ ), "go() must be called before get_results()"
83
159
  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
-
160
+ result = self._async_result.get(timeout=timeout)
103
161
  return result
104
- except Exception as e:
105
- raise e
162
+ except multiprocessing.TimeoutError:
163
+ raise TimeoutError(f"runner timed out after {timeout} seconds.")
164
+ except asyncio.CancelledError:
165
+ logger.debug(f"Runner {self.name} was cancelled")
166
+ raise
106
167
 
107
168
  def cancel(self) -> bool:
108
169
  """
109
- Attempts to cancel the running process.
170
+ Attempts to cancel the running task by setting the cancellation event.
110
171
 
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
172
+ Cancellation behavior:
173
+ - Async tasks: Checked every second during execution, task will be cancelled
174
+ - Sync tasks: Only checked before execution starts (cannot interrupt mid-execution)
175
+ - Queued tasks: Cancelled before they begin executing
118
176
 
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
177
+ :returns: True if cancellation signal was sent successfully.
178
+ """
179
+ logger.debug(f"Cancelling runner {self.name}")
180
+ self._cancel_event.set()
181
+ return True
127
182
 
128
183
 
129
184
  class SmallRunner(threading.Thread):
@@ -253,7 +308,11 @@ class SmallRunner(threading.Thread):
253
308
 
254
309
 
255
310
  def go_func(
256
- func: Callable[..., Any], use_mp: bool = True, *args: Any, **kwargs: Any
311
+ func: Callable[..., Any],
312
+ use_mp: bool = True,
313
+ *args: Any,
314
+ pool: Any = None,
315
+ **kwargs: Any,
257
316
  ) -> Union[Runner, SmallRunner]:
258
317
  """
259
318
  Go-like async function execution factory. Selects the appropriate runner.
@@ -262,11 +321,14 @@ def go_func(
262
321
  :param use_mp: If True (default), uses the multiprocessing Runner (isolated).
263
322
  If False, uses the threading SmallRunner (shared state).
264
323
  :param args: Positional arguments for the function.
324
+ :param pool: Multiprocessing.Pool for process reuse (required if use_mp=True).
265
325
  :param kwargs: Keyword arguments for the function.
266
326
  :returns: A running Runner or SmallRunner instance.
267
327
  """
268
328
  if use_mp:
269
- runner = Runner(func, *args, **kwargs)
329
+ if pool is None:
330
+ raise ValueError("pool is required when use_mp=True")
331
+ runner = Runner(func, *args, pool=pool, **kwargs)
270
332
  else:
271
333
  runner = SmallRunner(func, *args, **kwargs)
272
334
 
@@ -9,7 +9,7 @@ from typing import List, Optional, Tuple
9
9
  from uuid import UUID
10
10
  from psycopg.rows import dict_row
11
11
 
12
- from ..helper.database import Database
12
+ from ..helper.database import Database, DatabaseConfiguration
13
13
  from ..helper.error import QueuerError
14
14
  from ..helper.sql import SQLLoader, run_ddl
15
15
  from ..model.job import Job
@@ -25,7 +25,7 @@ class JobDBHandler:
25
25
  def __init__(
26
26
  self,
27
27
  db_connection: Database,
28
- with_table_drop: bool = False,
28
+ db_config: DatabaseConfiguration,
29
29
  encryption_key: str = "",
30
30
  ):
31
31
  """Initialize job database handler."""
@@ -35,12 +35,13 @@ class JobDBHandler:
35
35
  raise ValueError("Database connection is not established")
36
36
 
37
37
  self.encryption_key: str = encryption_key
38
+ self.with_timescale: bool = db_config.with_timescale
38
39
 
39
40
  sql_loader: SQLLoader = SQLLoader()
40
- sql_loader.load_notify_sql(self.db.instance, force=with_table_drop)
41
- sql_loader.load_job_sql(self.db.instance, force=with_table_drop)
41
+ sql_loader.load_notify_sql(self.db.instance, force=db_config.with_table_drop)
42
+ sql_loader.load_job_sql(self.db.instance, force=db_config.with_table_drop)
42
43
 
43
- if with_table_drop:
44
+ if db_config.with_table_drop:
44
45
  self.drop_tables()
45
46
 
46
47
  self.create_table()
@@ -72,7 +73,7 @@ class JobDBHandler:
72
73
  if self.db.instance is None:
73
74
  raise ValueError("Database connection is not established")
74
75
 
75
- run_ddl(self.db.instance, "SELECT init_job();")
76
+ run_ddl(self.db.instance, f"SELECT init_job({self.with_timescale});")
76
77
 
77
78
  def drop_tables(self) -> None:
78
79
  """Drop job tables with DDL deadlock protection."""
@@ -379,9 +380,15 @@ class JobDBHandler:
379
380
  if self.db.instance is None:
380
381
  raise ValueError("Database connection is not established")
381
382
 
382
- with self.db.instance.cursor() as cur:
383
- cur.execute("SELECT add_retention_archive(%s);", (days,))
384
- self.db.instance.commit()
383
+ try:
384
+ with self.db.instance.cursor() as cur:
385
+ cur.execute(
386
+ "SELECT add_retention_archive(%s, %s);", (days, self.with_timescale)
387
+ )
388
+ self.db.instance.commit()
389
+ except Exception as e:
390
+ self.db.instance.rollback()
391
+ raise e
385
392
 
386
393
  def remove_retention_archive(self) -> None:
387
394
  """
@@ -391,9 +398,15 @@ class JobDBHandler:
391
398
  if self.db.instance is None:
392
399
  raise ValueError("Database connection is not established")
393
400
 
394
- with self.db.instance.cursor() as cur:
395
- cur.execute("SELECT remove_retention_archive();")
396
- self.db.instance.commit()
401
+ try:
402
+ with self.db.instance.cursor() as cur:
403
+ cur.execute(
404
+ "SELECT remove_retention_archive(%s);", (self.with_timescale,)
405
+ )
406
+ self.db.instance.commit()
407
+ except Exception as e:
408
+ self.db.instance.rollback()
409
+ raise e
397
410
 
398
411
  def select_job_from_archive(self, rid: UUID) -> Optional[Job]:
399
412
  """
@@ -30,6 +30,7 @@ class DatabaseConfiguration:
30
30
  schema: str = "public"
31
31
  sslmode: str = "require"
32
32
  with_table_drop: bool = False
33
+ with_timescale: bool = True
33
34
 
34
35
  @classmethod
35
36
  def from_env(cls) -> "DatabaseConfiguration":
@@ -45,6 +46,9 @@ class DatabaseConfiguration:
45
46
  with_table_drop = (
46
47
  os.getenv("QUEUER_DB_WITH_TABLE_DROP", "false").lower() == "true"
47
48
  )
49
+ with_timescale = (
50
+ os.getenv("QUEUER_DB_WITH_TIMESCALE", "true").lower() != "false"
51
+ )
48
52
 
49
53
  # Validate required fields
50
54
  if not all(
@@ -72,6 +76,7 @@ class DatabaseConfiguration:
72
76
  schema=schema,
73
77
  sslmode=sslmode,
74
78
  with_table_drop=with_table_drop,
79
+ with_timescale=with_timescale,
75
80
  )
76
81
 
77
82
  def connection_string(self) -> str:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "queuerPy"
7
- version = "0.19.0"
7
+ version = "0.21.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.19.0"
95
+ python_version = "0.21.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,25 @@ 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
+ self._process_pool.join(timeout=5.0)
323
+ # Force terminate if workers didn't finish gracefully
324
+ self._process_pool.terminate()
325
+ self._process_pool.join()
326
+ self._process_pool = None
327
+ except Exception as e:
328
+ logger.warning(f"Error closing process pool: {e}")
329
+
299
330
  # Cancel the context to stop the queuer
300
331
  if self._cancel_event:
301
332
  self._cancel_event.set()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.19.0
3
+ Version: 0.21.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>
@@ -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,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,
@@ -56,7 +61,7 @@ class QueuerGlobalMixin:
56
61
 
57
62
  # Database handlers
58
63
  self.db_job: JobDBHandler = JobDBHandler(
59
- self.database, db_config.with_table_drop, encryption_key
64
+ self.database, db_config, encryption_key
60
65
  )
61
66
  self.db_worker: WorkerDBHandler = WorkerDBHandler(
62
67
  self.database, db_config.with_table_drop
@@ -110,6 +110,7 @@ class QueuerJobMixin(QueuerGlobalMixin):
110
110
  runner = Runner(
111
111
  task=self._wait_for_job_added_inner,
112
112
  args=(timeout_seconds,),
113
+ pool=self._process_pool,
113
114
  kwargs={},
114
115
  )
115
116
  runner.go()
@@ -421,7 +422,9 @@ class QueuerJobMixin(QueuerGlobalMixin):
421
422
  try:
422
423
  parameters = getattr(job, "parameters", [])
423
424
  parameters_keyed = getattr(job, "parameters_keyed", {})
424
- runner = Runner(task, *parameters, **parameters_keyed)
425
+ runner = Runner(
426
+ task, *parameters, pool=self._process_pool, **parameters_keyed
427
+ )
425
428
 
426
429
  logger.info(f"Created runner for job {job.rid}")
427
430
 
@@ -507,9 +510,9 @@ class QueuerJobMixin(QueuerGlobalMixin):
507
510
  """
508
511
  try:
509
512
  # Create a Runner process for the async job
510
- runner = Runner(self._run_job, job)
513
+ runner = Runner(self._run_job, job, pool=self._process_pool)
511
514
  runner.go()
512
- logger.info(f"Scheduled job {job.rid} started in process {runner.pid}")
515
+ logger.info(f"Scheduled job {job.rid} started")
513
516
 
514
517
  except Exception as e:
515
518
  logger.error(f"Error executing scheduled job {job.rid}: {e}")
@@ -38,14 +38,27 @@ class QueuerMasterMixin(QueuerGlobalMixin):
38
38
 
39
39
  # Handle retention archive configuration
40
40
  try:
41
- if old_master.settings.retention_archive == 0:
42
- self.db_job.add_retention_archive(master_settings.retention_archive)
43
- elif (
44
- old_master.settings.retention_archive
45
- != master_settings.retention_archive
46
- ):
47
- self.db_job.remove_retention_archive()
48
- self.db_job.add_retention_archive(master_settings.retention_archive)
41
+ if master_settings.retention_archive > 0:
42
+ if old_master.settings.retention_archive == 0:
43
+ self.db_job.add_retention_archive(master_settings.retention_archive)
44
+ elif (
45
+ old_master.settings.retention_archive
46
+ != master_settings.retention_archive
47
+ ):
48
+ try:
49
+ self.db_job.remove_retention_archive()
50
+ except Exception as e:
51
+ logger.warning(
52
+ f"Could not remove old retention policy (may not exist): {e}"
53
+ )
54
+ self.db_job.add_retention_archive(master_settings.retention_archive)
55
+ elif old_master.settings.retention_archive > 0:
56
+ try:
57
+ self.db_job.remove_retention_archive()
58
+ except Exception as e:
59
+ logger.warning(
60
+ f"Could not remove retention policy (may not exist): {e}"
61
+ )
49
62
  except Exception as e:
50
63
  raise QueuerError("managing retention archive", e)
51
64
 
@@ -83,10 +96,9 @@ class QueuerMasterMixin(QueuerGlobalMixin):
83
96
  logger.error(f"Error in master ticker task: {e}")
84
97
 
85
98
  ticker = Ticker(
86
- task=ticker_task,
87
- name="_master_ticker",
88
99
  interval=timedelta(seconds=master_settings.master_poll_interval),
89
- func=ticker_task,
100
+ task=ticker_task,
101
+ use_mp=False, # Use threading instead of multiprocessing to avoid pickling issues
90
102
  )
91
103
  logger.info("Starting master ticker...")
92
104
  ticker.go()
@@ -150,10 +162,9 @@ class QueuerMasterMixin(QueuerGlobalMixin):
150
162
 
151
163
  # Create and start the polling ticker
152
164
  ticker = Ticker(
153
- task=ticker_task,
154
- name="_poll_master_ticker",
155
165
  interval=timedelta(seconds=master_settings.master_poll_interval),
156
- func=ticker_task,
166
+ task=ticker_task,
167
+ use_mp=False, # Use threading instead of multiprocessing to avoid pickling issues
157
168
  )
158
169
 
159
170
  logger.info("Starting master poll ticker...")
@@ -15,6 +15,19 @@ from .queuer_global import QueuerGlobalMixin
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def get_current_worker_rid(self) -> Optional[UUID]:
19
+ """
20
+ Get the current worker's RID safely with mutex locking.
21
+ Mirrors Go's GetCurrentWorkerRID method.
22
+
23
+ :return: Worker RID or None if no worker
24
+ """
25
+ with self.worker_mutex:
26
+ if self.worker is not None:
27
+ return self.worker.rid
28
+ return None
29
+
30
+
18
31
  class QueuerWorkerMixin(QueuerGlobalMixin):
19
32
  """
20
33
  Mixin class containing worker-related methods for the Queuer.
@@ -1,7 +1,9 @@
1
- CREATE OR REPLACE FUNCTION init_job() RETURNS VOID AS $$
1
+ CREATE OR REPLACE FUNCTION init_job(enable_timescaledb BOOLEAN DEFAULT TRUE) RETURNS VOID AS $$
2
2
  BEGIN
3
3
  -- Create TimescaleDB extension
4
- CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
4
+ IF enable_timescaledb THEN
5
+ CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
6
+ END IF;
5
7
 
6
8
  -- Create pgcrypto extension
7
9
  CREATE EXTENSION IF NOT EXISTS pgcrypto;
@@ -37,7 +39,9 @@ BEGIN
37
39
  );
38
40
 
39
41
  -- Create hypertable (TimescaleDB feature)
40
- PERFORM create_hypertable('job_archive', by_range('updated_at'), if_not_exists => TRUE);
42
+ IF enable_timescaledb THEN
43
+ PERFORM create_hypertable('job_archive', by_range('updated_at'), if_not_exists => TRUE);
44
+ END IF;
41
45
 
42
46
  -- Create triggers for notifications
43
47
  CREATE OR REPLACE TRIGGER job_notify_event
@@ -698,17 +702,64 @@ BEGIN
698
702
  END;
699
703
  $$ LANGUAGE plpgsql;
700
704
 
701
- CREATE OR REPLACE FUNCTION add_retention_archive(input_retention_days INT)
705
+ CREATE OR REPLACE FUNCTION add_retention_archive(input_retention_days INT, enable_timescaledb BOOLEAN DEFAULT TRUE)
702
706
  RETURNS VOID AS $$
703
707
  BEGIN
704
- PERFORM add_retention_policy('job_archive', (input_retention_days * INTERVAL '1 day'));
708
+ IF enable_timescaledb THEN
709
+ -- Use TimescaleDB retention policy
710
+ PERFORM add_retention_policy('job_archive', (input_retention_days * INTERVAL '1 day'));
711
+ ELSE
712
+ -- Create sequence for tracking inserts
713
+ CREATE SEQUENCE IF NOT EXISTS job_archive_insert_counter;
714
+
715
+ -- Create trigger function that deletes old records every 10th insert
716
+ EXECUTE format('
717
+ CREATE OR REPLACE FUNCTION cleanup_job_archive_trigger()
718
+ RETURNS TRIGGER AS $func$
719
+ DECLARE
720
+ insert_count BIGINT;
721
+ deleted_count INT;
722
+ BEGIN
723
+ -- Only run cleanup every 10th insert
724
+ insert_count := nextval(''job_archive_insert_counter'');
725
+ IF insert_count %% 10 = 0 THEN
726
+ DELETE FROM job_archive
727
+ WHERE updated_at < (CURRENT_TIMESTAMP - INTERVAL ''%s days'');
728
+
729
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
730
+
731
+ -- Log cleanup if any rows were deleted (optional)
732
+ IF deleted_count > 0 THEN
733
+ RAISE NOTICE ''Cleaned up %% old job_archive records'', deleted_count;
734
+ END IF;
735
+ END IF;
736
+ RETURN NEW;
737
+ END;
738
+ $func$ LANGUAGE plpgsql;
739
+ ', input_retention_days);
740
+
741
+ -- Create trigger on job_archive
742
+ DROP TRIGGER IF EXISTS job_archive_cleanup_trigger ON job_archive;
743
+ CREATE TRIGGER job_archive_cleanup_trigger
744
+ AFTER INSERT ON job_archive
745
+ FOR EACH ROW
746
+ EXECUTE FUNCTION cleanup_job_archive_trigger();
747
+ END IF;
705
748
  END;
706
749
  $$ LANGUAGE plpgsql;
707
750
 
708
- CREATE OR REPLACE FUNCTION remove_retention_archive()
751
+ CREATE OR REPLACE FUNCTION remove_retention_archive(enable_timescaledb BOOLEAN DEFAULT TRUE)
709
752
  RETURNS VOID AS $$
710
753
  BEGIN
711
- PERFORM remove_retention_policy('job_archive');
754
+ IF enable_timescaledb THEN
755
+ -- Remove TimescaleDB retention policy
756
+ PERFORM remove_retention_policy('job_archive');
757
+ ELSE
758
+ -- Drop trigger and related objects
759
+ DROP TRIGGER IF EXISTS job_archive_cleanup_trigger ON job_archive;
760
+ DROP FUNCTION IF EXISTS cleanup_job_archive_trigger();
761
+ DROP SEQUENCE IF EXISTS job_archive_insert_counter;
762
+ END IF;
712
763
  END;
713
764
  $$ LANGUAGE plpgsql;
714
765
 
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