rrq 0.9.8__py3-none-macosx_11_0_arm64.whl → 0.9.9__py3-none-macosx_11_0_arm64.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
@@ -1,24 +1,24 @@
1
1
  from .client import RRQClient
2
- from .executor import (
2
+ from .job import JobResult, JobStatus
3
+ from .runner import (
3
4
  ExecutionContext,
4
5
  ExecutionError,
5
6
  ExecutionOutcome,
6
7
  ExecutionRequest,
7
- PythonExecutor,
8
+ PythonRunner,
8
9
  )
9
- from .executor_settings import PythonExecutorSettings
10
- from .registry import JobRegistry
11
- from .settings import ExecutorConfig, RRQSettings
10
+ from .runner_settings import PythonRunnerSettings
11
+ from .registry import Registry
12
12
 
13
13
  __all__ = [
14
14
  "RRQClient",
15
- "RRQSettings",
16
- "ExecutorConfig",
17
- "JobRegistry",
18
- "PythonExecutorSettings",
15
+ "Registry",
16
+ "JobResult",
17
+ "JobStatus",
18
+ "PythonRunnerSettings",
19
19
  "ExecutionContext",
20
20
  "ExecutionError",
21
21
  "ExecutionRequest",
22
22
  "ExecutionOutcome",
23
- "PythonExecutor",
23
+ "PythonRunner",
24
24
  ]
rrq/bin/README.md CHANGED
@@ -1,4 +1,5 @@
1
- This directory is reserved for the Rust `rrq` binary.
1
+ This directory is reserved for the Rust `rrq` binary and shared libraries.
2
2
 
3
- Packaging note: build pipelines should place the compiled `rrq` executable here
4
- before producing a wheel so the Python wrapper can locate it.
3
+ Packaging note: build pipelines should place the compiled `rrq` executable and
4
+ the `rrq_producer` shared library here before producing a wheel so the Python
5
+ wrapper can locate them.
Binary file
rrq/bin/rrq CHANGED
Binary file
rrq/client.py CHANGED
@@ -1,196 +1,155 @@
1
- """This module defines the RRQClient, used for enqueuing jobs into the RRQ system."""
1
+ """RRQ client for enqueuing jobs via the Rust producer FFI."""
2
2
 
3
- import logging
4
- import uuid
5
- from datetime import timezone, datetime, timedelta
6
- from typing import Any, Optional
3
+ from __future__ import annotations
7
4
 
8
- from .job import Job, JobStatus
9
- from .settings import RRQSettings
10
- from .store import JobStore
11
- from .telemetry import get_telemetry
5
+ import asyncio
6
+ from datetime import datetime, timezone
7
+ from typing import Any
12
8
 
13
- logger = logging.getLogger(__name__)
9
+ from .job import JobResult, JobStatus
10
+ from .producer_ffi import (
11
+ JobResultModel,
12
+ JobStatusResponseModel,
13
+ RustProducer,
14
+ RustProducerError,
15
+ )
16
+
17
+
18
+ def _normalize_datetime(value: datetime) -> str:
19
+ if value.tzinfo is None:
20
+ value = value.replace(tzinfo=timezone.utc)
21
+ else:
22
+ value = value.astimezone(timezone.utc)
23
+ return value.isoformat()
24
+
25
+
26
+ def _to_job_result(payload: JobResultModel) -> JobResult:
27
+ try:
28
+ status = JobStatus(payload.status)
29
+ except ValueError:
30
+ status = JobStatus.UNKNOWN
31
+ return JobResult(
32
+ status=status,
33
+ result=payload.result,
34
+ last_error=payload.last_error,
35
+ )
14
36
 
15
37
 
16
38
  class RRQClient:
17
- """Client interface for interacting with the RRQ (Reliable Redis Queue) system.
18
-
19
- Provides methods primarily for enqueuing jobs.
20
- """
21
-
22
- def __init__(self, settings: RRQSettings, job_store: Optional[JobStore] = None):
23
- """Initializes the RRQClient.
24
-
25
- Args:
26
- settings: The RRQSettings instance containing configuration.
27
- job_store: Optional JobStore instance. If not provided, a new one
28
- will be created based on the settings. This allows sharing
29
- a JobStore instance across multiple components.
30
- """
31
- self.settings = settings
32
- # If job_store is not provided, create one. This allows for flexibility:
33
- # - External management of JobStore (e.g., passed from an application context)
34
- # - Client creates its own if used standalone.
35
- if job_store:
36
- self.job_store = job_store
37
- self._created_store_internally = False
39
+ """Thin async wrapper around the Rust producer FFI."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ config: dict[str, Any] | None = None,
45
+ config_path: str | None = None,
46
+ ) -> None:
47
+ if config is not None and config_path is not None:
48
+ raise ValueError("Provide either config or config_path, not both.")
49
+ if config is not None:
50
+ self._producer = RustProducer.from_config(config)
38
51
  else:
39
- self.job_store = JobStore(settings=self.settings)
40
- self._created_store_internally = True
52
+ self._producer = RustProducer.from_toml(config_path)
41
53
 
42
54
  async def close(self) -> None:
43
- """Closes the underlying JobStore's Redis connection if it was created internally by this client."""
44
- if self._created_store_internally:
45
- await self.job_store.aclose()
55
+ self._producer.close()
46
56
 
47
57
  async def enqueue(
48
58
  self,
49
59
  function_name: str,
50
- *args: Any,
51
- _queue_name: Optional[str] = None,
52
- _job_id: Optional[str] = None,
53
- _unique_key: Optional[str] = None,
54
- _max_retries: Optional[int] = None,
55
- _job_timeout_seconds: Optional[int] = None,
56
- _defer_until: Optional[datetime] = None,
57
- _defer_by: Optional[timedelta] = None,
58
- _result_ttl_seconds: Optional[int] = None,
59
- **kwargs: Any,
60
- ) -> Optional[Job]:
61
- """Enqueues a job to be processed by RRQ workers.
62
-
63
- Args:
64
- function_name: The registered name of the handler function to execute.
65
- *args: Positional arguments to pass to the handler function.
66
- _queue_name: Specific queue to enqueue the job to. Defaults to `RRQSettings.default_queue_name`.
67
- _job_id: User-provided job ID for idempotency or tracking. If None, a UUID is generated.
68
- _unique_key: If provided, ensures that only one job with this key is active or recently completed.
69
- Uses a Redis lock with `default_unique_job_lock_ttl_seconds`.
70
- _max_retries: Maximum number of retries for this specific job. Overrides `RRQSettings.default_max_retries`.
71
- _job_timeout_seconds: Timeout (in seconds) for this specific job. Overrides `RRQSettings.default_job_timeout_seconds`.
72
- _defer_until: A specific datetime (timezone.utc recommended) when the job should become available for processing.
73
- _defer_by: A timedelta relative to now, specifying when the job should become available.
74
- _result_ttl_seconds: Time-to-live (in seconds) for the result of this specific job. Overrides `RRQSettings.default_result_ttl_seconds`.
75
- **kwargs: Keyword arguments to pass to the handler function.
76
-
77
- Returns:
78
- The created Job object if successfully enqueued, or None if enqueueing was denied
79
- (e.g., due to a unique key conflict).
80
- """
81
- # Determine job ID and queue name early for telemetry.
82
- job_id_to_use = _job_id or str(uuid.uuid4())
83
- queue_name_to_use = _queue_name or self.settings.default_queue_name
84
-
85
- telemetry = get_telemetry()
86
- with telemetry.enqueue_span(
87
- job_id=job_id_to_use,
88
- function_name=function_name,
89
- queue_name=queue_name_to_use,
90
- ) as trace_context:
91
- # Determine enqueue timestamp (after telemetry span starts).
92
- enqueue_time_utc = datetime.now(timezone.utc)
93
-
94
- # Compute base desired run time and unique lock TTL to cover deferral
95
- lock_ttl_seconds = self.settings.default_unique_job_lock_ttl_seconds
96
- desired_run_time = enqueue_time_utc
97
- if _defer_until is not None:
98
- dt = _defer_until
99
- if dt.tzinfo is None:
100
- dt = dt.replace(tzinfo=timezone.utc)
101
- elif dt.tzinfo != timezone.utc:
102
- dt = dt.astimezone(timezone.utc)
103
- desired_run_time = dt
104
- diff = (dt - enqueue_time_utc).total_seconds()
105
- if diff > 0:
106
- lock_ttl_seconds = max(lock_ttl_seconds, int(diff) + 1)
107
- elif _defer_by is not None:
108
- defer_secs = max(0, int(_defer_by.total_seconds()))
109
- desired_run_time = enqueue_time_utc + timedelta(seconds=defer_secs)
110
- lock_ttl_seconds = max(lock_ttl_seconds, defer_secs + 1)
111
-
112
- # Handle unique key with deferral if locked
113
- unique_acquired = False
114
- if _unique_key:
115
- remaining_ttl = await self.job_store.get_lock_ttl(_unique_key)
116
- if remaining_ttl > 0:
117
- desired_run_time = max(
118
- desired_run_time,
119
- enqueue_time_utc + timedelta(seconds=remaining_ttl),
120
- )
121
- else:
122
- acquired = await self.job_store.acquire_unique_job_lock(
123
- _unique_key, job_id_to_use, lock_ttl_seconds
124
- )
125
- if acquired:
126
- unique_acquired = True
127
- else:
128
- # Race: lock acquired after our check; defer by remaining TTL
129
- remaining = await self.job_store.get_lock_ttl(_unique_key)
130
- desired_run_time = max(
131
- desired_run_time,
132
- enqueue_time_utc
133
- + timedelta(seconds=max(0, int(remaining))),
134
- )
135
-
136
- # Create the Job instance with all provided details and defaults
137
- job = Job(
138
- id=job_id_to_use,
139
- function_name=function_name,
140
- job_args=list(args),
141
- job_kwargs=kwargs,
142
- enqueue_time=enqueue_time_utc,
143
- status=JobStatus.PENDING,
144
- current_retries=0,
145
- max_retries=(
146
- _max_retries
147
- if _max_retries is not None
148
- else self.settings.default_max_retries
149
- ),
150
- job_timeout_seconds=(
151
- _job_timeout_seconds
152
- if _job_timeout_seconds is not None
153
- else self.settings.default_job_timeout_seconds
154
- ),
155
- result_ttl_seconds=(
156
- _result_ttl_seconds
157
- if _result_ttl_seconds is not None
158
- else self.settings.default_result_ttl_seconds
159
- ),
160
- job_unique_key=_unique_key,
161
- queue_name=queue_name_to_use, # Store the target queue name
162
- trace_context=trace_context,
60
+ options: dict[str, Any] | None = None,
61
+ ) -> str:
62
+ options = dict(options) if options else {}
63
+ args = list(options.pop("args", []))
64
+ kwargs = dict(options.pop("kwargs", {}))
65
+
66
+ defer_until = options.get("defer_until")
67
+ if isinstance(defer_until, datetime):
68
+ options["defer_until"] = _normalize_datetime(defer_until)
69
+
70
+ request = {
71
+ "function_name": function_name,
72
+ "args": args,
73
+ "kwargs": kwargs,
74
+ "options": options,
75
+ }
76
+
77
+ try:
78
+ response = await asyncio.to_thread(self._producer.enqueue, request)
79
+ except RustProducerError as exc:
80
+ raise ValueError(str(exc)) from exc
81
+
82
+ job_id = response.get("job_id")
83
+ if not job_id:
84
+ raise ValueError("Producer did not return a job_id")
85
+ return job_id
86
+
87
+ async def enqueue_with_unique_key(
88
+ self,
89
+ function_name: str,
90
+ unique_key: str,
91
+ options: dict[str, Any] | None = None,
92
+ ) -> str:
93
+ merged = dict(options or {})
94
+ merged["unique_key"] = unique_key
95
+ return await self.enqueue(function_name, merged)
96
+
97
+ async def enqueue_with_rate_limit(
98
+ self,
99
+ function_name: str,
100
+ options: dict[str, Any],
101
+ ) -> str | None:
102
+ merged = dict(options)
103
+ request = {
104
+ "function_name": function_name,
105
+ "args": list(merged.pop("args", [])),
106
+ "kwargs": dict(merged.pop("kwargs", {})),
107
+ "options": merged,
108
+ "mode": "rate_limit",
109
+ }
110
+ try:
111
+ response = await asyncio.to_thread(self._producer.enqueue, request)
112
+ except RustProducerError as exc:
113
+ raise ValueError(str(exc)) from exc
114
+ return response.get("job_id")
115
+
116
+ async def enqueue_with_debounce(
117
+ self,
118
+ function_name: str,
119
+ options: dict[str, Any],
120
+ ) -> str:
121
+ merged = dict(options)
122
+ request = {
123
+ "function_name": function_name,
124
+ "args": list(merged.pop("args", [])),
125
+ "kwargs": dict(merged.pop("kwargs", {})),
126
+ "options": merged,
127
+ "mode": "debounce",
128
+ }
129
+ try:
130
+ response = await asyncio.to_thread(self._producer.enqueue, request)
131
+ except RustProducerError as exc:
132
+ raise ValueError(str(exc)) from exc
133
+ job_id = response.get("job_id")
134
+ if not job_id:
135
+ raise ValueError("Producer did not return a job_id")
136
+ return job_id
137
+
138
+ async def enqueue_deferred(
139
+ self,
140
+ function_name: str,
141
+ options: dict[str, Any],
142
+ ) -> str:
143
+ return await self.enqueue(function_name, options)
144
+
145
+ async def get_job_status(self, job_id: str) -> JobResult | None:
146
+ try:
147
+ response: JobStatusResponseModel = await asyncio.to_thread(
148
+ self._producer.get_job_status, job_id
163
149
  )
150
+ except RustProducerError as exc:
151
+ raise ValueError(str(exc)) from exc
164
152
 
165
- # Determine the score for the sorted set (queue)
166
- # Score is a millisecond timestamp for when the job should be processed.
167
- score_dt = desired_run_time
168
-
169
- # Ensure score_dt is timezone-aware (timezone.utc) if it's naive from user input
170
- if score_dt.tzinfo is None:
171
- score_dt = score_dt.replace(tzinfo=timezone.utc)
172
- elif score_dt.tzinfo != timezone.utc:
173
- # Convert to timezone.utc if it's aware but not timezone.utc
174
- score_dt = score_dt.astimezone(timezone.utc)
175
-
176
- score_timestamp_ms = int(score_dt.timestamp() * 1000)
177
- # Record when the job is next scheduled to run (for deferred execution)
178
- job.next_scheduled_run_time = score_dt
179
-
180
- # Save the full job definition and add to queue (ensure unique lock is released on error)
181
- try:
182
- await self.job_store.save_job_definition(job)
183
- await self.job_store.add_job_to_queue(
184
- queue_name_to_use,
185
- job.id,
186
- float(score_timestamp_ms),
187
- )
188
- except Exception:
189
- if unique_acquired and _unique_key is not None:
190
- await self.job_store.release_unique_job_lock(_unique_key)
191
- raise
192
-
193
- logger.debug(
194
- f"Enqueued job {job.id} ('{job.function_name}') to queue '{queue_name_to_use}' with score {score_timestamp_ms}"
195
- )
196
- return job
153
+ if not response.found or response.job is None:
154
+ return None
155
+ return _to_job_result(response.job)
rrq/exc.py CHANGED
@@ -37,12 +37,6 @@ class HandlerNotFound(RRQError):
37
37
  pass
38
38
 
39
39
 
40
- class JobNotFound(RRQError):
41
- """Exception raised when a job definition cannot be found in the JobStore."""
42
-
43
- pass
44
-
45
-
46
40
  class MaxRetriesExceeded(Exception):
47
41
  """Raised when a job fails after reaching its maximum retry limit."""
48
42