redis-message-queue 8.0.1__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.1 → redis_message_queue-8.0.2}/PKG-INFO +1 -1
  2. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/pyproject.toml +1 -1
  3. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/__init__.py +2 -0
  4. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_config.py +79 -25
  5. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_event.py +4 -0
  6. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_exceptions.py +4 -0
  7. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_gateway.py +65 -7
  8. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_stored_message.py +49 -30
  9. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/__init__.py +2 -0
  10. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_redis_gateway.py +65 -7
  11. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/redis_message_queue.py +94 -77
  12. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/redis_message_queue.py +104 -85
  13. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/LICENSE +0 -0
  14. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/README.md +0 -0
  15. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  16. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_callable_utils.py +0 -0
  17. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-8.0.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "8.0.1"
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
 
@@ -16,6 +16,7 @@ from redis_message_queue._config import (
16
16
  ADD_MESSAGE_LUA_SCRIPT,
17
17
  CLAIM_MESSAGE_LUA_SCRIPT,
18
18
  CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
19
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
19
20
  DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
20
21
  DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
21
22
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
@@ -56,6 +57,7 @@ from redis_message_queue.interrupt_handler._interface import (
56
57
  logger = logging.getLogger(__name__)
57
58
  _TClaim = TypeVar("_TClaim", bound=ClaimedMessage | MessageData)
58
59
  _TRedisCall = TypeVar("_TRedisCall")
60
+ _MessageAttemptEvent = tuple[str | None, int]
59
61
 
60
62
  _LEASE_DEADLINES_SUFFIX = ":lease_deadlines"
61
63
  _LEASE_TOKENS_SUFFIX = ":lease_tokens"
@@ -125,6 +127,26 @@ def _coerce_lua_count(value: object) -> int:
125
127
  return 0
126
128
 
127
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
+
128
150
  def _pending_overload_max_backoff_seconds(block_timeout_seconds: float) -> float:
129
151
  return min(_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS, block_timeout_seconds / 10)
130
152
 
@@ -259,8 +281,11 @@ class RedisGateway(AbstractRedisGateway):
259
281
  operation: EventOperation,
260
282
  outcome: EventOutcome,
261
283
  *,
284
+ message_id: str | None = None,
262
285
  claim_id: str | None = None,
263
286
  destination_queue: str | None = None,
287
+ delivery_count: int | None = None,
288
+ max_delivery_count: int | None = None,
264
289
  exception_type: str | None = None,
265
290
  error: BaseException | None = None,
266
291
  ) -> None:
@@ -269,8 +294,11 @@ class RedisGateway(AbstractRedisGateway):
269
294
  self._event_emitter(
270
295
  operation,
271
296
  outcome,
297
+ message_id=message_id,
272
298
  claim_id=claim_id,
273
299
  destination_queue=destination_queue,
300
+ delivery_count=delivery_count,
301
+ max_delivery_count=max_delivery_count,
274
302
  exception_type=exception_type,
275
303
  error=error,
276
304
  )
@@ -278,12 +306,20 @@ class RedisGateway(AbstractRedisGateway):
278
306
  def _emit_repeated_event(
279
307
  self,
280
308
  operation: EventOperation,
281
- count: int,
309
+ attempts: list[_MessageAttemptEvent],
282
310
  *,
283
311
  destination_queue: str | None = None,
312
+ max_delivery_count: int | None = None,
284
313
  ) -> None:
285
- for _ in range(max(count, 0)):
286
- 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
+ )
287
323
 
288
324
  def _eval(self, *args: object) -> object:
289
325
  try:
@@ -839,10 +875,15 @@ class RedisGateway(AbstractRedisGateway):
839
875
  return None
840
876
 
841
877
  stored_message, lease_token = result[0], result[1]
842
- reclaimed_count = _coerce_lua_count(result[2]) if len(result) > 2 else 0
843
- dead_lettered_count = _coerce_lua_count(result[3]) if len(result) > 3 else 0
844
- self._emit_repeated_event("claim_reclaim", reclaimed_count)
845
- 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
+ )
846
887
  if stored_message in ("", b"") and lease_token in ("", b""):
847
888
  return None
848
889
  if isinstance(lease_token, bytes):
@@ -912,6 +953,23 @@ class RedisGateway(AbstractRedisGateway):
912
953
  def _claim_result_ttl_ms(self) -> str:
913
954
  return str(max(self._message_wait_interval_seconds, 120) * 1000)
