rrq 0.3.7__py3-none-any.whl → 0.5.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.
rrq/__init__.py CHANGED
@@ -0,0 +1,14 @@
1
+ from .cron import CronJob, CronSchedule
2
+ from .worker import RRQWorker
3
+ from .client import RRQClient
4
+ from .registry import JobRegistry
5
+ from .settings import RRQSettings
6
+
7
+ __all__ = [
8
+ "CronJob",
9
+ "CronSchedule",
10
+ "RRQWorker",
11
+ "RRQClient",
12
+ "JobRegistry",
13
+ "RRQSettings",
14
+ ]
rrq/cli.py CHANGED
@@ -7,6 +7,9 @@ import os
7
7
  import signal
8
8
  import subprocess
9
9
  import sys
10
+ import time
11
+
12
+ # import multiprocessing # No longer needed directly, os.cpu_count() is sufficient
10
13
  from contextlib import suppress
11
14
 
12
15
  import click
@@ -30,6 +33,29 @@ logger = logging.getLogger(__name__)
30
33
 
31
34
 
32
35
  # Helper to load settings for commands
36
+ def _resolve_settings_source(
37
+ settings_object_path: str | None = None,
38
+ ) -> tuple[str | None, str]:
39
+ """Resolve the settings path and its source.
40
+
41
+ Returns:
42
+ A tuple of (settings_path, source_description)
43
+ """
44
+ if settings_object_path is not None:
45
+ return settings_object_path, "--settings parameter"
46
+
47
+ env_setting = os.getenv("RRQ_SETTINGS")
48
+ if env_setting is not None:
49
+ # Check if a .env file exists to give more specific info
50
+ if DOTENV_AVAILABLE and find_dotenv(usecwd=True):
51
+ # We can't definitively know if it came from .env or system env,
52
+ # but we can indicate both are possible
53
+ return env_setting, "RRQ_SETTINGS env var (system or .env)"
54
+ return env_setting, "RRQ_SETTINGS env var"
55
+
56
+ return None, "built-in defaults"
57
+
58
+
33
59
  def _load_app_settings(settings_object_path: str | None = None) -> RRQSettings:
