rrq 0.3.7__py3-none-any.whl → 0.4.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/cron.py ADDED
@@ -0,0 +1,153 @@
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(field: str, *, names: dict[str, int] | None, min_val: int, max_val: int) -> Sequence[int]:
46
+ names = names or {}
47
+ if field == "*":
48
+ return list(range(min_val, max_val + 1))
49
+ values: set[int] = set()
50
+ for part in field.split(','):
51
+ step = 1
52
+ if '/' in part:
53
+ base, step_str = part.split('/', 1)
54
+ step = int(step_str)
55
+ else:
56
+ base = part
57
+ if base == "*":
58
+ start, end = min_val, max_val
59
+ elif '-' in base:
60
+ a, b = base.split('-', 1)
61
+ start = _parse_value(a, names, min_val, max_val)
62
+ end = _parse_value(b, names, min_val, max_val)
63
+ else:
64
+ val = _parse_value(base, names, min_val, max_val)
65
+ start = end = val
66
+ if start > end:
67
+ raise ValueError(f"invalid range {base}")
68
+ for v in range(start, end + 1, step):
69
+ values.add(v)
70
+ return sorted(values)
71
+
72
+
73
+ class CronSchedule:
74
+ """Represents a cron schedule expression."""
75
+
76
+ def __init__(self, expression: str) -> None:
77
+ fields = expression.split()
78
+ if len(fields) != 5:
79
+ raise ValueError("Cron expression must have 5 fields")
80
+ minute, hour, dom, month, dow = fields
81
+ self.minutes = _parse_field(minute, names=None, min_val=0, max_val=59)
82
+ self.hours = _parse_field(hour, names=None, min_val=0, max_val=23)
83
+ self.dom = _parse_field(dom, names=None, min_val=1, max_val=31)
84
+ self.months = _parse_field(month, names=MONTH_NAMES, min_val=1, max_val=12)
85
+ self.dow = _parse_field(dow, names=WEEKDAY_NAMES, min_val=0, max_val=6)
86
+ self.dom_all = dom == "*"
87
+ self.dow_all = dow == "*"
88
+
89
+ def next_after(self, dt: datetime) -> datetime:
90
+ dt = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
91
+ while True:
92
+ if dt.month not in self.months:
93
+ dt += timedelta(minutes=1)
94
+ continue
95
+ if dt.hour not in self.hours or dt.minute not in self.minutes:
96
+ dt += timedelta(minutes=1)
97
+ continue
98
+ dom_match = dt.day in self.dom
99
+ # Convert Python weekday (Monday=0) to cron weekday (Sunday=0)
100
+ # Python: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
101
+ # Cron: Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6
102
+ python_weekday = dt.weekday()
103
+ cron_weekday = (python_weekday + 1) % 7
104
+ dow_match = cron_weekday in self.dow
105
+
106
+ if self.dom_all and self.dow_all:
107
+ condition = True
108
+ elif self.dom_all:
109
+ # Only day-of-week constraint
110
+ condition = dow_match
111
+ elif self.dow_all:
112
+ # Only day-of-month constraint
113
+ condition = dom_match
114
+ else:
115
+ # Both constraints specified - use OR logic (standard cron behavior)
116
+ condition = dom_match or dow_match
117
+ if condition:
118
+ return dt
119
+ dt += timedelta(minutes=1)
120
+
121
+
122
+
123
+ class CronJob(BaseModel):
124
+ """Simple cron job specification based on a cron schedule."""
125
+
126
+ function_name: str
127
+ schedule: str = Field(
128
+ description="Cron expression 'm h dom mon dow'. Resolution is one minute."
129
+ )
130
+ args: list[Any] = Field(default_factory=list)
131
+ kwargs: dict[str, Any] = Field(default_factory=dict)
132
+ queue_name: Optional[str] = None
133
+ unique: bool = False
134
+
135
+ # Next run time and parsed schedule are maintained at runtime
136
+ next_run_time: Optional[datetime] = Field(default=None, exclude=True)
137
+ _cron: CronSchedule | None = PrivateAttr(default=None)
138
+
139
+ def model_post_init(self, __context: Any) -> None: # type: ignore[override]
140
+ self._cron = CronSchedule(self.schedule)
141
+
142
+ def schedule_next(self, now: Optional[datetime] = None) -> None:
143
+ """Compute the next run time strictly after *now*."""
144
+ now = (now or datetime.now(UTC)).replace(second=0, microsecond=0)
145
+ if self._cron is None:
146
+ self._cron = CronSchedule(self.schedule)
147
+ self.next_run_time = self._cron.next_after(now)
148
+
149
+ def due(self, now: Optional[datetime] = None) -> bool:
150
+ now = now or datetime.now(UTC)
151
+ if self.next_run_time is None:
152
+ self.schedule_next(now)
153
+ 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",
rrq/worker.py CHANGED
@@ -28,6 +28,7 @@ from .job import Job, JobStatus
28
28
  from .registry import JobRegistry
