redis-message-queue 2.1.0__tar.gz → 3.0.0__tar.gz
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.
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/PKG-INFO +22 -15
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/README.md +21 -14
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/pyproject.toml +1 -1
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_config.py +53 -3
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_gateway.py +42 -28
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/_redis_gateway.py +42 -28
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/redis_message_queue.py +18 -2
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/redis_message_queue.py +11 -2
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/LICENSE +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_queue_key_manager.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redis-message-queue
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Python message queuing with Redis and message deduplication
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: Elijas
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
# redis-message-queue
|
|
18
18
|
|
|
19
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
20
20
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
21
21
|
[](LICENSE)
|
|
22
22
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
**Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
pip install "redis-message-queue>=
|
|
30
|
+
pip install "redis-message-queue>=3.0.0,<4.0.0"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Requires Redis server >= 6.2.
|
|
@@ -219,10 +219,12 @@ while not interrupt.is_interrupted():
|
|
|
219
219
|
```python
|
|
220
220
|
from redis_message_queue._redis_gateway import RedisGateway
|
|
221
221
|
|
|
222
|
-
#
|
|
222
|
+
# Tune retry budget, dedup TTL, or wait interval
|
|
223
223
|
gateway = RedisGateway(
|
|
224
224
|
redis_client=client,
|
|
225
|
-
|
|
225
|
+
retry_budget_seconds=120, # total retry window (set 0 to disable retry)
|
|
226
|
+
retry_max_delay_seconds=5.0, # cap on per-attempt backoff
|
|
227
|
+
retry_initial_delay_seconds=0.01, # first backoff
|
|
226
228
|
message_deduplication_log_ttl_seconds=3600,
|
|
227
229
|
message_wait_interval_seconds=10,
|
|
228
230
|
message_visibility_timeout_seconds=300,
|
|
@@ -230,6 +232,21 @@ gateway = RedisGateway(
|
|
|
230
232
|
queue = RedisMessageQueue("q", gateway=gateway)
|
|
231
233
|
```
|
|
232
234
|
|
|
235
|
+
The retry knobs configure an internal `tenacity` strategy: exponential
|
|
236
|
+
backoff with jitter, retry on transient Redis errors only, capped at
|
|
237
|
+
`retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
|
|
238
|
+
entirely (single attempt; exceptions propagate). The library uses
|
|
239
|
+
`retry_budget_seconds` to size the operation-result cache TTL automatically,
|
|
240
|
+
so the previous footgun of an over-long retry budget out-living the cache
|
|
241
|
+
and producing misleading "cleanup was a no-op" warnings is now structurally
|
|
242
|
+
impossible.
|
|
243
|
+
|
|
244
|
+
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
245
|
+
own logic) or fundamentally different semantics, subclass
|
|
246
|
+
`AbstractRedisGateway` from `redis_message_queue._abstract_redis_gateway`
|
|
247
|
+
(or `redis_message_queue.asyncio._abstract_redis_gateway`) and override the
|
|
248
|
+
operation methods directly.
|
|
249
|
+
|
|
233
250
|
If your custom gateway uses visibility timeouts, it must expose a public
|
|
234
251
|
`message_visibility_timeout_seconds` value and return `ClaimedMessage` from
|
|
235
252
|
`wait_for_message_and_move()`. The queue now fails closed if a lease-capable
|
|
@@ -240,16 +257,6 @@ the queue cannot detect that lease semantics are in play and will treat the
|
|
|
240
257
|
gateway as a non-lease gateway. In that misconfigured state, lease-token safety
|
|
241
258
|
checks and heartbeat validation are bypassed.
|
|
242
259
|
|
|
243
|
-
A custom `retry_strategy` MUST have a total retry budget no longer than
|
|
244
|
-
`max(message_visibility_timeout_seconds, 300)` seconds. That value is the TTL
|
|
245
|
-
of the built-in gateway's ambiguous-success cache: if a retry arrives after the
|
|
246
|
-
cache has expired, the gateway re-runs the Lua script and — because the message
|
|
247
|
-
was already acked on the first attempt — sees `LREM=0` and returns `False`. This
|
|
248
|
-
surfaces as a misleading "cleanup was a no-op" warning from `process_message`;
|
|
249
|
-
no data is lost or double-processed, but a `max_completed_length` /
|
|
250
|
-
`max_failed_length` bound may be skipped on that call. The default
|
|
251
|
-
`tenacity.stop_after_delay(120)` is safely within the 300 s floor.
|
|
252
|
-
|
|
253
260
|
When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
|
|
254
261
|
and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
|
|
255
262
|
`RedisMessageQueue`:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# redis-message-queue
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
**Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
pip install "redis-message-queue>=
|
|
14
|
+
pip install "redis-message-queue>=3.0.0,<4.0.0"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires Redis server >= 6.2.
|
|
@@ -203,10 +203,12 @@ while not interrupt.is_interrupted():
|
|
|
203
203
|
```python
|
|
204
204
|
from redis_message_queue._redis_gateway import RedisGateway
|
|
205
205
|
|
|
206
|
-
#
|
|
206
|
+
# Tune retry budget, dedup TTL, or wait interval
|
|
207
207
|
gateway = RedisGateway(
|
|
208
208
|
redis_client=client,
|
|
209
|
-
|
|
209
|
+
retry_budget_seconds=120, # total retry window (set 0 to disable retry)
|
|
210
|
+
retry_max_delay_seconds=5.0, # cap on per-attempt backoff
|
|
211
|
+
retry_initial_delay_seconds=0.01, # first backoff
|
|
210
212
|
message_deduplication_log_ttl_seconds=3600,
|
|
211
213
|
message_wait_interval_seconds=10,
|
|
212
214
|
message_visibility_timeout_seconds=300,
|
|
@@ -214,6 +216,21 @@ gateway = RedisGateway(
|
|
|
214
216
|
queue = RedisMessageQueue("q", gateway=gateway)
|
|
215
217
|
```
|
|
216
218
|
|
|
219
|
+
The retry knobs configure an internal `tenacity` strategy: exponential
|
|
220
|
+
backoff with jitter, retry on transient Redis errors only, capped at
|
|
221
|
+
`retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
|
|
222
|
+
entirely (single attempt; exceptions propagate). The library uses
|
|
223
|
+
`retry_budget_seconds` to size the operation-result cache TTL automatically,
|
|
224
|
+
so the previous footgun of an over-long retry budget out-living the cache
|
|
225
|
+
and producing misleading "cleanup was a no-op" warnings is now structurally
|
|
226
|
+
impossible.
|
|
227
|
+
|
|
228
|
+
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
229
|
+
own logic) or fundamentally different semantics, subclass
|
|
230
|
+
`AbstractRedisGateway` from `redis_message_queue._abstract_redis_gateway`
|
|
231
|
+
(or `redis_message_queue.asyncio._abstract_redis_gateway`) and override the
|
|
232
|
+
operation methods directly.
|
|
233
|
+
|
|
217
234
|
If your custom gateway uses visibility timeouts, it must expose a public
|
|
218
235
|
`message_visibility_timeout_seconds` value and return `ClaimedMessage` from
|
|
219
236
|
`wait_for_message_and_move()`. The queue now fails closed if a lease-capable
|
|
@@ -224,16 +241,6 @@ the queue cannot detect that lease semantics are in play and will treat the
|
|
|
224
241
|
gateway as a non-lease gateway. In that misconfigured state, lease-token safety
|
|
225
242
|
checks and heartbeat validation are bypassed.
|
|
226
243
|
|
|
227
|
-
A custom `retry_strategy` MUST have a total retry budget no longer than
|
|
228
|
-
`max(message_visibility_timeout_seconds, 300)` seconds. That value is the TTL
|
|
229
|
-
of the built-in gateway's ambiguous-success cache: if a retry arrives after the
|
|
230
|
-
cache has expired, the gateway re-runs the Lua script and — because the message
|
|
231
|
-
was already acked on the first attempt — sees `LREM=0` and returns `False`. This
|
|
232
|
-
surfaces as a misleading "cleanup was a no-op" warning from `process_message`;
|
|
233
|
-
no data is lost or double-processed, but a `max_completed_length` /
|
|
234
|
-
`max_failed_length` bound may be skipped on that call. The default
|
|
235
|
-
`tenacity.stop_after_delay(120)` is safely within the 300 s floor.
|
|
236
|
-
|
|
237
244
|
When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
|
|
238
245
|
and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
|
|
239
246
|
`RedisMessageQueue`:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import math
|
|
2
3
|
import typing
|
|
3
4
|
|
|
4
5
|
import redis
|
|
@@ -19,6 +20,10 @@ from redis_message_queue.interrupt_handler._interface import (
|
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
23
|
+
DEFAULT_RETRY_BUDGET_SECONDS = 120
|
|
24
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS = 5.0
|
|
25
|
+
DEFAULT_RETRY_INITIAL_DELAY_SECONDS = 0.01
|
|
26
|
+
|
|
22
27
|
|
|
23
28
|
def is_redis_retryable_exception(exception):
|
|
24
29
|
# 1. Handle ConnectionError hierarchy (retryable except credentials/config issues)
|
|
@@ -62,10 +67,27 @@ class interruptable_retry(retry_base):
|
|
|
62
67
|
return self._parent_instance.__call__(retry_state)
|
|
63
68
|
|
|
64
69
|
|
|
65
|
-
def
|
|
70
|
+
def _noop_retry(func):
|
|
71
|
+
return func
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_retry_strategy(
|
|
75
|
+
*,
|
|
76
|
+
retry_budget_seconds: int,
|
|
77
|
+
retry_max_delay_seconds: float,
|
|
78
|
+
retry_initial_delay_seconds: float,
|
|
79
|
+
interrupt: BaseGracefulInterruptHandler | None = None,
|
|
80
|
+
):
|
|
81
|
+
if retry_budget_seconds == 0:
|
|
82
|
+
return _noop_retry
|
|
66
83
|
return retry(
|
|
67
|
-
stop=stop_after_delay(
|
|
68
|
-
wait=wait_exponential_jitter(
|
|
84
|
+
stop=stop_after_delay(retry_budget_seconds),
|
|
85
|
+
wait=wait_exponential_jitter(
|
|
86
|
+
initial=retry_initial_delay_seconds,
|
|
87
|
+
exp_base=2,
|
|
88
|
+
max=retry_max_delay_seconds,
|
|
89
|
+
jitter=0.1,
|
|
90
|
+
),
|
|
69
91
|
retry=interruptable_retry(
|
|
70
92
|
interrupt=interrupt,
|
|
71
93
|
get_parent_retry=lambda: retry_if_exception(is_redis_retryable_exception),
|
|
@@ -82,6 +104,10 @@ def validate_gateway_parameters(
|
|
|
82
104
|
message_deduplication_log_ttl_seconds: int,
|
|
83
105
|
message_wait_interval_seconds: int,
|
|
84
106
|
message_visibility_timeout_seconds: int | None = None,
|
|
107
|
+
*,
|
|
108
|
+
retry_budget_seconds: int,
|
|
109
|
+
retry_max_delay_seconds: float,
|
|
110
|
+
retry_initial_delay_seconds: float,
|
|
85
111
|
) -> None:
|
|
86
112
|
if not isinstance(message_deduplication_log_ttl_seconds, int) or isinstance(
|
|
87
113
|
message_deduplication_log_ttl_seconds, bool
|
|
@@ -114,6 +140,30 @@ def validate_gateway_parameters(
|
|
|
114
140
|
f"got {message_visibility_timeout_seconds}"
|
|
115
141
|
)
|
|
116
142
|
|
|
143
|
+
if not isinstance(retry_budget_seconds, int) or isinstance(retry_budget_seconds, bool):
|
|
144
|
+
raise TypeError(f"'retry_budget_seconds' must be an int, got {type(retry_budget_seconds).__name__}")
|
|
145
|
+
if retry_budget_seconds < 0:
|
|
146
|
+
raise ValueError(f"'retry_budget_seconds' must be non-negative, got {retry_budget_seconds}")
|
|
147
|
+
|
|
148
|
+
if isinstance(retry_max_delay_seconds, bool) or not isinstance(retry_max_delay_seconds, (int, float)):
|
|
149
|
+
raise TypeError(f"'retry_max_delay_seconds' must be a number, got {type(retry_max_delay_seconds).__name__}")
|
|
150
|
+
if not math.isfinite(retry_max_delay_seconds) or retry_max_delay_seconds <= 0:
|
|
151
|
+
raise ValueError(f"'retry_max_delay_seconds' must be a finite positive number, got {retry_max_delay_seconds}")
|
|
152
|
+
|
|
153
|
+
if isinstance(retry_initial_delay_seconds, bool) or not isinstance(retry_initial_delay_seconds, (int, float)):
|
|
154
|
+
raise TypeError(
|
|
155
|
+
f"'retry_initial_delay_seconds' must be a number, got {type(retry_initial_delay_seconds).__name__}"
|
|
156
|
+
)
|
|
157
|
+
if not math.isfinite(retry_initial_delay_seconds) or retry_initial_delay_seconds <= 0:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"'retry_initial_delay_seconds' must be a finite positive number, got {retry_initial_delay_seconds}"
|
|
160
|
+
)
|
|
161
|
+
if retry_initial_delay_seconds > retry_max_delay_seconds:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"'retry_initial_delay_seconds' must be <= 'retry_max_delay_seconds', "
|
|
164
|
+
f"got {retry_initial_delay_seconds} > {retry_max_delay_seconds}"
|
|
165
|
+
)
|
|
166
|
+
|
|
117
167
|
|
|
118
168
|
def validate_dead_letter_parameters(
|
|
119
169
|
max_delivery_count: int | None,
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -9,19 +9,21 @@ import redis
|
|
|
9
9
|
import redis.asyncio
|
|
10
10
|
|
|
11
11
|
from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
|
|
12
|
-
from redis_message_queue._callable_utils import is_async_callable
|
|
13
12
|
from redis_message_queue._config import (
|
|
14
13
|
CLAIM_MESSAGE_LUA_SCRIPT,
|
|
15
14
|
CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
|
|
16
15
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
|
|
17
16
|
DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
|
|
17
|
+
DEFAULT_RETRY_BUDGET_SECONDS,
|
|
18
|
+
DEFAULT_RETRY_INITIAL_DELAY_SECONDS,
|
|
19
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
18
20
|
MOVE_MESSAGE_LUA_SCRIPT,
|
|
19
21
|
MOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
20
22
|
PUBLISH_MESSAGE_LUA_SCRIPT,
|
|
21
23
|
REMOVE_MESSAGE_LUA_SCRIPT,
|
|
22
24
|
REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
23
25
|
RENEW_MESSAGE_LEASE_LUA_SCRIPT,
|
|
24
|
-
|
|
26
|
+
build_retry_strategy,
|
|
25
27
|
is_redis_retryable_exception,
|
|
26
28
|
validate_dead_letter_parameters,
|
|
27
29
|
validate_gateway_parameters,
|
|
@@ -54,11 +56,28 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
class RedisGateway(AbstractRedisGateway):
|
|
59
|
+
"""Sync Redis gateway with built-in tenacity-based retry on transient errors.
|
|
60
|
+
|
|
61
|
+
The retry knobs (``retry_budget_seconds``, ``retry_max_delay_seconds``,
|
|
62
|
+
``retry_initial_delay_seconds``) configure the internal tenacity strategy.
|
|
63
|
+
Setting ``retry_budget_seconds=0`` disables retry entirely (single attempt;
|
|
64
|
+
exceptions propagate). The library uses ``retry_budget_seconds`` to size the
|
|
65
|
+
operation-result cache TTL so that a successfully-acked operation cannot
|
|
66
|
+
appear "not removed" to a retry that arrives after the budget elapses.
|
|
67
|
+
|
|
68
|
+
Power-user escape hatch: to plug in a different retry library
|
|
69
|
+
(``backoff``, ``asyncstdlib.retry``, custom exponential backoff, etc.) or
|
|
70
|
+
fundamentally different retry semantics, subclass
|
|
71
|
+
:class:`AbstractRedisGateway` and override the operation methods directly.
|
|
72
|
+
"""
|
|
73
|
+
|
|
57
74
|
def __init__(
|
|
58
75
|
self,
|
|
59
76
|
*,
|
|
60
77
|
redis_client: redis.Redis,
|
|
61
|
-
|
|
78
|
+
retry_budget_seconds: int = DEFAULT_RETRY_BUDGET_SECONDS,
|
|
79
|
+
retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
80
|
+
retry_initial_delay_seconds: float = DEFAULT_RETRY_INITIAL_DELAY_SECONDS,
|
|
62
81
|
message_deduplication_log_ttl_seconds: Optional[int] = None,
|
|
63
82
|
message_wait_interval_seconds: Optional[int] = None,
|
|
64
83
|
message_visibility_timeout_seconds: Optional[int] = None,
|
|
@@ -78,21 +97,9 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
78
97
|
"Pass the underlying redis.Redis instance instead."
|
|
79
98
|
)
|
|
80
99
|
self._redis_client = redis_client
|
|
81
|
-
if retry_strategy is not None and not callable(retry_strategy):
|
|
82
|
-
raise TypeError(f"'retry_strategy' must be callable, got {type(retry_strategy).__name__}")
|
|
83
|
-
if retry_strategy is not None and is_async_callable(retry_strategy):
|
|
84
|
-
raise TypeError(
|
|
85
|
-
"'retry_strategy' is an async callable; "
|
|
86
|
-
"use the async RedisGateway from redis_message_queue.asyncio instead"
|
|
87
|
-
)
|
|
88
100
|
if interrupt is not None and not isinstance(interrupt, BaseGracefulInterruptHandler):
|
|
89
101
|
raise TypeError(f"'interrupt' must be a BaseGracefulInterruptHandler, got {type(interrupt).__name__}")
|
|
90
102
|
self._interrupt = interrupt
|
|
91
|
-
self._retry_strategy = (
|
|
92
|
-
get_default_redis_connection_retry_strategy(interrupt=interrupt)
|
|
93
|
-
if retry_strategy is None
|
|
94
|
-
else retry_strategy
|
|
95
|
-
)
|
|
96
103
|
self._message_deduplication_log_ttl_seconds = (
|
|
97
104
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL
|
|
98
105
|
if message_deduplication_log_ttl_seconds is None
|
|
@@ -108,12 +115,22 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
108
115
|
self._message_deduplication_log_ttl_seconds,
|
|
109
116
|
self._message_wait_interval_seconds,
|
|
110
117
|
self._message_visibility_timeout_seconds,
|
|
118
|
+
retry_budget_seconds=retry_budget_seconds,
|
|
119
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
120
|
+
retry_initial_delay_seconds=retry_initial_delay_seconds,
|
|
111
121
|
)
|
|
112
122
|
validate_dead_letter_parameters(
|
|
113
123
|
max_delivery_count,
|
|
114
124
|
dead_letter_queue,
|
|
115
125
|
self._message_visibility_timeout_seconds,
|
|
116
126
|
)
|
|
127
|
+
self._retry_budget_seconds = retry_budget_seconds
|
|
128
|
+
self._retry_strategy = build_retry_strategy(
|
|
129
|
+
retry_budget_seconds=retry_budget_seconds,
|
|
130
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
131
|
+
retry_initial_delay_seconds=retry_initial_delay_seconds,
|
|
132
|
+
interrupt=interrupt,
|
|
133
|
+
)
|
|
117
134
|
self._max_delivery_count = max_delivery_count
|
|
118
135
|
self._dead_letter_queue = dead_letter_queue
|
|
119
136
|
self._pending_claim_ids: dict[str, list[str]] = {}
|
|
@@ -575,20 +592,17 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
575
592
|
return str(max(self._message_deduplication_log_ttl_seconds, 3600) * 1000)
|
|
576
593
|
|
|
577
594
|
def _operation_result_ttl_ms(self) -> str:
|
|
578
|
-
# Floor is
|
|
579
|
-
#
|
|
580
|
-
# produce a boundary race where a retry arriving past
|
|
581
|
-
# cache just expired and
|
|
595
|
+
# Floor is derived from the configured retry budget so the cached
|
|
596
|
+
# operation result outlives the retry window with a 180s margin. Equal
|
|
597
|
+
# deadlines produce a boundary race where a retry arriving past the
|
|
598
|
+
# budget finds the cache just expired and re-runs the Lua, which then
|
|
599
|
+
# observes LREM=0 for an already-acked message and returns False.
|
|
582
600
|
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
ttl_seconds = self._message_visibility_timeout_seconds
|
|
589
|
-
if ttl_seconds is None:
|
|
590
|
-
ttl_seconds = 120
|
|
591
|
-
return str(max(ttl_seconds, 300) * 1000)
|
|
601
|
+
# Sized internally from ``retry_budget_seconds`` (which the library now
|
|
602
|
+
# owns), so the relationship is a structural invariant rather than a
|
|
603
|
+
# caller-supplied constraint.
|
|
604
|
+
vt_seconds = self._message_visibility_timeout_seconds or 0
|
|
605
|
+
return str(max(vt_seconds, self._retry_budget_seconds + 180) * 1000)
|
|
592
606
|
|
|
593
607
|
def _lease_operation_result_ttl_ms(self) -> str:
|
|
594
608
|
return self._operation_result_ttl_ms()
|
|
@@ -8,19 +8,21 @@ from typing import Awaitable, Callable, Optional, TypeVar
|
|
|
8
8
|
import redis
|
|
9
9
|
import redis.asyncio
|
|
10
10
|
|
|
11
|
-
from redis_message_queue._callable_utils import is_async_callable
|
|
12
11
|
from redis_message_queue._config import (
|
|
13
12
|
CLAIM_MESSAGE_LUA_SCRIPT,
|
|
14
13
|
CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
|
|
15
14
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
|
|
16
15
|
DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
|
|
16
|
+
DEFAULT_RETRY_BUDGET_SECONDS,
|
|
17
|
+
DEFAULT_RETRY_INITIAL_DELAY_SECONDS,
|
|
18
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
17
19
|
MOVE_MESSAGE_LUA_SCRIPT,
|
|
18
20
|
MOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
19
21
|
PUBLISH_MESSAGE_LUA_SCRIPT,
|
|
20
22
|
REMOVE_MESSAGE_LUA_SCRIPT,
|
|
21
23
|
REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
22
24
|
RENEW_MESSAGE_LEASE_LUA_SCRIPT,
|
|
23
|
-
|
|
25
|
+
build_retry_strategy,
|
|
24
26
|
is_redis_retryable_exception,
|
|
25
27
|
validate_dead_letter_parameters,
|
|
26
28
|
validate_gateway_parameters,
|
|
@@ -54,11 +56,28 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
class RedisGateway(AbstractRedisGateway):
|
|
59
|
+
"""Async Redis gateway with built-in tenacity-based retry on transient errors.
|
|
60
|
+
|
|
61
|
+
The retry knobs (``retry_budget_seconds``, ``retry_max_delay_seconds``,
|
|
62
|
+
``retry_initial_delay_seconds``) configure the internal tenacity strategy.
|
|
63
|
+
Setting ``retry_budget_seconds=0`` disables retry entirely (single attempt;
|
|
64
|
+
exceptions propagate). The library uses ``retry_budget_seconds`` to size the
|
|
65
|
+
operation-result cache TTL so that a successfully-acked operation cannot
|
|
66
|
+
appear "not removed" to a retry that arrives after the budget elapses.
|
|
67
|
+
|
|
68
|
+
Power-user escape hatch: to plug in a different retry library
|
|
69
|
+
(``backoff``, ``asyncstdlib.retry``, custom exponential backoff, etc.) or
|
|
70
|
+
fundamentally different retry semantics, subclass
|
|
71
|
+
:class:`AbstractRedisGateway` and override the operation methods directly.
|
|
72
|
+
"""
|
|
73
|
+
|
|
57
74
|
def __init__(
|
|
58
75
|
self,
|
|
59
76
|
*,
|
|
60
77
|
redis_client: redis.asyncio.Redis,
|
|
61
|
-
|
|
78
|
+
retry_budget_seconds: int = DEFAULT_RETRY_BUDGET_SECONDS,
|
|
79
|
+
retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
80
|
+
retry_initial_delay_seconds: float = DEFAULT_RETRY_INITIAL_DELAY_SECONDS,
|
|
62
81
|
message_deduplication_log_ttl_seconds: Optional[int] = None,
|
|
63
82
|
message_wait_interval_seconds: Optional[int] = None,
|
|
64
83
|
message_visibility_timeout_seconds: Optional[int] = None,
|
|
@@ -78,21 +97,9 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
78
97
|
"Pass the underlying redis.asyncio.Redis instance instead."
|
|
79
98
|
)
|
|
80
99
|
self._redis_client = redis_client
|
|
81
|
-
if retry_strategy is not None and not callable(retry_strategy):
|
|
82
|
-
raise TypeError(f"'retry_strategy' must be callable, got {type(retry_strategy).__name__}")
|
|
83
|
-
if retry_strategy is not None and is_async_callable(retry_strategy):
|
|
84
|
-
raise TypeError(
|
|
85
|
-
"'retry_strategy' must not be an async callable. "
|
|
86
|
-
"Provide a synchronous callable decorator (e.g., tenacity.retry(...))"
|
|
87
|
-
)
|
|
88
100
|
if interrupt is not None and not isinstance(interrupt, BaseGracefulInterruptHandler):
|
|
89
101
|
raise TypeError(f"'interrupt' must be a BaseGracefulInterruptHandler, got {type(interrupt).__name__}")
|
|
90
102
|
self._interrupt = interrupt
|
|
91
|
-
self._retry_strategy = (
|
|
92
|
-
get_default_redis_connection_retry_strategy(interrupt=interrupt)
|
|
93
|
-
if retry_strategy is None
|
|
94
|
-
else retry_strategy
|
|
95
|
-
)
|
|
96
103
|
self._message_deduplication_log_ttl_seconds = (
|
|
97
104
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL
|
|
98
105
|
if message_deduplication_log_ttl_seconds is None
|
|
@@ -108,12 +115,22 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
108
115
|
self._message_deduplication_log_ttl_seconds,
|
|
109
116
|
self._message_wait_interval_seconds,
|
|
110
117
|
self._message_visibility_timeout_seconds,
|
|
118
|
+
retry_budget_seconds=retry_budget_seconds,
|
|
119
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
120
|
+
retry_initial_delay_seconds=retry_initial_delay_seconds,
|
|
111
121
|
)
|
|
112
122
|
validate_dead_letter_parameters(
|
|
113
123
|
max_delivery_count,
|
|
114
124
|
dead_letter_queue,
|
|
115
125
|
self._message_visibility_timeout_seconds,
|
|
116
126
|
)
|
|
127
|
+
self._retry_budget_seconds = retry_budget_seconds
|
|
128
|
+
self._retry_strategy = build_retry_strategy(
|
|
129
|
+
retry_budget_seconds=retry_budget_seconds,
|
|
130
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
131
|
+
retry_initial_delay_seconds=retry_initial_delay_seconds,
|
|
132
|
+
interrupt=interrupt,
|
|
133
|
+
)
|
|
117
134
|
self._max_delivery_count = max_delivery_count
|
|
118
135
|
self._dead_letter_queue = dead_letter_queue
|
|
119
136
|
self._pending_claim_ids: dict[str, list[str]] = {}
|
|
@@ -576,20 +593,17 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
576
593
|
return str(max(self._message_deduplication_log_ttl_seconds, 3600) * 1000)
|
|
577
594
|
|
|
578
595
|
def _operation_result_ttl_ms(self) -> str:
|
|
579
|
-
# Floor is
|
|
580
|
-
#
|
|
581
|
-
# produce a boundary race where a retry arriving past
|
|
582
|
-
# cache just expired and
|
|
596
|
+
# Floor is derived from the configured retry budget so the cached
|
|
597
|
+
# operation result outlives the retry window with a 180s margin. Equal
|
|
598
|
+
# deadlines produce a boundary race where a retry arriving past the
|
|
599
|
+
# budget finds the cache just expired and re-runs the Lua, which then
|
|
600
|
+
# observes LREM=0 for an already-acked message and returns False.
|
|
583
601
|
#
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
ttl_seconds = self._message_visibility_timeout_seconds
|
|
590
|
-
if ttl_seconds is None:
|
|
591
|
-
ttl_seconds = 120
|
|
592
|
-
return str(max(ttl_seconds, 300) * 1000)
|
|
602
|
+
# Sized internally from ``retry_budget_seconds`` (which the library now
|
|
603
|
+
# owns), so the relationship is a structural invariant rather than a
|
|
604
|
+
# caller-supplied constraint.
|
|
605
|
+
vt_seconds = self._message_visibility_timeout_seconds or 0
|
|
606
|
+
return str(max(vt_seconds, self._retry_budget_seconds + 180) * 1000)
|
|
593
607
|
|
|
594
608
|
def _lease_operation_result_ttl_ms(self) -> str:
|
|
595
609
|
return self._operation_result_ttl_ms()
|
|
@@ -36,10 +36,16 @@ async def _run_operation_in_task(operation: Awaitable[_T]) -> _T:
|
|
|
36
36
|
raise _TaskBaseException(exc) from None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _consume_task_exception(task: "asyncio.Task[_T]") -> None:
|
|
40
|
+
if not task.cancelled():
|
|
41
|
+
task.exception()
|
|
42
|
+
|
|
43
|
+
|
|
39
44
|
async def _await_preserving_cancellation(operation: Awaitable[_T]) -> _T:
|
|
40
45
|
"""Finish cleanup before propagating task cancellation."""
|
|
41
46
|
|
|
42
47
|
task = asyncio.create_task(_run_operation_in_task(operation))
|
|
48
|
+
task.add_done_callback(_consume_task_exception)
|
|
43
49
|
try:
|
|
44
50
|
return await asyncio.shield(task)
|
|
45
51
|
except asyncio.CancelledError:
|
|
@@ -68,6 +74,7 @@ async def _await_suppressing_external_cancellation(operation: Awaitable[_T]) ->
|
|
|
68
74
|
"""
|
|
69
75
|
|
|
70
76
|
task = asyncio.create_task(_run_operation_in_task(operation))
|
|
77
|
+
task.add_done_callback(_consume_task_exception)
|
|
71
78
|
try:
|
|
72
79
|
return await asyncio.shield(task)
|
|
73
80
|
except asyncio.CancelledError:
|
|
@@ -427,12 +434,21 @@ class RedisMessageQueue:
|
|
|
427
434
|
"""Publish a message.
|
|
428
435
|
|
|
429
436
|
Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
|
|
430
|
-
|
|
431
|
-
``
|
|
437
|
+
All top-level dict keys must be strings; non-string keys raise
|
|
438
|
+
``TypeError`` to avoid silent ``json.dumps`` coercion that would
|
|
439
|
+
collapse distinct keys into the same dedup key (e.g. ``{1: "x"}``
|
|
440
|
+
vs ``{"1": "x"}``). Only top-level keys are validated; nested
|
|
441
|
+
dicts follow ``json.dumps`` defaults.
|
|
432
442
|
"""
|
|
433
443
|
if not isinstance(message, (str, dict)):
|
|
434
444
|
raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
|
|
435
445
|
if isinstance(message, dict):
|
|
446
|
+
non_str_keys = [k for k in message if not isinstance(k, str)]
|
|
447
|
+
if non_str_keys:
|
|
448
|
+
raise TypeError(
|
|
449
|
+
"'message' dict keys must all be strings; "
|
|
450
|
+
f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
|
|
451
|
+
)
|
|
436
452
|
message_str = json.dumps(message, sort_keys=True)
|
|
437
453
|
else:
|
|
438
454
|
message_str = message
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -387,12 +387,21 @@ class RedisMessageQueue:
|
|
|
387
387
|
"""Publish a message.
|
|
388
388
|
|
|
389
389
|
Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
|
|
390
|
-
|
|
391
|
-
``
|
|
390
|
+
All top-level dict keys must be strings; non-string keys raise
|
|
391
|
+
``TypeError`` to avoid silent ``json.dumps`` coercion that would
|
|
392
|
+
collapse distinct keys into the same dedup key (e.g. ``{1: "x"}``
|
|
393
|
+
vs ``{"1": "x"}``). Only top-level keys are validated; nested
|
|
394
|
+
dicts follow ``json.dumps`` defaults.
|
|
392
395
|
"""
|
|
393
396
|
if not isinstance(message, (str, dict)):
|
|
394
397
|
raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
|
|
395
398
|
if isinstance(message, dict):
|
|
399
|
+
non_str_keys = [k for k in message if not isinstance(k, str)]
|
|
400
|
+
if non_str_keys:
|
|
401
|
+
raise TypeError(
|
|
402
|
+
"'message' dict keys must all be strings; "
|
|
403
|
+
f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
|
|
404
|
+
)
|
|
396
405
|
message_str = json.dumps(message, sort_keys=True)
|
|
397
406
|
else:
|
|
398
407
|
message_str = message
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_queue_key_manager.py
RENAMED
|
File without changes
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
{redis_message_queue-2.1.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|