redis-message-queue 8.0.0__tar.gz → 8.0.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 (23) hide show
  1. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/PKG-INFO +2 -2
  2. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/README.md +1 -1
  3. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/pyproject.toml +1 -1
  4. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/__init__.py +2 -0
  5. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_config.py +79 -25
  6. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_event.py +4 -0
  7. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_exceptions.py +4 -0
  8. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_redis_gateway.py +222 -32
  9. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_stored_message.py +49 -30
  10. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/__init__.py +2 -0
  11. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_redis_gateway.py +205 -31
  12. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/redis_message_queue.py +142 -84
  13. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/redis_message_queue.py +152 -93
  14. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/LICENSE +0 -0
  15. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  16. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_callable_utils.py +0 -0
  17. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-8.0.0 → redis_message_queue-8.0.2}/redis_message_queue/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 8.0.0
3
+ Version: 8.0.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/v8.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v8.0.1-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)
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v8.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v8.0.1-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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "8.0.0"
3
+ version = "8.0.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"
@@ -5,6 +5,7 @@ from redis_message_queue._exceptions import (
5
5
  ConfigurationError,
6
6
  GatewayContractError,
7
7
  LuaScriptError,
8
+ MalformedStoredMessageError,
8
9
  QueueBackpressureError,
9
10
  QueueDrainedError,
10
11
  RedisMessageQueueError,
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "ConfigurationError",
34
35
  "GatewayContractError",
35
36
  "LuaScriptError",
37
+ "MalformedStoredMessageError",
36
38
  "QueueBackpressureError",
37
39
  "QueueDrainedError",
38
40
  "CleanupFailedError",
@@ -739,6 +739,26 @@ local function redis_message_queue_decode_claim(cached_claim)
739
739
  return nil
740
740
  end
741
741
 
742
+ local function redis_message_queue_decode_envelope(stored)
743
+ local prefix = string.char(30) .. 'RMQ1:'
744
+ if type(stored) ~= 'string' or string.sub(stored, 1, string.len(prefix)) ~= prefix then
745
+ return nil
746
+ end
747
+ local ok, envelope = pcall(cjson.decode, string.sub(stored, string.len(prefix) + 1))
748
+ if ok and type(envelope) == 'table' and type(envelope['id']) == 'string' then
749
+ return envelope
750
+ end
751
+ return nil
752
+ end
753
+
754
+ local function redis_message_queue_message_id(stored)
755
+ local envelope = redis_message_queue_decode_envelope(stored)
756
+ if envelope then
757
+ return envelope['id']
758
+ end
759
+ return ''
760
+ end
761
+
742
762
  local time = redis.call('TIME')
743
763
  local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
744
764
 
@@ -779,6 +799,7 @@ end
779
799
  -- With a single consumer polling at default interval, 1000 expired leases drain in ~2.5s.
780
800
  local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now_ms, 'LIMIT', 0, 100)
781
801
  local to_requeue = {}
802
+ local reclaimed_events = {}
782
803
  for i = #expired, 1, -1 do
783
804
  local expired_lease_token = redis.call('HGET', KEYS[4], expired[i])
784
805
  redis.call('ZREM', KEYS[3], expired[i])
@@ -800,13 +821,14 @@ for i = #expired, 1, -1 do
800
821
  end
801
822
  if redis.call('LREM', KEYS[2], 1, expired[i]) == 1 then
802
823
  table.insert(to_requeue, expired[i])
824
+ local delivery_count = redis.call('HGET', KEYS[6], expired[i])
825
+ table.insert(reclaimed_events, {redis_message_queue_message_id(expired[i]), tostring(delivery_count or '0')})
803
826
  end
804
827
  end
805
828
  if #to_requeue > 0 then
806
829
  redis.call('RPUSH', KEYS[1], unpack(to_requeue))
807
830
  end
808
- local reclaimed_count = #to_requeue
809
- local dead_lettered_count = 0
831
+ local dead_lettered_events = {}
810
832
 
811
833
  local function store_claim_and_return(stored)
812
834
  -- pcall guards against OOM mid-write: compensate by returning message to pending
@@ -819,7 +841,7 @@ local function store_claim_and_return(stored)
819
841
  redis.call('HSET', KEYS[9], lease_token, KEYS[8])
