redis-message-queue 3.1.0__tar.gz → 3.1.2__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 (20) hide show
  1. redis_message_queue-3.1.0/README.md → redis_message_queue-3.1.2/PKG-INFO +47 -3
  2. redis_message_queue-3.1.0/PKG-INFO → redis_message_queue-3.1.2/README.md +20 -20
  3. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/pyproject.toml +17 -1
  4. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/__init__.py +2 -0
  5. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_abstract_redis_gateway.py +8 -0
  6. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_config.py +33 -16
  7. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_queue_key_manager.py +2 -0
  8. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_gateway.py +5 -5
  9. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/__init__.py +2 -1
  10. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +8 -0
  11. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_redis_gateway.py +5 -5
  12. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/redis_message_queue.py +2 -2
  13. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_implementation.py +10 -2
  14. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/redis_message_queue.py +3 -3
  15. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/LICENSE +0 -0
  16. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_callable_utils.py +0 -0
  17. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_cluster.py +0 -0
  18. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_stored_message.py +0 -0
  19. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  20. {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
@@ -1,6 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: redis-message-queue
3
+ Version: 3.1.2
4
+ Summary: Python message queuing with Redis and message deduplication
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: redis,message-queue,deduplication,task-queue
8
+ Author: Elijas
9
+ Author-email: 4084885+Elijas@users.noreply.github.com
10
+ Requires-Python: >=3.12,<4.0
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Requires-Dist: redis (>=5.0.0)
21
+ Requires-Dist: tenacity (>=8.1.0)
22
+ Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
23
+ Project-URL: Issues, https://github.com/Elijas/redis-message-queue/issues
24
+ Project-URL: Repository, https://github.com/Elijas/redis-message-queue
25
+ Description-Content-Type: text/markdown
26
+
1
27
  # redis-message-queue
2
28
 
3
- [![PyPI Version](https://img.shields.io/badge/v3.1.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v3.1.2-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
4
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)
5
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
6
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -134,7 +160,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
134
160
  Tradeoffs:
135
161
  - delivery becomes at-least-once after lease expiry
136
162
  - the timeout must be longer than your normal processing time if you do not use heartbeats
137
- - if you do use heartbeats, the heartbeat interval must be no more than half of the visibility timeout
163
+ - if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
138
164
  - recovery happens on consumer polling cadence rather than instantly
139
165
  - heartbeats add background renewal work for active messages
140
166
  - 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
@@ -223,7 +249,7 @@ entirely (single attempt; exceptions propagate). The library uses
223
249
  `retry_budget_seconds` to size the operation-result cache TTL automatically,
224
250
  so the previous footgun of an over-long retry budget out-living the cache
225
251
  and producing misleading "cleanup was a no-op" warnings is now structurally
226
- impossible.
252
+ impossible. Note: tenacity may allow one additional attempt beyond the budget if the budget check passes at attempt start — total wall-clock time can exceed `retry_budget_seconds` by the duration of that final attempt.
227
253
 
228
254
  To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
229
255
  own logic) or fundamentally different semantics, subclass
@@ -284,11 +310,28 @@ await client.aclose()
284
310
  - **Redis Lua is atomic, not rollback-transactional.** The built-in scripts now preflight queue key types and fail closed on `WRONGTYPE` before mutating queue state, but Redis does not undo earlier writes if a later script command fails for another reason (for example `OOM` under severe memory pressure).
285
311
  - **Batch reclaim limit of 100.** The visibility-timeout reclaim Lua script processes at most 100 expired messages per consumer poll. Under extreme backlog this may delay recovery, but prevents any single poll from blocking Redis.
286
312
  - **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
313
+ - **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
314
+ - **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.
287
315
  - **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
316
+ - **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.
288
317
  - **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`.
289
318
 
290
319
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
291
320
 
321
+ ## Upgrading
322
+
323
+ ### Configuration changes on live queues
324
+
325
+ > **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
326
+
327
+ - **Do not change `key_separator` on a live queue.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
328
+ - **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
329
+ - **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.
330
+
331
+ ### v2 to v3 migration
332
+
333
+ 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)).
334
+
292
335
  ## Running locally
293
336
 
294
337
  You'll need a Redis server:
@@ -311,3 +354,4 @@ poetry run python -m examples.receive_messages
311
354
  ```
312
355
 
313
356
  ![GitHub Repo stars](https://img.shields.io/github/stars/elijas/redis-message-queue?style=flat&color=fcfcfc&labelColor=white&logo=github&logoColor=black&label=stars)
357
+
@@ -1,22 +1,6 @@
1
- Metadata-Version: 2.4
2
- Name: redis-message-queue
3
- Version: 3.1.0
4
- Summary: Python message queuing with Redis and message deduplication
5
- License-File: LICENSE
6
- Author: Elijas
7
- Author-email: 4084885+Elijas@users.noreply.github.com
8
- Requires-Python: >=3.12,<4.0
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.12
11
- Classifier: Programming Language :: Python :: 3.13
12
- Classifier: Programming Language :: Python :: 3.14
13
- Requires-Dist: redis (>=5.0.0)
14
- Requires-Dist: tenacity (>=8.1.0)
15
- Description-Content-Type: text/markdown
16
-
17
1
  # redis-message-queue
18
2
 
19
- [![PyPI Version](https://img.shields.io/badge/v3.1.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v3.1.2-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
20
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)
21
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
22
6
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -150,7 +134,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
150
134
  Tradeoffs:
151
135
  - delivery becomes at-least-once after lease expiry
152
136
  - the timeout must be longer than your normal processing time if you do not use heartbeats
153
- - if you do use heartbeats, the heartbeat interval must be no more than half of the visibility timeout
137
+ - if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
154
138
  - recovery happens on consumer polling cadence rather than instantly
155
139
  - heartbeats add background renewal work for active messages
156
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
@@ -239,7 +223,7 @@ entirely (single attempt; exceptions propagate). The library uses
239
223
  `retry_budget_seconds` to size the operation-result cache TTL automatically,
240
224
  so the previous footgun of an over-long retry budget out-living the cache
241
225
  and producing misleading "cleanup was a no-op" warnings is now structurally
242
- impossible.
226
+ impossible. Note: tenacity may allow one additional attempt beyond the budget if the budget check passes at attempt start — total wall-clock time can exceed `retry_budget_seconds` by the duration of that final attempt.
243
227
 
244
228
  To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
245
229
  own logic) or fundamentally different semantics, subclass
@@ -300,11 +284,28 @@ await client.aclose()
300
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).
301
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.
302
286
  - **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
287
+ - **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
288
+ - **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.
303
289
  - **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
290
+ - **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.
304
291
  - **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`.
305
292
 
306
293
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
307
294
 
295
+ ## Upgrading
296
+
297
+ ### Configuration changes on live queues
298
+
299
+ > **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
300
+
301
+ - **Do not change `key_separator` on a live queue.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
302
+ - **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
303
+ - **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.
304
+
305
+ ### v2 to v3 migration
306
+
307
+ 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)).
308
+
308
309
  ## Running locally
309
310
 
310
311
  You'll need a Redis server:
@@ -327,4 +328,3 @@ poetry run python -m examples.receive_messages
327
328
  ```
328
329
 
329
330
  ![GitHub Repo stars](https://img.shields.io/github/stars/elijas/redis-message-queue?style=flat&color=fcfcfc&labelColor=white&logo=github&logoColor=black&label=stars)
330
-
@@ -1,9 +1,25 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "3.1.0"
3
+ version = "3.1.2"
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
+ license = "MIT"
8
+ keywords = ["redis", "message-queue", "deduplication", "task-queue"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Software Development :: Libraries",
16
+ "Topic :: System :: Distributed Computing",
17
+ ]
18
+
19
+ [tool.poetry.urls]
20
+ Homepage = "https://github.com/Elijas/redis-message-queue"
21
+ Repository = "https://github.com/Elijas/redis-message-queue"
22
+ Issues = "https://github.com/Elijas/redis-message-queue/issues"
7
23
 
8
24
  [tool.poetry.dependencies]
9
25
  python = "^3.12"
@@ -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",
@@ -19,6 +19,10 @@ class AbstractRedisGateway(ABC):
19
19
  silently disables heartbeat validation and lease-token safety checks,
20
20
  causing the queue to treat the gateway as a non-lease implementation.
21
21
 
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
+
22
26
  Gateways that wrap a Redis Cluster client should expose an
23
27
  ``is_redis_cluster`` property returning ``True`` so the queue can apply
24
28
  hash-tag validation at construction time.
@@ -124,6 +128,10 @@ class AbstractRedisGateway(ABC):
124
128
  use leases.
125
129
 
126
130
  Return None if no message was available (e.g. timeout or interrupt).
131
+
132
+ Implementations MUST respect a reasonable timeout or return None
133
+ periodically so the consumer can check for interrupts. Blocking
134
+ indefinitely without returning prevents graceful shutdown.
127
135
  """
128
136
 
129
137
  @abstractmethod
@@ -36,13 +36,13 @@ def is_redis_retryable_exception(exception):
36
36
  ),
37
37
  )
38
38
 
39
- # 2. Explicit retryable exceptions
39
+ # 2. Explicit retryable exceptions (BusyLoadingError is a ConnectionError
40
+ # subclass, so it is already handled by branch 1 above)
40
41
  return isinstance(
41
42
  exception,
42
43
  (
43
44
  # Network/availability issues
44
45
  redis.exceptions.TimeoutError, # Socket or server-side timeout
45
- redis.exceptions.BusyLoadingError, # Server loading data
46
46
  # Cluster transient failures
47
47
  redis.exceptions.ClusterDownError, # Covers ClusterDown + MasterDown
48
48
  redis.exceptions.TryAgainError, # Cluster state requires retry
@@ -179,6 +179,8 @@ def validate_dead_letter_parameters(
179
179
  raise ValueError("'max_delivery_count' requires 'message_visibility_timeout_seconds' to be set.")
180
180
  if dead_letter_queue is not None and not isinstance(dead_letter_queue, str):
181
181
  raise TypeError(f"'dead_letter_queue' must be a str or None, got {type(dead_letter_queue).__name__}")
182
+ if isinstance(dead_letter_queue, str) and dead_letter_queue and not dead_letter_queue.strip():
183
+ raise ValueError("'dead_letter_queue' must be a non-empty string")
182
184
  if max_delivery_count is not None and not dead_letter_queue:
183
185
  raise ValueError("'dead_letter_queue' is required when 'max_delivery_count' is set.")
184
186
  if dead_letter_queue and max_delivery_count is None:
@@ -273,6 +275,7 @@ if cached_result then
273
275
  return tonumber(cached_result)
274
276
  end
275
277
 
278
+ redis.call('LPUSH', KEYS[2], ARGV[2])
276
279
  local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
277
280
  if removed == 1 then
278
281
  local claim_id = redis.call('HGET', KEYS[4], ARGV[1])
@@ -280,7 +283,8 @@ if removed == 1 then
280
283
  redis.call('HDEL', KEYS[3], claim_id)
281
284
  redis.call('HDEL', KEYS[4], ARGV[1])
282
285
  end
283
- redis.call('LPUSH', KEYS[2], ARGV[2])
286
+ else
287
+ redis.call('LREM', KEYS[2], 1, ARGV[2])
284
288
  end
285
289
 
286
290
  redis.call('SET', KEYS[5], tostring(removed), 'PX', tonumber(ARGV[3]))
@@ -453,6 +457,9 @@ local function redis_message_queue_decode_claim(cached_claim)
453
457
  return nil
454
458
  end
455
459
 
460
+ local time = redis.call('TIME')
461
+ local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
462
+
456
463
  -- Cache replay paths below return the ORIGINAL claim (same lease_token) even if
457
464
  -- the lease deadline has passed in wall-clock time. Safe because ack is gated by
458
465
  -- the server-side HGET lease_tokens check in MOVE/REMOVE_WITH_LEASE_TOKEN: if
@@ -467,6 +474,7 @@ if cached_claim then
467
474
  redis.call('HSET', KEYS[10], ARGV[4], cached_claim)
468
475
  redis.call('HSET', KEYS[11], claim[2], ARGV[4])
469
476
  redis.call('HSET', KEYS[9], claim[2], KEYS[8])
477
+ redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
470
478
  return {claim[1], claim[2]}
471
479
  end
472
480
  redis.call('DEL', KEYS[8])
@@ -479,14 +487,12 @@ if cached_recovery then
479
487
  redis.call('SET', KEYS[8], cached_recovery, 'PX', tonumber(ARGV[3]))
480
488
  redis.call('HSET', KEYS[11], claim[2], ARGV[4])
481
489
  redis.call('HSET', KEYS[9], claim[2], KEYS[8])
490
+ redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
482
491
  return {claim[1], claim[2]}
483
492
  end
484
493
  redis.call('HDEL', KEYS[10], ARGV[4])
485
494
  end
486
495
 
487
- local time = redis.call('TIME')
488
- local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
489
-
490
496
  -- Cap at 100 to bound Lua execution time (Redis blocks during scripts).
491
497
  -- With a single consumer polling at default interval, 1000 expired leases drain in ~2.5s.
492
498
  local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now_ms, 'LIMIT', 0, 100)
@@ -516,15 +522,24 @@ if #to_requeue > 0 then
516
522
  end
517
523
 
518
524
  local function store_claim_and_return(stored)
519
- local lease_token = tostring(redis.call('INCR', KEYS[5]))
520
- local claim_payload = cjson.encode({stored, lease_token})
521
- redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
522
- redis.call('HSET', KEYS[4], stored, lease_token)
523
- redis.call('SET', KEYS[8], claim_payload, 'PX', tonumber(ARGV[3]))
524
- redis.call('HSET', KEYS[9], lease_token, KEYS[8])
525
- redis.call('HSET', KEYS[10], ARGV[4], claim_payload)
526
- redis.call('HSET', KEYS[11], lease_token, ARGV[4])
527
- return {stored, lease_token}
525
+ -- pcall guards against OOM mid-write: compensate by returning message to pending
526
+ local ok, result = pcall(function()
527
+ local lease_token = tostring(redis.call('INCR', KEYS[5]))
528
+ local claim_payload = cjson.encode({stored, lease_token})
529
+ redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
530
+ redis.call('HSET', KEYS[4], stored, lease_token)
531
+ redis.call('SET', KEYS[8], claim_payload, 'PX', tonumber(ARGV[3]))
532
+ redis.call('HSET', KEYS[9], lease_token, KEYS[8])
533
+ redis.call('HSET', KEYS[10], ARGV[4], claim_payload)
534
+ redis.call('HSET', KEYS[11], lease_token, ARGV[4])
535
+ return {stored, lease_token}
536
+ end)
537
+ if not ok then
538
+ redis.call('LREM', KEYS[2], 1, stored)
539
+ redis.pcall('RPUSH', KEYS[1], stored)
540
+ return false
541
+ end
542
+ return result
528
543
  end
529
544
 
530
545
  local claim_attempts = 0
@@ -703,6 +718,7 @@ end
703
718
 
704
719
  -- See REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT for the bounded-leak rationale
705
720
  -- on the removed == 0 branch (externally-removed message + valid lease token).
721
+ redis.call('LPUSH', KEYS[2], ARGV[2])
706
722
  local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
707
723
  if removed == 1 then
708
724
  redis.call('ZREM', KEYS[3], ARGV[1])
@@ -718,8 +734,9 @@ if removed == 1 then
718
734
  redis.call('HDEL', KEYS[8], ARGV[3])
719
735
  end
720
736
  redis.call('HDEL', KEYS[5], ARGV[1])
721
- redis.call('LPUSH', KEYS[2], ARGV[2])
722
737
  redis.call('SET', KEYS[9], '1', 'PX', tonumber(ARGV[4]))
738
+ else
739
+ redis.call('LREM', KEYS[2], 1, ARGV[2])
723
740
  end
724
741
 
725
742
  return removed
@@ -23,6 +23,8 @@ class QueueKeyManager:
23
23
  raise TypeError(f"'name' must be a string, got {type(queue_name).__name__}")
24
24
  if not queue_name.strip():
25
25
  raise ValueError("'name' must be a non-empty string")
26
+ if "\x00" in queue_name:
27
+ raise ValueError("queue name must not contain null bytes")
26
28
  if not isinstance(key_separator, str):
27
29
  raise TypeError(f"'key_separator' must be a string, got {type(key_separator).__name__}")
28
30
  if not key_separator.strip():
@@ -88,7 +88,7 @@ class RedisGateway(AbstractRedisGateway):
88
88
  if isinstance(redis_client, redis.asyncio.Redis):
89
89
  raise TypeError(
90
90
  "'redis_client' is an async Redis client (redis.asyncio.Redis); "
91
- "use the async RedisGateway from redis_message_queue.asyncio instead"
91
+ "use the async RedisMessageQueue from redis_message_queue.asyncio instead"
92
92
  )
93
93
  if isinstance(redis_client, (redis.client.Pipeline, redis.asyncio.client.Pipeline)):
94
94
  raise TypeError(
@@ -380,9 +380,10 @@ class RedisGateway(AbstractRedisGateway):
380
380
  claimed_message = claim_message(from_queue, to_queue, claim_id)
381
381
  except Exception as exc:
382
382
  if not is_redis_retryable_exception(exc):
383
+ pending_claim_id_to_share = claim_id
383
384
  raise
384
385
  claim_may_need_recovery = True
385
- logger.warning(non_blocking_retry_log, exc)
386
+ logger.warning(non_blocking_retry_log, type(exc).__name__)
386
387
  if self._is_interrupted():
387
388
  pending_claim_id_to_share = claim_id
388
389
  return None
@@ -414,11 +415,10 @@ class RedisGateway(AbstractRedisGateway):
414
415
  claimed_message = claim_message(from_queue, to_queue, claim_id)
415
416
  except Exception as exc:
416
417
  if not is_redis_retryable_exception(exc):
417
- if claim_may_need_recovery:
418
- pending_claim_id_to_share = claim_id
418
+ pending_claim_id_to_share = claim_id
419
419
  raise
420
420
  claim_may_need_recovery = True
421
- logger.warning(polling_retry_log, exc)
421
+ logger.warning(polling_retry_log, type(exc).__name__)
422
422
  last_retryable_exception = exc
423
423
  except BaseException:
424
424
  pending_claim_id_to_share = claim_id
@@ -1,5 +1,6 @@
1
1
  from redis_message_queue._stored_message import ClaimedMessage, MessageData
2
2
  from redis_message_queue.asyncio._abstract_redis_gateway import AbstractRedisGateway
3
+ from redis_message_queue.asyncio._redis_gateway import RedisGateway
3
4
  from redis_message_queue.asyncio.redis_message_queue import RedisMessageQueue
4
5
 
5
- __all__ = ["RedisMessageQueue", "AbstractRedisGateway", "ClaimedMessage", "MessageData"]
6
+ __all__ = ["RedisMessageQueue", "RedisGateway", "AbstractRedisGateway", "ClaimedMessage", "MessageData"]
@@ -20,6 +20,10 @@ class AbstractRedisGateway(ABC):
20
20
  silently disables heartbeat validation and lease-token safety checks,
21
21
  causing the queue to treat the gateway as a non-lease implementation.
22
22
 
23
+ The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
24
+ from the gateway via ``getattr``. Avoid using these attribute names for
25
+ unrelated purposes on custom gateway implementations.
26
+
23
27
  Gateways that wrap a Redis Cluster client should expose an
24
28
  ``is_redis_cluster`` property returning ``True`` so the queue can apply
25
29
  hash-tag validation at construction time.
@@ -124,6 +128,10 @@ class AbstractRedisGateway(ABC):
124
128
  use leases.
125
129
 
126
130
  Return None if no message was available (e.g. timeout or interrupt).
131
+
132
+ Implementations MUST respect a reasonable timeout or return None
133
+ periodically so the consumer can check for interrupts. Blocking
134
+ indefinitely without returning prevents graceful shutdown.
127
135
  """
128
136
 
129
137
  @abstractmethod
@@ -88,7 +88,7 @@ class RedisGateway(AbstractRedisGateway):
88
88
  if isinstance(redis_client, redis.Redis) and not isinstance(redis_client, redis.asyncio.Redis):
89
89
  raise TypeError(
90
90
  "'redis_client' is a sync Redis client (redis.Redis); "
91
- "use the sync RedisGateway from redis_message_queue instead"
91
+ "use the sync RedisMessageQueue from redis_message_queue instead"
92
92
  )
93
93
  if isinstance(redis_client, (redis.client.Pipeline, redis.asyncio.client.Pipeline)):
94
94
  raise TypeError(
@@ -380,9 +380,10 @@ class RedisGateway(AbstractRedisGateway):
380
380
  claimed_message = await claim_message(from_queue, to_queue, claim_id)
381
381
  except Exception as exc:
382
382
  if not is_redis_retryable_exception(exc):
383
+ pending_claim_id_to_share = claim_id
383
384
  raise
384
385
  claim_may_need_recovery = True
385
- logger.warning(non_blocking_retry_log, exc)
386
+ logger.warning(non_blocking_retry_log, type(exc).__name__)
386
387
  if self._is_interrupted():
387
388
  pending_claim_id_to_share = claim_id
388
389
  return None
@@ -415,11 +416,10 @@ class RedisGateway(AbstractRedisGateway):
415
416
  claimed_message = await claim_message(from_queue, to_queue, claim_id)
416
417
  except Exception as exc:
417
418
  if not is_redis_retryable_exception(exc):
418
- if claim_may_need_recovery:
419
- pending_claim_id_to_share = claim_id
419
+ pending_claim_id_to_share = claim_id
420
420
  raise
421
421
  claim_may_need_recovery = True
422
- logger.warning(polling_retry_log, exc)
422
+ logger.warning(polling_retry_log, type(exc).__name__)
423
423
  last_retryable_exception = exc
424
424
  except BaseException:
425
425
  pending_claim_id_to_share = claim_id
@@ -327,7 +327,7 @@ class RedisMessageQueue:
327
327
  max_failed_length: int | None = None,
328
328
  max_delivery_count: int | None = None,
329
329
  key_separator: str = "::",
330
- get_deduplication_key: Optional[Callable] = None,
330
+ get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
331
331
  interrupt: BaseGracefulInterruptHandler | None = None,
332
332
  on_heartbeat_failure: Callable[[], Awaitable[None] | None] | None = None,
333
333
  ):
@@ -460,7 +460,7 @@ class RedisMessageQueue:
460
460
  "'message' dict keys must all be strings; "
461
461
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
462
462
  )
463
- message_str = json.dumps(message, sort_keys=True)
463
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
464
464
  else:
465
465
  message_str = message
466
466
 
@@ -61,7 +61,10 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
61
61
  f"'signals' must contain signal.Signals members, got {type(sig).__name__} at position {i}"
62
62
  )
