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.
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/PKG-INFO +36 -6
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/README.md +35 -5
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/pyproject.toml +1 -1
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_abstract_redis_gateway.py +17 -5
- redis_message_queue-2.1.0/redis_message_queue/_config.py +703 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_queue_key_manager.py +10 -1
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_redis_gateway.py +260 -200
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_stored_message.py +10 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +17 -5
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/_redis_gateway.py +261 -208
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/redis_message_queue.py +39 -5
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/interrupt_handler/_implementation.py +14 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/redis_message_queue.py +58 -11
- redis_message_queue-2.0.0/redis_message_queue/_config.py +0 -318
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/LICENSE +0 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-2.0.0 → redis_message_queue-2.1.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {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.
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
20
20
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
21
21
|
[](LICENSE)
|
|
22
22
|
[](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,
|
|
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`.
|
|
200
|
-
>
|
|
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
|
-
- **
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -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,
|
|
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`.
|
|
184
|
-
>
|
|
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
|
-
- **
|
|
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
|
|
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
"""
|