redis-message-queue 8.0.0__tar.gz → 8.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/PKG-INFO +2 -2
  2. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/README.md +1 -1
  3. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/pyproject.toml +1 -1
  4. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_redis_gateway.py +157 -25
  5. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/asyncio/_redis_gateway.py +140 -24
  6. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/asyncio/redis_message_queue.py +49 -8
  7. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/redis_message_queue.py +49 -9
  8. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/LICENSE +0 -0
  9. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/__init__.py +0 -0
  10. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  11. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_callable_utils.py +0 -0
  12. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_config.py +0 -0
  13. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_event.py +0 -0
  14. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_exceptions.py +0 -0
  15. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_queue_key_manager.py +0 -0
  16. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_redis_cluster.py +0 -0
  17. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-8.0.0 → redis_message_queue-8.0.1}/redis_message_queue/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 8.0.0
3
+ Version: 8.0.1
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # redis-message-queue
28
28
 
29
- [![PyPI Version](https://img.shields.io/badge/v8.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v8.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
30
30
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
31
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
32
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v8.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v8.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
4
4
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
6
6
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "8.0.0"
3
+ version = "8.0.1"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
6
6
  readme = "README.md"
@@ -1,10 +1,11 @@
1
1
  import json
2
2
  import logging
3
+ import queue
3
4
  import random
4
5
  import threading
5
6
  import time
6
7
  import uuid
7
- from typing import Callable, Optional, TypeVar
8
+ from typing import Callable, Optional, TypeVar, cast
8
9
 
9
10
  import redis
10
11
  import redis.asyncio
@@ -54,6 +55,7 @@ from redis_message_queue.interrupt_handler._interface import (
54
55
 
55
56
  logger = logging.getLogger(__name__)
56
57
  _TClaim = TypeVar("_TClaim", bound=ClaimedMessage | MessageData)
58
+ _TRedisCall = TypeVar("_TRedisCall")
57
59
 
58
60
  _LEASE_DEADLINES_SUFFIX = ":lease_deadlines"
59
61
  _LEASE_TOKENS_SUFFIX = ":lease_tokens"
@@ -71,6 +73,49 @@ _PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
71
73
  _PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
72
74
 
73
75
 
76
+ class _DrainDeadlineExceeded(Exception):
77
+ """Internal sentinel for drain-only pending-claim recovery deadlines."""
78
+
79
+
80
+ def _raise_if_drain_deadline_expired(deadline_monotonic: float | None) -> None:
81
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
82
+ raise _DrainDeadlineExceeded
83
+
84
+
85
+ def _call_with_drain_deadline(
86
+ call: Callable[[], _TRedisCall],
87
+ *,
88
+ deadline_monotonic: float | None,
89
+ ) -> _TRedisCall:
90
+ if deadline_monotonic is None:
91
+ return call()
92
+
93
+ remaining_seconds = deadline_monotonic - time.monotonic()
94
+ if remaining_seconds <= 0:
95
+ raise _DrainDeadlineExceeded
96
+
97
+ result_queue: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
98
+
99
+ def run_call() -> None:
100
+ try:
101
+ result = call()
102
+ except Exception as exc:
103
+ result_queue.put_nowait((False, exc))
104
+ else:
105
+ result_queue.put_nowait((True, result))
106
+
107
+ thread = threading.Thread(target=run_call, daemon=True)
108
+ thread.start()
109
+ thread.join(timeout=remaining_seconds)
110
+ if thread.is_alive():
111
+ raise _DrainDeadlineExceeded
112
+
113
+ succeeded, value = result_queue.get_nowait()
114
+ if succeeded:
115
+ return cast(_TRedisCall, value)
116
+ raise cast(BaseException, value)
117
+
118
+
74
119
  def _coerce_lua_count(value: object) -> int:
75
120
  if isinstance(value, bytes):
76
121
  value = value.decode("utf-8")
@@ -525,6 +570,7 @@ class RedisGateway(AbstractRedisGateway):
525
570
  claim_message: Callable[[str, str, str], _TClaim | None],
526
571
  non_blocking_retry_log: str,
527
572
  polling_retry_log: str,
573
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
528
574
  ) -> _TClaim | None:
529
575
  while True:
530
576
  pending_claim_id = self._acquire_pending_claim_id(to_queue)
@@ -552,7 +598,7 @@ class RedisGateway(AbstractRedisGateway):
552
598
  self._emit_event("claim_reclaim", "success", claim_id=pending_claim_id)
553
599
  return recovered_claim
554
600
 
555
- if self._is_interrupted():
601
+ if self._is_interrupted(is_interrupted):
556
602
  return None
557
603
 
558
604
  pending_claim_id_to_share: str | None = None
@@ -575,7 +621,7 @@ class RedisGateway(AbstractRedisGateway):
575
621
  error=exc,
576
622
  )
577
623
  logger.warning(non_blocking_retry_log, type(exc).__name__)
578
- if self._is_interrupted():
624
+ if self._is_interrupted(is_interrupted):
579
625
  pending_claim_id_to_share = claim_id
580
626
  return None
581
627
  try:
@@ -607,7 +653,7 @@ class RedisGateway(AbstractRedisGateway):
607
653
  claim_may_need_recovery = False
608
654
  last_retryable_exception: Exception | None = None
609
655
  while True:
610
- if self._is_interrupted():
656
+ if self._is_interrupted(is_interrupted):
611
657
  if claim_may_need_recovery:
612
658
  pending_claim_id_to_share = claim_id
613
659
  return None
@@ -640,7 +686,7 @@ class RedisGateway(AbstractRedisGateway):
640
686
  remaining = deadline - time.monotonic()
641
687
  if remaining <= 0:
642
688
  if last_retryable_exception is not None:
643
- if self._is_interrupted():
689
+ if self._is_interrupted(is_interrupted):
644
690
  if claim_may_need_recovery:
645
691
  pending_claim_id_to_share = claim_id
646
692
  return None
@@ -675,11 +721,36 @@ class RedisGateway(AbstractRedisGateway):
675
721
  def wait_for_message_and_move(self, from_queue: str, to_queue: str) -> ClaimedMessage | MessageData | None:
676
722
  if self._is_interrupted():
677
723
  return None
724
+ return self._wait_for_message_and_move_interruptible(from_queue, to_queue)
725
+
726
+ def _wait_for_message_and_move_interruptible(
727
+ self,
728
+ from_queue: str,
729
+ to_queue: str,
730
+ *,
731
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
732
+ ) -> ClaimedMessage | MessageData | None:
733
+ if self._is_interrupted(is_interrupted):
734
+ return None
678
735
  if self._message_visibility_timeout_seconds is not None:
679
- return self._wait_for_message_with_visibility_timeout(from_queue, to_queue)
680
- return self._wait_for_message_without_visibility_timeout(from_queue, to_queue)
736
+ return self._wait_for_message_with_visibility_timeout(
737
+ from_queue,
738
+ to_queue,
739
+ is_interrupted=is_interrupted,
740
+ )
741
+ return self._wait_for_message_without_visibility_timeout(
742
+ from_queue,
743
+ to_queue,
744
+ is_interrupted=is_interrupted,
745
+ )
681
746
 
682
- def _wait_for_message_without_visibility_timeout(self, from_queue: str, to_queue: str) -> MessageData | None:
747
+ def _wait_for_message_without_visibility_timeout(
748
+ self,
749
+ from_queue: str,
750
+ to_queue: str,
751
+ *,
752
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
753
+ ) -> MessageData | None:
683
754
  return self._wait_for_claim(
684
755
  from_queue,
685
756
  to_queue,
@@ -693,9 +764,16 @@ class RedisGateway(AbstractRedisGateway):
693
764
  "Transient error during non-visibility-timeout non-blocking claim, retrying once to recover claim: %s"
694
765
  ),
695
766
  polling_retry_log="Transient error during non-visibility-timeout claim poll, will retry: %s",
767
+ is_interrupted=is_interrupted,
696
768
  )
