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.
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}")
queuer_listener.py ADDED
@@ -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)}")