redis-message-queue 3.1.1__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.1 → redis_message_queue-3.1.2}/PKG-INFO +18 -2
  2. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/README.md +17 -1
  3. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/pyproject.toml +1 -1
  4. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/__init__.py +2 -0
  5. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_abstract_redis_gateway.py +4 -0
  6. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_config.py +31 -16
  7. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_queue_key_manager.py +2 -0
  8. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_redis_gateway.py +3 -3
  9. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/__init__.py +2 -1
  10. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +4 -0
  11. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_redis_gateway.py +3 -3
  12. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/redis_message_queue.py +1 -1
  13. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/redis_message_queue.py +2 -2
  14. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/LICENSE +0 -0
  15. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_callable_utils.py +0 -0
  16. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_redis_cluster.py +0 -0
  17. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  19. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  20. {redis_message_queue-3.1.1 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # redis-message-queue
28
28
 
29
- [![PyPI Version](https://img.shields.io/badge/v3.1.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v3.1.2-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
30
30
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
31
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
32
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -310,12 +310,28 @@ await client.aclose()
310
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).
311
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.
312
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.
313
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.
314
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.
315
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`.
316
318
 
317
319
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
318
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
+
319
335
  ## Running locally
320
336
 
321
337
  You'll need a Redis server:
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v3.1.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v3.1.2-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)
@@ -284,12 +284,28 @@ await client.aclose()
284
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).
285
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.
286
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.
287
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.
288
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.
289
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`.
290
292
 
291
293
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
292
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
+
293
309
  ## Running locally
294
310
 
295
311
  You'll need a Redis server:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "3.1.1"
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"
@@ -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",
@@ -128,6 +128,10 @@ class AbstractRedisGateway(ABC):
128
128
  use leases.
129
129
 
130
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.
131
135
  """
132
136
 
133
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
@@ -275,6 +275,7 @@ if cached_result then
275
275
  return tonumber(cached_result)
276
276
  end
277
277
 
278
+ redis.call('LPUSH', KEYS[2], ARGV[2])
278
279
  local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
279
280
  if removed == 1 then
280
281
  local claim_id = redis.call('HGET', KEYS[4], ARGV[1])
@@ -282,7 +283,8 @@ if removed == 1 then
282
283
  redis.call('HDEL', KEYS[3], claim_id)
283
284
  redis.call('HDEL', KEYS[4], ARGV[1])
284
285
  end
285
- redis.call('LPUSH', KEYS[2], ARGV[2])
286
+ else
287
+ redis.call('LREM', KEYS[2], 1, ARGV[2])
286
288
  end
287
289
 
288
290
  redis.call('SET', KEYS[5], tostring(removed), 'PX', tonumber(ARGV[3]))
@@ -455,6 +457,9 @@ local function redis_message_queue_decode_claim(cached_claim)
455
457
  return nil
456
458
  end
457
459
 
460
+ local time = redis.call('TIME')
461
+ local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
462
+
458
463
  -- Cache replay paths below return the ORIGINAL claim (same lease_token) even if
459
464
  -- the lease deadline has passed in wall-clock time. Safe because ack is gated by
460
465
  -- the server-side HGET lease_tokens check in MOVE/REMOVE_WITH_LEASE_TOKEN: if
@@ -469,6 +474,7 @@ if cached_claim then
469
474
  redis.call('HSET', KEYS[10], ARGV[4], cached_claim)
470
475
  redis.call('HSET', KEYS[11], claim[2], ARGV[4])
471
476
  redis.call('HSET', KEYS[9], claim[2], KEYS[8])
477
+ redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
472
478
  return {claim[1], claim[2]}
473
479
  end
474
480
  redis.call('DEL', KEYS[8])
@@ -481,14 +487,12 @@ if cached_recovery then
481
487
  redis.call('SET', KEYS[8], cached_recovery, 'PX', tonumber(ARGV[3]))
482
488
  redis.call('HSET', KEYS[11], claim[2], ARGV[4])
483
489
  redis.call('HSET', KEYS[9], claim[2], KEYS[8])
490
+ redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
484
491
  return {claim[1], claim[2]}
485
492
  end
486
493
  redis.call('HDEL', KEYS[10], ARGV[4])
487
494
  end
488
495
 
489
- local time = redis.call('TIME')
490
- local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
491
-
492
496
  -- Cap at 100 to bound Lua execution time (Redis blocks during scripts).
493
497
  -- With a single consumer polling at default interval, 1000 expired leases drain in ~2.5s.
494
498
  local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now_ms, 'LIMIT', 0, 100)
@@ -518,15 +522,24 @@ if #to_requeue > 0 then
518
522
  end
519
523
 
520
524
  local function store_claim_and_return(stored)
521
- local lease_token = tostring(redis.call('INCR', KEYS[5]))
522
- local claim_payload = cjson.encode({stored, lease_token})
523
- redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
524
- redis.call('HSET', KEYS[4], stored, lease_token)
525
- redis.call('SET', KEYS[8], claim_payload, 'PX', tonumber(ARGV[3]))
526
- redis.call('HSET', KEYS[9], lease_token, KEYS[8])
527
- redis.call('HSET', KEYS[10], ARGV[4], claim_payload)
528
- redis.call('HSET', KEYS[11], lease_token, ARGV[4])
529
- 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
530
543
  end
531
544
 
532
545
  local claim_attempts = 0
@@ -705,6 +718,7 @@ end
705
718
 
706
719
  -- See REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT for the bounded-leak rationale
707
720
  -- on the removed == 0 branch (externally-removed message + valid lease token).
721
+ redis.call('LPUSH', KEYS[2], ARGV[2])
708
722
  local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
709
723
  if removed == 1 then
710
724
  redis.call('ZREM', KEYS[3], ARGV[1])
@@ -720,8 +734,9 @@ if removed == 1 then
720
734
  redis.call('HDEL', KEYS[8], ARGV[3])
721
735
  end
722
736
  redis.call('HDEL', KEYS[5], ARGV[1])
723
- redis.call('LPUSH', KEYS[2], ARGV[2])
724
737
  redis.call('SET', KEYS[9], '1', 'PX', tonumber(ARGV[4]))
738
+ else
739
+ redis.call('LREM', KEYS[2], 1, ARGV[2])
725
740
  end
726
741
 
727
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,6 +380,7 @@ 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
386
  logger.warning(non_blocking_retry_log, type(exc).__name__)
@@ -414,8 +415,7 @@ 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
421
  logger.warning(polling_retry_log, type(exc).__name__)
@@ -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"]
@@ -128,6 +128,10 @@ class AbstractRedisGateway(ABC):
128
128
  use leases.
129
129
 
130
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.
131
135
  """
132
136
 
133
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,6 +380,7 @@ 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
386
  logger.warning(non_blocking_retry_log, type(exc).__name__)
@@ -415,8 +416,7 @@ 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
422
  logger.warning(polling_retry_log, type(exc).__name__)
@@ -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
  ):
@@ -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"