queuerPy 0.16.0__tar.gz → 0.18.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.16.0/queuerPy.egg-info → queuerpy-0.18.0}/PKG-INFO +82 -7
  2. {queuerpy-0.16.0 → queuerpy-0.18.0}/README.md +81 -6
  3. {queuerpy-0.16.0 → queuerpy-0.18.0}/_version.py +1 -1
  4. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/worker.py +1 -0
  5. {queuerpy-0.16.0 → queuerpy-0.18.0}/pyproject.toml +2 -2
  6. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer.py +111 -143
  7. {queuerpy-0.16.0 → queuerpy-0.18.0/queuerPy.egg-info}/PKG-INFO +82 -7
  8. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuerPy.egg-info/SOURCES.txt +2 -0
  9. queuerpy-0.18.0/queuer_worker.py +96 -0
  10. {queuerpy-0.16.0 → queuerpy-0.18.0}/LICENSE +0 -0
  11. {queuerpy-0.16.0 → queuerpy-0.18.0}/MANIFEST.in +0 -0
  12. {queuerpy-0.16.0 → queuerpy-0.18.0}/__init__.py +0 -0
  13. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/__init__.py +0 -0
  14. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/broadcaster.py +0 -0
  15. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/listener.py +0 -0
  16. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/retryer.py +0 -0
  17. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/runner.py +0 -0
  18. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/scheduler.py +0 -0
  19. {queuerpy-0.16.0 → queuerpy-0.18.0}/core/ticker.py +0 -0
  20. {queuerpy-0.16.0 → queuerpy-0.18.0}/database/__init__.py +0 -0
  21. {queuerpy-0.16.0 → queuerpy-0.18.0}/database/db_job.py +0 -0
  22. {queuerpy-0.16.0 → queuerpy-0.18.0}/database/db_listener.py +0 -0
  23. {queuerpy-0.16.0 → queuerpy-0.18.0}/database/db_master.py +0 -0
  24. {queuerpy-0.16.0 → queuerpy-0.18.0}/database/db_worker.py +0 -0
  25. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/__init__.py +0 -0
  26. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/database.py +0 -0
  27. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/error.py +0 -0
  28. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/logging.py +0 -0
  29. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/sql.py +0 -0
  30. {queuerpy-0.16.0 → queuerpy-0.18.0}/helper/task.py +0 -0
  31. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/__init__.py +0 -0
  32. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/batch_job.py +0 -0
  33. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/connection.py +0 -0
  34. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/job.py +0 -0
  35. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/master.py +0 -0
  36. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/options.py +0 -0
  37. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/options_on_error.py +0 -0
  38. {queuerpy-0.16.0 → queuerpy-0.18.0}/model/task.py +0 -0
  39. {queuerpy-0.16.0 → queuerpy-0.18.0}/py.typed +0 -0
  40. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuerPy.egg-info/dependency_links.txt +0 -0
  41. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuerPy.egg-info/requires.txt +0 -0
  42. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuerPy.egg-info/top_level.txt +0 -0
  43. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_global.py +0 -0
  44. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_job.py +0 -0
  45. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_listener.py +0 -0
  46. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_master.py +0 -0
  47. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_next_interval.py +0 -0
  48. {queuerpy-0.16.0 → queuerpy-0.18.0}/queuer_task.py +0 -0
  49. {queuerpy-0.16.0 → queuerpy-0.18.0}/setup.cfg +0 -0
  50. {queuerpy-0.16.0 → queuerpy-0.18.0}/sql/job.sql +0 -0
  51. {queuerpy-0.16.0 → queuerpy-0.18.0}/sql/master.sql +0 -0
  52. {queuerpy-0.16.0 → queuerpy-0.18.0}/sql/notify.sql +0 -0
  53. {queuerpy-0.16.0 → queuerpy-0.18.0}/sql/worker.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.16.0
