rrq 0.2.5__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/rrq.py ADDED
@@ -0,0 +1,328 @@
1
+ """RRQ: Reliable Redis Queue Command Line Interface"""
2
+
3
+ import asyncio
4
+ import importlib
5
+ import logging
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ from contextlib import suppress
11
+
12
+ import click
13
+ import redis.exceptions
14
+ from watchfiles import awatch
15
+
16
+ from .constants import HEALTH_KEY_PREFIX
17
+ from .settings import RRQSettings
18
+ from .store import JobStore
19
+ from .worker import RRQWorker
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Helper to load settings for commands
24
+ def _load_app_settings(settings_object_path: str | None = None) -> RRQSettings:
25
+ """Load the settings object from the given path.
26
+ If not provided, the RRQ_SETTINGS environment variable will be used.
27
+ If the environment variable is not set, will create a default settings object.
28
+ RRQ Setting objects, automatically pick up ENVIRONMENT variables starting with RRQ_.
29
+
30
+ Args:
31
+ settings_object_path: A string representing the path to the settings object. (e.g. "myapp.worker_config.rrq_settings").
32
+
33
+ Returns:
34
+ The RRQSettings object.
35
+ """
36
+ try:
37
+ if settings_object_path is None:
38
+ settings_object_path = os.getenv("RRQ_SETTINGS")
39
+
40
+ if settings_object_path is None:
41
+ return RRQSettings()
42
+
43
+ # Split into module path and object name
44
+ parts = settings_object_path.split(".")
45
+ settings_object_name = parts[-1]
46
+ settings_object_module_path = ".".join(parts[:-1])
47
+
48
+ # Import the module
49
+ settings_object_module = importlib.import_module(settings_object_module_path)
50
+
51
+ # Get the object
52
+ settings_object = getattr(settings_object_module, settings_object_name)
53
+
54
+ return settings_object
55
+ except ImportError:
56
+ click.echo(click.style(f"ERROR: Could not import settings object '{settings_object_path}'. Make sure it is in PYTHONPATH.", fg="red"), err=True)
57
+ sys.exit(1)
58
+ except Exception as e:
59
+ click.echo(click.style(f"ERROR: Unexpected error processing settings object '{settings_object_path}': {e}", fg="red"), err=True)
60
+ sys.exit(1)
61
+
62
+
63
+ # --- Health Check ---
64
+ async def check_health_async_impl(settings_object_path: str | None = None) -> bool:
65
+ """Performs health check for RRQ workers."""
66
+ rrq_settings = _load_app_settings(settings_object_path)
67
+
68
+ logger.info("Performing RRQ worker health check...")
69
+ job_store = None
70
+ try:
71
+ job_store = JobStore(settings=rrq_settings)
72
+ await job_store.redis.ping()
73
+ logger.debug(f"Successfully connected to Redis: {rrq_settings.redis_dsn}")
74
+
75
+ health_key_pattern = f"{HEALTH_KEY_PREFIX}*"
76
+ worker_keys = [key_bytes.decode("utf-8") async for key_bytes in job_store.redis.scan_iter(match=health_key_pattern)]
77
+
78
+ if not worker_keys:
79
+ click.echo(click.style("Worker Health Check: FAIL (No active workers found)", fg="red"))
80
+ return False
81
+
82
+ click.echo(click.style(f"Worker Health Check: Found {len(worker_keys)} active worker(s):", fg="green"))
83
+ for key in worker_keys:
84
+ worker_id = key.split(HEALTH_KEY_PREFIX)[1]
85
+ health_data, ttl = await job_store.get_worker_health(worker_id)
86
+ if health_data:
87
+ status = health_data.get("status", "N/A")
88
+ active_jobs = health_data.get("active_jobs", "N/A")
89
+ timestamp = health_data.get("timestamp", "N/A")
90
+ click.echo(
91
+ f" - Worker ID: {click.style(worker_id, bold=True)}\n"
92
+ f" Status: {status}\n"
93
+ f" Active Jobs: {active_jobs}\n"
94
+ f" Last Heartbeat: {timestamp}\n"
95
+ f" TTL: {ttl if ttl is not None else 'N/A'} seconds"
96
+ )
97
+ else:
98
+ click.echo(f" - Worker ID: {click.style(worker_id, bold=True)} - Health data missing/invalid. TTL: {ttl if ttl is not None else 'N/A'}s")
99
+ return True
100
+ except redis.exceptions.ConnectionError as e:
101
+ logger.error(f"Redis connection failed during health check: {e}", exc_info=True)
102
+ click.echo(click.style(f"Worker Health Check: FAIL - Redis connection error: {e}", fg="red"))
103
+ return False
104
+ except Exception as e:
105
+ logger.error(f"An unexpected error occurred during health check: {e}", exc_info=True)
106
+ click.echo(click.style(f"Worker Health Check: FAIL - Unexpected error: {e}", fg="red"))
107
+ return False
108
+ finally:
109
+ if job_store:
110
+ await job_store.aclose()
111
+
112
+ # --- Process Management ---
113
+ def start_rrq_worker_subprocess(is_detached: bool = False, settings_object_path: str | None = None) -> subprocess.Popen | None:
114
+ """Start an RRQ worker process."""
115
+ command = ["rrq", "worker", "run"]
116
+ if settings_object_path:
117
+ command.extend(["--settings", settings_object_path])
118
+ else:
119
+ raise ValueError("start_rrq_worker_subprocess called without settings_object_path!")
120
+
121
+ logger.info(f"Starting worker subprocess with command: {' '.join(command)}")
122
+ if is_detached:
123
+ process = subprocess.Popen(
124
+ command,
125
+ start_new_session=True,
126
+ stdout=subprocess.DEVNULL,
127
+ stderr=subprocess.DEVNULL,
128
+ stdin=subprocess.DEVNULL,
129
+ )
130
+ logger.info(f"RRQ worker started in background with PID: {process.pid}")
131
+ else:
132
+ process = subprocess.Popen(
133
+ command,
134
+ start_new_session=True,
135
+ stdout=sys.stdout,
136
+ stderr=sys.stderr,
137
+ )
138
+
139
+ return process
140
+
141
+
142
+ def terminate_worker_process(process: subprocess.Popen | None, logger: logging.Logger) -> None:
143
+ if not process or process.pid is None:
144
+ logger.debug("No active worker process to terminate.")
145
+ return
146
+
147
+ try:
148
+ if process.poll() is not None:
149
+ logger.debug(f"Worker process {process.pid} already terminated (poll returned exit code: {process.returncode}).")
150
+ return
151
+
152
+ pgid = os.getpgid(process.pid)
153
+ logger.info(f"Terminating worker process group for PID {process.pid} (PGID {pgid})...")
154
+ os.killpg(pgid, signal.SIGTERM)
155
+ process.wait(timeout=5)
156
+ except subprocess.TimeoutExpired:
157
+ logger.warning(f"Worker process {process.pid} did not terminate gracefully (SIGTERM timeout), sending SIGKILL.")
158
+ with suppress(ProcessLookupError):
159
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
160
+ except Exception as e:
161
+ logger.error(f"Unexpected error checking worker process {process.pid}: {e}")
162
+
163
+
164
+ async def watch_rrq_worker_impl(watch_path: str, settings_object_path: str | None = None) -> None:
165
+ if not settings_object_path:
166
+ click.echo(click.style("ERROR: 'rrq worker watch' requires --settings to be specified.", fg="red"), err=True)
167
+ sys.exit(1)
168
+
169
+ abs_watch_path = os.path.abspath(watch_path)
170
+ click.echo(f"Watching for file changes in {abs_watch_path} to restart RRQ worker (app settings: {settings_object_path})...")
171
+ worker_process: subprocess.Popen | None = None
172
+ loop = asyncio.get_event_loop()
173
+ shutdown_event = asyncio.Event()
174
+
175
+ def sig_handler(_signum, _frame):
176
+ logger.info("Signal received, stopping watcher and worker...")
177
+ if worker_process is not None:
178
+ terminate_worker_process(worker_process, logger)
179
+ loop.call_soon_threadsafe(shutdown_event.set)
180
+
181
+ original_sigint = signal.getsignal(signal.SIGINT)
182
+ original_sigterm = signal.getsignal(signal.SIGTERM)
183
+ signal.signal(signal.SIGINT, sig_handler)
184
+ signal.signal(signal.SIGTERM, sig_handler)
185
+
186
+ try:
187
+ worker_process = start_rrq_worker_subprocess(is_detached=False, settings_object_path=settings_object_path)
188
+ async for changes in awatch(abs_watch_path, stop_event=shutdown_event):
189
+ if shutdown_event.is_set():
190
+ break
191
+ if not changes:
192
+ continue
193
+
194
+ logger.info(f"File changes detected: {changes}. Restarting RRQ worker...")
195
+ if worker_process is not None:
196
+ terminate_worker_process(worker_process, logger)
197
+ await asyncio.sleep(1)
198
+ if shutdown_event.is_set():
199
+ break
200
+ worker_process = start_rrq_worker_subprocess(is_detached=False, settings_object_path=settings_object_path)
201
+ except Exception as e:
202
+ logger.error(f"Error in watch_rrq_worker: {e}", exc_info=True)
203
+ finally:
204
+ logger.info("Exiting watch mode. Ensuring worker process is terminated.")
205
+ if not shutdown_event.is_set():
206
+ shutdown_event.set()
207
+ if worker_process is not None:
208
+ terminate_worker_process(worker_process, logger)
209
+ signal.signal(signal.SIGINT, original_sigint)
210
+ signal.signal(signal.SIGTERM, original_sigterm)
211
+ logger.info("Watch worker cleanup complete.")
212
+
213
+
214
+ # --- Click CLI Definitions ---
215
+
216
+ CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
217
+
218
+ @click.group(context_settings=CONTEXT_SETTINGS)
219
+ def rrq():
220
+ """RRQ: Reliable Redis Queue Command Line Interface.
221
+
222
+ Provides tools for running RRQ workers, checking system health,
223
+ and managing jobs. Requires an application-specific --settings module
224
+ for most operations.
225
+ """
226
+ pass
227
+
228
+
229
+
230
+ @rrq.group("worker")
231
+ def worker_cli():
232
+ """Manage RRQ workers (run, watch)."""
233
+ pass
234
+
235
+
236
+ @worker_cli.command("run")
237
+ @click.option("--burst", is_flag=True, help="Run worker in burst mode (process one job/batch then exit). Not Implemented yet.")
238
+ @click.option("--detach", is_flag=True, help="Run the worker in the background (detached).")
239
+ @click.option(
240
+ "--settings",
241
+ "settings_object_path",
242
+ type=str,
243
+ required=False,
244
+ default=None,
245
+ help="Python settings path for application worker settings (e.g., myapp.worker_config.rrq_settings)."
246
+ )
247
+ def worker_run_command(burst: bool, detach: bool, settings_object_path: str):
248
+ """Run an RRQ worker process. Requires --settings."""
249
+ rrq_settings = _load_app_settings(settings_object_path)
250
+
251
+ if detach:
252
+ logger.info("Attempting to start worker in detached (background) mode...")
253
+ process = start_rrq_worker_subprocess(is_detached=True, settings_object_path=settings_object_path)
254
+ click.echo(f"Worker initiated in background (PID: {process.pid}). Check logs for status.")
255
+ return
256
+
257
+ if burst:
258
+ raise NotImplementedError("Burst mode is not implemented yet.")
259
+
260
+ logger.info(f"Starting RRQ Worker (Burst: {burst}, App Settings: {settings_object_path})")
261
+
262
+ if not rrq_settings.job_registry:
263
+ click.echo(click.style("ERROR: No 'job_registry_app'. You must provide a JobRegistry instance in settings.", fg="red"), err=True)
264
+ sys.exit(1)
265
+
266
+ logger.debug(f"Registered handlers (from effective registry): {rrq_settings.job_registry.get_registered_functions()}")
267
+ logger.debug(f"Effective RRQ settings for worker: {rrq_settings}")
268
+
269
+ worker_instance = RRQWorker(
270
+ settings=rrq_settings,
271
+ job_registry=rrq_settings.job_registry,
272
+ )
273
+
274
+ loop = asyncio.get_event_loop()
275
+ try:
276
+ logger.info("Starting worker run loop...")
277
+ loop.run_until_complete(worker_instance.run())
278
+ except KeyboardInterrupt:
279
+ logger.info("RRQ Worker run interrupted by user (KeyboardInterrupt).")
280
+ except Exception as e:
281
+ logger.error(f"Exception during RRQ Worker run: {e}", exc_info=True)
282
+ finally:
283
+ logger.info("RRQ Worker run finished or exited. Cleaning up event loop.")
284
+ if loop.is_running():
285
+ loop.run_until_complete(loop.shutdown_asyncgens())
286
+ loop.close()
287
+ logger.info("RRQ Worker has shut down.")
288
+
289
+
290
+ @worker_cli.command("watch")
291
+ @click.option(
292
+ "--path",
293
+ default=".",
294
+ type=click.Path(exists=True, dir_okay=True, file_okay=False, readable=True),
295
+ help="Directory path to watch for changes. Default is current directory.",
296
+ show_default=True,
297
+ )
298
+ @click.option(
299
+ "--settings",
300
+ "settings_object_path",
301
+ type=str,
302
+ required=False,
303
+ default=None,
304
+ help="Python settings path for application worker settings (e.g., myapp.worker_config.rrq_settings)."
305
+ )
306
+ def worker_watch_command(path: str, settings_object_path: str):
307
+ """Run the RRQ worker with auto-restart on file changes in PATH. Requires --settings."""
308
+ asyncio.run(watch_rrq_worker_impl(path, settings_object_path=settings_object_path))
309
+
310
+
311
+ @rrq.command("check")
312
+ @click.option(
313
+ "--settings",
314
+ "settings_object_path",
315
+ type=str,
316
+ required=False,
317
+ default=None,
318
+ help="Python settings path for application worker settings (e.g., myapp.worker_config.rrq_settings)."
319
+ )
320
+ def check_command(settings_object_path: str):
321
+ """Perform a health check on active RRQ worker(s). Requires --settings."""
322
+ click.echo("Performing RRQ health check...")
323
+ healthy = asyncio.run(check_health_async_impl(settings_object_path=settings_object_path))
324
+ if healthy:
325
+ click.echo(click.style("Health check PASSED.", fg="green"))
326
+ else:
327
+ click.echo(click.style("Health check FAILED.", fg="red"))
328
+ sys.exit(1)
rrq/settings.py ADDED
@@ -0,0 +1,107 @@
1
+ """This module defines the configuration settings for the RRQ (Reliable Redis Queue) system
2
+ using Pydantic's BaseSettings.
3
+
4
+ Settings can be loaded from environment variables (with a prefix of `RRQ_`) or
5
+ from a .env file. Sensible defaults are provided for most settings.
6
+ """
7
+
8
+ # Import Callable and Awaitable for type hinting hooks
9
+ from typing import Awaitable, Callable, Optional
10
+
11
+ from pydantic import Field
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+ from .constants import (
15
+ DEFAULT_DLQ_NAME,
16
+ DEFAULT_JOB_TIMEOUT_SECONDS,
17
+ DEFAULT_LOCK_TIMEOUT_EXTENSION_SECONDS,
18
+ DEFAULT_MAX_RETRIES,
19
+ DEFAULT_POLL_DELAY_SECONDS,
20
+ DEFAULT_QUEUE_NAME,
21
+ DEFAULT_RESULT_TTL_SECONDS,
22
+ DEFAULT_UNIQUE_JOB_LOCK_TTL_SECONDS,
23
+ )
24
+ from .registry import JobRegistry
25
+
26
+
27
+ class RRQSettings(BaseSettings):
28
+ """Configuration settings for the RRQ (Reliable Redis Queue) system.
29
+
30
+ These settings control various aspects of the client, worker, and job store behavior,
31
+ such as Redis connection, queue names, timeouts, retry policies, and worker concurrency.
32
+ """
33
+
34
+ # Startup and Shutdown Hooks
35
+ on_startup: Optional[Callable[[], Awaitable[None]]] = Field(
36
+ default=None, description="Async callable to run on worker startup."
37
+ )
38
+ on_shutdown: Optional[Callable[[], Awaitable[None]]] = Field(
39
+ default=None, description="Async callable to run on worker shutdown."
40
+ )
41
+ redis_dsn: str = Field(
42
+ default="redis://localhost:6379/0",
43
+ description="Redis Data Source Name (DSN) for connecting to the Redis server.",
44
+ )
45
+ default_queue_name: str = Field(
46
+ default=DEFAULT_QUEUE_NAME,
47
+ description="Default queue name used if not specified when enqueuing or processing jobs.",
48
+ )
49
+ default_dlq_name: str = Field(
50
+ default=DEFAULT_DLQ_NAME,
51
+ description="Default Dead Letter Queue (DLQ) name for jobs that fail permanently.",
52
+ )
53
+ default_max_retries: int = Field(
54
+ default=DEFAULT_MAX_RETRIES,
55
+ description="Default maximum number of retries for a job before it's moved to the DLQ.",
56
+ )
57
+ default_job_timeout_seconds: int = Field(
58
+ default=DEFAULT_JOB_TIMEOUT_SECONDS,
59
+ description="Default timeout (in seconds) for a single job execution attempt.",
60
+ )
61
+ default_lock_timeout_extension_seconds: int = Field(
62
+ default=DEFAULT_LOCK_TIMEOUT_EXTENSION_SECONDS,
63
+ description="Extra time (in seconds) added to a job's timeout to determine the Redis lock's TTL.",
64
+ )
65
+ default_result_ttl_seconds: int = Field(
66
+ default=DEFAULT_RESULT_TTL_SECONDS,
67
+ description="Default Time-To-Live (in seconds) for storing successful job results.",
68
+ )
69
+ default_poll_delay_seconds: float = Field(
70
+ default=DEFAULT_POLL_DELAY_SECONDS,
71
+ description="Default delay (in seconds) for worker polling when queues are empty.",
72
+ )
73
+ default_unique_job_lock_ttl_seconds: int = Field(
74
+ default=DEFAULT_UNIQUE_JOB_LOCK_TTL_SECONDS,
75
+ description="Default TTL (in seconds) for unique job locks if `_unique_key` is used during enqueue.",
76
+ )
77
+ worker_concurrency: int = Field(
78
+ default=10,
79
+ description="Default number of concurrent jobs a single worker process can handle.",
80
+ )
81
+ worker_health_check_interval_seconds: int = Field(
82
+ default=60,
83
+ description="Interval (in seconds) at which a worker updates its health check status in Redis.",
84
+ )
85
+ base_retry_delay_seconds: float = Field(
86
+ default=5.0,
87
+ description="Initial delay (in seconds) for the first retry attempt when using exponential backoff.",
88
+ )
89
+ max_retry_delay_seconds: float = Field(
90
+ default=60 * 60, # 1 hour
91
+ description="Maximum delay (in seconds) for a retry attempt when using exponential backoff.",
92
+ )
93
+ worker_shutdown_grace_period_seconds: float = Field(
94
+ default=10.0,
95
+ description="Grace period (in seconds) for active job tasks to finish during worker shutdown.",
96
+ )
97
+ job_registry: Optional[JobRegistry] = Field(
98
+ default=None, description="Job registry instance, typically provided by the application."
99
+ )
100
+ model_config = SettingsConfigDict(
101
+ env_prefix="RRQ_",
102
+ extra="ignore",
103
+ # For local dev, you might want to load from a .env file:
104
+ # env_file=".env",
105
+ # env_file_encoding='utf-8'
106
+ )
107
+