34
60
  """Load the settings object from the given path.
35
61
  If not provided, the RRQ_SETTINGS environment variable will be used.
@@ -142,7 +168,12 @@ async def check_health_async_impl(settings_object_path: str | None = None) -> bo
142
168
  )
143
169
  return True
144
170
  except redis.exceptions.ConnectionError as e:
145
- logger.error(f"Redis connection failed during health check: {e}", exc_info=True)
171
+ click.echo(
172
+ click.style(
173
+ f"ERROR: Redis connection failed during health check: {e}", fg="red"
174
+ ),
175
+ err=True,
176
+ )
146
177
  click.echo(
147
178
  click.style(
148
179
  f"Worker Health Check: FAIL - Redis connection error: {e}", fg="red"
@@ -150,8 +181,12 @@ async def check_health_async_impl(settings_object_path: str | None = None) -> bo
150
181
  )
151
182
  return False
152
183
  except Exception as e:
153
- logger.error(
154
- f"An unexpected error occurred during health check: {e}", exc_info=True
184
+ click.echo(
185
+ click.style(
186
+ f"ERROR: An unexpected error occurred during health check: {e}",
187
+ fg="red",
188
+ ),
189
+ err=True,
155
190
  )
156
191
  click.echo(
157
192
  click.style(f"Worker Health Check: FAIL - Unexpected error: {e}", fg="red")
@@ -169,7 +204,7 @@ def start_rrq_worker_subprocess(
169
204
  queues: list[str] | None = None,
170
205
  ) -> subprocess.Popen | None:
171
206
  """Start an RRQ worker process, optionally for specific queues."""
172
- command = ["rrq", "worker", "run"]
207
+ command = ["rrq", "worker", "run", "--num-workers", "1"]
173
208
 
174
209
  if settings_object_path:
175
210
  command.extend(["--settings", settings_object_path])
@@ -219,15 +254,25 @@ def terminate_worker_process(
219
254
  f"Terminating worker process group for PID {process.pid} (PGID {pgid})..."
220
255
  )
221
256
  os.killpg(pgid, signal.SIGTERM)
222
- process.wait(timeout=5)
257
+ process.wait(timeout=10)
223
258
  except subprocess.TimeoutExpired:
224
- logger.warning(
225
- f"Worker process {process.pid} did not terminate gracefully (SIGTERM timeout), sending SIGKILL."
259
+ click.echo(
260
+ click.style(
261
+ f"WARNING: Worker process {process.pid} did not terminate gracefully (SIGTERM timeout), sending SIGKILL.",
262
+ fg="yellow",
263
+ ),
264
+ err=True,
226
265
  )
227
266
  with suppress(ProcessLookupError):
228
267
  os.killpg(os.getpgid(process.pid), signal.SIGKILL)
229
268
  except Exception as e:
230
- logger.error(f"Unexpected error checking worker process {process.pid}: {e}")
269
+ click.echo(
270
+ click.style(
271
+ f"ERROR: Unexpected error checking worker process {process.pid}: {e}",
272
+ fg="red",
273
+ ),
274
+ err=True,
275
+ )
231
276
 
232
277
 
233
278
  async def watch_rrq_worker_impl(
@@ -236,9 +281,19 @@ async def watch_rrq_worker_impl(
236
281
  queues: list[str] | None = None,
237
282
  ) -> None:
238
283
  abs_watch_path = os.path.abspath(watch_path)
239
- click.echo(
240
- f"Watching for file changes in {abs_watch_path} to restart RRQ worker (app settings: {settings_object_path})..."
241
- )
284
+ click.echo(f"Watching for file changes in {abs_watch_path}...")
285
+
286
+ # Load settings and display source
287
+ click.echo("Loading RRQ Settings... ", nl=False)
288
+
289
+ if settings_object_path:
290
+ click.echo(f"from --settings parameter ({settings_object_path}).")
291
+ elif os.getenv("RRQ_SETTINGS"):
292
+ click.echo(f"from RRQ_SETTINGS env var ({os.getenv('RRQ_SETTINGS')}).")
293
+ elif DOTENV_AVAILABLE and find_dotenv(usecwd=True):
294
+ click.echo("found in .env file.")
295
+ else:
296
+ click.echo("using defaults.")
242
297
  worker_process: subprocess.Popen | None = None
243
298
  loop = asyncio.get_event_loop()
244
299
  shutdown_event = asyncio.Event()
@@ -278,7 +333,9 @@ async def watch_rrq_worker_impl(
278
333
  queues=queues,
279
334
  )
280
335
  except Exception as e:
281
- logger.error(f"Error in watch_rrq_worker: {e}", exc_info=True)
336
+ click.echo(
337
+ click.style(f"ERROR: Error in watch_rrq_worker: {e}", fg="red"), err=True
338
+ )
282
339
  finally:
283
340
  logger.info("Exiting watch mode. Ensuring worker process is terminated.")
284
341
  if not shutdown_event.is_set():
@@ -338,28 +395,85 @@ def worker_cli():
338
395
  "The specified settings object must include a `job_registry: JobRegistry`."
339
396
  ),
340
397
  )
398
+ @click.option(
399
+ "--num-workers",
400
+ type=int,
401
+ default=None,
402
+ help="Number of parallel worker processes to start. Defaults to the number of CPU cores.",
403
+ )
341
404
  def worker_run_command(
342
405
  burst: bool,
343
406
  queues: tuple[str, ...],
344
407
  settings_object_path: str,
408
+ num_workers: int | None,
345
409
  ):
346
- """Run an RRQ worker process.
410
+ """Run RRQ worker processes.
347
411
  Requires an application-specific settings object.
348
412
  """
349
- rrq_settings = _load_app_settings(settings_object_path)
413
+ if num_workers is None:
414
+ num_workers = (
415
+ os.cpu_count() or 1
416
+ ) # Default to CPU cores, or 1 if cpu_count() is None
417
+ click.echo(
418
+ f"No --num-workers specified, defaulting to {num_workers} (CPU cores)."
419
+ )
420
+ elif num_workers <= 0:
421
+ click.echo(
422
+ click.style("ERROR: --num-workers must be a positive integer.", fg="red"),
423
+ err=True,
424
+ )
425
+ sys.exit(1)
350
426
 
351
- # Determine queues to poll
352
- queues_arg = list(queues) if queues else None
353
- # Run worker in foreground (burst or continuous mode)
427
+ # Restrict burst mode with multiple workers
428
+ if num_workers > 1 and burst:
429
+ click.echo(
430
+ click.style(
431
+ "ERROR: --burst mode is not supported with multiple workers (--num-workers > 1). "
432
+ "Burst mode cannot coordinate across multiple processes.",
433
+ fg="red",
434
+ ),
435
+ err=True,
436
+ )
437
+ sys.exit(1)
354
438
 
