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.
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/PKG-INFO +1 -1
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/pyproject.toml +1 -1
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/__init__.py +2 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_config.py +79 -25
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_event.py +4 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_exceptions.py +4 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_gateway.py +65 -7
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_stored_message.py +49 -30
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/__init__.py +2 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_redis_gateway.py +65 -7
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/redis_message_queue.py +94 -77
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/redis_message_queue.py +104 -85
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/LICENSE +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/README.md +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_queue_key_manager.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/py.typed +0 -0
|
@@ -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
|
|
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,
|
|
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 {'', '',
|
|
860
|
+
return {'', '', reclaimed_events, dead_lettered_events}
|
|
839
861
|
end
|
|
840
862
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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 {'', '',
|
|
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
|
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
286
|
-
self._emit_event(
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
self._emit_repeated_event("claim_reclaim",
|
|
845
|
-
self._emit_repeated_event(
|
|
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,
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_stored_message.py
RENAMED
|
@@ -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
|
-
|
|
55
|
-
if
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
return None
|
|
78
|
+
message_id, _payload = envelope
|
|
79
|
+
return message_id
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
def
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
91
|
-
except json.JSONDecodeError:
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
|
|
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
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/asyncio/__init__.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
260
|
-
await self._emit_event(
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
await self._emit_repeated_event("claim_reclaim",
|
|
825
|
-
await self._emit_repeated_event(
|
|
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
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_queue_key_manager.py
RENAMED
|
File without changes
|
{redis_message_queue-8.0.1 → redis_message_queue-8.0.2}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|