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,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