355
- logger.info(
356
- f"Starting RRQ Worker (Burst: {burst}, App Settings: {settings_object_path})"
357
- )
439
+ # Display settings source
440
+ click.echo("Loading RRQ Settings... ", nl=False)
441
+ if settings_object_path:
442
+ click.echo(f"from --settings parameter ({settings_object_path}).")
443
+ elif os.getenv("RRQ_SETTINGS"):
444
+ click.echo(f"from RRQ_SETTINGS env var ({os.getenv('RRQ_SETTINGS')}).")
445
+ elif DOTENV_AVAILABLE and find_dotenv(usecwd=True):
446
+ click.echo("found in .env file.")
447
+ else:
448
+ click.echo("using defaults.")
449
+
450
+ if num_workers == 1:
451
+ # Run a single worker in the current process
452
+ click.echo(f"Starting 1 RRQ worker process (Burst: {burst})")
453
+ _run_single_worker(
454
+ burst, list(queues) if queues else None, settings_object_path
455
+ )
456
+ else:
457
+ # Run multiple worker subprocesses
458
+ click.echo(f"Starting {num_workers} RRQ worker processes")
459
+ # Burst is guaranteed to be False here
460
+ _run_multiple_workers(
461
+ num_workers, list(queues) if queues else None, settings_object_path
462
+ )
463
+
464
+
465
+ def _run_single_worker(
466
+ burst: bool,
467
+ queues_arg: list[str] | None,
468
+ settings_object_path: str | None,
469
+ ):
470
+ """Helper function to run a single RRQ worker instance."""
471
+ rrq_settings = _load_app_settings(settings_object_path)
358
472
 
359
473
  if not rrq_settings.job_registry:
