queuerPy 0.17.0__tar.gz → 0.19.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.17.0/queuerPy.egg-info → queuerpy-0.19.0}/PKG-INFO +55 -12
  2. {queuerpy-0.17.0 → queuerpy-0.19.0}/README.md +54 -11
  3. {queuerpy-0.17.0 → queuerpy-0.19.0}/_version.py +1 -1
  4. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/worker.py +1 -0
  5. {queuerpy-0.17.0 → queuerpy-0.19.0}/pyproject.toml +2 -2
  6. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer.py +111 -143
  7. {queuerpy-0.17.0 → queuerpy-0.19.0/queuerPy.egg-info}/PKG-INFO +55 -12
  8. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuerPy.egg-info/SOURCES.txt +2 -0
  9. queuerpy-0.19.0/queuer_worker.py +96 -0
  10. {queuerpy-0.17.0 → queuerpy-0.19.0}/LICENSE +0 -0
  11. {queuerpy-0.17.0 → queuerpy-0.19.0}/MANIFEST.in +0 -0
  12. {queuerpy-0.17.0 → queuerpy-0.19.0}/__init__.py +0 -0
  13. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/__init__.py +0 -0
  14. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/broadcaster.py +0 -0
  15. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/listener.py +0 -0
  16. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/retryer.py +0 -0
  17. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/runner.py +0 -0
  18. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/scheduler.py +0 -0
  19. {queuerpy-0.17.0 → queuerpy-0.19.0}/core/ticker.py +0 -0
  20. {queuerpy-0.17.0 → queuerpy-0.19.0}/database/__init__.py +0 -0
  21. {queuerpy-0.17.0 → queuerpy-0.19.0}/database/db_job.py +0 -0
  22. {queuerpy-0.17.0 → queuerpy-0.19.0}/database/db_listener.py +0 -0
  23. {queuerpy-0.17.0 → queuerpy-0.19.0}/database/db_master.py +0 -0
  24. {queuerpy-0.17.0 → queuerpy-0.19.0}/database/db_worker.py +0 -0
  25. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/__init__.py +0 -0
  26. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/database.py +0 -0
  27. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/error.py +0 -0
  28. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/logging.py +0 -0
  29. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/sql.py +0 -0
  30. {queuerpy-0.17.0 → queuerpy-0.19.0}/helper/task.py +0 -0
  31. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/__init__.py +0 -0
  32. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/batch_job.py +0 -0
  33. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/connection.py +0 -0
  34. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/job.py +0 -0
  35. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/master.py +0 -0
  36. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/options.py +0 -0
  37. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/options_on_error.py +0 -0
  38. {queuerpy-0.17.0 → queuerpy-0.19.0}/model/task.py +0 -0
  39. {queuerpy-0.17.0 → queuerpy-0.19.0}/py.typed +0 -0
  40. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuerPy.egg-info/dependency_links.txt +0 -0
  41. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuerPy.egg-info/requires.txt +0 -0
  42. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuerPy.egg-info/top_level.txt +0 -0
  43. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_global.py +0 -0
  44. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_job.py +0 -0
  45. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_listener.py +0 -0
  46. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_master.py +0 -0
  47. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_next_interval.py +0 -0
  48. {queuerpy-0.17.0 → queuerpy-0.19.0}/queuer_task.py +0 -0
  49. {queuerpy-0.17.0 → queuerpy-0.19.0}/setup.cfg +0 -0
  50. {queuerpy-0.17.0 → queuerpy-0.19.0}/sql/job.sql +0 -0
  51. {queuerpy-0.17.0 → queuerpy-0.19.0}/sql/master.sql +0 -0
  52. {queuerpy-0.17.0 → queuerpy-0.19.0}/sql/notify.sql +0 -0
  53. {queuerpy-0.17.0 → queuerpy-0.19.0}/sql/worker.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queuerPy
