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.
@@ -0,0 +1,370 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Synchronous Redis Streams backend."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Protocol
9
+
10
+ from redqueue.backends.base import BaseMessageBackend
11
+ from redqueue.compat import RedisCapabilities, RedisVersion
12
+ from redqueue.config import QueueConfig
13
+ from redqueue.exceptions import AckError, BackendUnavailableError, RetryExceededError
14
+ from redqueue.message import Message, new_message_id
15
+ from redqueue.monitoring import MonitoringEventType
16
+
17
+
18
+ class SyncStreamRedis(Protocol):
19
+ """Redis command subset required by the synchronous Streams backend."""
20
+
21
+ def xadd(self, name: str, fields: dict[str, bytes | str], id: str = "*") -> str: ...
22
+
23
+ def xgroup_create(
24
+ self,
25
+ name: str,
26
+ groupname: str,
27
+ id: str = "0",
28
+ mkstream: bool = True,
29
+ ) -> bool: ...
30
+
31
+ def xreadgroup(
32
+ self,
33
+ groupname: str,
34
+ consumername: str,
35
+ streams: dict[str, str],
36
+ count: int | None = None,
37
+ block: int | None = None,
38
+ ) -> list[Any]: ...
39
+
40
+ def xack(self, name: str, groupname: str, *ids: str) -> int: ...
41
+
42
+ def xautoclaim(
43
+ self,
44
+ name: str,
45
+ groupname: str,
46
+ consumername: str,
47
+ min_idle_time: int,
48
+ start_id: str,
49
+ count: int | None = None,
50
+ ) -> Any: ...
51
+
52
+ def xpending_range(
53
+ self,
54
+ name: str,
55
+ groupname: str,
56
+ min: str,
57
+ max: str,
58
+ count: int,
59
+ ) -> list[Any]: ...
60
+
61
+ def xclaim(
62
+ self,
63
+ name: str,
64
+ groupname: str,
65
+ consumername: str,
66
+ min_idle_time: int,
67
+ message_ids: list[str],
68
+ ) -> list[Any]: ...
69
+
70
+
71
+ class StreamBackend(BaseMessageBackend):
72
+ """Reliable queue backend implemented with Redis Streams."""
73
+
74
+ backend_name = "stream"
75
+
76
+ def __init__(
77
+ self,
78
+ redis: SyncStreamRedis,
79
+ config: QueueConfig,
80
+ capabilities: RedisCapabilities,
81
+ ) -> None:
82
+ capabilities.require_streams()
83
+ super().__init__(config)
84
+ self.redis = redis
85
+ self.capabilities = capabilities
86
+ self._ensure_group()
87
+
88
+ @classmethod
89
+ def for_modern_redis(
90
+ cls,
91
+ redis: SyncStreamRedis,
92
+ config: QueueConfig,
93
+ ) -> StreamBackend:
94
+ return cls(redis, config, RedisCapabilities(RedisVersion(7, 0, 0)))
95
+
96
+ @property
97
+ def stream_key(self) -> str:
98
+ return self.config.key("stream")
99
+
100
+ @property
101
+ def dead_key(self) -> str:
102
+ return self.config.key("dead")
103
+
104
+ def publish(
105
+ self,
106
+ payload: Any,
107
+ *,
108
+ headers: dict[str, Any] | None = None,
109
+ message_id: str | None = None,
110
+ ) -> str:
111
+ message = Message(
112
+ id=message_id or new_message_id(),
113
+ queue=self.config.queue,
114
+ payload=payload,
115
+ headers=headers or {},
116
+ backend=self.backend_name,
117
+ )
118
+ self._publish_message(message)
119
+ return message.id
120
+
121
+ def consume(
122
+ self,
123
+ *,
124
+ timeout: float | None = None,
125
+ batch_size: int = 1,
126
+ ) -> Message | list[Message] | None:
127
+ block = int(timeout * 1000) if timeout is not None else None
128
+ response = self._execute(
129
+ "redis.xreadgroup",
130
+ self.redis.xreadgroup,
131
+ self.config.consumer_group,
132
+ self._consumer_name(),
133
+ {self.stream_key: ">"},
134
+ batch_size,
135
+ block,
136
+ )
137
+ messages = self._parse_read_response(response)
138
+ if batch_size <= 1:
139
+ if not messages:
140
+ return None
141
+ self._emit(MonitoringEventType.MESSAGE_CONSUMED, messages[0])
142
+ return messages[0]
143
+ for message in messages:
144
+ self._emit(MonitoringEventType.MESSAGE_CONSUMED, message)
145
+ return messages
146
+
147
+ def ack(self, message: Message) -> None:
148
+ if not message.raw_id:
149
+ raise AckError(
150
+ "stream message is missing raw Redis stream id",
151
+ action="message.ack",
152
+ queue=self.config.queue,
153
+ details={"message_id": message.id},
154
+ )
155
+ removed = self._execute(
156
+ "redis.xack",
157
+ self.redis.xack,
158
+ self.stream_key,
159
+ self.config.consumer_group,
160
+ message.raw_id,
161
+ )
162
+ if removed < 1:
163
+ raise AckError(
164
+ "stream message was not acknowledged",
165
+ action="message.ack",
166
+ queue=self.config.queue,
167
+ details={"message_id": message.id, "raw_id": message.raw_id},
168
+ )
169
+ self._emit(MonitoringEventType.MESSAGE_ACKED, message)
170
+
171
+ def nack(self, message: Message, *, requeue: bool = True) -> None:
172
+ if requeue:
173
+ self.publish(
174
+ message.payload,
175
+ headers=message.headers,
176
+ message_id=message.id,
177
+ )
178
+ self.ack(message)
179
+ else:
180
+ self._move_to_dead(message)
181
+ self._emit(
182
+ MonitoringEventType.MESSAGE_NACKED,
183
+ message,
184
+ attributes={"requeue": requeue},
185
+ )
186
+
187
+ def retry(
188
+ self,
189
+ message: Message,
190
+ *,
191
+ delay: float | None = None,
192
+ reason: str | None = None,
193
+ ) -> None:
194
+ if message.attempts >= self.config.retry.max_retries:
195
+ self._move_to_dead(message)
196
+ self._emit(
197
+ MonitoringEventType.MESSAGE_DEAD_LETTERED,
198
+ message,
199
+ attributes={"reason": reason, "attempts": message.attempts},
200
+ )
201
+ raise RetryExceededError(
202
+ "stream message exceeded max retries and was moved to dead letter",
203
+ action="message.retry",
204
+ queue=self.config.queue,
205
+ details={
206
+ "message_id": message.id,
207
+ "attempts": message.attempts,
208
+ "max_retries": self.config.retry.max_retries,
209
+ },
210
+ )
211
+ retried = message.with_attempt()
212
+ self._publish_message(retried)
213
+ self.ack(message)
214
+ self._emit(
215
+ MonitoringEventType.MESSAGE_RETRIED,
216
+ retried,
217
+ attributes={"delay": delay, "reason": reason},
218
+ )
219
+
220
+ def recover_pending(self, *, min_idle_ms: int, limit: int = 100) -> list[Message]:
221
+ if not self.capabilities.supports_streams_auto_claim:
222
+ pending = self._execute(
223
+ "redis.xpending_range",
224
+ self.redis.xpending_range,
225
+ self.stream_key,
226
+ self.config.consumer_group,
227
+ "-",
228
+ "+",
229
+ limit,
230
+ )
231
+ message_ids = [self._pending_id(item) for item in pending]
232
+ if not message_ids:
233
+ return []
234
+ claimed = self._execute(
235
+ "redis.xclaim",
236
+ self.redis.xclaim,
237
+ self.stream_key,
238
+ self.config.consumer_group,
239
+ self._consumer_name(),
240
+ min_idle_ms,
241
+ message_ids,
242
+ )
243
+ return [
244
+ self._decode_stream_entry(str(raw_id), fields)
245
+ for raw_id, fields in claimed
246
+ ]
247
+ response = self._execute(
248
+ "redis.xautoclaim",
249
+ self.redis.xautoclaim,
250
+ self.stream_key,
251
+ self.config.consumer_group,
252
+ self._consumer_name(),
253
+ min_idle_ms,
254
+ "0-0",
255
+ limit,
256
+ )
257
+ return self._parse_autoclaim_response(response)
258
+
259
+ def dead_letters(self, *, limit: int = 100) -> list[Message]:
260
+ response = self._execute(
261
+ "redis.xreadgroup",
262
+ self.redis.xreadgroup,
263
+ self.config.consumer_group,
264
+ self._consumer_name(),
265
+ {self.dead_key: ">"},
266
+ limit,
267
+ None,
268
+ )
269
+ return self._parse_read_response(response)
270
+
271
+ def requeue_dead(self, message: Message) -> None:
272
+ self.publish(message.payload, headers=message.headers, message_id=message.id)
273
+ if message.raw_id:
274
+ self._execute(
275
+ "redis.xack",
276
+ self.redis.xack,
277
+ self.dead_key,
278
+ self.config.consumer_group,
279
+ message.raw_id,
280
+ )
281
+
282
+ def _publish_message(self, message: Message) -> str:
283
+ raw_id = self._execute(
284
+ "redis.xadd",
285
+ self.redis.xadd,
286
+ self.stream_key,
287
+ {"payload": self._encode(message)},
288
+ )
289
+ published = message.with_backend(self.backend_name, raw_id=str(raw_id))
290
+ self._emit(
291
+ MonitoringEventType.MESSAGE_PUBLISHED,
292
+ published,
293
+ attributes={"key": self.stream_key},
294
+ )
295
+ return str(raw_id)
296
+
297
+ def _ensure_group(self) -> None:
298
+ try:
299
+ self.redis.xgroup_create(
300
+ self.stream_key,
301
+ self.config.consumer_group,
302
+ id="0",
303
+ mkstream=True,
304
+ )
305
+ except Exception as exc:
306
+ if "BUSYGROUP" in str(exc):
307
+ return
308
+ self._emit_backend_error("redis.xgroup_create", str(exc))
309
+ raise BackendUnavailableError(
310
+ "Redis Streams group initialization failed",
311
+ action="redis.xgroup_create",
312
+ queue=self.config.queue,
313
+ ) from exc
314
+
315
+ def _move_to_dead(self, message: Message) -> None:
316
+ self._execute(
317
+ "redis.xadd",
318
+ self.redis.xadd,
319
+ self.dead_key,
320
+ {"payload": self._encode(message)},
321
+ )
322
+ self.ack(message)
323
+
324
+ def _parse_read_response(self, response: list[Any]) -> list[Message]:
325
+ messages: list[Message] = []
326
+ for _stream, entries in response or []:
327
+ for raw_id, fields in entries:
328
+ messages.append(self._decode_stream_entry(str(raw_id), fields))
329
+ return messages
330
+
331
+ def _parse_autoclaim_response(self, response: Any) -> list[Message]:
332
+ if not response:
333
+ return []
334
+ entries = response[1] if len(response) > 1 else []
335
+ return [
336
+ self._decode_stream_entry(str(raw_id), fields)
337
+ for raw_id, fields in entries
338
+ ]
339
+
340
+ def _decode_stream_entry(self, raw_id: str, fields: dict[Any, Any]) -> Message:
341
+ payload = fields.get("payload") or fields.get(b"payload")
342
+ if payload is None:
343
+ raise BackendUnavailableError(
344
+ "stream entry is missing payload field",
345
+ action="message.decode",
346
+ queue=self.config.queue,
347
+ details={"raw_id": raw_id},
348
+ )
349
+ return self._decode(payload).with_backend(self.backend_name, raw_id=raw_id)
350
+
351
+ def _consumer_name(self) -> str:
352
+ return self.config.consumer_name or "redqueue-consumer"
353
+
354
+ @staticmethod
355
+ def _pending_id(item: Any) -> str:
356
+ if isinstance(item, dict):
357
+ value = item.get("message_id") or item.get("message-id") or item.get("id")
358
+ return str(value)
359
+ return str(item[0])
360
+
361
+ def _execute(self, action: str, func: Any, *args: Any) -> Any:
362
+ try:
363
+ return func(*args)
364
+ except Exception as exc:
365
+ self._emit_backend_error(action, str(exc))
366
+ raise BackendUnavailableError(
367
+ "Redis Streams backend command failed",
368
+ action=action,
369
+ queue=self.config.queue,
370
+ ) from exc
redqueue/client.py ADDED
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Author: SpringMirror-pear
3
+
4
+ """Synchronous RedQueue client."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, cast
9
+
10
+ from redis import Redis
11
+
12
+ from redqueue.backends import DelayBackend, ListBackend, StreamBackend
13
+ from redqueue.compat import RedisCapabilities, RedisInfoClient, detect_capabilities
14
+ from redqueue.config import BackendType, QueueConfig
15
+ from redqueue.message import Message
16
+ from redqueue.monitoring import MonitoringEvent, MonitoringEventType
17
+
18
+
19
+ class QueueClient:
20
+ """Synchronous queue client."""
21
+
22
+ def __init__(
23
+ self,
24
+ config: QueueConfig,
25
+ *,
26
+ redis: Any | None = None,
27
+ capabilities: RedisCapabilities | None = None,
28
+ ) -> None:
29
+ self.config = config
30
+ self.redis = redis
31
+ self.capabilities = capabilities
32
+ self.backend = self._create_backend()
33
+ self.delay_backend = self._create_delay_backend()
34
+ self.config.monitoring.emit(
35
+ MonitoringEvent(
36
+ type=MonitoringEventType.CLIENT_CREATED,
37
+ queue=config.queue,
38
+ backend=config.backend_type.value,
39
+ )
40
+ )
41
+
42
+ @classmethod
43
+ def from_url(
44
+ cls,
45
+ url: str,
46
+ *,
47
+ queue: str,
48
+ backend: str | BackendType = BackendType.LIST,
49
+ **options: Any,
50
+ ) -> QueueClient:
51
+ redis = options.pop("redis", None) or Redis.from_url(url)
52
+ capabilities = options.pop("capabilities", None) or detect_capabilities(
53
+ cast(RedisInfoClient, redis)
54
+ )
55
+ config = QueueConfig(queue=queue, backend=backend, **options)
56
+ return cls(config=config, redis=redis, capabilities=capabilities)
57
+
58
+ def publish(
59
+ self,
60
+ payload: Any,
61
+ *,
62
+ delay: float | None = None,
63
+ headers: dict[str, Any] | None = None,
64
+ message_id: str | None = None,
65
+ ) -> str:
66
+ if delay is not None:
67
+ return self.delay(payload, delay_seconds=delay, headers=headers)
68
+ return self.backend.publish(
69
+ payload,
70
+ headers=headers,
71
+ message_id=message_id,
72
+ )
73
+
74
+ def consume(
75
+ self,
76
+ *,
77
+ timeout: float | None = None,
78
+ batch_size: int = 1,
79
+ ) -> Message | list[Message] | None:
80
+ return self.backend.consume(timeout=timeout, batch_size=batch_size)
81
+
82
+ def ack(self, message: Message) -> None:
83
+ self.backend.ack(message)
84
+
85
+ def nack(self, message: Message, *, requeue: bool = True) -> None:
86
+ self.backend.nack(message, requeue=requeue)
87
+
88
+ def retry(
89
+ self,
90
+ message: Message,
91
+ *,
92
+ delay: float | None = None,
93
+ reason: str | None = None,
94
+ ) -> None:
95
+ self.backend.retry(message, delay=delay, reason=reason)
96
+
97
+ def delay(
98
+ self,
99
+ payload: Any,
100
+ *,
101
+ delay_seconds: float | None = None,
102
+ run_at: float | None = None,
103
+ headers: dict[str, Any] | None = None,
104
+ ) -> str:
105
+ message_id = self.delay_backend.delay(
106
+ payload,
107
+ delay_seconds=delay_seconds,
108
+ run_at=run_at,
109
+ headers=headers,
110
+ )
111
+ self.config.monitoring.emit(
112
+ MonitoringEvent(
113
+ type=MonitoringEventType.DELAY_SCHEDULED,
114
+ queue=self.config.queue,
115
+ message_id=message_id,
116
+ backend=self.config.backend_type.value,
117
+ attributes={"delay_seconds": delay_seconds, "run_at": run_at},
118
+ )
119
+ )
120
+ return message_id
121
+
122
+ def schedule_due(self, *, limit: int = 100, now: float | None = None) -> int:
123
+ return self.delay_backend.schedule_due(limit=limit, now=now)
124
+
125
+ def recover_stale(self, *, min_idle_ms: int | None = None, limit: int = 100) -> int:
126
+ if isinstance(self.backend, StreamBackend):
127
+ idle = min_idle_ms or int(self.config.visibility_timeout_seconds * 1000)
128
+ return len(self.backend.recover_pending(min_idle_ms=idle, limit=limit))
129
+ return self.backend.recover_stale(limit=limit)
130
+
131
+ def dead_letters(self, *, limit: int = 100) -> list[Message]:
132
+ return self.backend.dead_letters(limit=limit)
133
+
134
+ def requeue_dead(self, message: Message) -> None:
135
+ self.backend.requeue_dead(message)
136
+
137
+ def close(self) -> None:
138
+ close = getattr(self.redis, "close", None)
139
+ if close is not None:
140
+ close()
141
+
142
+ def _create_backend(self) -> ListBackend | StreamBackend:
143
+ if self.config.backend_type is BackendType.LIST:
144
+ if self.redis is None:
145
+ raise TypeError("redis client is required for List backend")
146
+ capabilities = self.capabilities or detect_capabilities(
147
+ cast(RedisInfoClient, self.redis)
148
+ )
149
+ return ListBackend(self.redis, self.config, capabilities)
150
+ if self.config.backend_type is BackendType.STREAM:
151
+ if self.redis is None:
152
+ raise TypeError("redis client is required for Streams backend")
153
+ capabilities = self.capabilities or detect_capabilities(
154
+ cast(RedisInfoClient, self.redis)
155
+ )
156
+ return StreamBackend(self.redis, self.config, capabilities)
157
+ raise NotImplementedError(
158
+ f"backend {self.config.backend_type.value!r} is not implemented"
159
+ )
160
+
161
+ def _create_delay_backend(self) -> DelayBackend:
162
+ if self.redis is None:
163
+ raise TypeError("redis client is required for delayed tasks")
164
+ capabilities = self.capabilities or detect_capabilities(
165
+ cast(RedisInfoClient, self.redis)
166
+ )
167
+ capabilities.require_delay_sorted_set()
168
+ return DelayBackend(self.redis, self.config, self.backend)