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
queuerPy/queuer_job.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job-related methods for the Python queuer implementation.
|
|
3
|
+
Mirrors Go's queuerJob.go functionality.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Dict, List, Optional, Any, Tuple, Union, Callable, TYPE_CHECKING
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from core.broadcaster import BroadcasterQueue
|
|
13
|
+
from core.runner import Runner, SmallRunner
|
|
14
|
+
from core.retryer import Retryer
|
|
15
|
+
from core.scheduler import Scheduler
|
|
16
|
+
from helper.error import QueuerError
|
|
17
|
+
from helper.task import get_task_name_from_interface
|
|
18
|
+
from model.job import Job, JobStatus, new_job as create_job
|
|
19
|
+
from model.options import Options
|
|
20
|
+
from model.worker import Worker
|
|
21
|
+
from queuer_global import QueuerGlobalMixin
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from model.batch_job import BatchJob
|
|
25
|
+
|
|
26
|
+
# Set up logger
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QueuerJobMixin(QueuerGlobalMixin):
|
|
31
|
+
"""
|
|
32
|
+
Mixin class containing job-related methods for the Queuer.
|
|
33
|
+
This mirrors the job methods from Go's queuerJob.go.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
super().__init__()
|
|
38
|
+
|
|
39
|
+
def add_job(self, task: Union[Callable[..., Any], str], *parameters: Any) -> Job:
|
|
40
|
+
"""
|
|
41
|
+
Add a job to the queue with the given task and parameters.
|
|
42
|
+
|
|
43
|
+
:param task: Either a function or a string with the task name
|
|
44
|
+
:param parameters: Parameters to pass to the task
|
|
45
|
+
:returns: The created job
|
|
46
|
+
:raises Exception: If something goes wrong
|
|
47
|
+
"""
|
|
48
|
+
options: Optional[Options] = self._merge_options(None)
|
|
49
|
+
job: Job = self._add_job(task, options, *parameters)
|
|
50
|
+
|
|
51
|
+
logger.info(f"Job added: {job.rid}")
|
|
52
|
+
|
|
53
|
+
return job
|
|
54
|
+
|
|
55
|
+
def add_job_with_options(
|
|
56
|
+
self,
|
|
57
|
+
options: Optional[Options],
|
|
58
|
+
task: Union[Callable[..., Any], str],
|
|
59
|
+
*parameters: Any,
|
|
60
|
+
) -> Job:
|
|
61
|
+
"""Add a new job with specific options."""
|
|
62
|
+
# Merge default options with provided options
|
|
63
|
+
options_merged: Optional[Options] = self._merge_options(options)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
# Create new job
|
|
67
|
+
new_job: Job = create_job(task, options_merged, *parameters)
|
|
68
|
+
job: Job = self.db_job.insert_job(new_job)
|
|
69
|
+
|
|
70
|
+
logger.info(f"Job with options added: {job.rid}")
|
|
71
|
+
return job
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Error adding job with options: {str(e)}")
|
|
74
|
+
raise Exception(f"Adding job: {str(e)}")
|
|
75
|
+
|
|
76
|
+
def add_jobs(self, batch_jobs: List["BatchJob"]):
|
|
77
|
+
"""
|
|
78
|
+
Add a batch of jobs to the queue.
|
|
79
|
+
|
|
80
|
+
:param batch_jobs: List of BatchJob objects to add
|
|
81
|
+
:raises Exception: If something goes wrong during the process
|
|
82
|
+
"""
|
|
83
|
+
jobs: List[Job] = []
|
|
84
|
+
for batch_job in batch_jobs:
|
|
85
|
+
options: Optional[Options] = self._merge_options(batch_job.options)
|
|
86
|
+
task_name: str = get_task_name_from_interface(batch_job.task)
|
|
87
|
+
job: Job = create_job(task_name, options, *batch_job.parameters)
|
|
88
|
+
jobs.append(job)
|
|
89
|
+
|
|
90
|
+
self.db_job.batch_insert_jobs(jobs)
|
|
91
|
+
logger.info(f"Jobs added: {len(jobs)}")
|
|
92
|
+
|
|
93
|
+
def wait_for_job_added(self, timeout_seconds: float = 30.0) -> Optional[Job]:
|
|
94
|
+
"""
|
|
95
|
+
Wait for any job to start and return the job.
|
|
96
|
+
Uses the shared Runner event loop.
|
|
97
|
+
|
|
98
|
+
:param timeout_seconds: Maximum time to wait for a job to be added
|
|
99
|
+
:returns: The job that was added, or None if cancelled or timeout
|
|
100
|
+
"""
|
|
101
|
+
runner = Runner(
|
|
102
|
+
task=self._wait_for_job_added_inner,
|
|
103
|
+
args=(timeout_seconds,),
|
|
104
|
+
kwargs={},
|
|
105
|
+
)
|
|
106
|
+
runner.go()
|
|
107
|
+
return runner.get_results(timeout=timeout_seconds)
|
|
108
|
+
|
|
109
|
+
async def _wait_for_job_added_inner(
|
|
110
|
+
self, timeout_seconds: float = 30.0
|
|
111
|
+
) -> Optional[Job]:
|
|
112
|
+
"""
|
|
113
|
+
Wait for any job to start and return the job.
|
|
114
|
+
Uses the broadcaster approach.
|
|
115
|
+
|
|
116
|
+
:param timeout_seconds: Maximum time to wait for a job to be added
|
|
117
|
+
:returns: The job that was added, or None if cancelled or timeout
|
|
118
|
+
:raises Exception: If broadcaster is not available
|
|
119
|
+
"""
|
|
120
|
+
channel: BroadcasterQueue[Job] = await self.job_insert_broadcaster.subscribe()
|
|
121
|
+
try:
|
|
122
|
+
job = await asyncio.wait_for(channel.get(), timeout=timeout_seconds)
|
|
123
|
+
return job
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
logger.debug(f"Timed out waiting for job added ({timeout_seconds}s)")
|
|
126
|
+
return None
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Error waiting for job added: {e}")
|
|
129
|
+
return None
|
|
130
|
+
finally:
|
|
131
|
+
# Unsubscribe from broadcaster
|
|
132
|
+
await self.job_insert_broadcaster.unsubscribe(channel)
|
|
133
|
+
|
|
134
|
+
def wait_for_job_finished(
|
|
135
|
+
self, job_rid: UUID, timeout_seconds: float = 30.0
|
|
136
|
+
) -> Optional[Job]:
|
|
137
|
+
"""
|
|
138
|
+
Wait for a job to finish and return the job.
|
|
139
|
+
Uses SmallRunner for shared state access.
|
|
140
|
+
|
|
141
|
+
:param job_rid: The RID of the job to wait for
|
|
142
|
+
:param timeout_seconds: Maximum time to wait for job completion
|
|
143
|
+
:returns: The completed job, or None if not found or timeout
|
|
144
|
+
:raises Exception: If broadcaster is not available
|
|
145
|
+
"""
|
|
146
|
+
# Add buffer to runner timeout to allow async method final check to complete
|
|
147
|
+
runner_timeout = timeout_seconds + 1.0
|
|
148
|
+
runner = SmallRunner(self._wait_with_listener, job_rid, timeout_seconds)
|
|
149
|
+
runner.go()
|
|
150
|
+
return runner.get_results(timeout=runner_timeout)
|
|
151
|
+
|
|
152
|
+
async def _wait_with_listener(
|
|
153
|
+
self, job_rid: UUID, timeout_seconds: float
|
|
154
|
+
) -> Optional[Job]:
|
|
155
|
+
"""
|
|
156
|
+
Wait for job completion using pure database notifications.
|
|
157
|
+
|
|
158
|
+
:param job_rid: The RID of the job to wait for
|
|
159
|
+
:param timeout_seconds: Maximum time to wait for job completion
|
|
160
|
+
:returns: The completed job, or None if not found or timeout
|
|
161
|
+
:raises Exception: If broadcaster is not available
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
job_ended: Job = self.get_job_ended(job_rid)
|
|
165
|
+
if job_ended:
|
|
166
|
+
return job_ended
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
channel: BroadcasterQueue[Job] = await self.job_delete_broadcaster.subscribe()
|
|
171
|
+
try:
|
|
172
|
+
while True:
|
|
173
|
+
job = await asyncio.wait_for(channel.get(), timeout=timeout_seconds)
|
|
174
|
+
if job.rid == job_rid:
|
|
175
|
+
return job
|
|
176
|
+
except asyncio.TimeoutError:
|
|
177
|
+
logger.debug(f"Timed out waiting for job ended ({timeout_seconds}s)")
|
|
178
|
+
try:
|
|
179
|
+
job_ended: Job = self.get_job_ended(job_rid)
|
|
180
|
+
if job_ended:
|
|
181
|
+
return job_ended
|
|
182
|
+
except Exception as e:
|
|
183
|
+
raise QueuerError("get job ended", e)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
raise QueuerError("waiting for job", e)
|
|
186
|
+
finally:
|
|
187
|
+
await self.job_insert_broadcaster.unsubscribe(channel)
|
|
188
|
+
|
|
189
|
+
def cancel_job(self, job_rid: UUID) -> Job:
|
|
190
|
+
"""
|
|
191
|
+
Cancel a job with the given job RID.
|
|
192
|
+
|
|
193
|
+
:param job_rid: The RID of the job to cancel
|
|
194
|
+
:returns: The cancelled job
|
|
195
|
+
:raises Exception: If the job is not found or already cancelled
|
|
196
|
+
"""
|
|
197
|
+
job = self.db_job.select_job(job_rid)
|
|
198
|
+
if not job:
|
|
199
|
+
raise Exception(f"Job not found: {job_rid}")
|
|
200
|
+
|
|
201
|
+
self._cancel_job(job)
|
|
202
|
+
return job
|
|
203
|
+
|
|
204
|
+
def cancel_all_jobs_by_worker(self, worker_rid: UUID, entries: int = 100):
|
|
205
|
+
"""
|
|
206
|
+
Cancel all jobs assigned to a specific worker by its RID.
|
|
207
|
+
|
|
208
|
+
:param worker_rid: The RID of the worker
|
|
209
|
+
:param entries: Maximum number of jobs to cancel
|
|
210
|
+
:raises Exception: If something goes wrong during the process
|
|
211
|
+
"""
|
|
212
|
+
jobs = self.db_job.select_all_jobs_by_worker_rid(worker_rid, 0, entries)
|
|
213
|
+
for job in jobs:
|
|
214
|
+
self._cancel_job(job)
|
|
215
|
+
|
|
216
|
+
def readd_job_from_archive(self, job_rid: UUID) -> Job:
|
|
217
|
+
"""
|
|
218
|
+
Readd a job from the archive back to the queue.
|
|
219
|
+
|
|
220
|
+
:param job_rid: The RID of the job to readd
|
|
221
|
+
:returns: The new job created from the archived job
|
|
222
|
+
:raises Exception: If the job is not found in archive
|
|
223
|
+
"""
|
|
224
|
+
job = self.db_job.select_job_from_archive(job_rid)
|
|
225
|
+
if not job:
|
|
226
|
+
raise Exception(f"Job not found in archive: {job_rid}")
|
|
227
|
+
|
|
228
|
+
# Readd the job to the queue
|
|
229
|
+
new_job = self.add_job_with_options(job.options, job.task_name, *job.parameters)
|
|
230
|
+
logger.info(f"Job readded: {new_job.rid}")
|
|
231
|
+
return new_job
|
|
232
|
+
|
|
233
|
+
def get_job(self, job_rid: UUID) -> Job:
|
|
234
|
+
"""
|
|
235
|
+
Retrieve a job by its RID.
|
|
236
|
+
|
|
237
|
+
:param job_rid: The RID of the job
|
|
238
|
+
:returns: The job
|
|
239
|
+
:raises Exception: If the job is not found
|
|
240
|
+
"""
|
|
241
|
+
job = self.db_job.select_job(job_rid)
|
|
242
|
+
if not job:
|
|
243
|
+
raise Exception(f"Job not found: {job_rid}")
|
|
244
|
+
return job
|
|
245
|
+
|
|
246
|
+
def get_jobs(self, last_id: int = 0, entries: int = 100) -> List[Job]:
|
|
247
|
+
"""
|
|
248
|
+
Retrieve all jobs in the queue.
|
|
249
|
+
|
|
250
|
+
:param last_id: Last job ID for pagination
|
|
251
|
+
:param entries: Number of entries to retrieve
|
|
252
|
+
:returns: List of jobs
|
|
253
|
+
"""
|
|
254
|
+
return self.db_job.select_all_jobs(last_id, entries)
|
|
255
|
+
|
|
256
|
+
def get_job_ended(self, job_rid: UUID) -> Job:
|
|
257
|
+
"""
|
|
258
|
+
Retrieve a job that has ended (succeeded, cancelled or failed) by its RID.
|
|
259
|
+
|
|
260
|
+
:param job_rid: The RID of the job
|
|
261
|
+
:returns: The ended job
|
|
262
|
+
:raises Exception: If the job is not found
|
|
263
|
+
"""
|
|
264
|
+
job = self.db_job.select_job_from_archive(job_rid)
|
|
265
|
+
if not job:
|
|
266
|
+
raise Exception(f"Ended job not found: {job_rid}")
|
|
267
|
+
return job
|
|
268
|
+
|
|
269
|
+
def get_jobs_ended(self, last_id: int = 0, entries: int = 100) -> List[Job]:
|
|
270
|
+
"""
|
|
271
|
+
Retrieve all jobs that have ended (succeeded, cancelled or failed).
|
|
272
|
+
|
|
273
|
+
:param last_id: Last job ID for pagination
|
|
274
|
+
:param entries: Number of entries to retrieve
|
|
275
|
+
:returns: List of ended jobs
|
|
276
|
+
"""
|
|
277
|
+
return self.db_job.select_all_jobs_from_archive(last_id, entries)
|
|
278
|
+
|
|
279
|
+
def get_jobs_by_worker_rid(
|
|
280
|
+
self, worker_rid: UUID, last_id: int = 0, entries: int = 100
|
|
281
|
+
) -> List[Job]:
|
|
282
|
+
"""
|
|
283
|
+
Retrieve jobs assigned to a specific worker by its RID.
|
|
284
|
+
|
|
285
|
+
:param worker_rid: The RID of the worker
|
|
286
|
+
:param last_id: Last job ID for pagination
|
|
287
|
+
:param entries: Number of entries to retrieve
|
|
288
|
+
:returns: List of jobs assigned to the worker
|
|
289
|
+
"""
|
|
290
|
+
return self.db_job.select_all_jobs_by_worker_rid(worker_rid, last_id, entries)
|
|
291
|
+
|
|
292
|
+
# Internal/Private methods
|
|
293
|
+
|
|
294
|
+
def _merge_options(self, options: Optional[Options]) -> Optional[Options]:
|
|
295
|
+
"""
|
|
296
|
+
Merge the worker options with optional job options.
|
|
297
|
+
|
|
298
|
+
:param options: Job-specific options (can be None)
|
|
299
|
+
:returns: Merged options
|
|
300
|
+
"""
|
|
301
|
+
worker_options = self.worker.options if self.worker else None
|
|
302
|
+
if options is not None and options.on_error is None:
|
|
303
|
+
options.on_error = worker_options
|
|
304
|
+
elif options is None and worker_options is not None:
|
|
305
|
+
options = Options(on_error=worker_options)
|
|
306
|
+
|
|
307
|
+
return options
|
|
308
|
+
|
|
309
|
+
def _add_job(
|
|
310
|
+
self,
|
|
311
|
+
task: Union[Callable[..., Any], str],
|
|
312
|
+
options: Optional["Options"],
|
|
313
|
+
*parameters: Any,
|
|
314
|
+
) -> Job:
|
|
315
|
+
"""
|
|
316
|
+
Add a job to the queue with all necessary parameters.
|
|
317
|
+
|
|
318
|
+
:param task: Either a function or a string with the task name
|
|
319
|
+
:param options: Job-specific options (can be None)
|
|
320
|
+
:param parameters: Parameters to pass to the task
|
|
321
|
+
:returns: The created job
|
|
322
|
+
:raises Exception: If something goes wrong
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
new_job = create_job(task, options, *parameters)
|
|
326
|
+
job = self.db_job.insert_job(new_job)
|
|
327
|
+
|
|
328
|
+
return job
|
|
329
|
+
|
|
330
|
+
except Exception as e:
|
|
331
|
+
raise Exception(f"Creating or inserting job: {str(e)}")
|
|
332
|
+
|
|
333
|
+
def _run_job_initial(self):
|
|
334
|
+
"""
|
|
335
|
+
Called to run the next job in the queue.
|
|
336
|
+
Updates job status to running with worker.
|
|
337
|
+
"""
|
|
338
|
+
if not hasattr(self, "worker") or not getattr(self, "worker"):
|
|
339
|
+
logger.debug("No worker available, skipping job run")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# Check if database connection is available
|
|
343
|
+
if not self.db_job or not self.db_job.db or not self.db_job.db.instance:
|
|
344
|
+
logger.debug("Database connection unavailable, skipping job run")
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
worker: Worker = getattr(self, "worker")
|
|
348
|
+
|
|
349
|
+
logger.debug(f"Worker available_tasks: {worker.available_tasks}")
|
|
350
|
+
logger.debug(f"Worker max_concurrency: {worker.max_concurrency}")
|
|
351
|
+
|
|
352
|
+
jobs = self.db_job.update_jobs_initial(worker)
|
|
353
|
+
if not jobs:
|
|
354
|
+
logger.debug("No jobs found to run")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
logger.info(f"Found {len(jobs)} jobs to run")
|
|
358
|
+
|
|
359
|
+
for job in jobs:
|
|
360
|
+
logger.info(f"Processing job: {job.rid}")
|
|
361
|
+
if (
|
|
362
|
+
job.options
|
|
363
|
+
and job.options.schedule
|
|
364
|
+
and job.options.schedule.start
|
|
365
|
+
and job.options.schedule.start > datetime.now()
|
|
366
|
+
):
|
|
367
|
+
try:
|
|
368
|
+
scheduler = Scheduler(
|
|
369
|
+
self._run_job_sync,
|
|
370
|
+
job.options.schedule.start,
|
|
371
|
+
job,
|
|
372
|
+
)
|
|
373
|
+
scheduler.go()
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Failed to schedule job with Scheduler: {e}")
|
|
376
|
+
|
|
377
|
+
logger.info(f"Scheduled job {job.rid} for {job.options.schedule.start}")
|
|
378
|
+
else:
|
|
379
|
+
logger.info(f"Running job immediately: {job.rid}")
|
|
380
|
+
runner = SmallRunner(self._run_job, job)
|
|
381
|
+
runner.go()
|
|
382
|
+
|
|
383
|
+
async def _wait_for_job(
|
|
384
|
+
self, job: Job
|
|
385
|
+
) -> tuple[List[Any], bool, Optional[Exception]]:
|
|
386
|
+
"""
|
|
387
|
+
Execute the job and return the results or an error.
|
|
388
|
+
|
|
389
|
+
:param job: The job to execute
|
|
390
|
+
:returns: A tuple containing the results, a cancellation flag, and an error if any
|
|
391
|
+
"""
|
|
392
|
+
task_obj = self.tasks.get(job.task_name)
|
|
393
|
+
if not task_obj:
|
|
394
|
+
return [], False, Exception(f"Task not found: {job.task_name}")
|
|
395
|
+
|
|
396
|
+
# Extract the actual callable function from the Task object
|
|
397
|
+
task = task_obj.task if hasattr(task_obj, "task") else task_obj
|
|
398
|
+
if not callable(task):
|
|
399
|
+
return [], False, Exception(f"Task is not callable: {job.task_name}")
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
parameters = getattr(job, "parameters", [])
|
|
403
|
+
runner = Runner(task, *parameters)
|
|
404
|
+
|
|
405
|
+
logger.info(f"Created runner for job {job.rid}")
|
|
406
|
+
|
|
407
|
+
if not hasattr(self, "active_runners"):
|
|
408
|
+
self.active_runners: Dict[UUID, Runner] = {}
|
|
409
|
+
self.active_runners[job.rid] = runner
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
runner.go()
|
|
413
|
+
results = runner.get_results(timeout=300)
|
|
414
|
+
logger.debug(f"Runner task {job.task_name} completed: {results}")
|
|
415
|
+
return results, False, None
|
|
416
|
+
|
|
417
|
+
except asyncio.CancelledError:
|
|
418
|
+
if runner:
|
|
419
|
+
runner.cancel()
|
|
420
|
+
return [], True, None
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.error(f"Process runner task {job.task_name} failed: {e}")
|
|
423
|
+
return [], False, e
|
|
424
|
+
finally:
|
|
425
|
+
self.active_runners.pop(job.rid, None)
|
|
426
|
+
|
|
427
|
+
except Exception as e:
|
|
428
|
+
logger.error(f"Error setting up runner for task {job.task_name}: {e}")
|
|
429
|
+
return [], False, e
|
|
430
|
+
|
|
431
|
+
async def _retry_job(self, job: Job, job_error: Exception):
|
|
432
|
+
"""
|
|
433
|
+
Retry the job with the given job error.
|
|
434
|
+
|
|
435
|
+
:param job: The job to retry
|
|
436
|
+
:param job_error: The error that occurred during the job execution
|
|
437
|
+
"""
|
|
438
|
+
if (
|
|
439
|
+
not job.options
|
|
440
|
+
or not job.options.on_error
|
|
441
|
+
or job.options.on_error.max_retries <= 0
|
|
442
|
+
):
|
|
443
|
+
await self._fail_job(job, job_error)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
async def retry_function() -> Tuple[List[Job], bool]:
|
|
447
|
+
logger.debug(f"Trying/retrying job: {job.rid}")
|
|
448
|
+
results, cancelled, e = await self._wait_for_job(job)
|
|
449
|
+
if e:
|
|
450
|
+
raise QueuerError("retrying job", e)
|
|
451
|
+
return results, cancelled
|
|
452
|
+
|
|
453
|
+
# Create retryer and attempt retry
|
|
454
|
+
retryer = Retryer(retry_function, job.options.on_error)
|
|
455
|
+
error = await retryer.retry()
|
|
456
|
+
if error:
|
|
457
|
+
await self._fail_job(
|
|
458
|
+
job, QueuerError(f"Retrying job failed: {error}", job_error)
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
results, _, _ = await self._wait_for_job(job)
|
|
462
|
+
await self._succeed_job(job, results)
|
|
463
|
+
|
|
464
|
+
async def _run_job(self, job: Job):
|
|
465
|
+
"""
|
|
466
|
+
Run the job.
|
|
467
|
+
|
|
468
|
+
:param job: The job to run
|
|
469
|
+
"""
|
|
470
|
+
logger.info(f"Running job: {job.rid}")
|
|
471
|
+
|
|
472
|
+
results, cancelled, error = await self._wait_for_job(job)
|
|
473
|
+
if cancelled:
|
|
474
|
+
return
|
|
475
|
+
elif error:
|
|
476
|
+
await self._retry_job(job, error)
|
|
477
|
+
else:
|
|
478
|
+
await self._succeed_job(job, results)
|
|
479
|
+
|
|
480
|
+
def _run_job_sync(self, job: Job):
|
|
481
|
+
"""
|
|
482
|
+
Synchronous wrapper for _run_job that can be called by the Scheduler.
|
|
483
|
+
Mirrors the Go pattern where scheduler calls the function directly.
|
|
484
|
+
|
|
485
|
+
:param job: The job to run
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
# Create a Runner process for the async job
|
|
489
|
+
runner = Runner(self._run_job, job)
|
|
490
|
+
runner.go()
|
|
491
|
+
logger.info(f"Scheduled job {job.rid} started in process {runner.pid}")
|
|
492
|
+
|
|
493
|
+
except Exception as e:
|
|
494
|
+
logger.error(f"Error executing scheduled job {job.rid}: {e}")
|
|
495
|
+
|
|
496
|
+
def _cancel_job(self, job: Job):
|
|
497
|
+
"""
|
|
498
|
+
Cancel a job.
|
|
499
|
+
|
|
500
|
+
:param job: The job to cancel
|
|
501
|
+
"""
|
|
502
|
+
if job.status in [JobStatus.RUNNING, JobStatus.SCHEDULED, JobStatus.QUEUED]:
|
|
503
|
+
# Cancel the running process if it exists
|
|
504
|
+
if hasattr(self, "active_runners") and job.rid in self.active_runners:
|
|
505
|
+
runner = self.active_runners[job.rid]
|
|
506
|
+
try:
|
|
507
|
+
runner.cancel()
|
|
508
|
+
logger.info(f"Cancelled running process for job: {job.rid}")
|
|
509
|
+
except Exception as e:
|
|
510
|
+
logger.warning(
|
|
511
|
+
f"Failed to cancel running process for job: {job.rid}: {e}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
job.status = JobStatus.CANCELLED
|
|
515
|
+
try:
|
|
516
|
+
self.db_job.update_job_final(job)
|
|
517
|
+
logger.info(f"Job cancelled: {job.rid}")
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.error(f"Error updating job status to cancelled: {e}")
|
|
520
|
+
|
|
521
|
+
async def _succeed_job(self, job: Job, results: List[Any]):
|
|
522
|
+
"""
|
|
523
|
+
Update the job status to succeeded and run the next job if available.
|
|
524
|
+
|
|
525
|
+
:param job: The job that succeeded
|
|
526
|
+
:param results: The results from the job
|
|
527
|
+
"""
|
|
528
|
+
job.status = JobStatus.SUCCEEDED
|
|
529
|
+
job.results = results
|
|
530
|
+
await self._end_job(job)
|
|
531
|
+
|
|
532
|
+
async def _fail_job(self, job: Job, job_error: Exception):
|
|
533
|
+
"""
|
|
534
|
+
Update the job status to failed.
|
|
535
|
+
|
|
536
|
+
:param job: The job that failed
|
|
537
|
+
:param job_error: The error that occurred
|
|
538
|
+
"""
|
|
539
|
+
job.status = JobStatus.FAILED
|
|
540
|
+
job.error = str(job_error)
|
|
541
|
+
await self._end_job(job)
|
|
542
|
+
|
|
543
|
+
async def _end_job(self, job: Job):
|
|
544
|
+
"""
|
|
545
|
+
End a job and potentially schedule it again if it's a recurring job.
|
|
546
|
+
|
|
547
|
+
:param job: The job to end
|
|
548
|
+
"""
|
|
549
|
+
if not self.db_job or not self.db_job.db or not self.db_job.db.instance:
|
|
550
|
+
logger.error(f"Database connection unavailable for job {job.rid}")
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
if job.worker_id != self.worker.id:
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
ended_job = self.db_job.update_job_final(job)
|
|
558
|
+
logger.debug(f"Job ended: {ended_job.status} - {ended_job.rid}")
|
|
559
|
+
|
|
560
|
+
if (
|
|
561
|
+
ended_job.options
|
|
562
|
+
and ended_job.scheduled_at
|
|
563
|
+
and ended_job.options.schedule
|
|
564
|
+
and ended_job.options.schedule.interval
|
|
565
|
+
and ended_job.schedule_count < ended_job.options.schedule.max_count
|
|
566
|
+
):
|
|
567
|
+
if ended_job.options.schedule.next_interval:
|
|
568
|
+
# Use next interval function if available
|
|
569
|
+
next_interval_func = self.next_interval_funcs.get(
|
|
570
|
+
ended_job.options.schedule.next_interval
|
|
571
|
+
)
|
|
572
|
+
if not next_interval_func:
|
|
573
|
+
logger.error(
|
|
574
|
+
f"NextIntervalFunc not found: {ended_job.options.schedule.next_interval} for job {ended_job.rid}"
|
|
575
|
+
)
|
|
576
|
+
return
|
|
577
|
+
new_scheduled_at = next_interval_func(
|
|
578
|
+
ended_job.scheduled_at, ended_job.schedule_count
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
# Use regular interval
|
|
582
|
+
new_scheduled_at = ended_job.scheduled_at + (
|
|
583
|
+
ended_job.schedule_count * ended_job.options.schedule.interval
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
ended_job.scheduled_at = new_scheduled_at
|
|
587
|
+
ended_job.status = JobStatus.SCHEDULED
|
|
588
|
+
|
|
589
|
+
new_job = self.db_job.insert_job(ended_job)
|
|
590
|
+
logger.info(f"Job added for next iteration to the queue: {new_job.rid}")
|
|
591
|
+
|
|
592
|
+
except Exception as e:
|
|
593
|
+
logger.error(f"Error updating finished job: {e}")
|
|
594
|
+
|
|
595
|
+
# Try to run the next job in the queue, so if one job is finished another can directly start
|
|
596
|
+
try:
|
|
597
|
+
if self.db_job and self.db_job.db and self.db_job.db.instance:
|
|
598
|
+
self._run_job_initial()
|
|
599
|
+
except Exception as e:
|
|
600
|
+
logger.error(f"Error running next job: {e}")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Listener-related methods for the Python queuer implementation.
|
|
3
|
+
Mirrors Go's queuerListener.go functionality.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from queuer_global import QueuerGlobalMixin
|
|
10
|
+
from model.job import Job
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueuerListenerMixin(QueuerGlobalMixin):
|
|
16
|
+
"""
|
|
17
|
+
Mixin class containing listener-related methods for the Queuer.
|
|
18
|
+
Mirrors Go's queuerListener.go functionality.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
super().__init__()
|
|
23
|
+
|
|
24
|
+
def listen_for_job_update(self, notify_function: Callable[[Job], None]) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Listen for job update events and notify the provided function when a job is updated.
|
|
27
|
+
|
|
28
|
+
:param notify_function: Function to call when a job update event occurs
|
|
29
|
+
:raises: RuntimeError: If queuer is not running
|
|
30
|
+
"""
|
|
31
|
+
if not self.running:
|
|
32
|
+
raise RuntimeError("Cannot listen with not running Queuer")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Start listening in the background
|
|
36
|
+
self.job_update_listener.listen(notify_function=notify_function)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Error listening for job updates: {str(e)}")
|
|
39
|
+
raise RuntimeError(f"Failed to listen for job updates: {str(e)}")
|
|
40
|
+
|
|
41
|
+
def listen_for_job_delete(self, notify_function: Callable[[Job], None]) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Listen for job delete events and notify the provided function when a job is deleted.
|
|
44
|
+
|
|
45
|
+
:param notify_function: Function to call when a job delete event occurs
|
|
46
|
+
:raises: RuntimeError: If queuer is not running
|
|
47
|
+
"""
|
|
48
|
+
if not self.running:
|
|
49
|
+
raise RuntimeError("Cannot listen with not running Queuer")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Start listening in the background
|
|
53
|
+
self.job_delete_listener.listen(notify_function=notify_function)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f"Error listening for job deletes: {str(e)}")
|
|
56
|
+
raise RuntimeError(f"Failed to listen for job deletes: {str(e)}")
|
|
57
|
+
|
|
58
|
+
def listen_for_job_insert(self, notify_function: Callable[[Job], None]) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Listen for job insert events and notify the provided function when a job is inserted.
|
|
61
|
+
|
|
62
|
+
:param notify_function: Function to call when a job insert event occurs
|
|
63
|
+
:raises: RuntimeError: If queuer is not running
|
|
64
|
+
"""
|
|
65
|
+
if not self.running:
|
|
66
|
+
raise RuntimeError("Cannot listen with not running Queuer")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Start listening in the background
|
|
70
|
+
self.job_insert_listener.listen(notify_function=notify_function)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"Error listening for job inserts: {str(e)}")
|
|
73
|
+
raise RuntimeError(f"Failed to listen for job inserts: {str(e)}")
|