redis-message-queue 3.1.2__tar.gz → 5.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-3.1.2 → redis_message_queue-5.0.0}/PKG-INFO +47 -15
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/README.md +46 -14
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/pyproject.toml +12 -1
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_abstract_redis_gateway.py +53 -8
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_config.py +34 -10
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_queue_key_manager.py +4 -2
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_redis_cluster.py +6 -2
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_redis_gateway.py +5 -1
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_stored_message.py +14 -3
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/__init__.py +10 -1
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +53 -8
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_redis_gateway.py +10 -6
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/redis_message_queue.py +203 -46
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/_implementation.py +9 -3
- redis_message_queue-5.0.0/redis_message_queue/py.typed +0 -0
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/redis_message_queue.py +203 -46
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/LICENSE +0 -0
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-3.1.2 → redis_message_queue-5.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: 5.0.0
|
|
4
4
|
Summary: Python message queuing with Redis and message deduplication
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# redis-message-queue
|
|
28
28
|
|
|
29
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
30
30
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
31
31
|
[](LICENSE)
|
|
32
32
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -50,7 +50,7 @@ Requires Redis server >= 6.2.
|
|
|
50
50
|
from redis import Redis
|
|
51
51
|
from redis_message_queue import RedisMessageQueue
|
|
52
52
|
|
|
53
|
-
client = Redis.from_url("redis://localhost:6379/0")
|
|
53
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
54
54
|
queue = RedisMessageQueue("my_queue", client=client, deduplication=True)
|
|
55
55
|
|
|
56
56
|
queue.publish("order:1234") # returns True
|
|
@@ -74,6 +74,9 @@ while True:
|
|
|
74
74
|
# Auto-acknowledged on success; cleaned up on exception
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
`RedisMessageQueue` itself is not a context manager. Use
|
|
78
|
+
`with queue.process_message() as message:` for each message.
|
|
79
|
+
|
|
77
80
|
## Why redis-message-queue
|
|
78
81
|
|
|
79
82
|
**The problem:** You're sending messages between services or workers and need guarantees. Simple Redis LPUSH/BRPOP loses messages on crashes, doesn't deduplicate, and gives you no visibility into what succeeded or failed.
|
|
@@ -97,8 +100,8 @@ All features are optional and can be enabled or disabled as needed.
|
|
|
97
100
|
|
|
98
101
|
| Configuration | Delivery guarantee |
|
|
99
102
|
|---|---|
|
|
100
|
-
| Default (
|
|
101
|
-
| With `visibility_timeout_seconds` | **At-
|
|
103
|
+
| Default (`visibility_timeout_seconds=300`) | **At-least-once** — expired messages are reclaimed and redelivered |
|
|
104
|
+
| With `visibility_timeout_seconds=None` | **At-most-once** — a consumer crash loses the in-flight message |
|
|
102
105
|
|
|
103
106
|
See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
|
|
104
107
|
|
|
@@ -107,7 +110,7 @@ See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-tim
|
|
|
107
110
|
### Deduplication
|
|
108
111
|
|
|
109
112
|
```python
|
|
110
|
-
# Default: deduplicate by
|
|
113
|
+
# Default: deduplicate by SHA-256 hash of canonical message content (1-hour TTL)
|
|
111
114
|
queue = RedisMessageQueue("q", client=client, deduplication=True)
|
|
112
115
|
|
|
113
116
|
# Custom dedup key (e.g., deduplicate by order ID only)
|
|
@@ -131,7 +134,8 @@ queue = RedisMessageQueue(
|
|
|
131
134
|
)
|
|
132
135
|
```
|
|
133
136
|
|
|
134
|
-
|
|
137
|
+
Completed and failed tracking queues are capped at 1,000 entries by default
|
|
138
|
+
when enabled. Override the caps when you need a different retention window:
|
|
135
139
|
|
|
136
140
|
```python
|
|
137
141
|
queue = RedisMessageQueue(
|
|
@@ -144,6 +148,8 @@ queue = RedisMessageQueue(
|
|
|
144
148
|
```
|
|
145
149
|
|
|
146
150
|
When set, `LTRIM` is called after each message is moved to the completed/failed queue. This is best-effort cleanup — if the trim fails, the queue is slightly longer until the next successful trim.
|
|
151
|
+
Pass `max_completed_length=None` or `max_failed_length=None` explicitly if you
|
|
152
|
+
want unbounded tracking queues.
|
|
147
153
|
|
|
148
154
|
### Crash recovery with visibility timeout
|
|
149
155
|
|
|
@@ -191,14 +197,14 @@ queue = RedisMessageQueue(
|
|
|
191
197
|
)
|
|
192
198
|
```
|
|
193
199
|
|
|
194
|
-
When a message has been delivered more than `max_delivery_count` times (due to consumer crashes causing visibility-timeout reclaim), it is automatically routed to a dead-letter queue (`{name}::
|
|
200
|
+
When a message has been delivered more than `max_delivery_count` times (due to consumer crashes causing visibility-timeout reclaim), it is automatically routed to a dead-letter queue (`{name}::dlq`) instead of being redelivered. `max_delivery_count` defaults to `10` on the built-in `client=` path, with the DLQ name auto-derived from the queue name. This prevents poison messages from cycling indefinitely.
|
|
195
201
|
|
|
196
202
|
Notes:
|
|
197
203
|
- requires `visibility_timeout_seconds` to be set (poison messages are only a concern with VT reclaim)
|
|
198
204
|
- the delivery count is tracked per-message in a Redis HASH and cleaned up on successful ack or move to completed/failed
|
|
199
205
|
- the delivery count increments when Redis grants the claim/lease, not when your handler begins running. If a process exits after Redis claims a message, that claim still counts toward `max_delivery_count`
|
|
200
206
|
- `max_delivery_count=1` means the message is delivered once; any reclaim routes it to the dead-letter queue
|
|
201
|
-
-
|
|
207
|
+
- set `max_delivery_count=None` explicitly for unlimited redelivery
|
|
202
208
|
- dead-lettered messages contain the **raw payload** only — the internal envelope (which carries a per-delivery UUID) is stripped before pushing to the DLQ, consistent with how completed/failed queues store messages. Two identical payloads dead-lettered separately are indistinguishable in the DLQ
|
|
203
209
|
|
|
204
210
|
### Graceful shutdown
|
|
@@ -227,7 +233,7 @@ while not interrupt.is_interrupted():
|
|
|
227
233
|
### Custom gateway
|
|
228
234
|
|
|
229
235
|
```python
|
|
230
|
-
from redis_message_queue
|
|
236
|
+
from redis_message_queue import RedisGateway
|
|
231
237
|
|
|
232
238
|
# Tune retry budget, dedup TTL, or wait interval
|
|
233
239
|
gateway = RedisGateway(
|
|
@@ -253,8 +259,8 @@ impossible. Note: tenacity may allow one additional attempt beyond the budget if
|
|
|
253
259
|
|
|
254
260
|
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
255
261
|
own logic) or fundamentally different semantics, subclass
|
|
256
|
-
`AbstractRedisGateway` from `redis_message_queue
|
|
257
|
-
|
|
262
|
+
`AbstractRedisGateway` from `redis_message_queue` (or
|
|
263
|
+
`redis_message_queue.asyncio` for the async sibling) and override the
|
|
258
264
|
operation methods directly.
|
|
259
265
|
|
|
260
266
|
If your custom gateway uses visibility timeouts, it must expose a public
|
|
@@ -285,6 +291,16 @@ Use a separate gateway instance per queue when `max_delivery_count` is enabled.
|
|
|
285
291
|
Dead-letter routing is gateway-scoped, so reusing the same gateway across different
|
|
286
292
|
queues is rejected.
|
|
287
293
|
|
|
294
|
+
If you use Redis Sentinel, pass the Redis client returned by
|
|
295
|
+
`sentinel.master_for(name)` to `client=` or `RedisGateway(redis_client=...)`, not
|
|
296
|
+
the `sentinel` object itself.
|
|
297
|
+
|
|
298
|
+
### Connection pool sizing
|
|
299
|
+
|
|
300
|
+
Each queue with `heartbeat_interval_seconds` set uses up to 2 simultaneous
|
|
301
|
+
connections: one for the main operation and one for heartbeat renewal. Size Redis
|
|
302
|
+
client pools with `max_connections >= 2 * number_of_queues + headroom`.
|
|
303
|
+
|
|
288
304
|
## Async API
|
|
289
305
|
|
|
290
306
|
Replace the import to use the async variant — the API is identical:
|
|
@@ -293,6 +309,11 @@ Replace the import to use the async variant — the API is identical:
|
|
|
293
309
|
from redis_message_queue.asyncio import RedisMessageQueue
|
|
294
310
|
```
|
|
295
311
|
|
|
312
|
+
The sync and async classes intentionally share names. In modules that use both,
|
|
313
|
+
alias the imports explicitly, for example
|
|
314
|
+
`from redis_message_queue import RedisMessageQueue as SyncRedisMessageQueue` and
|
|
315
|
+
`from redis_message_queue.asyncio import RedisMessageQueue as AsyncRedisMessageQueue`.
|
|
316
|
+
|
|
296
317
|
All examples work the same way. Remember to close the connection when done:
|
|
297
318
|
|
|
298
319
|
```python
|
|
@@ -303,6 +324,9 @@ client = redis.Redis()
|
|
|
303
324
|
await client.aclose()
|
|
304
325
|
```
|
|
305
326
|
|
|
327
|
+
For the sync Redis client, call `client.close()` during application shutdown when
|
|
328
|
+
you own the client lifecycle.
|
|
329
|
+
|
|
306
330
|
## Known limitations
|
|
307
331
|
|
|
308
332
|
- **No metrics or observability hooks.** The library logs warnings (stale leases, heartbeat failures, transient errors) via Python's `logging` module but does not expose callbacks, event hooks, or metric counters. To monitor queue health, inspect the underlying Redis keys directly or parse log output.
|
|
@@ -310,11 +334,11 @@ await client.aclose()
|
|
|
310
334
|
- **Redis Lua is atomic, not rollback-transactional.** The built-in scripts now preflight queue key types and fail closed on `WRONGTYPE` before mutating queue state, but Redis does not undo earlier writes if a later script command fails for another reason (for example `OOM` under severe memory pressure).
|
|
311
335
|
- **Batch reclaim limit of 100.** The visibility-timeout reclaim Lua script processes at most 100 expired messages per consumer poll. Under extreme backlog this may delay recovery, but prevents any single poll from blocking Redis.
|
|
312
336
|
- **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
|
|
313
|
-
- **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
|
|
314
337
|
- **Cluster detection uses `isinstance(client, RedisCluster)`.** Wrapped or instrumented cluster clients that delegate without inheriting will bypass hash-tag validation. Custom gateways should set `is_redis_cluster = True` explicitly.
|
|
315
338
|
- **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
|
|
316
339
|
- **Non-ASCII payloads use ~2x storage.** The default `ensure_ascii=True` in JSON serialization encodes non-ASCII characters as `\uXXXX` escape sequences. This is a deliberate compatibility choice.
|
|
317
340
|
- **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk. More broadly, any non-idempotent `LPUSH` path is vulnerable if the connection drops after server execution but before the client receives the response; all other built-in operations (deduplicated publish, lease-scoped ack/move, lease renewal) use replay markers and are safe under client-level `Retry`.
|
|
341
|
+
- **Redis Cluster default retry can stack with this library's retry budget.** In redis-py 6.0+, `RedisCluster()` constructs a default `ExponentialWithJitterBackoff` retry below this library's `retry_budget_seconds`. If you need a single retry surface, pass `retry=Retry(NoBackoff(), 0)` to the cluster client or reduce `retry_budget_seconds` to account for the lower-level retry window.
|
|
318
342
|
|
|
319
343
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
320
344
|
|
|
@@ -324,9 +348,17 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
|
|
|
324
348
|
|
|
325
349
|
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
326
350
|
|
|
327
|
-
- **Do not change `key_separator` on a live queue.**
|
|
328
|
-
- **Do not
|
|
351
|
+
- **Do not change `name` or `key_separator` on a live queue.** Both settings define the Redis key namespace. Existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing either value.
|
|
352
|
+
- **Do not rename `dead_letter_queue` on a live queue.** Existing DLQ records stay in the old list, while new failures route to the new list. Inspect or drain the old DLQ manually before switching names.
|
|
353
|
+
- **Do not toggle visibility timeout in either direction with messages in processing.** Messages claimed by non-VT consumers have no lease metadata, so VT-enabled consumers cannot reclaim them. Disabling VT later orphans existing lease deadline, lease token, and delivery count metadata and removes crash recovery for those in-flight messages. Drain the processing queue first.
|
|
329
354
|
- **Reducing `max_delivery_count` retroactively DLQs messages.** The delivery count hash persists across restarts. Messages whose accumulated count exceeds the new limit are immediately dead-lettered on next claim.
|
|
355
|
+
- **Changing `max_delivery_count` from a number to `None` leaves delivery metadata behind.** The delivery count hash continues to exist but is no longer consulted. Use this only after draining or after planning manual cleanup of the delivery-count hash.
|
|
356
|
+
- **Changing `get_deduplication_key` changes the dedup keyspace.** Existing dedup records become inert for the duration of their TTL. Drain the queue or clear the old deduplication keys before switching between the default hash, explicit `None`, or a custom key function.
|
|
357
|
+
- **Disabling `deduplication` has a retention-window overlap.** Existing dedup records remain in Redis until their TTL expires, but new publishes bypass them. Republishes that would have been suppressed under the old setting can enqueue during that window.
|
|
358
|
+
- **Disabling `enable_failed_queue` stops recording handler failures.** Existing failed entries remain in Redis, but new failures are removed from `processing` without being appended to the failed queue. If `max_delivery_count=None` is also set, repeated handler failures can be dropped with no DLQ or failed-queue record; see [Dead-letter queue](#dead-letter-queue).
|
|
359
|
+
- **Lowering `max_completed_length` or `max_failed_length` trims existing history.** The next completed or failed move calls `LTRIM`, so changing `None` to `N` or lowering `N` can immediately reduce historical entries to the new cap.
|
|
360
|
+
- **Do not switch sync and async gateway instances mid-process while claims are active.** Redis state is compatible across deploys, but each gateway instance keeps its own pending claim-recovery IDs. In-flight claim recovery state does not transfer between instances.
|
|
361
|
+
- **Switching between `gateway=` and `client=` can retarget the DLQ.** The built-in `client=` path derives the DLQ from the queue name. If a custom gateway used a different `dead_letter_queue`, switching paths has the same orphaning impact as renaming the DLQ.
|
|
330
362
|
|
|
331
363
|
### v2 to v3 migration
|
|
332
364
|
|
|
@@ -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)
|
|
@@ -24,7 +24,7 @@ Requires Redis server >= 6.2.
|
|
|
24
24
|
from redis import Redis
|
|
25
25
|
from redis_message_queue import RedisMessageQueue
|
|
26
26
|
|
|
27
|
-
client = Redis.from_url("redis://localhost:6379/0")
|
|
27
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
28
28
|
queue = RedisMessageQueue("my_queue", client=client, deduplication=True)
|
|
29
29
|
|
|
30
30
|
queue.publish("order:1234") # returns True
|
|
@@ -48,6 +48,9 @@ while True:
|
|
|
48
48
|
# Auto-acknowledged on success; cleaned up on exception
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
`RedisMessageQueue` itself is not a context manager. Use
|
|
52
|
+
`with queue.process_message() as message:` for each message.
|
|
53
|
+
|
|
51
54
|
## Why redis-message-queue
|
|
52
55
|
|
|
53
56
|
**The problem:** You're sending messages between services or workers and need guarantees. Simple Redis LPUSH/BRPOP loses messages on crashes, doesn't deduplicate, and gives you no visibility into what succeeded or failed.
|
|
@@ -71,8 +74,8 @@ All features are optional and can be enabled or disabled as needed.
|
|
|
71
74
|
|
|
72
75
|
| Configuration | Delivery guarantee |
|
|
73
76
|
|---|---|
|
|
74
|
-
| Default (
|
|
75
|
-
| With `visibility_timeout_seconds` | **At-
|
|
77
|
+
| Default (`visibility_timeout_seconds=300`) | **At-least-once** — expired messages are reclaimed and redelivered |
|
|
78
|
+
| With `visibility_timeout_seconds=None` | **At-most-once** — a consumer crash loses the in-flight message |
|
|
76
79
|
|
|
77
80
|
See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
|
|
78
81
|
|
|
@@ -81,7 +84,7 @@ See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-tim
|
|
|
81
84
|
### Deduplication
|
|
82
85
|
|
|
83
86
|
```python
|
|
84
|
-
# Default: deduplicate by
|
|
87
|
+
# Default: deduplicate by SHA-256 hash of canonical message content (1-hour TTL)
|
|
85
88
|
queue = RedisMessageQueue("q", client=client, deduplication=True)
|
|
86
89
|
|
|
87
90
|
# Custom dedup key (e.g., deduplicate by order ID only)
|
|
@@ -105,7 +108,8 @@ queue = RedisMessageQueue(
|
|
|
105
108
|
)
|
|
106
109
|
```
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
Completed and failed tracking queues are capped at 1,000 entries by default
|
|
112
|
+
when enabled. Override the caps when you need a different retention window:
|
|
109
113
|
|
|
110
114
|
```python
|
|
111
115
|
queue = RedisMessageQueue(
|
|
@@ -118,6 +122,8 @@ queue = RedisMessageQueue(
|
|
|
118
122
|
```
|
|
119
123
|
|
|
120
124
|
When set, `LTRIM` is called after each message is moved to the completed/failed queue. This is best-effort cleanup — if the trim fails, the queue is slightly longer until the next successful trim.
|
|
125
|
+
Pass `max_completed_length=None` or `max_failed_length=None` explicitly if you
|
|
126
|
+
want unbounded tracking queues.
|
|
121
127
|
|
|
122
128
|
### Crash recovery with visibility timeout
|
|
123
129
|
|
|
@@ -165,14 +171,14 @@ queue = RedisMessageQueue(
|
|
|
165
171
|
)
|
|
166
172
|
```
|
|
167
173
|
|
|
168
|
-
When a message has been delivered more than `max_delivery_count` times (due to consumer crashes causing visibility-timeout reclaim), it is automatically routed to a dead-letter queue (`{name}::
|
|
174
|
+
When a message has been delivered more than `max_delivery_count` times (due to consumer crashes causing visibility-timeout reclaim), it is automatically routed to a dead-letter queue (`{name}::dlq`) instead of being redelivered. `max_delivery_count` defaults to `10` on the built-in `client=` path, with the DLQ name auto-derived from the queue name. This prevents poison messages from cycling indefinitely.
|
|
169
175
|
|
|
170
176
|
Notes:
|
|
171
177
|
- requires `visibility_timeout_seconds` to be set (poison messages are only a concern with VT reclaim)
|
|
172
178
|
- the delivery count is tracked per-message in a Redis HASH and cleaned up on successful ack or move to completed/failed
|
|
173
179
|
- the delivery count increments when Redis grants the claim/lease, not when your handler begins running. If a process exits after Redis claims a message, that claim still counts toward `max_delivery_count`
|
|
174
180
|
- `max_delivery_count=1` means the message is delivered once; any reclaim routes it to the dead-letter queue
|
|
175
|
-
-
|
|
181
|
+
- set `max_delivery_count=None` explicitly for unlimited redelivery
|
|
176
182
|
- dead-lettered messages contain the **raw payload** only — the internal envelope (which carries a per-delivery UUID) is stripped before pushing to the DLQ, consistent with how completed/failed queues store messages. Two identical payloads dead-lettered separately are indistinguishable in the DLQ
|
|
177
183
|
|
|
178
184
|
### Graceful shutdown
|
|
@@ -201,7 +207,7 @@ while not interrupt.is_interrupted():
|
|
|
201
207
|
### Custom gateway
|
|
202
208
|
|
|
203
209
|
```python
|
|
204
|
-
from redis_message_queue
|
|
210
|
+
from redis_message_queue import RedisGateway
|
|
205
211
|
|
|
206
212
|
# Tune retry budget, dedup TTL, or wait interval
|
|
207
213
|
gateway = RedisGateway(
|
|
@@ -227,8 +233,8 @@ impossible. Note: tenacity may allow one additional attempt beyond the budget if
|
|
|
227
233
|
|
|
228
234
|
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
229
235
|
own logic) or fundamentally different semantics, subclass
|
|
230
|
-
`AbstractRedisGateway` from `redis_message_queue
|
|
231
|
-
|
|
236
|
+
`AbstractRedisGateway` from `redis_message_queue` (or
|
|
237
|
+
`redis_message_queue.asyncio` for the async sibling) and override the
|
|
232
238
|
operation methods directly.
|
|
233
239
|
|
|
234
240
|
If your custom gateway uses visibility timeouts, it must expose a public
|
|
@@ -259,6 +265,16 @@ Use a separate gateway instance per queue when `max_delivery_count` is enabled.
|
|
|
259
265
|
Dead-letter routing is gateway-scoped, so reusing the same gateway across different
|
|
260
266
|
queues is rejected.
|
|
261
267
|
|
|
268
|
+
If you use Redis Sentinel, pass the Redis client returned by
|
|
269
|
+
`sentinel.master_for(name)` to `client=` or `RedisGateway(redis_client=...)`, not
|
|
270
|
+
the `sentinel` object itself.
|
|
271
|
+
|
|
272
|
+
### Connection pool sizing
|
|
273
|
+
|
|
274
|
+
Each queue with `heartbeat_interval_seconds` set uses up to 2 simultaneous
|
|
275
|
+
connections: one for the main operation and one for heartbeat renewal. Size Redis
|
|
276
|
+
client pools with `max_connections >= 2 * number_of_queues + headroom`.
|
|
277
|
+
|
|
262
278
|
## Async API
|
|
263
279
|
|
|
264
280
|
Replace the import to use the async variant — the API is identical:
|
|
@@ -267,6 +283,11 @@ Replace the import to use the async variant — the API is identical:
|
|
|
267
283
|
from redis_message_queue.asyncio import RedisMessageQueue
|
|
268
284
|
```
|
|
269
285
|
|
|
286
|
+
The sync and async classes intentionally share names. In modules that use both,
|
|
287
|
+
alias the imports explicitly, for example
|
|
288
|
+
`from redis_message_queue import RedisMessageQueue as SyncRedisMessageQueue` and
|
|
289
|
+
`from redis_message_queue.asyncio import RedisMessageQueue as AsyncRedisMessageQueue`.
|
|
290
|
+
|
|
270
291
|
All examples work the same way. Remember to close the connection when done:
|
|
271
292
|
|
|
272
293
|
```python
|
|
@@ -277,6 +298,9 @@ client = redis.Redis()
|
|
|
277
298
|
await client.aclose()
|
|
278
299
|
```
|
|
279
300
|
|
|
301
|
+
For the sync Redis client, call `client.close()` during application shutdown when
|
|
302
|
+
you own the client lifecycle.
|
|
303
|
+
|
|
280
304
|
## Known limitations
|
|
281
305
|
|
|
282
306
|
- **No metrics or observability hooks.** The library logs warnings (stale leases, heartbeat failures, transient errors) via Python's `logging` module but does not expose callbacks, event hooks, or metric counters. To monitor queue health, inspect the underlying Redis keys directly or parse log output.
|
|
@@ -284,11 +308,11 @@ await client.aclose()
|
|
|
284
308
|
- **Redis Lua is atomic, not rollback-transactional.** The built-in scripts now preflight queue key types and fail closed on `WRONGTYPE` before mutating queue state, but Redis does not undo earlier writes if a later script command fails for another reason (for example `OOM` under severe memory pressure).
|
|
285
309
|
- **Batch reclaim limit of 100.** The visibility-timeout reclaim Lua script processes at most 100 expired messages per consumer poll. Under extreme backlog this may delay recovery, but prevents any single poll from blocking Redis.
|
|
286
310
|
- **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
|
|
287
|
-
- **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
|
|
288
311
|
- **Cluster detection uses `isinstance(client, RedisCluster)`.** Wrapped or instrumented cluster clients that delegate without inheriting will bypass hash-tag validation. Custom gateways should set `is_redis_cluster = True` explicitly.
|
|
289
312
|
- **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
|
|
290
313
|
- **Non-ASCII payloads use ~2x storage.** The default `ensure_ascii=True` in JSON serialization encodes non-ASCII characters as `\uXXXX` escape sequences. This is a deliberate compatibility choice.
|
|
291
314
|
- **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk. More broadly, any non-idempotent `LPUSH` path is vulnerable if the connection drops after server execution but before the client receives the response; all other built-in operations (deduplicated publish, lease-scoped ack/move, lease renewal) use replay markers and are safe under client-level `Retry`.
|
|
315
|
+
- **Redis Cluster default retry can stack with this library's retry budget.** In redis-py 6.0+, `RedisCluster()` constructs a default `ExponentialWithJitterBackoff` retry below this library's `retry_budget_seconds`. If you need a single retry surface, pass `retry=Retry(NoBackoff(), 0)` to the cluster client or reduce `retry_budget_seconds` to account for the lower-level retry window.
|
|
292
316
|
|
|
293
317
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
294
318
|
|
|
@@ -298,9 +322,17 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
|
|
|
298
322
|
|
|
299
323
|
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
300
324
|
|
|
301
|
-
- **Do not change `key_separator` on a live queue.**
|
|
302
|
-
- **Do not
|
|
325
|
+
- **Do not change `name` or `key_separator` on a live queue.** Both settings define the Redis key namespace. Existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing either value.
|
|
326
|
+
- **Do not rename `dead_letter_queue` on a live queue.** Existing DLQ records stay in the old list, while new failures route to the new list. Inspect or drain the old DLQ manually before switching names.
|
|
327
|
+
- **Do not toggle visibility timeout in either direction with messages in processing.** Messages claimed by non-VT consumers have no lease metadata, so VT-enabled consumers cannot reclaim them. Disabling VT later orphans existing lease deadline, lease token, and delivery count metadata and removes crash recovery for those in-flight messages. Drain the processing queue first.
|
|
303
328
|
- **Reducing `max_delivery_count` retroactively DLQs messages.** The delivery count hash persists across restarts. Messages whose accumulated count exceeds the new limit are immediately dead-lettered on next claim.
|
|
329
|
+
- **Changing `max_delivery_count` from a number to `None` leaves delivery metadata behind.** The delivery count hash continues to exist but is no longer consulted. Use this only after draining or after planning manual cleanup of the delivery-count hash.
|
|
330
|
+
- **Changing `get_deduplication_key` changes the dedup keyspace.** Existing dedup records become inert for the duration of their TTL. Drain the queue or clear the old deduplication keys before switching between the default hash, explicit `None`, or a custom key function.
|
|
331
|
+
- **Disabling `deduplication` has a retention-window overlap.** Existing dedup records remain in Redis until their TTL expires, but new publishes bypass them. Republishes that would have been suppressed under the old setting can enqueue during that window.
|
|
332
|
+
- **Disabling `enable_failed_queue` stops recording handler failures.** Existing failed entries remain in Redis, but new failures are removed from `processing` without being appended to the failed queue. If `max_delivery_count=None` is also set, repeated handler failures can be dropped with no DLQ or failed-queue record; see [Dead-letter queue](#dead-letter-queue).
|
|
333
|
+
- **Lowering `max_completed_length` or `max_failed_length` trims existing history.** The next completed or failed move calls `LTRIM`, so changing `None` to `N` or lowering `N` can immediately reduce historical entries to the new cap.
|
|
334
|
+
- **Do not switch sync and async gateway instances mid-process while claims are active.** Redis state is compatible across deploys, but each gateway instance keeps its own pending claim-recovery IDs. In-flight claim recovery state does not transfer between instances.
|
|
335
|
+
- **Switching between `gateway=` and `client=` can retarget the DLQ.** The built-in `client=` path derives the DLQ from the queue name. If a custom gateway used a different `dead_letter_queue`, switching paths has the same orphaning impact as renaming the DLQ.
|
|
304
336
|
|
|
305
337
|
### v2 to v3 migration
|
|
306
338
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "redis-message-queue"
|
|
3
|
-
version = "
|
|
3
|
+
version = "5.0.0"
|
|
4
4
|
description = "Python message queuing with Redis and message deduplication"
|
|
5
5
|
authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
8
|
+
include = ["redis_message_queue/py.typed"]
|
|
8
9
|
keywords = ["redis", "message-queue", "deduplication", "task-queue"]
|
|
9
10
|
classifiers = [
|
|
10
11
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -46,6 +47,16 @@ ignore = ["E731"]
|
|
|
46
47
|
|
|
47
48
|
[tool.pytest.ini_options]
|
|
48
49
|
asyncio_default_fixture_loop_scope = "function"
|
|
50
|
+
filterwarnings = [
|
|
51
|
+
# Channel-rule warnings are emitted at user-actionable failure sites
|
|
52
|
+
# (see _STALE_LEASE_*_WARNING, heartbeat-failure, etc.). Dedicated B8
|
|
53
|
+
# tests assert emission via pytest.warns(); other tests trigger these
|
|
54
|
+
# warnings incidentally as a side effect of exercising lifecycle paths
|
|
55
|
+
# and would fail under "error::" promotion. Ignore at the default
|
|
56
|
+
# filter so emission is silent in incidental tests but pytest.warns
|
|
57
|
+
# assertions still verify emission where intended.
|
|
58
|
+
"ignore::RuntimeWarning:redis_message_queue",
|
|
59
|
+
]
|
|
49
60
|
markers = [
|
|
50
61
|
"integration: tests that require a real Redis server",
|
|
51
62
|
]
|
|
@@ -20,12 +20,18 @@ class AbstractRedisGateway(ABC):
|
|
|
20
20
|
causing the queue to treat the gateway as a non-lease implementation.
|
|
21
21
|
|
|
22
22
|
The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
|
|
23
|
-
from the gateway
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
from the gateway. The abstract base provides ``None`` defaults via
|
|
24
|
+
``@property``; lease + DLQ-enabled custom gateways MUST override both
|
|
25
|
+
to enable poison-message routing. Avoid using these attribute names
|
|
26
|
+
for unrelated purposes on custom gateway implementations.
|
|
27
|
+
When DLQ routing is enabled, the queue also attaches an internal
|
|
28
|
+
``_rmq_bound_pending_queue`` attribute to gateway instances to reject
|
|
29
|
+
reusing one DLQ-bound gateway across multiple pending queues.
|
|
30
|
+
|
|
31
|
+
Gateways that wrap a Redis Cluster client should override the
|
|
32
|
+
``is_redis_cluster`` property to return ``True`` so the queue can
|
|
33
|
+
apply hash-tag validation at construction time. The abstract base
|
|
34
|
+
provides ``False`` as the default; non-cluster gateways inherit it.
|
|
29
35
|
|
|
30
36
|
Concurrency
|
|
31
37
|
-----------
|
|
@@ -35,8 +41,29 @@ class AbstractRedisGateway(ABC):
|
|
|
35
41
|
``remove_message``. Implementations must be safe for concurrent calls
|
|
36
42
|
across these methods. The built-in gateway achieves this via atomic
|
|
37
43
|
Lua scripts.
|
|
44
|
+
|
|
45
|
+
Server-side atomicity (Lua scripts, redis-py transactions, or a
|
|
46
|
+
single-command-per-mutation discipline) is the recommended pattern.
|
|
47
|
+
Python-level locks (``threading.Lock``, ``asyncio.Lock``) shared across
|
|
48
|
+
``renew_message_lease`` and ``move_message`` / ``remove_message`` are an
|
|
49
|
+
anti-pattern: they serialize without giving Redis-server-side atomicity,
|
|
50
|
+
leave partial-failure orphans, and can deadlock against the heartbeat
|
|
51
|
+
lifecycle (the heartbeat is awaited from the same finally block that
|
|
52
|
+
issues the cleanup move/remove).
|
|
38
53
|
"""
|
|
39
54
|
|
|
55
|
+
@property
|
|
56
|
+
def is_redis_cluster(self) -> bool:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def max_delivery_count(self) -> int | None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dead_letter_queue(self) -> str | None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
40
67
|
@abstractmethod
|
|
41
68
|
def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
|
|
42
69
|
"""Publish a message with deduplication.
|
|
@@ -48,7 +75,20 @@ class AbstractRedisGateway(ABC):
|
|
|
48
75
|
|
|
49
76
|
@abstractmethod
|
|
50
77
|
def add_message(self, queue: str, message: str) -> None:
|
|
51
|
-
"""Unconditionally enqueue a message. No deduplication is performed.
|
|
78
|
+
"""Unconditionally enqueue a message. No deduplication is performed.
|
|
79
|
+
|
|
80
|
+
This library deliberately does not wrap the underlying enqueue in a
|
|
81
|
+
retry — retrying after the server may already have executed the
|
|
82
|
+
command can silently duplicate the message. The caller can still
|
|
83
|
+
retry (accepting duplicates).
|
|
84
|
+
|
|
85
|
+
Note: a client-level retry policy bypasses this guarantee. If the
|
|
86
|
+
underlying ``redis.Redis`` / ``redis.asyncio.Redis`` client was
|
|
87
|
+
constructed with ``retry=Retry(...)``, redis-py retries on
|
|
88
|
+
``ConnectionError`` / ``TimeoutError`` below this call and may
|
|
89
|
+
duplicate. Pass ``retry=None`` (the default) when strict at-most-once
|
|
90
|
+
is required for non-deduplicated publishes.
|
|
91
|
+
"""
|
|
52
92
|
|
|
53
93
|
@abstractmethod
|
|
54
94
|
def move_message(
|
|
@@ -131,7 +171,12 @@ class AbstractRedisGateway(ABC):
|
|
|
131
171
|
|
|
132
172
|
Implementations MUST respect a reasonable timeout or return None
|
|
133
173
|
periodically so the consumer can check for interrupts. Blocking
|
|
134
|
-
indefinitely without returning prevents graceful shutdown.
|
|
174
|
+
indefinitely without returning prevents graceful shutdown. As a
|
|
175
|
+
concrete reference, the built-in gateway uses a 5s outer wait
|
|
176
|
+
decomposed into 0.25s polling steps (see
|
|
177
|
+
``_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS``); custom
|
|
178
|
+
implementations should keep the longest single block below ~5s so
|
|
179
|
+
an interrupt is observed within one polling step.
|
|
135
180
|
"""
|
|
136
181
|
|
|
137
182
|
@abstractmethod
|
|
@@ -112,13 +112,16 @@ def validate_gateway_parameters(
|
|
|
112
112
|
if not isinstance(message_deduplication_log_ttl_seconds, int) or isinstance(
|
|
113
113
|
message_deduplication_log_ttl_seconds, bool
|
|
114
114
|
):
|
|
115
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(message_deduplication_log_ttl_seconds, bool) else ""
|
|
115
116
|
raise TypeError(
|
|
116
117
|
f"'message_deduplication_log_ttl_seconds' must be an int, "
|
|
117
|
-
f"got {type(message_deduplication_log_ttl_seconds).__name__}"
|
|
118
|
+
f"got {type(message_deduplication_log_ttl_seconds).__name__}{bool_hint}"
|
|
118
119
|
)
|
|
119
120
|
if not isinstance(message_wait_interval_seconds, int) or isinstance(message_wait_interval_seconds, bool):
|
|
121
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(message_wait_interval_seconds, bool) else ""
|
|
120
122
|
raise TypeError(
|
|
121
|
-
f"'message_wait_interval_seconds' must be an int,
|
|
123
|
+
f"'message_wait_interval_seconds' must be an int, "
|
|
124
|
+
f"got {type(message_wait_interval_seconds).__name__}{bool_hint}"
|
|
122
125
|
)
|
|
123
126
|
if message_deduplication_log_ttl_seconds <= 0:
|
|
124
127
|
raise ValueError(
|
|
@@ -130,9 +133,10 @@ def validate_gateway_parameters(
|
|
|
130
133
|
if not isinstance(message_visibility_timeout_seconds, int) or isinstance(
|
|
131
134
|
message_visibility_timeout_seconds, bool
|
|
132
135
|
):
|
|
136
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(message_visibility_timeout_seconds, bool) else ""
|
|
133
137
|
raise TypeError(
|
|
134
138
|
"'message_visibility_timeout_seconds' must be an int or None, "
|
|
135
|
-
f"got {type(message_visibility_timeout_seconds).__name__}"
|
|
139
|
+
f"got {type(message_visibility_timeout_seconds).__name__}{bool_hint}"
|
|
136
140
|
)
|
|
137
141
|
if message_visibility_timeout_seconds <= 0:
|
|
138
142
|
raise ValueError(
|
|
@@ -141,18 +145,24 @@ def validate_gateway_parameters(
|
|
|
141
145
|
)
|
|
142
146
|
|
|
143
147
|
if not isinstance(retry_budget_seconds, int) or isinstance(retry_budget_seconds, bool):
|
|
144
|
-
|
|
148
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(retry_budget_seconds, bool) else ""
|
|
149
|
+
raise TypeError(f"'retry_budget_seconds' must be an int, got {type(retry_budget_seconds).__name__}{bool_hint}")
|
|
145
150
|
if retry_budget_seconds < 0:
|
|
146
151
|
raise ValueError(f"'retry_budget_seconds' must be non-negative, got {retry_budget_seconds}")
|
|
147
152
|
|
|
148
153
|
if isinstance(retry_max_delay_seconds, bool) or not isinstance(retry_max_delay_seconds, (int, float)):
|
|
149
|
-
|
|
154
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(retry_max_delay_seconds, bool) else ""
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"'retry_max_delay_seconds' must be a number, got {type(retry_max_delay_seconds).__name__}{bool_hint}"
|
|
157
|
+
)
|
|
150
158
|
if not math.isfinite(retry_max_delay_seconds) or retry_max_delay_seconds <= 0:
|
|
151
159
|
raise ValueError(f"'retry_max_delay_seconds' must be a finite positive number, got {retry_max_delay_seconds}")
|
|
152
160
|
|
|
153
161
|
if isinstance(retry_initial_delay_seconds, bool) or not isinstance(retry_initial_delay_seconds, (int, float)):
|
|
162
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(retry_initial_delay_seconds, bool) else ""
|
|
154
163
|
raise TypeError(
|
|
155
|
-
f"'retry_initial_delay_seconds' must be a number,
|
|
164
|
+
f"'retry_initial_delay_seconds' must be a number, "
|
|
165
|
+
f"got {type(retry_initial_delay_seconds).__name__}{bool_hint}"
|
|
156
166
|
)
|
|
157
167
|
if not math.isfinite(retry_initial_delay_seconds) or retry_initial_delay_seconds <= 0:
|
|
158
168
|
raise ValueError(
|
|
@@ -172,15 +182,19 @@ def validate_dead_letter_parameters(
|
|
|
172
182
|
) -> None:
|
|
173
183
|
if max_delivery_count is not None:
|
|
174
184
|
if not isinstance(max_delivery_count, int) or isinstance(max_delivery_count, bool):
|
|
175
|
-
|
|
185
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(max_delivery_count, bool) else ""
|
|
186
|
+
raise TypeError(
|
|
187
|
+
f"'max_delivery_count' must be an int or None, got {type(max_delivery_count).__name__}{bool_hint}"
|
|
188
|
+
)
|
|
176
189
|
if max_delivery_count <= 0:
|
|
177
190
|
raise ValueError(f"'max_delivery_count' must be positive, got {max_delivery_count}")
|
|
178
191
|
if message_visibility_timeout_seconds is None:
|
|
179
192
|
raise ValueError("'max_delivery_count' requires 'message_visibility_timeout_seconds' to be set.")
|
|
180
193
|
if dead_letter_queue is not None and not isinstance(dead_letter_queue, str):
|
|
181
|
-
|
|
194
|
+
bool_hint = " (use True or False, not 1/0)" if isinstance(dead_letter_queue, bool) else ""
|
|
195
|
+
raise TypeError(f"'dead_letter_queue' must be a str or None, got {type(dead_letter_queue).__name__}{bool_hint}")
|
|
182
196
|
if isinstance(dead_letter_queue, str) and dead_letter_queue and not dead_letter_queue.strip():
|
|
183
|
-
raise ValueError("'dead_letter_queue' must
|
|
197
|
+
raise ValueError(f"'dead_letter_queue' must contain non-whitespace characters; got {dead_letter_queue!r}")
|
|
184
198
|
if max_delivery_count is not None and not dead_letter_queue:
|
|
185
199
|
raise ValueError("'dead_letter_queue' is required when 'max_delivery_count' is set.")
|
|
186
200
|
if dead_letter_queue and max_delivery_count is None:
|
|
@@ -233,7 +247,17 @@ end
|
|
|
233
247
|
local result = 0
|
|
234
248
|
local was_set = redis.call('SET', KEYS[1], '', 'NX', 'EX', tonumber(ARGV[1]))
|
|
235
249
|
if was_set then
|
|
236
|
-
|
|
250
|
+
-- pcall guards against LPUSH OOM after the dedup key was committed.
|
|
251
|
+
-- Without this, OOM would strand the publish: dedup says "already
|
|
252
|
+
-- published" on retry, but the message is in no queue. Compensate by
|
|
253
|
+
-- clearing the dedup key so the retry can re-attempt.
|
|
254
|
+
local ok = pcall(function()
|
|
255
|
+
redis.call('LPUSH', KEYS[2], ARGV[2])
|
|
256
|
+
end)
|
|
257
|
+
if not ok then
|
|
258
|
+
redis.pcall('DEL', KEYS[1])
|
|
259
|
+
return redis.error_reply('OOM during publish; dedup key cleared for retry')
|
|
260
|
+
end
|
|
237
261
|
result = 1
|
|
238
262
|
end
|
|
239
263
|
|
{redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -22,13 +22,15 @@ class QueueKeyManager:
|
|
|
22
22
|
if not isinstance(queue_name, str):
|
|
23
23
|
raise TypeError(f"'name' must be a string, got {type(queue_name).__name__}")
|
|
24
24
|
if not queue_name.strip():
|
|
25
|
-
raise ValueError("'name' must be a non-empty string")
|
|
25
|
+
raise ValueError(f"'name' must be a non-empty string with non-whitespace characters; got {queue_name!r}")
|
|
26
26
|
if "\x00" in queue_name:
|
|
27
27
|
raise ValueError("queue name must not contain null bytes")
|
|
28
28
|
if not isinstance(key_separator, str):
|
|
29
29
|
raise TypeError(f"'key_separator' must be a string, got {type(key_separator).__name__}")
|
|
30
30
|
if not key_separator.strip():
|
|
31
|
-
raise ValueError(
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"'key_separator' must be a non-empty string with non-whitespace characters; got {key_separator!r}"
|
|
33
|
+
)
|
|
32
34
|
# Reject names containing the separator: ``QueueKeyManager('q').deduplication('pending')``
|
|
33
35
|
# and ``QueueKeyManager('q::deduplication').pending`` would both map to
|
|
34
36
|
# ``'q::deduplication::pending'`` — a string key colliding with a list key, producing
|
{redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_redis_cluster.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
|
-
from redis.
|
|
3
|
+
from redis.crc import key_slot
|
|
4
4
|
|
|
5
5
|
from redis_message_queue._queue_key_manager import QueueKeyManager
|
|
6
6
|
|
|
@@ -42,4 +42,8 @@ def validate_queue_keys_for_redis_cluster(
|
|
|
42
42
|
queue_slot = next(iter(slots))
|
|
43
43
|
dead_letter_slot = _redis_cluster_key_slot(dead_letter_queue)
|
|
44
44
|
if dead_letter_slot != queue_slot:
|
|
45
|
-
raise ValueError(
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"'dead_letter_queue' must share the same Redis Cluster hash tag as the queue keys."
|
|
47
|
+
" For example, both '{myqueue}::pending' and '{myqueue}::dlq' share the '{myqueue}' tag —"
|
|
48
|
+
" give your DLQ a name with the same braces."
|
|
49
|
+
)
|