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,103 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Shared backend helpers."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from redqueue.config import QueueConfig
|
|
11
|
+
from redqueue.exceptions import BackendUnavailableError
|
|
12
|
+
from redqueue.message import Message
|
|
13
|
+
from redqueue.monitoring import MonitoringEvent, MonitoringEventType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseMessageBackend:
|
|
17
|
+
"""Shared message envelope and monitoring behavior."""
|
|
18
|
+
|
|
19
|
+
backend_name = "backend"
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: QueueConfig) -> None:
|
|
22
|
+
self.config = config
|
|
23
|
+
|
|
24
|
+
def _encode(self, message: Message) -> bytes:
|
|
25
|
+
envelope = {
|
|
26
|
+
"id": message.id,
|
|
27
|
+
"queue": message.queue,
|
|
28
|
+
"payload": message.payload,
|
|
29
|
+
"headers": message.headers,
|
|
30
|
+
"attempts": message.attempts,
|
|
31
|
+
"created_at": message.created_at,
|
|
32
|
+
"available_at": message.available_at,
|
|
33
|
+
"backend": self.backend_name,
|
|
34
|
+
"raw_id": message.raw_id,
|
|
35
|
+
}
|
|
36
|
+
return self.config.serializer.encode(envelope, queue=self.config.queue)
|
|
37
|
+
|
|
38
|
+
def _decode(self, payload: bytes) -> Message:
|
|
39
|
+
envelope = self.config.serializer.decode(payload, queue=self.config.queue)
|
|
40
|
+
if not isinstance(envelope, dict):
|
|
41
|
+
raise BackendUnavailableError(
|
|
42
|
+
"decoded message envelope must be a mapping",
|
|
43
|
+
action="message.decode",
|
|
44
|
+
queue=self.config.queue,
|
|
45
|
+
details={"payload_type": type(envelope).__name__},
|
|
46
|
+
)
|
|
47
|
+
return Message(
|
|
48
|
+
id=str(envelope["id"]),
|
|
49
|
+
queue=str(envelope["queue"]),
|
|
50
|
+
payload=envelope["payload"],
|
|
51
|
+
headers=dict(envelope.get("headers") or {}),
|
|
52
|
+
attempts=int(envelope.get("attempts") or 0),
|
|
53
|
+
created_at=float(envelope["created_at"]),
|
|
54
|
+
available_at=envelope.get("available_at"),
|
|
55
|
+
backend=self.backend_name,
|
|
56
|
+
raw_id=envelope.get("raw_id"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _emit(
|
|
60
|
+
self,
|
|
61
|
+
event_type: MonitoringEventType,
|
|
62
|
+
message: Message,
|
|
63
|
+
*,
|
|
64
|
+
attributes: dict[str, Any] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.config.monitoring.emit(
|
|
67
|
+
MonitoringEvent(
|
|
68
|
+
type=event_type,
|
|
69
|
+
queue=self.config.queue,
|
|
70
|
+
message_id=message.id,
|
|
71
|
+
backend=self.backend_name,
|
|
72
|
+
attributes=attributes or {},
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _emit_backend_error(self, action: str, error: str | None = None) -> None:
|
|
77
|
+
self.config.monitoring.emit(
|
|
78
|
+
MonitoringEvent(
|
|
79
|
+
type=MonitoringEventType.BACKEND_ERROR,
|
|
80
|
+
queue=self.config.queue,
|
|
81
|
+
backend=self.backend_name,
|
|
82
|
+
error=error,
|
|
83
|
+
attributes={"action": action},
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BaseListBackend(BaseMessageBackend):
|
|
89
|
+
"""Shared Redis List backend behavior."""
|
|
90
|
+
|
|
91
|
+
backend_name = "list"
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ready_key(self) -> str:
|
|
95
|
+
return self.config.key("ready")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def processing_key(self) -> str:
|
|
99
|
+
return self.config.key("processing")
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def dead_key(self) -> str:
|
|
103
|
+
return self.config.key("dead")
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Synchronous 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 SyncDelayRedis(Protocol):
|
|
18
|
+
"""Redis command subset required by the delayed task backend."""
|
|
19
|
+
|
|
20
|
+
def set(self, name: str, value: bytes) -> bool: ...
|
|
21
|
+
|
|
22
|
+
def get(self, name: str) -> bytes | None: ...
|
|
23
|
+
|
|
24
|
+
def delete(self, *names: str) -> int: ...
|
|
25
|
+
|
|
26
|
+
def zadd(self, name: str, mapping: dict[str, float]) -> int: ...
|
|
27
|
+
|
|
28
|
+
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
|
+
def zrem(self, name: str, *values: str) -> int: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DelayBackend:
|
|
41
|
+
"""Delayed task scheduler implemented with Redis Sorted Set."""
|
|
42
|
+
|
|
43
|
+
backend_name = "delay"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
redis: SyncDelayRedis,
|
|
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
|
+
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
|
+
encoded = self._encode(message)
|
|
81
|
+
self._execute(
|
|
82
|
+
"redis.set",
|
|
83
|
+
self.redis.set,
|
|
84
|
+
self.payload_key(message.id),
|
|
85
|
+
encoded,
|
|
86
|
+
)
|
|
87
|
+
self._execute(
|
|
88
|
+
"redis.zadd",
|
|
89
|
+
self.redis.zadd,
|
|
90
|
+
self.delayed_key,
|
|
91
|
+
{message.id: available_at},
|
|
92
|
+
)
|
|
93
|
+
self._emit(MonitoringEventType.DELAY_SCHEDULED, message)
|
|
94
|
+
return message.id
|
|
95
|
+
|
|
96
|
+
def schedule_due(self, *, limit: int = 100, now: float | None = None) -> int:
|
|
97
|
+
now_value = time() if now is None else now
|
|
98
|
+
due_ids = self._execute(
|
|
99
|
+
"redis.zrangebyscore",
|
|
100
|
+
self.redis.zrangebyscore,
|
|
101
|
+
self.delayed_key,
|
|
102
|
+
"-inf",
|
|
103
|
+
now_value,
|
|
104
|
+
0,
|
|
105
|
+
limit,
|
|
106
|
+
)
|
|
107
|
+
released = 0
|
|
108
|
+
for raw_message_id in due_ids:
|
|
109
|
+
message_id = self._to_text(raw_message_id)
|
|
110
|
+
removed = self._execute(
|
|
111
|
+
"redis.zrem",
|
|
112
|
+
self.redis.zrem,
|
|
113
|
+
self.delayed_key,
|
|
114
|
+
message_id,
|
|
115
|
+
)
|
|
116
|
+
if removed < 1:
|
|
117
|
+
continue
|
|
118
|
+
message = self._load_message(message_id)
|
|
119
|
+
try:
|
|
120
|
+
self.publisher.publish(
|
|
121
|
+
message.payload,
|
|
122
|
+
headers=message.headers,
|
|
123
|
+
message_id=message.id,
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
self._execute(
|
|
127
|
+
"redis.zadd",
|
|
128
|
+
self.redis.zadd,
|
|
129
|
+
self.delayed_key,
|
|
130
|
+
{message_id: message.available_at or now_value},
|
|
131
|
+
)
|
|
132
|
+
raise
|
|
133
|
+
self._execute(
|
|
134
|
+
"redis.delete",
|
|
135
|
+
self.redis.delete,
|
|
136
|
+
self.payload_key(message_id),
|
|
137
|
+
)
|
|
138
|
+
self._emit(MonitoringEventType.DELAY_RELEASED, message)
|
|
139
|
+
released += 1
|
|
140
|
+
return released
|
|
141
|
+
|
|
142
|
+
def _load_message(self, message_id: str) -> Message:
|
|
143
|
+
payload = self._execute(
|
|
144
|
+
"redis.get",
|
|
145
|
+
self.redis.get,
|
|
146
|
+
self.payload_key(message_id),
|
|
147
|
+
)
|
|
148
|
+
if payload is None:
|
|
149
|
+
raise BackendUnavailableError(
|
|
150
|
+
"delayed payload is missing",
|
|
151
|
+
action="delay.load",
|
|
152
|
+
queue=self.config.queue,
|
|
153
|
+
details={"message_id": message_id},
|
|
154
|
+
)
|
|
155
|
+
return self._decode(payload)
|
|
156
|
+
|
|
157
|
+
def _available_at(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
delay_seconds: float | None,
|
|
161
|
+
run_at: float | None,
|
|
162
|
+
) -> float:
|
|
163
|
+
if delay_seconds is not None and run_at is not None:
|
|
164
|
+
raise QueueConfigError("delay_seconds and run_at cannot both be set")
|
|
165
|
+
if delay_seconds is not None:
|
|
166
|
+
if delay_seconds < 0:
|
|
167
|
+
raise QueueConfigError(
|
|
168
|
+
"delay_seconds must be greater than or equal to 0"
|
|
169
|
+
)
|
|
170
|
+
return time() + delay_seconds
|
|
171
|
+
if run_at is not None:
|
|
172
|
+
if run_at < 0:
|
|
173
|
+
raise QueueConfigError("run_at must be greater than or equal to 0")
|
|
174
|
+
return run_at
|
|
175
|
+
return time()
|
|
176
|
+
|
|
177
|
+
def _encode(self, message: Message) -> bytes:
|
|
178
|
+
envelope = {
|
|
179
|
+
"id": message.id,
|
|
180
|
+
"queue": message.queue,
|
|
181
|
+
"payload": message.payload,
|
|
182
|
+
"headers": message.headers,
|
|
183
|
+
"attempts": message.attempts,
|
|
184
|
+
"created_at": message.created_at,
|
|
185
|
+
"available_at": message.available_at,
|
|
186
|
+
"backend": self.backend_name,
|
|
187
|
+
"raw_id": message.raw_id,
|
|
188
|
+
}
|
|
189
|
+
return self.config.serializer.encode(envelope, queue=self.config.queue)
|
|
190
|
+
|
|
191
|
+
def _decode(self, payload: bytes) -> Message:
|
|
192
|
+
envelope = self.config.serializer.decode(payload, queue=self.config.queue)
|
|
193
|
+
if not isinstance(envelope, dict):
|
|
194
|
+
raise BackendUnavailableError(
|
|
195
|
+
"decoded delayed message envelope must be a mapping",
|
|
196
|
+
action="delay.decode",
|
|
197
|
+
queue=self.config.queue,
|
|
198
|
+
details={"payload_type": type(envelope).__name__},
|
|
199
|
+
)
|
|
200
|
+
return Message(
|
|
201
|
+
id=str(envelope["id"]),
|
|
202
|
+
queue=str(envelope["queue"]),
|
|
203
|
+
payload=envelope["payload"],
|
|
204
|
+
headers=dict(envelope.get("headers") or {}),
|
|
205
|
+
attempts=int(envelope.get("attempts") or 0),
|
|
206
|
+
created_at=float(envelope["created_at"]),
|
|
207
|
+
available_at=envelope.get("available_at"),
|
|
208
|
+
backend=self.backend_name,
|
|
209
|
+
raw_id=envelope.get("raw_id"),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _execute(self, action: str, func: Any, *args: Any) -> Any:
|
|
213
|
+
try:
|
|
214
|
+
return func(*args)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
self.config.monitoring.emit(
|
|
217
|
+
MonitoringEvent(
|
|
218
|
+
type=MonitoringEventType.BACKEND_ERROR,
|
|
219
|
+
queue=self.config.queue,
|
|
220
|
+
backend=self.backend_name,
|
|
221
|
+
error=str(exc),
|
|
222
|
+
attributes={"action": action},
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
raise BackendUnavailableError(
|
|
226
|
+
"Redis delayed task backend command failed",
|
|
227
|
+
action=action,
|
|
228
|
+
queue=self.config.queue,
|
|
229
|
+
) from exc
|
|
230
|
+
|
|
231
|
+
def _emit(self, event_type: MonitoringEventType, message: Message) -> None:
|
|
232
|
+
self.config.monitoring.emit(
|
|
233
|
+
MonitoringEvent(
|
|
234
|
+
type=event_type,
|
|
235
|
+
queue=self.config.queue,
|
|
236
|
+
message_id=message.id,
|
|
237
|
+
backend=self.backend_name,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _to_text(value: str | bytes) -> str:
|
|
243
|
+
return value.decode() if isinstance(value, bytes) else value
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Synchronous 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 SyncListRedis(Protocol):
|
|
19
|
+
"""Redis command subset required by the synchronous List backend."""
|
|
20
|
+
|
|
21
|
+
def lpush(self, name: str, *values: bytes) -> int: ...
|
|
22
|
+
|
|
23
|
+
def lrem(self, name: str, count: int, value: bytes) -> int: ...
|
|
24
|
+
|
|
25
|
+
def lrange(self, name: str, start: int, end: int) -> list[bytes]: ...
|
|
26
|
+
|
|
27
|
+
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
|
+
def brpoplpush(
|
|
37
|
+
self,
|
|
38
|
+
src: str,
|
|
39
|
+
dst: str,
|
|
40
|
+
timeout: float,
|
|
41
|
+
) -> bytes | None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ListBackend(BaseListBackend):
|
|
45
|
+
"""Reliable queue backend implemented with Redis List commands."""
|
|
46
|
+
|
|
47
|
+
backend_name = "list"
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
redis: SyncListRedis,
|
|
52
|
+
config: QueueConfig,
|
|
53
|
+
capabilities: RedisCapabilities,
|
|
54
|
+
) -> None:
|
|
55
|
+
capabilities.require_list_reliable()
|
|
56
|
+
super().__init__(config)
|
|
57
|
+
self.redis = redis
|
|
58
|
+
self.capabilities = capabilities
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def for_modern_redis(
|
|
62
|
+
cls,
|
|
63
|
+
redis: SyncListRedis,
|
|
64
|
+
config: QueueConfig,
|
|
65
|
+
) -> ListBackend:
|
|
66
|
+
"""Create a backend for tests or callers that already know Redis is modern."""
|
|
67
|
+
|
|
68
|
+
return cls(redis, config, RedisCapabilities(RedisVersion(7, 0, 0)))
|
|
69
|
+
|
|
70
|
+
def publish(
|
|
71
|
+
self,
|
|
72
|
+
payload: Any,
|
|
73
|
+
*,
|
|
74
|
+
headers: dict[str, Any] | None = None,
|
|
75
|
+
message_id: str | None = None,
|
|
76
|
+
) -> str:
|
|
77
|
+
message = Message(
|
|
78
|
+
id=message_id or new_message_id(),
|
|
79
|
+
queue=self.config.queue,
|
|
80
|
+
payload=payload,
|
|
81
|
+
headers=headers or {},
|
|
82
|
+
backend=self.backend_name,
|
|
83
|
+
)
|
|
84
|
+
encoded = self._encode(message)
|
|
85
|
+
self._execute("redis.lpush", self.redis.lpush, self.ready_key, encoded)
|
|
86
|
+
self._emit(
|
|
87
|
+
MonitoringEventType.MESSAGE_PUBLISHED,
|
|
88
|
+
message,
|
|
89
|
+
attributes={"key": self.ready_key},
|
|
90
|
+
)
|
|
91
|
+
return message.id
|
|
92
|
+
|
|
93
|
+
def consume(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
timeout: float | None = None,
|
|
97
|
+
batch_size: int = 1,
|
|
98
|
+
) -> Message | list[Message] | None:
|
|
99
|
+
timeout_value = timeout or 0
|
|
100
|
+
if batch_size <= 1:
|
|
101
|
+
payload = self._move_to_processing(timeout_value)
|
|
102
|
+
if payload is None:
|
|
103
|
+
return None
|
|
104
|
+
message = self._decode(payload)
|
|
105
|
+
self._emit(MonitoringEventType.MESSAGE_CONSUMED, message)
|
|
106
|
+
return message
|
|
107
|
+
|
|
108
|
+
messages: list[Message] = []
|
|
109
|
+
for _ in range(batch_size):
|
|
110
|
+
payload = self._move_to_processing(timeout_value)
|
|
111
|
+
if payload is None:
|
|
112
|
+
break
|
|
113
|
+
message = self._decode(payload)
|
|
114
|
+
self._emit(MonitoringEventType.MESSAGE_CONSUMED, message)
|
|
115
|
+
messages.append(message)
|
|
116
|
+
return messages
|
|
117
|
+
|
|
118
|
+
def ack(self, message: Message) -> None:
|
|
119
|
+
encoded = self._encode(message)
|
|
120
|
+
removed = self._execute(
|
|
121
|
+
"redis.lrem",
|
|
122
|
+
self.redis.lrem,
|
|
123
|
+
self.processing_key,
|
|
124
|
+
1,
|
|
125
|
+
encoded,
|
|
126
|
+
)
|
|
127
|
+
if removed < 1:
|
|
128
|
+
raise AckError(
|
|
129
|
+
"message was not found in processing queue",
|
|
130
|
+
action="message.ack",
|
|
131
|
+
queue=self.config.queue,
|
|
132
|
+
details={"message_id": message.id, "key": self.processing_key},
|
|
133
|
+
)
|
|
134
|
+
self._emit(MonitoringEventType.MESSAGE_ACKED, message)
|
|
135
|
+
|
|
136
|
+
def nack(self, message: Message, *, requeue: bool = True) -> None:
|
|
137
|
+
encoded = self._encode(message)
|
|
138
|
+
removed = self._execute(
|
|
139
|
+
"redis.lrem",
|
|
140
|
+
self.redis.lrem,
|
|
141
|
+
self.processing_key,
|
|
142
|
+
1,
|
|
143
|
+
encoded,
|
|
144
|
+
)
|
|
145
|
+
if removed < 1:
|
|
146
|
+
raise AckError(
|
|
147
|
+
"message was not found in processing queue",
|
|
148
|
+
action="message.nack",
|
|
149
|
+
queue=self.config.queue,
|
|
150
|
+
details={"message_id": message.id, "key": self.processing_key},
|
|
151
|
+
)
|
|
152
|
+
target_key = self.ready_key if requeue else self.dead_key
|
|
153
|
+
self._execute("redis.lpush", self.redis.lpush, target_key, encoded)
|
|
154
|
+
self._emit(
|
|
155
|
+
MonitoringEventType.MESSAGE_NACKED,
|
|
156
|
+
message,
|
|
157
|
+
attributes={"requeue": requeue, "target_key": target_key},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def retry(
|
|
161
|
+
self,
|
|
162
|
+
message: Message,
|
|
163
|
+
*,
|
|
164
|
+
delay: float | None = None,
|
|
165
|
+
reason: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
if message.attempts >= self.config.retry.max_retries:
|
|
168
|
+
self.nack(message, requeue=False)
|
|
169
|
+
self._emit(
|
|
170
|
+
MonitoringEventType.MESSAGE_DEAD_LETTERED,
|
|
171
|
+
message,
|
|
172
|
+
attributes={"reason": reason, "attempts": message.attempts},
|
|
173
|
+
)
|
|
174
|
+
raise RetryExceededError(
|
|
175
|
+
"message exceeded max retries and was moved to dead letter queue",
|
|
176
|
+
action="message.retry",
|
|
177
|
+
queue=self.config.queue,
|
|
178
|
+
details={
|
|
179
|
+
"message_id": message.id,
|
|
180
|
+
"attempts": message.attempts,
|
|
181
|
+
"max_retries": self.config.retry.max_retries,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
retried = message.with_attempt()
|
|
186
|
+
old_encoded = self._encode(message)
|
|
187
|
+
removed = self._execute(
|
|
188
|
+
"redis.lrem",
|
|
189
|
+
self.redis.lrem,
|
|
190
|
+
self.processing_key,
|
|
191
|
+
1,
|
|
192
|
+
old_encoded,
|
|
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
|
+
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
|
+
def recover_stale(self, *, limit: int = 100) -> int:
|
|
214
|
+
recovered = 0
|
|
215
|
+
entries = 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 = 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
|
+
self._execute("redis.lpush", self.redis.lpush, self.ready_key, payload)
|
|
234
|
+
self._emit(
|
|
235
|
+
MonitoringEventType.MESSAGE_RETRIED,
|
|
236
|
+
message,
|
|
237
|
+
attributes={"reason": "stale_processing_recovered"},
|
|
238
|
+
)
|
|
239
|
+
recovered += 1
|
|
240
|
+
return recovered
|
|
241
|
+
|
|
242
|
+
def dead_letters(self, *, limit: int = 100) -> list[Message]:
|
|
243
|
+
entries = self._execute(
|
|
244
|
+
"redis.lrange",
|
|
245
|
+
self.redis.lrange,
|
|
246
|
+
self.dead_key,
|
|
247
|
+
0,
|
|
248
|
+
max(limit - 1, 0),
|
|
249
|
+
)
|
|
250
|
+
return [self._decode(payload) for payload in entries]
|
|
251
|
+
|
|
252
|
+
def requeue_dead(self, message: Message) -> None:
|
|
253
|
+
encoded = self._encode(message)
|
|
254
|
+
removed = self._execute(
|
|
255
|
+
"redis.lrem",
|
|
256
|
+
self.redis.lrem,
|
|
257
|
+
self.dead_key,
|
|
258
|
+
1,
|
|
259
|
+
encoded,
|
|
260
|
+
)
|
|
261
|
+
if removed < 1:
|
|
262
|
+
raise AckError(
|
|
263
|
+
"message was not found in dead letter queue",
|
|
264
|
+
action="message.requeue_dead",
|
|
265
|
+
queue=self.config.queue,
|
|
266
|
+
details={"message_id": message.id, "key": self.dead_key},
|
|
267
|
+
)
|
|
268
|
+
self._execute("redis.lpush", self.redis.lpush, self.ready_key, encoded)
|
|
269
|
+
self._emit(
|
|
270
|
+
MonitoringEventType.MESSAGE_RETRIED,
|
|
271
|
+
message,
|
|
272
|
+
attributes={"reason": "dead_letter_requeued"},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _move_to_processing(self, timeout: float) -> bytes | None:
|
|
276
|
+
if self.capabilities.supports_list_reliable_blmove:
|
|
277
|
+
return self._execute(
|
|
278
|
+
"redis.blmove",
|
|
279
|
+
self.redis.blmove,
|
|
280
|
+
self.ready_key,
|
|
281
|
+
self.processing_key,
|
|
282
|
+
timeout,
|
|
283
|
+
"RIGHT",
|
|
284
|
+
"LEFT",
|
|
285
|
+
)
|
|
286
|
+
return self._execute(
|
|
287
|
+
"redis.brpoplpush",
|
|
288
|
+
self.redis.brpoplpush,
|
|
289
|
+
self.ready_key,
|
|
290
|
+
self.processing_key,
|
|
291
|
+
timeout,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _execute(self, action: str, func: Any, *args: Any) -> Any:
|
|
295
|
+
try:
|
|
296
|
+
return func(*args)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
self._emit_backend_error(action, str(exc))
|
|
299
|
+
raise BackendUnavailableError(
|
|
300
|
+
"Redis List backend command failed",
|
|
301
|
+
action=action,
|
|
302
|
+
queue=self.config.queue,
|
|
303
|
+
) from exc
|