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 +14 -0
- rrq/cli.py +303 -29
- rrq/cron.py +154 -0
- rrq/settings.py +5 -0
- rrq/store.py +122 -2
- rrq/worker.py +238 -133
- {rrq-0.3.7.dist-info → rrq-0.5.0.dist-info}/METADATA +125 -7
- rrq-0.5.0.dist-info/RECORD +16 -0
- rrq-0.3.7.dist-info/RECORD +0 -15
- {rrq-0.3.7.dist-info → rrq-0.5.0.dist-info}/WHEEL +0 -0
- {rrq-0.3.7.dist-info → rrq-0.5.0.dist-info}/entry_points.txt +0 -0
- {rrq-0.3.7.dist-info → rrq-0.5.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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=
|
|
257
|
+
process.wait(timeout=10)
|
|
223
258
|
except subprocess.TimeoutExpired:
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
410
|
+
"""Run RRQ worker processes.
|
|
347
411
|
Requires an application-specific settings object.
|
|
348
412
|
"""
|
|
349
|
-
|
|
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
|
-
#
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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",
|