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.
- {queuerpy-0.19.0/queuerPy.egg-info → queuerpy-0.21.0}/PKG-INFO +2 -1
- {queuerpy-0.19.0 → queuerpy-0.21.0}/README.md +1 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/_version.py +1 -1
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/runner.py +129 -67
- {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_job.py +25 -12
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/database.py +5 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/pyproject.toml +2 -2
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer.py +32 -1
- {queuerpy-0.19.0 → queuerpy-0.21.0/queuerPy.egg-info}/PKG-INFO +2 -1
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_global.py +6 -1
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_job.py +6 -3
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_master.py +25 -14
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_worker.py +13 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/job.sql +58 -7
- {queuerpy-0.19.0 → queuerpy-0.21.0}/LICENSE +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/MANIFEST.in +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/__init__.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/__init__.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/broadcaster.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/listener.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/retryer.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/scheduler.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/core/ticker.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/database/__init__.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_listener.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_master.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/database/db_worker.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/__init__.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/error.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/logging.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/sql.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/helper/task.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/__init__.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/batch_job.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/connection.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/job.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/master.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/options.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/options_on_error.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/task.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/model/worker.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/py.typed +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/SOURCES.txt +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/dependency_links.txt +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/requires.txt +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuerPy.egg-info/top_level.txt +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_listener.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_next_interval.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/queuer_task.py +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/setup.cfg +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/master.sql +0 -0
- {queuerpy-0.19.0 → queuerpy-0.21.0}/sql/notify.sql +0 -0
- {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.
|
|
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.
|
|
@@ -15,115 +15,170 @@ from ..helper.logging import get_logger
|
|
|
15
15
|
logger = get_logger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
class Runner
|
|
18
|
+
class Runner:
|
|
19
19
|
"""
|
|
20
20
|
The core execution environment. Manages the execution of a single task
|
|
21
|
-
in a
|
|
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
|
|
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
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
except
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
105
|
-
raise
|
|
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
|
|
170
|
+
Attempts to cancel the running task by setting the cancellation event.
|
|
110
171
|
|
|
111
|
-
:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|