820
842
  redis.call('HSET', KEYS[10], ARGV[4], claim_payload)
821
843
  redis.call('HSET', KEYS[11], lease_token, ARGV[4])
822
- return {stored, lease_token, tostring(reclaimed_count), tostring(dead_lettered_count)}
844
+ return {stored, lease_token, reclaimed_events, dead_lettered_events}
823
845
  end)
824
846
  if not ok then
825
847
  redis.call('LREM', KEYS[2], 1, stored)
@@ -835,36 +857,28 @@ while claim_attempts < 100 do
835
857
 
836
858
  local stored = redis.call('LMOVE', KEYS[1], KEYS[2], 'RIGHT', 'LEFT')
837
859
  if not stored then
838
- return {'', '', tostring(reclaimed_count), tostring(dead_lettered_count)}
860
+ return {'', '', reclaimed_events, dead_lettered_events}
839
861
  end
840
862
 
841
- if max_delivery_count > 0 then
842
- local count = redis.call('HINCRBY', KEYS[6], stored, 1)
843
- if count > max_delivery_count then
844
- redis.call('LREM', KEYS[2], 1, stored)
845
- redis.call('HDEL', KEYS[6], stored)
846
- -- Strip envelope to store raw payload in DLQ, consistent with completed/failed queues.
847
- -- The per-delivery UUID in the envelope is lost; see README dead-letter notes.
848
- local dead_letter_value = stored
849
- local prefix = string.char(30) .. 'RMQ1:'
850
- if string.sub(stored, 1, string.len(prefix)) == prefix then
851
- local ok, envelope = pcall(cjson.decode, string.sub(stored, string.len(prefix) + 1))
852
- if ok and type(envelope) == 'table' and type(envelope['id']) == 'string'
853
- and type(envelope['payload']) == 'string' then
854
- dead_letter_value = envelope['payload']
855
- end
856
- end
857
- redis.call('LPUSH', KEYS[7], dead_letter_value)
858
- dead_lettered_count = dead_lettered_count + 1
859
- else
860
- return store_claim_and_return(stored)
863
+ local count = redis.call('HINCRBY', KEYS[6], stored, 1)
864
+ if max_delivery_count > 0 and count > max_delivery_count then
865
+ redis.call('LREM', KEYS[2], 1, stored)
866
+ redis.call('HDEL', KEYS[6], stored)
867
+ -- Strip envelope to store raw payload in DLQ, consistent with completed/failed queues.
868
+ -- The per-delivery UUID in the envelope is lost; see README dead-letter notes.
869
+ local dead_letter_value = stored
870
+ local envelope = redis_message_queue_decode_envelope(stored)
871
+ if envelope and type(envelope['payload']) == 'string' then
872
+ dead_letter_value = envelope['payload']
861
873
  end
874
+ redis.call('LPUSH', KEYS[7], dead_letter_value)
875
+ table.insert(dead_lettered_events, {redis_message_queue_message_id(stored), tostring(count)})
862
876
  else
863
877
  return store_claim_and_return(stored)
864
878
  end
865
879
  end
866
880
 
867
- return {'', '', tostring(reclaimed_count), tostring(dead_lettered_count)}
881
+ return {'', '', reclaimed_events, dead_lettered_events}
868
882
  """
869
883
  )
870
884
 
@@ -1037,6 +1051,46 @@ return removed
1037
1051
  """
1038
1052
  )
1039
1053
 
