redis-message-queue 8.2.5__tar.gz → 8.2.7__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 (26) hide show
  1. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/PKG-INFO +12 -7
  2. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/README.md +11 -6
  3. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/pyproject.toml +2 -2
  4. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_redis_gateway.py +65 -11
  5. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/asyncio/_redis_gateway.py +65 -11
  6. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/asyncio/redis_message_queue.py +63 -32
  7. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/redis_message_queue.py +68 -30
  8. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/.gitignore +0 -0
  9. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/LICENSE +0 -0
  10. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/__init__.py +0 -0
  11. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  12. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_callable_utils.py +0 -0
  13. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_config.py +0 -0
  14. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_event.py +0 -0
  15. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_exceptions.py +0 -0
  16. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_payload_limits.py +0 -0
  17. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/_stored_message.py +0 -0
  20. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/asyncio/__init__.py +0 -0
  21. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  22. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {redis_message_queue-8.2.5 → redis_message_queue-8.2.7}/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.2.5
3
+ Version: 8.2.7
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
6
6
  Project-URL: Repository, https://github.com/Elijas/redis-message-queue
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
35
35
 
36
36
  ```bash
37
- pip install "redis-message-queue>=8.2.5,<9.0.0"
37
+ pip install "redis-message-queue>=8.2.7,<9.0.0"
38
38
  ```
39
39
 
40
40
  Requires Redis server >= 6.2.
@@ -46,6 +46,7 @@ Redis must be running locally first: use `redis-server` or
46
46
 
47
47
  ```python
48
48
  import json
49
+ from uuid import uuid4
49
50
  from redis import Redis
50
51
  from redis_message_queue import RedisMessageQueue
51
52
 
@@ -56,7 +57,8 @@ queue = RedisMessageQueue(
56
57
  deduplication=True,
57
58
  get_deduplication_key=lambda msg: msg["id"],
58
59
  )
59
- queue.publish({"id": "msg-1", "text": "hello"})
60
+ message = {"id": f"msg-{uuid4().hex}", "text": "hello"}
61
+ queue.publish(message)
60
62
  with queue.process_message() as message:
61
63
  if message is not None:
62
64
  payload = json.loads(message)
@@ -80,6 +82,7 @@ with queue.process_message() as message:
80
82
  ```python
81
83
  import asyncio
82
84
  import json
85
+ from uuid import uuid4
83
86
  from redis.asyncio import Redis
84
87
  from redis_message_queue.asyncio import RedisMessageQueue
85
88
 
@@ -91,10 +94,12 @@ async def main():
91
94
  deduplication=True,
92
95
  get_deduplication_key=lambda msg: msg["id"],
93
96
  )
94
- await queue.publish({"id": "msg-1", "text": "hello"})
97
+ message = {"id": f"msg-{uuid4().hex}", "text": "hello"}
98
+ await queue.publish(message)
95
99
  async with queue.process_message() as message:
96
- payload = json.loads(message)
97
- print(f"got {payload['text']}")
100
+ if message is not None:
101
+ payload = json.loads(message)
102
+ print(f"got {payload['text']}")
98
103
  await client.aclose()
99
104
 
100
105
  asyncio.run(main()) # Expected output: got hello
@@ -1044,7 +1049,7 @@ created:
1044
1049
  dead-letter handling is required.`
1045
1050
 
1046
1051
  **AC-16: redis-py is capped below 8.0.0.** The package dependency is
1047
- `redis>=5.0.0,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
1052
+ `redis>=5.0.1,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
1048
1053
  Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
1049
1054
  8.0.0 beta explicitly, downgrade with `pip install "redis<8.0.0"`.
1050
1055
 
@@ -11,7 +11,7 @@
11
11
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
12
12
 
13
13
  ```bash
14
- pip install "redis-message-queue>=8.2.5,<9.0.0"
14
+ pip install "redis-message-queue>=8.2.7,<9.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -23,6 +23,7 @@ Redis must be running locally first: use `redis-server` or
23
23
 