360
474
  click.echo(
361
475
  click.style(
362
- "ERROR: No 'job_registry_app'. You must provide a JobRegistry instance in settings.",
476
+ "ERROR: No 'job_registry'. You must provide a JobRegistry instance in settings.",
363
477
  fg="red",
364
478
  ),
365
479
  err=True,
@@ -378,22 +492,182 @@ def worker_run_command(
378
492
  burst=burst,
379
493
  )
380
494
 
381
- loop = asyncio.get_event_loop()
382
495
  try:
383
- logger.info("Starting worker run loop...")
384
- loop.run_until_complete(worker_instance.run())
496
+ logger.info("Starting worker run loop for single worker...")
497
+ asyncio.run(worker_instance.run())
385
498
  except KeyboardInterrupt:
386
499
  logger.info("RRQ Worker run interrupted by user (KeyboardInterrupt).")
387
500
  except Exception as e:
388
- logger.error(f"Exception during RRQ Worker run: {e}", exc_info=True)
501
+ click.echo(
502
+ click.style(f"ERROR: Exception during RRQ Worker run: {e}", fg="red"),
503
+ err=True,
504
+ )
505
+ # Consider re-raising or sys.exit(1) if the exception means failure
389
506
  finally:
390
- logger.info("RRQ Worker run finished or exited. Cleaning up event loop.")
391
- if loop.is_running():
392
- loop.run_until_complete(loop.shutdown_asyncgens())
393
- loop.close()
507
+ # asyncio.run handles loop cleanup.
508
+ logger.info("RRQ Worker run finished or exited.")
394
509
  logger.info("RRQ Worker has shut down.")
395
510
 
396
511
 
512
+ def _run_multiple_workers(
513
+ num_workers: int,
514
+ queues: list[str] | None,
515
+ settings_object_path: str | None,
516
+ ):
517
+ """Manages multiple worker subprocesses."""
518
+ processes: list[subprocess.Popen] = []
519
+ # loop = asyncio.get_event_loop() # Not needed here, this function is synchronous
520
+
521
+ original_sigint_handler = signal.getsignal(signal.SIGINT)
522
+ original_sigterm_handler = signal.getsignal(signal.SIGTERM)
523
+
524
+ def sig_handler(signum, frame):
525
+ click.echo(
526
+ f"\nSignal {signal.Signals(signum).name} received. Terminating child workers..."
527
+ )
528
+ # Send SIGTERM to all processes
529
+ for i, p in enumerate(processes):
530
+ if p.poll() is None: # Process is still running
531
+ try:
532
+ pgid = os.getpgid(p.pid)
533
+ click.echo(f"Sending SIGTERM to worker {i + 1} (PID {p.pid})...")
534
+ os.killpg(pgid, signal.SIGTERM)
535
+ except (ProcessLookupError, OSError):
536
+ pass # Process already dead
537
+ # Restore original handlers before exiting or re-raising
538
+ signal.signal(signal.SIGINT, original_sigint_handler)
539
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
540
+ # Propagate signal to ensure parent exits if it was, e.g., a Ctrl+C
541
+ # This is a bit tricky; for now, just exit.
542
+ # A more robust way might involve re-raising the signal if not handled by click/asyncio.
543
+ sys.exit(0)
544
+
545
+ signal.signal(signal.SIGINT, sig_handler)
546
+ signal.signal(signal.SIGTERM, sig_handler)
547
+
548
+ try:
549
+ for i in range(num_workers):
550
+ # Construct the command for the subprocess.
551
+ # Each subprocess runs 'rrq worker run' for a single worker.
552
+ # We pass along relevant flags like --settings, --queue, and --burst.
553
+ # Crucially, we do *not* pass --num-workers to the child,
554
+ # or rather, we could conceptually pass --num-workers 1.
555
+ # Use the rrq executable from the same venv
556
+ venv_bin_dir = os.path.dirname(sys.executable)
557
+ rrq_executable = os.path.join(venv_bin_dir, "rrq")
558
+ cmd = [rrq_executable, "worker", "run", "--num-workers=1"]
559
+ if settings_object_path:
560
+ cmd.extend(["--settings", settings_object_path])
561
+ elif os.getenv("RRQ_SETTINGS"):
562
+ # Pass the RRQ_SETTINGS env var as explicit parameter to subprocess
563
+ cmd.extend(["--settings", os.getenv("RRQ_SETTINGS")])
564
+ else:
565
+ # Default to app.config.rrq.rrq_settings for ResQ
566
+ cmd.extend(["--settings", "app.config.rrq.rrq_settings"])
567
+ if queues:
568
+ for q_name in queues:
569
+ cmd.extend(["--queue", q_name])
570
+ click.echo(f"Starting worker subprocess {i + 1}/{num_workers}...")
571
+
572
+ # Set up environment - add current directory to PYTHONPATH
573
+ env = os.environ.copy()
574
+ current_pythonpath = env.get("PYTHONPATH", "")
575
+ current_dir = os.getcwd()
576
+ if current_pythonpath:
577
+ env["PYTHONPATH"] = f"{current_dir}:{current_pythonpath}"
578
+ else:
579
+ env["PYTHONPATH"] = current_dir
580
+
581
+ # Configure output redirection
582
+ is_testing = "PYTEST_CURRENT_TEST" in os.environ
583
+ stdout_dest = None if not is_testing else subprocess.DEVNULL
584
+ stderr_dest = None if not is_testing else subprocess.DEVNULL
585
+
586
+ process = subprocess.Popen(
587
+ cmd,
588
+ start_new_session=True,
589
+ stdout=stdout_dest,
590
+ stderr=stderr_dest,
591
+ cwd=os.getcwd(),
592
+ env=env,
593
+ )
594
+ processes.append(process)
595
+ click.echo(f"Worker subprocess {i + 1} started with PID {process.pid}")
596
+
597
+ # Wait for all processes to complete
598
+ click.echo(f"All {num_workers} workers started. Press Ctrl+C to stop.")
599
+ exit_codes = []
600
+
601
+ try:
602
+ for p in processes:
603
+ exit_code = p.wait()
604
+ exit_codes.append(exit_code)
605
+ except KeyboardInterrupt:
606
+ # Signal handler has already sent SIGTERM, now wait with timeout
607
+ max_wait = 10
608
+ check_interval = 0.1
609
+ elapsed = 0
610
+
611
+ while elapsed < max_wait:
612
+ time.sleep(check_interval)
613
+ elapsed += check_interval
614
+
615
+ # Check if all processes have terminated
616
+ all_terminated = all(p.poll() is not None for p in processes)
617
+ if all_terminated:
618
+ click.echo("All workers terminated gracefully.")
619
+ break
620
+ else:
621
+ # Timeout reached, force kill remaining processes
622
+ for i, p in enumerate(processes):
623
+ if p.poll() is None:
624
+ try:
625
+ click.echo(
626
+ click.style(
627
+ f"WARNING: Worker {i + 1} did not terminate gracefully, sending SIGKILL.",
628
+ fg="yellow",
629
+ ),
630
+ err=True,
631
+ )
632
+ os.killpg(os.getpgid(p.pid), signal.SIGKILL)
633
+ except (ProcessLookupError, OSError):
634
+ pass
635
+
636
+ # Collect exit codes
637
+ for p in processes:
638
+ exit_codes.append(p.wait())
639
+
640
+ # Report results
641
+ for i, exit_code in enumerate(exit_codes):
642
+ click.echo(f"Worker subprocess {i + 1} exited with code {exit_code}")
643
+ if exit_code != 0:
644
+ click.echo(
645
+ click.style(
646
+ f"Worker subprocess {i + 1} failed with exit code {exit_code}",
647
+ fg="red",
648
+ ),
649
+ err=True,
650
+ )
651
+
652
+ except Exception as e:
653
+ click.echo(
654
+ click.style(f"ERROR: Error managing worker subprocesses: {e}", fg="red"),
655
+ err=True,
656
+ )
657
+ # Terminate any running processes if an error occurs in the manager
658
+ for p in processes:
659
+ if p.poll() is None: # If process is still running
660
+ terminate_worker_process(p, logger)
661
+ finally:
662
+ logger.info("All worker subprocesses terminated or completed.")
663
+ # Restore original signal handlers
664
+ signal.signal(signal.SIGINT, original_sigint_handler)
665
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
666
+ # Any other cleanup for the parent process
667
+ # No loop to check or close here as this part is synchronous
668
+ logger.info("Parent process for multi-worker management is exiting.")
669
+
670
+
397
671
  @worker_cli.command("watch")
398
672
  @click.option(
399
673
  "--path",
rrq/cron.py ADDED
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Any, Optional, Sequence
5
+
6
+ from pydantic import BaseModel, Field, PrivateAttr
7
+
8
+ MONTH_NAMES = {
9
+ "jan": 1,
10
+ "feb": 2,
11
+ "mar": 3,
12
+ "apr": 4,
13
+ "may": 5,
14
+ "jun": 6,
15
+ "jul": 7,
16
+ "aug": 8,
17
+ "sep": 9,
18
+ "oct": 10,
19
+ "nov": 11,
20
+ "dec": 12,
21
+ }
22
+
23
+ WEEKDAY_NAMES = {
24
+ "sun": 0,
25
+ "mon": 1,
26
+ "tue": 2,
27
+ "wed": 3,
28
+ "thu": 4,
29
+ "fri": 5,
30
+ "sat": 6,
31
+ }
32
+
33
+
34
+ def _parse_value(value: str, names: dict[str, int], min_val: int, max_val: int) -> int:
35
+ if value.lower() in names:
36
+ return names[value.lower()]
37
+ num = int(value)
38
+ if names is WEEKDAY_NAMES and num == 7:
39
+ num = 0
40
+ if not (min_val <= num <= max_val):
41
+ raise ValueError(f"value {num} out of range {min_val}-{max_val}")
42
+ return num
43
+
44
+
45
+ def _parse_field(
46
+ field: str, *, names: dict[str, int] | None, min_val: int, max_val: int
47
+ ) -> Sequence[int]:
48
+ names = names or {}
49
+ if field == "*":
50
+ return list(range(min_val, max_val + 1))
51
+ values: set[int] = set()
52
+ for part in field.split(","):
53
+ step = 1
54
+ if "/" in part:
55
+ base, step_str = part.split("/", 1)
56
+ step = int(step_str)
57
+ else:
58
+ base = part
59
+ if base == "*":
60
+ start, end = min_val, max_val
61
+ elif "-" in base:
62
+ a, b = base.split("-", 1)
63
+ start = _parse_value(a, names, min_val, max_val)
64
+ end = _parse_value(b, names, min_val, max_val)
65
+ else:
66
+ val = _parse_value(base, names, min_val, max_val)
67
+ start = end = val
68
+ if start > end:
69
+ raise ValueError(f"invalid range {base}")
70
+ for v in range(start, end + 1, step):
71
+ values.add(v)
72
+ return sorted(values)
73
+
74
+
75
+ class CronSchedule:
76
+ """Represents a cron schedule expression."""
77
+
78
+ def __init__(self, expression: str) -> None:
79
+ fields = expression.split()
80
+ if len(fields) != 5:
81
+ raise ValueError("Cron expression must have 5 fields")
82
+ minute, hour, dom, month, dow = fields
83
+ self.minutes = _parse_field(minute, names=None, min_val=0, max_val=59)
84
+ self.hours = _parse_field(hour, names=None, min_val=0, max_val=23)
85
+ self.dom = _parse_field(dom, names=None, min_val=1, max_val=31)
86
+ self.months = _parse_field(month, names=MONTH_NAMES, min_val=1, max_val=12)
87
+ self.dow = _parse_field(dow, names=WEEKDAY_NAMES, min_val=0, max_val=6)
88
+ self.dom_all = dom == "*"
89
+ self.dow_all = dow == "*"
90
+
91
+ def next_after(self, dt: datetime) -> datetime:
92
+ dt = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
93
+ while True:
94
+ if dt.month not in self.months:
95
+ dt += timedelta(minutes=1)
96
+ continue
97
+ if dt.hour not in self.hours or dt.minute not in self.minutes:
98
+ dt += timedelta(minutes=1)
99
+ continue
100
+ dom_match = dt.day in self.dom
101
+ # Convert Python weekday (Monday=0) to cron weekday (Sunday=0)
102
+ # Python: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
103
+ # Cron: Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6
104
+ python_weekday = dt.weekday()
105
+ cron_weekday = (python_weekday + 1) % 7
106
+ dow_match = cron_weekday in self.dow
107
+
108
+ if self.dom_all and self.dow_all:
109
+ condition = True
110
+ elif self.dom_all:
111
+ # Only day-of-week constraint
112
+ condition = dow_match
113
+ elif self.dow_all:
114
+ # Only day-of-month constraint
115
+ condition = dom_match
116
+ else:
117
+ # Both constraints specified - use OR logic (standard cron behavior)
118
+ condition = dom_match or dow_match
119
+ if condition:
120
+ return dt
121
+ dt += timedelta(minutes=1)
122
+
123
+
124
+ class CronJob(BaseModel):
125
+ """Simple cron job specification based on a cron schedule."""
126
+
127
+ function_name: str
128
+ schedule: str = Field(
129
+ description="Cron expression 'm h dom mon dow'. Resolution is one minute."
130
+ )
131
+ args: list[Any] = Field(default_factory=list)
132
+ kwargs: dict[str, Any] = Field(default_factory=dict)
133
+ queue_name: Optional[str] = None
134
+ unique: bool = False
135
+
136
+ # Next run time and parsed schedule are maintained at runtime
137
+ next_run_time: Optional[datetime] = Field(default=None, exclude=True)
138
+ _cron: CronSchedule | None = PrivateAttr(default=None)
139
+
140
+ def model_post_init(self, __context: Any) -> None: # type: ignore[override]
141
+ self._cron = CronSchedule(self.schedule)
142
+
143
+ def schedule_next(self, now: Optional[datetime] = None) -> None:
144
+ """Compute the next run time strictly after *now*."""
145
+ now = (now or datetime.now(UTC)).replace(second=0, microsecond=0)
146
+ if self._cron is None:
147
+ self._cron = CronSchedule(self.schedule)
148
+ self.next_run_time = self._cron.next_after(now)
149
+
150
+ def due(self, now: Optional[datetime] = None) -> bool:
151
+ now = now or datetime.now(UTC)
152
+ if self.next_run_time is None:
153
+ self.schedule_next(now)
154
+ return now >= (self.next_run_time or now)
rrq/settings.py CHANGED
@@ -21,6 +21,7 @@ from .constants import (
21
21
  DEFAULT_UNIQUE_JOB_LOCK_TTL_SECONDS,
22
22
  )
23
23
  from .registry import JobRegistry
24
+ from .cron import CronJob
24
25
 
25
26
 
26
27
  class RRQSettings(BaseSettings):
@@ -97,6 +98,10 @@ class RRQSettings(BaseSettings):
97
98
  default=None,
98
99
  description="Job registry instance, typically provided by the application.",
99
100
  )
101
+ cron_jobs: list[CronJob] = Field(
102
+ default_factory=list,
103
+ description="Optional list of cron job specifications to run periodically.",
104
+ )
100
105
  model_config = SettingsConfigDict(
101
106
  env_prefix="RRQ_",
102
107
  extra="ignore",