3
- Version: 0.17.0
3
+ Version: 0.19.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>
@@ -58,9 +58,7 @@ The job table contains only queued, scheduled and running tasks. The ended jobs
58
58
 
59
59
  ---
60
60
 
61
- # 🛠️ Installation
62
-
63
- ## PyPI Installation (Recommended)
61
+ ## 🛠️ Installation
64
62
 
65
63
  ```bash
66
64
  pip install queuerPy
@@ -134,10 +132,10 @@ You can find a full example in the example folder.
134
132
  def new_queuer(name: str, max_concurrency: int, *options: OnError) -> Queuer
135
133
 
136
134
  def new_queuer_with_db(
137
- name: str,
138
- max_concurrency: int,
139
- encryption_key: str,
140
- db_config: DatabaseConfiguration,
135
+ name: str,
136
+ max_concurrency: int,
137
+ encryption_key: str,
138
+ db_config: DatabaseConfiguration,
141
139
  *options: OnError
142
140
  ) -> Queuer
143
141
  ```
@@ -149,6 +147,7 @@ def new_queuer_with_db(
149
147
  - `options`: Optional `OnError` configurations to apply to the worker.
150
148
 
151
149
  This function performs the following setup:
150
+
152
151
  - Initializes a logger.
153
152
  - Sets up the database connection using the provided `db_config` or environment variables.
154
153
  - Creates `JobDBHandler`, `WorkerDBHandler` instances for database interactions.
@@ -167,6 +166,7 @@ def start(self) -> None
167
166
  ```
168
167
 
169
168
  Upon calling `start`:
169
+
170
170
  - It performs a basic check to ensure internal listeners are initialized.
171
171
  - Database listeners are created to listen to job events (inserts, updates, deletes) via PostgreSQL NOTIFY/LISTEN.
172
172
  - It starts a poller to periodically poll the database for new jobs to process.
@@ -185,7 +185,50 @@ The `stop` method gracefully shuts down the Queuer instance, releasing resources
185
185
  def stop(self) -> None
186
186
  ```
187
187
 
188
- The `stop` method cancels all jobs, closes database listeners, and cleans up resources.
188
+ The `stop` method cancels all jobs, closes database listeners, and cleans up resources. **Note:** This method can only be used to stop the current worker instance that the code is running in. To stop other workers, use `stop_worker` or `stop_worker_gracefully`.
189
+
190
+ ---
191
+
192
+ ## stop_worker
193
+
194
+ The `stop_worker` method immediately stops a worker by setting its status to `STOPPED`. This will cancel all running jobs on that worker.
195
+
196
+ ```python
197
+ def stop_worker(self, worker_rid: UUID) -> None
198
+ ```
199
+
200
+ - `worker_rid`: The `UUID` identifying the worker to stop.
201
+
202
+ When a worker is stopped:
203
+
204
+ - The worker status is immediately set to `STOPPED` in the database
205
+ - The heartbeat ticker detects the `STOPPED` status and calls `stop()` on that worker
206
+ - All running jobs on that worker are cancelled immediately
207
+ - The worker will no longer accept new jobs
208
+
209
+ This method is useful for immediately shutting down a worker, for example in emergency situations or when you need to take a worker offline quickly.
210
+
211
+ ---
212
+
213
+ ## stop_worker_gracefully
214
+
215
+ The `stop_worker_gracefully` method gracefully stops a worker by setting its status to `STOPPING`. This allows currently running jobs to complete before the worker shuts down.
216
+
217
+ ```python
218
+ def stop_worker_gracefully(self, worker_rid: UUID) -> None
219
+ ```
220
+
221
+ - `worker_rid`: The `UUID` identifying the worker to stop gracefully.
222
+
223
+ When a worker is stopped gracefully:
224
+
225
+ - The worker status is set to `STOPPING` in the database
226
+ - The heartbeat ticker detects the `STOPPING` status and sets `max_concurrency` to `0`
227
+ - Currently running jobs are allowed to complete normally
228
+ - No new jobs will be accepted by this worker
229
+ - Once all running jobs have finished, the worker status is automatically set to `STOPPED` and the worker shuts down
230
+
231
+ This method is ideal for maintenance scenarios where you want to ensure all in-progress work completes before shutting down the worker.
189
232
 
190
233
  ---
191
234
 
@@ -212,8 +255,8 @@ The `add_job` method adds a new job to the queue for execution. Jobs are units o
212
255
 
213
256
  ```python
214
257
  def add_job(
215
- self,
216
- task: Union[Callable, str],
258
+ self,
259
+ task: Union[Callable, str],
217
260
  *parameters: Any,
218
261
  **parameters_keyed: Any
219
262
  ) -> Job
@@ -322,7 +365,7 @@ class RetryBackoff(str, Enum):
322
365
 
323
366
  - `RETRY_BACKOFF_NONE`: No backoff. The retry_delay remains constant for all retries.
324
367
  - `RETRY_BACKOFF_LINEAR`: The retry delay increases linearly with each attempt (e.g., delay, 2*delay, 3*delay).
325
- - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2*2).
368
+ - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2\*2).
326
369
 
327
370
  ---
328
371
 
@@ -15,9 +15,7 @@ The job table contains only queued, scheduled and running tasks. The ended jobs
15
15
 
16
16
  ---
17
17
 
18
- # 🛠️ Installation
19
-
20
- ## PyPI Installation (Recommended)
18
+ ## 🛠️ Installation
21
19
 
22
20
  ```bash
23
21
  pip install queuerPy
@@ -91,10 +89,10 @@ You can find a full example in the example folder.
91
89
  def new_queuer(name: str, max_concurrency: int, *options: OnError) -> Queuer
92
90
 
93
91
  def new_queuer_with_db(
94
- name: str,
95
- max_concurrency: int,
96
- encryption_key: str,
97
- db_config: DatabaseConfiguration,
92
+ name: str,
93
+ max_concurrency: int,
94
+ encryption_key: str,
95
+ db_config: DatabaseConfiguration,
98
96
  *options: OnError
99
97
  ) -> Queuer
100
98
  ```
@@ -106,6 +104,7 @@ def new_queuer_with_db(
106
104
  - `options`: Optional `OnError` configurations to apply to the worker.
107
105
 
108
106
  This function performs the following setup:
107
+
109
108
  - Initializes a logger.
110
109
  - Sets up the database connection using the provided `db_config` or environment variables.
111
110
  - Creates `JobDBHandler`, `WorkerDBHandler` instances for database interactions.
@@ -124,6 +123,7 @@ def start(self) -> None
124
123
  ```
125
124
 
126
125
  Upon calling `start`:
126
+
127
127
  - It performs a basic check to ensure internal listeners are initialized.
128
128
  - Database listeners are created to listen to job events (inserts, updates, deletes) via PostgreSQL NOTIFY/LISTEN.
129
129
  - It starts a poller to periodically poll the database for new jobs to process.
@@ -142,7 +142,50 @@ The `stop` method gracefully shuts down the Queuer instance, releasing resources
142
142
  def stop(self) -> None
143
143
  ```
144
144
 
145
- The `stop` method cancels all jobs, closes database listeners, and cleans up resources.
145
+ The `stop` method cancels all jobs, closes database listeners, and cleans up resources. **Note:** This method can only be used to stop the current worker instance that the code is running in. To stop other workers, use `stop_worker` or `stop_worker_gracefully`.
146
+
147
+ ---
148
+
149
+ ## stop_worker
150
+
151
+ The `stop_worker` method immediately stops a worker by setting its status to `STOPPED`. This will cancel all running jobs on that worker.
152
+
153
+ ```python
154
+ def stop_worker(self, worker_rid: UUID) -> None
155
+ ```
156
+
157
+ - `worker_rid`: The `UUID` identifying the worker to stop.
158
+
159
+ When a worker is stopped:
160
+
161
+ - The worker status is immediately set to `STOPPED` in the database
162
+ - The heartbeat ticker detects the `STOPPED` status and calls `stop()` on that worker
163
+ - All running jobs on that worker are cancelled immediately
164
+ - The worker will no longer accept new jobs
165
+
166
+ This method is useful for immediately shutting down a worker, for example in emergency situations or when you need to take a worker offline quickly.
167
+
168
+ ---
169
+
170
+ ## stop_worker_gracefully
171
+
172
+ The `stop_worker_gracefully` method gracefully stops a worker by setting its status to `STOPPING`. This allows currently running jobs to complete before the worker shuts down.
173
+
174
+ ```python
175
+ def stop_worker_gracefully(self, worker_rid: UUID) -> None
176
+ ```
177
+
178
+ - `worker_rid`: The `UUID` identifying the worker to stop gracefully.
179
+
180
+ When a worker is stopped gracefully:
181
+
182
+ - The worker status is set to `STOPPING` in the database
183
+ - The heartbeat ticker detects the `STOPPING` status and sets `max_concurrency` to `0`
184
+ - Currently running jobs are allowed to complete normally
185
+ - No new jobs will be accepted by this worker
186
+ - Once all running jobs have finished, the worker status is automatically set to `STOPPED` and the worker shuts down
187
+
188
+ This method is ideal for maintenance scenarios where you want to ensure all in-progress work completes before shutting down the worker.
146
189
 
147
190
  ---
148
191
 
@@ -169,8 +212,8 @@ The `add_job` method adds a new job to the queue for execution. Jobs are units o
169
212
 
170
213
  ```python
171
214
  def add_job(
172
- self,
173
- task: Union[Callable, str],
215
+ self,
216
+ task: Union[Callable, str],
174
217
  *parameters: Any,
175
218
  **parameters_keyed: Any
176
219
  ) -> Job
@@ -279,7 +322,7 @@ class RetryBackoff(str, Enum):
279
322
 
280
323
  - `RETRY_BACKOFF_NONE`: No backoff. The retry_delay remains constant for all retries.
281
324
  - `RETRY_BACKOFF_LINEAR`: The retry delay increases linearly with each attempt (e.g., delay, 2*delay, 3*delay).
282
- - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2*2).
325
+ - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2\*2).
283
326
 