697
769
 
698
- def _wait_for_message_with_visibility_timeout(self, from_queue: str, to_queue: str) -> ClaimedMessage | None:
770
+ def _wait_for_message_with_visibility_timeout(
771
+ self,
772
+ from_queue: str,
773
+ to_queue: str,
774
+ *,
775
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
776
+ ) -> ClaimedMessage | None:
699
777
  return self._wait_for_claim(
700
778
  from_queue,
701
779
  to_queue,
@@ -709,6 +787,7 @@ class RedisGateway(AbstractRedisGateway):
709
787
  "Transient error during visibility-timeout non-blocking claim, retrying once to recover claim: %s"
710
788
  ),
711
789
  polling_retry_log="Transient error during visibility-timeout claim poll, will retry: %s",
790
+ is_interrupted=is_interrupted,
712
791
  )
713
792
 
714
793
  def _claim_message_without_visibility_timeout(
@@ -833,16 +912,37 @@ class RedisGateway(AbstractRedisGateway):
833
912
  def _claim_result_ttl_ms(self) -> str:
834
913
  return str(max(self._message_wait_interval_seconds, 120) * 1000)
835
914
 
836
- def _delete_claim_result_key(self, claim_result_key: str) -> None:
915
+ def _delete_claim_result_key(
916
+ self,
917
+ claim_result_key: str,
918
+ *,
919
+ deadline_monotonic: float | None = None,
920
+ ) -> None:
837
921
  try:
838
- self._redis_client.delete(claim_result_key)
922
+ _call_with_drain_deadline(
923
+ lambda: self._redis_client.delete(claim_result_key),
924
+ deadline_monotonic=deadline_monotonic,
925
+ )
926
+ except _DrainDeadlineExceeded:
927
+ raise
839
928
  except Exception:
840
929
  # Claim-result keys have bounded TTLs; this cleanup is intentionally best-effort.
841
930
  logger.warning("Failed to delete claim result key %s", claim_result_key, exc_info=True)
842
931
 
843
- def _delete_claim_result_ref(self, claim_result_refs_key: str, lease_token: str) -> None:
932
+ def _delete_claim_result_ref(
933
+ self,
934
+ claim_result_refs_key: str,
935
+ lease_token: str,
936
+ *,
937
+ deadline_monotonic: float | None = None,
938
+ ) -> None:
844
939
  try:
845
- self._redis_client.hdel(claim_result_refs_key, lease_token)
940
+ _call_with_drain_deadline(
941
+ lambda: self._redis_client.hdel(claim_result_refs_key, lease_token),
942
+ deadline_monotonic=deadline_monotonic,
943
+ )
944
+ except _DrainDeadlineExceeded:
945
+ raise
846
946
  except Exception:
847
947
  # Claim-result refs have bounded TTLs; this cleanup is intentionally best-effort.
848
948
  logger.warning(
@@ -906,25 +1006,48 @@ class RedisGateway(AbstractRedisGateway):
906
1006
  self,
907
1007
  processing_queue: str,
908
1008
  claim_id: str,
1009
+ *,
1010
+ deadline_monotonic: float | None = None,
909
1011
  ) -> MessageData | None:
1012
+ _raise_if_drain_deadline_expired(deadline_monotonic)
910
1013
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
911
- cached_claim = self._redis_client.get(claim_result_key)
1014
+ cached_claim = _call_with_drain_deadline(
1015
+ lambda: self._redis_client.get(claim_result_key),
1016
+ deadline_monotonic=deadline_monotonic,
1017
+ )
1018
+ _raise_if_drain_deadline_expired(deadline_monotonic)
912
1019
  if cached_claim is None:
913
- cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1020
+ cached_claim = _call_with_drain_deadline(
1021
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1022
+ deadline_monotonic=deadline_monotonic,
1023
+ )
1024
+ _raise_if_drain_deadline_expired(deadline_monotonic)
914
1025
  if cached_claim is None:
915
1026
  return None
916
- self._delete_claim_result_key(claim_result_key)
1027
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1028
+ _raise_if_drain_deadline_expired(deadline_monotonic)
917
1029
  return cached_claim
918
1030
 
919
1031
  def _recover_pending_visibility_timeout_claim(
920
1032
  self,
921
1033
  processing_queue: str,
922
1034
  claim_id: str,
1035
+ *,
1036
+ deadline_monotonic: float | None = None,
923
1037
  ) -> ClaimedMessage | None:
1038
+ _raise_if_drain_deadline_expired(deadline_monotonic)
924
1039
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
925
- cached_claim = self._redis_client.get(claim_result_key)
1040
+ cached_claim = _call_with_drain_deadline(
1041
+ lambda: self._redis_client.get(claim_result_key),
1042
+ deadline_monotonic=deadline_monotonic,
1043
+ )
1044
+ _raise_if_drain_deadline_expired(deadline_monotonic)
926
1045
  if cached_claim is None:
927
- cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1046
+ cached_claim = _call_with_drain_deadline(
1047
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1048
+ deadline_monotonic=deadline_monotonic,
1049
+ )
1050
+ _raise_if_drain_deadline_expired(deadline_monotonic)
928
1051
  if cached_claim is None:
929
1052
  return None
930
1053
 
@@ -932,7 +1055,7 @@ class RedisGateway(AbstractRedisGateway):
932
1055
  try:
933
1056
  claim = json.loads(cached_claim_text)
934
1057
  except json.JSONDecodeError:
935
- self._delete_claim_result_key(claim_result_key)
1058
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
936
1059
  return None
937
1060
 
938
1061
  if (
@@ -941,7 +1064,7 @@ class RedisGateway(AbstractRedisGateway):
941
1064
  or not isinstance(claim[0], str)
942
1065
  or not isinstance(claim[1], str)
943
1066
  ):
944
- self._delete_claim_result_key(claim_result_key)
1067
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
945
1068
  return None
946
1069
 
947
1070
  stored_message: MessageData = claim[0]
@@ -949,12 +1072,19 @@ class RedisGateway(AbstractRedisGateway):
949
1072
  stored_message = stored_message.encode("utf-8")
950
1073
  lease_token = claim[1]
951
1074
 
952
- self._delete_claim_result_key(claim_result_key)
953
- self._delete_claim_result_ref(self._claim_result_refs_key(processing_queue), lease_token)
1075
+ self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1076
+ self._delete_claim_result_ref(
1077
+ self._claim_result_refs_key(processing_queue),
1078
+ lease_token,
1079
+ deadline_monotonic=deadline_monotonic,
1080
+ )
1081
+ _raise_if_drain_deadline_expired(deadline_monotonic)
954
1082
  return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
955
1083
 
956
- def _is_interrupted(self) -> bool:
957
- return self._interrupt is not None and self._interrupt.is_interrupted()
1084
+ def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
1085
+ return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
1086
+ is_interrupted is not None and is_interrupted.is_interrupted()
1087
+ )
958
1088
 
