redis-message-queue 2.0.0__tar.gz → 2.1.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-2.0.0 → redis_message_queue-2.1.0}/PKG-INFO +36 -6
  2. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/README.md +35 -5
  3. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/pyproject.toml +1 -1
  4. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_abstract_redis_gateway.py +17 -5
  5. redis_message_queue-2.1.0/redis_message_queue/_config.py +703 -0
  6. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_queue_key_manager.py +10 -1
  7. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_redis_gateway.py +260 -200
  8. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_stored_message.py +10 -0
  9. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +17 -5
  10. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/_redis_gateway.py +261 -208
  11. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/redis_message_queue.py +39 -5
  12. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/interrupt_handler/_implementation.py +14 -0
  13. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/redis_message_queue.py +58 -11
  14. redis_message_queue-2.0.0/redis_message_queue/_config.py +0 -318
  15. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/LICENSE +0 -0
  16. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/__init__.py +0 -0
  17. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_callable_utils.py +0 -0
  18. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/__init__.py +0 -0
  20. {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-2.0.0 → redis_message_queue-2.1.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: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  License-File: LICENSE
6
6
  Author: Elijas
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  # redis-message-queue
18
18
 
19
- [![PyPI Version](https://img.shields.io/badge/v2.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
19
+ [![PyPI Version](https://img.shields.io/badge/v2.1.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
20
20
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
21
21
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
22
22
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -78,7 +78,7 @@ while True:
78
78
  | **Dead-letter queue** | Poison messages that exceed a configurable delivery count are automatically routed to a dead-letter queue instead of being redelivered indefinitely |
79
79
  | **Graceful shutdown** | Built-in interrupt handler lets consumers finish current work before stopping |
80
80
  | **Lease heartbeats** | Optional background lease renewal keeps long-running handlers from being redelivered prematurely |
81
- | **Connection retries** | Exponential backoff with jitter for Redis operations (deduplicated publish, ack, lease renewal). Message-claim paths use idempotent Lua claim IDs so retryable errors can recover the original claim safely, including on the next call from the same gateway instance if the original wait call had to give up before Redis became reachable again. Non-deduplicated publish is not retried — the exception propagates so the caller can decide whether to retry (accepting potential duplicates) |
81
+ | **Connection retries** | Exponential backoff with jitter for Redis operations (deduplicated publish, ack, lease renewal). Publish and cleanup paths use replay markers so retryable connection drops preserve the original result within the same call. Message-claim paths use idempotent Lua claim IDs plus persisted claim metadata so retryable errors can recover the original claim safely, either in the same wait call or on the next call from the same gateway instance if the original wait had to give up before Redis became reachable again. Active waits keep their in-flight claim IDs private until they exit, so a concurrent caller on the same gateway instance cannot recover the same claim twice. Timed waits also stay bounded: once the configured wait window expires, the queue only replays persisted state for that same claim attempt and will not claim fresh work after the deadline. If a graceful interrupt arrives during claim recovery, the wait call stops instead of taking fresh work. Non-deduplicated publish is not retried — the exception propagates so the caller can decide whether to retry (accepting potential duplicates) |
82
82
  | **Async support** | Drop-in async variant with identical API |
83
83
 
84
84
  All features are optional and can be enabled or disabled as needed.
@@ -155,6 +155,19 @@ Tradeoffs:
155
155
  - heartbeats add background renewal work for active messages
156
156
  - if a heartbeat fails (network error or stale lease), the heartbeat stops silently; the consumer continues processing but may find at ack time that the message was reclaimed by another consumer
157
157
 
158
+ Pass `on_heartbeat_failure` to receive a best-effort callback when the heartbeat stops because renewal failed:
159
+
160
+ ```python
161
+ queue = RedisMessageQueue(
162
+ "q", client=client,
163
+ visibility_timeout_seconds=300,
164
+ heartbeat_interval_seconds=60,
165
+ on_heartbeat_failure=lambda: log.warning("heartbeat failed; lease may be stale"),
166
+ )
167
+ ```
168
+
169
+ The callback is **advisory** — it may fire briefly after a successful `process_message` exit when a final renewal coincided with the success path. Use it for metrics or alerting, not as a correctness signal. For the async queue (`redis_message_queue.asyncio`), the callback may also be `async def`.
170
+
158
171
  Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
159
172
 
160
173
  ### Dead-letter queue
@@ -176,6 +189,7 @@ Notes:
176
189
  - 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`
177
190
  - `max_delivery_count=1` means the message is delivered once; any reclaim routes it to the dead-letter queue
178
191
  - without `max_delivery_count`, messages are redelivered indefinitely (existing behavior)
192
+ - 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
179
193
 
180
194
  ### Graceful shutdown
181
195
 
@@ -196,8 +210,9 @@ while not interrupt.is_interrupted():
196
210
  > its signals (default: SIGINT, SIGTERM, SIGHUP), but only when those signals are
197
211
  > still using Python's default disposition. If another handler is already installed,
198
212
  > or if another `GracefulInterruptHandler` already owns the signal, construction raises
199
- > `ValueError`. If you need multiple shutdown hooks, use a single handler and fan out
200
- > in your own code.
213
+ > `ValueError`. A repeated owned signal falls back to the default behavior
214
+ > (for example, a second Ctrl+C raises `KeyboardInterrupt`). If you need multiple
215
+ > shutdown hooks, use a single handler and fan out in your own code.
201
216
 
202
217
  ### Custom gateway
203
218
 
@@ -220,6 +235,20 @@ If your custom gateway uses visibility timeouts, it must expose a public
220
235
  `wait_for_message_and_move()`. The queue now fails closed if a lease-capable
221
236
  gateway returns plain `str`/`bytes`, because cleanup without a lease token can
222
237
  ack a message that has already been reclaimed by another consumer.
238
+ If a lease-capable custom gateway omits `message_visibility_timeout_seconds`,
239
+ the queue cannot detect that lease semantics are in play and will treat the
240
+ gateway as a non-lease gateway. In that misconfigured state, lease-token safety
241
+ checks and heartbeat validation are bypassed.
242
+
243
+ A custom `retry_strategy` MUST have a total retry budget no longer than
244
+ `max(message_visibility_timeout_seconds, 300)` seconds. That value is the TTL
245
+ of the built-in gateway's ambiguous-success cache: if a retry arrives after the
246
+ cache has expired, the gateway re-runs the Lua script and — because the message
247
+ was already acked on the first attempt — sees `LREM=0` and returns `False`. This
248
+ surfaces as a misleading "cleanup was a no-op" warning from `process_message`;
249
+ no data is lost or double-processed, but a `max_completed_length` /
250
+ `max_failed_length` bound may be skipped on that call. The default
251
+ `tenacity.stop_after_delay(120)` is safely within the 300 s floor.
223
252
 
224
253
  When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
225
254
  and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
@@ -261,9 +290,10 @@ await client.aclose()
261
290
 
262
291
  - **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.
263
292
  - **Timed waits use polling claim loops.** To make claims recoverable after ambiguous connection drops, `wait_for_message_and_move()` uses idempotent Lua claim polling instead of raw blocking list-move commands. This adds a small polling cadence during timed waits.
264
- - **Low-level gateway boolean returns can be conservative after retries.** If a connection drops after Redis already applied an idempotent operation, direct gateway calls such as `publish_message()`, plus non-lease `move_message()` / `remove_message()`, may return `False` on retry even though Redis state is already correct.
293
+ - **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).
265
294
  - **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.
266
295
  - **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.
296
+ - **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.
267
297
 
268
298
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
269
299
 
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v2.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v2.1.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)
@@ -62,7 +62,7 @@ while True:
62
62
  | **Dead-letter queue** | Poison messages that exceed a configurable delivery count are automatically routed to a dead-letter queue instead of being redelivered indefinitely |
63
63
  | **Graceful shutdown** | Built-in interrupt handler lets consumers finish current work before stopping |
64
64
  | **Lease heartbeats** | Optional background lease renewal keeps long-running handlers from being redelivered prematurely |
65
- | **Connection retries** | Exponential backoff with jitter for Redis operations (deduplicated publish, ack, lease renewal). Message-claim paths use idempotent Lua claim IDs so retryable errors can recover the original claim safely, including on the next call from the same gateway instance if the original wait call had to give up before Redis became reachable again. Non-deduplicated publish is not retried — the exception propagates so the caller can decide whether to retry (accepting potential duplicates) |
65
+ | **Connection retries** | Exponential backoff with jitter for Redis operations (deduplicated publish, ack, lease renewal). Publish and cleanup paths use replay markers so retryable connection drops preserve the original result within the same call. Message-claim paths use idempotent Lua claim IDs plus persisted claim metadata so retryable errors can recover the original claim safely, either in the same wait call or on the next call from the same gateway instance if the original wait had to give up before Redis became reachable again. Active waits keep their in-flight claim IDs private until they exit, so a concurrent caller on the same gateway instance cannot recover the same claim twice. Timed waits also stay bounded: once the configured wait window expires, the queue only replays persisted state for that same claim attempt and will not claim fresh work after the deadline. If a graceful interrupt arrives during claim recovery, the wait call stops instead of taking fresh work. Non-deduplicated publish is not retried — the exception propagates so the caller can decide whether to retry (accepting potential duplicates) |
66
66
  | **Async support** | Drop-in async variant with identical API |
67
67
 
68
68
  All features are optional and can be enabled or disabled as needed.
@@ -139,6 +139,19 @@ Tradeoffs:
139
139
  - heartbeats add background renewal work for active messages
140
140
  - if a heartbeat fails (network error or stale lease), the heartbeat stops silently; the consumer continues processing but may find at ack time that the message was reclaimed by another consumer
141
141
 
142
+ Pass `on_heartbeat_failure` to receive a best-effort callback when the heartbeat stops because renewal failed:
143
+
144
+ ```python
145
+ queue = RedisMessageQueue(
146
+ "q", client=client,
147
+ visibility_timeout_seconds=300,
148
+ heartbeat_interval_seconds=60,
149
+ on_heartbeat_failure=lambda: log.warning("heartbeat failed; lease may be stale"),
150
+ )
151
+ ```
152
+
153
+ The callback is **advisory** — it may fire briefly after a successful `process_message` exit when a final renewal coincided with the success path. Use it for metrics or alerting, not as a correctness signal. For the async queue (`redis_message_queue.asyncio`), the callback may also be `async def`.
154
+
142
155
  Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
143
156
 
144
157
  ### Dead-letter queue
@@ -160,6 +173,7 @@ Notes:
160
173
  - 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`
161
174
  - `max_delivery_count=1` means the message is delivered once; any reclaim routes it to the dead-letter queue
162
175
  - without `max_delivery_count`, messages are redelivered indefinitely (existing behavior)
176
+ - 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
163
177
 
164
178
  ### Graceful shutdown
165
179
 
@@ -180,8 +194,9 @@ while not interrupt.is_interrupted():
180
194
  > its signals (default: SIGINT, SIGTERM, SIGHUP), but only when those signals are
181
195
  > still using Python's default disposition. If another handler is already installed,
182
196
  > or if another `GracefulInterruptHandler` already owns the signal, construction raises
183
- > `ValueError`. If you need multiple shutdown hooks, use a single handler and fan out
184
- > in your own code.
197
+ > `ValueError`. A repeated owned signal falls back to the default behavior
198
+ > (for example, a second Ctrl+C raises `KeyboardInterrupt`). If you need multiple
199
+ > shutdown hooks, use a single handler and fan out in your own code.
185
200
 
186
201
  ### Custom gateway
187
202
 
@@ -204,6 +219,20 @@ If your custom gateway uses visibility timeouts, it must expose a public
204
219
  `wait_for_message_and_move()`. The queue now fails closed if a lease-capable
205
220
  gateway returns plain `str`/`bytes`, because cleanup without a lease token can
206
221
  ack a message that has already been reclaimed by another consumer.
222
+ If a lease-capable custom gateway omits `message_visibility_timeout_seconds`,
223
+ the queue cannot detect that lease semantics are in play and will treat the
224
+ gateway as a non-lease gateway. In that misconfigured state, lease-token safety
225
+ checks and heartbeat validation are bypassed.
226
+
227
+ A custom `retry_strategy` MUST have a total retry budget no longer than
228
+ `max(message_visibility_timeout_seconds, 300)` seconds. That value is the TTL
229
+ of the built-in gateway's ambiguous-success cache: if a retry arrives after the
230
+ cache has expired, the gateway re-runs the Lua script and — because the message
231
+ was already acked on the first attempt — sees `LREM=0` and returns `False`. This
232
+ surfaces as a misleading "cleanup was a no-op" warning from `process_message`;
233
+ no data is lost or double-processed, but a `max_completed_length` /
234
+ `max_failed_length` bound may be skipped on that call. The default
235
+ `tenacity.stop_after_delay(120)` is safely within the 300 s floor.
207
236
 
208
237
  When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
209
238
  and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
@@ -245,9 +274,10 @@ await client.aclose()
245
274
 
246
275
  - **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.
247
276
  - **Timed waits use polling claim loops.** To make claims recoverable after ambiguous connection drops, `wait_for_message_and_move()` uses idempotent Lua claim polling instead of raw blocking list-move commands. This adds a small polling cadence during timed waits.
248
- - **Low-level gateway boolean returns can be conservative after retries.** If a connection drops after Redis already applied an idempotent operation, direct gateway calls such as `publish_message()`, plus non-lease `move_message()` / `remove_message()`, may return `False` on retry even though Redis state is already correct.
277
+ - **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).
249
278
  - **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.
250
279
  - **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.
280
+ - **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.
251
281
 
252
282
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
253
283
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "2.0.0"
3
+ version = "2.1.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"
@@ -15,6 +15,9 @@ class AbstractRedisGateway(ABC):
15
15
  a ``message_visibility_timeout_seconds`` property (int or None). This is not
16
16
  abstract because it is configuration rather than protocol, but it is required
17
17
  when the queue is configured with ``heartbeat_interval_seconds``.
18
+ Lease-capable custom gateways should always expose this property; otherwise
19
+ the queue cannot enforce lease-specific fail-closed checks and will treat the
20
+ gateway as a non-lease implementation.
18
21
 
19
22
  Concurrency
20
23
  -----------
@@ -57,9 +60,13 @@ class AbstractRedisGateway(ABC):
57
60
 
58
61
  When ``lease_token`` is provided, the implementation MUST validate that
59
62
  the token matches the current lease holder before moving. If the token
60
- is stale or the lease has expired, the method MUST return False and
61
- leave the message in ``from_queue``. Ignoring ``lease_token`` silently
62
- breaks mutual exclusion.
63
+ no longer matches the current lease holder (i.e. another consumer has
64
+ reclaimed the message), the method MUST return False and leave the
65
+ message in ``from_queue``. Note: the built-in gateway intentionally
66
+ does NOT reject completions whose wall-clock deadline has passed but
67
+ where no other consumer has reclaimed the message — that path keeps
68
+ at-least-once semantics from producing spurious double-processing.
69
+ Ignoring ``lease_token`` entirely silently breaks mutual exclusion.
63
70
 
64
71
  Returns True if the message was moved, False otherwise.
65
72
  """
@@ -75,8 +82,13 @@ class AbstractRedisGateway(ABC):
75
82
 
76
83
  When ``lease_token`` is provided, the implementation MUST validate that
77
84
  the token matches the current lease holder before removing. If the token
78
- is stale or the lease has expired, the method MUST return False.
79
- Ignoring ``lease_token`` silently breaks mutual exclusion.
85
+ no longer matches the current lease holder (i.e. another consumer has
86
+ reclaimed the message), the method MUST return False. Note: the
87
+ built-in gateway intentionally does NOT reject completions whose
88
+ wall-clock deadline has passed but where no other consumer has
89
+ reclaimed the message — that path keeps at-least-once semantics from
90
+ producing spurious double-processing. Ignoring ``lease_token``
91
+ entirely silently breaks mutual exclusion.
80
92
 
81
93
  Returns True if the message was removed, False otherwise.
82
94
  """