284
327
  ---
285
328
 
@@ -1,6 +1,6 @@
1
1
  """Version information for queuerPy package."""
2
2
 
3
- __version__ = "0.17.0"
3
+ __version__ = "0.19.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.17.0"
7
+ version = "0.19.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.17.0"
95
+ python_version = "0.19.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.17.0
3
+ Version: 0.19.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>
@@ -58,9 +58,7 @@ The job table contains only queued, scheduled and running tasks. The ended jobs
58
58
 
59
59
  ---
60
60
 
61
- # 🛠️ Installation
62
-
63
- ## PyPI Installation (Recommended)
61
+ ## 🛠️ Installation
64
62
 
65
63
  ```bash
66
64
  pip install queuerPy
@@ -134,10 +132,10 @@ You can find a full example in the example folder.
134
132
  def new_queuer(name: str, max_concurrency: int, *options: OnError) -> Queuer
135
133
 
136
134
  def new_queuer_with_db(
137
- name: str,
138
- max_concurrency: int,
139
- encryption_key: str,
140
- db_config: DatabaseConfiguration,
135
+ name: str,
136
+ max_concurrency: int,
137
+ encryption_key: str,
138
+ db_config: DatabaseConfiguration,
141
139
  *options: OnError
142
140
  ) -> Queuer
143
141
  ```