914
955
 
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
+
915
973
  def _delete_claim_result_key(
916
974
  self,
917
975
  claim_result_key: str,
@@ -2,9 +2,12 @@ import json
2
2
  import uuid
3
3
  from dataclasses import dataclass
4
4
 
5
+ from redis_message_queue._exceptions import MalformedStoredMessageError
6
+
5
7
  MessageData = str | bytes
6
8
 
7
9
  _STORED_MESSAGE_PREFIX = "\x1eRMQ1:"
10
+ _STORED_MESSAGE_PREFIX_BYTES = _STORED_MESSAGE_PREFIX.encode("utf-8")
8
11
 
9
12
 
10
13
  @dataclass(frozen=True)
@@ -50,52 +53,68 @@ def decode_stored_message(message: MessageData) -> MessageData:
50
53
  only when input came through ``encode_stored_message`` first. Built-in
51
54
  publish/consume always re-wraps so this footgun cannot fire end-to-end;
52
55
  custom gateways feeding raw input must wrap before decoding.
56
+
57
+ Raises ``MalformedStoredMessageError`` when the value starts with the RMQ
58
+ envelope prefix but is not a valid payload-bearing envelope.
53
59
  """
54
- payload = _extract_payload(message)
55
- if payload is None:
60
+ envelope = _decode_envelope(message)
61
+ if envelope is None:
56
62
  return message
63
+ _message_id, payload = envelope
57
64
  if isinstance(message, bytes):
58
65
  return payload.encode("utf-8")
59
66
  return payload
60
67
 
61
68
 
62
69
  def extract_stored_message_id(message: MessageData) -> str | None:
63
- if isinstance(message, bytes):
64
- try:
65
- message = message.decode("utf-8")
66
- except UnicodeDecodeError:
67
- return None
68
- if not message.startswith(_STORED_MESSAGE_PREFIX):
69
- return None
70
- try:
71
- envelope = json.loads(message[len(_STORED_MESSAGE_PREFIX) :])
72
- except json.JSONDecodeError:
70
+ """Return the RMQ envelope id, or None for values that are not RMQ envelopes.
71
+
72
+ Raises ``MalformedStoredMessageError`` when the value starts with the RMQ
73
+ envelope prefix but is not a valid payload-bearing envelope.
74
+ """
75
+ envelope = _decode_envelope(message)
76
+ if envelope is None:
73
77
  return None
74
- if isinstance(envelope, dict) and isinstance(envelope.get("id"), str):
75
- return envelope["id"]
76
- return None
78
+ message_id, _payload = envelope
79
+ return message_id
77
80
 
78
81
 
79
- def _extract_payload(message: MessageData) -> str | None:
82
+ def _decode_envelope(message: MessageData) -> tuple[str, str] | None:
80
83
  if isinstance(message, bytes):
84
+ if not message.startswith(_STORED_MESSAGE_PREFIX_BYTES):
85
+ return None
81
86
  try:
82
87
  message = message.decode("utf-8")
83
- except UnicodeDecodeError:
84
- return None
85
-
86
- if not message.startswith(_STORED_MESSAGE_PREFIX):
88
+ except UnicodeDecodeError as exc:
89
+ raise MalformedStoredMessageError(
90
+ "Stored message starts with the RMQ envelope prefix but is not valid UTF-8"
91
+ ) from exc
92
+ elif not message.startswith(_STORED_MESSAGE_PREFIX):
87
93
  return None
88
94
 
95
+ envelope_body = message[len(_STORED_MESSAGE_PREFIX) :]
96
+
89
97
  try:
90
- envelope = json.loads(message[len(_STORED_MESSAGE_PREFIX) :])
91
- except json.JSONDecodeError:
92
- return None
98
+ envelope = json.loads(envelope_body)
99
+ except json.JSONDecodeError as exc:
100
+ raise MalformedStoredMessageError(
101
+ "Stored message starts with the RMQ envelope prefix but does not contain valid JSON"
102
+ ) from exc
93
103
 
94
104
  if not isinstance(envelope, dict):
95
- return None
96
-
97
- payload = envelope.get("payload")
98
- envelope_id = envelope.get("id")
99
- if not isinstance(payload, str) or not isinstance(envelope_id, str):
100
- return None
101
- return payload
105
+ raise MalformedStoredMessageError(
106
+ "Stored message starts with the RMQ envelope prefix but does not contain a JSON object"
107
+ )
108
+
109
+ if "id" not in envelope:
110
+ raise MalformedStoredMessageError("Stored RMQ envelope is missing required 'id' field")
111
+ if "payload" not in envelope:
112
+ raise MalformedStoredMessageError("Stored RMQ envelope is missing required 'payload' field")
113
+
114
+ envelope_id = envelope["id"]
115
+ payload = envelope["payload"]
116
+ if not isinstance(envelope_id, str):
117
+ raise MalformedStoredMessageError("Stored RMQ envelope 'id' field must be a string")
118
+ if not isinstance(payload, str):
119
+ raise MalformedStoredMessageError("Stored RMQ envelope 'payload' field must be a string")
120
+ return envelope_id, payload
@@ -4,6 +4,7 @@ from redis_message_queue._exceptions import (
4
4
  ConfigurationError,
5
5
  GatewayContractError,
6
6
  LuaScriptError,
7
+ MalformedStoredMessageError,
7
8
  QueueBackpressureError,
8
9
  QueueDrainedError,
9
10
  RedisMessageQueueError,
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "ConfigurationError",
31
32
  "GatewayContractError",
32
33
  "LuaScriptError",
34
+ "MalformedStoredMessageError",
33
35
  "QueueBackpressureError",
34
36
  "QueueDrainedError",
35
37
  "CleanupFailedError",
@@ -14,6 +14,7 @@ from redis_message_queue._config import (
14
14
  ADD_MESSAGE_LUA_SCRIPT,
15
15
  CLAIM_MESSAGE_LUA_SCRIPT,
16
16
  CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
17
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
17
18
  DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
18
19
  DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
19
20
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
@@ -55,6 +56,7 @@ from redis_message_queue.interrupt_handler._interface import (
55
56
  logger = logging.getLogger(__name__)
56
57
  _TClaim = TypeVar("_TClaim", bound=ClaimedMessage | MessageData)
57
58
  _TRedisCall = TypeVar("_TRedisCall")
59
+ _MessageAttemptEvent = tuple[str | None, int]
58
60
 
59
61
  _LEASE_DEADLINES_SUFFIX = ":lease_deadlines"
60
62
  _LEASE_TOKENS_SUFFIX = ":lease_tokens"
@@ -108,6 +110,26 @@ def _coerce_lua_count(value: object) -> int:
108
110
  return 0
109
111
 
110
112
 
113
+ def _decode_lua_text(value: object) -> str | None:
114
+ if isinstance(value, bytes):
115
+ value = value.decode("utf-8")
116
+ if isinstance(value, str) and value:
117
+ return value
118
+ return None
119
+
120
+
121
+ def _coerce_lua_message_attempts(value: object) -> list[_MessageAttemptEvent]:
122
+ if not isinstance(value, list | tuple):
123
+ return []
124
+
125
+ attempts: list[_MessageAttemptEvent] = []
126
+ for item in value:
127
+ if not isinstance(item, list | tuple) or len(item) < 2:
128
+ continue
129
+ attempts.append((_decode_lua_text(item[0]), _coerce_lua_count(item[1])))
130
+ return attempts
131
+
132
+
111
133
  def _pending_overload_max_backoff_seconds(block_timeout_seconds: float) -> float:
112
134
  return min(_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS, block_timeout_seconds / 10)
113
135
 
@@ -233,8 +255,11 @@ class RedisGateway(AbstractRedisGateway):
233
255
  operation: EventOperation,
234
256
  outcome: EventOutcome,
235
257
  *,
258
+ message_id: str | None = None,
236
259
  claim_id: str | None = None,
237
260
  destination_queue: str | None = None,
261
+ delivery_count: int | None = None,
262
+ max_delivery_count: int | None = None,
238
263
  exception_type: str | None = None,
239
264
  error: BaseException | None = None,
240
265
  ) -> None:
@@ -243,8 +268,11 @@ class RedisGateway(AbstractRedisGateway):
243
268
  await self._event_emitter(
244
269
  operation,
245
270
  outcome,
271
+ message_id=message_id,
246
272
  claim_id=claim_id,
247
273
  destination_queue=destination_queue,
274
+ delivery_count=delivery_count,
275
+ max_delivery_count=max_delivery_count,
248
276
  exception_type=exception_type,
249
277
  error=error,
250
278
  )
@@ -252,12 +280,20 @@ class RedisGateway(AbstractRedisGateway):
252
280
  async def _emit_repeated_event(
253
281
  self,
254
282
  operation: EventOperation,
255
- count: int,
283
+ attempts: list[_MessageAttemptEvent],
256
284
  *,
257
285
  destination_queue: str | None = None,
286
+ max_delivery_count: int | None = None,
258
287
  ) -> None:
259
- for _ in range(max(count, 0)):
260
- await self._emit_event(operation, "success", destination_queue=destination_queue)
288
+ for message_id, delivery_count in attempts:
289
+ await self._emit_event(
290
+ operation,
291
+ "success",
292
+ message_id=message_id,
293
+ destination_queue=destination_queue,
294
+ delivery_count=delivery_count,
295
+ max_delivery_count=max_delivery_count,
296
+ )
261
297
 
262
298
  async def _eval(self, *args: object) -> object:
263
299
  try:
@@ -819,10 +855,15 @@ class RedisGateway(AbstractRedisGateway):
819
855
  return None
820
856
 
821
857
  stored_message, lease_token = result[0], result[1]
822
- reclaimed_count = _coerce_lua_count(result[2]) if len(result) > 2 else 0
823
- dead_lettered_count = _coerce_lua_count(result[3]) if len(result) > 3 else 0
824
- await self._emit_repeated_event("claim_reclaim", reclaimed_count)
825
- await self._emit_repeated_event("dlq", dead_lettered_count, destination_queue=self._dead_letter_queue)
858
+ reclaimed_attempts = _coerce_lua_message_attempts(result[2]) if len(result) > 2 else []
859
+ dead_lettered_attempts = _coerce_lua_message_attempts(result[3]) if len(result) > 3 else []
860
+ await self._emit_repeated_event("claim_reclaim", reclaimed_attempts)
861
+ await self._emit_repeated_event(
862
+ "dlq",
863
+ dead_lettered_attempts,
864
+ destination_queue=self._dead_letter_queue,
865
+ max_delivery_count=self._max_delivery_count,
866
+ )
826
867
  if stored_message in ("", b"") and lease_token in ("", b""):
827
868
  return None
828
869
  if isinstance(lease_token, bytes):
@@ -892,6 +933,23 @@ class RedisGateway(AbstractRedisGateway):
892
933
  def _claim_result_ttl_ms(self) -> str:
893
934
  return str(max(self._message_wait_interval_seconds, 120) * 1000)
894
935
 
936
+ async def _cleanup_drained_lease_token_counter(self, processing_queue: str) -> bool:
937
+ if self._message_visibility_timeout_seconds is None:
938
+ return False
939
+ return bool(
940
+ _coerce_lua_count(
941
+ await self._eval(
942
+ CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
943
+ 5,
944
+ processing_queue,
945
+ self._lease_deadlines_key(processing_queue),
946
+ self._lease_tokens_key(processing_queue),
947
+ self._delivery_counts_key(processing_queue),
948
+ self._lease_token_counter_key(processing_queue),
949
+ )
950
+ )
951
+ )
952
+
895
953
  async def _delete_claim_result_key(
896
954
  self,
897
955
  claim_result_key: str,
@@ -23,6 +23,7 @@ from redis_message_queue._exceptions import (
23
23
  CleanupFailedError,
24
24
  ConfigurationError,
25
25
  GatewayContractError,
26
+ MalformedStoredMessageError,
26
27
  QueueDrainedError,
27
28
  )
28
29
  from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
@@ -750,6 +751,8 @@ class RedisMessageQueue:
750
751
  claim_id: str | None = None,
751
752
  lease_token_hash: str | None = None,
752
753
  destination_queue: str | None = None,
754
+ delivery_count: int | None = None,
755
+ max_delivery_count: int | None = None,
753
756
  exception_type: str | None = None,
754
757
  error: BaseException | None = None,
755
758
  duration_ms: float | None = None,
@@ -764,6 +767,8 @@ class RedisMessageQueue:
764
767
  claim_id=claim_id,
765
768
  lease_token_hash=lease_token_hash,
766
769
  destination_queue=destination_queue,
770
+ delivery_count=delivery_count,
771
+ max_delivery_count=max_delivery_count,
767
772
  exception_type=exception_type,
768
773
  error=error,
769
774
  duration_ms=duration_ms,
@@ -801,48 +806,40 @@ class RedisMessageQueue:
801
806
  return await self._publish_unlocked(message)
802
807
 
803
808
  async def _publish_unlocked(self, message: str | dict) -> bool:
804
- if not isinstance(message, (str, dict)):
805
- raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
806
- if isinstance(message, dict):
807
- non_str_keys = _find_non_string_dict_keys(message)
808
- if non_str_keys:
809
- raise TypeError(
810
- "'message' dict keys must all be strings; "
811
- f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
812
- )
813
- message_str = json.dumps(message, sort_keys=True, allow_nan=False)
814
- else:
815
- message_str = message
816
-
817
809
  started_at = time.perf_counter()
818
- if not self._deduplication:
819
- try:
820
- result = await self._redis.add_message(self.key.pending, message_str)
821
- except Exception as exc:
822
- await self._emit_event(
823
- "publish",
824
- "failure",
825
- exception_type=type(exc).__name__,
826
- error=exc,
827
- duration_ms=_duration_ms(started_at),
828
- )
829
- raise
830
- if result is not None:
831
- raise GatewayContractError(
832
- f"gateway.add_message() must return None, got {type(result).__name__}. "
833
- "See AbstractRedisGateway.add_message for the full contract."
834
- )
835
- await self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
836
- return True
837
-
838
- dedup_key = self._get_deduplication_key(message)
839
- if inspect.isawaitable(dedup_key):
840
- dedup_key = await dedup_key
841
- dedup_key = validate_callable_deduplication_key(dedup_key, message)
842
- dedup_key = self.key.deduplication(dedup_key)
843
-
844
810
  try:
845
- result = await self._redis.publish_message(self.key.pending, message_str, dedup_key)
811
+ if not isinstance(message, (str, dict)):
812
+ raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
813
+ if isinstance(message, dict):
814
+ non_str_keys = _find_non_string_dict_keys(message)
815
+ if non_str_keys:
816
+ raise TypeError(
817
+ "'message' dict keys must all be strings; "
818
+ f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
819
+ )
820
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
821
+ else:
822
+ message_str = message
823
+
824
+ if not self._deduplication:
825
+ result = await self._redis.add_message(self.key.pending, message_str)
826
+ if result is not None:
827
+ raise GatewayContractError(
828
+ f"gateway.add_message() must return None, got {type(result).__name__}. "
829
+ "See AbstractRedisGateway.add_message for the full contract."
830
+ )
831
+ else:
832
+ dedup_key = self._get_deduplication_key(message)
833
+ if inspect.isawaitable(dedup_key):
834
+ dedup_key = await dedup_key
835
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
836
+ dedup_key = self.key.deduplication(dedup_key)
837
+ result = await self._redis.publish_message(self.key.pending, message_str, dedup_key)
838
+ if not isinstance(result, bool):
839
+ raise GatewayContractError(
840
+ f"gateway.publish_message() must return bool, got {type(result).__name__}. "
841
+ "See AbstractRedisGateway.publish_message for the full contract."
842
+ )
846
843
  except Exception as exc:
847
844
  await self._emit_event(
848
845
  "publish",
@@ -852,11 +849,10 @@ class RedisMessageQueue:
852
849
  duration_ms=_duration_ms(started_at),
853
850
  )
854
851
  raise
855
- if not isinstance(result, bool):
856
- raise GatewayContractError(
857
- f"gateway.publish_message() must return bool, got {type(result).__name__}. "
858
- "See AbstractRedisGateway.publish_message for the full contract."
859
- )
852
+
853
+ if not self._deduplication:
854
+ await self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
855
+ return True
860
856
  await self._emit_event(
861
857
  "publish" if result else "publish_dedup_hit",
862
858
  "success" if result else "skipped",
@@ -878,6 +874,38 @@ class RedisMessageQueue:
878
874
  return
879
875
  try:
880
876
  claimed_message = await self._wait_for_message_and_move()
877
+ if claimed_message is not None:
878
+ if not isinstance(claimed_message, (ClaimedMessage, str, bytes)):
879
+ raise GatewayContractError(
880
+ f"gateway.wait_for_message_and_move() must return ClaimedMessage, str, bytes, or None; "
881
+ f"got {type(claimed_message).__name__}. "
882
+ "See AbstractRedisGateway.wait_for_message_and_move for the full contract."
883
+ )
884
+
885
+ stored_message = claimed_message
886
+ lease_token = None
887
+ if isinstance(claimed_message, ClaimedMessage):
888
+ stored_message = claimed_message.stored_message
889
+ lease_token = claimed_message.lease_token
890
+ lease_token_hash = _hash_lease_token(lease_token)
891
+
892
+ if lease_token is None and self._heartbeat_interval_seconds is not None:
893
+ if not self._warned_no_lease_for_heartbeat:
894
+ self._warned_no_lease_for_heartbeat = True
895
+ no_lease_token_warning = (
896
+ "Heartbeat is configured but the gateway returned no lease token. "
897
+ "The heartbeat will not run for this message. Ensure the gateway "
898
+ "returns ClaimedMessage from wait_for_message_and_move() when "
899
+ "visibility timeouts are in use."
900
+ )
901
+ logger.warning(no_lease_token_warning)
902
+ warnings.warn(no_lease_token_warning, RuntimeWarning, stacklevel=2)
903
+
904
+ if lease_token is None and self._requires_claimed_message:
905
+ raise GatewayContractError(
906
+ "gateways with visibility timeouts must return ClaimedMessage from "
907
+ "wait_for_message_and_move(); got plain MessageData without a lease token"
908
+ )
881
909
  except Exception as exc:
882
910
  await self._emit_event(
883
911
  "claim",
@@ -892,20 +920,21 @@ class RedisMessageQueue:
892
920
  yield None
893
921
  return
894
922
 
895
- if not isinstance(claimed_message, (ClaimedMessage, str, bytes)):
896
- raise GatewayContractError(
897
- f"gateway.wait_for_message_and_move() must return ClaimedMessage, str, bytes, or None; "
898
- f"got {type(claimed_message).__name__}. "
899
- "See AbstractRedisGateway.wait_for_message_and_move for the full contract."
923
+ message_id = None
924
+ try:
925
+ message_id = extract_stored_message_id(stored_message)
926
+ message = decode_stored_message(stored_message)
927
+ except MalformedStoredMessageError as exc:
928
+ await self._emit_event(
929
+ "claim",
930
+ "failure",
931
+ message_id=message_id,
932
+ lease_token_hash=lease_token_hash,
933
+ exception_type=type(exc).__name__,
934
+ error=exc,
935
+ duration_ms=_duration_ms(claim_started_at),
900
936
  )
901
-
902
- stored_message = claimed_message
903
- lease_token = None
904
- if isinstance(claimed_message, ClaimedMessage):
905
- stored_message = claimed_message.stored_message
906
- lease_token = claimed_message.lease_token
907
- message_id = extract_stored_message_id(stored_message)
908
- lease_token_hash = _hash_lease_token(lease_token)
937
+ raise
909
938
  await self._emit_event(
910
939
  "claim",
911
940
  "success",
@@ -914,25 +943,6 @@ class RedisMessageQueue:
914
943
  duration_ms=_duration_ms(claim_started_at),
915
944
  )
916
945
 
917
- if lease_token is None and self._heartbeat_interval_seconds is not None:
918
- if not self._warned_no_lease_for_heartbeat:
919
- self._warned_no_lease_for_heartbeat = True
920
- no_lease_token_warning = (
921
- "Heartbeat is configured but the gateway returned no lease token. "
922
- "The heartbeat will not run for this message. Ensure the gateway "
923
- "returns ClaimedMessage from wait_for_message_and_move() when "
924
- "visibility timeouts are in use."
925
- )
926
- logger.warning(no_lease_token_warning)
927
- warnings.warn(no_lease_token_warning, RuntimeWarning, stacklevel=2)
928
-
929
- if lease_token is None and self._requires_claimed_message:
930
- raise GatewayContractError(
931
- "gateways with visibility timeouts must return ClaimedMessage from "
932
- "wait_for_message_and_move(); got plain MessageData without a lease token"
933
- )
934
-
935
- message = decode_stored_message(stored_message)
936
946
  lease_heartbeat = self._build_lease_heartbeat(stored_message, lease_token, message_id, lease_token_hash)
937
947
  if lease_heartbeat is not None:
938
948
  lease_heartbeat.start()
@@ -1153,7 +1163,10 @@ class RedisMessageQueue:
1153
1163
  if timeout is not None and timeout < 0:
1154
1164
  raise ConfigurationError(f"'timeout' must be non-negative when provided, got {timeout}")
1155
1165
  async with self._aclose_lock:
1166
+ cleanup_lease_counter = getattr(self._redis, "_cleanup_drained_lease_token_counter", None)
1156
1167
  if self._aclose_result is not None:
1168
+ if cleanup_lease_counter is not None:
1169
+ await _await_preserving_cancellation(cleanup_lease_counter(self.key.processing))
1157
1170
  return self._aclose_result
1158
1171
 
1159
1172
  async with self._publish_lock:
@@ -1161,6 +1174,8 @@ class RedisMessageQueue:
1161
1174
  self._drained = True
1162
1175
  drainer = getattr(self._redis, "_drain_pending_claim_ids", None)
1163
1176
  if drainer is None:
1177
+ if cleanup_lease_counter is not None:
1178
+ await _await_preserving_cancellation(cleanup_lease_counter(self.key.processing))
1164
1179
  self._aclose_result = True
1165
1180
  return self._aclose_result
1166
1181
  loop = asyncio.get_running_loop()
@@ -1169,6 +1184,8 @@ class RedisMessageQueue:
1169
1184
  drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1170
1185
  )
1171
1186
  if result is True:
1187
+ if cleanup_lease_counter is not None:
1188
+ await _await_preserving_cancellation(cleanup_lease_counter(self.key.processing))
1172
1189
  self._aclose_result = True
1173
1190
  else:
1174
1191
  self._aclose_result = None
@@ -24,6 +24,7 @@ from redis_message_queue._exceptions import (
24
24
  CleanupFailedError,
25
25
  ConfigurationError,
26
26
  GatewayContractError,
27
+ MalformedStoredMessageError,
27
28
  QueueDrainedError,
28
29
  )
29
30
  from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
@@ -703,6 +704,8 @@ class RedisMessageQueue:
703
704
  claim_id: str | None = None,
704
705
  lease_token_hash: str | None = None,
705
706
  destination_queue: str | None = None,
707
+ delivery_count: int | None = None,
708
+ max_delivery_count: int | None = None,
706
709
  exception_type: str | None = None,
707
710
  error: BaseException | None = None,
708
711
  duration_ms: float | None = None,
@@ -717,6 +720,8 @@ class RedisMessageQueue:
717
720
  claim_id=claim_id,
718
721
  lease_token_hash=lease_token_hash,
719
722
  destination_queue=destination_queue,
723
+ delivery_count=delivery_count,
724
+ max_delivery_count=max_delivery_count,
720
725
  exception_type=exception_type,
721
726
  error=error,
722
727
  duration_ms=duration_ms,
@@ -754,56 +759,50 @@ class RedisMessageQueue:
754
759
  return self._publish_unlocked(message)
755
760
 
756
761
  def _publish_unlocked(self, message: str | dict) -> bool:
757
- if not isinstance(message, (str, dict)):
758
- raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
759
- if isinstance(message, dict):
760
- non_str_keys = _find_non_string_dict_keys(message)
761
- if non_str_keys:
762
- raise TypeError(
763
- "'message' dict keys must all be strings; "
764
- f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
765
- )
766
- message_str = json.dumps(message, sort_keys=True, allow_nan=False)
767
- else:
768
- message_str = message
769
-
770
762
  started_at = time.perf_counter()
771
- if not self._deduplication:
772
- try:
773
- result = self._redis.add_message(self.key.pending, message_str)
774
- except Exception as exc:
775
- self._emit_event(
776
- "publish",
777
- "failure",
778
- exception_type=type(exc).__name__,
779
- error=exc,
780
- duration_ms=_duration_ms(started_at),
781
- )
782
- raise
783
- if result is not None:
784
- raise GatewayContractError(
785
- f"gateway.add_message() must return None, got {type(result).__name__}. "
786
- "See AbstractRedisGateway.add_message for the full contract."
787
- )
788
- self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
789
- return True
790
-
791
- dedup_key = self._get_deduplication_key(message)
792
- if inspect.isawaitable(dedup_key):
793
- is_coroutine = inspect.iscoroutine(dedup_key)
794
- _close_or_cancel_awaitable(dedup_key)
795
- if is_coroutine:
796
- raise TypeError(
797
- "'get_deduplication_key' returned a coroutine; use the async RedisMessageQueue for async callables"
798
- )
799
- raise TypeError(
800
- "'get_deduplication_key' returned an awaitable; use the async RedisMessageQueue for async callables"
801
- )
802
- dedup_key = validate_callable_deduplication_key(dedup_key, message)
803
- dedup_key = self.key.deduplication(dedup_key)
804
-
805
763
  try:
806
- result = self._redis.publish_message(self.key.pending, message_str, dedup_key)
764
+ if not isinstance(message, (str, dict)):
765
+ raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
766
+ if isinstance(message, dict):
767
+ non_str_keys = _find_non_string_dict_keys(message)
768
+ if non_str_keys:
769
+ raise TypeError(
770
+ "'message' dict keys must all be strings; "
771
+ f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
772
+ )
773
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
774
+ else:
775
+ message_str = message
776
+
777
+ if not self._deduplication:
778
+ result = self._redis.add_message(self.key.pending, message_str)
779
+ if result is not None:
780
+ raise GatewayContractError(
781
+ f"gateway.add_message() must return None, got {type(result).__name__}. "
782
+ "See AbstractRedisGateway.add_message for the full contract."
783
+ )
784
+ else:
785
+ dedup_key = self._get_deduplication_key(message)
786
+ if inspect.isawaitable(dedup_key):
787
+ is_coroutine = inspect.iscoroutine(dedup_key)
788
+ _close_or_cancel_awaitable(dedup_key)
789
+ if is_coroutine:
790
+ raise TypeError(
791
+ "'get_deduplication_key' returned a coroutine; "
792
+ "use the async RedisMessageQueue for async callables"
793
+ )
794
+ raise TypeError(
795
+ "'get_deduplication_key' returned an awaitable; "
796
+ "use the async RedisMessageQueue for async callables"
797
+ )
798
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
799
+ dedup_key = self.key.deduplication(dedup_key)
800
+ result = self._redis.publish_message(self.key.pending, message_str, dedup_key)
801
+ if not isinstance(result, bool):
802
+ raise GatewayContractError(
803
+ f"gateway.publish_message() must return bool, got {type(result).__name__}. "
804
+ "See AbstractRedisGateway.publish_message for the full contract."
805
+ )
807
806
  except Exception as exc:
808
807
  self._emit_event(
809
808
  "publish",
@@ -813,11 +812,10 @@ class RedisMessageQueue:
813
812
  duration_ms=_duration_ms(started_at),
814
813
  )
815
814
  raise
816
- if not isinstance(result, bool):
817
- raise GatewayContractError(
818
- f"gateway.publish_message() must return bool, got {type(result).__name__}. "
819
- "See AbstractRedisGateway.publish_message for the full contract."
820
- )
815
+
816
+ if not self._deduplication:
817
+ self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
818
+ return True
821
819
  self._emit_event(
822
820
  "publish" if result else "publish_dedup_hit",
823
821
  "success" if result else "skipped",
@@ -839,6 +837,38 @@ class RedisMessageQueue:
839
837
  return
840
838
  try:
841
839
  claimed_message = self._wait_for_message_and_move()
840
+ if claimed_message is not None:
841
+ if not isinstance(claimed_message, (ClaimedMessage, str, bytes)):
842
+ raise GatewayContractError(
843
+ f"gateway.wait_for_message_and_move() must return ClaimedMessage, str, bytes, or None; "
844
+ f"got {type(claimed_message).__name__}. "
845
+ "See AbstractRedisGateway.wait_for_message_and_move for the full contract."
846
+ )
847
+
848
+ stored_message = claimed_message
849
+ lease_token = None
850
+ if isinstance(claimed_message, ClaimedMessage):
851
+ stored_message = claimed_message.stored_message
852
+ lease_token = claimed_message.lease_token
853
+ lease_token_hash = _hash_lease_token(lease_token)
854
+
855
+ if lease_token is None and self._heartbeat_interval_seconds is not None:
856
+ if not self._warned_no_lease_for_heartbeat:
857
+ self._warned_no_lease_for_heartbeat = True
858
+ no_lease_token_warning = (
859
+ "Heartbeat is configured but the gateway returned no lease token. "
860
+ "The heartbeat will not run for this message. Ensure the gateway "
861
+ "returns ClaimedMessage from wait_for_message_and_move() when "
862
+ "visibility timeouts are in use."
863
+ )
864
+ logger.warning(no_lease_token_warning)
865
+ warnings.warn(no_lease_token_warning, RuntimeWarning, stacklevel=2)
866
+
867
+ if lease_token is None and self._requires_claimed_message:
868
+ raise GatewayContractError(
869
+ "gateways with visibility timeouts must return ClaimedMessage from "
870
+ "wait_for_message_and_move(); got plain MessageData without a lease token"
871
+ )
842
872
  except Exception as exc:
843
873
  self._emit_event(
844
874
  "claim",
@@ -853,20 +883,21 @@ class RedisMessageQueue:
853
883
  yield None
854
884
  return
855
885
 
856
- if not isinstance(claimed_message, (ClaimedMessage, str, bytes)):
857
- raise GatewayContractError(
858
- f"gateway.wait_for_message_and_move() must return ClaimedMessage, str, bytes, or None; "
859
- f"got {type(claimed_message).__name__}. "
860
- "See AbstractRedisGateway.wait_for_message_and_move for the full contract."
886
+ message_id = None
887
+ try:
888
+ message_id = extract_stored_message_id(stored_message)
889
+ message = decode_stored_message(stored_message)
890
+ except MalformedStoredMessageError as exc:
891
+ self._emit_event(
892
+ "claim",
893
+ "failure",
894
+ message_id=message_id,
895
+ lease_token_hash=lease_token_hash,
896
+ exception_type=type(exc).__name__,
897
+ error=exc,
898
+ duration_ms=_duration_ms(claim_started_at),
861
899
  )
862
-
863
- stored_message = claimed_message
864
- lease_token = None
865
- if isinstance(claimed_message, ClaimedMessage):
866
- stored_message = claimed_message.stored_message
867
- lease_token = claimed_message.lease_token
868
- message_id = extract_stored_message_id(stored_message)
869
- lease_token_hash = _hash_lease_token(lease_token)
900
+ raise
870
901
  self._emit_event(
871
902
  "claim",
872
903
  "success",
@@ -875,25 +906,6 @@ class RedisMessageQueue:
875
906
  duration_ms=_duration_ms(claim_started_at),
876
907
  )
877
908
 
878
- if lease_token is None and self._heartbeat_interval_seconds is not None:
879
- if not self._warned_no_lease_for_heartbeat:
880
- self._warned_no_lease_for_heartbeat = True
881
- no_lease_token_warning = (
882
- "Heartbeat is configured but the gateway returned no lease token. "
883
- "The heartbeat will not run for this message. Ensure the gateway "
884
- "returns ClaimedMessage from wait_for_message_and_move() when "
885
- "visibility timeouts are in use."
886
- )
887
- logger.warning(no_lease_token_warning)
888
- warnings.warn(no_lease_token_warning, RuntimeWarning, stacklevel=2)
889
-
890
- if lease_token is None and self._requires_claimed_message:
891
- raise GatewayContractError(
892
- "gateways with visibility timeouts must return ClaimedMessage from "
893
- "wait_for_message_and_move(); got plain MessageData without a lease token"
894
- )
895
-
896
- message = decode_stored_message(stored_message)
897
909
  lease_heartbeat = self._build_lease_heartbeat(stored_message, lease_token, message_id, lease_token_hash)
898
910
  if lease_heartbeat is not None:
899
911
  lease_heartbeat.start()
@@ -1110,7 +1122,10 @@ class RedisMessageQueue:
1110
1122
  if timeout is not None and timeout < 0:
1111
1123
  raise ConfigurationError(f"'timeout' must be non-negative when provided, got {timeout}")
1112
1124
  with self._drain_lock:
1125
+ cleanup_lease_counter = getattr(self._redis, "_cleanup_drained_lease_token_counter", None)
1113
1126
  if self._drain_result is True:
1127
+ if cleanup_lease_counter is not None:
1128
+ cleanup_lease_counter(self.key.processing)
1114
1129
  return True
1115
1130
 
1116
1131
  with self._publish_lock:
@@ -1118,11 +1133,15 @@ class RedisMessageQueue:
1118
1133
  self._drained.set()
1119
1134
  drainer = getattr(self._redis, "_drain_pending_claim_ids", None)
1120
1135
  if drainer is None:
1136
+ if cleanup_lease_counter is not None:
1137
+ cleanup_lease_counter(self.key.processing)
1121
1138
  self._drain_result = True
1122
1139
  return True
1123
1140
  deadline_monotonic = None if timeout is None else (time.monotonic() + float(timeout))
1124
1141
  result = drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1125
1142
  if result is True:
1143
+ if cleanup_lease_counter is not None:
1144
+ cleanup_lease_counter(self.key.processing)
1126
1145
  self._drain_result = True
1127
1146
  else:
1128
1147
  self._drain_result = None