rrq 0.4.0__py3-none-any.whl → 0.7.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/cli.py +340 -91
- rrq/cli_commands/__init__.py +1 -0
- rrq/cli_commands/base.py +102 -0
- rrq/cli_commands/commands/__init__.py +1 -0
- rrq/cli_commands/commands/debug.py +551 -0
- rrq/cli_commands/commands/dlq.py +853 -0
- rrq/cli_commands/commands/jobs.py +516 -0
- rrq/cli_commands/commands/monitor.py +776 -0
- rrq/cli_commands/commands/queues.py +539 -0
- rrq/cli_commands/utils.py +161 -0
- rrq/client.py +39 -35
- rrq/constants.py +10 -0
- rrq/cron.py +75 -15
- rrq/hooks.py +217 -0
- rrq/job.py +5 -5
- rrq/registry.py +0 -3
- rrq/settings.py +13 -1
- rrq/store.py +333 -55
- rrq/worker.py +199 -139
- {rrq-0.4.0.dist-info → rrq-0.7.0.dist-info}/METADATA +208 -24
- rrq-0.7.0.dist-info/RECORD +26 -0
- rrq-0.4.0.dist-info/RECORD +0 -16
- {rrq-0.4.0.dist-info → rrq-0.7.0.dist-info}/WHEEL +0 -0
- {rrq-0.4.0.dist-info → rrq-0.7.0.dist-info}/entry_points.txt +0 -0
- {rrq-0.4.0.dist-info → rrq-0.7.0.dist-info}/licenses/LICENSE +0 -0
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():
|
|
@@ -306,6 +363,38 @@ def rrq():
|
|
|
306
363
|
pass
|
|
307
364
|
|
|
308
365
|
|
|
366
|
+
# Register modular commands
|
|
367
|
+
try:
|
|
368
|
+
# Import new command classes
|
|
369
|
+
from .cli_commands.commands.queues import QueueCommands
|
|
370
|
+
from .cli_commands.commands.jobs import JobCommands
|
|
371
|
+
from .cli_commands.commands.monitor import MonitorCommands
|
|
372
|
+
from .cli_commands.commands.debug import DebugCommands
|
|
373
|
+
from .cli_commands.commands.dlq import DLQCommands
|
|
374
|
+
|
|
375
|
+
# Register new commands with existing CLI
|
|
376
|
+
command_classes = [
|
|
377
|
+
QueueCommands(),
|
|
378
|
+
JobCommands(),
|
|
379
|
+
MonitorCommands(),
|
|
380
|
+
DebugCommands(),
|
|
381
|
+
DLQCommands(),
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
for command_instance in command_classes:
|
|
385
|
+
try:
|
|
386
|
+
command_instance.register(rrq)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
click.echo(
|
|
389
|
+
f"Warning: Failed to register command {command_instance.__class__.__name__}: {e}",
|
|
390
|
+
err=True,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
except ImportError as e:
|
|
394
|
+
# Fall back to original CLI if new modules aren't available
|
|
395
|
+
click.echo(f"Warning: Enhanced CLI features not available: {e}", err=True)
|
|
396
|
+
|
|
397
|
+
|
|
309
398
|
@rrq.group("worker")
|
|
310
399
|
def worker_cli():
|
|
311
400
|
"""Manage RRQ workers (run, watch)."""
|
|
@@ -338,28 +427,85 @@ def worker_cli():
|
|
|
338
427
|
"The specified settings object must include a `job_registry: JobRegistry`."
|
|
339
428
|
),
|
|
340
429
|
)
|
|
430
|
+
@click.option(
|
|
431
|
+
"--num-workers",
|
|
432
|
+
type=int,
|
|
433
|
+
default=None,
|
|
434
|
+
help="Number of parallel worker processes to start. Defaults to the number of CPU cores.",
|
|
435
|
+
)
|
|
341
436
|
def worker_run_command(
|
|
342
437
|
burst: bool,
|
|
343
438
|
queues: tuple[str, ...],
|
|
344
439
|
settings_object_path: str,
|
|
440
|
+
num_workers: int | None,
|
|
345
441
|
):
|
|
346
|
-
"""Run
|
|
442
|
+
"""Run RRQ worker processes.
|
|
347
443
|
Requires an application-specific settings object.
|
|
348
444
|
"""
|
|
349
|
-
|
|
445
|
+
if num_workers is None:
|
|
446
|
+
num_workers = (
|
|
447
|
+
os.cpu_count() or 1
|
|
448
|
+
) # Default to CPU cores, or 1 if cpu_count() is None
|
|
449
|
+
click.echo(
|
|
450
|
+
f"No --num-workers specified, defaulting to {num_workers} (CPU cores)."
|
|
451
|
+
)
|
|
452
|
+
elif num_workers <= 0:
|
|
453
|
+
click.echo(
|
|
454
|
+
click.style("ERROR: --num-workers must be a positive integer.", fg="red"),
|
|
455
|
+
err=True,
|
|
456
|
+
)
|
|
457
|
+
sys.exit(1)
|
|
350
458
|
|
|
351
|
-
#
|
|
352
|
-
|
|
353
|
-
|
|
459
|
+
# Restrict burst mode with multiple workers
|
|
460
|
+
if num_workers > 1 and burst:
|
|
461
|
+
click.echo(
|
|
462
|
+
click.style(
|
|
463
|
+
"ERROR: --burst mode is not supported with multiple workers (--num-workers > 1). "
|
|
464
|
+
"Burst mode cannot coordinate across multiple processes.",
|
|
465
|
+
fg="red",
|
|
466
|
+
),
|
|
467
|
+
err=True,
|
|
468
|
+
)
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
# Display settings source
|
|
472
|
+
click.echo("Loading RRQ Settings... ", nl=False)
|
|
473
|
+
if settings_object_path:
|
|
474
|
+
click.echo(f"from --settings parameter ({settings_object_path}).")
|
|
475
|
+
elif os.getenv("RRQ_SETTINGS"):
|
|
476
|
+
click.echo(f"from RRQ_SETTINGS env var ({os.getenv('RRQ_SETTINGS')}).")
|
|
477
|
+
elif DOTENV_AVAILABLE and find_dotenv(usecwd=True):
|
|
478
|
+
click.echo("found in .env file.")
|
|
479
|
+
else:
|
|
480
|
+
click.echo("using defaults.")
|
|
481
|
+
|
|
482
|
+
if num_workers == 1:
|
|
483
|
+
# Run a single worker in the current process
|
|
484
|
+
click.echo(f"Starting 1 RRQ worker process (Burst: {burst})")
|
|
485
|
+
_run_single_worker(
|
|
486
|
+
burst, list(queues) if queues else None, settings_object_path
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
# Run multiple worker subprocesses
|
|
490
|
+
click.echo(f"Starting {num_workers} RRQ worker processes")
|
|
491
|
+
# Burst is guaranteed to be False here
|
|
492
|
+
_run_multiple_workers(
|
|
493
|
+
num_workers, list(queues) if queues else None, settings_object_path
|
|
494
|
+
)
|
|
354
495
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
496
|
+
|
|
497
|
+
def _run_single_worker(
|
|
498
|
+
burst: bool,
|
|
499
|
+
queues_arg: list[str] | None,
|
|
500
|
+
settings_object_path: str | None,
|
|
501
|
+
):
|
|
502
|
+
"""Helper function to run a single RRQ worker instance."""
|
|
503
|
+
rrq_settings = _load_app_settings(settings_object_path)
|
|
358
504
|
|
|
359
505
|
if not rrq_settings.job_registry:
|
|
360
506
|
click.echo(
|
|
361
507
|
click.style(
|
|
362
|
-
"ERROR: No '
|
|
508
|
+
"ERROR: No 'job_registry'. You must provide a JobRegistry instance in settings.",
|
|
363
509
|
fg="red",
|
|
364
510
|
),
|
|
365
511
|
err=True,
|
|
@@ -378,22 +524,187 @@ def worker_run_command(
|
|
|
378
524
|
burst=burst,
|
|
379
525
|
)
|
|
380
526
|
|
|
381
|
-
loop = asyncio.get_event_loop()
|
|
382
527
|
try:
|
|
383
|
-
logger.info("Starting worker run loop...")
|
|
384
|
-
|
|
528
|
+
logger.info("Starting worker run loop for single worker...")
|
|
529
|
+
asyncio.run(worker_instance.run())
|
|
385
530
|
except KeyboardInterrupt:
|
|
386
531
|
logger.info("RRQ Worker run interrupted by user (KeyboardInterrupt).")
|
|
387
532
|
except Exception as e:
|
|
388
|
-
|
|
533
|
+
click.echo(
|
|
534
|
+
click.style(f"ERROR: Exception during RRQ Worker run: {e}", fg="red"),
|
|
535
|
+
err=True,
|
|
536
|
+
)
|
|
537
|
+
# Consider re-raising or sys.exit(1) if the exception means failure
|
|
389
538
|
finally:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
393
|
-
loop.close()
|
|
539
|
+
# asyncio.run handles loop cleanup.
|
|
540
|
+
logger.info("RRQ Worker run finished or exited.")
|
|
394
541
|
logger.info("RRQ Worker has shut down.")
|
|
395
542
|
|
|
396
543
|
|
|
544
|
+
def _run_multiple_workers(
|
|
545
|
+
num_workers: int,
|
|
546
|
+
queues: list[str] | None,
|
|
547
|
+
settings_object_path: str | None,
|
|
548
|
+
):
|
|
549
|
+
"""Manages multiple worker subprocesses."""
|
|
550
|
+
processes: list[subprocess.Popen] = []
|
|
551
|
+
# loop = asyncio.get_event_loop() # Not needed here, this function is synchronous
|
|
552
|
+
|
|
553
|
+
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
|
554
|
+
original_sigterm_handler = signal.getsignal(signal.SIGTERM)
|
|
555
|
+
|
|
556
|
+
def sig_handler(signum, frame):
|
|
557
|
+
click.echo(
|
|
558
|
+
f"\nSignal {signal.Signals(signum).name} received. Terminating child workers..."
|
|
559
|
+
)
|
|
560
|
+
# Send SIGTERM to all processes
|
|
561
|
+
for i, p in enumerate(processes):
|
|
562
|
+
if p.poll() is None: # Process is still running
|
|
563
|
+
try:
|
|
564
|
+
pgid = os.getpgid(p.pid)
|
|
565
|
+
click.echo(f"Sending SIGTERM to worker {i + 1} (PID {p.pid})...")
|
|
566
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
567
|
+
except (ProcessLookupError, OSError):
|
|
568
|
+
pass # Process already dead
|
|
569
|
+
# Restore original handlers before exiting or re-raising
|
|
570
|
+
signal.signal(signal.SIGINT, original_sigint_handler)
|
|
571
|
+
signal.signal(signal.SIGTERM, original_sigterm_handler)
|
|
572
|
+
# Propagate signal to ensure parent exits if it was, e.g., a Ctrl+C
|
|
573
|
+
# This is a bit tricky; for now, just exit.
|
|
574
|
+
# A more robust way might involve re-raising the signal if not handled by click/asyncio.
|
|
575
|
+
sys.exit(0)
|
|
576
|
+
|
|
577
|
+
signal.signal(signal.SIGINT, sig_handler)
|
|
578
|
+
signal.signal(signal.SIGTERM, sig_handler)
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
for i in range(num_workers):
|
|
582
|
+
# Construct the command for the subprocess.
|
|
583
|
+
# Each subprocess runs 'rrq worker run' for a single worker.
|
|
584
|
+
# We pass along relevant flags like --settings, --queue, and --burst.
|
|
585
|
+
# Crucially, we do *not* pass --num-workers to the child,
|
|
586
|
+
# or rather, we could conceptually pass --num-workers 1.
|
|
587
|
+
# Use the rrq executable from the same venv
|
|
588
|
+
venv_bin_dir = os.path.dirname(sys.executable)
|
|
589
|
+
rrq_executable = os.path.join(venv_bin_dir, "rrq")
|
|
590
|
+
cmd = [rrq_executable, "worker", "run", "--num-workers=1"]
|
|
591
|
+
if settings_object_path:
|
|
592
|
+
cmd.extend(["--settings", settings_object_path])
|
|
593
|
+
elif os.getenv("RRQ_SETTINGS"):
|
|
594
|
+
# Pass the RRQ_SETTINGS env var as explicit parameter to subprocess
|
|
595
|
+
cmd.extend(["--settings", os.getenv("RRQ_SETTINGS")])
|
|
596
|
+
else:
|
|
597
|
+
# Error: No settings provided for multi-worker mode
|
|
598
|
+
click.echo(
|
|
599
|
+
"Error: Multi-worker mode requires explicit settings. "
|
|
600
|
+
"Please provide either --settings option or set RRQ_SETTINGS environment variable.",
|
|
601
|
+
err=True,
|
|
602
|
+
)
|
|
603
|
+
sys.exit(1)
|
|
604
|
+
if queues:
|
|
605
|
+
for q_name in queues:
|
|
606
|
+
cmd.extend(["--queue", q_name])
|
|
607
|
+
click.echo(f"Starting worker subprocess {i + 1}/{num_workers}...")
|
|
608
|
+
|
|
609
|
+
# Set up environment - add current directory to PYTHONPATH
|
|
610
|
+
env = os.environ.copy()
|
|
611
|
+
current_pythonpath = env.get("PYTHONPATH", "")
|
|
612
|
+
current_dir = os.getcwd()
|
|
613
|
+
if current_pythonpath:
|
|
614
|
+
env["PYTHONPATH"] = f"{current_dir}:{current_pythonpath}"
|
|
615
|
+
else:
|
|
616
|
+
env["PYTHONPATH"] = current_dir
|
|
617
|
+
|
|
618
|
+
# Configure output redirection
|
|
619
|
+
is_testing = "PYTEST_CURRENT_TEST" in os.environ
|
|
620
|
+
stdout_dest = None if not is_testing else subprocess.DEVNULL
|
|
621
|
+
stderr_dest = None if not is_testing else subprocess.DEVNULL
|
|
622
|
+
|
|
623
|
+
process = subprocess.Popen(
|
|
624
|
+
cmd,
|
|
625
|
+
start_new_session=True,
|
|
626
|
+
stdout=stdout_dest,
|
|
627
|
+
stderr=stderr_dest,
|
|
628
|
+
cwd=os.getcwd(),
|
|
629
|
+
env=env,
|
|
630
|
+
)
|
|
631
|
+
processes.append(process)
|
|
632
|
+
click.echo(f"Worker subprocess {i + 1} started with PID {process.pid}")
|
|
633
|
+
|
|
634
|
+
# Wait for all processes to complete
|
|
635
|
+
click.echo(f"All {num_workers} workers started. Press Ctrl+C to stop.")
|
|
636
|
+
exit_codes = []
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
for p in processes:
|
|
640
|
+
exit_code = p.wait()
|
|
641
|
+
exit_codes.append(exit_code)
|
|
642
|
+
except KeyboardInterrupt:
|
|
643
|
+
# Signal handler has already sent SIGTERM, now wait with timeout
|
|
644
|
+
max_wait = 10
|
|
645
|
+
check_interval = 0.1
|
|
646
|
+
elapsed = 0
|
|
647
|
+
|
|
648
|
+
while elapsed < max_wait:
|
|
649
|
+
time.sleep(check_interval)
|
|
650
|
+
elapsed += check_interval
|
|
651
|
+
|
|
652
|
+
# Check if all processes have terminated
|
|
653
|
+
all_terminated = all(p.poll() is not None for p in processes)
|
|
654
|
+
if all_terminated:
|
|
655
|
+
click.echo("All workers terminated gracefully.")
|
|
656
|
+
break
|
|
657
|
+
else:
|
|
658
|
+
# Timeout reached, force kill remaining processes
|
|
659
|
+
for i, p in enumerate(processes):
|
|
660
|
+
if p.poll() is None:
|
|
661
|
+
try:
|
|
662
|
+
click.echo(
|
|
663
|
+
click.style(
|
|
664
|
+
f"WARNING: Worker {i + 1} did not terminate gracefully, sending SIGKILL.",
|
|
665
|
+
fg="yellow",
|
|
666
|
+
),
|
|
667
|
+
err=True,
|
|
668
|
+
)
|
|
669
|
+
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
|
|
670
|
+
except (ProcessLookupError, OSError):
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
# Collect exit codes
|
|
674
|
+
for p in processes:
|
|
675
|
+
exit_codes.append(p.wait())
|
|
676
|
+
|
|
677
|
+
# Report results
|
|
678
|
+
for i, exit_code in enumerate(exit_codes):
|
|
679
|
+
click.echo(f"Worker subprocess {i + 1} exited with code {exit_code}")
|
|
680
|
+
if exit_code != 0:
|
|
681
|
+
click.echo(
|
|
682
|
+
click.style(
|
|
683
|
+
f"Worker subprocess {i + 1} failed with exit code {exit_code}",
|
|
684
|
+
fg="red",
|
|
685
|
+
),
|
|
686
|
+
err=True,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
except Exception as e:
|
|
690
|
+
click.echo(
|
|
691
|
+
click.style(f"ERROR: Error managing worker subprocesses: {e}", fg="red"),
|
|
692
|
+
err=True,
|
|
693
|
+
)
|
|
694
|
+
# Terminate any running processes if an error occurs in the manager
|
|
695
|
+
for p in processes:
|
|
696
|
+
if p.poll() is None: # If process is still running
|
|
697
|
+
terminate_worker_process(p, logger)
|
|
698
|
+
finally:
|
|
699
|
+
logger.info("All worker subprocesses terminated or completed.")
|
|
700
|
+
# Restore original signal handlers
|
|
701
|
+
signal.signal(signal.SIGINT, original_sigint_handler)
|
|
702
|
+
signal.signal(signal.SIGTERM, original_sigterm_handler)
|
|
703
|
+
# Any other cleanup for the parent process
|
|
704
|
+
# No loop to check or close here as this part is synchronous
|
|
705
|
+
logger.info("Parent process for multi-worker management is exiting.")
|
|
706
|
+
|
|
707
|
+
|
|
397
708
|
@worker_cli.command("watch")
|
|
398
709
|
@click.option(
|
|
399
710
|
"--path",
|
|
@@ -468,65 +779,3 @@ def check_command(settings_object_path: str):
|
|
|
468
779
|
else:
|
|
469
780
|
click.echo(click.style("Health check FAILED.", fg="red"))
|
|
470
781
|
sys.exit(1)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
@rrq.group("dlq")
|
|
474
|
-
def dlq_cli():
|
|
475
|
-
"""Manage the Dead Letter Queue (DLQ)."""
|
|
476
|
-
pass
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
@dlq_cli.command("requeue")
|
|
480
|
-
@click.option(
|
|
481
|
-
"--settings",
|
|
482
|
-
"settings_object_path",
|
|
483
|
-
type=str,
|
|
484
|
-
required=False,
|
|
485
|
-
default=None,
|
|
486
|
-
help=(
|
|
487
|
-
"Python settings path for application worker settings "
|
|
488
|
-
"(e.g., myapp.worker_config.rrq_settings). "
|
|
489
|
-
"Must include `job_registry: JobRegistry` if requeueing requires handler resolution."
|
|
490
|
-
),
|
|
491
|
-
)
|
|
492
|
-
@click.option(
|
|
493
|
-
"--dlq-name",
|
|
494
|
-
"dlq_name",
|
|
495
|
-
type=str,
|
|
496
|
-
required=False,
|
|
497
|
-
default=None,
|
|
498
|
-
help="Name of the DLQ (without prefix). Defaults to settings.default_dlq_name.",
|
|
499
|
-
)
|
|
500
|
-
@click.option(
|
|
501
|
-
"--queue",
|
|
502
|
-
"target_queue",
|
|
503
|
-
type=str,
|
|
504
|
-
required=False,
|
|
505
|
-
default=None,
|
|
506
|
-
help="Name of the target queue (without prefix). Defaults to settings.default_queue_name.",
|
|
507
|
-
)
|
|
508
|
-
@click.option(
|
|
509
|
-
"--limit",
|
|
510
|
-
type=int,
|
|
511
|
-
required=False,
|
|
512
|
-
default=None,
|
|
513
|
-
help="Maximum number of DLQ jobs to requeue; all if not set.",
|
|
514
|
-
)
|
|
515
|
-
def dlq_requeue_command(
|
|
516
|
-
settings_object_path: str,
|
|
517
|
-
dlq_name: str,
|
|
518
|
-
target_queue: str,
|
|
519
|
-
limit: int,
|
|
520
|
-
):
|
|
521
|
-
"""Requeue jobs from the dead letter queue back into a live queue."""
|
|
522
|
-
rrq_settings = _load_app_settings(settings_object_path)
|
|
523
|
-
dlq_to_use = dlq_name or rrq_settings.default_dlq_name
|
|
524
|
-
queue_to_use = target_queue or rrq_settings.default_queue_name
|
|
525
|
-
job_store = JobStore(settings=rrq_settings)
|
|
526
|
-
click.echo(
|
|
527
|
-
f"Requeuing jobs from DLQ '{dlq_to_use}' to queue '{queue_to_use}' (limit: {limit or 'all'})..."
|
|
528
|
-
)
|
|
529
|
-
count = asyncio.run(job_store.requeue_dlq(dlq_to_use, queue_to_use, limit))
|
|
530
|
-
click.echo(
|
|
531
|
-
f"Requeued {count} job(s) from DLQ '{dlq_to_use}' to queue '{queue_to_use}'."
|
|
532
|
-
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RRQ CLI module"""
|
rrq/cli_commands/base.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Base classes and utilities for RRQ CLI commands"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import pkgutil
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ..settings import RRQSettings
|
|
13
|
+
from ..store import JobStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseCommand(ABC):
|
|
17
|
+
"""Base class for all RRQ CLI commands"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def register(self, cli_group: click.Group) -> None:
|
|
21
|
+
"""Register the command with the CLI group"""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncCommand(BaseCommand):
|
|
26
|
+
"""Base class for async CLI commands"""
|
|
27
|
+
|
|
28
|
+
def make_async(self, func: Callable) -> Callable:
|
|
29
|
+
"""Wrapper to run async functions in click commands"""
|
|
30
|
+
|
|
31
|
+
def wrapper(*args, **kwargs):
|
|
32
|
+
return asyncio.run(func(*args, **kwargs))
|
|
33
|
+
|
|
34
|
+
return wrapper
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_app_settings(settings_object_path: str | None = None) -> RRQSettings:
|
|
38
|
+
"""Load the settings object from the given path.
|
|
39
|
+
|
|
40
|
+
If not provided, the RRQ_SETTINGS environment variable will be used.
|
|
41
|
+
If the environment variable is not set, will create a default settings object.
|
|
42
|
+
"""
|
|
43
|
+
# Import the original function from cli.py
|
|
44
|
+
from ..cli import _load_app_settings
|
|
45
|
+
|
|
46
|
+
return _load_app_settings(settings_object_path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_settings_source(
|
|
50
|
+
settings_object_path: str | None = None,
|
|
51
|
+
) -> tuple[str | None, str]:
|
|
52
|
+
"""Resolve the settings path and its source."""
|
|
53
|
+
# Import the original function from cli.py
|
|
54
|
+
from ..cli import _resolve_settings_source
|
|
55
|
+
|
|
56
|
+
return _resolve_settings_source(settings_object_path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def auto_discover_commands(package_path: str) -> list[type[BaseCommand]]:
|
|
60
|
+
"""Auto-discover command classes in the given package"""
|
|
61
|
+
commands = []
|
|
62
|
+
|
|
63
|
+
# Get the package module
|
|
64
|
+
try:
|
|
65
|
+
package = importlib.import_module(package_path)
|
|
66
|
+
package_dir = os.path.dirname(package.__file__)
|
|
67
|
+
except ImportError:
|
|
68
|
+
# Return empty list for non-existent packages
|
|
69
|
+
return commands
|
|
70
|
+
|
|
71
|
+
# Iterate through all modules in the package
|
|
72
|
+
for _, module_name, is_pkg in pkgutil.iter_modules([package_dir]):
|
|
73
|
+
if is_pkg:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Import the module
|
|
77
|
+
module_path = f"{package_path}.{module_name}"
|
|
78
|
+
try:
|
|
79
|
+
module = importlib.import_module(module_path)
|
|
80
|
+
|
|
81
|
+
# Look for BaseCommand subclasses
|
|
82
|
+
for attr_name in dir(module):
|
|
83
|
+
attr = getattr(module, attr_name)
|
|
84
|
+
if (
|
|
85
|
+
isinstance(attr, type)
|
|
86
|
+
and issubclass(attr, BaseCommand)
|
|
87
|
+
and attr not in (BaseCommand, AsyncCommand)
|
|
88
|
+
):
|
|
89
|
+
commands.append(attr)
|
|
90
|
+
except ImportError:
|
|
91
|
+
# Skip modules that can't be imported
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
return commands
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def get_job_store(settings: RRQSettings) -> JobStore:
|
|
98
|
+
"""Create and return a JobStore instance"""
|
|
99
|
+
job_store = JobStore(settings=settings)
|
|
100
|
+
# Test connection
|
|
101
|
+
await job_store.redis.ping()
|
|
102
|
+
return job_store
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RRQ CLI commands module"""
|