FlowerPower 0.20.0__py3-none-any.whl → 0.30.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.
Files changed (51) hide show
  1. flowerpower/__init__.py +2 -6
  2. flowerpower/cfg/__init__.py +4 -11
  3. flowerpower/cfg/base.py +29 -25
  4. flowerpower/cfg/pipeline/__init__.py +3 -3
  5. flowerpower/cfg/pipeline/_schedule.py +32 -0
  6. flowerpower/cfg/pipeline/adapter.py +0 -5
  7. flowerpower/cfg/pipeline/builder.py +377 -0
  8. flowerpower/cfg/pipeline/run.py +89 -0
  9. flowerpower/cfg/project/__init__.py +8 -21
  10. flowerpower/cfg/project/adapter.py +0 -12
  11. flowerpower/cli/__init__.py +2 -28
  12. flowerpower/cli/pipeline.py +10 -4
  13. flowerpower/flowerpower.py +275 -585
  14. flowerpower/pipeline/base.py +19 -10
  15. flowerpower/pipeline/io.py +52 -46
  16. flowerpower/pipeline/manager.py +149 -91
  17. flowerpower/pipeline/pipeline.py +159 -87
  18. flowerpower/pipeline/registry.py +68 -33
  19. flowerpower/pipeline/visualizer.py +4 -4
  20. flowerpower/plugins/{_io → io}/__init__.py +1 -1
  21. flowerpower/settings/__init__.py +0 -2
  22. flowerpower/settings/{backend.py → _backend.py} +0 -19
  23. flowerpower/settings/logging.py +1 -1
  24. flowerpower/utils/logging.py +24 -12
  25. flowerpower/utils/misc.py +17 -0
  26. flowerpower-0.30.0.dist-info/METADATA +451 -0
  27. flowerpower-0.30.0.dist-info/RECORD +42 -0
  28. flowerpower/cfg/pipeline/schedule.py +0 -74
  29. flowerpower/cfg/project/job_queue.py +0 -111
  30. flowerpower/cli/job_queue.py +0 -1329
  31. flowerpower/cli/mqtt.py +0 -174
  32. flowerpower/job_queue/__init__.py +0 -205
  33. flowerpower/job_queue/base.py +0 -611
  34. flowerpower/job_queue/rq/__init__.py +0 -10
  35. flowerpower/job_queue/rq/_trigger.py +0 -37
  36. flowerpower/job_queue/rq/concurrent_workers/gevent_worker.py +0 -226
  37. flowerpower/job_queue/rq/concurrent_workers/thread_worker.py +0 -228
  38. flowerpower/job_queue/rq/manager.py +0 -1893
  39. flowerpower/job_queue/rq/setup.py +0 -154
  40. flowerpower/job_queue/rq/utils.py +0 -69
  41. flowerpower/mqtt.py +0 -12
  42. flowerpower/plugins/mqtt/__init__.py +0 -12
  43. flowerpower/plugins/mqtt/cfg.py +0 -17
  44. flowerpower/plugins/mqtt/manager.py +0 -962
  45. flowerpower/settings/job_queue.py +0 -31
  46. flowerpower-0.20.0.dist-info/METADATA +0 -693
  47. flowerpower-0.20.0.dist-info/RECORD +0 -58
  48. {flowerpower-0.20.0.dist-info → flowerpower-0.30.0.dist-info}/WHEEL +0 -0
  49. {flowerpower-0.20.0.dist-info → flowerpower-0.30.0.dist-info}/entry_points.txt +0 -0
  50. {flowerpower-0.20.0.dist-info → flowerpower-0.30.0.dist-info}/licenses/LICENSE +0 -0
  51. {flowerpower-0.20.0.dist-info → flowerpower-0.30.0.dist-info}/top_level.txt +0 -0
