redqueue 0.10.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.
redqueue/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Redis-backed message queue library."""
5
+
6
+ from redqueue._version import __version__
7
+ from redqueue.async_client import AsyncQueueClient
8
+ from redqueue.client import QueueClient
9
+ from redqueue.compat import (
10
+ RedisCapabilities,
11
+ RedisVersion,
12
+ detect_capabilities,
13
+ detect_capabilities_async,
14
+ extract_redis_version,
15
+ )
16
+ from redqueue.config import BackendType, QueueConfig, RetryConfig
17
+ from redqueue.exceptions import (
18
+ AckError,
19
+ BackendUnavailableError,
20
+ ErrorContext,
21
+ MessageDecodeError,
22
+ MessageEncodeError,
23
+ QueueConfigError,
24
+ RedisCompatibilityError,
25
+ RedQueueError,
26
+ RetryExceededError,
27
+ )
28
+ from redqueue.message import Message, new_message_id
29
+ from redqueue.monitoring import (
30
+ CompositeMonitoringHook,
31
+ InMemoryMonitoringHook,
32
+ MonitoringEvent,
33
+ MonitoringEventType,
34
+ MonitoringHook,
35
+ NoopMonitoringHook,
36
+ SafeMonitoringHook,
37
+ )
38
+ from redqueue.serialization import JsonSerializer, Serializer
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "AckError",
43
+ "AsyncQueueClient",
44
+ "BackendType",
45
+ "BackendUnavailableError",
46
+ "ErrorContext",
47
+ "CompositeMonitoringHook",
48
+ "InMemoryMonitoringHook",
49
+ "JsonSerializer",
50
+ "Message",
51
+ "MessageDecodeError",
52
+ "MessageEncodeError",
53
+ "MonitoringEvent",
54
+ "MonitoringEventType",
55
+ "MonitoringHook",
56
+ "NoopMonitoringHook",
57
+ "QueueClient",
58
+ "QueueConfig",
59
+ "QueueConfigError",
60
+ "RedisCapabilities",
61
+ "RedQueueError",
62
+ "RedisCompatibilityError",
63
+ "RedisVersion",
64
+ "RetryConfig",
65
+ "RetryExceededError",
66
+ "SafeMonitoringHook",
67
+ "Serializer",
68
+ "detect_capabilities",
69
+ "detect_capabilities_async",
70
+ "extract_redis_version",
71
+ "new_message_id",
72
+ ]
redqueue/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Package version."""
5
+
6
+ __version__ = "0.10.0"
@@ -0,0 +1,194 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Asynchronous RedQueue client."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, cast
9
+
10
+ from redis.asyncio import Redis
11
+
12
+ from redqueue.backends import AsyncDelayBackend, AsyncListBackend, AsyncStreamBackend
13
+ from redqueue.compat import (
14
+ AsyncRedisInfoClient,
15
+ RedisCapabilities,
16
+ detect_capabilities_async,
17
+ )
18
+ from redqueue.config import BackendType, QueueConfig
19
+ from redqueue.message import Message
20
+
21
+
22
+ class AsyncQueueClient:
23
+ """Asynchronous queue client with API parity to QueueClient."""
24
+
25
+ def __init__(
26
+ self,
27
+ config: QueueConfig,
28
+ *,
29
+ redis: Any | None = None,
30
+ capabilities: RedisCapabilities | None = None,
31
+ ) -> None:
32
+ self.config = config
33
+ self.redis = redis
34
+ self.capabilities = capabilities
35
+ self.backend: AsyncListBackend | AsyncStreamBackend | None = None
36
+ self.delay_backend: AsyncDelayBackend | None = None
37
+
38
+ @classmethod
39
+ async def from_url(
40
+ cls,
41
+ url: str,
42
+ *,
43
+ queue: str,
44
+ backend: str | BackendType = BackendType.LIST,
45
+ **options: Any,
46
+ ) -> AsyncQueueClient:
47
+ redis = options.pop("redis", None) or Redis.from_url(url)
48
+ capabilities = options.pop(
49
+ "capabilities",
50
+ None,
51
+ ) or await detect_capabilities_async(cast(AsyncRedisInfoClient, redis))
52
+ config = QueueConfig(queue=queue, backend=backend, **options)
53
+ client = cls(config=config, redis=redis, capabilities=capabilities)
54
+ await client._ensure_backend()
55
+ return client
56
+
57
+ async def publish(
58
+ self,
59
+ payload: Any,
60
+ *,
61
+ delay: float | None = None,
62
+ headers: dict[str, Any] | None = None,
63
+ message_id: str | None = None,
64
+ ) -> str:
65
+ if delay is not None:
66
+ return await self.delay(payload, delay_seconds=delay, headers=headers)
67
+ backend = await self._ensure_backend()
68
+ return await backend.publish(
69
+ payload,
70
+ headers=headers,
71
+ message_id=message_id,
72
+ )
73
+
74
+ async def consume(
75
+ self,
76
+ *,
77
+ timeout: float | None = None,
78
+ batch_size: int = 1,
79
+ ) -> Message | list[Message] | None:
80
+ backend = await self._ensure_backend()
81
+ return await backend.consume(
82
+ timeout=timeout,
83
+ batch_size=batch_size,
84
+ )
85
+
86
+ async def ack(self, message: Message) -> None:
87
+ await (await self._ensure_backend()).ack(message)
88
+
89
+ async def nack(self, message: Message, *, requeue: bool = True) -> None:
90
+ await (await self._ensure_backend()).nack(message, requeue=requeue)
91
+
92
+ async def retry(
93
+ self,
94
+ message: Message,
95
+ *,
96
+ delay: float | None = None,
97
+ reason: str | None = None,
98
+ ) -> None:
99
+ await (await self._ensure_backend()).retry(message, delay=delay, reason=reason)
100
+
101
+ async def delay(
102
+ self,
103
+ payload: Any,
104
+ *,
105
+ delay_seconds: float | None = None,
106
+ run_at: float | None = None,
107
+ headers: dict[str, Any] | None = None,
108
+ ) -> str:
109
+ delay_backend = await self._ensure_delay_backend()
110
+ message_id = await delay_backend.delay(
111
+ payload,
112
+ delay_seconds=delay_seconds,
113
+ run_at=run_at,
114
+ headers=headers,
115
+ )
116
+ return message_id
117
+
118
+ async def schedule_due(self, *, limit: int = 100, now: float | None = None) -> int:
119
+ return await (await self._ensure_delay_backend()).schedule_due(
120
+ limit=limit,
121
+ now=now,
122
+ )
123
+
124
+ async def recover_stale(
125
+ self,
126
+ *,
127
+ min_idle_ms: int | None = None,
128
+ limit: int = 100,
129
+ ) -> int:
130
+ backend = await self._ensure_backend()
131
+ if isinstance(backend, AsyncStreamBackend):
132
+ idle = min_idle_ms or int(self.config.visibility_timeout_seconds * 1000)
133
+ return len(await backend.recover_pending(min_idle_ms=idle, limit=limit))
134
+ return await backend.recover_stale(limit=limit)
135
+
136
+ async def dead_letters(self, *, limit: int = 100) -> list[Message]:
137
+ return await (await self._ensure_backend()).dead_letters(limit=limit)
138
+
139
+ async def requeue_dead(self, message: Message) -> None:
140
+ await (await self._ensure_backend()).requeue_dead(message)
141
+
142
+ async def close(self) -> None:
143
+ close = getattr(self.redis, "aclose", None) or getattr(
144
+ self.redis,
145
+ "close",
146
+ None,
147
+ )
148
+ if close is not None:
149
+ result = close()
150
+ if hasattr(result, "__await__"):
151
+ await result
152
+
153
+ async def _ensure_backend(self) -> AsyncListBackend | AsyncStreamBackend:
154
+ if self.backend is not None:
155
+ return self.backend
156
+ if self.config.backend_type is BackendType.LIST:
157
+ if self.redis is None:
158
+ raise TypeError("redis client is required for async List backend")
159
+ capabilities = self.capabilities
160
+ if capabilities is None:
161
+ raise TypeError("Redis capabilities are required before backend use")
162
+ self.backend = AsyncListBackend(self.redis, self.config, capabilities)
163
+ return self.backend
164
+ if self.config.backend_type is BackendType.STREAM:
165
+ if self.redis is None:
166
+ raise TypeError("redis client is required for async Streams backend")
167
+ capabilities = self.capabilities
168
+ if capabilities is None:
169
+ raise TypeError("Redis capabilities are required before backend use")
170
+ self.backend = await AsyncStreamBackend.create(
171
+ self.redis,
172
+ self.config,
173
+ capabilities,
174
+ )
175
+ return self.backend
176
+ raise NotImplementedError(
177
+ f"backend {self.config.backend_type.value!r} is not implemented"
178
+ )
179
+
180
+ async def _ensure_delay_backend(self) -> AsyncDelayBackend:
181
+ if self.delay_backend is not None:
182
+ return self.delay_backend
183
+ if self.redis is None:
184
+ raise TypeError("redis client is required for async delayed tasks")
185
+ capabilities = self.capabilities
186
+ if capabilities is None:
187
+ raise TypeError("Redis capabilities are required before delay backend use")
188
+ capabilities.require_delay_sorted_set()
189
+ self.delay_backend = AsyncDelayBackend(
190
+ self.redis,
191
+ self.config,
192
+ await self._ensure_backend(),
193
+ )
194
+ return self.delay_backend
@@ -0,0 +1,20 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Backend implementations for RedQueue."""
5
+
6
+ from redqueue.backends.async_delay import AsyncDelayBackend
7
+ from redqueue.backends.async_list import AsyncListBackend
8
+ from redqueue.backends.async_stream import AsyncStreamBackend
9
+ from redqueue.backends.delay import DelayBackend
10
+ from redqueue.backends.list import ListBackend
11
+ from redqueue.backends.stream import StreamBackend
12
+
13
+ __all__ = [
14
+ "AsyncDelayBackend",
15
+ "AsyncListBackend",
16
+ "AsyncStreamBackend",
17
+ "DelayBackend",
18
+ "ListBackend",
19
+ "StreamBackend",
20
+ ]
@@ -0,0 +1,242 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Asynchronous delayed task backend based on Redis Sorted Set."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from time import time
9
+ from typing import Any, Protocol
10
+
11
+ from redqueue.config import QueueConfig
12
+ from redqueue.exceptions import BackendUnavailableError, QueueConfigError
13
+ from redqueue.message import Message, new_message_id
14
+ from redqueue.monitoring import MonitoringEvent, MonitoringEventType
15
+
16
+
17
+ class AsyncDelayRedis(Protocol):
18
+ """Redis command subset required by the async delayed task backend."""
19
+
20
+ async def set(self, name: str, value: bytes) -> bool: ...
21
+
22
+ async def get(self, name: str) -> bytes | None: ...
23
+
24
+ async def delete(self, *names: str) -> int: ...
25
+
26
+ async def zadd(self, name: str, mapping: dict[str, float]) -> int: ...
27
+
28
+ async def zrangebyscore(
29
+ self,
30
+ name: str,
31
+ min: float | str,
32
+ max: float | str,
33
+ start: int | None = None,
34
+ num: int | None = None,
35
+ ) -> list[str | bytes]: ...
36
+
37
+ async def zrem(self, name: str, *values: str) -> int: ...
38
+
39
+
40
+ class AsyncDelayBackend:
41
+ """Async delayed task scheduler implemented with Redis Sorted Set."""
42
+
43
+ backend_name = "delay"
44
+
45
+ def __init__(
46
+ self,
47
+ redis: AsyncDelayRedis,
48
+ config: QueueConfig,
49
+ publisher: Any,
50
+ ) -> None:
51
+ self.redis = redis
52
+ self.config = config
53
+ self.publisher = publisher
54
+
55
+ @property
56
+ def delayed_key(self) -> str:
57
+ return self.config.key("delayed")
58
+
59
+ def payload_key(self, message_id: str) -> str:
60
+ return self.config.key(f"payload:{message_id}")
61
+
62
+ async def delay(
63
+ self,
64
+ payload: Any,
65
+ *,
66
+ delay_seconds: float | None = None,
67
+ run_at: float | None = None,
68
+ headers: dict[str, Any] | None = None,
69
+ message_id: str | None = None,
70
+ ) -> str:
71
+ available_at = self._available_at(delay_seconds=delay_seconds, run_at=run_at)
72
+ message = Message(
73
+ id=message_id or new_message_id(),
74
+ queue=self.config.queue,
75
+ payload=payload,
76
+ headers=headers or {},
77
+ available_at=available_at,
78
+ backend=self.backend_name,
79
+ )
80
+ await self._execute(
81
+ "redis.set",
82
+ self.redis.set,
83
+ self.payload_key(message.id),
84
+ self._encode(message),
85
+ )
86
+ await self._execute(
87
+ "redis.zadd",
88
+ self.redis.zadd,
89
+ self.delayed_key,
90
+ {message.id: available_at},
91
+ )
92
+ self._emit(MonitoringEventType.DELAY_SCHEDULED, message)
93
+ return message.id
94
+
95
+ async def schedule_due(self, *, limit: int = 100, now: float | None = None) -> int:
96
+ now_value = time() if now is None else now
97
+ due_ids = await self._execute(
98
+ "redis.zrangebyscore",
99
+ self.redis.zrangebyscore,
100
+ self.delayed_key,
101
+ "-inf",
102
+ now_value,
103
+ 0,
104
+ limit,
105
+ )
106
+ released = 0
107
+ for raw_message_id in due_ids:
108
+ message_id = self._to_text(raw_message_id)
109
+ removed = await self._execute(
110
+ "redis.zrem",
111
+ self.redis.zrem,
112
+ self.delayed_key,
113
+ message_id,
114
+ )
115
+ if removed < 1:
116
+ continue
117
+ message = await self._load_message(message_id)
118
+ try:
119
+ await self.publisher.publish(
120
+ message.payload,
121
+ headers=message.headers,
122
+ message_id=message.id,
123
+ )
124
+ except Exception:
125
+ await self._execute(
126
+ "redis.zadd",
127
+ self.redis.zadd,
128
+ self.delayed_key,
129
+ {message_id: message.available_at or now_value},
130
+ )
131
+ raise
132
+ await self._execute(
133
+ "redis.delete",
134
+ self.redis.delete,
135
+ self.payload_key(message_id),
136
+ )
137
+ self._emit(MonitoringEventType.DELAY_RELEASED, message)
138
+ released += 1
139
+ return released
140
+
141
+ async def _load_message(self, message_id: str) -> Message:
142
+ payload = await self._execute(
143
+ "redis.get",
144
+ self.redis.get,
145
+ self.payload_key(message_id),
146
+ )
147
+ if payload is None:
148
+ raise BackendUnavailableError(
149
+ "delayed payload is missing",
150
+ action="delay.load",
151
+ queue=self.config.queue,
152
+ details={"message_id": message_id},
153
+ )
154
+ return self._decode(payload)
155
+
156
+ def _available_at(
157
+ self,
158
+ *,
159
+ delay_seconds: float | None,
160
+ run_at: float | None,
161
+ ) -> float:
162
+ if delay_seconds is not None and run_at is not None:
163
+ raise QueueConfigError("delay_seconds and run_at cannot both be set")
164
+ if delay_seconds is not None:
165
+ if delay_seconds < 0:
166
+ raise QueueConfigError(
167
+ "delay_seconds must be greater than or equal to 0"
168
+ )
169
+ return time() + delay_seconds
170
+ if run_at is not None:
171
+ if run_at < 0:
172
+ raise QueueConfigError("run_at must be greater than or equal to 0")
173
+ return run_at
174
+ return time()
175
+
176
+ def _encode(self, message: Message) -> bytes:
177
+ envelope = {
178
+ "id": message.id,
179
+ "queue": message.queue,
180
+ "payload": message.payload,
181
+ "headers": message.headers,
182
+ "attempts": message.attempts,
183
+ "created_at": message.created_at,
184
+ "available_at": message.available_at,
185
+ "backend": self.backend_name,
186
+ "raw_id": message.raw_id,
187
+ }
188
+ return self.config.serializer.encode(envelope, queue=self.config.queue)
189
+
190
+ def _decode(self, payload: bytes) -> Message:
191
+ envelope = self.config.serializer.decode(payload, queue=self.config.queue)
192
+ if not isinstance(envelope, dict):
193
+ raise BackendUnavailableError(
194
+ "decoded delayed message envelope must be a mapping",
195
+ action="delay.decode",
196
+ queue=self.config.queue,
197
+ details={"payload_type": type(envelope).__name__},
198
+ )
199
+ return Message(
200
+ id=str(envelope["id"]),
201
+ queue=str(envelope["queue"]),
202
+ payload=envelope["payload"],
203
+ headers=dict(envelope.get("headers") or {}),
204
+ attempts=int(envelope.get("attempts") or 0),
205
+ created_at=float(envelope["created_at"]),
206
+ available_at=envelope.get("available_at"),
207
+ backend=self.backend_name,
208
+ raw_id=envelope.get("raw_id"),
209
+ )
210
+
211
+ async def _execute(self, action: str, func: Any, *args: Any) -> Any:
212
+ try:
213
+ return await func(*args)
214
+ except Exception as exc:
215
+ self.config.monitoring.emit(
216
+ MonitoringEvent(
217
+ type=MonitoringEventType.BACKEND_ERROR,
218
+ queue=self.config.queue,
219
+ backend=self.backend_name,
220
+ error=str(exc),
221
+ attributes={"action": action},
222
+ )
223
+ )
224
+ raise BackendUnavailableError(
225
+ "Redis async delayed task backend command failed",
226
+ action=action,
227
+ queue=self.config.queue,
228
+ ) from exc
229
+
230
+ def _emit(self, event_type: MonitoringEventType, message: Message) -> None:
231
+ self.config.monitoring.emit(
232
+ MonitoringEvent(
233
+ type=event_type,
234
+ queue=self.config.queue,
235
+ message_id=message.id,
236
+ backend=self.backend_name,
237
+ )
238
+ )
239
+
240
+ @staticmethod
241
+ def _to_text(value: str | bytes) -> str:
242
+ return value.decode() if isinstance(value, bytes) else value