63
63
  for sig in signals:
64
- current = signal.getsignal(sig)
64
+ try:
65
+ current = signal.getsignal(sig)
66
+ except OSError:
67
+ raise ValueError(f"Signal {sig.name} cannot be caught or handled by user code.")
65
68
  if _is_graceful_interrupt_handler(current):
66
69
  raise ValueError(
67
70
  f"Signal {sig.name} is already owned by another GracefulInterruptHandler."
@@ -78,7 +81,12 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
78
81
  self._signals = signals
79
82
  self._previous_handlers = {sig: signal.getsignal(sig) for sig in self._signals}
80
83
  for sig in self._signals:
81
- signal.signal(sig, self._signal_handler)
84
+ try:
85
+ signal.signal(sig, self._signal_handler)
86
+ except ValueError as e:
87
+ if "main thread" in str(e):
88
+ raise ValueError("GracefulInterruptHandler must be created on the main thread.") from e
89
+ raise
82
90
 
83
91
  def is_interrupted(self) -> bool:
84
92
  return self._interrupted
@@ -270,7 +270,7 @@ class RedisMessageQueue:
270
270
  max_failed_length: int | None = None,
271
271
  max_delivery_count: int | None = None,
272
272
  key_separator: str = "::",
273
- get_deduplication_key: Optional[Callable] = None,
273
+ get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
274
274
  interrupt: BaseGracefulInterruptHandler | None = None,
275
275
  on_heartbeat_failure: Callable[[], None] | None = None,
276
276
  ):
@@ -314,7 +314,7 @@ class RedisMessageQueue:
314
314
  )
315
315
  if get_deduplication_key is not None and not callable(get_deduplication_key):
316
316
  raise TypeError(f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}")
317
- if get_deduplication_key is not None and inspect.iscoroutinefunction(get_deduplication_key):
317
+ if get_deduplication_key is not None and is_async_callable(get_deduplication_key):
318
318
  raise TypeError(
319
319
  "'get_deduplication_key' is an async callable; "
320
320
  "use the async RedisMessageQueue from redis_message_queue.asyncio instead"
@@ -414,7 +414,7 @@ class RedisMessageQueue:
414
414
  "'message' dict keys must all be strings; "
415
415
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
416
416
  )
417
- message_str = json.dumps(message, sort_keys=True)
417
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
418
418
  else:
419
419
  message_str = message
420
420