queuerPy 0.1.0__py3-none-any.whl
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.
- _version.py +7 -0
- queuer.py +580 -0
- queuerPy/__init__.py +77 -0
- queuerPy/_version.py +7 -0
- queuerPy/queuer.py +580 -0
- queuerPy/queuer_global.py +56 -0
- queuerPy/queuer_job.py +600 -0
- queuerPy/queuer_listener.py +73 -0
- queuerPy/queuer_next_interval.py +96 -0
- queuerPy/queuer_task.py +89 -0
- queuer_global.py +56 -0
- queuer_job.py +600 -0
- queuer_listener.py +73 -0
- queuer_next_interval.py +96 -0
- queuer_task.py +89 -0
- queuerpy-0.1.0.dist-info/METADATA +393 -0
- queuerpy-0.1.0.dist-info/RECORD +20 -0
- queuerpy-0.1.0.dist-info/WHEEL +5 -0
- queuerpy-0.1.0.dist-info/licenses/LICENSE +13 -0
- queuerpy-0.1.0.dist-info/top_level.txt +8 -0
_version.py
ADDED
queuer.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Queuer class for Python implementation.
|
|
3
|
+
Mirrors the Go Queuer struct with Python async patterns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Standard library imports
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from typing import Optional, Dict, Callable, Any
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from core.ticker import Ticker
|
|
18
|
+
from core.runner import Runner, SmallRunner, go_func
|
|
19
|
+
from database.db_listener import QueuerListener, new_queuer_db_listener
|
|
20
|
+
from helper.logging import get_logger
|
|
21
|
+
from helper.database import DatabaseConfiguration
|
|
22
|
+
from model.job import Job, JobStatus
|
|
23
|
+
from model.worker import Worker, new_worker, new_worker_with_options, WorkerStatus
|
|
24
|
+
from model.options_on_error import OnError
|
|
25
|
+
from queuer_job import QueuerJobMixin
|
|
26
|
+
from queuer_task import QueuerTaskMixin
|
|
27
|
+
from queuer_next_interval import QueuerNextIntervalMixin
|
|
28
|
+
from queuer_listener import QueuerListenerMixin
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def new_queuer(name: str, max_concurrency: int, *options: OnError) -> "Queuer":
|
|
34
|
+
"""
|
|
35
|
+
Create a new Queuer instance with the given name and max concurrency.
|
|
36
|
+
The encryption key for the database is taken from environment variable QUEUER_ENCRYPTION_KEY.
|
|
37
|
+
"""
|
|
38
|
+
encryption_key = os.getenv("QUEUER_ENCRYPTION_KEY", "")
|
|
39
|
+
first_option = options[0] if len(options) > 0 else None
|
|
40
|
+
return Queuer(name, max_concurrency, encryption_key, None, first_option)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def new_queuer_with_db(
|
|
44
|
+
name: str,
|
|
45
|
+
max_concurrency: int,
|
|
46
|
+
encryption_key: str = "",
|
|
47
|
+
db_config: Optional[DatabaseConfiguration] = None,
|
|
48
|
+
*options: OnError,
|
|
49
|
+
) -> "Queuer":
|
|
50
|
+
"""
|
|
51
|
+
Create a new Queuer instance with database configuration.
|
|
52
|
+
If dbConfig is None, uses environment variables for database connection.
|
|
53
|
+
|
|
54
|
+
If the encryption key is empty, it defaults to unencrypted results.
|
|
55
|
+
|
|
56
|
+
If any error occurs during initialization, it logs a panic error and exits the program.
|
|
57
|
+
It returns a pointer to the newly created Queuer instance.
|
|
58
|
+
"""
|
|
59
|
+
first_option = options[0] if len(options) > 0 else None
|
|
60
|
+
return Queuer(name, max_concurrency, encryption_key, db_config, first_option)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Queuer(
|
|
64
|
+
QueuerJobMixin,
|
|
65
|
+
QueuerTaskMixin,
|
|
66
|
+
QueuerNextIntervalMixin,
|
|
67
|
+
QueuerListenerMixin,
|
|
68
|
+
):
|
|
69
|
+
"""
|
|
70
|
+
Main queuing system class.
|
|
71
|
+
Mirrors the Go Queuer struct with Python async compatibility.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
name: str,
|
|
77
|
+
max_concurrency: int,
|
|
78
|
+
encryption_key: str = "",
|
|
79
|
+
db_config: Optional[DatabaseConfiguration] = None,
|
|
80
|
+
options: Optional[OnError] = None,
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
__init__ is the inner initialization method that's being used for new_queuer and new_queuer_with_db.
|
|
84
|
+
It initializes the database connection and worker.
|
|
85
|
+
If options are provided, it creates a worker with those options.
|
|
86
|
+
|
|
87
|
+
It takes the db configuration from environment variables if dbConfig is nil.
|
|
88
|
+
- QUEUER_DB_HOST (required)
|
|
89
|
+
- QUEUER_DB_PORT (required)
|
|
90
|
+
- QUEUER_DB_DATABASE (required)
|
|
91
|
+
- QUEUER_DB_USERNAME (required)
|
|
92
|
+
- QUEUER_DB_PASSWORD (required)
|
|
93
|
+
- QUEUER_DB_SCHEMA (required)
|
|
94
|
+
- QUEUER_DB_SSLMODE (optional, defaults to "require")
|
|
95
|
+
|
|
96
|
+
If the encryption key is empty, it defaults to unencrypted results.
|
|
97
|
+
|
|
98
|
+
If any error occurs during initialization, it logs a panic error and exits the program.
|
|
99
|
+
It returns a pointer to the newly created Queuer instance.
|
|
100
|
+
"""
|
|
101
|
+
super().__init__()
|
|
102
|
+
|
|
103
|
+
# Store name and concurrency for access
|
|
104
|
+
self.name: str = name
|
|
105
|
+
self.max_concurrency: int = max_concurrency
|
|
106
|
+
|
|
107
|
+
# Context management - will be set in start()
|
|
108
|
+
self._stopped: threading.Event = threading.Event()
|
|
109
|
+
self._cancel_event: Optional[asyncio.Event] = None
|
|
110
|
+
|
|
111
|
+
# Configuration
|
|
112
|
+
self.job_poll_interval: timedelta = timedelta(minutes=5)
|
|
113
|
+
self.retention_archive: timedelta = timedelta(days=30)
|
|
114
|
+
|
|
115
|
+
# Active runners (jobs currently executing)
|
|
116
|
+
self.active_runners: Dict[UUID, Runner] = {}
|
|
117
|
+
|
|
118
|
+
# Database listeners (will be initialized in start())
|
|
119
|
+
self.job_db_listener: Optional[QueuerListener] = None
|
|
120
|
+
self.job_archive_db_listener: Optional[QueuerListener] = None
|
|
121
|
+
|
|
122
|
+
# Database listener runners (track go_func runners for cleanup)
|
|
123
|
+
self.job_db_listener_runner: Optional[SmallRunner] = None
|
|
124
|
+
self.job_archive_db_listener_runner: Optional[SmallRunner] = None
|
|
125
|
+
|
|
126
|
+
# Database configuration
|
|
127
|
+
if db_config is not None:
|
|
128
|
+
self.db_config: DatabaseConfiguration = db_config
|
|
129
|
+
else:
|
|
130
|
+
self.db_config: DatabaseConfiguration = DatabaseConfiguration.from_env()
|
|
131
|
+
|
|
132
|
+
super().initialise(
|
|
133
|
+
db_config=self.db_config,
|
|
134
|
+
encryption_key=encryption_key,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Create and insert worker
|
|
138
|
+
if options:
|
|
139
|
+
new_worker_obj: Worker = new_worker_with_options(
|
|
140
|
+
name, max_concurrency, options
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
new_worker_obj: Worker = new_worker(name, max_concurrency)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
self.worker: Worker = self.db_worker.insert_worker(new_worker_obj)
|
|
147
|
+
self.worker_mutex: threading.RLock = threading.RLock()
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Error inserting worker into database: {e}")
|
|
150
|
+
raise RuntimeError(f"Error inserting worker into database: {e}")
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
f"Queuer with worker created: {new_worker_obj.name} (RID: {self.worker.rid})"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def start(self):
|
|
157
|
+
"""Start the queuer."""
|
|
158
|
+
if self.running:
|
|
159
|
+
raise RuntimeError("Queuer is already running")
|
|
160
|
+
|
|
161
|
+
# No need to verify event loop with multiprocessing-based Runner
|
|
162
|
+
|
|
163
|
+
# Set up context
|
|
164
|
+
self.running = True
|
|
165
|
+
self._stopped.clear()
|
|
166
|
+
self._cancel_event = asyncio.Event()
|
|
167
|
+
|
|
168
|
+
# Set up database listeners
|
|
169
|
+
try:
|
|
170
|
+
self.job_db_listener = new_queuer_db_listener(self.db_config, "job")
|
|
171
|
+
logger.info("Added listener for channel: job")
|
|
172
|
+
self.job_archive_db_listener = new_queuer_db_listener(
|
|
173
|
+
self.db_config, "job_archive"
|
|
174
|
+
)
|
|
175
|
+
logger.info("Added listener for channel: job_archive")
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Error creating database listeners: {e}")
|
|
178
|
+
raise RuntimeError(f"Error creating database listeners: {e}")
|
|
179
|
+
|
|
180
|
+
# Ensure database is connected (reconnect if it was closed by a previous stop())
|
|
181
|
+
try:
|
|
182
|
+
if not self.database.instance:
|
|
183
|
+
logger.debug("Database connection was closed, reconnecting...")
|
|
184
|
+
self.database.connect_to_database()
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error(f"Error reconnecting to database: {e}")
|
|
187
|
+
raise RuntimeError(f"Error reconnecting to database: {e}")
|
|
188
|
+
|
|
189
|
+
# Update worker status to running
|
|
190
|
+
try:
|
|
191
|
+
with self.worker_mutex:
|
|
192
|
+
self.worker.status = WorkerStatus.RUNNING
|
|
193
|
+
worker_updated = self.db_worker.update_worker(self.worker)
|
|
194
|
+
if not worker_updated:
|
|
195
|
+
raise Exception("Could not update worker to RUNNING")
|
|
196
|
+
|
|
197
|
+
self.worker = worker_updated
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Error updating worker status to running: {e}")
|
|
200
|
+
raise RuntimeError(f"Error updating worker status to running: {e}")
|
|
201
|
+
|
|
202
|
+
# Start background tasks
|
|
203
|
+
self._start_listeners()
|
|
204
|
+
self._wait_for_listeners_ready()
|
|
205
|
+
|
|
206
|
+
self._start_heartbeat_ticker()
|
|
207
|
+
self._start_poll_job_ticker() # Backup polling every 5 minutes
|
|
208
|
+
|
|
209
|
+
logger.info("Queuer started")
|
|
210
|
+
|
|
211
|
+
def stop(self):
|
|
212
|
+
"""Stop the queuer."""
|
|
213
|
+
# Store whether we were running to decide if we need to close connections
|
|
214
|
+
was_running = getattr(self, "_running", False)
|
|
215
|
+
|
|
216
|
+
# Stop tickers first to prevent new work from being queued
|
|
217
|
+
if self.heartbeat_ticker:
|
|
218
|
+
try:
|
|
219
|
+
self.heartbeat_ticker.stop()
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Error stopping heartbeat ticker: {e}")
|
|
222
|
+
|
|
223
|
+
if self.poll_job_ticker:
|
|
224
|
+
try:
|
|
225
|
+
self.poll_job_ticker.stop()
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"Error stopping poll job ticker: {e}")
|
|
228
|
+
|
|
229
|
+
# Close database listeners
|
|
230
|
+
if self.job_db_listener:
|
|
231
|
+
try:
|
|
232
|
+
# Use go_func but wait for completion
|
|
233
|
+
task = go_func(self.job_db_listener.stop, use_mp=False)
|
|
234
|
+
task.get_results(timeout=3.0) # Wait up to 3 seconds total
|
|
235
|
+
except Exception as e:
|
|
236
|
+
# Only log if it's not already closed
|
|
237
|
+
if "closed" not in str(e).lower():
|
|
238
|
+
logger.warning(f"Error stopping job listener: {e}")
|
|
239
|
+
|
|
240
|
+
# Wait for database listener runners to complete
|
|
241
|
+
if self.job_db_listener_runner:
|
|
242
|
+
try:
|
|
243
|
+
self.job_db_listener_runner.get_results(timeout=2.0)
|
|
244
|
+
self.job_db_listener_runner = None
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.warning(f"Error waiting for job listener runner: {e}")
|
|
247
|
+
|
|
248
|
+
if self.job_archive_db_listener:
|
|
249
|
+
try:
|
|
250
|
+
task = go_func(self.job_archive_db_listener.stop, use_mp=False)
|
|
251
|
+
task.get_results(timeout=3.0) # Wait up to 3 seconds total
|
|
252
|
+
except Exception as e:
|
|
253
|
+
if "closed" not in str(e).lower():
|
|
254
|
+
logger.warning(f"Error stopping job archive listener: {e}")
|
|
255
|
+
|
|
256
|
+
# Wait for database archive listener runner to complete
|
|
257
|
+
if self.job_archive_db_listener_runner:
|
|
258
|
+
try:
|
|
259
|
+
self.job_archive_db_listener_runner.get_results(timeout=2.0)
|
|
260
|
+
self.job_archive_db_listener_runner = None
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.warning(f"Error waiting for job archive listener runner: {e}")
|
|
263
|
+
|
|
264
|
+
# Update worker status to stopped (only if we were running)
|
|
265
|
+
worker_rid = None
|
|
266
|
+
if was_running:
|
|
267
|
+
try:
|
|
268
|
+
with self.worker_mutex:
|
|
269
|
+
self.worker.status = WorkerStatus.STOPPED
|
|
270
|
+
worker_updated = self.db_worker.update_worker(self.worker)
|
|
271
|
+
if worker_updated:
|
|
272
|
+
worker_rid = self.worker.rid
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error(f"Error updating worker status to stopped: {e}")
|
|
275
|
+
else:
|
|
276
|
+
# If we were never running, just get the worker_rid for job cancellation
|
|
277
|
+
worker_rid = self.worker.rid
|
|
278
|
+
|
|
279
|
+
# Cancel all queued and running jobs
|
|
280
|
+
if worker_rid:
|
|
281
|
+
try:
|
|
282
|
+
self.cancel_all_jobs_by_worker(worker_rid, 100)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Error cancelling all jobs by worker: {e}")
|
|
285
|
+
|
|
286
|
+
# Cancel the context equivalent - set cancellation event
|
|
287
|
+
if self._cancel_event:
|
|
288
|
+
try:
|
|
289
|
+
self._cancel_event.set()
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.warning(f"Error setting cancel event: {e}")
|
|
292
|
+
|
|
293
|
+
# Signal that we're stopping
|
|
294
|
+
self.running = False
|
|
295
|
+
self._stopped.set()
|
|
296
|
+
|
|
297
|
+
# Cancel all active runners - similar to Go's job cancellation
|
|
298
|
+
active_runner_count = len(self.active_runners)
|
|
299
|
+
if active_runner_count > 0:
|
|
300
|
+
logger.info(f"Cancelling {active_runner_count} active runners")
|
|
301
|
+
|
|
302
|
+
for runner_id, runner in list(self.active_runners.items()):
|
|
303
|
+
try:
|
|
304
|
+
runner.cancel()
|
|
305
|
+
if runner_id in self.active_runners:
|
|
306
|
+
del self.active_runners[runner_id]
|
|
307
|
+
logger.debug(f"Removed runner {runner_id} from active_runners")
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.warning(f"Error cancelling runner {runner_id}: {e}")
|
|
311
|
+
|
|
312
|
+
# Final check
|
|
313
|
+
remaining_runners = len(self.active_runners)
|
|
314
|
+
if remaining_runners > 0:
|
|
315
|
+
logger.warning(f"{remaining_runners} runners could not be cleaned up")
|
|
316
|
+
else:
|
|
317
|
+
logger.debug("All active runners cleaned up successfully")
|
|
318
|
+
|
|
319
|
+
# Cleanup broadcasters and listeners
|
|
320
|
+
try:
|
|
321
|
+
broadcaster_count = 0
|
|
322
|
+
if self.job_insert_broadcaster:
|
|
323
|
+
listener_count = len(self.job_insert_broadcaster.listeners)
|
|
324
|
+
if listener_count > 0:
|
|
325
|
+
logger.debug(
|
|
326
|
+
f"Clearing {listener_count} listeners from job_insert_broadcaster"
|
|
327
|
+
)
|
|
328
|
+
self.job_insert_broadcaster.listeners.clear()
|
|
329
|
+
broadcaster_count += 1
|
|
330
|
+
|
|
331
|
+
if self.job_update_broadcaster:
|
|
332
|
+
listener_count = len(self.job_update_broadcaster.listeners)
|
|
333
|
+
if listener_count > 0:
|
|
334
|
+
logger.debug(
|
|
335
|
+
f"Clearing {listener_count} listeners from job_update_broadcaster"
|
|
336
|
+
)
|
|
337
|
+
self.job_update_broadcaster.listeners.clear()
|
|
338
|
+
broadcaster_count += 1
|
|
339
|
+
|
|
340
|
+
if self.job_delete_broadcaster:
|
|
341
|
+
listener_count = len(self.job_delete_broadcaster.listeners)
|
|
342
|
+
if listener_count > 0:
|
|
343
|
+
logger.debug(
|
|
344
|
+
f"Clearing {listener_count} listeners from job_delete_broadcaster"
|
|
345
|
+
)
|
|
346
|
+
self.job_delete_broadcaster.listeners.clear()
|
|
347
|
+
broadcaster_count += 1
|
|
348
|
+
|
|
349
|
+
if broadcaster_count > 0:
|
|
350
|
+
logger.debug(f"Cleaned up {broadcaster_count} broadcasters")
|
|
351
|
+
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.warning(f"Error during broadcaster cleanup: {e}")
|
|
354
|
+
|
|
355
|
+
# Cleanup AsyncIO resources
|
|
356
|
+
try:
|
|
357
|
+
try:
|
|
358
|
+
loop = asyncio.get_running_loop()
|
|
359
|
+
pending_tasks = [
|
|
360
|
+
task for task in asyncio.all_tasks(loop) if not task.done()
|
|
361
|
+
]
|
|
362
|
+
if pending_tasks:
|
|
363
|
+
logger.debug(f"Cancelling {len(pending_tasks)} pending async tasks")
|
|
364
|
+
for task in pending_tasks:
|
|
365
|
+
task.cancel()
|
|
366
|
+
except RuntimeError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
# Clear the cancel event
|
|
370
|
+
self._cancel_event = None
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.warning(f"Error during AsyncIO cleanup: {e}")
|
|
374
|
+
|
|
375
|
+
if self.database:
|
|
376
|
+
try:
|
|
377
|
+
logger.info(f"Closing database '{self.name}'")
|
|
378
|
+
self.database.close()
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Error closing main database connection: {e}")
|
|
381
|
+
|
|
382
|
+
logger.info(f"Queuer '{self.worker.name}' stopped")
|
|
383
|
+
|
|
384
|
+
# Job notification listeners
|
|
385
|
+
async def _handle_job_notification(self, notification: str):
|
|
386
|
+
"""
|
|
387
|
+
Handle job database notifications.
|
|
388
|
+
|
|
389
|
+
:param notification: The notification payload as a string
|
|
390
|
+
"""
|
|
391
|
+
if not self.running:
|
|
392
|
+
logger.warning(f"Queuer not running, ignoring job notification")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if not (
|
|
396
|
+
hasattr(self, "job_insert_broadcaster") and self.job_insert_broadcaster
|
|
397
|
+
):
|
|
398
|
+
logger.debug(f"[{self.name}] No job_insert_broadcaster available")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
if not (
|
|
402
|
+
hasattr(self, "job_update_broadcaster") and self.job_update_broadcaster
|
|
403
|
+
):
|
|
404
|
+
logger.debug(f"[{self.name}] No job_update_broadcaster available")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
job = Job.from_dict(json.loads(notification))
|
|
409
|
+
|
|
410
|
+
logger.debug(f"[{self.name}] Received job notification: {job}")
|
|
411
|
+
|
|
412
|
+
if job.status in [JobStatus.QUEUED, JobStatus.SCHEDULED]:
|
|
413
|
+
go_func(self._run_job_initial, use_mp=False)
|
|
414
|
+
await self.job_insert_broadcaster.broadcast(job)
|
|
415
|
+
else:
|
|
416
|
+
await self.job_update_broadcaster.broadcast(job)
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.error(f"Error handling job notification: {e}")
|
|
419
|
+
|
|
420
|
+
async def _handle_job_archive_notification(self, notification: str):
|
|
421
|
+
"""
|
|
422
|
+
Handle job archive database notifications.
|
|
423
|
+
|
|
424
|
+
:param notification: The notification payload as a string
|
|
425
|
+
"""
|
|
426
|
+
try:
|
|
427
|
+
job_data = json.loads(notification)
|
|
428
|
+
job = Job.from_dict(job_data)
|
|
429
|
+
|
|
430
|
+
if job.status == "CANCELLED":
|
|
431
|
+
if job.rid in self.active_runners:
|
|
432
|
+
logger.info(f"Canceling running job: {job.rid}")
|
|
433
|
+
runner: Runner = self.active_runners[job.rid]
|
|
434
|
+
runner.cancel()
|
|
435
|
+
del self.active_runners[job.rid]
|
|
436
|
+
else:
|
|
437
|
+
logger.info(f"Job {job.rid} completed with status: {job.status}")
|
|
438
|
+
if (
|
|
439
|
+
hasattr(self, "job_delete_broadcaster")
|
|
440
|
+
and self.job_delete_broadcaster
|
|
441
|
+
):
|
|
442
|
+
await self.job_delete_broadcaster.broadcast(job)
|
|
443
|
+
logger.info(f"Broadcasted job deletion completion for {job.rid}")
|
|
444
|
+
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.error(f"Error handling job archive notification: {e}")
|
|
447
|
+
traceback.print_exc()
|
|
448
|
+
|
|
449
|
+
def _start_listeners(self):
|
|
450
|
+
"""Start database listeners."""
|
|
451
|
+
# Start job listener
|
|
452
|
+
if self.job_db_listener:
|
|
453
|
+
try:
|
|
454
|
+
logger.debug("Starting job listeners")
|
|
455
|
+
job_runner = go_func(
|
|
456
|
+
self._start_job_listener,
|
|
457
|
+
False,
|
|
458
|
+
self._handle_job_notification,
|
|
459
|
+
)
|
|
460
|
+
if job_runner and isinstance(job_runner, SmallRunner):
|
|
461
|
+
self.job_db_listener_runner = job_runner
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"Error starting job listener: {e}")
|
|
464
|
+
|
|
465
|
+
# Start job archive listener
|
|
466
|
+
if self.job_archive_db_listener:
|
|
467
|
+
try:
|
|
468
|
+
logger.debug("Starting job archive listeners")
|
|
469
|
+
archive_runner = go_func(
|
|
470
|
+
self._start_job_archive_listener,
|
|
471
|
+
False,
|
|
472
|
+
self._handle_job_archive_notification,
|
|
473
|
+
)
|
|
474
|
+
if archive_runner and isinstance(archive_runner, SmallRunner):
|
|
475
|
+
self.job_archive_db_listener_runner = archive_runner
|
|
476
|
+
except Exception as e:
|
|
477
|
+
logger.error(f"Error starting job archive listener: {e}")
|
|
478
|
+
|
|
479
|
+
def _wait_for_listeners_ready(self, timeout_seconds: float = 3.0):
|
|
480
|
+
"""
|
|
481
|
+
Wait for both database listeners to be ready by checking their connection status.
|
|
482
|
+
|
|
483
|
+
:param timeout_seconds: Maximum time to wait for listeners to be ready
|
|
484
|
+
:raises RuntimeError: If listeners don't become ready within timeout
|
|
485
|
+
"""
|
|
486
|
+
logger.debug("Waiting for database listeners to be ready...")
|
|
487
|
+
start_time = time.time()
|
|
488
|
+
|
|
489
|
+
while time.time() - start_time < timeout_seconds:
|
|
490
|
+
job_ready = (
|
|
491
|
+
self.job_db_listener
|
|
492
|
+
and hasattr(self.job_db_listener, "connection")
|
|
493
|
+
and self.job_db_listener.connection is not None
|
|
494
|
+
and hasattr(self.job_db_listener, "listening")
|
|
495
|
+
and self.job_db_listener.listening
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
archive_ready = (
|
|
499
|
+
self.job_archive_db_listener
|
|
500
|
+
and hasattr(self.job_archive_db_listener, "connection")
|
|
501
|
+
and self.job_archive_db_listener.connection is not None
|
|
502
|
+
and hasattr(self.job_archive_db_listener, "listening")
|
|
503
|
+
and self.job_archive_db_listener.listening
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if job_ready and archive_ready:
|
|
507
|
+
logger.info("Database listeners are ready")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
time.sleep(0.1) # Small delay between checks
|
|
511
|
+
|
|
512
|
+
raise RuntimeError(
|
|
513
|
+
f"Database listeners failed to become ready within {timeout_seconds} seconds"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Tickers
|
|
517
|
+
def _heartbeat_func(self):
|
|
518
|
+
"""Send periodic heartbeats - only updates database, not queuer state."""
|
|
519
|
+
try:
|
|
520
|
+
# Get current worker from database
|
|
521
|
+
current_worker = self.db_worker.select_worker(self.worker.rid)
|
|
522
|
+
if current_worker:
|
|
523
|
+
# Update timestamp and save to database
|
|
524
|
+
current_worker.updated_at = datetime.now()
|
|
525
|
+
self.db_worker.update_worker(current_worker)
|
|
526
|
+
logger.debug(
|
|
527
|
+
f"Updated worker heartbeat timestamp: {current_worker.updated_at}"
|
|
528
|
+
)
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.error(f"Heartbeat error: {e}")
|
|
531
|
+
|
|
532
|
+
def _start_heartbeat_ticker(self):
|
|
533
|
+
"""Start heartbeat ticker using threading."""
|
|
534
|
+
self.heartbeat_ticker = Ticker(
|
|
535
|
+
timedelta(seconds=30),
|
|
536
|
+
self._heartbeat_func,
|
|
537
|
+
use_mp=False,
|
|
538
|
+
)
|
|
539
|
+
self.heartbeat_ticker.go()
|
|
540
|
+
|
|
541
|
+
def _poll_jobs_func(self):
|
|
542
|
+
"""Poll for jobs as backup to notification system."""
|
|
543
|
+
try:
|
|
544
|
+
logger.debug("Running backup job polling")
|
|
545
|
+
self._run_job_initial()
|
|
546
|
+
except Exception as e:
|
|
547
|
+
logger.warning(f"Backup job polling error: {e}")
|
|
548
|
+
|
|
549
|
+
def _start_poll_job_ticker(self):
|
|
550
|
+
"""
|
|
551
|
+
Start job polling ticker as backup mechanism.
|
|
552
|
+
This provides a safety net in case notification-based processing fails.
|
|
553
|
+
"""
|
|
554
|
+
self.poll_job_ticker = Ticker(
|
|
555
|
+
self.job_poll_interval,
|
|
556
|
+
self._poll_jobs_func,
|
|
557
|
+
use_mp=False,
|
|
558
|
+
)
|
|
559
|
+
self.poll_job_ticker.go()
|
|
560
|
+
|
|
561
|
+
# Database listeners
|
|
562
|
+
async def _start_job_listener(self, handle_job_notification: Callable[[str], Any]):
|
|
563
|
+
"""
|
|
564
|
+
Start job database listener.
|
|
565
|
+
|
|
566
|
+
:param handle_job_notification: The callback to handle job notifications
|
|
567
|
+
"""
|
|
568
|
+
if self.job_db_listener:
|
|
569
|
+
await self.job_db_listener.listen(handle_job_notification)
|
|
570
|
+
|
|
571
|
+
async def _start_job_archive_listener(
|
|
572
|
+
self, handle_job_archive_notification: Callable[[str], Any]
|
|
573
|
+
):
|
|
574
|
+
"""
|
|
575
|
+
Start job archive database listener.
|
|
576
|
+
|
|
577
|
+
:param handle_job_archive_notification: The callback to handle job archive notifications
|
|
578
|
+
"""
|
|
579
|
+
if self.job_archive_db_listener:
|
|
580
|
+
await self.job_archive_db_listener.listen(handle_job_archive_notification)
|
queuerPy/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueuerPy - A Python implementation of the queuer system
|
|
3
|
+
|
|
4
|
+
A job queuing and processing system with PostgreSQL backend that provides:
|
|
5
|
+
- Asynchronous job processing
|
|
6
|
+
- Retry mechanisms with configurable policies
|
|
7
|
+
- Job scheduling with interval functions
|
|
8
|
+
- Worker management and monitoring
|
|
9
|
+
- Database-backed persistence
|
|
10
|
+
- Event-driven notifications
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
# Add the package directory to Python path to enable direct imports
|
|
17
|
+
_package_dir = os.path.dirname(__file__)
|
|
18
|
+
if _package_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _package_dir)
|
|
20
|
+
|
|
21
|
+
from _version import __version__
|
|
22
|
+
|
|
23
|
+
__author__ = "Simon Herrmann"
|
|
24
|
+
__email__ = "siherrmann@users.noreply.github.com"
|
|
25
|
+
|
|
26
|
+
# Core exports
|
|
27
|
+
from queuer import (
|
|
28
|
+
Queuer,
|
|
29
|
+
new_queuer,
|
|
30
|
+
new_queuer_with_db,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from helper.database import (
|
|
34
|
+
DatabaseConfiguration,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from model.job import (
|
|
38
|
+
Job,
|
|
39
|
+
JobStatus,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from model.worker import (
|
|
43
|
+
Worker,
|
|
44
|
+
WorkerStatus,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from model.options_on_error import (
|
|
48
|
+
OnError,
|
|
49
|
+
RetryBackoff,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
from helper.error import (
|
|
53
|
+
QueuerError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Convenience imports for common use cases
|
|
57
|
+
__all__ = [
|
|
58
|
+
# Core classes
|
|
59
|
+
"Queuer",
|
|
60
|
+
"new_queuer",
|
|
61
|
+
"new_queuer_with_db",
|
|
62
|
+
# Configuration
|
|
63
|
+
"DatabaseConfiguration",
|
|
64
|
+
# Models
|
|
65
|
+
"Job",
|
|
66
|
+
"JobStatus",
|
|
67
|
+
"Worker",
|
|
68
|
+
"WorkerStatus",
|
|
69
|
+
"OnError",
|
|
70
|
+
"RetryBackoff",
|
|
71
|
+
# Exceptions
|
|
72
|
+
"QueuerError",
|
|
73
|
+
# Version info
|
|
74
|
+
"__version__",
|
|
75
|
+
"__author__",
|
|
76
|
+
"__email__",
|
|
77
|
+
]
|