1054
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT = (
1055
+ _LUA_KEY_TYPE_GUARD
1056
+ + """
1057
+ local err = redis_message_queue_require_type(KEYS[1], 'list')
1058
+ if err then
1059
+ return err
1060
+ end
1061
+
1062
+ local err = redis_message_queue_require_type(KEYS[2], 'zset')
1063
+ if err then
1064
+ return err
1065
+ end
1066
+
1067
+ local err = redis_message_queue_require_type(KEYS[3], 'hash')
1068
+ if err then
1069
+ return err
1070
+ end
1071
+
1072
+ local err = redis_message_queue_require_type(KEYS[4], 'hash')
1073
+ if err then
1074
+ return err
1075
+ end
1076
+
1077
+ local err = redis_message_queue_require_type(KEYS[5], 'string')
1078
+ if err then
1079
+ return err
1080
+ end
1081
+
1082
+ if redis.call('LLEN', KEYS[1]) == 0
1083
+ and redis.call('ZCARD', KEYS[2]) == 0
1084
+ and redis.call('HLEN', KEYS[3]) == 0
1085
+ and redis.call('HLEN', KEYS[4]) == 0 then
1086
+ redis.call('DEL', KEYS[5])
1087
+ return 1
1088
+ end
1089
+
1090
+ return 0
1091
+ """
1092
+ )
1093
+
1040
1094
  RENEW_MESSAGE_LEASE_LUA_SCRIPT = (
1041
1095
  _LUA_KEY_TYPE_GUARD
1042
1096
  + """
@@ -52,6 +52,10 @@ class QueueEvent:
52
52
  """a diagnostic hash of the lease token when visibility timeout is enabled"""
53
53
  destination_queue: str | None = None
54
54
  """the queue a message was moved to, when applicable"""
55
+ delivery_count: int | None = None
56
+ """the number of delivery attempts recorded for this message, when applicable"""
57
+ max_delivery_count: int | None = None
58
+ """the configured delivery-attempt threshold, when applicable"""
55
59
  exception_type: str | None = None
56
60
  """
57
61
  type name of the raised exception for metrics labels (e.g., 'TimeoutError');
@@ -21,6 +21,10 @@ class CleanupFailedError(RedisMessageQueueError):
21
21
  """Cleanup after handler completion failed."""
22
22
 
23
23
 
24
+ class MalformedStoredMessageError(RedisMessageQueueError):
25
+ """Stored value starts with the RMQ envelope prefix but is not a valid envelope."""
26
+
27
+
24
28
  class QueueBackpressureError(RedisMessageQueueError):
25
29
  """Publish rejected because the pending queue is at its configured limit."""
26
30
 
@@ -1,10 +1,11 @@
1
1
  import json
2
2
  import logging
3
+ import queue
3
4
  import random
4
5
  import threading
5
6
  import time
6
7
  import uuid
7
- from typing import Callable, Optional, TypeVar
8
+ from typing import Callable, Optional, TypeVar, cast
8
9
 
9
10
  import redis
10
11
  import redis.asyncio
@@ -15,6 +16,7 @@ from redis_message_queue._config import (
15
16
  ADD_MESSAGE_LUA_SCRIPT,
16
17
  CLAIM_MESSAGE_LUA_SCRIPT,
17
18
  CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
19
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
18
20
  DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
19
21
  DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
20
22
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
@@ -54,6 +56,8 @@ from redis_message_queue.interrupt_handler._interface import (
54
56
 
55
57
  logger = logging.getLogger(__name__)
56
58
  _TClaim = TypeVar("_TClaim", bound=ClaimedMessage | MessageData)
59
+ _TRedisCall = TypeVar("_TRedisCall")
60
+ _MessageAttemptEvent = tuple[str | None, int]
57
61
 
58
62
  _LEASE_DEADLINES_SUFFIX = ":lease_deadlines"
59
63
  _LEASE_TOKENS_SUFFIX = ":lease_tokens"
@@ -71,6 +75,49 @@ _PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
71
75
  _PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
72
76
 
73
77
 
78
+ class _DrainDeadlineExceeded(Exception):
79
+ """Internal sentinel for drain-only pending-claim recovery deadlines."""
80
+
81
+
82
+ def _raise_if_drain_deadline_expired(deadline_monotonic: float | None) -> None:
83
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
84
+ raise _DrainDeadlineExceeded
85
+
86
+
87
+ def _call_with_drain_deadline(
88
+ call: Callable[[], _TRedisCall],
89
+ *,
90
+ deadline_monotonic: float | None,
91
+ ) -> _TRedisCall:
92
+ if deadline_monotonic is None:
93
+ return call()
94
+
95
+ remaining_seconds = deadline_monotonic - time.monotonic()
96
+ if remaining_seconds <= 0:
97
+ raise _DrainDeadlineExceeded
98
+
99
+ result_queue: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
100
+
101
+ def run_call() -> None:
102
+ try:
103
+ result = call()
104
+ except Exception as exc:
105
+ result_queue.put_nowait((False, exc))
106
+ else:
107
+ result_queue.put_nowait((True, result))
108
+
109
+ thread = threading.Thread(target=run_call, daemon=True)
110
+ thread.start()
111
+ thread.join(timeout=remaining_seconds)
112
+ if thread.is_alive():
113
+ raise _DrainDeadlineExceeded
114
+
115
+ succeeded, value = result_queue.get_nowait()
116
+ if succeeded:
117
+ return cast(_TRedisCall, value)
118
+ raise cast(BaseException, value)
119
+
120
+
74
121
  def _coerce_lua_count(value: object) -> int:
75
122
  if isinstance(value, bytes):
76
123
  value = value.decode("utf-8")
@@ -80,6 +127,26 @@ def _coerce_lua_count(value: object) -> int:
80
127
  return 0
81
128
 
82
129
 
130
+ def _decode_lua_text(value: object) -> str | None:
131
+ if isinstance(value, bytes):
132
+ value = value.decode("utf-8")
133
+ if isinstance(value, str) and value:
134
+ return value
135
+ return None
136
+
137
+
138
+ def _coerce_lua_message_attempts(value: object) -> list[_MessageAttemptEvent]:
139
+ if not isinstance(value, list | tuple):
140
+ return []
141
+
142
+ attempts: list[_MessageAttemptEvent] = []
143
+ for item in value:
144
+ if not isinstance(item, list | tuple) or len(item) < 2:
145
+ continue
146
+ attempts.append((_decode_lua_text(item[0]), _coerce_lua_count(item[1])))
147
+ return attempts
148
+
149
+
83
150
  def _pending_overload_max_backoff_seconds(block_timeout_seconds: float) -> float:
84
151
  return min(_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS, block_timeout_seconds / 10)
85
152
 
@@ -214,8 +281,11 @@ class RedisGateway(AbstractRedisGateway):
214
281
  operation: EventOperation,
215
282
  outcome: EventOutcome,
216
283
  *,
284
+ message_id: str | None = None,
217
285
  claim_id: str | None = None,
218
286
  destination_queue: str | None = None,
287
+ delivery_count: int | None = None,
288
+ max_delivery_count: int | None = None,
219
289
  exception_type: str | None = None,
220
290
  error: BaseException | None = None,
221
291
  ) -> None:
@@ -224,8 +294,11 @@ class RedisGateway(AbstractRedisGateway):
224
294
  self._event_emitter(
225
295
  operation,
226
296
  outcome,
297
+ message_id=message_id,
227
298
  claim_id=claim_id,
228
299
  destination_queue=destination_queue,
300
+ delivery_count=delivery_count,
301
+ max_delivery_count=max_delivery_count,
229
302
  exception_type=exception_type,
230
303
  error=error,
231
304
  )
@@ -233,12 +306,20 @@ class RedisGateway(AbstractRedisGateway):
233
306
  def _emit_repeated_event(
234
307
  self,
235
308
  operation: EventOperation,
236
- count: int,
309
+ attempts: list[_MessageAttemptEvent],
237
310
  *,
238
311
  destination_queue: str | None = None,
312
+ max_delivery_count: int | None = None,
239
313
  ) -> None:
240
- for _ in range(max(count, 0)):
241
- self._emit_event(operation, "success", destination_queue=destination_queue)
314
+ for message_id, delivery_count in attempts:
315
+ self._emit_event(
316
+ operation,
317
+ "success",
318
+ message_id=message_id,
319
+ destination_queue=destination_queue,
320
+ delivery_count=delivery_count,
321
+ max_delivery_count=max_delivery_count,
322
+ )
242
323
 
243
324
  def _eval(self, *args: object) -> object:
244
325
  try:
@@ -525,6 +606,7 @@ class RedisGateway(AbstractRedisGateway):
525
606
  claim_message: Callable[[str, str, str], _TClaim | None],
526
607
  non_blocking_retry_log: str,
527
608
  polling_retry_log: str,
609
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
528
610
  ) -> _TClaim | None:
529
611
  while True:
530
612
  pending_claim_id = self._acquire_pending_claim_id(to_queue)
@@ -552,7 +634,7 @@ class RedisGateway(AbstractRedisGateway):
552
634
  self._emit_event("claim_reclaim", "success", claim_id=pending_claim_id)
553
635
  return recovered_claim
554
636
 
555
- if self._is_interrupted():
637
+ if self._is_interrupted(is_interrupted):
556
638
  return None
557
639
 
558
640
  pending_claim_id_to_share: str | None = None
@@ -575,7 +657,7 @@ class RedisGateway(AbstractRedisGateway):
575
657
  error=exc,
576
658
  )