24
24
  ```python
25
25
  import json
26
+ from uuid import uuid4
26
27
  from redis import Redis
27
28
  from redis_message_queue import RedisMessageQueue
28
29
 
@@ -33,7 +34,8 @@ queue = RedisMessageQueue(
33
34
  deduplication=True,
34
35
  get_deduplication_key=lambda msg: msg["id"],
35
36
  )
36
- queue.publish({"id": "msg-1", "text": "hello"})
37
+ message = {"id": f"msg-{uuid4().hex}", "text": "hello"}
38
+ queue.publish(message)
37
39
  with queue.process_message() as message:
38
40
  if message is not None:
39
41
  payload = json.loads(message)
@@ -57,6 +59,7 @@ with queue.process_message() as message:
57
59
  ```python
58
60
  import asyncio
59
61
  import json
62
+ from uuid import uuid4
60
63
  from redis.asyncio import Redis
61
64
  from redis_message_queue.asyncio import RedisMessageQueue
62
65
 
@@ -68,10 +71,12 @@ async def main():
68
71
  deduplication=True,
69
72
  get_deduplication_key=lambda msg: msg["id"],
70
73
  )
71
- await queue.publish({"id": "msg-1", "text": "hello"})
74
+ message = {"id": f"msg-{uuid4().hex}", "text": "hello"}
75
+ await queue.publish(message)
72
76
  async with queue.process_message() as message:
73
- payload = json.loads(message)
74
- print(f"got {payload['text']}")
77
+ if message is not None:
78
+ payload = json.loads(message)
79
+ print(f"got {payload['text']}")
75
80
  await client.aclose()
76
81
 
77
82
  asyncio.run(main()) # Expected output: got hello
