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 +72 -0
- redqueue/_version.py +6 -0
- redqueue/async_client.py +194 -0
- redqueue/backends/__init__.py +20 -0
- redqueue/backends/async_delay.py +242 -0
- redqueue/backends/async_list.py +308 -0
- redqueue/backends/async_stream.py +394 -0
- redqueue/backends/base.py +103 -0
- redqueue/backends/delay.py +243 -0
- redqueue/backends/list.py +303 -0
- redqueue/backends/stream.py +370 -0
- redqueue/client.py +168 -0
- redqueue/compat.py +184 -0
- redqueue/config.py +155 -0
- redqueue/exceptions.py +167 -0
- redqueue/message.py +67 -0
- redqueue/monitoring.py +112 -0
- redqueue/serialization.py +79 -0
- redqueue-0.10.0.dist-info/METADATA +312 -0
- redqueue-0.10.0.dist-info/RECORD +23 -0
- redqueue-0.10.0.dist-info/WHEEL +4 -0
- redqueue-0.10.0.dist-info/licenses/LICENSE +158 -0
- redqueue-0.10.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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
|