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.
Files changed (21) hide show
  1. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/PKG-INFO +47 -15
  2. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/README.md +46 -14
  3. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/pyproject.toml +12 -1
  4. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_abstract_redis_gateway.py +53 -8
  5. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_config.py +34 -10
  6. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_queue_key_manager.py +4 -2
  7. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_redis_cluster.py +6 -2
  8. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_redis_gateway.py +5 -1
  9. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_stored_message.py +14 -3
  10. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/__init__.py +10 -1
  11. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +53 -8
  12. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_redis_gateway.py +10 -6
  13. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/redis_message_queue.py +203 -46
  14. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/_implementation.py +9 -3
  15. redis_message_queue-5.0.0/redis_message_queue/py.typed +0 -0
  16. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/redis_message_queue.py +203 -46
  17. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/LICENSE +0 -0
  18. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/__init__.py +0 -0
  19. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/_callable_utils.py +0 -0
  20. {redis_message_queue-3.1.2 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {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.1.2
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
- [![PyPI Version](https://img.shields.io/badge/v3.1.2-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v5.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
30
30
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
31
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
32
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](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 (no visibility timeout) | **At-most-once** — a consumer crash loses the in-flight message |
101
- | With `visibility_timeout_seconds` | **At-least-once** — expired messages are reclaimed and redelivered |
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 full message content (1-hour TTL)
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
- To prevent unbounded growth, cap the queue lengths:
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}::dead_letter`) instead of being redelivered. This prevents poison messages from cycling indefinitely.
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
- - without `max_delivery_count`, messages are redelivered indefinitely (existing behavior)
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._redis_gateway import RedisGateway
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._abstract_redis_gateway`
257
- (or `redis_message_queue.asyncio._abstract_redis_gateway`) and override the
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.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
328
- - **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
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
- [![PyPI Version](https://img.shields.io/badge/v3.1.2-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v5.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
4
4
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
6
6
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](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 (no visibility timeout) | **At-most-once** — a consumer crash loses the in-flight message |
75
- | With `visibility_timeout_seconds` | **At-least-once** — expired messages are reclaimed and redelivered |
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 full message content (1-hour TTL)
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
- To prevent unbounded growth, cap the queue lengths:
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}::dead_letter`) instead of being redelivered. This prevents poison messages from cycling indefinitely.
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
- - without `max_delivery_count`, messages are redelivered indefinitely (existing behavior)
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._redis_gateway import RedisGateway
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._abstract_redis_gateway`
231
- (or `redis_message_queue.asyncio._abstract_redis_gateway`) and override the
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.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
302
- - **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
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.1.2"
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 via ``getattr``. Avoid using these attribute names for
24
- unrelated purposes on custom gateway implementations.
25
-
26
- Gateways that wrap a Redis Cluster client should expose an
27
- ``is_redis_cluster`` property returning ``True`` so the queue can apply
28
- hash-tag validation at construction time.
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, got {type(message_wait_interval_seconds).__name__}"
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
- raise TypeError(f"'retry_budget_seconds' must be an int, got {type(retry_budget_seconds).__name__}")
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
- raise TypeError(f"'retry_max_delay_seconds' must be a number, got {type(retry_max_delay_seconds).__name__}")
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, got {type(retry_initial_delay_seconds).__name__}"
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
- raise TypeError(f"'max_delivery_count' must be an int or None, got {type(max_delivery_count).__name__}")
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
- raise TypeError(f"'dead_letter_queue' must be a str or None, got {type(dead_letter_queue).__name__}")
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 be a non-empty string")
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
- redis.call('LPUSH', KEYS[2], ARGV[2])
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
 
@@ -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("'key_separator' must be a non-empty string")
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
@@ -1,6 +1,6 @@
1
1
  import re
2
2
 
3
- from redis.cluster import key_slot
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("'dead_letter_queue' must share the same Redis Cluster hash tag as the queue keys.")
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
+ )