577
659
  logger.warning(non_blocking_retry_log, type(exc).__name__)
578
- if self._is_interrupted():
660
+ if self._is_interrupted(is_interrupted):
579
661
  pending_claim_id_to_share = claim_id
580
662
  return None
581
663
  try:
@@ -607,7 +689,7 @@ class RedisGateway(AbstractRedisGateway):
607
689
  claim_may_need_recovery = False
608
690
  last_retryable_exception: Exception | None = None
609
691
  while True:
610
- if self._is_interrupted():
692
+ if self._is_interrupted(is_interrupted):
611
693
  if claim_may_need_recovery:
612
694
  pending_claim_id_to_share = claim_id
613
695
  return None
@@ -640,7 +722,7 @@ class RedisGateway(AbstractRedisGateway):
640
722
  remaining = deadline - time.monotonic()
641
723
  if remaining <= 0:
642
724
  if last_retryable_exception is not None:
643
- if self._is_interrupted():
725
+ if self._is_interrupted(is_interrupted):
644
726
  if claim_may_need_recovery:
645
727
  pending_claim_id_to_share = claim_id
646
728
  return None
@@ -675,11 +757,36 @@ class RedisGateway(AbstractRedisGateway):
675
757
  def wait_for_message_and_move(self, from_queue: str, to_queue: str) -> ClaimedMessage | MessageData | None:
676
758
  if self._is_interrupted():
677
759
  return None
760
+ return self._wait_for_message_and_move_interruptible(from_queue, to_queue)
761
+
762
+ def _wait_for_message_and_move_interruptible(
763
+ self,
764
+ from_queue: str,
765
+ to_queue: str,
766
+ *,
767
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
768
+ ) -> ClaimedMessage | MessageData | None:
769
+ if self._is_interrupted(is_interrupted):
770
+ return None
678
771
  if self._message_visibility_timeout_seconds is not None:
679
- return self._wait_for_message_with_visibility_timeout(from_queue, to_queue)
680
- return self._wait_for_message_without_visibility_timeout(from_queue, to_queue)
772
+ return self._wait_for_message_with_visibility_timeout(
773
+ from_queue,
774
+ to_queue,
775
+ is_interrupted=is_interrupted,
776
+ )
777
+ return self._wait_for_message_without_visibility_timeout(
778
+ from_queue,
779
+ to_queue,
780
+ is_interrupted=is_interrupted,
781
+ )
681
782
 
682
- def _wait_for_message_without_visibility_timeout(self, from_queue: str, to_queue: str) -> MessageData | None:
783
+ def _wait_for_message_without_visibility_timeout(
784
+ self,
785
+ from_queue: str,
786
+ to_queue: str,
787
+ *,
788
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
789
+ ) -> MessageData | None:
683
790
  return self._wait_for_claim(
684
791
  from_queue,
685
792
  to_queue,
@@ -693,9 +800,16 @@ class RedisGateway(AbstractRedisGateway):
693
800
  "Transient error during non-visibility-timeout non-blocking claim, retrying once to recover claim: %s"
694
801
  ),
695
802
  polling_retry_log="Transient error during non-visibility-timeout claim poll, will retry: %s",
803
+ is_interrupted=is_interrupted,
696
804
  )
697
805
 
698
- def _wait_for_message_with_visibility_timeout(self, from_queue: str, to_queue: str) -> ClaimedMessage | None:
806
+ def _wait_for_message_with_visibility_timeout(
807
+ self,
808
+ from_queue: str,
809
+ to_queue: str,
810
+ *,
811
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
812
+ ) -> ClaimedMessage | None:
699
813
  return self._wait_for_claim(
700
814
  from_queue,
701
815
  to_queue,
@@ -709,6 +823,7 @@ class RedisGateway(AbstractRedisGateway):
709
823
  "Transient error during visibility-timeout non-blocking claim, retrying once to recover claim: %s"
710
824
  ),
711
825
  polling_retry_log="Transient error during visibility-timeout claim poll, will retry: %s",
826
+ is_interrupted=is_interrupted,
712
827
  )
713
828
 