@@ -1021,7 +1026,7 @@ created:
1021
1026
  dead-letter handling is required.`
1022
1027
 
1023
1028
  **AC-16: redis-py is capped below 8.0.0.** The package dependency is
1024
- `redis>=5.0.0,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
1029
+ `redis>=5.0.1,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
1025
1030
  Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
1026
1031
  8.0.0 beta explicitly, downgrade with `pip install "redis<8.0.0"`.
1027
1032
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-message-queue"
3
- version = "8.2.5"
3
+ version = "8.2.7"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
6
6
  readme = "README.md"
@@ -48,7 +48,7 @@ default-groups = ["dev", "test"]
48
48
  ##############################
49
49
 
50
50
  [tool.bumpversion]
51
- current_version = "8.2.5"
51
+ current_version = "8.2.7"
52
52
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
53
53
  serialize = ["{major}.{minor}.{patch}"]
54
54
  search = "{current_version}"
@@ -291,6 +291,7 @@ class RedisGateway(AbstractRedisGateway):
291
291
  self._pending_overload_policy = pending_overload_policy
292
292
  self._pending_overload_block_timeout_seconds = pending_overload_block_timeout_seconds
293
293
  self._pending_claim_ids: dict[str, list[str]] = {}
294
+ self._in_flight_claim_ids: dict[str, set[str]] = {}
294
295
  self._recovering_claim_ids: dict[str, set[str]] = {}
295
296
  self._pending_claim_ids_lock = threading.Lock()
296
297
  self._drain_pending_claim_ids_lock = threading.Lock()
@@ -715,10 +716,31 @@ class RedisGateway(AbstractRedisGateway):
715
716
  return None
716
717
 
717
718
  pending_claim_id_to_share: str | None = None
719
+ active_claim_id: str | None = None
720
+
721
+ def begin_active_claim(claim_id: str) -> None:
722
+ nonlocal active_claim_id
723
+ if active_claim_id == claim_id:
724
+ return
725
+ if active_claim_id is not None:
726
+ self._finish_in_flight_claim_id(to_queue, active_claim_id)
727
+ self._begin_in_flight_claim_id(to_queue, claim_id)
728
+ active_claim_id = claim_id
729
+
730
+ def finish_active_claim() -> None:
731
+ nonlocal active_claim_id
732
+ if active_claim_id is None:
733
+ return
734
+ self._finish_in_flight_claim_id(to_queue, active_claim_id)
735
+ active_claim_id = None
736
+
718
737
  try:
719
738
  if self._message_wait_interval_seconds == 0:
720
739
  claim_id = uuid.uuid4().hex
721
740
  claim_may_need_recovery = False
741
+ begin_active_claim(claim_id)
742
+ if self._is_interrupted(is_interrupted):
743
+ return None
722
744
  try:
723
745
  claimed_message = claim_message(from_queue, to_queue, claim_id)
724
746
  except Exception as exc:
@@ -768,6 +790,8 @@ class RedisGateway(AbstractRedisGateway):
768
790
  claim_may_need_recovery = False
769
791
  last_retryable_exception: Exception | None = None
770
792
  while True:
793
+ if active_claim_id is None:
794
+ begin_active_claim(claim_id)
771
795
  if self._is_interrupted(is_interrupted):
772
796
  if claim_may_need_recovery:
773
797
  pending_claim_id_to_share = claim_id
@@ -796,6 +820,7 @@ class RedisGateway(AbstractRedisGateway):
796
820
  return claimed_message
797
821
  claim_may_need_recovery = False
798
822
  last_retryable_exception = None
823
+ finish_active_claim()
799
824
  claim_id = uuid.uuid4().hex
800
825
 
801
826
  remaining = deadline - time.monotonic()
@@ -836,6 +861,7 @@ class RedisGateway(AbstractRedisGateway):
836
861
  finally:
837
862
  if pending_claim_id_to_share is not None:
838
863
  self._set_pending_claim_id(to_queue, pending_claim_id_to_share)
864
+ finish_active_claim()
839
865
 
840
866
  def wait_for_message_and_move(self, from_queue: str, to_queue: str) -> ClaimedMessage | MessageData | None:
841
867
  if self._is_interrupted():
@@ -1127,6 +1153,19 @@ class RedisGateway(AbstractRedisGateway):
1127
1153
  if claim_id not in pending_claim_ids:
1128
1154
  pending_claim_ids.append(claim_id)
1129
1155
 
1156
+ def _begin_in_flight_claim_id(self, processing_queue: str, claim_id: str) -> None:
1157
+ with self._pending_claim_ids_lock:
1158
+ self._in_flight_claim_ids.setdefault(processing_queue, set()).add(claim_id)
1159
+
1160
+ def _finish_in_flight_claim_id(self, processing_queue: str, claim_id: str) -> None:
1161
+ with self._pending_claim_ids_lock:
1162
+ in_flight_claim_ids = self._in_flight_claim_ids.get(processing_queue)
1163
+ if in_flight_claim_ids is None:
1164
+ return
1165
+ in_flight_claim_ids.discard(claim_id)
1166
+ if not in_flight_claim_ids:
1167
+ self._in_flight_claim_ids.pop(processing_queue, None)
1168
+
1130
1169
  def _finish_pending_claim_recovery(
1131
1170
  self,
1132
1171
  processing_queue: str,
@@ -1328,15 +1367,29 @@ class RedisGateway(AbstractRedisGateway):
1328
1367
  with self._pending_claim_ids_lock:
1329
1368
  pending = self._pending_claim_ids.get(processing_queue)
1330
1369
  if not pending:
1331
- return True
1332
- recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1333
- claim_id = next(
1334
- (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1335
- None,
1336
- )
1337
- if claim_id is None:
1338
- break
1339
- recovering.add(claim_id)
1370
+ in_flight = self._in_flight_claim_ids.get(processing_queue)
1371
+ if not in_flight:
1372
+ return True
1373
+ claim_id = None
1374
+ else:
1375
+ recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1376
+ claim_id = next(
1377
+ (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1378
+ None,
1379
+ )
1380
+ if claim_id is None:
1381
+ break
1382
+ recovering.add(claim_id)
1383
+ if claim_id is None:
1384
+ if deadline_monotonic is None:
1385
+ time.sleep(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS)
1386
+ else:
1387
+ remaining = deadline_monotonic - time.monotonic()
1388
+ if remaining <= 0:
1389
+ last_error = TimeoutError("drain pending-claim recovery deadline expired")
1390
+ break
1391
+ time.sleep(min(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS, remaining))
1392
+ continue
1340
1393
  clear = False
1341
1394
  try:
1342
1395
  try:
@@ -1385,6 +1438,7 @@ class RedisGateway(AbstractRedisGateway):
1385
1438
  self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
1386
1439
  with self._pending_claim_ids_lock:
1387
1440
  pending = self._pending_claim_ids.get(processing_queue)
1388
- if pending:
1441
+ in_flight = self._in_flight_claim_ids.get(processing_queue)
1442
+ if pending or in_flight:
1389
1443
  self._last_drain_error = last_error
1390
- return not pending
1444
+ return not pending and not in_flight
@@ -265,6 +265,7 @@ class RedisGateway(AbstractRedisGateway):
265
265
  self._pending_overload_policy = pending_overload_policy
266
266
  self._pending_overload_block_timeout_seconds = pending_overload_block_timeout_seconds
267
267
  self._pending_claim_ids: dict[str, list[str]] = {}
268
+ self._in_flight_claim_ids: dict[str, set[str]] = {}
268
269
  self._recovering_claim_ids: dict[str, set[str]] = {}
269
270
  self._pending_claim_ids_lock = threading.Lock()
270
271
  self._drain_pending_claim_ids_lock = asyncio.Lock()
@@ -694,10 +695,31 @@ class RedisGateway(AbstractRedisGateway):
694
695
  return None
695
696
 
696
697
  pending_claim_id_to_share: str | None = None
698
+ active_claim_id: str | None = None
699
+
700
+ def begin_active_claim(claim_id: str) -> None:
701
+ nonlocal active_claim_id
702
+ if active_claim_id == claim_id:
703
+ return
704
+ if active_claim_id is not None:
705
+ self._finish_in_flight_claim_id(to_queue, active_claim_id)
706
+ self._begin_in_flight_claim_id(to_queue, claim_id)
707
+ active_claim_id = claim_id
708
+
709
+ def finish_active_claim() -> None:
710
+ nonlocal active_claim_id
711
+ if active_claim_id is None:
712
+ return
713
+ self._finish_in_flight_claim_id(to_queue, active_claim_id)
714
+ active_claim_id = None
715
+
697
716
  try:
698
717
  if self._message_wait_interval_seconds == 0:
699
718
  claim_id = uuid.uuid4().hex
700
719
  claim_may_need_recovery = False
720
+ begin_active_claim(claim_id)
721
+ if self._is_interrupted(is_interrupted):
722
+ return None
701
723
  try:
702
724
  claimed_message = await claim_message(from_queue, to_queue, claim_id)
703
725
  except Exception as exc:
@@ -748,6 +770,8 @@ class RedisGateway(AbstractRedisGateway):
748
770
  claim_may_need_recovery = False
749
771
  last_retryable_exception: Exception | None = None
750
772
  while True:
773
+ if active_claim_id is None:
774
+ begin_active_claim(claim_id)
751
775
  if self._is_interrupted(is_interrupted):
752
776
  if claim_may_need_recovery:
753
777
  pending_claim_id_to_share = claim_id
@@ -776,6 +800,7 @@ class RedisGateway(AbstractRedisGateway):
776
800
  return claimed_message
777
801
  claim_may_need_recovery = False
778
802
  last_retryable_exception = None
803
+ finish_active_claim()
779
804
  claim_id = uuid.uuid4().hex
780
805
 
781
806
  remaining = deadline - loop.time()
@@ -816,6 +841,7 @@ class RedisGateway(AbstractRedisGateway):
816
841
  finally:
817
842
  if pending_claim_id_to_share is not None:
818
843
  self._set_pending_claim_id(to_queue, pending_claim_id_to_share)
844
+ finish_active_claim()
819
845
 
820
846
  async def wait_for_message_and_move(self, from_queue: str, to_queue: str) -> ClaimedMessage | MessageData | None:
821
847
  if self._is_interrupted():
@@ -1107,6 +1133,19 @@ class RedisGateway(AbstractRedisGateway):
1107
1133
  if claim_id not in pending_claim_ids:
1108
1134
  pending_claim_ids.append(claim_id)
1109
1135
 
1136
+ def _begin_in_flight_claim_id(self, processing_queue: str, claim_id: str) -> None:
1137
+ with self._pending_claim_ids_lock:
1138
+ self._in_flight_claim_ids.setdefault(processing_queue, set()).add(claim_id)
1139
+
1140
+ def _finish_in_flight_claim_id(self, processing_queue: str, claim_id: str) -> None:
1141
+ with self._pending_claim_ids_lock:
1142
+ in_flight_claim_ids = self._in_flight_claim_ids.get(processing_queue)
1143
+ if in_flight_claim_ids is None:
1144
+ return
1145
+ in_flight_claim_ids.discard(claim_id)
1146
+ if not in_flight_claim_ids:
1147
+ self._in_flight_claim_ids.pop(processing_queue, None)
1148
+
1110
1149
  def _finish_pending_claim_recovery(
1111
1150
  self,
1112
1151
  processing_queue: str,
@@ -1308,15 +1347,29 @@ class RedisGateway(AbstractRedisGateway):
1308
1347
  with self._pending_claim_ids_lock:
1309
1348
  pending = self._pending_claim_ids.get(processing_queue)
1310
1349
  if not pending:
1311
- return True
1312
- recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1313
- claim_id = next(
1314
- (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1315
- None,
1316
- )
1317
- if claim_id is None:
1318
- break
1319
- recovering.add(claim_id)
1350
+ in_flight = self._in_flight_claim_ids.get(processing_queue)
1351
+ if not in_flight:
1352
+ return True
1353
+ claim_id = None
1354
+ else:
1355
+ recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1356
+ claim_id = next(
1357
+ (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1358
+ None,
1359
+ )
1360
+ if claim_id is None:
1361
+ break
1362
+ recovering.add(claim_id)
1363
+ if claim_id is None:
1364
+ if deadline_monotonic is None:
1365
+ await asyncio.sleep(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS)
1366
+ else:
1367
+ remaining = deadline_monotonic - loop.time()
1368
+ if remaining <= 0:
1369
+ last_error = TimeoutError("drain pending-claim recovery deadline expired")
1370
+ break
1371
+ await asyncio.sleep(min(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS, remaining))
1372
+ continue
1320
1373
  clear = False
1321
1374
  try:
1322
1375
  try:
@@ -1365,6 +1418,7 @@ class RedisGateway(AbstractRedisGateway):
1365
1418
  self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
1366
1419
  with self._pending_claim_ids_lock:
1367
1420
  pending = self._pending_claim_ids.get(processing_queue)
1368
- if pending:
1421
+ in_flight = self._in_flight_claim_ids.get(processing_queue)
1422
+ if pending or in_flight:
1369
1423
  self._last_drain_error = last_error
1370
- return not pending
1424
+ return not pending and not in_flight
@@ -488,18 +488,22 @@ class _LeaseHeartbeat:
488
488
  if current_task is not None and current_task.cancelling() > 0:
489
489
  raise
490
490
  logger.exception("on_heartbeat_failure callback raised an exception")
491
- warnings.warn(
492
- f"on_heartbeat_failure callback raised {type(exc).__name__}",
493
- RuntimeWarning,
494
- stacklevel=1,
495
- )
491
+ with warnings.catch_warnings():
492
+ warnings.simplefilter("always", RuntimeWarning)
493
+ warnings.warn(
494
+ f"on_heartbeat_failure callback raised {type(exc).__name__}",
495
+ RuntimeWarning,
496
+ stacklevel=1,
497
+ )
496
498
  except Exception as exc:
497
499
  logger.exception("on_heartbeat_failure callback raised an exception")
498
- warnings.warn(
499
- f"on_heartbeat_failure callback raised {type(exc).__name__}",
500
- RuntimeWarning,
501
- stacklevel=1,
502
- )
500
+ with warnings.catch_warnings():
501
+ warnings.simplefilter("always", RuntimeWarning)
502
+ warnings.warn(
503
+ f"on_heartbeat_failure callback raised {type(exc).__name__}",
504
+ RuntimeWarning,
505
+ stacklevel=1,
506
+ )
503
507
 
504
508
  async def _run(self) -> None:
505
509
  try:
@@ -535,13 +539,15 @@ class _LeaseHeartbeat:
535
539
  exception_type=type(exc).__name__,
536
540
  error=exc,
537
541
  )
538
- warnings.warn(
539
- "Failed to renew message lease "
540
- f"({_warning_exception_name(exc)}); message will be reclaimed by another consumer "
541
- "when the visibility timeout expires",
542
- RuntimeWarning,
543
- stacklevel=1,
544
- )
542
+ with warnings.catch_warnings():
543
+ warnings.simplefilter("always", RuntimeWarning)
544
+ warnings.warn(
545
+ "Failed to renew message lease "
546
+ f"({_warning_exception_name(exc)}); message will be reclaimed by another consumer "
547
+ "when the visibility timeout expires",
548
+ RuntimeWarning,
549
+ stacklevel=1,
550
+ )
545
551
  await self._invoke_failure_callback()
546
552
  return
547
553
  if not renewed:
@@ -956,15 +962,22 @@ class RedisMessageQueue:
956
962
  pending_claim_ids = getattr(self._redis, "_pending_claim_ids", None)
957
963
  if not isinstance(pending_claim_ids, dict):
958
964
  return None
965
+ in_flight_claim_ids = getattr(self._redis, "_in_flight_claim_ids", None)
959
966
 
960
967
  lock = getattr(self._redis, "_pending_claim_ids_lock", None)
961
968
  if lock is None:
962
969
  pending = pending_claim_ids.get(self.key.processing)
963
- return len(pending) if pending is not None else 0
970
+ in_flight = in_flight_claim_ids.get(self.key.processing) if isinstance(in_flight_claim_ids, dict) else None
971
+ pending_count = len(pending) if pending is not None else 0
972
+ in_flight_count = len(in_flight) if in_flight is not None else 0
973
+ return pending_count + in_flight_count
964
974
 
965
975
  with lock:
966
976
  pending = pending_claim_ids.get(self.key.processing)
967
- return len(pending) if pending is not None else 0
977
+ in_flight = in_flight_claim_ids.get(self.key.processing) if isinstance(in_flight_claim_ids, dict) else None
978
+ pending_count = len(pending) if pending is not None else 0
979
+ in_flight_count = len(in_flight) if in_flight is not None else 0
980
+ return pending_count + in_flight_count
968
981
 
969
982
  def _drain_failure_error(self, timeout_seconds: float | None, pending_claim_ids: int | None) -> BaseException:
970
983
  last_drain_error = getattr(self._redis, "_last_drain_error", None)
@@ -1059,9 +1072,23 @@ class RedisMessageQueue:
1059
1072
  "See AbstractRedisGateway.add_message for the full contract."
1060
1073
  )
1061
1074
  else:
1062
- dedup_key = self._get_deduplication_key(message)
1063
- if inspect.isawaitable(dedup_key):
1064
- dedup_key = await dedup_key
1075
+ try:
1076
+ dedup_key = self._get_deduplication_key(message)
1077
+ if inspect.isawaitable(dedup_key):
1078
+ dedup_key = await dedup_key
1079
+ except asyncio.CancelledError as exc:
1080
+ current_task = asyncio.current_task()
1081
+ if current_task is not None and current_task.cancelling() > 0:
1082
+ raise
1083
+ _set_exception_context(exc, queue=self._queue_name, operation="publish")
1084
+ await self._emit_event(
1085
+ "publish",
1086
+ "failure",
1087
+ exception_type=type(exc).__name__,
1088
+ error=exc,
1089
+ duration_ms=_duration_ms(started_at),
1090
+ )
1091
+ raise
1065
1092
  dedup_key = validate_callable_deduplication_key(dedup_key, message)
1066
1093
  dedup_key = self.key.deduplication(dedup_key)
1067
1094
  result = await self._redis.publish_message(self.key.pending, message_str, dedup_key)
@@ -1466,16 +1493,20 @@ class RedisMessageQueue:
1466
1493
  async with self._aclose_lock:
1467
1494
  cleanup_lease_counter = getattr(self._redis, "_cleanup_drained_lease_token_counter", None)
1468
1495
  if self._aclose_result is not None:
1469
- if cleanup_lease_counter is not None:
1470
- await _await_preserving_cancellation(cleanup_lease_counter(self.key.processing))
1471
- await self._emit_event(
1472
- "drain",
1473
- "skipped",
1474
- duration_ms=_duration_ms(started_at),
1475
- timeout_seconds=timeout_seconds,
1476
- pending_claim_ids=self._pending_claim_ids_count(),
1477
- )
1478
- return self._aclose_result
1496
+ pending_claim_ids = self._pending_claim_ids_count()
1497
+ if pending_claim_ids:
1498
+ self._aclose_result = None
1499
+ else:
1500
+ if cleanup_lease_counter is not None:
1501
+ await _await_preserving_cancellation(cleanup_lease_counter(self.key.processing))
1502
+ await self._emit_event(
1503
+ "drain",
1504
+ "skipped",
1505
+ duration_ms=_duration_ms(started_at),
1506
+ timeout_seconds=timeout_seconds,
1507
+ pending_claim_ids=pending_claim_ids,
1508
+ )
1509
+ return self._aclose_result
1479
1510
 
1480
1511
  async with self._publish_lock:
1481
1512
  self._draining = True
@@ -80,6 +80,14 @@ def _duration_ms(started_at: float) -> float:
80
80
  return (time.perf_counter() - started_at) * 1000
81
81
 
82
82
 
83
+ def _current_async_task_is_cancelling() -> bool:
84
+ try:
85
+ current_task = asyncio.current_task()
86
+ except RuntimeError:
87
+ return False
88
+ return current_task is not None and current_task.cancelling() > 0
89
+
90
+
83
91
  def _hash_lease_token(lease_token: str | None) -> str | None:
84
92
  if lease_token is None:
85
93
  return None
@@ -447,18 +455,22 @@ class _LeaseHeartbeat:
447
455
  )
448
456
  except asyncio.CancelledError as exc:
449
457
  logger.exception("on_heartbeat_failure callback raised an exception")
450
- warnings.warn(
451
- f"on_heartbeat_failure callback raised {type(exc).__name__}",
452
- RuntimeWarning,
453
- stacklevel=1,
454
- )
458
+ with warnings.catch_warnings():
459
+ warnings.simplefilter("always", RuntimeWarning)
460
+ warnings.warn(
461
+ f"on_heartbeat_failure callback raised {type(exc).__name__}",
462
+ RuntimeWarning,
463
+ stacklevel=1,
464
+ )
455
465
  except Exception as exc:
456
466
  logger.exception("on_heartbeat_failure callback raised an exception")
457
- warnings.warn(
458
- f"on_heartbeat_failure callback raised {type(exc).__name__}",
459
- RuntimeWarning,
460
- stacklevel=1,
461
- )
467
+ with warnings.catch_warnings():
468
+ warnings.simplefilter("always", RuntimeWarning)
469
+ warnings.warn(
470
+ f"on_heartbeat_failure callback raised {type(exc).__name__}",
471
+ RuntimeWarning,
472
+ stacklevel=1,
473
+ )
462
474
 
463
475
  def _run(self) -> None:
464
476
  # No explicit _is_interrupted() check here. Heartbeat lifetime is owned
@@ -488,13 +500,15 @@ class _LeaseHeartbeat:
488
500
  exception_type=type(exc).__name__,
489
501
  error=exc,
490
502
  )
491
- warnings.warn(
492
- "Failed to renew message lease "
493
- f"({_warning_exception_name(exc)}); message will be reclaimed by another consumer "
494
- "when the visibility timeout expires",
495
- RuntimeWarning,
496
- stacklevel=1,
497
- )
503
+ with warnings.catch_warnings():
504
+ warnings.simplefilter("always", RuntimeWarning)
505
+ warnings.warn(
506
+ "Failed to renew message lease "
507
+ f"({_warning_exception_name(exc)}); message will be reclaimed by another consumer "
508
+ "when the visibility timeout expires",
509
+ RuntimeWarning,
510
+ stacklevel=1,
511
+ )
498
512
  self._invoke_failure_callback()
499
513
  return
500
514
  if not renewed:
@@ -914,15 +928,22 @@ class RedisMessageQueue:
914
928
  pending_claim_ids = getattr(self._redis, "_pending_claim_ids", None)
915
929
  if not isinstance(pending_claim_ids, dict):
916
930
  return None
931
+ in_flight_claim_ids = getattr(self._redis, "_in_flight_claim_ids", None)
917
932
 
918
933
  lock = getattr(self._redis, "_pending_claim_ids_lock", None)
919
934
  if lock is None:
920
935
  pending = pending_claim_ids.get(self.key.processing)
921
- return len(pending) if pending is not None else 0
936
+ in_flight = in_flight_claim_ids.get(self.key.processing) if isinstance(in_flight_claim_ids, dict) else None
937
+ pending_count = len(pending) if pending is not None else 0
938
+ in_flight_count = len(in_flight) if in_flight is not None else 0
939
+ return pending_count + in_flight_count
922
940
 
923
941
  with lock:
924
942
  pending = pending_claim_ids.get(self.key.processing)
925
- return len(pending) if pending is not None else 0
943
+ in_flight = in_flight_claim_ids.get(self.key.processing) if isinstance(in_flight_claim_ids, dict) else None
944
+ pending_count = len(pending) if pending is not None else 0
945
+ in_flight_count = len(in_flight) if in_flight is not None else 0
946
+ return pending_count + in_flight_count
926
947
 
927
948
  def _drain_failure_error(self, timeout_seconds: float | None, pending_claim_ids: int | None) -> BaseException:
928
949
  last_drain_error = getattr(self._redis, "_last_drain_error", None)
@@ -1004,7 +1025,20 @@ class RedisMessageQueue:
1004
1025
  "See AbstractRedisGateway.add_message for the full contract."
1005
1026
  )
1006
1027
  else:
1007
- dedup_key = self._get_deduplication_key(message)
1028
+ try:
1029
+ dedup_key = self._get_deduplication_key(message)
1030
+ except asyncio.CancelledError as exc:
1031
+ if _current_async_task_is_cancelling():
1032
+ raise
1033
+ _set_exception_context(exc, queue=self._queue_name, operation="publish")
1034
+ self._emit_event(
1035
+ "publish",
1036
+ "failure",
1037
+ exception_type=type(exc).__name__,
1038
+ error=exc,
1039
+ duration_ms=_duration_ms(started_at),
1040
+ )
1041
+ raise
1008
1042
  if inspect.isawaitable(dedup_key):
1009
1043
  is_coroutine = inspect.iscoroutine(dedup_key)
1010
1044
  _close_or_cancel_awaitable(dedup_key)
@@ -1426,16 +1460,20 @@ class RedisMessageQueue:
1426
1460
  with self._drain_lock:
1427
1461
  cleanup_lease_counter = getattr(self._redis, "_cleanup_drained_lease_token_counter", None)
1428
1462
  if self._drain_result is True:
1429
- if cleanup_lease_counter is not None:
1430
- cleanup_lease_counter(self.key.processing)
1431
- self._emit_event(
1432
- "drain",
1433
- "skipped",
1434
- duration_ms=_duration_ms(started_at),
1435
- timeout_seconds=timeout_seconds,
1436
- pending_claim_ids=self._pending_claim_ids_count(),
1437
- )
1438
- return True
1463
+ pending_claim_ids = self._pending_claim_ids_count()
1464
+ if pending_claim_ids:
1465
+ self._drain_result = None
1466
+ else:
1467
+ if cleanup_lease_counter is not None:
1468
+ cleanup_lease_counter(self.key.processing)
1469
+ self._emit_event(
1470
+ "drain",
1471
+ "skipped",
1472
+ duration_ms=_duration_ms(started_at),
1473
+ timeout_seconds=timeout_seconds,
1474
+ pending_claim_ids=pending_claim_ids,
1475
+ )
1476
+ return True
1439
1477
 
1440
1478
  with self._publish_lock:
1441
1479
  self._draining = True