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.
Files changed (22) hide show
  1. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/PKG-INFO +60 -12
  2. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/README.md +59 -11
  3. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/pyproject.toml +12 -1
  4. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/__init__.py +2 -0
  5. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_abstract_redis_gateway.py +56 -7
  6. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_config.py +65 -26
  7. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_queue_key_manager.py +6 -2
  8. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_redis_cluster.py +6 -2
  9. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_redis_gateway.py +8 -4
  10. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_stored_message.py +14 -3
  11. redis_message_queue-5.0.0/redis_message_queue/asyncio/__init__.py +15 -0
  12. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +56 -7
  13. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/_redis_gateway.py +12 -8
  14. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/asyncio/redis_message_queue.py +203 -46
  15. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/_implementation.py +9 -3
  16. redis_message_queue-5.0.0/redis_message_queue/py.typed +0 -0
  17. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/redis_message_queue.py +204 -47
  18. redis_message_queue-3.1.1/redis_message_queue/asyncio/__init__.py +0 -5
  19. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/LICENSE +0 -0
  20. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/_callable_utils.py +0 -0
  21. {redis_message_queue-3.1.1 → redis_message_queue-5.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  22. {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.1.1
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.1-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.
@@ -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
- [![PyPI Version](https://img.shields.io/badge/v3.1.1-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.
@@ -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.1.1"
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 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(
@@ -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