@@ -1,1893 +0,0 @@
1
- """
2
- RQSchedulerBackend implementation for FlowerPower using RQ and rq-scheduler.
3
-
4
- This module implements the scheduler backend using RQ (Redis Queue) and rq-scheduler.
5
- """
6
-
7
- import datetime as dt
8
- import multiprocessing
9
- import platform
10
- import sys
11
- import time
12
- import uuid
13
- import warnings
14
- from typing import Any, Callable
15
-
16
- import duration_parser
17
- from cron_descriptor import get_description
18
- # from ...fs import AbstractFileSystem
19
- from fsspec_utils import AbstractFileSystem
20
- from humanize import precisedelta
21
- from loguru import logger
22
- from rq import Queue, Repeat, Retry
23
- from rq.job import Callback, Job
24
- from rq.results import Result
25
- from rq.worker import Worker
26
- from rq.worker_pool import WorkerPool
27
- from rq_scheduler import Scheduler
28
-
29
- from ...utils.logging import setup_logging
30
- from ..base import BaseJobQueueManager
31
- from .setup import RQBackend
32
-
33
- setup_logging()
34
-
35
- if sys.platform == "darwin" and platform.machine() == "arm64":
36
- try:
37
- # Check if the start method has already been set to avoid errors
38
- if multiprocessing.get_start_method(allow_none=True) is None:
39
- multiprocessing.set_start_method("fork")
40
- logger.debug("Set multiprocessing start method to 'fork' for macOS ARM.")
41
- elif multiprocessing.get_start_method() != "fork":
42
- logger.warning(
43
- f"Multiprocessing start method already set to '{multiprocessing.get_start_method()}'. "
44
- f"Cannot set to 'fork'. This might cause issues on macOS ARM."
45
- )
46
- except RuntimeError as e:
47
- # Handle cases where the context might already be started
48
- logger.warning(f"Could not set multiprocessing start method to 'fork': {e}")
49
-
50
-
51
- class RQManager(BaseJobQueueManager):
52
- """Implementation of BaseScheduler using Redis Queue (RQ) and rq-scheduler.
53
-
54
- This worker class uses RQ and rq-scheduler as the backend to manage jobs and schedules.
55
- It supports multiple queues, background workers, and job scheduling capabilities.
56
-
57
- Typical usage:
58
- ```python
59
- worker = RQManager(name="my_rq_worker")
60
- worker.start_worker(background=True)
61
-
62
- # Add a job
63
- def my_job(x: int) -> int:
64
- return x * 2
65
-
66
- job_id = worker.add_job(my_job, func_args=(10,))
67
- ```
68
- """
69
-
70
- def __init__(
71
- self,
72
- name: str = "rq_scheduler",
73
- base_dir: str | None = None,
74
- backend: RQBackend | None = None,
75
- storage_options: dict[str, Any] | None = None,
76
- fs: AbstractFileSystem | None = None,
77
- log_level: str | None = None,
78
- ):
79
- """Initialize the RQ scheduler backend.
80
-
81
- Args:
82
- name: Name of the scheduler instance. Used for identification in logs
83
- and queue names.
84
- base_dir: Base directory for the FlowerPower project. Used for finding
85
- configuration files.
86
- backend: RQBackend instance for Redis connection configuration.
87
- If None, configuration is loaded from project settings.
88
- storage_options: Options for configuring file system storage access.
89
- Example: {"mode": "async", "root": "/tmp"}
90
- fs: Custom filesystem implementation for storage operations.
91
- log_level: Logging level to use for this worker instance.
92
- Example: "DEBUG", "INFO", "WARNING", etc.
93
-
94
- Raises:
95
- RuntimeError: If backend setup fails due to Redis connection issues
96
- or missing configurations.
97
- ImportError: If required dependencies are not installed.
98
-
99
- Example:
100
- ```python
101
- # Basic initialization
102
- worker = RQManager(name="my_worker")
103
-
104
- # With custom backend and logging
105
- backend = RQBackend(
106
- uri="redis://localhost:6379/0",
107
- queues=["high", "default", "low"]
108
- )
109
- worker = RQManager(
110
- name="custom_worker",
111
- backend=backend,
112
- log_level="DEBUG"
113
- )
114
- ```
115
- """
116
- if log_level:
117
- setup_logging(level=log_level)
118
- self._log_level = log_level or "INFO"
119
-
120
- super().__init__(
121
- type="rq",
122
- name=name,
123
- base_dir=base_dir,
124
- backend=backend,
125
- fs=fs,
126
- storage_options=storage_options,
127
- )
128
-
129
- if self._backend is None:
130
- self._setup_backend()
131
-
132
- redis_conn = self._backend.client
133
- self._queues = {}
134
-
135
- self._queue_names = self._backend.queues # [:-1]
136
- for queue_name in self._queue_names:
137
- queue = Queue(name=queue_name, connection=redis_conn)
138
- self._queues[queue_name] = queue
139
- self._queues[queue_name].log = logger
140
- logger.debug(f"Created queue and scheduler for '{queue_name}'")
141
-
142
- self._scheduler_name = self._backend.queues[-1]
143
- self._scheduler = Scheduler(
144
- connection=redis_conn, queue_name=self._backend.queues[-1], interval=60
145
- )
146
- self._scheduler.log = logger
147
-
148
- def _setup_backend(self) -> None:
149
- """Set up the Redis backend for the scheduler.
150
-
151
- This internal method initializes the Redis connection and queues based on
152
- project configuration. It validates configuration, handles errors, and logs
153
- the setup process.
154
-
155
- Raises:
156
- RuntimeError: If Redis connection fails or configuration is invalid.
157
- """
158
- backend_cfg = getattr(self.cfg, "backend", None)
159
- if not backend_cfg:
160
- logger.error(
161
- "Backend configuration is missing in project.worker.rq_backend.backend."
162
- )
163
- raise RuntimeError("Backend configuration is missing.")
164
- try:
165
- self._backend = RQBackend(**backend_cfg.to_dict())
166
- logger.info(
167
- f"RQ backend setup successful (type: {self._backend.type}, uri: {self._backend.uri})"
168
- )
169
- except Exception as exc:
170
- logger.exception(
171
- f"Failed to set up RQ backend (type: {getattr(self._backend, 'type', None)}, uri: {getattr(self._backend, 'uri', None)}): {exc}"
172
- )
173
- raise RuntimeError(f"Failed to set up RQ backend: {exc}") from exc
174
-
175
- def start_worker(
176
- self,
177
- background: bool = False,
178
- queue_names: list[str] | None = None,
179
- with_scheduler: bool = True,
180
- num_workers: int | None = None,
181
- **kwargs: Any,
182
- ) -> None:
183
- """Start a worker process for processing jobs from the queues.
184
-
185
- Args:
186
- background: If True, runs the worker in a non-blocking background mode.
187
- If False, runs in the current process and blocks until stopped.
188
- queue_names: List of queue names to process. If None, processes all
189
- queues defined in the backend configuration.
190
- with_scheduler: Whether to include the scheduler queue for processing
191
- scheduled jobs.
192
- num_workers: Number of worker processes to start (pool mode).
193
- **kwargs: Additional arguments passed to RQ's Worker class.
194
- Example: {"burst": True, "logging_level": "INFO", "job_monitoring_interval": 30}
195
-
196
- Raises:
197
- RuntimeError: If worker fails to start or if Redis connection fails.
198
-
199
- Example:
200
- ```python
201
- # Start worker in background processing all queues
202
- worker.start_worker(background=True)
203
-
204
- # Start worker for specific queues
205
- worker.start_worker(
206
- background=True,
207
- queue_names=["high", "default"],
208
- with_scheduler=False
209
- )
210
-
211
- # Start worker with custom settings
212
- worker.start_worker(
213
- background=True,
214
- max_jobs=100,
215
- job_monitoring_interval=30
216
- )
217
- # Start a worker pool with 4 processes
218
- worker.start_worker(
219
- background=True,
220
- num_workers=4
221
- )
222
- ```
223
- """
224
- if num_workers is not None and num_workers > 1:
225
- self.start_worker_pool(
226
- num_workers=num_workers,
227
- background=background,
228
- queue_names=queue_names,
229
- with_scheduler=with_scheduler,
230
- **kwargs,
231
- )
232
- else:
233
- import multiprocessing
234
-
235
- logging_level = kwargs.pop("logging_level", self._log_level)
236
- burst = kwargs.pop("burst", False)
237
- max_jobs = kwargs.pop("max_jobs", None)
238
- # Determine which queues to process
239
- if queue_names is None:
240
- # Use all queues by default
241
- queue_names = self._queue_names
242
- queue_names_str = ", ".join(queue_names)
243
- else:
244
- # Filter to only include valid queue names
245
- queue_names = [
246
- name for name in queue_names if name in self._queue_names
247
- ]
248
- queue_names_str = ", ".join(queue_names)
249
-
250
- if not queue_names:
251
- logger.error("No valid queues specified, cannot start worker")
252
- return
253
-
254
- if with_scheduler:
255
- # Add the scheduler queue to the list of queues
256
- queue_names.append(self._scheduler_name)
257
- queue_names_str = ", ".join(queue_names)
258
-
259
- # Create a worker instance with queue names (not queue objects)
260
- worker = Worker(queue_names, connection=self._backend.client, **kwargs)
261
-
262
- if background:
263
- # We need to use a separate process rather than a thread because
264
- # RQ's signal handler registration only works in the main thread
265
- def run_worker_process(queue_names_arg):
266
- # Import RQ inside the process to avoid connection sharing issues
267
- from redis import Redis
268
- from rq import Worker
269
-
270
- # Create a fresh Redis connection in this process
271
- redis_conn = Redis.from_url(self._backend.uri)
272
-
273
- # Create a worker instance with queue names
274
- worker_proc = Worker(queue_names_arg, connection=redis_conn)
275
-
276
- # Disable the default signal handlers in RQ worker by patching
277
- # the _install_signal_handlers method to do nothing
278
- worker_proc._install_signal_handlers = lambda: None
279
-
280
- # Work until terminated
281
- worker_proc.work(
282
- with_scheduler=True,
283
- logging_level=logging_level,
284
- burst=burst,
285
- max_jobs=max_jobs,
286
- )
287
-
288
- # Create and start the process
289
- process = multiprocessing.Process(
290
- target=run_worker_process,
291
- args=(queue_names,),
292
- name=f"rq-worker-{self.name}",
293
- )
294
- # Don't use daemon=True to avoid the "daemonic processes are not allowed to have children" error
295
- process.start()
296
- self._worker_process = process
297
- logger.info(
298
- f"Started RQ worker in background process (PID: {process.pid}) for queues: {queue_names_str}"
299
- )
300
- else:
301
- # Start worker in the current process (blocking)
302
- logger.info(
303
- f"Starting RQ worker in current process (blocking) for queues: {queue_names_str}"
304
- )
305
- worker.work(
306
- with_scheduler=True,
307
- logging_level=logging_level,
308
- burst=burst,
309
- max_jobs=max_jobs,
310
- )
311
-
312
- def stop_worker(self) -> None:
313
- """Stop the worker process.
314
-
315
- This method stops the worker process if running in background mode and
316
- performs cleanup. It should be called before program exit.
317
-
318
- Example:
319
- ```python
320
- try:
321
- worker.start_worker(background=True)
322
- # ... do work ...
323
- finally:
324
- worker.stop_worker()
325
- ```
326
- """
327
- if hasattr(self, "_worker_pool"):
328
- self.stop_worker_pool()
329
- else:
330
- if hasattr(self, "_worker_process") and self._worker_process is not None:
331
- if self._worker_process.is_alive():
332
- self._worker_process.terminate()
333
- self._worker_process.join(timeout=5)
334
- logger.info("RQ worker process terminated")
335
- self._worker_process = None
336
- else:
337
- logger.warning("No worker process to stop")
338
-
339
- def start_worker_pool(
340
- self,
341
- num_workers: int | None = None,
342
- background: bool = False,
343
- queue_names: list[str] | None = None,
344
- with_scheduler: bool = True,
345
- **kwargs: Any,
346
- ) -> None:
347
- """Start a pool of worker processes to handle jobs in parallel.
348
-
349
- This implementation uses RQ's WorkerPool class which provides robust worker
350
- management with proper monitoring and graceful shutdown.
351
-
352
- Args:
353
- num_workers: Number of worker processes to start. If None, uses CPU
354
- count or configuration value.
355
- background: If True, runs the worker pool in background mode.
356
- If False, runs in the current process and blocks.
357
- queue_names: List of queue names to process. If None, processes all
358
- queues defined in the backend configuration.
359
- with_scheduler: Whether to include the scheduler queue for processing
360
- scheduled jobs.
361
- **kwargs: Additional arguments passed to RQ's WorkerPool class.
362
- Example: {"max_jobs": 100, "job_monitoring_interval": 30}
363
-
364
- Raises:
365
- RuntimeError: If worker pool fails to start or Redis connection fails.
366
-
367
- Example:
368
- ```python
369
- # Start pool with default settings
370
- worker.start_worker_pool(num_workers=4, background=True)
371
-
372
- # Start pool for specific queues
373
- worker.start_worker_pool(
374
- num_workers=4,
375
- background=True,
376
- queue_names=["high", "default"],
377
- with_scheduler=False
378
- )
379
-
380
- # Start pool with custom settings
381
- worker.start_worker_pool(
382
- num_workers=4,
383
- background=True,
384
- max_jobs=100,
385
- job_monitoring_interval=30
386
- )
387
- ```
388
- """
389
- import multiprocessing
390
-
391
- logging_level = kwargs.pop("logging_level", self._log_level)
392
- burst = kwargs.pop("burst", False)
393
- max_jobs = kwargs.pop("max_jobs", None)
394
-
395
- # if num_workers is None:
396
- # backend = getattr(self.cfg, "backend", None)
397
- # if backend is not None:
398
- # num_workers = getattr(backend, "num_workers", None)
399
- if num_workers is None:
400
- num_workers = self.cfg.num_workers or multiprocessing.cpu_count()
401
- # Determine which queues to process
402
- if queue_names is None:
403
- # Use all queues by default
404
- queue_list = self._queue_names
405
- queue_names_str = ", ".join(queue_list)
406
- else:
407
- # Filter to only include valid queue names
408
- queue_list = [name for name in queue_names if name in self._queue_names]
409
- queue_names_str = ", ".join(queue_list)
410
-
411
- if not queue_list:
412
- logger.error("No valid queues specified, cannot start worker pool")
413
- return
414
- if with_scheduler:
415
- # Add the scheduler queue to the list of queues
416
- queue_list.append(self._scheduler_name)
417
- queue_names_str = ", ".join(queue_list)
418
-
419
- # Initialize RQ's WorkerPool
420
- worker_pool = WorkerPool(
421
- queues=queue_list,
422
- connection=self._backend.client,
423
- num_workers=num_workers,
424
- **kwargs,
425
- )
426
- # worker_pool.log = logger
427
-
428
- self._worker_pool = worker_pool
429
-
430
- if background:
431
- # Start the worker pool process using multiprocessing to avoid signal handler issues
432
- def run_pool_process():
433
- worker_pool.start(
434
- burst=burst, logging_level=logging_level, max_jobs=max_jobs
435
- )
436
-
437
- self._pool_process = multiprocessing.Process(
438
- target=run_pool_process,
439
- name=f"rq-worker-pool-{self.name}",
440
- )
441
- self._pool_process.start()
442
- logger.info(
443
- f"Worker pool started with {num_workers} workers across queues: {queue_names_str} in background process (PID: {self._pool_process.pid})"
444
- )
445
- else:
446
- # Start the worker pool in the current process (blocking)
447
- logger.info(
448
- f"Starting worker pool with {num_workers} workers across queues: {queue_names_str} in foreground (blocking)"
449
- )
450
- worker_pool.start(burst=burst, logging_level=logging_level)
451
-
452
- def stop_worker_pool(self) -> None:
453
- """Stop all worker processes in the pool.
454
-
455
- This method stops all worker processes in the pool and performs cleanup.
456
- It ensures a graceful shutdown of all workers.
457
-
458
- Example:
459
- ```python
460
- try:
461
- worker.start_worker_pool(num_workers=4, background=True)
462
- # ... do work ...
463
- finally:
464
- worker.stop_worker_pool()
465
- ```
466
- """
467
- if hasattr(self, "_worker_pool"):
468
- logger.info("Stopping RQ worker pool")
469
- self._worker_pool.stop_workers()
470
-
471
- if hasattr(self, "_pool_process") and self._pool_process.is_alive():
472
- # Terminate the worker pool process
473
- self._pool_process.terminate()
474
- self._pool_process.join(timeout=10)
475
- if self._pool_process.is_alive():
476
- logger.warning(
477
- "Worker pool process did not terminate within timeout"
478
- )
479
-
480
- self._worker_pool = None
481
-
482
- if hasattr(self, "_pool_process"):
483
- self._pool_process = None
484
- else:
485
- logger.warning("No worker pool to stop")
486
-
487
- def start_scheduler(self, background: bool = False, interval: int = 60) -> None:
488
- """Start the RQ scheduler process.
489
-
490
- The scheduler process manages scheduled and recurring jobs. It must be
491
- running for scheduled jobs to execute.
492
-
493
- Args:
494
- background: If True, runs the scheduler in a non-blocking background mode.
495
- If False, runs in the current process and blocks.
496
- interval: How often to check for scheduled jobs, in seconds.
497
-
498
- Raises:
499
- RuntimeError: If scheduler fails to start or Redis connection fails.
500
-
501
- Example:
502
- ```python
503
- # Start scheduler in background checking every 30 seconds
504
- worker.start_scheduler(background=True, interval=30)
505
-
506
- # Start scheduler in foreground (blocking)
507
- worker.start_scheduler(background=False)
508
- ```
509
- """
510
- # Create a scheduler instance with the queue name
511
- if not hasattr(self, "_scheduler"):
512
- self._scheduler = Scheduler(
513
- connection=self._backend.client,
514
- queue_name=self._backend.queues[-1],
515
- interval=interval,
516
- )
517
- self._scheduler.log = logger
518
-
519
- elif self._scheduler._interval != interval:
520
- self._scheduler = Scheduler(
521
- connection=self._backend.client,
522
- queue_name=self._backend.queues[-1],
523
- interval=interval,
524
- )
525
- self._scheduler.log = logger
526
-
527
- if background:
528
-
529
- def run_scheduler():
530
- self._scheduler.run()
531
-
532
- self._scheduler_process = multiprocessing.Process(
533
- target=run_scheduler, name=f"rq-scheduler-{self.name}"
534
- )
535
- self._scheduler_process.start()
536
- logger.info(
537
- f"Started RQ scheduler in background (PID: {self._scheduler_process.pid})"
538
- )
539
- else:
540
- logger.info("Starting RQ scheduler in current process (blocking)")
541
- self._scheduler.run()
542
-
543
- def stop_scheduler(self) -> None:
544
- """Stop the RQ scheduler process.
545
-
546
- This method stops the scheduler process if running in background mode
547
- and performs cleanup.
548
-
549
- Example:
550
- ```python
551
- try:
552
- worker.start_scheduler(background=True)
553
- # ... do work ...
554
- finally:
555
- worker.stop_scheduler()
556
- ```
557
- """
558
- if hasattr(self, "_scheduler_process") and self._scheduler_process is not None:
559
- if self._scheduler_process.is_alive():
560
- self._scheduler_process.terminate()
561
- self._scheduler_process.join(timeout=5)
562
- logger.info("RQ scheduler process terminated")
563
- self._scheduler_process = None
564
- else:
565
- logger.debug("No scheduler process to stop")
566
-
567
- ## Jobs ###
568
-
569
- def enqueue(
570
- self,
571
- func: Callable,
572
- *args,
573
- **kwargs,
574
- ) -> Job:
575
- """Enqueue a job for execution (immediate, delayed, or scheduled).
576
-
577
- This is the main method for adding jobs to the queue. It supports:
578
- - Immediate execution (no run_at or run_in parameters)
579
- - Delayed execution (run_in parameter)
580
- - Scheduled execution (run_at parameter)
581
-
582
- Args:
583
- func: Function to execute. Must be importable from the worker process.
584
- *args: Positional arguments for the function
585
- **kwargs: Keyword arguments including:
586
- - run_in: Schedule the job to run after a delay (timedelta, int seconds, or string)
587
- - run_at: Schedule the job to run at a specific datetime
588
- - func_args: Alternative way to pass positional arguments
589
- - func_kwargs: Alternative way to pass keyword arguments
590
- - Other job queue specific parameters (timeout, retry, etc.)
591
-
592
- Returns:
593
- Job: The created job instance
594
-
595
- Example:
596
- ```python
597
- # Immediate execution
598
- manager.enqueue(my_func, arg1, arg2, kwarg1="value")
599
-
600
- # Delayed execution
601
- manager.enqueue(my_func, arg1, run_in=300) # 5 minutes
602
- manager.enqueue(my_func, arg1, run_in=timedelta(hours=1))
603
-
604
- # Scheduled execution
605
- manager.enqueue(my_func, arg1, run_at=datetime(2025, 1, 1, 9, 0))
606
- ```
607
- """
608
- # Extract func_args and func_kwargs if provided as alternatives to *args
609
- func_args = kwargs.pop("func_args", None)
610
- func_kwargs = kwargs.pop("func_kwargs", None)
611
-
612
- # Use provided args or fall back to func_args
613
- if args:
614
- final_args = args
615
- elif func_args:
616
- final_args = func_args
617
- else:
618
- final_args = ()
619
-
620
- # Extract function keyword arguments
621
- if func_kwargs:
622
- final_kwargs = func_kwargs
623
- else:
624
- final_kwargs = {}
625
-
626
- # Delegate to add_job with the parameters
627
- return self.add_job(
628
- func=func, func_args=final_args, func_kwargs=final_kwargs, **kwargs
629
- )
630
-
631
- def enqueue_in(
632
- self,
633
- delay,
634
- func: Callable,
635
- *args,
636
- **kwargs,
637
- ) -> Job:
638
- """Enqueue a job to run after a specified delay.
639
-
640
- This is a convenience method for delayed execution.
641
-
642
- Args:
643
- delay: Time to wait before execution (timedelta, int seconds, or string)
644
- func: Function to execute
645
- *args: Positional arguments for the function
646
- **kwargs: Keyword arguments for the function and job options
647
-
648
- Returns:
649
- Job: The created job instance
650
-
651
- Example:
652
- ```python
653
- # Run in 5 minutes
654
- manager.enqueue_in(300, my_func, arg1, arg2)
655
-
656
- # Run in 1 hour
657
- manager.enqueue_in(timedelta(hours=1), my_func, arg1, kwarg1="value")
658
- ```
659
- """
660
- return self.enqueue(func, *args, run_in=delay, **kwargs)
661
-
662
- def enqueue_at(
663
- self,
664
- datetime,
665
- func: Callable,
666
- *args,
667
- **kwargs,
668
- ) -> Job:
669
- """Enqueue a job to run at a specific datetime.
670
-
671
- This is a convenience method for scheduled execution.
672
-
673
- Args:
674
- datetime: When to execute the job (datetime object or ISO string)
675
- func: Function to execute
676
- *args: Positional arguments for the function
677
- **kwargs: Keyword arguments for the function and job options
678
-
679
- Returns:
680
- Job: The created job instance
681
-
682
- Example:
683
- ```python
684
- # Run at specific time
685
- manager.enqueue_at(datetime(2025, 1, 1, 9, 0), my_func, arg1, arg2)
686
-
687
- # Run tomorrow at 9 AM
688
- tomorrow_9am = datetime.now() + timedelta(days=1)
689
- tomorrow_9am = tomorrow_9am.replace(hour=9, minute=0, second=0)
690
- manager.enqueue_at(tomorrow_9am, my_func, arg1, kwarg1="value")
691
- ```
692
- """
693
- return self.enqueue(func, *args, run_at=datetime, **kwargs)
694
-
695
- def add_job(
696
- self,
697
- func: Callable,
698
- func_args: tuple | None = None,
699
- func_kwargs: dict[str, Any] | None = None,
700
- job_id: str | None = None,
701
- result_ttl: int | dt.timedelta | None = None,
702
- ttl: int | dt.timedelta | None = None,
703
- timeout: int | dt.timedelta | None = None,
704
- queue_name: str | None = None,
705
- run_at: dt.datetime | str | None = None,
706
- run_in: dt.timedelta | int | str | None = None,
707
- retry: int | dict | None = None,
708
- repeat: int | dict | None = None,
709
- meta: dict | None = None,
710
- failure_ttl: int | dt.timedelta | None = None,
711
- group_id: str | None = None,
712
- on_success: Callback | Callable | str | None = None,
713
- on_failure: Callback | Callable | str | None = None,
714
- on_stopped: Callback | Callable | str | None = None,
715
- **job_kwargs,
716
- ) -> Job:
717
- """Add a job for immediate or scheduled execution.
718
-
719
- .. deprecated:: 0.12.0
720
- Use :meth:`enqueue`, :meth:`enqueue_in`, or :meth:`enqueue_at` instead.
721
- The add_job method will be removed in version 1.0.0.
722
-
723
- Args:
724
- func: Function to execute. Must be importable from the worker process.
725
- func_args: Positional arguments to pass to the function.
726
- func_kwargs: Keyword arguments to pass to the function.
727
- job_id: Optional unique identifier for the job. If None, a UUID is generated.
728
- result_ttl: Time to live for the job result, as seconds or timedelta.
729
- After this time, the result may be removed from Redis.
730
- ttl: Maximum time the job can exist in Redis, as seconds or timedelta.
731
- After this time, the job will be removed even if not complete.
732
- timeout: Maximum time the job can run before being killed, as seconds or timedelta.
733
- queue_name: Name of the queue to place the job in. If None, uses the
734
- first queue from configuration.
735
- run_at: Schedule the job to run at a specific datetime.
736
- run_in: Schedule the job to run after a delay.
737
- retry: Number of retries or retry configuration dictionary.
738
- Example dict: {"max": 3, "interval": 60}
739
- repeat: Number of repetitions or repeat configuration dictionary.
740
- Example dict: {"max": 5, "interval": 3600}
741
- meta: Additional metadata to store with the job.
742
- failure_ttl: Time to live for the job failure result, as seconds or timedelta.
743
- group_id: Optional group ID to associate this job with a group.
744
- on_success: Callback to run on job success. Can be a function, string,
745
- or RQ Callback instance.
746
- on_failure: Callback to run on job failure. Can be a function, string,
747
- or RQ Callback instance.
748
- on_stopped: Callback to run when the job is stopped. Can be a function,
749
- string, or RQ Callback instance.
750
- **job_kwargs: Additional arguments for RQ's Job class.
751
-
752
- Returns:
753
- Job: The created job instance.
754
-
755
- Raises:
756
- ValueError: If the function is not serializable or arguments are invalid.
757
- RuntimeError: If Redis connection fails.
758
-
759
- Example:
760
- ```python
761
- def my_task(x: int, y: int = 0) -> int:
762
- return x + y
763
-
764
- # Add immediate job
765
- job = worker.add_job(
766
- my_task,
767
- func_args=(1,),
768
- func_kwargs={"y": 2},
769
- result_ttl=3600 # Keep result for 1 hour
770
- )
771
-
772
- # Add scheduled job
773
- tomorrow = dt.datetime.now() + dt.timedelta(days=1)
774
- job = worker.add_job(
775
- my_task,
776
- func_args=(1, 2),
777
- run_at=tomorrow,
778
- queue_name="scheduled"
779
- )
780
-
781
- # Add job with retries
782
- job = worker.add_job(
783
- my_task,
784
- func_args=(1, 2),
785
- retry={"max": 3, "interval": 60} # 3 retries, 1 minute apart
786
- )
787
-
788
- # Add repeating job
789
- job = worker.add_job(
790
- my_task,
791
- func_args=(1, 2),
792
- repeat={"max": 5, "interval": 3600} # 5 times, hourly
793
- )
794
- ```
795
- """
796
- # Issue deprecation warning
797
- warnings.warn(
798
- "add_job() is deprecated and will be removed in version 1.0.0. "
799
- "Use enqueue(), enqueue_in(), or enqueue_at() instead.",
800
- DeprecationWarning,
801
- stacklevel=2,
802
- )
803
-
804
- job_id = job_id or str(uuid.uuid4())
805
- if isinstance(result_ttl, (int, float)):
806
- result_ttl = dt.timedelta(seconds=result_ttl)
807
- # args = args or ()
808
- # kwargs = kwargs or {}
809
- if queue_name is None:
810
- queue_name = self._queue_names[0]
811
- elif queue_name not in self._queue_names:
812
- logger.warning(
813
- f"Queue '{queue_name}' not found, using '{self._queue_names[0]}'"
814
- )
815
- queue_name = self._queue_names[0]
816
-
817
- if repeat:
818
- # If repeat is an int, convert it to a Repeat instance
819
- if isinstance(repeat, int):
820
- repeat = Repeat(max=repeat)
821
- elif isinstance(repeat, dict):
822
- # If repeat is a dict, convert it to a Repeat instance
823
- repeat = Repeat(**repeat)
824
- else:
825
- raise ValueError("Invalid repeat value. Must be int or dict.")
826
- if retry:
827
- if isinstance(retry, int):
828
- retry = Retry(max=retry)
829
- elif isinstance(retry, dict):
830
- # If retry is a dict, convert it to a Retry instance
831
- retry = Retry(**retry)
832
- else:
833
- raise ValueError("Invalid retry value. Must be int or dict.")
834
-
835
- if isinstance(ttl, dt.timedelta):
836
- ttl = ttl.total_seconds()
837
- if isinstance(timeout, dt.timedelta):
838
- timeout = timeout.total_seconds()
839
- if isinstance(result_ttl, dt.timedelta):
840
- result_ttl = result_ttl.total_seconds()
841
- if isinstance(failure_ttl, dt.timedelta):
842
- failure_ttl = failure_ttl.total_seconds()
843
-
844
- if isinstance(on_success, (str, Callable)):
845
- on_success = Callback(on_success)
846
- if isinstance(on_failure, (str, Callable)):
847
- on_failure = Callback(on_failure)
848
- if isinstance(on_stopped, (str, Callable)):
849
- on_stopped = Callback(on_stopped)
850
-
851
- queue = self._queues[queue_name]
852
- if run_at:
853
- # Schedule the job to run at a specific time
854
- run_at = (
855
- dt.datetime.fromisoformat(run_at) if isinstance(run_at, str) else run_at
856
- )
857
- job = queue.enqueue_at(
858
- run_at,
859
- func,
860
- args=func_args,
861
- kwargs=func_kwargs,
862
- job_id=job_id,
863
- result_ttl=int(result_ttl) if result_ttl else None,
864
- ttl=int(ttl) if ttl else None,
865
- failure_ttl=int(failure_ttl) if failure_ttl else None,
866
- timeout=int(timeout) if timeout else None,
867
- retry=retry,
868
- repeat=repeat,
869
- meta=meta,
870
- group_id=group_id,
871
- on_success=on_success,
872
- on_failure=on_failure,
873
- on_stopped=on_stopped,
874
- **job_kwargs,
875
- )
876
- logger.info(
877
- f"Enqueued job {job.id} ({func.__name__}) on queue '{queue_name}'. Scheduled to run at {run_at}."
878
- )
879
- elif run_in:
880
- # Schedule the job to run after a delay
881
- run_in = (
882
- duration_parser.parse(run_in) if isinstance(run_in, str) else run_in
883
- )
884
- run_in = (
885
- dt.timedelta(seconds=run_in)
886
- if isinstance(run_in, (int, float))
887
- else run_in
888
- )
889
- job = queue.enqueue_in(
890
- run_in,
891
- func,
892
- args=func_args,
893
- kwargs=func_kwargs,
894
- job_id=job_id,
895
- result_ttl=int(result_ttl) if result_ttl else None,
896
- ttl=int(ttl) if ttl else None,
897
- failure_ttl=int(failure_ttl) if failure_ttl else None,
898
- timeout=int(timeout) if timeout else None,
899
- retry=retry,
900
- repeat=repeat,
901
- meta=meta,
902
- group_id=group_id,
903
- on_success=on_success,
904
- on_failure=on_failure,
905
- on_stopped=on_stopped,
906
- **job_kwargs,
907
- )
908
- logger.info(
909
- f"Enqueued job {job.id} ({func.__name__}) on queue '{queue_name}'. Scheduled to run in {precisedelta(run_in)}."
910
- )
911
- else:
912
- # Enqueue the job for immediate execution
913
- job = queue.enqueue(
914
- func,
915
- args=func_args,
916
- kwargs=func_kwargs,
917
- job_id=job_id,
918
- result_ttl=int(result_ttl) if result_ttl else None,
919
- ttl=int(ttl) if ttl else None,
920
- failure_ttl=int(failure_ttl) if failure_ttl else None,
921
- timeout=int(timeout) if timeout else None,
922
- retry=retry,
923
- repeat=repeat,
924
- meta=meta,
925
- group_id=group_id,
926
- on_success=on_success,
927
- on_failure=on_failure,
928
- on_stopped=on_stopped,
929
- **job_kwargs,
930
- )
931
- logger.info(
932
- f"Enqueued job {job.id} ({func.__name__}) on queue '{queue_name}'"
933
- )
934
- return job
935
-
936
- def run_job(
937
- self,
938
- func: Callable,
939
- func_args: tuple | None = None,
940
- func_kwargs: dict[str, Any] | None = None,
941
- job_id: str | None = None,
942
- result_ttl: int | dt.timedelta | None = None,
943
- ttl: int | dt.timedelta | None = None,
944
- queue_name: str | None = None,
945
- retry: int | dict | None = None,
946
- repeat: int | dict | None = None,
947
- meta: dict | None = None,
948
- failure_ttl: int | dt.timedelta | None = None,
949
- group_id: str | None = None,
950
- on_success: Callback | Callable | str | None = None,
951
- on_failure: Callback | Callable | str | None = None,
952
- on_stopped: Callback | Callable | str | None = None,
953
- **job_kwargs,
954
- ) -> Any:
955
- """Run a job immediately and return its result.
956
-
957
- This method is a wrapper around add_job that waits for the job to complete
958
- and returns its result.
959
-
960
- Args:
961
- func: Function to execute. Must be importable from the worker process.
962
- func_args: Positional arguments to pass to the function.
963
- func_kwargs: Keyword arguments to pass to the function.
964
- job_id: Optional unique identifier for the job.
965
- result_ttl: Time to live for the job result.
966
- ttl: Maximum time the job can exist.
967
- queue_name: Name of the queue to use.
968
- retry: Number of retries or retry configuration.
969
- repeat: Number of repetitions or repeat configuration.
970
- meta: Additional metadata to store with the job.
971
- failure_ttl: Time to live for the job failure result.
972
- group_id: Optional group ID to associate this job with a group.
973
- on_success: Callback to run on job success.
974
- on_failure: Callback to run on job failure.
975
- on_stopped: Callback to run when the job is stopped.
976
- **job_kwargs: Additional arguments for RQ's Job class.
977
-
978
- Returns:
979
- Any: The result returned by the executed function.
980
-
981
- Raises:
982
- Exception: Any exception raised by the executed function.
983
- TimeoutError: If the job times out before completion.
984
-
985
- Example:
986
- ```python
987
- def add(x: int, y: int) -> int:
988
- return x + y
989
-
990
- # Run job and get result immediately
991
- result = worker.run_job(
992
- add,
993
- func_args=(1, 2),
994
- retry=3 # Retry up to 3 times on failure
995
- )
996
- assert result == 3
997
- ```
998
- """
999
- job = self.add_job(
1000
- func=func,
1001
- func_args=func_args,
1002
- func_kwargs=func_kwargs,
1003
- job_id=job_id,
1004
- result_ttl=result_ttl,
1005
- ttl=ttl,
1006
- queue_name=queue_name,
1007
- retry=retry,
1008
- repeat=repeat,
1009
- meta=meta,
1010
- failure_ttl=failure_ttl,
1011
- group_id=group_id,
1012
- on_success=on_success,
1013
- on_failure=on_failure,
1014
- on_stopped=on_stopped,
1015
- **job_kwargs,
1016
- )
1017
- while not job.is_finished:
1018
- job.refresh()
1019
- time.sleep(0.1)
1020
- return job.result
1021
-
1022
- def _get_job_queue_name(self, job: str | Job) -> str | None:
1023
- """Get the queue name for a job.
1024
-
1025
- Args:
1026
- job: Job ID or Job object.
1027
-
1028
- Returns:
1029
- str | None: Name of the queue containing the job, or None if not found.
1030
- """
1031
- job_id = job if isinstance(job, str) else job.id
1032
- for queue_name in self.job_ids:
1033
- if job_id in self.job_ids[queue_name]:
1034
- return queue_name
1035
- return None
1036
-
1037
- def get_jobs(
1038
- self, queue_name: str | list[str] | None = None
1039
- ) -> dict[str, list[Job]]:
1040
- """Get all jobs from specified queues.
1041
-
1042
- Args:
1043
- queue_name: Optional queue name or list of queue names to get jobs from.
1044
- If None, gets jobs from all queues.
1045
-
1046
- Returns:
1047
- dict[str, list[Job]]: Dictionary mapping queue names to lists of jobs.
1048
-
1049
- Example:
1050
- ```python
1051
- # Get jobs from all queues
1052
- jobs = worker.get_jobs()
1053
- for queue_name, queue_jobs in jobs.items():
1054
- print(f"Queue {queue_name}: {len(queue_jobs)} jobs")
1055
-
1056
- # Get jobs from specific queues
1057
- jobs = worker.get_jobs(["high", "default"])
1058
- ```
1059
- """
1060
- if queue_name is None:
1061
- queue_name = self._queue_names
1062
- elif isinstance(queue_name, str):
1063
- queue_name = [queue_name]
1064
- jobs = {
1065
- queue_name: self._queues[queue_name].get_jobs() for queue_name in queue_name
1066
- }
1067
- return jobs
1068
-
1069
- def get_job(self, job_id: str) -> Job | None:
1070
- """Get a specific job by its ID.
1071
-
1072
- Args:
1073
- job_id: Unique identifier of the job to retrieve.
1074
-
1075
- Returns:
1076
- Job | None: The job object if found, None otherwise.
1077
-
1078
- Example:
1079
- ```python
1080
- job = worker.get_job("550e8400-e29b-41d4-a716-446655440000")
1081
- if job:
1082
- print(f"Job status: {job.get_status()}")
1083
- ```
1084
- """
1085
- queue_name = self._get_job_queue_name(job=job_id)
1086
- if queue_name is None:
1087
- logger.error(f"Job {job_id} not found in any queue")
1088
- return None
1089
- job = self._queues[queue_name].fetch_job(job_id)
1090
- if job is None:
1091
- logger.error(f"Job {job_id} not found in queue '{queue_name}'")
1092
- return None
1093
- return job
1094
-
1095
- def get_job_result(self, job: str | Job, delete_result: bool = False) -> Any:
1096
- """Get the result of a completed job.
1097
-
1098
- Args:
1099
- job: Job ID or Job object.
1100
- delete_result: If True, deletes the job and its result after retrieval.
1101
-
1102
- Returns:
1103
- Any: The result of the job if available.
1104
-
1105
- Example:
1106
- ```python
1107
- # Get result and keep the job
1108
- result = worker.get_job_result("job-123")
1109
-
1110
- # Get result and clean up
1111
- result = worker.get_job_result("job-123", delete_result=True)
1112
- ```
1113
- """
1114
- if isinstance(job, str):
1115
- job = self.get_job(job_id=job)
1116
-
1117
- if job is None:
1118
- logger.error(f"Job {job} not found in any queue")
1119
- return None
1120
-
1121
- if delete_result:
1122
- self.delete_job(job)
1123
-
1124
- return job.result
1125
-
1126
- def cancel_job(self, job: str | Job) -> bool:
1127
- """
1128
- Cancel a job in the queue.
1129
-
1130
- Args:
1131
- job: Job ID or Job object
1132
-
1133
- Returns:
1134
- bool: True if the job was canceled, False otherwise
1135
- """
1136
- if isinstance(job, str):
1137
- job = self.get_job(job_id=job)
1138
- if job is None:
1139
- logger.error(f"Job {job} not found in any queue")
1140
- return False
1141
-
1142
- job.cancel()
1143
- logger.info(f"Canceled job {job.id} from queue '{job.origin}'")
1144
- return True
1145
-
1146
- def delete_job(self, job: str | Job, ttl: int = 0, **kwargs) -> bool:
1147
- """
1148
- Remove a job from the queue.
1149
-
1150
- Args:
1151
- job: Job ID or Job object
1152
- ttl: Optional time to live for the job (in seconds). 0 means no TTL.
1153
- Remove the job immediately.
1154
- **kwargs: Additional parameters for the job removal
1155
-
1156
- Returns:
1157
- bool: True if the job was removed, False otherwise
1158
- """
1159
- if isinstance(job, str):
1160
- job = self.get_job(job)
1161
- if job is None:
1162
- return False
1163
- if ttl:
1164
- job.cleanup(ttl=ttl, **kwargs)
1165
- logger.info(
1166
- f"Removed job {job.id} from queue '{job.origin}' with TTL {ttl}"
1167
- )
1168
- else:
1169
- job.delete(**kwargs)
1170
- logger.info(f"Removed job {job.id} from queue '{job.origin}'")
1171
-
1172
- return True
1173
-
1174
- def cancel_all_jobs(self, queue_name: str | None = None) -> None:
1175
- """
1176
- Cancel all jobs in a queue.
1177
-
1178
- Args:
1179
- queue_name (str | None): Optional name of the queue to cancel jobs from.
1180
- If None, cancels jobs from all queues.
1181
- """
1182
- if queue_name is None:
1183
- queue_name = self._queue_names
1184
- elif isinstance(queue_name, str):
1185
- queue_name = [queue_name]
1186
-
1187
- for queue_name in queue_name:
1188
- if queue_name not in self._queue_names:
1189
- logger.warning(f"Queue '{queue_name}' not found, skipping")
1190
- continue
1191
-
1192
- for job in self.get_jobs(queue_name=queue_name):
1193
- self.cancel_job(job)
1194
-
1195
- def delete_all_jobs(self, queue_name: str | None = None, ttl: int = 0) -> None:
1196
- """
1197
- Remove all jobs from a queue.
1198
-
1199
- Args:
1200
- queue_name (str | None): Optional name of the queue to remove jobs from.
1201
- If None, removes jobs from all queues.
1202
- ttl: Optional time to live for the job (in seconds). 0 means no TTL.
1203
- Remove the job immediately.
1204
-
1205
- """
1206
- if queue_name is None:
1207
- queue_name = self._queue_names
1208
- elif isinstance(queue_name, str):
1209
- queue_name = [queue_name]
1210
-
1211
- for queue_name in queue_name:
1212
- if queue_name not in self._queue_names:
1213
- logger.warning(f"Queue '{queue_name}' not found, skipping")
1214
- continue
1215
-
1216
- for job in self.get_jobs(queue_name=queue_name):
1217
- self.delete_job(job, ttl=ttl)
1218
-
1219
- @property
1220
- def job_ids(self):
1221
- """Get all job IDs from all queues.
1222
-
1223
- Returns:
1224
- dict[str, list[str]]: Dictionary mapping queue names to lists of job IDs.
1225
-
1226
- Example:
1227
- ```python
1228
- all_ids = worker.job_ids
1229
- for queue_name, ids in all_ids.items():
1230
- print(f"Queue {queue_name}: {len(ids)} jobs")
1231
- ```
1232
- """
1233
- job_ids = {}
1234
- for queue_name in self._queue_names:
1235
- job_ids[queue_name] = self._queues[queue_name].job_ids
1236
-
1237
- return job_ids
1238
-
1239
- @property
1240
- def jobs(self):
1241
- """Get all jobs from all queues.
1242
-
1243
- Returns:
1244
- dict[str, list[Job]]: Dictionary mapping queue names to lists of jobs.
1245
-
1246
- Example:
1247
- ```python
1248
- all_jobs = worker.jobs
1249
- for queue_name, queue_jobs in all_jobs.items():
1250
- print(f"Queue {queue_name}: {len(queue_jobs)} jobs")
1251
- ```
1252
- """
1253
- jobs = {}
1254
- for queue_name in self._queue_names:
1255
- jobs[queue_name] = self._queues[queue_name].get_jobs()
1256
-
1257
- return jobs
1258
-
1259
- ### Schedules ###
1260
-
1261
- def add_schedule(
1262
- self,
1263
- func: Callable,
1264
- func_args: tuple | None = None,
1265
- func_kwargs: dict[str, Any] | None = None,
1266
- cron: str | None = None, # Cron expression for scheduling
1267
- interval: int | None = None, # Interval in seconds
1268
- date: dt.datetime | None = None, # Date to run the job
1269
- queue_name: str | None = None,
1270
- schedule_id: str | None = None,
1271
- ttl: int | dt.timedelta | None = None,
1272
- result_ttl: int | dt.timedelta | None = None,
1273
- repeat: int | None = None,
1274
- timeout: int | dt.timedelta | None = None,
1275
- meta: dict | None = None,
1276
- on_success: Callback | Callable | str | None = None,
1277
- on_failure: Callback | Callable | str | None = None,
1278
- on_stopped: Callback | Callable | str | None = None,
1279
- **schedule_kwargs,
1280
- ) -> Job:
1281
- """Schedule a job for repeated or one-time execution.
1282
-
1283
- Args:
1284
- func: Function to execute. Must be importable from the worker process.
1285
- func_args: Positional arguments to pass to the function.
1286
- func_kwargs: Keyword arguments to pass to the function.
1287
- cron: Cron expression for scheduling (e.g. "0 * * * *" for hourly).
1288
- interval: Interval in seconds for recurring execution.
1289
- date: Specific datetime for one-time execution.
1290
- queue_name: Name of the queue to use for the scheduled job.
1291
- schedule_id: Optional unique identifier for the schedule.
1292
- ttl: Time to live for the schedule, as seconds or timedelta.
1293
- result_ttl: Time to live for the job result, as seconds or timedelta.
1294
- repeat: Number of repetitions
1295
- timeout: Maximum time the job can run before being killed, as seconds or timedelta.
1296
- meta: Additional metadata to store with the schedule.
1297
- on_success: Callback to run on schedule success. Can be a function,
1298
- string, or RQ Callback instance.
1299
- on_failure: Callback to run on schedule failure. Can be a function,
1300
- string, or RQ Callback instance.
1301
- on_stopped: Callback to run when the schedule is stopped. Can be a function,
1302
- string, or RQ Callback instance.
1303
- **schedule_kwargs: Additional scheduling parameters:
1304
- - repeat: Number of repetitions (int or dict)
1305
- - result_ttl: Time to live for results (float or timedelta)
1306
- - ttl: Time to live for the schedule (float or timedelta)
1307
- - use_local_time_zone: Whether to use local time (bool)
1308
- - queue_name: Queue to use for the scheduled jobs
1309
-
1310
- Returns:
1311
- Job: The scheduled job instance.
1312
-
1313
- Raises:
1314
- ValueError: If no scheduling method specified or invalid cron expression.
1315
- RuntimeError: If Redis connection fails.
1316
-
1317
- Example:
1318
- ```python
1319
- def my_task(msg: str) -> None:
1320
- print(f"Task: {msg}")
1321
-
1322
- # Schedule with cron (every hour)
1323
- job = worker.add_schedule(
1324
- my_task,
1325
- func_kwargs={"msg": "Hourly check"},
1326
- cron="0 * * * *"
1327
- )
1328
-
1329
- # Schedule with interval (every 5 minutes)
1330
- job = worker.add_schedule(
1331
- my_task,
1332
- func_kwargs={"msg": "Regular check"},
1333
- interval=300
1334
- )
1335
-
1336
- # Schedule for specific time
1337
- tomorrow = dt.datetime.now() + dt.timedelta(days=1)
1338
- job = worker.add_schedule(
1339
- my_task,
1340
- func_kwargs={"msg": "One-time task"},
1341
- date=tomorrow
1342
- )
1343
- ```
1344
- """
1345
- schedule_id = schedule_id or str(uuid.uuid4())
1346
- func_args = func_args or ()
1347
- func_kwargs = func_kwargs or {}
1348
-
1349
- # Use the specified scheduler or default to the first one
1350
-
1351
- scheduler = self._scheduler
1352
-
1353
- use_local_time_zone = schedule_kwargs.get("use_local_time_zone", True)
1354
- # repeat = schedule_kwargs.get("repeat", None)
1355
- # result_ttl = schedule_kwargs.get("result_ttl", None)
1356
- # ttl = schedule_kwargs.get("ttl", None)
1357
- if isinstance(result_ttl, dt.timedelta):
1358
- result_ttl = result_ttl.total_seconds()
1359
- if isinstance(ttl, dt.timedelta):
1360
- ttl = ttl.total_seconds()
1361
- if isinstance(timeout, dt.timedelta):
1362
- timeout = timeout.total_seconds()
1363
- if isinstance(interval, dt.timedelta):
1364
- interval = interval.total_seconds()
1365
-
1366
- if isinstance(on_failure, (str, Callable)):
1367
- on_failure = Callback(on_failure)
1368
- if isinstance(on_success, (str, Callable)):
1369
- on_success = Callback(on_success)
1370
- if isinstance(on_stopped, (str, Callable)):
1371
- on_stopped = Callback(on_stopped)
1372
-
1373
- if cron:
1374
- if meta:
1375
- meta.update({"cron": cron})
1376
- else:
1377
- meta = {"cron": cron}
1378
- schedule = scheduler.cron(
1379
- cron_string=cron,
1380
- func=func,
1381
- args=func_args,
1382
- kwargs=func_kwargs,
1383
- id=schedule_id,
1384
- repeat=repeat, # Infinite by default
1385
- result_ttl=int(result_ttl) if result_ttl else None,
1386
- ttl=int(ttl) if ttl else None,
1387
- timeout=int(timeout) if timeout else None,
1388
- meta=meta,
1389
- use_local_time_zone=use_local_time_zone,
1390
- queue_name=queue_name or self._scheduler_name,
1391
- on_success=on_success,
1392
- on_failure=on_failure,
1393
- **schedule_kwargs,
1394
- )
1395
- logger.info(
1396
- f"Scheduled job {schedule.id} ({func.__name__}) with cron '{get_description(cron)}'"
1397
- )
1398
-
1399
- if interval:
1400
- if meta:
1401
- meta.update({"interval": int(interval)})
1402
- else:
1403
- meta = {"interval": int(interval)}
1404
- schedule = scheduler.schedule(
1405
- scheduled_time=dt.datetime.now(dt.timezone.utc),
1406
- func=func,
1407
- args=func_args,
1408
- kwargs=func_kwargs,
1409
- interval=int(interval),
1410
- id=schedule_id,
1411
- repeat=repeat, # Infinite by default
1412
- result_ttl=int(result_ttl) if result_ttl else None,
1413
- ttl=int(ttl) if ttl else None,
1414
- timeout=int(timeout) if timeout else None,
1415
- meta=meta,
1416
- queue_name=queue_name or self._scheduler_name,
1417
- on_success=on_success,
1418
- on_failure=on_failure,
1419
- **schedule_kwargs,
1420
- )
1421
- logger.info(
1422
- f"Scheduled job {schedule.id} ({func.__name__}) with interval '{precisedelta(interval)}'"
1423
- )
1424
-
1425
- if date:
1426
- if meta:
1427
- meta.update({"date": date})
1428
- else:
1429
- meta = {"date": date}
1430
- schedule = scheduler.schedule(
1431
- scheduled_time=date,
1432
- func=func,
1433
- args=func_args,
1434
- kwargs=func_kwargs,
1435
- id=schedule_id,
1436
- repeat=1, # Infinite by default
1437
- result_ttl=int(result_ttl) if result_ttl else None,
1438
- ttl=int(ttl) if ttl else None,
1439
- timeout=int(timeout) if timeout else None,
1440
- meta=meta,
1441
- queue_name=queue_name or self._scheduler_name,
1442
- on_success=on_success,
1443
- on_failure=on_failure,
1444
- on_stopped=on_stopped,
1445
- )
1446
- logger.info(
1447
- f"Scheduled job {schedule.id} ({func.__name__}) to run at '{date}'"
1448
- )
1449
-
1450
- return schedule
1451
-
1452
- def _get_schedule_queue_name(self, schedule: str | Job) -> str | None:
1453
- """Get the queue name for a schedule.
1454
-
1455
- Args:
1456
- schedule: Schedule ID or Job object.
1457
-
1458
- Returns:
1459
- str | None: Name of the scheduler queue.
1460
- """
1461
- return self._scheduler_name
1462
-
1463
- def get_schedules(
1464
- self,
1465
- until: Any | None = None,
1466
- with_times: bool = False,
1467
- offset: Any | None = None,
1468
- length: Any | None = None,
1469
- ) -> dict[str, list[Job]]:
1470
- """Get all schedules from the scheduler.
1471
-
1472
- Args:
1473
- until: Get schedules until this time.
1474
- with_times: Include next run times in the results.
1475
- offset: Number of schedules to skip.
1476
- length: Maximum number of schedules to return.
1477
-
1478
- Returns:
1479
- dict[str, list[Job]]: Dictionary mapping queue names to lists of schedules.
1480
-
1481
- Example:
1482
- ```python
1483
- # Get all schedules
1484
- schedules = worker.get_schedules()
1485
-
1486
- # Get next 10 schedules with run times
1487
- schedules = worker.get_schedules(
1488
- with_times=True,
1489
- length=10
1490
- )
1491
- ```
1492
- """
1493
- schedules = list(
1494
- self._scheduler.get_jobs(
1495
- until=until, with_times=with_times, offset=offset, length=length
1496
- )
1497
- )
1498
- if not schedules:
1499
- logger.info("No schedules found")
1500
- return []
1501
- return schedules
1502
-
1503
- def get_schedule(self, schedule_id: str) -> Job | None:
1504
- """
1505
- Get a schedule by its ID.
1506
-
1507
- Args:
1508
- schedule_id: ID of the schedule
1509
-
1510
- Returns:
1511
- Job | None: Schedule object if found, None otherwise
1512
- """
1513
- schedule = self.get_job(job_id=schedule_id)
1514
- return schedule
1515
-
1516
- def _get_schedule_results(self, schedule: str | Job) -> list[Result]:
1517
- """Get all results from a schedule's execution history.
1518
-
1519
- Args:
1520
- schedule: Schedule ID or Job object.
1521
-
1522
- Returns:
1523
- list[Result]: List of all results from the schedule's executions.
1524
-
1525
- Raises:
1526
- ValueError: If schedule not found.
1527
- """
1528
- if isinstance(schedule, str):
1529
- schedule = self.get_schedule(schedule_id=schedule)
1530
-
1531
- if schedule is None:
1532
- logger.error(f"Schedule {schedule} not found in any queue")
1533
- return None
1534
-
1535
- return [res.return_value for res in schedule.results()]
1536
-
1537
- def get_schedule_latest_result(
1538
- self, schedule: str | Job, delete_result: bool = False
1539
- ) -> Any:
1540
- """Get the most recent result of a schedule.
1541
-
1542
- Args:
1543
- schedule: Schedule ID or Job object.
1544
- delete_result: If True, deletes the schedule and results after retrieval.
1545
-
1546
- Returns:
1547
- Any: The most recent result of the schedule if available.
1548
-
1549
- Example:
1550
- ```python
1551
- # Get latest result
1552
- result = worker.get_schedule_latest_result("schedule-123")
1553
-
1554
- # Get result and clean up
1555
- result = worker.get_schedule_latest_result(
1556
- "schedule-123",
1557
- delete_result=True
1558
- )
1559
- ```
1560
- """
1561
- result = self._get_schedule_result(schedule=schedule)[-1]
1562
-
1563
- if delete_result:
1564
- self.delete_schedule(schedule)
1565
-
1566
- return result
1567
-
1568
- def get_schedule_result(
1569
- self, schedule: str | Job, index: int | list[str] | slice | str
1570
- ) -> list[Result]:
1571
- """Get specific results from a schedule's execution history.
1572
-
1573
- Args:
1574
- schedule: Schedule ID or Job object.
1575
- index: Which results to retrieve. Can be:
1576
- - int: Specific index
1577
- - list[str]: List of indices
1578
- - slice: Range of indices
1579
- - str: "all", "latest", or "earliest"
1580
-
1581
- Returns:
1582
- list[Result]: List of requested results.
1583
-
1584
- Example:
1585
- ```python
1586
- # Get all results
1587
- results = worker.get_schedule_result("schedule-123", "all")
1588
-
1589
- # Get latest result
1590
- result = worker.get_schedule_result("schedule-123", "latest")
1591
-
1592
- # Get specific results
1593
- results = worker.get_schedule_result("schedule-123", [0, 2, 4])
1594
-
1595
- # Get range of results
1596
- results = worker.get_schedule_result("schedule-123", slice(0, 5))
1597
- ```
1598
- """
1599
- results = self._get_schedule_results(schedule=schedule)
1600
- if not results:
1601
- return []
1602
-
1603
- if isinstance(index, str):
1604
- if ":" in index:
1605
- index = [int(i) for i in index.split(":")]
1606
- index = slice(index[0], index[1])
1607
- return [result for result in results[index]]
1608
-
1609
- if index == "all":
1610
- return [result for result in results]
1611
- if index == "latest":
1612
- return results[-1]
1613
- if index == "earliest":
1614
- return results[0]
1615
-
1616
- elif isinstance(index, list):
1617
- return [results[i].return_value for i in index if i < len(results)]
1618
- elif isinstance(index, slice):
1619
- return [result.return_value for result in results[index]]
1620
- elif isinstance(index, int):
1621
- if index >= len(results):
1622
- logger.error(f"Index {index} out of range for schedule {schedule.id}")
1623
- return []
1624
- return results[index].return_value
1625
-
1626
- def cancel_schedule(self, schedule: str | Job) -> bool:
1627
- """Cancel a schedule.
1628
-
1629
- This method stops any future executions of the schedule without removing
1630
- past results.
1631
-
1632
- Args:
1633
- schedule: Schedule ID or Job object to cancel.
1634
-
1635
- Returns:
1636
- bool: True if successfully canceled, False if schedule not found.
1637
-
1638
- Example:
1639
- ```python
1640
- # Cancel by ID
1641
- worker.cancel_schedule("schedule-123")
1642
-
1643
- # Cancel using job object
1644
- schedule = worker.get_schedule("schedule-123")
1645
- if schedule:
1646
- worker.cancel_schedule(schedule)
1647
- ```
1648
- """
1649
- if schedule is None:
1650
- logger.error(f"Schedule {schedule} not found")
1651
- return False
1652
-
1653
- self._scheduler.cancel(schedule)
1654
- logger.info(
1655
- f"Canceled schedule {schedule.id if isinstance(schedule, Job) else schedule}"
1656
- )
1657
- return True
1658
-
1659
- def cancel_all_schedules(self) -> None:
1660
- """Cancel all schedules in the scheduler.
1661
-
1662
- This method stops all future executions of all schedules without removing
1663
- past results.
1664
-
1665
- Example:
1666
- ```python
1667
- # Stop all scheduled jobs
1668
- worker.cancel_all_schedules()
1669
- ```
1670
- """
1671
- for job in self._scheduler.get_jobs():
1672
- self._scheduler.cancel(job.id)
1673
- logger.info(f"Canceled schedule {job.id} ")
1674
- logger.info("Canceled all schedules from all queues.")
1675
-
1676
- def delete_schedule(self, schedule: str | Job) -> bool:
1677
- """Delete a schedule and optionally its results.
1678
-
1679
- This method removes the schedule and optionally its execution history
1680
- from Redis.
1681
-
1682
- Args:
1683
- schedule: Schedule ID or Job object to delete.
1684
-
1685
- Returns:
1686
- bool: True if successfully deleted, False if schedule not found.
1687
-
1688
- Example:
1689
- ```python
1690
- # Delete schedule and its history
1691
- worker.delete_schedule("schedule-123")
1692
- ```
1693
- """
1694
- return self.delete_job(schedule)
1695
-
1696
- def delete_all_schedules(self) -> None:
1697
- """Delete all schedules and their results.
1698
-
1699
- This method removes all schedules and their execution histories from Redis.
1700
-
1701
- Example:
1702
- ```python
1703
- # Remove all schedules and their histories
1704
- worker.delete_all_schedules()
1705
- ```
1706
- """
1707
- for schedule in self.schedule_ids:
1708
- self.delete_schedule(schedule)
1709
- logger.info(f"Deleted schedule {schedule}")
1710
- logger.info("Deleted all schedules from all queues.")
1711
-
1712
- @property
1713
- def schedules(self):
1714
- """Get all schedules from all schedulers.
1715
-
1716
- Returns:
1717
- list[Job]: List of all scheduled jobs.
1718
-
1719
- Example:
1720
- ```python
1721
- all_schedules = worker.schedules
1722
- print(f"Total schedules: {len(all_schedules)}")
1723
- ```
1724
- """
1725
- schedules = self.get_schedules()
1726
-
1727
- return schedules
1728
-
1729
- @property
1730
- def schedule_ids(self):
1731
- """Get all schedule IDs.
1732
-
1733
- Returns:
1734
- list[str]: List of unique identifiers for all schedules.
1735
-
1736
- Example:
1737
- ```python
1738
- ids = worker.schedule_ids
1739
- print(f"Schedule IDs: {', '.join(ids)}")
1740
- ```
1741
- """
1742
- schedule_ids = [schedule.id for schedule in self.schedules]
1743
- return schedule_ids
1744
-
1745
- # --- Pipeline-specific high-level methods implementation ---
1746
-
1747
- def schedule_pipeline(self, name: str, project_context=None, *args, **kwargs):
1748
- """Schedule a pipeline for execution using its name.
1749
-
1750
- This high-level method loads the pipeline from the internal registry and schedules
1751
- its execution with the job queue using the existing add_schedule method.
1752
-
1753
- Args:
1754
- name: Name of the pipeline to schedule
1755
- project_context: Project context for the pipeline (optional)
1756
- *args: Additional positional arguments for scheduling
1757
- **kwargs: Additional keyword arguments for scheduling
1758
-
1759
- Returns:
1760
- Schedule ID from the underlying add_schedule call
1761
-
1762
- Example:
1763
- ```python
1764
- manager = RQManager(base_dir="/path/to/project")
1765
- schedule_id = manager.schedule_pipeline(
1766
- "my_pipeline",
1767
- cron="0 9 * * *", # Run daily at 9 AM
1768
- inputs={"date": "today"}
1769
- )
1770
- ```
1771
- """
1772
- logger.info(f"Scheduling pipeline '{name}' via RQ job queue")
1773
-
1774
- # Create a function that will be executed by the job queue
1775
- def pipeline_job(*job_args, **job_kwargs):
1776
- # Get the pipeline instance
1777
- pipeline = self.pipeline_registry.get_pipeline(
1778
- name=name,
1779
- project_context=project_context,
1780
- reload=job_kwargs.pop("reload", False),
1781
- )
1782
-
1783
- # Execute the pipeline
1784
- return pipeline.run(*job_args, **job_kwargs)
1785
-
1786
- # Extract pipeline execution arguments from kwargs
1787
- pipeline_kwargs = {
1788
- k: v
1789
- for k, v in kwargs.items()
1790
- if k
1791
- in [
1792
- "inputs",
1793
- "final_vars",
1794
- "config",
1795
- "cache",
1796
- "executor_cfg",
1797
- "with_adapter_cfg",
1798
- "pipeline_adapter_cfg",
1799
- "project_adapter_cfg",
1800
- "adapter",
1801
- "reload",
1802
- "log_level",
1803
- "max_retries",
1804
- "retry_delay",
1805
- "jitter_factor",
1806
- "retry_exceptions",
1807
- "on_success",
1808
- "on_failure",
1809
- ]
1810
- }
1811
-
1812
- # Extract scheduling arguments
1813
- schedule_kwargs = {k: v for k, v in kwargs.items() if k not in pipeline_kwargs}
1814
-
1815
- # Schedule the job
1816
- return self.add_schedule(
1817
- func=pipeline_job, func_kwargs=pipeline_kwargs, **schedule_kwargs
1818
- )
1819
-
1820
- def enqueue_pipeline(self, name: str, project_context=None, *args, **kwargs):
1821
- """Enqueue a pipeline for immediate execution using its name.
1822
-
1823
- This high-level method loads the pipeline from the internal registry and enqueues
1824
- it for immediate execution in the job queue using the existing enqueue method.
1825
-
1826
- Args:
1827
- name: Name of the pipeline to enqueue
1828
- project_context: Project context for the pipeline (optional)
1829
- *args: Additional positional arguments for job execution
1830
- **kwargs: Additional keyword arguments for job execution
1831
-
1832
- Returns:
1833
- Job ID from the underlying enqueue call
1834
-
1835
- Example:
1836
- ```python
1837
- manager = RQManager(base_dir="/path/to/project")
1838
- job_id = manager.enqueue_pipeline(
1839
- "my_pipeline",
1840
- inputs={"date": "2025-01-01"},
1841
- final_vars=["result"]
1842
- )
1843
- ```
1844
- """
1845
- logger.info(
1846
- f"Enqueueing pipeline '{name}' for immediate execution via RQ job queue"
1847
- )
1848
-
1849
- # Create a function that will be executed by the job queue
1850
- def pipeline_job(*job_args, **job_kwargs):
1851
- # Get the pipeline instance
1852
- pipeline = self.pipeline_registry.get_pipeline(
1853
- name=name,
1854
- project_context=project_context,
1855
- reload=job_kwargs.pop("reload", False),
1856
- )
1857
-
1858
- # Execute the pipeline
1859
- return pipeline.run(*job_args, **job_kwargs)
1860
-
1861
- # Extract pipeline execution arguments from kwargs
1862
- pipeline_kwargs = {
1863
- k: v
1864
- for k, v in kwargs.items()
1865
- if k
1866
- in [
1867
- "inputs",
1868
- "final_vars",
1869
- "config",
1870
- "cache",
1871
- "executor_cfg",
1872
- "with_adapter_cfg",
1873
- "pipeline_adapter_cfg",
1874
- "project_adapter_cfg",
1875
- "adapter",
1876
- "reload",
1877
- "log_level",
1878
- "max_retries",
1879
- "retry_delay",
1880
- "jitter_factor",
1881
- "retry_exceptions",
1882
- "on_success",
1883
- "on_failure",
1884
- ]
1885
- }
1886
-
1887
- # Extract job queue arguments
1888
- job_kwargs = {k: v for k, v in kwargs.items() if k not in pipeline_kwargs}
1889
-
1890
- # Add the job
1891
- return self.enqueue(
1892
- func=pipeline_job, func_kwargs=pipeline_kwargs, *args, **job_kwargs
1893
- )