redis-message-queue 2.0.0__tar.gz → 3.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-2.0.0 → redis_message_queue-3.0.0}/PKG-INFO +46 -9
  2. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/README.md +45 -8
  3. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/pyproject.toml +1 -1
  4. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_abstract_redis_gateway.py +17 -5
  5. redis_message_queue-3.0.0/redis_message_queue/_config.py +753 -0
  6. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_queue_key_manager.py +10 -1
  7. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_gateway.py +289 -215
  8. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_stored_message.py +10 -0
  9. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +17 -5
  10. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/_redis_gateway.py +290 -223
  11. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/redis_message_queue.py +55 -5
  12. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/interrupt_handler/_implementation.py +14 -0
  13. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/redis_message_queue.py +67 -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-3.0.0}/LICENSE +0 -0
  16. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/__init__.py +0 -0
  17. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_callable_utils.py +0 -0
  18. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/asyncio/__init__.py +0 -0
  20. {redis_message_queue-2.0.0 → redis_message_queue-3.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-2.0.0 → redis_message_queue-3.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: 2.0.0
3
+ Version: 3.0.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/v3.0.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)
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
27
27
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
28
28
 
29
29
  ```bash
30
- pip install "redis-message-queue>=2.0.0,<3.0.0"
30
+ pip install "redis-message-queue>=3.0.0,<4.0.0"
31
31
  ```
32
32
 
33
33
  Requires Redis server >= 6.2.
@@ -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,18 +210,21 @@ 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
 
204
219
  ```python
205
220
  from redis_message_queue._redis_gateway import RedisGateway
206
221
 
207
- # Custom retry logic, dedup TTL, or wait interval
222
+ # Tune retry budget, dedup TTL, or wait interval
208
223
  gateway = RedisGateway(
209
224
  redis_client=client,
210
- retry_strategy=my_custom_retry,
225
+ retry_budget_seconds=120, # total retry window (set 0 to disable retry)
226
+ retry_max_delay_seconds=5.0, # cap on per-attempt backoff
227
+ retry_initial_delay_seconds=0.01, # first backoff
211
228
  message_deduplication_log_ttl_seconds=3600,
212
229
  message_wait_interval_seconds=10,
213
230
  message_visibility_timeout_seconds=300,
@@ -215,11 +232,30 @@ gateway = RedisGateway(
215
232
  queue = RedisMessageQueue("q", gateway=gateway)
216
233
  ```
217
234
 
235
+ The retry knobs configure an internal `tenacity` strategy: exponential
236
+ backoff with jitter, retry on transient Redis errors only, capped at
237
+ `retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
238
+ entirely (single attempt; exceptions propagate). The library uses
239
+ `retry_budget_seconds` to size the operation-result cache TTL automatically,
240
+ so the previous footgun of an over-long retry budget out-living the cache
241
+ and producing misleading "cleanup was a no-op" warnings is now structurally
242
+ impossible.
243
+
244
+ To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
245
+ own logic) or fundamentally different semantics, subclass
246
+ `AbstractRedisGateway` from `redis_message_queue._abstract_redis_gateway`
247
+ (or `redis_message_queue.asyncio._abstract_redis_gateway`) and override the
248
+ operation methods directly.
249
+
218
250
  If your custom gateway uses visibility timeouts, it must expose a public
219
251
  `message_visibility_timeout_seconds` value and return `ClaimedMessage` from
220
252
  `wait_for_message_and_move()`. The queue now fails closed if a lease-capable
221
253
  gateway returns plain `str`/`bytes`, because cleanup without a lease token can
222
254
  ack a message that has already been reclaimed by another consumer.
255
+ If a lease-capable custom gateway omits `message_visibility_timeout_seconds`,
256
+ the queue cannot detect that lease semantics are in play and will treat the
257
+ gateway as a non-lease gateway. In that misconfigured state, lease-token safety
258
+ checks and heartbeat validation are bypassed.
223
259
 
224
260
  When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
225
261
  and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
@@ -261,9 +297,10 @@ await client.aclose()
261
297
 
262
298
  - **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
299
  - **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.
300
+ - **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
301
  - **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
302
  - **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.
303
+ - **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
304
 
268
305
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
269
306
 
@@ -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/v3.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)
@@ -11,7 +11,7 @@
11
11
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
12
12
 
13
13
  ```bash
14
- pip install "redis-message-queue>=2.0.0,<3.0.0"
14
+ pip install "redis-message-queue>=3.0.0,<4.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -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,18 +194,21 @@ 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
 
188
203
  ```python
189
204
  from redis_message_queue._redis_gateway import RedisGateway
190
205
 
191
- # Custom retry logic, dedup TTL, or wait interval
206
+ # Tune retry budget, dedup TTL, or wait interval
192
207
  gateway = RedisGateway(
193
208
  redis_client=client,
194
- retry_strategy=my_custom_retry,
209
+ retry_budget_seconds=120, # total retry window (set 0 to disable retry)
210
+ retry_max_delay_seconds=5.0, # cap on per-attempt backoff
211
+ retry_initial_delay_seconds=0.01, # first backoff
195
212
  message_deduplication_log_ttl_seconds=3600,
196
213
  message_wait_interval_seconds=10,
197
214
  message_visibility_timeout_seconds=300,
@@ -199,11 +216,30 @@ gateway = RedisGateway(
199
216
  queue = RedisMessageQueue("q", gateway=gateway)
200
217
  ```
201
218
 
219
+ The retry knobs configure an internal `tenacity` strategy: exponential
220
+ backoff with jitter, retry on transient Redis errors only, capped at
221
+ `retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
222
+ entirely (single attempt; exceptions propagate). The library uses
223
+ `retry_budget_seconds` to size the operation-result cache TTL automatically,
224
+ so the previous footgun of an over-long retry budget out-living the cache
225
+ and producing misleading "cleanup was a no-op" warnings is now structurally
226
+ impossible.
227
+
228
+ To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
229
+ 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
232
+ operation methods directly.
233
+
202
234
  If your custom gateway uses visibility timeouts, it must expose a public
203
235
  `message_visibility_timeout_seconds` value and return `ClaimedMessage` from
204
236
  `wait_for_message_and_move()`. The queue now fails closed if a lease-capable
205
237
  gateway returns plain `str`/`bytes`, because cleanup without a lease token can
206
238
  ack a message that has already been reclaimed by another consumer.
239
+ If a lease-capable custom gateway omits `message_visibility_timeout_seconds`,
240
+ the queue cannot detect that lease semantics are in play and will treat the
241
+ gateway as a non-lease gateway. In that misconfigured state, lease-token safety
242
+ checks and heartbeat validation are bypassed.
207
243
 
208
244
  When using a custom gateway with dead-letter queue support, configure `max_delivery_count`
209
245
  and `dead_letter_queue` directly on the gateway — do **not** pass `max_delivery_count` to
@@ -245,9 +281,10 @@ await client.aclose()
245
281
 
246
282
  - **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
283
  - **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.
284
+ - **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
285
  - **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
286
  - **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.
287
+ - **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
288
 
252
289
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
253
290
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "2.0.0"
3
+ version = "3.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"
@@ -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
  """