959
1089
  def _drain_pending_claim_ids(
960
1090
  self,
@@ -1010,8 +1140,10 @@ class RedisGateway(AbstractRedisGateway):
1010
1140
  clear = False
1011
1141
  try:
1012
1142
  try:
1013
- recover(processing_queue, claim_id)
1143
+ recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1014
1144
  clear = True
1145
+ except _DrainDeadlineExceeded:
1146
+ break
1015
1147
  except Exception as exc:
1016
1148
  if not is_redis_retryable_exception(exc):
1017
1149
  raise
@@ -54,6 +54,7 @@ from redis_message_queue.interrupt_handler._interface import (
54
54
 
55
55
  logger = logging.getLogger(__name__)
56
56
  _TClaim = TypeVar("_TClaim", bound=ClaimedMessage | MessageData)
57
+ _TRedisCall = TypeVar("_TRedisCall")
57
58
 
58
59
  _LEASE_DEADLINES_SUFFIX = ":lease_deadlines"
59
60
  _LEASE_TOKENS_SUFFIX = ":lease_tokens"
@@ -71,6 +72,33 @@ _PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
71
72
  _PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
72
73
 
73
74
 
75
+ class _DrainDeadlineExceeded(Exception):
76
+ """Internal sentinel for drain-only pending-claim recovery deadlines."""
77
+
78
+
79
+ def _raise_if_drain_deadline_expired(deadline_monotonic: float | None) -> None:
80
+ if deadline_monotonic is not None and asyncio.get_running_loop().time() >= deadline_monotonic:
81
+ raise _DrainDeadlineExceeded
82
+
83
+
84
+ async def _call_with_drain_deadline(
85
+ call: Callable[[], Awaitable[_TRedisCall]],
86
+ *,
87
+ deadline_monotonic: float | None,
88
+ ) -> _TRedisCall:
89
+ if deadline_monotonic is None:
90
+ return await call()
91
+
92
+ remaining_seconds = deadline_monotonic - asyncio.get_running_loop().time()
93
+ if remaining_seconds <= 0:
94
+ raise _DrainDeadlineExceeded
95
+
96
+ try:
97
+ return await asyncio.wait_for(call(), timeout=remaining_seconds)
98
+ except TimeoutError as exc:
99
+ raise _DrainDeadlineExceeded from exc
100
+
101
+
74
102
  def _coerce_lua_count(value: object) -> int:
75
103
  if isinstance(value, bytes):
76
104
  value = value.decode("utf-8")
@@ -521,6 +549,7 @@ class RedisGateway(AbstractRedisGateway):
521
549
  claim_message: Callable[[str, str, str], Awaitable[_TClaim | None]],
522
550
  non_blocking_retry_log: str,
523
551
  polling_retry_log: str,
552
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
524
553
  ) -> _TClaim | None:
525
554
  while True:
526
555
  pending_claim_id = self._acquire_pending_claim_id(to_queue)
@@ -548,7 +577,7 @@ class RedisGateway(AbstractRedisGateway):
548
577
  await self._emit_event("claim_reclaim", "success", claim_id=pending_claim_id)
549
578
  return recovered_claim
550
579
 
551
- if self._is_interrupted():
580
+ if self._is_interrupted(is_interrupted):
552
581
  return None
553
582
 
554
583
  pending_claim_id_to_share: str | None = None
@@ -571,7 +600,7 @@ class RedisGateway(AbstractRedisGateway):
571
600
  error=exc,
572
601
  )