3
+ Version: 0.18.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,9 +43,10 @@ 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
47
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
47
- [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
48
48
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
49
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
49
50
 
50
51
  Python port of the queuer package - a queueing system based on PostgreSQL.
51
52
 
@@ -97,9 +98,15 @@ def example_task():
97
98
  That's easy, right? Adding a job is just as easy:
98
99
 
99
100
  ```python
100
- # Add a job to the queue
101
+ # Add a job to the queue with positional arguments
101
102
  job = q.add_job(example_task, 5, "12")
102
103
  print(f"Job added: {job.rid}")
104
+
105
+ # Add a job with keyword arguments (kwargs)
106
+ job = q.add_job(example_task, paramKeyed1="test", debug=True)
107
+
108
+ # Add a job with both positional and keyword arguments
109
+ job = q.add_job(example_task, 5, "12", paramKeyed1="test", debug=True)
103
110
  ```
104
111
 
105
112
  In the initialisation of the queuer the existence of the necessary database tables is checked and if they don't exist they get created. The database is configured with these environment variables:
@@ -199,6 +206,72 @@ This method handles the registration of a task, making the worker able to pick u
199
206
 
200
207
  ---
201
208
 
209
+ ## add_job
210
+
211
+ The `add_job` method adds a new job to the queue for execution. Jobs are units of work that will be processed by the queuer.
212
+
213
+ ```python
214
+ def add_job(
215
+ self,
216
+ task: Union[Callable, str],
217
+ *parameters: Any,
218
+ **parameters_keyed: Any
219
+ ) -> Job
220
+
221
+ def add_job_with_options(
222
+ self,
223
+ options: Optional[Options],
224
+ task: Union[Callable, str],
225
+ *parameters: Any,
226
+ **parameters_keyed: Any
227
+ ) -> Job
228
+ ```
229
+
230
+ - `task`: A `Callable` or `str` representing the task to execute. If a callable, it must be registered with `add_task` first.
231
+ - `options`: Optional `Options` for custom error handling or scheduling behavior. Only available in `add_job_with_options()`.
232
+ - `*parameters`: Positional arguments to pass to the task function.
233
+ - `**parameters_keyed`: Keyword arguments to pass to the task function (Python-specific feature).
234
+
235
+ **Examples:**
236
+
237
+ ```python
238
+ # Job with both positional and keyword arguments
239
+ job = queuer.add_job(my_task, "arg1", paramKeyed1="test", paramKeyed2=1)
240
+
241
+ # Job with custom options (requires add_job_with_options)
242
+ options = Options(on_error=OnError(max_retries=5))
243
+ job = queuer.add_job_with_options(options, my_task, "arg1", debug=True)
244
+ ```
245
+
246
+ **Note:** Keyword arguments (`**parameters_keyed`) are stored separately in the database and enable Python functions to be called with named parameters. This is useful for optional parameters, default values, and improved code clarity. Go jobs continue to use only positional parameters.
247
+
248
+ ---
249
+
250
+ ## add_jobs
251
+
252
+ The `add_jobs` method allows efficient batch insertion of multiple jobs at once.
253
+
254
+ ```python
255
+ def add_jobs(self, batch_jobs: List[BatchJob]) -> List[UUID]
256
+ ```
257
+
258
+ - `batch_jobs`: A list of `BatchJob` instances to insert.
259
+
260
+ **Example:**
261
+
262
+ ```python
263
+ from model.batch_job import BatchJob
264
+
265
+ batch = [
266
+ BatchJob(task=my_task, parameters=[1, "a"]),
267
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
268
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
269
+ ]
270
+ job_rids = queuer.add_jobs(batch)
271
+ ```
272
+
273
+ ---
274
+
202
275
  ## add_next_interval_func
203
276
 
204
277
  The `add_next_interval_func` method registers a custom function that determines the next execution time for scheduled jobs. This is useful for implementing complex scheduling logic beyond simple fixed intervals.
@@ -296,19 +369,20 @@ class Schedule:
296
369
  ### Job Management
297
370
 
298
371
  ```python
299
- # Add a single job
300
- job = queuer.add_job(my_task, param1, param2)
372
+ # Add a job with both positional and keyword arguments
373
+ job = queuer.add_job(my_task, param1, param2, paramKeyed1="test", paramKeyed2=1)
301
374
 
302
375
  # Add a job with custom options
303
376
  from model.options import Options, OnError
304
377
  options = Options(on_error=OnError(max_retries=5, timeout=60.0))
305
- job = queuer.add_job_with_options(options, my_task, param1)
378
+ job = queuer.add_job_with_options(options, my_task, param1, paramKeyed1="test")
306
379
 
307
380
  # Add multiple jobs as a batch
308
381
  from model.batch_job import BatchJob
309
382
  batch = [
310
383
  BatchJob(task=my_task, parameters=[1, "a"]),
311
- BatchJob(task=my_task, parameters=[2, "b"])
384
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
385
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
312
386
  ]
313
387
  queuer.add_jobs(batch)
314
388
 
@@ -335,6 +409,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
335
409
 
336
410
  # ⭐ Features
337
411
 
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.
338
413
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
339
414
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
340
415
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
@@ -1,8 +1,9 @@
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
4
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
4
- [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
5
5
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
6
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
6
7
 
7
8
  Python port of the queuer package - a queueing system based on PostgreSQL.
8
9
 
@@ -54,9 +55,15 @@ def example_task():
54
55
  That's easy, right? Adding a job is just as easy:
55
56
 
56
57
  ```python
57
- # Add a job to the queue
58
+ # Add a job to the queue with positional arguments
58
59
  job = q.add_job(example_task, 5, "12")
59
60
  print(f"Job added: {job.rid}")
61
+
62
+ # Add a job with keyword arguments (kwargs)
63
+ job = q.add_job(example_task, paramKeyed1="test", debug=True)
64
+
65
+ # Add a job with both positional and keyword arguments
66
+ job = q.add_job(example_task, 5, "12", paramKeyed1="test", debug=True)
60
67
  ```
61
68
 
62
69
  In the initialisation of the queuer the existence of the necessary database tables is checked and if they don't exist they get created. The database is configured with these environment variables:
@@ -156,6 +163,72 @@ This method handles the registration of a task, making the worker able to pick u
156
163
 
157
164
  ---
158
165
 
166
+ ## add_job
167
+
168
+ The `add_job` method adds a new job to the queue for execution. Jobs are units of work that will be processed by the queuer.
169
+
170
+ ```python
171
+ def add_job(
172
+ self,
173
+ task: Union[Callable, str],
174
+ *parameters: Any,
175
+ **parameters_keyed: Any
176
+ ) -> Job
177
+
178
+ def add_job_with_options(
179
+ self,
180
+ options: Optional[Options],
181
+ task: Union[Callable, str],
182
+ *parameters: Any,
183
+ **parameters_keyed: Any
184
+ ) -> Job
185
+ ```
186
+
187
+ - `task`: A `Callable` or `str` representing the task to execute. If a callable, it must be registered with `add_task` first.
188
+ - `options`: Optional `Options` for custom error handling or scheduling behavior. Only available in `add_job_with_options()`.
189
+ - `*parameters`: Positional arguments to pass to the task function.
190
+ - `**parameters_keyed`: Keyword arguments to pass to the task function (Python-specific feature).
191
+
192
+ **Examples:**
193
+
194
+ ```python
195
+ # Job with both positional and keyword arguments
196
+ job = queuer.add_job(my_task, "arg1", paramKeyed1="test", paramKeyed2=1)
197
+
198
+ # Job with custom options (requires add_job_with_options)
199
+ options = Options(on_error=OnError(max_retries=5))
200
+ job = queuer.add_job_with_options(options, my_task, "arg1", debug=True)
201
+ ```
202
+
203
+ **Note:** Keyword arguments (`**parameters_keyed`) are stored separately in the database and enable Python functions to be called with named parameters. This is useful for optional parameters, default values, and improved code clarity. Go jobs continue to use only positional parameters.
204
+
205
+ ---
206
+
207
+ ## add_jobs
208
+
209
+ The `add_jobs` method allows efficient batch insertion of multiple jobs at once.
210
+
211
+ ```python
212
+ def add_jobs(self, batch_jobs: List[BatchJob]) -> List[UUID]
213
+ ```
214
+
215
+ - `batch_jobs`: A list of `BatchJob` instances to insert.
216
+
217
+ **Example:**
218
+
219
+ ```python
220
+ from model.batch_job import BatchJob
221
+
222
+ batch = [
223
+ BatchJob(task=my_task, parameters=[1, "a"]),
224
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
225
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
226
+ ]
227
+ job_rids = queuer.add_jobs(batch)
228
+ ```
229
+
230
+ ---
231
+
159
232
  ## add_next_interval_func
160
233
 
161
234
  The `add_next_interval_func` method registers a custom function that determines the next execution time for scheduled jobs. This is useful for implementing complex scheduling logic beyond simple fixed intervals.
@@ -253,19 +326,20 @@ class Schedule:
253
326
  ### Job Management
254
327
 
255
328
  ```python
256
- # Add a single job
257
- job = queuer.add_job(my_task, param1, param2)
329
+ # Add a job with both positional and keyword arguments
330
+ job = queuer.add_job(my_task, param1, param2, paramKeyed1="test", paramKeyed2=1)
258
331
 
259
332
  # Add a job with custom options
260
333
  from model.options import Options, OnError
261
334
  options = Options(on_error=OnError(max_retries=5, timeout=60.0))
262
- job = queuer.add_job_with_options(options, my_task, param1)
335
+ job = queuer.add_job_with_options(options, my_task, param1, paramKeyed1="test")
263
336
 
264
337
  # Add multiple jobs as a batch
265
338
  from model.batch_job import BatchJob
266
339
  batch = [
267
340
  BatchJob(task=my_task, parameters=[1, "a"]),
268
- BatchJob(task=my_task, parameters=[2, "b"])
341
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
342
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
269
343
  ]
270
344
  queuer.add_jobs(batch)
271
345
 
@@ -292,6 +366,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
292
366
 
293
367
  # ⭐ Features
294
368
 
369
+ - **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.
295
370
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
296
371
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
297
372
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
@@ -1,6 +1,6 @@
1
1
  """Version information for queuerPy package."""
2
2
 
3
- __version__ = "0.16.0"
3
+ __version__ = "0.18.0"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # For compatibility
@@ -17,6 +17,7 @@ class WorkerStatus:
17
17
  READY = "READY"
18
18
  RUNNING = "RUNNING"
19
19
  FAILED = "FAILED"
20
+ STOPPING = "STOPPING"
20
21
  STOPPED = "STOPPED"
21
22
 
22
23
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "queuerPy"
7
- version = "0.16.0"
7
+ version = "0.18.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.16.0"
95
+ python_version = "0.18.0"
96
96
  warn_return_any = true
97
97
  warn_unused_configs = true
98
98
  disallow_untyped_defs = true
@@ -28,6 +28,7 @@ from .queuer_task import QueuerTaskMixin
28
28
  from .queuer_next_interval import QueuerNextIntervalMixin
29
29
  from .queuer_listener import QueuerListenerMixin
30
30
  from .queuer_master import QueuerMasterMixin
31
+ from .queuer_worker import QueuerWorkerMixin
31
32
 
32
33
  logger = get_logger(__name__)
33
34
 
@@ -68,6 +69,7 @@ class Queuer(
68
69
  QueuerNextIntervalMixin,
69
70
  QueuerListenerMixin,
70
71
  QueuerMasterMixin,
72
+ QueuerWorkerMixin,
71
73
  ):
72
74
  """
73
75
  Main queuing system class.
@@ -113,6 +115,7 @@ class Queuer(
113
115
 
114
116
  # Configuration
115
117
  self.job_poll_interval: timedelta = timedelta(minutes=5)
118
+ self.worker_poll_interval: timedelta = timedelta(seconds=30)
116
119
  self.retention_archive: timedelta = timedelta(days=30)
117
120
 
118
121
  # Active runners (jobs currently executing)
@@ -219,177 +222,96 @@ class Queuer(
219
222
  logger.info("Queuer started")
220
223
 
221
224
  def stop(self) -> None:
222
- """Stop the queuer."""
223
- # Store whether we were running to decide if we need to close connections
224
- was_running = getattr(self, "_running", False)
225
+ """
226
+ Stop stops the queuer by closing the job listeners, cancelling all queued and running jobs,
227
+ and cancelling the context to stop the queuer.
228
+ """
229
+ # Check if already stopped (database is None means we've already cleaned up)
230
+ if self.database is None:
231
+ return # Already stopped
232
+
233
+ # Mark as not running immediately to prevent re-entrant calls
234
+ was_running = self.running
235
+ self.running = False
225
236
 
226
- # Stop tickers first to prevent new work from being queued
227
- if self.heartbeat_ticker:
237
+ # Stop tickers first to prevent them from calling stop() recursively
238
+ if hasattr(self, "heartbeat_ticker") and self.heartbeat_ticker:
228
239
  try:
229
240
  self.heartbeat_ticker.stop()
230
241
  except Exception as e:
231
242
  logger.warning(f"Error stopping heartbeat ticker: {e}")
232
243
 
233
- if self.poll_job_ticker:
244
+ if hasattr(self, "poll_job_ticker") and self.poll_job_ticker:
234
245
  try:
235
246
  self.poll_job_ticker.stop()
236
247
  except Exception as e:
237
248
  logger.warning(f"Error stopping poll job ticker: {e}")
238
249
 
239
- # Close database listeners
250
+ # Close db listeners
240
251
  if self.job_db_listener:
241
252
  try:
242
- # Use go_func but wait for completion
243
253
  task = go_func(self.job_db_listener.stop, use_mp=False)
244
- task.get_results(timeout=3.0) # Wait up to 3 seconds total
254
+ task.get_results(timeout=3.0)
245
255
  except Exception as e:
246
256
  # Only log if it's not already closed
247
- if "closed" not in str(e).lower():
248
- logger.warning(f"Error stopping job listener: {e}")
249
-
250
- # Wait for database listener runners to complete
251
- if self.job_db_listener_runner:
252
- try:
253
- self.job_db_listener_runner.get_results(timeout=2.0)
254
- self.job_db_listener_runner = None
255
- except Exception as e:
256
- logger.warning(f"Error waiting for job listener runner: {e}")
257
+ if "Listener has been closed" not in str(e):
258
+ logger.error(f"Error closing job insert listener: {e}")
257
259
 
258
260
  if self.job_archive_db_listener:
259
261
  try:
260
262
  task = go_func(self.job_archive_db_listener.stop, use_mp=False)
261
- task.get_results(timeout=3.0) # Wait up to 3 seconds total
263
+ task.get_results(timeout=3.0)
262
264
  except Exception as e:
263
- if "closed" not in str(e).lower():
264
- logger.warning(f"Error stopping job archive listener: {e}")
265
+ if "Listener has been closed" not in str(e):
266
+ logger.error(f"Error closing job archive listener: {e}")
265
267
 
266
- # Wait for database archive listener runner to complete
267
- if self.job_archive_db_listener_runner:
268
- try:
269
- self.job_archive_db_listener_runner.get_results(timeout=2.0)
270
- self.job_archive_db_listener_runner = None
271
- except Exception as e:
272
- logger.warning(f"Error waiting for job archive listener runner: {e}")
273
-
274
- # Update worker status to stopped (only if we were running)
268
+ # Update worker status to stopped
269
+ err = None
275
270
  worker_rid = None
276
- if was_running:
277
- try:
278
- with self.worker_mutex:
271
+ if self.database and self.database.instance:
272
+ with self.worker_mutex:
273
+ if self.worker:
279
274
  self.worker.status = WorkerStatus.STOPPED
280
275
  worker_updated = self.db_worker.update_worker(self.worker)
281
276
  if worker_updated:
277
+ self.worker = worker_updated
282
278
  worker_rid = self.worker.rid
283
- except Exception as e:
284
- logger.error(f"Error updating worker status to stopped: {e}")
285
- else:
286
- # If we were never running, just get the worker_rid for job cancellation
287
- worker_rid = self.worker.rid
279
+ else:
280
+ err = Exception("Failed to update worker")
288
281
 
289
- # Cancel all queued and running jobs
282
+ if err:
283
+ logger.error(f"Error updating worker status to stopped: {err}")
284
+ return
285
+
286
+ # Cancel all queued and running jobs (only if we have a valid worker RID)
290
287
  if worker_rid:
291
288
  try:
292
289
  self.cancel_all_jobs_by_worker(worker_rid, 100)
293
290
  except Exception as e:
294
291
  logger.error(f"Error cancelling all jobs by worker: {e}")
292
+ return
295
293
 
296
- # Cancel the context equivalent - set cancellation event
297
- if self._cancel_event:
298
- try:
299
- self._cancel_event.set()
300
- except Exception as e:
301
- logger.warning(f"Error setting cancel event: {e}")
302
-
303
- # Signal that we're stopping
304
- self.running = False
305
- self._stopped.set()
306
-
307
- # Cancel all active runners - similar to Go's job cancellation
308
- active_runner_count = len(self.active_runners)
309
- if active_runner_count > 0:
310
- logger.info(f"Cancelling {active_runner_count} active runners")
311
-
294
+ # Cancel all active runners
312
295
  for runner_id, runner in list(self.active_runners.items()):
313
- try:
314
- runner.cancel()
315
- if runner_id in self.active_runners:
316
- del self.active_runners[runner_id]
317
- logger.debug(f"Removed runner {runner_id} from active_runners")
296
+ runner.cancel()
297
+ del self.active_runners[runner_id]
318
298
 
319
- except Exception as e:
320
- logger.warning(f"Error cancelling runner {runner_id}: {e}")
321
-
322
- # Final check
323
- remaining_runners = len(self.active_runners)
324
- if remaining_runners > 0:
325
- logger.warning(f"{remaining_runners} runners could not be cleaned up")
326
- else:
327
- logger.debug("All active runners cleaned up successfully")
328
-
329
- # Cleanup broadcasters and listeners
330
- try:
331
- broadcaster_count = 0
332
- if self.job_insert_broadcaster:
333
- listener_count = len(self.job_insert_broadcaster.listeners)
334
- if listener_count > 0:
335
- logger.debug(
336
- f"Clearing {listener_count} listeners from job_insert_broadcaster"
337
- )
338
- self.job_insert_broadcaster.listeners.clear()
339
- broadcaster_count += 1
340
-
341
- if self.job_update_broadcaster:
342
- listener_count = len(self.job_update_broadcaster.listeners)
343
- if listener_count > 0:
344
- logger.debug(
345
- f"Clearing {listener_count} listeners from job_update_broadcaster"
346
- )
347
- self.job_update_broadcaster.listeners.clear()
348
- broadcaster_count += 1
349
-
350
- if self.job_delete_broadcaster:
351
- listener_count = len(self.job_delete_broadcaster.listeners)
352
- if listener_count > 0:
353
- logger.debug(
354
- f"Clearing {listener_count} listeners from job_delete_broadcaster"
355
- )
356
- self.job_delete_broadcaster.listeners.clear()
357
- broadcaster_count += 1
358
-
359
- if broadcaster_count > 0:
360
- logger.debug(f"Cleaned up {broadcaster_count} broadcasters")
361
-
362
- except Exception as e:
363
- logger.warning(f"Error during broadcaster cleanup: {e}")
364
-
365
- # Cleanup AsyncIO resources
366
- try:
367
- try:
368
- loop = asyncio.get_running_loop()
369
- pending_tasks = [
370
- task for task in asyncio.all_tasks(loop) if not task.done()
371
- ]
372
- if pending_tasks:
373
- logger.debug(f"Cancelling {len(pending_tasks)} pending async tasks")
374
- for task in pending_tasks:
375
- task.cancel()
376
- except RuntimeError:
377
- pass
378
-
379
- # Clear the cancel event
380
- self._cancel_event = None
299
+ # Cancel the context to stop the queuer
300
+ if self._cancel_event:
301
+ self._cancel_event.set()
381
302
 
382
- except Exception as e:
383
- logger.warning(f"Error during AsyncIO cleanup: {e}")
303
+ # Wait a moment for background goroutines to finish gracefully
304
+ time.sleep(0.1)
384
305
 
306
+ # Close database connection
385
307
  if self.database:
308
+ logger.info("Closing database connection")
386
309
  try:
387
- logger.info(f"Closing database '{self.name}'")
388
310
  self.database.close()
389
311
  except Exception as e:
390
- logger.error(f"Error closing main database connection: {e}")
312
+ logger.error(f"Error closing database connection: {e}")
391
313
 
392
- logger.info(f"Queuer '{self.worker.name}' stopped")
314
+ logger.info("Queuer stopped")
393
315
 
394
316
  # Job notification listeners
395
317
  async def _handle_job_notification(self, notification: str) -> None:
@@ -524,31 +446,77 @@ class Queuer(
524
446
 
525
447
  # Tickers
526
448
  def _heartbeat_func(self) -> None:
527
- """Send periodic heartbeats - only updates database, not queuer state."""
449
+ """Send periodic heartbeats and handle worker status changes."""
528
450
  try:
529
- # Get current worker with mutex
451
+ logger.debug("Sending worker heartbeat...")
452
+
453
+ # Get current worker with read lock
530
454
  with self.worker_mutex:
531
- current_worker = self.worker
532
-
533
- updated_worker: Optional[Worker] = None
534
- if current_worker:
535
- # Update timestamp and save to database
536
- current_worker.updated_at = datetime.now()
537
- updated_worker = self.db_worker.update_worker(current_worker)
538
- logger.debug(
539
- f"Updated worker heartbeat timestamp: {current_worker.updated_at}"
455
+ worker = self.worker
456
+
457
+ if worker is None:
458
+ return
459
+
460
+ # Select worker from database for heartbeat
461
+ worker_from_db = self.db_worker.select_worker(worker.rid)
462
+ if not worker_from_db:
463
+ logger.error("Error selecting worker for heartbeat")
464
+ return
465
+
466
+ # Handle worker status
467
+ if worker_from_db.status == WorkerStatus.STOPPED:
468
+ logger.info(
469
+ f"Stopping worker... (worker_status: {worker_from_db.status})"
540
470
  )
471
+ try:
472
+ self.stop()
473
+ except Exception as e:
474
+ logger.error(f"Error stopping queuer: {e}")
475
+ return
476
+
477
+ elif worker_from_db.status == WorkerStatus.STOPPING:
478
+ if worker_from_db.max_concurrency != 0:
479
+ logger.info(
480
+ f"Gracefully stopping worker... (worker_status: {worker_from_db.status})"
481
+ )
482
+ worker_from_db.max_concurrency = 0
483
+ worker_from_db = self.db_worker.update_worker(worker_from_db)
484
+ if not worker_from_db:
485
+ logger.error("Error updating worker concurrency")
486
+ return
487
+ elif len(self.active_runners) == 0:
488
+ logger.info(
489
+ f"All running jobs finished, stopping worker... (worker_status: {worker_from_db.status})"
490
+ )
491
+ worker_from_db.status = WorkerStatus.STOPPED
492
+ worker_from_db = self.db_worker.update_worker(worker_from_db)
493
+ if not worker_from_db:
494
+ logger.error("Error updating worker status to stopped")
495
+ return
496
+ try:
497
+ self.stop()
498
+ except Exception as e:
499
+ logger.error(f"Error stopping queuer: {e}")
500
+ return
501
+
502
+ else:
503
+ # Default case: update worker heartbeat
504
+ worker_from_db = self.db_worker.update_worker(worker)
505
+ if not worker_from_db:
506
+ logger.error("Error updating worker heartbeat")
507
+ return
508
+
509
+ # Update local worker with write lock
510
+ with self.worker_mutex:
511
+ self.worker = worker_from_db
541
512
 
542
- if updated_worker:
543
- with self.worker_mutex:
544
- self.worker = updated_worker
545
513
  except Exception as e:
546
514
  logger.error(f"Heartbeat error: {e}")
547
515
 
548
516
  def _start_heartbeat_ticker(self) -> None:
549
517
  """Start heartbeat ticker using threading."""
550
518
  self.heartbeat_ticker = Ticker(
551
- timedelta(seconds=30),
519
+ self.worker_poll_interval,
552
520
  self._heartbeat_func,
553
521
  use_mp=False,
554
522
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.16.0
3
+ Version: 0.18.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,9 +43,10 @@ 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
47
  [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
47
- [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
48
48
  ![Coverage](https://raw.githubusercontent.com/siherrmann/queuerPy/refs/heads/main/coverage-badge.svg)
49
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/siherrmann/queuer/blob/master/LICENSE)
49
50
 
50
51
  Python port of the queuer package - a queueing system based on PostgreSQL.
51
52
 
@@ -97,9 +98,15 @@ def example_task():
97
98
  That's easy, right? Adding a job is just as easy:
98
99
 
99
100
  ```python
100
- # Add a job to the queue
101
+ # Add a job to the queue with positional arguments
101
102
  job = q.add_job(example_task, 5, "12")
102
103
  print(f"Job added: {job.rid}")
104
+
105
+ # Add a job with keyword arguments (kwargs)
106
+ job = q.add_job(example_task, paramKeyed1="test", debug=True)
107
+
108
+ # Add a job with both positional and keyword arguments
109
+ job = q.add_job(example_task, 5, "12", paramKeyed1="test", debug=True)
103
110
  ```
104
111
 
105
112
  In the initialisation of the queuer the existence of the necessary database tables is checked and if they don't exist they get created. The database is configured with these environment variables:
@@ -199,6 +206,72 @@ This method handles the registration of a task, making the worker able to pick u
199
206
 
200
207
  ---
201
208
 
209
+ ## add_job
210
+
211
+ The `add_job` method adds a new job to the queue for execution. Jobs are units of work that will be processed by the queuer.
212
+
213
+ ```python
214
+ def add_job(
215
+ self,
216
+ task: Union[Callable, str],
217
+ *parameters: Any,
218
+ **parameters_keyed: Any
219
+ ) -> Job
220
+
221
+ def add_job_with_options(
222
+ self,
223
+ options: Optional[Options],
224
+ task: Union[Callable, str],
225
+ *parameters: Any,
226
+ **parameters_keyed: Any
227
+ ) -> Job
228
+ ```
229
+
230
+ - `task`: A `Callable` or `str` representing the task to execute. If a callable, it must be registered with `add_task` first.
231
+ - `options`: Optional `Options` for custom error handling or scheduling behavior. Only available in `add_job_with_options()`.
232
+ - `*parameters`: Positional arguments to pass to the task function.
233
+ - `**parameters_keyed`: Keyword arguments to pass to the task function (Python-specific feature).
234
+
235
+ **Examples:**
236
+
237
+ ```python
238
+ # Job with both positional and keyword arguments
239
+ job = queuer.add_job(my_task, "arg1", paramKeyed1="test", paramKeyed2=1)
240
+
241
+ # Job with custom options (requires add_job_with_options)
242
+ options = Options(on_error=OnError(max_retries=5))
243
+ job = queuer.add_job_with_options(options, my_task, "arg1", debug=True)
244
+ ```
245
+
246
+ **Note:** Keyword arguments (`**parameters_keyed`) are stored separately in the database and enable Python functions to be called with named parameters. This is useful for optional parameters, default values, and improved code clarity. Go jobs continue to use only positional parameters.
247
+
248
+ ---
249
+
250
+ ## add_jobs
251
+
252
+ The `add_jobs` method allows efficient batch insertion of multiple jobs at once.
253
+
254
+ ```python
255
+ def add_jobs(self, batch_jobs: List[BatchJob]) -> List[UUID]
256
+ ```
257
+
258
+ - `batch_jobs`: A list of `BatchJob` instances to insert.
259
+
260
+ **Example:**
261
+
262
+ ```python
263
+ from model.batch_job import BatchJob
264
+
265
+ batch = [
266
+ BatchJob(task=my_task, parameters=[1, "a"]),
267
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
268
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
269
+ ]
270
+ job_rids = queuer.add_jobs(batch)
271
+ ```
272
+
273
+ ---
274
+
202
275
  ## add_next_interval_func
203
276
 
204
277
  The `add_next_interval_func` method registers a custom function that determines the next execution time for scheduled jobs. This is useful for implementing complex scheduling logic beyond simple fixed intervals.
@@ -296,19 +369,20 @@ class Schedule:
296
369
  ### Job Management
297
370
 
298
371
  ```python
299
- # Add a single job
300
- job = queuer.add_job(my_task, param1, param2)
372
+ # Add a job with both positional and keyword arguments
373
+ job = queuer.add_job(my_task, param1, param2, paramKeyed1="test", paramKeyed2=1)
301
374
 
302
375
  # Add a job with custom options
303
376
  from model.options import Options, OnError
304
377
  options = Options(on_error=OnError(max_retries=5, timeout=60.0))
305
- job = queuer.add_job_with_options(options, my_task, param1)
378
+ job = queuer.add_job_with_options(options, my_task, param1, paramKeyed1="test")
306
379
 
307
380
  # Add multiple jobs as a batch
308
381
  from model.batch_job import BatchJob
309
382
  batch = [
310
383
  BatchJob(task=my_task, parameters=[1, "a"]),
311
- BatchJob(task=my_task, parameters=[2, "b"])
384
+ BatchJob(task=my_task, parameters=[2], parameters_keyed={"name": "b"}),
385
+ BatchJob(task=my_task, parameters_keyed={"id": 3, "name": "c"})
312
386
  ]
313
387
  queuer.add_jobs(batch)
314
388
 
@@ -335,6 +409,7 @@ worker_jobs = queuer.get_jobs_by_worker_rid(worker.rid)
335
409
 
336
410
  # ⭐ Features
337
411
 
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.
338
413
  - **Async/Await Support**: Full asyncio integration with threading fallbacks.
339
414
  - **PostgreSQL NOTIFY/LISTEN**: Real-time job notifications without polling overhead.
340
415
  - **Batch Job Processing**: Insert job batches efficiently using PostgreSQL's `COPY FROM` feature.
@@ -12,6 +12,7 @@ queuer_listener.py
12
12
  queuer_master.py
13
13
  queuer_next_interval.py
14
14
  queuer_task.py
15
+ queuer_worker.py
15
16
  ./__init__.py
16
17
  ./_version.py
17
18
  ./py.typed
@@ -22,6 +23,7 @@ queuer_task.py
22
23
  ./queuer_master.py
23
24
  ./queuer_next_interval.py
24
25
  ./queuer_task.py
26
+ ./queuer_worker.py
25
27
  ./core/__init__.py
26
28
  ./core/broadcaster.py
27
29
  ./core/listener.py
@@ -0,0 +1,96 @@
1
+ """
2
+ Worker-related methods for the Python queuer implementation.
3
+ Mirrors Go's queuerWorker.go functionality.
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional
8
+ from uuid import UUID
9
+
10
+ from .helper.error import QueuerError
11
+ from .model.worker import Worker, WorkerStatus
12
+ from .queuer_global import QueuerGlobalMixin
13
+
14
+ # Set up logger
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class QueuerWorkerMixin(QueuerGlobalMixin):
19
+ """
20
+ Mixin class containing worker-related methods for the Queuer.
21
+ This mirrors the worker methods from Go's queuerWorker.go.
22
+ """
23
+
24
+ def __init__(self):
25
+ super().__init__()
26
+
27
+ def stop_worker(self, worker_rid: UUID) -> None:
28
+ """
29
+ StopWorkerGracefully sets the status of the specified worker to 'STOPPED'
30
+ to cancel running jobs when stopping.
31
+
32
+ :param worker_rid: The RID of the worker to stop
33
+ :raises QueuerError: If getting or updating the worker fails
34
+ """
35
+ try:
36
+ worker = self.get_worker(worker_rid)
37
+ if worker is None:
38
+ raise ValueError(f"Worker {worker_rid} not found")
39
+
40
+ worker.status = WorkerStatus.STOPPED
41
+
42
+ worker_updated = self.db_worker.update_worker(worker)
43
+ if not worker_updated:
44
+ raise ValueError(
45
+ f"Failed to update worker {worker_rid} status to stopped"
46
+ )
47
+
48
+ # Update local worker object if this is the current queuer's worker
49
+ if self.worker is not None and self.worker.rid == worker_rid:
50
+ with self.worker_mutex:
51
+ self.worker = worker_updated
52
+
53
+ except Exception as e:
54
+ raise QueuerError("stopping worker", e)
55
+
56
+ def stop_worker_gracefully(self, worker_rid: UUID) -> None:
57
+ """
58
+ StopWorkerGracefully sets the worker's status to STOPPING
59
+ to allow it to finish current tasks before stopping.
60
+
61
+ :param worker_rid: The RID of the worker to stop gracefully
62
+ :raises QueuerError: If getting or updating the worker fails
63
+ """
64
+ try:
65
+ worker = self.get_worker(worker_rid)
66
+ if worker is None:
67
+ raise ValueError(f"Worker {worker_rid} not found")
68
+
69
+ worker.status = WorkerStatus.STOPPING
70
+
71
+ worker_updated = self.db_worker.update_worker(worker)
72
+ if not worker_updated:
73
+ raise ValueError(
74
+ f"Failed to update worker {worker_rid} status to stopping"
75
+ )
76
+
77
+ # Update local worker object if this is the current queuer's worker
78
+ if self.worker is not None and self.worker.rid == worker_rid:
79
+ with self.worker_mutex:
80
+ self.worker = worker_updated
81
+
82
+ except Exception as e:
83
+ raise QueuerError("stopping worker gracefully", e)
84
+
85
+ def get_worker(self, worker_rid: UUID) -> Optional[Worker]:
86
+ """
87
+ Get a worker by its RID.
88
+
89
+ :param worker_rid: The RID of the worker to get
90
+ :returns: The worker if found, None otherwise
91
+ :raises QueuerError: If the database query fails
92
+ """
93
+ try:
94
+ return self.db_worker.select_worker(worker_rid)
95
+ except Exception as e:
96
+ raise QueuerError("getting worker", e)
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