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