573
602
  logger.warning(non_blocking_retry_log, type(exc).__name__)
574
- if self._is_interrupted():
603
+ if self._is_interrupted(is_interrupted):
575
604
  pending_claim_id_to_share = claim_id
576
605
  return None
577
606
  try:
@@ -604,7 +633,7 @@ class RedisGateway(AbstractRedisGateway):
604
633
  claim_may_need_recovery = False
605
634
  last_retryable_exception: Exception | None = None
606
635
  while True:
607
- if self._is_interrupted():
636
+ if self._is_interrupted(is_interrupted):
608
637
  if claim_may_need_recovery:
609
638
  pending_claim_id_to_share = claim_id
610
639
  return None
@@ -637,7 +666,7 @@ class RedisGateway(AbstractRedisGateway):
637
666
  remaining = deadline - loop.time()
638
667
  if remaining <= 0:
639
668
  if last_retryable_exception is not None:
640
- if self._is_interrupted():
669
+ if self._is_interrupted(is_interrupted):
641
670
  if claim_may_need_recovery:
642
671
  pending_claim_id_to_share = claim_id
643
672
  return None
@@ -672,11 +701,36 @@ class RedisGateway(AbstractRedisGateway):
672
701
  async def wait_for_message_and_move(self, from_queue: str, to_queue: str) -> ClaimedMessage | MessageData | None:
673
702
  if self._is_interrupted():
674
703
  return None
704
+ return await self._wait_for_message_and_move_interruptible(from_queue, to_queue)
705
+
706
+ async def _wait_for_message_and_move_interruptible(
707
+ self,
708
+ from_queue: str,
709
+ to_queue: str,
710
+ *,
711
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
712
+ ) -> ClaimedMessage | MessageData | None:
713
+ if self._is_interrupted(is_interrupted):
714
+ return None
675
715
  if self._message_visibility_timeout_seconds is not None:
676
- return await self._wait_for_message_with_visibility_timeout(from_queue, to_queue)
677
- return await self._wait_for_message_without_visibility_timeout(from_queue, to_queue)
716
+ return await self._wait_for_message_with_visibility_timeout(
717
+ from_queue,
718
+ to_queue,
719
+ is_interrupted=is_interrupted,
720
+ )
721
+ return await self._wait_for_message_without_visibility_timeout(
722
+ from_queue,
723
+ to_queue,
724
+ is_interrupted=is_interrupted,
725
+ )
678
726
 
679
- async def _wait_for_message_without_visibility_timeout(self, from_queue: str, to_queue: str) -> MessageData | None:
727
+ async def _wait_for_message_without_visibility_timeout(
728
+ self,
729
+ from_queue: str,
730
+ to_queue: str,
731
+ *,
732
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
733
+ ) -> MessageData | None:
680
734
  return await self._wait_for_claim(
681
735
  from_queue,
682
736
  to_queue,
@@ -690,9 +744,16 @@ class RedisGateway(AbstractRedisGateway):
690
744
  "Transient error during non-visibility-timeout non-blocking claim, retrying once to recover claim: %s"
691
745
  ),
692
746
  polling_retry_log="Transient error during non-visibility-timeout claim poll, will retry: %s",
747
+ is_interrupted=is_interrupted,
693
748
  )
694
749
 
695
- async def _wait_for_message_with_visibility_timeout(self, from_queue: str, to_queue: str) -> ClaimedMessage | None:
750
+ async def _wait_for_message_with_visibility_timeout(
751
+ self,
752
+ from_queue: str,
753
+ to_queue: str,
754
+ *,
755
+ is_interrupted: BaseGracefulInterruptHandler | None = None,
756
+ ) -> ClaimedMessage | None:
696
757
  return await self._wait_for_claim(
697
758
  from_queue,
698
759
  to_queue,
@@ -706,6 +767,7 @@ class RedisGateway(AbstractRedisGateway):
706
767
  "Transient error during visibility-timeout non-blocking claim, retrying once to recover claim: %s"
707
768
  ),
708
769
  polling_retry_log="Transient error during visibility-timeout claim poll, will retry: %s",
770
+ is_interrupted=is_interrupted,
709
771
  )
710
772
 
