redis-message-queue 3.1.1__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.1 → redis_message_queue-5.0.0}/PKG-INFO +60 -12
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/README.md +59 -11
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/pyproject.toml +12 -1
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/__init__.py +2 -0
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_abstract_redis_gateway.py +56 -7
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_config.py +65 -26
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_queue_key_manager.py +6 -2
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_redis_cluster.py +6 -2
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_redis_gateway.py +8 -4
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_stored_message.py +14 -3
- redis_message_queue-5.0.0/redis_message_queue/asyncio/__init__.py +15 -0
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +56 -7
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_redis_gateway.py +12 -8
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/redis_message_queue.py +203 -46
- {redis_message_queue-3.1.1 → 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.1 → redis_message_queue-5.0.0}/redis_message_queue/redis_message_queue.py +204 -47
- redis_message_queue-3.1.1/redis_message_queue/asyncio/__init__.py +0 -5
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/LICENSE +0 -0
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-3.1.1 → 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.
|
|
@@ -312,10 +336,34 @@ await client.aclose()
|
|
|
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
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.
|
|
314
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.
|
|
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.
|
|
315
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.
|
|
316
342
|
|
|
317
343
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
318
344
|
|
|
345
|
+
## Upgrading
|
|
346
|
+
|
|
347
|
+
### Configuration changes on live queues
|
|
348
|
+
|
|
349
|
+
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
350
|
+
|
|
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.
|
|
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.
|
|
362
|
+
|
|
363
|
+
### v2 to v3 migration
|
|
364
|
+
|
|
365
|
+
v3.0.0 replaced the `retry_strategy: Callable` constructor parameter with `retry_budget_seconds`, `retry_max_delay_seconds`, and `retry_initial_delay_seconds`. Users with custom retry strategies should subclass `AbstractRedisGateway` instead (see [Custom gateway](#custom-gateway)).
|
|
366
|
+
|
|
319
367
|
## Running locally
|
|
320
368
|
|
|
321
369
|
You'll need a Redis server:
|
|
@@ -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.
|
|
@@ -286,10 +310,34 @@ await client.aclose()
|
|
|
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
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.
|
|
288
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.
|
|
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.
|
|
289
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.
|
|
290
316
|
|
|
291
317
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
292
318
|
|
|
319
|
+
## Upgrading
|
|
320
|
+
|
|
321
|
+
### Configuration changes on live queues
|
|
322
|
+
|
|
323
|
+
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
324
|
+
|
|
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.
|
|
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.
|
|
336
|
+
|
|
337
|
+
### v2 to v3 migration
|
|
338
|
+
|
|
339
|
+
v3.0.0 replaced the `retry_strategy: Callable` constructor parameter with `retry_budget_seconds`, `retry_max_delay_seconds`, and `retry_initial_delay_seconds`. Users with custom retry strategies should subclass `AbstractRedisGateway` instead (see [Custom gateway](#custom-gateway)).
|
|
340
|
+
|
|
293
341
|
## Running locally
|
|
294
342
|
|
|
295
343
|
You'll need a Redis server:
|
|
@@ -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
|
]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
|
|
2
|
+
from redis_message_queue._redis_gateway import RedisGateway
|
|
2
3
|
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
3
4
|
from redis_message_queue.interrupt_handler import (
|
|
4
5
|
BaseGracefulInterruptHandler,
|
|
@@ -8,6 +9,7 @@ from redis_message_queue.redis_message_queue import RedisMessageQueue
|
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"RedisMessageQueue",
|
|
12
|
+
"RedisGateway",
|
|
11
13
|
"AbstractRedisGateway",
|
|
12
14
|
"ClaimedMessage",
|
|
13
15
|
"MessageData",
|
|
@@ -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(
|
|
@@ -128,6 +168,15 @@ class AbstractRedisGateway(ABC):
|
|
|
128
168
|
use leases.
|
|
129
169
|
|
|
130
170
|
Return None if no message was available (e.g. timeout or interrupt).
|
|
171
|
+
|
|
172
|
+
Implementations MUST respect a reasonable timeout or return None
|
|
173
|
+
periodically so the consumer can check for interrupts. Blocking
|
|
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.
|
|
131
180
|
"""
|
|
132
181
|
|
|
133
182
|
@abstractmethod
|