@@ -149,6 +147,7 @@ def new_queuer_with_db(
149
147
  - `options`: Optional `OnError` configurations to apply to the worker.
150
148
 
151
149
  This function performs the following setup:
150
+
152
151
  - Initializes a logger.
153
152
  - Sets up the database connection using the provided `db_config` or environment variables.
154
153
  - Creates `JobDBHandler`, `WorkerDBHandler` instances for database interactions.
@@ -167,6 +166,7 @@ def start(self) -> None
167
166
  ```
168
167
 
169
168
  Upon calling `start`:
169
+
170
170
  - It performs a basic check to ensure internal listeners are initialized.
171
171
  - Database listeners are created to listen to job events (inserts, updates, deletes) via PostgreSQL NOTIFY/LISTEN.
172
172
  - It starts a poller to periodically poll the database for new jobs to process.
@@ -185,7 +185,50 @@ The `stop` method gracefully shuts down the Queuer instance, releasing resources
185
185
  def stop(self) -> None
186
186
  ```
187
187
 
188
- The `stop` method cancels all jobs, closes database listeners, and cleans up resources.
188
+ The `stop` method cancels all jobs, closes database listeners, and cleans up resources. **Note:** This method can only be used to stop the current worker instance that the code is running in. To stop other workers, use `stop_worker` or `stop_worker_gracefully`.
189
+
190
+ ---
191
+
192
+ ## stop_worker
193
+
194
+ The `stop_worker` method immediately stops a worker by setting its status to `STOPPED`. This will cancel all running jobs on that worker.
195
+
196
+ ```python
197
+ def stop_worker(self, worker_rid: UUID) -> None
198
+ ```
199
+
200
+ - `worker_rid`: The `UUID` identifying the worker to stop.
201
+
202
+ When a worker is stopped:
203
+
204
+ - The worker status is immediately set to `STOPPED` in the database
205
+ - The heartbeat ticker detects the `STOPPED` status and calls `stop()` on that worker
206
+ - All running jobs on that worker are cancelled immediately
207
+ - The worker will no longer accept new jobs
208
+
209
+ This method is useful for immediately shutting down a worker, for example in emergency situations or when you need to take a worker offline quickly.
210
+
211
+ ---
212
+
213
+ ## stop_worker_gracefully
214
+
215
+ The `stop_worker_gracefully` method gracefully stops a worker by setting its status to `STOPPING`. This allows currently running jobs to complete before the worker shuts down.
216
+
217
+ ```python
218
+ def stop_worker_gracefully(self, worker_rid: UUID) -> None
219
+ ```
220
+
221
+ - `worker_rid`: The `UUID` identifying the worker to stop gracefully.
222
+
223
+ When a worker is stopped gracefully:
224
+
225
+ - The worker status is set to `STOPPING` in the database
226
+ - The heartbeat ticker detects the `STOPPING` status and sets `max_concurrency` to `0`
227
+ - Currently running jobs are allowed to complete normally
228
+ - No new jobs will be accepted by this worker
229
+ - Once all running jobs have finished, the worker status is automatically set to `STOPPED` and the worker shuts down
230
+
231
+ This method is ideal for maintenance scenarios where you want to ensure all in-progress work completes before shutting down the worker.
189
232
 
190
233
  ---
191
234
 
@@ -212,8 +255,8 @@ The `add_job` method adds a new job to the queue for execution. Jobs are units o
212
255
 
213
256
  ```python
214
257
  def add_job(
215
- self,
216
- task: Union[Callable, str],
258
+ self,
259
+ task: Union[Callable, str],
217
260
  *parameters: Any,
218
261
  **parameters_keyed: Any
219
262
  ) -> Job
@@ -322,7 +365,7 @@ class RetryBackoff(str, Enum):
322
365
 
323
366
  - `RETRY_BACKOFF_NONE`: No backoff. The retry_delay remains constant for all retries.
324
367
  - `RETRY_BACKOFF_LINEAR`: The retry delay increases linearly with each attempt (e.g., delay, 2*delay, 3*delay).
325
- - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2*2).
368
+ - `RETRY_BACKOFF_EXPONENTIAL`: The retry delay increases exponentially with each attempt (e.g., delay, delay*2, delay*2\*2).
326
369
 
327
370
  ---
328
371
 
@@ -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