711
773
  async def _claim_message_without_visibility_timeout(
@@ -830,16 +892,37 @@ class RedisGateway(AbstractRedisGateway):
830
892
  def _claim_result_ttl_ms(self) -> str:
831
893
  return str(max(self._message_wait_interval_seconds, 120) * 1000)
832
894
 
833
- async def _delete_claim_result_key(self, claim_result_key: str) -> None:
895
+ async def _delete_claim_result_key(
896
+ self,
897
+ claim_result_key: str,
898
+ *,
899
+ deadline_monotonic: float | None = None,
900
+ ) -> None:
834
901
  try:
835
- await self._redis_client.delete(claim_result_key)
902
+ await _call_with_drain_deadline(
903
+ lambda: self._redis_client.delete(claim_result_key),
904
+ deadline_monotonic=deadline_monotonic,
905
+ )
906
+ except _DrainDeadlineExceeded:
907
+ raise
836
908
  except Exception:
837
909
  # Claim-result keys have bounded TTLs; this cleanup is intentionally best-effort.
838
910
  logger.warning("Failed to delete claim result key %s", claim_result_key, exc_info=True)
839
911
 
840
- async def _delete_claim_result_ref(self, claim_result_refs_key: str, lease_token: str) -> None:
912
+ async def _delete_claim_result_ref(
913
+ self,
914
+ claim_result_refs_key: str,
915
+ lease_token: str,
916
+ *,
917
+ deadline_monotonic: float | None = None,
918
+ ) -> None:
841
919
  try:
842
- await self._redis_client.hdel(claim_result_refs_key, lease_token)
920
+ await _call_with_drain_deadline(
921
+ lambda: self._redis_client.hdel(claim_result_refs_key, lease_token),
922
+ deadline_monotonic=deadline_monotonic,
923
+ )
924
+ except _DrainDeadlineExceeded:
925
+ raise
843
926
  except Exception:
844
927
  # Claim-result refs have bounded TTLs; this cleanup is intentionally best-effort.
845
928
  logger.warning(
@@ -903,25 +986,48 @@ class RedisGateway(AbstractRedisGateway):
903
986
  self,
904
987
  processing_queue: str,
905
988
  claim_id: str,
989
+ *,
990
+ deadline_monotonic: float | None = None,
906
991
  ) -> MessageData | None:
992
+ _raise_if_drain_deadline_expired(deadline_monotonic)
907
993
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
908
- cached_claim = await self._redis_client.get(claim_result_key)
994
+ cached_claim = await _call_with_drain_deadline(
995
+ lambda: self._redis_client.get(claim_result_key),
996
+ deadline_monotonic=deadline_monotonic,
997
+ )
998
+ _raise_if_drain_deadline_expired(deadline_monotonic)
909
999
  if cached_claim is None:
910
- cached_claim = await self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1000
+ cached_claim = await _call_with_drain_deadline(
1001
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1002
+ deadline_monotonic=deadline_monotonic,
1003
+ )
1004
+ _raise_if_drain_deadline_expired(deadline_monotonic)
911
1005
  if cached_claim is None:
912
1006
  return None
913
- await self._delete_claim_result_key(claim_result_key)
1007
+ await self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1008
+ _raise_if_drain_deadline_expired(deadline_monotonic)
914
1009
  return cached_claim
915
1010
 
916
1011
  async def _recover_pending_visibility_timeout_claim(
917
1012
  self,
918
1013
  processing_queue: str,
919
1014
  claim_id: str,
1015
+ *,
1016
+ deadline_monotonic: float | None = None,
920
1017
  ) -> ClaimedMessage | None:
1018
+ _raise_if_drain_deadline_expired(deadline_monotonic)
921
1019
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
922
- cached_claim = await self._redis_client.get(claim_result_key)
1020
+ cached_claim = await _call_with_drain_deadline(
1021
+ lambda: self._redis_client.get(claim_result_key),
1022
+ deadline_monotonic=deadline_monotonic,
1023
+ )
1024
+ _raise_if_drain_deadline_expired(deadline_monotonic)
923
1025
  if cached_claim is None:
924
- cached_claim = await self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
1026
+ cached_claim = await _call_with_drain_deadline(
1027
+ lambda: self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id),
1028
+ deadline_monotonic=deadline_monotonic,
1029
+ )
1030
+ _raise_if_drain_deadline_expired(deadline_monotonic)
925
1031
  if cached_claim is None:
926
1032
  return None
927
1033
 
@@ -929,7 +1035,7 @@ class RedisGateway(AbstractRedisGateway):
929
1035
  try:
930
1036
  claim = json.loads(cached_claim_text)
931
1037
  except json.JSONDecodeError:
932
- await self._delete_claim_result_key(claim_result_key)
1038
+ await self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
933
1039
  return None
934
1040
 
935
1041
  if (
@@ -938,7 +1044,7 @@ class RedisGateway(AbstractRedisGateway):
938
1044
  or not isinstance(claim[0], str)
939
1045
  or not isinstance(claim[1], str)
940
1046
  ):
941
- await self._delete_claim_result_key(claim_result_key)
1047
+ await self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
942
1048
  return None
943
1049
 
944
1050
  stored_message: MessageData = claim[0]
@@ -946,12 +1052,20 @@ class RedisGateway(AbstractRedisGateway):
946
1052
  stored_message = stored_message.encode("utf-8")
947
1053
  lease_token = claim[1]
948
1054
 
949
- await self._delete_claim_result_key(claim_result_key)
950
- await self._delete_claim_result_ref(self._claim_result_refs_key(processing_queue), lease_token)
1055
+ await self._delete_claim_result_key(claim_result_key, deadline_monotonic=deadline_monotonic)
1056
+ _raise_if_drain_deadline_expired(deadline_monotonic)
1057
+ await self._delete_claim_result_ref(
1058
+ self._claim_result_refs_key(processing_queue),
1059
+ lease_token,
1060
+ deadline_monotonic=deadline_monotonic,
1061
+ )
1062
+ _raise_if_drain_deadline_expired(deadline_monotonic)
951
1063
  return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