714
829
  def _claim_message_without_visibility_timeout(
@@ -760,10 +875,15 @@ class RedisGateway(AbstractRedisGateway):
760
875
  return None
761
876
 
762
877
  stored_message, lease_token = result[0], result[1]
763
- reclaimed_count = _coerce_lua_count(result[2]) if len(result) > 2 else 0
764
- dead_lettered_count = _coerce_lua_count(result[3]) if len(result) > 3 else 0
765
- self._emit_repeated_event("claim_reclaim", reclaimed_count)
766
- self._emit_repeated_event("dlq", dead_lettered_count, destination_queue=self._dead_letter_queue)
878
+ reclaimed_attempts = _coerce_lua_message_attempts(result[2]) if len(result) > 2 else []
879
+ dead_lettered_attempts = _coerce_lua_message_attempts(result[3]) if len(result) > 3 else []
880
+ self._emit_repeated_event("claim_reclaim", reclaimed_attempts)
881
+ self._emit_repeated_event(
882
+ "dlq",
883
+ dead_lettered_attempts,
884
+ destination_queue=self._dead_letter_queue,
885
+ max_delivery_count=self._max_delivery_count,
886
+ )
767
887
  if stored_message in ("", b"") and lease_token in ("", b""):
768
888
  return None
769
889
  if isinstance(lease_token, bytes):
@@ -833,16 +953,54 @@ class RedisGateway(AbstractRedisGateway):
833
953
  def _claim_result_ttl_ms(self) -> str:
834
954
  return str(max(self._message_wait_interval_seconds, 120) * 1000)
835
955
 
836
- def _delete_claim_result_key(self, claim_result_key: str) -> None:
956
+ def _cleanup_drained_lease_token_counter(self, processing_queue: str) -> bool:
957
+ if self._message_visibility_timeout_seconds is None:
958
+ return False
959
+ return bool(
960
+ _coerce_lua_count(
961
+ self._eval(
962
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
963
+ 5,
964
+ processing_queue,
965
+ self._lease_deadlines_key(processing_queue),
966
+ self._lease_tokens_key(processing_queue),
967
+ self._delivery_counts_key(processing_queue),
968
+ self._lease_token_counter_key(processing_queue),
969
+ )
970
+ )
971
+ )
972
+
973
+ def _delete_claim_result_key(
974
+ self,
975
+ claim_result_key: str,
976
+ *,
977
+ deadline_monotonic: float | None = None,
978
+ ) -> None:
837
979
  try:
838
- self._redis_client.delete(claim_result_key)
980
+ _call_with_drain_deadline(
981
+ lambda: self._redis_client.delete(claim_result_key),
982
+ deadline_monotonic=deadline_monotonic,
983
+ )
984
+ except _DrainDeadlineExceeded:
985
+ raise
839
986
  except Exception:
840
987
  # Claim-result keys have bounded TTLs; this cleanup is intentionally best-effort.
841
988
  logger.warning("Failed to delete claim result key %s", claim_result_key, exc_info=True)
842
989
 
843
- def _delete_claim_result_ref(self, claim_result_refs_key: str, lease_token: str) -> None:
990
+ def _delete_claim_result_ref(
991
+ self,
992
+ claim_result_refs_key: str,
993
+ lease_token: str,
994
+ *,
995
+ deadline_monotonic: float | None = None,
996
+ ) -> None:
844
997
  try:
845
- self._redis_client.hdel(claim_result_refs_key, lease_token)
998
+ _call_with_drain_deadline(
999
+ lambda: self._redis_client.hdel(claim_result_refs_key, lease_token),
1000
+ deadline_monotonic=deadline_monotonic,
1001
+ )
1002
+ except _DrainDeadlineExceeded:
1003
+ raise
846
1004
  except Exception:
847
1005
  # Claim-result refs have bounded TTLs; this cleanup is intentionally best-effort.
848
1006
  logger.warning(
@@ -906,25 +1064,48 @@ class RedisGateway(AbstractRedisGateway):
906
1064
  self,
907
1065
  processing_queue: str,
908
1066
  claim_id: str,
1067
+ *,
1068
+ deadline_monotonic: float | None = None,
909
1069
  ) -> MessageData | None:
1070
+ _raise_if_drain_deadline_expired(deadline_monotonic)
910
1071
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
911
- cached_claim = self._redis_client.get(claim_result_key)
1072
+ cached_claim = _call_with_drain_deadline(
1073
+ lambda: self._redis_client.get(claim_result_key),
1074
+ deadline_monotonic=deadline_monotonic,
1075
+ )
1076
+ _raise_if_drain_deadline_expired(deadline_monotonic)
912
1077
  if cached_claim is None:
913
- cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1078
+ cached_claim = _call_with_drain_deadline(
1079
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1080
+ deadline_monotonic=deadline_monotonic,
1081
+ )
1082
+ _raise_if_drain_deadline_expired(deadline_monotonic)
914
1083
  if cached_claim is None:
915
1084
  return None
916
- self._delete_claim_result_key(claim_result_key)
1085
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1086
+ _raise_if_drain_deadline_expired(deadline_monotonic)
917
1087
  return cached_claim
918
1088
 
919
1089
  def _recover_pending_visibility_timeout_claim(
920
1090
  self,
921
1091
  processing_queue: str,
922
1092
  claim_id: str,
1093
+ *,
1094
+ deadline_monotonic: float | None = None,
923
1095
  ) -> ClaimedMessage | None:
1096
+ _raise_if_drain_deadline_expired(deadline_monotonic)
924
1097
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
925
- cached_claim = self._redis_client.get(claim_result_key)
1098
+ cached_claim = _call_with_drain_deadline(
1099
+ lambda: self._redis_client.get(claim_result_key),
1100
+ deadline_monotonic=deadline_monotonic,
1101
+ )
1102
+ _raise_if_drain_deadline_expired(deadline_monotonic)
926
1103
  if cached_claim is None:
927
- cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1104
+ cached_claim = _call_with_drain_deadline(
1105
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1106
+ deadline_monotonic=deadline_monotonic,
1107
+ )
1108
+ _raise_if_drain_deadline_expired(deadline_monotonic)
928
1109
  if cached_claim is None:
929
1110
  return None
930
1111
 
@@ -932,7 +1113,7 @@ class RedisGateway(AbstractRedisGateway):
932
1113
  try:
933
1114
  claim = json.loads(cached_claim_text)
934
1115
  except json.JSONDecodeError:
935
- self._delete_claim_result_key(claim_result_key)
1116
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
936
1117
  return None
937
1118
 
938
1119
  if (
@@ -941,7 +1122,7 @@ class RedisGateway(AbstractRedisGateway):
941
1122
  or not isinstance(claim[0], str)
942
1123
  or not isinstance(claim[1], str)
943
1124
  ):
944
- self._delete_claim_result_key(claim_result_key)
1125
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
945
1126
  return None
946
1127
 
947
1128
  stored_message: MessageData = claim[0]
@@ -949,12 +1130,19 @@ class RedisGateway(AbstractRedisGateway):
949
1130
  stored_message = stored_message.encode("utf-8")
950
1131
  lease_token = claim[1]
951
1132
 
952
- self._delete_claim_result_key(claim_result_key)
953
- self._delete_claim_result_ref(self._claim_result_refs_key(processing_queue), lease_token)
1133
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1134
+ self._delete_claim_result_ref(
1135
+ self._claim_result_refs_key(processing_queue),
1136
+ lease_token,
1137
+ deadline_monotonic=deadline_monotonic,
1138
+ )
1139
+ _raise_if_drain_deadline_expired(deadline_monotonic)
954
1140
  return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
955
1141
 
956
- def _is_interrupted(self) -> bool:
957
- return self._interrupt is not None and self._interrupt.is_interrupted()
1142
+ def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
1143
+ return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
1144
+ is_interrupted is not None and is_interrupted.is_interrupted()
1145
+ )
958
1146
 
959
1147
  def _drain_pending_claim_ids(
960
1148
  self,
@@ -1010,8 +1198,10 @@ class RedisGateway(AbstractRedisGateway):
1010
1198
  clear = False
1011
1199
  try:
1012
1200
  try:
1013
- recover(processing_queue, claim_id)
1201
+ recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1014
1202
  clear = True
1203
+ except _DrainDeadlineExceeded:
1204
+ break
1015
1205
  except Exception as exc:
1016
1206
  if not is_redis_retryable_exception(exc):
1017
1207
  raise