29
29
  from .settings import RRQSettings
30
30
  from .store import JobStore
31
+ from .cron import CronJob
31
32
 
32
33
  logger = logging.getLogger(__name__)
33
34
 
@@ -77,11 +78,14 @@ class RRQWorker:
77
78
  # Burst mode: process existing jobs then exit
78
79
  self.burst = burst
79
80
 
81
+ self.cron_jobs: list[CronJob] = list(self.settings.cron_jobs)
82
+
80
83
  self._semaphore = asyncio.Semaphore(self.settings.worker_concurrency)
81
84
  self._running_tasks: set[asyncio.Task] = set()
82
85
  self._shutdown_event = asyncio.Event()
83
86
  self._loop = None # Will be set in run()
84
87
  self._health_check_task: Optional[asyncio.Task] = None
88
+ self._cron_task: Optional[asyncio.Task] = None
85
89
  self.status: str = "initializing" # Worker status (e.g., initializing, running, polling, idle, stopped)
86
90
  logger.info(
87
91
  f"Initializing RRQWorker {self.worker_id} for queues: {self.queues}"
@@ -135,6 +139,10 @@ class RRQWorker:
135
139
  """
136
140
  logger.info(f"Worker {self.worker_id} starting run loop.")
137
141
  self._health_check_task = self._loop.create_task(self._heartbeat_loop())
142
+ if self.cron_jobs:
143
+ for cj in self.cron_jobs:
144
+ cj.schedule_next()
145
+ self._cron_task = self._loop.create_task(self._cron_loop())
138
146
 
139
147
  while not self._shutdown_event.is_set():
140
148
  try:
@@ -181,6 +189,10 @@ class RRQWorker:
181
189
  self._health_check_task.cancel()
182
190
  with suppress(asyncio.CancelledError):
183
191
  await self._health_check_task
192
+ if self._cron_task:
193
+ self._cron_task.cancel()
194
+ with suppress(asyncio.CancelledError):
195
+ await self._cron_task
184
196
 
185
197
  async def _poll_for_jobs(self, count: int) -> None:
186
198
  """Polls configured queues round-robin and attempts to start processing jobs.
@@ -781,6 +793,39 @@ class RRQWorker:
781
793
 
782
794
  logger.debug(f"Worker {self.worker_id} heartbeat loop finished.")
783
795
 
796
+ async def _maybe_enqueue_cron_jobs(self) -> None:
797
+ """Enqueue cron jobs that are due to run."""
798
+ now = datetime.now(UTC)
799
+ for cj in self.cron_jobs:
800
+ if cj.due(now):
801
+ unique_key = f"cron:{cj.function_name}" if cj.unique else None
802
+ try:
803
+ await self.client.enqueue(
804
+ cj.function_name,
805
+ *cj.args,
806
+ _queue_name=cj.queue_name,
807
+ _unique_key=unique_key,
808
+ **cj.kwargs,
809
+ )
810
+ finally:
811
+ cj.schedule_next(now)
812
+
813
+ async def _cron_loop(self) -> None:
814
+ logger.debug(f"Worker {self.worker_id} starting cron loop.")
815
+ while not self._shutdown_event.is_set():
816
+ try:
817
+ await self._maybe_enqueue_cron_jobs()
818
+ except Exception as e:
819
+ logger.error(
820
+ f"Worker {self.worker_id} error running cron jobs: {e}",
821
+ exc_info=True,
822
+ )
823
+ try:
824
+ await asyncio.wait_for(self._shutdown_event.wait(), timeout=30)
825
+ except TimeoutError:
826
+ pass
827
+ logger.debug(f"Worker {self.worker_id} cron loop finished.")
828
+
784
829
  async def _close_resources(self) -> None:
785
830
  """Closes the worker's resources, primarily the JobStore connection."""
786
831
  logger.info(f"Worker {self.worker_id} closing resources...")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrq
3
- Version: 0.3.7
3
+ Version: 0.4.0
4
4
  Summary: RRQ is a Python library for creating reliable job queues using Redis and asyncio
5
5
  Project-URL: Homepage, https://github.com/getresq/rrq
6
6
  Project-URL: Bug Tracker, https://github.com/getresq/rrq/issues
@@ -20,7 +20,7 @@ Requires-Dist: pydantic>=2.11.4
20
20
  Requires-Dist: redis[hiredis]<6,>=4.2.0
21
21
  Requires-Dist: watchfiles>=0.19.0
22
22
  Provides-Extra: dev
23
- Requires-Dist: pytest-asyncio>=0.26.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=1.0.0; extra == 'dev'
24
24
  Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
25
25
  Requires-Dist: pytest>=8.3.5; extra == 'dev'
26
26
  Description-Content-Type: text/markdown
@@ -40,6 +40,7 @@ RRQ is a Python library for creating reliable job queues using Redis and `asynci
40
40
  * **Graceful Shutdown**: Workers listen for SIGINT/SIGTERM and attempt to finish active jobs within a grace period before exiting. Interrupted jobs are re-queued.
41
41
  * **Worker Health Checks**: Workers periodically update a health key in Redis with a TTL, allowing monitoring systems to track active workers.
42
42
  * **Deferred Execution**: Jobs can be scheduled to run at a future time using `_defer_by` or `_defer_until`.
43
+ * **Cron Jobs**: Periodic jobs can be defined in `RRQSettings.cron_jobs` using a simple cron syntax.
43
44
 
44
45
  - Using deferral with a specific `_job_id` will effectively reschedule the job associated with that ID to the new time, overwriting its previous definition and score. It does not create multiple distinct scheduled jobs with the same ID.
45
46
 
@@ -138,6 +139,125 @@ if __name__ == "__main__":
138
139
 
139
140
  You can run multiple instances of `worker_script.py` for concurrent processing.
140
141
 
142
+ ## Cron Jobs
143
+
144
+ Add instances of `CronJob` to `RRQSettings.cron_jobs` to run periodic jobs. The
145
+ `schedule` string follows the typical five-field cron format `minute hour day-of-month month day-of-week`.
146
+ It supports the most common features from Unix cron:
147
+
148
+ - numeric values
149
+ - ranges (e.g. `8-11`)
150
+ - lists separated by commas (e.g. `mon,wed,fri`)
151
+ - step values using `/` (e.g. `*/15`)
152
+ - names for months and days (`jan-dec`, `sun-sat`)
153
+
154
+ Jobs are evaluated in the server's timezone and run with minute resolution.
155
+
156
+ ### Cron Schedule Examples
157
+
158
+ ```python
159
+ # Every minute
160
+ "* * * * *"
161
+
162
+ # Every hour at minute 30
163
+ "30 * * * *"
164
+
165
+ # Every day at 2:30 AM
166
+ "30 2 * * *"
167
+
168
+ # Every Monday at 9:00 AM
169
+ "0 9 * * mon"
170
+
171
+ # Every 15 minutes
172
+ "*/15 * * * *"
173
+
174
+ # Every weekday at 6:00 PM
175
+ "0 18 * * mon-fri"
176
+
177
+ # First day of every month at midnight
178
+ "0 0 1 * *"
179
+
180
+ # Every 2 hours during business hours on weekdays
181
+ "0 9-17/2 * * mon-fri"
182
+ ```
183
+
184
+ ### Defining Cron Jobs
185
+
186
+ ```python
187
+ from rrq.settings import RRQSettings
188
+ from rrq.cron import CronJob
189
+
190
+ # Define your cron jobs
191
+ cron_jobs = [
192
+ # Daily cleanup at 2 AM
193
+ CronJob(
194
+ function_name="daily_cleanup",
195
+ schedule="0 2 * * *",
196
+ args=["temp_files"],
197
+ kwargs={"max_age_days": 7}
198
+ ),
199
+
200
+ # Weekly report every Monday at 9 AM
201
+ CronJob(
202
+ function_name="generate_weekly_report",
203
+ schedule="0 9 * * mon",
204
+ unique=True # Prevent duplicate reports if worker restarts
205
+ ),
206
+
207
+ # Health check every 15 minutes on a specific queue
208
+ CronJob(
209
+ function_name="system_health_check",
210
+ schedule="*/15 * * * *",
211
+ queue_name="monitoring"
212
+ ),
213
+
214
+ # Backup database every night at 1 AM
215
+ CronJob(
216
+ function_name="backup_database",
217
+ schedule="0 1 * * *",
218
+ kwargs={"backup_type": "incremental"}
219
+ ),
220
+ ]
221
+
222
+ # Add to your settings
223
+ rrq_settings = RRQSettings(
224
+ redis_dsn="redis://localhost:6379/0",
225
+ cron_jobs=cron_jobs
226
+ )
227
+ ```
228
+
229
+ ### Cron Job Handlers
230
+
231
+ Your cron job handlers are regular async functions, just like other job handlers:
232
+
233
+ ```python
234
+ async def daily_cleanup(ctx, file_type: str, max_age_days: int = 7):
235
+ """Clean up old files."""
236
+ job_id = ctx['job_id']
237
+ print(f"Job {job_id}: Cleaning up {file_type} files older than {max_age_days} days")
238
+ # Your cleanup logic here
239
+ return {"cleaned_files": 42, "status": "completed"}
240
+
241
+ async def generate_weekly_report(ctx):
242
+ """Generate and send weekly report."""
243
+ job_id = ctx['job_id']
244
+ print(f"Job {job_id}: Generating weekly report")
245
+ # Your report generation logic here
246
+ return {"report_id": "weekly_2024_01", "status": "sent"}
247
+
248
+ # Register your handlers
249
+ from rrq.registry import JobRegistry
250
+
251
+ job_registry = JobRegistry()
252
+ job_registry.register("daily_cleanup", daily_cleanup)
253
+ job_registry.register("generate_weekly_report", generate_weekly_report)
254
+
255
+ # Add the registry to your settings
256
+ rrq_settings.job_registry = job_registry
257
+ ```
258
+
259
+ **Note:** Cron jobs are automatically enqueued by the worker when they become due. The worker checks for due cron jobs every 30 seconds and enqueues them as regular jobs to be processed.
260
+
141
261
  ## Command Line Interface
142
262
 
143
263
  RRQ provides a command-line interface (CLI) for managing workers and performing health checks:
@@ -178,7 +298,4 @@ RRQ can be configured in several ways, with the following precedence:
178
298
  * **`JobRegistry` (`registry.py`)**: A simple registry to map string function names (used when enqueuing) to the actual asynchronous handler functions the worker should execute.
179
299
  * **`JobStore` (`store.py`)**: An abstraction layer handling all direct interactions with Redis. It manages job definitions (Hashes), queues (Sorted Sets), processing locks (Strings with TTL), unique job locks, and worker health checks.
180
300
  * **`Job` (`job.py`)**: A Pydantic model representing a job, containing its ID, handler name, arguments, status, retry counts, timestamps, results, etc.
181
- * **`JobStatus` (`job.py`)**: An Enum defining the possible states of a job (`PENDING`, `ACTIVE`, `COMPLETED`, `FAILED`, `RETRYING`).
182
- * **`RRQSettings` (`settings.py`)**: A Pydantic `BaseSettings` model for configuring RRQ behavior (Redis DSN, queue names, timeouts, retry policies, concurrency, etc.). Loadable from environment variables (prefix `RRQ_`).
183
- * **`constants.py`**: Defines shared constants like Redis key prefixes and default configuration values.
184
- * **`exc.py`**: Defines custom exceptions, notably `RetryJob` which handlers can raise to explicitly request a retry, potentially with a custom delay.
301
+ * **`JobStatus` (`job.py`)**: An Enum defining the possible states of a job (`PENDING`, `ACTIVE`, `COMPLETED`, `FAILED`, `
@@ -0,0 +1,16 @@
1
+ rrq/__init__.py,sha256=3WYv9UkvnCbjKXrvmqiLm7yuVVQiLclbVCOXq5wb6ZM,290
2
+ rrq/cli.py,sha256=_LbaAH_w2a0VNRR0EctuE4afl-wccvMY2w2VbehFDEQ,16980
3
+ rrq/client.py,sha256=5_bmZ05LKIfY9WFSKU-nYawEupsnrnHT2HewXfC2Ahg,7831
4
+ rrq/constants.py,sha256=F_uZgBI3h00MctnEjBjiCGMrg5jUaz5Bz9I1vkyqNrs,1654
5
+ rrq/cron.py,sha256=9lxJ1OnrTbavJvbIdPp6u5ncYgyD35vRPsSulpVrQko,5244
6
+ rrq/exc.py,sha256=NJq3C7pUfcd47AB8kghIN8vdY0l90UrsHQEg4McBHP8,1281
7
+ rrq/job.py,sha256=eUbl33QDqDMXPKpo-0dl0Mp29LWWmtbBgRw0sclcwJ4,4011
8
+ rrq/registry.py,sha256=E9W_zx3QiKTBwMOGearaNpDKBDB87JIn0RlMQ3sAcP0,2925
9
+ rrq/settings.py,sha256=AxzSe_rw7-yduKST2c9mPunQWqPE2537XcC_XlMoHWM,4535
10
+ rrq/store.py,sha256=teO0Af8hzBiu7-dFn6_2lz5X90LAZXmtg0VDZuQoAwk,24972
11
+ rrq/worker.py,sha256=KspmZOL6i_dfIypcBi0UpQDpz2NrCj3vEl6CwTNlLKo,42479
12
+ rrq-0.4.0.dist-info/METADATA,sha256=2SFZJlfgwFSpmWfylQ6rSV072HGXlA2MBcECJppV_DY,12914
13
+ rrq-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ rrq-0.4.0.dist-info/entry_points.txt,sha256=f8eFjk2ygDSyu9USwXGj5IM8xeyQqZgDa1rSrCj4Mis,36
15
+ rrq-0.4.0.dist-info/licenses/LICENSE,sha256=XDvu5hKdS2-_ByiSj3tiu_3zSsrXXoJsgbILGoMpKCw,554
16
+ rrq-0.4.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- rrq/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- rrq/cli.py,sha256=_LbaAH_w2a0VNRR0EctuE4afl-wccvMY2w2VbehFDEQ,16980
3
- rrq/client.py,sha256=5_bmZ05LKIfY9WFSKU-nYawEupsnrnHT2HewXfC2Ahg,7831
4
- rrq/constants.py,sha256=F_uZgBI3h00MctnEjBjiCGMrg5jUaz5Bz9I1vkyqNrs,1654
5
- rrq/exc.py,sha256=NJq3C7pUfcd47AB8kghIN8vdY0l90UrsHQEg4McBHP8,1281
6
- rrq/job.py,sha256=eUbl33QDqDMXPKpo-0dl0Mp29LWWmtbBgRw0sclcwJ4,4011
7
- rrq/registry.py,sha256=E9W_zx3QiKTBwMOGearaNpDKBDB87JIn0RlMQ3sAcP0,2925
8
- rrq/settings.py,sha256=BPKP4XjG7z475gqRgHZt4-IvvOs8uZefq4fPfD2Bepk,4350
9
- rrq/store.py,sha256=teO0Af8hzBiu7-dFn6_2lz5X90LAZXmtg0VDZuQoAwk,24972
10
- rrq/worker.py,sha256=y0UTziZVh4QbOPv24b8cqbm_xDBM0HtJLwPNYsJPWnE,40706
11
- rrq-0.3.7.dist-info/METADATA,sha256=fqMod1pTxebf7d4fCh5vRK0o9gkD4OAYg-02TNHJfN4,10193
12
- rrq-0.3.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
- rrq-0.3.7.dist-info/entry_points.txt,sha256=f8eFjk2ygDSyu9USwXGj5IM8xeyQqZgDa1rSrCj4Mis,36
14
- rrq-0.3.7.dist-info/licenses/LICENSE,sha256=XDvu5hKdS2-_ByiSj3tiu_3zSsrXXoJsgbILGoMpKCw,554
15
- rrq-0.3.7.dist-info/RECORD,,
File without changes