952
1064
 
953
- def _is_interrupted(self) -> bool:
954
- return self._interrupt is not None and self._interrupt.is_interrupted()
1065
+ def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
1066
+ return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
1067
+ is_interrupted is not None and is_interrupted.is_interrupted()
1068
+ )
955
1069
 
956
1070
  async def _drain_pending_claim_ids(
957
1071
  self,
@@ -1006,8 +1120,10 @@ class RedisGateway(AbstractRedisGateway):
1006
1120
  clear = False
1007
1121
  try:
1008
1122
  try:
1009
- await recover(processing_queue, claim_id)
1123
+ await recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1010
1124
  clear = True
1125
+ except _DrainDeadlineExceeded:
1126
+ break
1011
1127
  except Exception as exc:
1012
1128
  if not is_redis_retryable_exception(exc):
1013
1129
  raise
@@ -74,6 +74,30 @@ def _warning_exception_name(exc: BaseException) -> str:
74
74
  return type(exc).__name__
75
75
 
76
76
 
77
+ def _find_non_string_dict_keys(value: object) -> list[object]:
78
+ non_str_keys: list[object] = []
79
+ seen: set[int] = set()
80
+
81
+ def visit(current: object) -> None:
82
+ if not isinstance(current, (dict, list, tuple)):
83
+ return
84
+ current_id = id(current)
85
+ if current_id in seen:
86
+ return
87
+ seen.add(current_id)
88
+ if isinstance(current, dict):
89
+ for key, child in current.items():
90
+ if not isinstance(key, str):
91
+ non_str_keys.append(key)
92
+ visit(child)
93
+ return
94
+ for child in current:
95
+ visit(child)
96
+
97
+ visit(value)
98
+ return non_str_keys
99
+
100
+
77
101
  class _TaskBaseException(Exception):
78
102
  def __init__(self, original: BaseException):
79
103
  super().__init__(str(original))
@@ -285,6 +309,14 @@ class _StopEventInterrupt(BaseGracefulInterruptHandler):
285
309
  return self._stop_event.is_set()
286
310
 
287
311
 
312
+ class _DrainInterrupt(BaseGracefulInterruptHandler):
313
+ def __init__(self, is_draining: Callable[[], bool]) -> None:
314
+ self._is_draining = is_draining
315
+
316
+ def is_interrupted(self) -> bool:
317
+ return self._is_draining()
318
+
319
+
288
320
  class _LeaseHeartbeat:
289
321
  def __init__(
290
322
  self,
@@ -758,11 +790,10 @@ class RedisMessageQueue:
758
790
  """Publish a message.
759
791
 
760
792
  Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
761
- All top-level dict keys must be strings; non-string keys raise
793
+ All dict keys must be strings; non-string keys raise
762
794
  ``TypeError`` to avoid silent ``json.dumps`` coercion that would
763
795
  collapse distinct keys into the same dedup key (e.g. ``{1: "x"}``
764
- vs ``{"1": "x"}``). Only top-level keys are validated; nested
765
- dicts follow ``json.dumps`` defaults.
796
+ vs ``{"1": "x"}``).
766
797
  """
767
798
  async with self._publish_lock:
768
799
  if self._drained:
@@ -773,7 +804,7 @@ class RedisMessageQueue:
773
804
  if not isinstance(message, (str, dict)):
774
805
  raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
775
806
  if isinstance(message, dict):
776
- non_str_keys = [k for k in message if not isinstance(k, str)]
807
+ non_str_keys = _find_non_string_dict_keys(message)
777
808
  if non_str_keys:
778
809
  raise TypeError(
779
810
  "'message' dict keys must all be strings; "
@@ -846,10 +877,7 @@ class RedisMessageQueue:
846
877
  yield None
847
878
  return
848
879
  try:
849
- claimed_message = await self._redis.wait_for_message_and_move(
850
- self.key.pending,
851
- self.key.processing,
852
- )
880
+ claimed_message = await self._wait_for_message_and_move()
853
881
  except Exception as exc:
854
882
  await self._emit_event(
855
883
  "claim",
@@ -1027,6 +1055,19 @@ class RedisMessageQueue:
1027
1055
  else:
1028
1056
  await _await_suppressing_external_cancellation(lease_heartbeat.stop())
1029
1057
 
1058
+ async def _wait_for_message_and_move(self) -> ClaimedMessage | MessageData | None:
1059
+ interruptible_wait = getattr(self._redis, "_wait_for_message_and_move_interruptible", None)
1060
+ if callable(interruptible_wait):
1061
+ return await interruptible_wait(
1062
+ self.key.pending,
1063
+ self.key.processing,
1064
+ is_interrupted=_DrainInterrupt(lambda: self._draining),
1065
+ )
1066
+ return await self._redis.wait_for_message_and_move(
1067
+ self.key.pending,
1068
+ self.key.processing,
1069
+ )
1070
+
1030
1071
  async def _move_processed_message(
1031
1072
  self,
1032
1073
  destination_queue: str,
@@ -73,6 +73,30 @@ def _warning_exception_name(exc: BaseException) -> str:
73
73
  return type(exc).__name__
74
74
 
75
75
 
76
+ def _find_non_string_dict_keys(value: object) -> list[object]:
77
+ non_str_keys: list[object] = []
78
+ seen: set[int] = set()
79
+
80
+ def visit(current: object) -> None:
81
+ if not isinstance(current, (dict, list, tuple)):
82
+ return
83
+ current_id = id(current)
84
+ if current_id in seen:
85
+ return
86
+ seen.add(current_id)
87
+ if isinstance(current, dict):
88
+ for key, child in current.items():
89
+ if not isinstance(key, str):
90
+ non_str_keys.append(key)
91
+ visit(child)
92
+ return
93
+ for child in current:
94
+ visit(child)
95
+
96
+ visit(value)
97
+ return non_str_keys
98
+
99
+
76
100
  def _validate_heartbeat_interval_seconds(
77
101
  heartbeat_interval_seconds: int | float | None,
78
102
  visibility_timeout_seconds: int | None,
@@ -235,6 +259,14 @@ class _StopEventInterrupt(BaseGracefulInterruptHandler):
235
259
  return self._stop_event.is_set()
236
260
 
237
261
 
262
+ class _DrainInterrupt(BaseGracefulInterruptHandler):
263
+ def __init__(self, is_draining: Callable[[], bool]) -> None:
264
+ self._is_draining = is_draining
265
+
266
+ def is_interrupted(self) -> bool:
267
+ return self._is_draining()
268
+
269
+
238
270
  class _LeaseHeartbeat:
239
271
  def __init__(
240
272
  self,
@@ -711,12 +743,10 @@ class RedisMessageQueue:
711
743
  """Publish a message.
712
744
 
713
745
  Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
714
- All top-level dict keys must be strings; non-string keys raise
746
+ All dict keys must be strings; non-string keys raise
715
747
  ``TypeError`` to avoid silent ``json.dumps`` coercion that would
716
748
  collapse distinct keys into the same dedup key (e.g. ``{1: "x"}``
717
- vs ``{"1": "x"}``). Only top-level keys are validated; nested
718
- dicts follow ``json.dumps`` defaults (e.g. nested non-string keys
719
- are silently coerced: integer keys become strings).
749
+ vs ``{"1": "x"}``).
720
750
  """
721
751
  with self._publish_lock:
722
752
  if self._drained.is_set():
@@ -727,7 +757,7 @@ class RedisMessageQueue:
727
757
  if not isinstance(message, (str, dict)):
728
758
  raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
729
759
  if isinstance(message, dict):
730
- non_str_keys = [k for k in message if not isinstance(k, str)]
760
+ non_str_keys = _find_non_string_dict_keys(message)
731
761
  if non_str_keys:
732
762
  raise TypeError(
733
763
  "'message' dict keys must all be strings; "
@@ -808,10 +838,7 @@ class RedisMessageQueue:
808
838
  yield None
809
839
  return
810
840
  try:
811
- claimed_message = self._redis.wait_for_message_and_move(
812
- self.key.pending,
813
- self.key.processing,
814
- )
841
+ claimed_message = self._wait_for_message_and_move()
815
842
  except Exception as exc:
816
843
  self._emit_event(
817
844
  "claim",
@@ -979,6 +1006,19 @@ class RedisMessageQueue:
979
1006
  if lease_heartbeat is not None:
980
1007
  lease_heartbeat.stop()
981
1008
 
1009
+ def _wait_for_message_and_move(self) -> ClaimedMessage | MessageData | None:
1010
+ interruptible_wait = getattr(self._redis, "_wait_for_message_and_move_interruptible", None)
1011
+ if callable(interruptible_wait):
1012
+ return interruptible_wait(
1013
+ self.key.pending,
1014
+ self.key.processing,
1015
+ is_interrupted=_DrainInterrupt(lambda: self._draining),
1016
+ )
1017
+ return self._redis.wait_for_message_and_move(
1018
+ self.key.pending,
1019
+ self.key.processing,
1020
+ )
1021
+
982
1022
  def _move_processed_message(
983
1023
  self,
984
1024
  destination_queue: str,