redis-message-queue 7.0.0__tar.gz → 7.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-7.0.0 → redis_message_queue-7.0.1}/PKG-INFO +148 -27
  2. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/README.md +147 -26
  3. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/pyproject.toml +1 -1
  4. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_config.py +50 -17
  5. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_exceptions.py +27 -0
  6. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_queue_key_manager.py +23 -0
  7. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_cluster.py +1 -1
  8. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_gateway.py +22 -0
  9. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/_redis_gateway.py +22 -0
  10. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/redis_message_queue.py +8 -5
  11. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/redis_message_queue.py +22 -11
  12. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/LICENSE +0 -0
  13. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/__init__.py +0 -0
  14. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  15. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_callable_utils.py +0 -0
  16. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_event.py +0 -0
  17. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-7.0.0 → redis_message_queue-7.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: 7.0.0
3
+ Version: 7.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/v7.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v7.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)
@@ -37,46 +37,50 @@ Description-Content-Type: text/markdown
37
37
  **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.
38
38
 
39
39
  ```bash
40
- pip install "redis-message-queue>=6.0.0,<7.0.0"
40
+ pip install "redis-message-queue>=7.0.0,<8.0.0"
41
41
  ```
42
42
 
43
43
  Requires Redis server >= 6.2.
44
44
 
45
45
  ## Quickstart
46
46
 
47
- ### Publish messages
47
+ Redis must be running locally first: use `redis-server` or
48
+ `docker run -p 6379:6379 redis:7`.
48
49
 
49
50
  ```python
50
51
  from redis import Redis
51
52
  from redis_message_queue import RedisMessageQueue
52
53
 
53
54
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
54
- queue = RedisMessageQueue("my_queue", client=client, deduplication=True)
55
-
56
- queue.publish("order:1234") # returns True
57
- queue.publish("order:1234") # returns False (deduplicated)
58
- queue.publish({"user": "alice"}) # dicts work too
55
+ queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
56
+ queue.publish("hello")
57
+ with queue.process_message() as message:
58
+ if message is not None:
59
+ print(f"got {message}")
60
+ # Expected output: got hello
59
61
  ```
60
62
 
61
- ### Consume messages
63
+ `RedisMessageQueue` itself is not a context manager. Use
64
+ `with queue.process_message() as message:` for each message.
65
+
66
+ ### Async quickstart
62
67
 
63
68
  ```python
64
- from redis import Redis
65
- from redis_message_queue import RedisMessageQueue
69
+ import asyncio
70
+ from redis.asyncio import Redis
71
+ from redis_message_queue.asyncio import RedisMessageQueue
66
72
 
67
- client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
68
- queue = RedisMessageQueue("my_queue", client=client)
73
+ async def main():
74
+ client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
75
+ queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
76
+ await queue.publish("hello")
77
+ async with queue.process_message() as message:
78
+ print(f"got {message}")
79
+ await client.aclose()
69
80
 
70
- while True:
71
- with queue.process_message() as message:
72
- if message is not None:
73
- print(f"Processing: {message}")
74
- # Auto-acknowledged on success; cleaned up on exception
81
+ asyncio.run(main()) # Expected output: got hello
75
82
  ```
76
83
 
77
- `RedisMessageQueue` itself is not a context manager. Use
78
- `with queue.process_message() as message:` for each message.
79
-
80
84
  ## Why redis-message-queue
81
85
 
82
86
  **The problem:** You're sending messages between services or workers and need guarantees. Simple Redis LPUSH/BRPOP loses messages on crashes, doesn't deduplicate, and gives you no visibility into what succeeded or failed.
@@ -124,6 +128,29 @@ queue = RedisMessageQueue(
124
128
  queue = RedisMessageQueue("q", client=client, deduplication=False)
125
129
  ```
126
130
 
131
+ #### Custom dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
132
+
133
+ When `get_deduplication_key` is a callable, it is called once per publish and
134
+ must return a `str` that uniquely represents the deduplication scope for that
135
+ message. Returning `None` or `""` raises `ConfigurationError` at publish time;
136
+ returning a non-`str` value raises `TypeError`.
137
+
138
+ Use stable, high-cardinality keys that include any tenant or account boundary
139
+ needed by your system:
140
+
141
+ ```python
142
+ queue = RedisMessageQueue(
143
+ "orders",
144
+ client=client,
145
+ deduplication=True,
146
+ get_deduplication_key=lambda msg: f"{msg['tenant_id']}:{msg['order_id']}",
147
+ )
148
+ ```
149
+
150
+ Avoid fallback patterns such as `lambda msg: msg.get("order_id", "")`.
151
+ Missing fields should fail loudly instead of collapsing unrelated messages into
152
+ one deduplication key.
153
+
127
154
  ### Success and failure tracking
128
155
 
129
156
  ```python
@@ -588,17 +615,46 @@ events_total = Counter(
588
615
  "redis-message-queue lifecycle events",
589
616
  ["queue", "operation", "outcome", "exception_type"],
590
617
  )
618
+ SPAN_SINK_TRUSTED = False
591
619
 
592
620
  def observe(event: QueueEvent) -> None:
593
621
  events_total.labels(
594
622
  event.queue, event.operation, event.outcome, event.exception_type or ""
595
623
  ).inc()
596
- if event.error is not None:
624
+ if event.error is not None and SPAN_SINK_TRUSTED:
597
625
  trace.get_current_span().record_exception(event.error)
598
626
 
599
627
  queue = RedisMessageQueue("jobs", client=client, on_event=observe)
600
628
  ```
601
629
 
630
+ #### ⚠ Secrets in `event.error`
631
+
632
+ `event.error` is the actual exception object — it retains the exception
633
+ message, `__cause__` chain, and traceback. These can contain sensitive content:
634
+ Redis credentials in connection-error messages, message payloads in handler
635
+ exceptions, environment values in stack-frame locals.
636
+
637
+ When exporting to telemetry sinks (OpenTelemetry, Sentry, Datadog), prefer the
638
+ redaction-friendly `event.exception_type` for metrics and labels. Use
639
+ `event.error` for full structured error data ONLY if your sink is
640
+ trust-equivalent to your application logs and is access-controlled.
641
+
642
+ Recommended pattern:
643
+
644
+ ```python
645
+ def on_event(event: QueueEvent) -> None:
646
+ # Metric labels — always safe (just the exception class name)
647
+ metric_counter.labels(
648
+ operation=event.operation,
649
+ outcome=event.outcome,
650
+ exception_type=event.exception_type or "none",
651
+ ).inc()
652
+
653
+ # Full exception — only if your span sink is trusted
654
+ if event.error is not None and SPAN_SINK_TRUSTED:
655
+ span.record_exception(event.error)
656
+ ```
657
+
602
658
  #### Event dispatch context
603
659
 
604
660
  Callbacks fire inline:
@@ -704,10 +760,39 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
704
760
 
705
761
  ### v6 to v7 migration
706
762
 
707
- v7.0.0 changes explicit drain shutdown semantics. After `queue.drain()` /
708
- `queue.close()` (sync) or `await queue.drain()` / `await queue.aclose()`
709
- (async), the same queue instance rejects `publish()` with
710
- `QueueDrainedError("queue is drained")`.
763
+ v7.0.0 has four breaking changes to check during upgrade.
764
+
765
+ **AC-02: queue event operation/outcome types are `StrEnum` members.**
766
+ Runtime string comparisons keep working because `StrEnum` subclasses `str`,
767
+ but type-strict code should replace old `Literal[...]` annotations and raw
768
+ string constants with enum members.
769
+
770
+ Before:
771
+
772
+ ```python
773
+ from typing import Literal
774
+
775
+ QueueOperation = Literal["publish", "claim", "ack"]
776
+
777
+ def record(operation: QueueOperation) -> None:
778
+ if operation == "publish":
779
+ print("published")
780
+ ```
781
+
782
+ After:
783
+
784
+ ```python
785
+ from redis_message_queue import EventOperation
786
+
787
+ def record(operation: EventOperation) -> None:
788
+ if operation is EventOperation.PUBLISH:
789
+ print("published")
790
+ ```
791
+
792
+ **AC-03: drained queue instances refuse new publishes.** After
793
+ `queue.drain()` / `queue.close()` (sync) or `await queue.drain()` /
794
+ `await queue.aclose()` (async), the same queue instance rejects `publish()`
795
+ with `QueueDrainedError("queue is drained")`.
711
796
 
712
797
  This state is queue-local and process-local; it is not stored in Redis. If a
713
798
  producer must continue publishing after a worker has drained, use a separate
@@ -715,6 +800,42 @@ producer must continue publishing after a worker has drained, use a separate
715
800
  shutdown, catch `QueueDrainedError` only at boundaries where late publishes are
716
801
  expected and safe to drop or reschedule.
717
802
 
803
+ ```python
804
+ from redis_message_queue import QueueDrainedError
805
+
806
+ try:
807
+ queue.publish("late shutdown audit event")
808
+ except QueueDrainedError:
809
+ # The queue instance already began draining; drop or reschedule elsewhere.
810
+ pass
811
+ ```
812
+
813
+ **AC-09: unsafe `drop_oldest` combinations now fail at construction.** These
814
+ configurations raise `ConfigurationError` before the queue or gateway is
815
+ created:
816
+
817
+ - `pending_overload_policy="drop_oldest"` with `max_pending_length=None`:
818
+ `drop_oldest requires max_pending_length to be set. Use a positive
819
+ max_pending_length to define what can be dropped, or use
820
+ pending_overload_policy='raise' or 'block' for unbounded queues.`
821
+ - `pending_overload_policy="drop_oldest"` with deduplication enabled or
822
+ `get_deduplication_key` configured:
823
+ `'pending_overload_policy=drop_oldest' cannot be used with deduplication
824
+ because dropped messages leave their deduplication keys in Redis, causing
825
+ future publishes of the same payload to be silently suppressed. Use 'raise'
826
+ or 'block' for deduplicated queues, or disable deduplication if 'drop_oldest'
827
+ is required.`
828
+ - `pending_overload_policy="drop_oldest"` with `max_delivery_count` set:
829
+ `drop_oldest is incompatible with max_delivery_count (set
830
+ max_delivery_count=None or pick another policy to avoid silent loss of
831
+ pending DLQ candidates). Use pending_overload_policy='raise' or 'block' when
832
+ dead-letter handling is required.`
833
+
834
+ **AC-16: redis-py is capped below 8.0.0.** The package dependency is
835
+ `redis>=5.0.0,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
836
+ Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
837
+ 8.0.0 beta explicitly, downgrade with `pip install "redis<8.0.0"`.
838
+
718
839
  ### Configuration changes on live queues
719
840
 
720
841
  > **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v7.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v7.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)
@@ -11,46 +11,50 @@
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>=6.0.0,<7.0.0"
14
+ pip install "redis-message-queue>=7.0.0,<8.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
18
18
 
19
19
  ## Quickstart
20
20
 
21
- ### Publish messages
21
+ Redis must be running locally first: use `redis-server` or
22
+ `docker run -p 6379:6379 redis:7`.
22
23
 
23
24
  ```python
24
25
  from redis import Redis
25
26
  from redis_message_queue import RedisMessageQueue
26
27
 
27
28
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
28
- queue = RedisMessageQueue("my_queue", client=client, deduplication=True)
29
-
30
- queue.publish("order:1234") # returns True
31
- queue.publish("order:1234") # returns False (deduplicated)
32
- queue.publish({"user": "alice"}) # dicts work too
29
+ queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
30
+ queue.publish("hello")
31
+ with queue.process_message() as message:
32
+ if message is not None:
33
+ print(f"got {message}")
34
+ # Expected output: got hello
33
35
  ```
34
36
 
35
- ### Consume messages
37
+ `RedisMessageQueue` itself is not a context manager. Use
38
+ `with queue.process_message() as message:` for each message.
39
+
40
+ ### Async quickstart
36
41
 
37
42
  ```python
38
- from redis import Redis
39
- from redis_message_queue import RedisMessageQueue
43
+ import asyncio
44
+ from redis.asyncio import Redis
45
+ from redis_message_queue.asyncio import RedisMessageQueue
40
46
 
41
- client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
42
- queue = RedisMessageQueue("my_queue", client=client)
47
+ async def main():
48
+ client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
49
+ queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
50
+ await queue.publish("hello")
51
+ async with queue.process_message() as message:
52
+ print(f"got {message}")
53
+ await client.aclose()
43
54
 
44
- while True:
45
- with queue.process_message() as message:
46
- if message is not None:
47
- print(f"Processing: {message}")
48
- # Auto-acknowledged on success; cleaned up on exception
55
+ asyncio.run(main()) # Expected output: got hello
49
56
  ```
50
57
 
51
- `RedisMessageQueue` itself is not a context manager. Use
52
- `with queue.process_message() as message:` for each message.
53
-
54
58
  ## Why redis-message-queue
55
59
 
56
60
  **The problem:** You're sending messages between services or workers and need guarantees. Simple Redis LPUSH/BRPOP loses messages on crashes, doesn't deduplicate, and gives you no visibility into what succeeded or failed.
@@ -98,6 +102,29 @@ queue = RedisMessageQueue(
98
102
  queue = RedisMessageQueue("q", client=client, deduplication=False)
99
103
  ```
100
104
 
105
+ #### Custom dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
106
+
107
+ When `get_deduplication_key` is a callable, it is called once per publish and
108
+ must return a `str` that uniquely represents the deduplication scope for that
109
+ message. Returning `None` or `""` raises `ConfigurationError` at publish time;
110
+ returning a non-`str` value raises `TypeError`.
111
+
112
+ Use stable, high-cardinality keys that include any tenant or account boundary
113
+ needed by your system:
114
+
115
+ ```python
116
+ queue = RedisMessageQueue(
117
+ "orders",
118
+ client=client,
119
+ deduplication=True,
120
+ get_deduplication_key=lambda msg: f"{msg['tenant_id']}:{msg['order_id']}",
121
+ )
122
+ ```
123
+
124
+ Avoid fallback patterns such as `lambda msg: msg.get("order_id", "")`.
125
+ Missing fields should fail loudly instead of collapsing unrelated messages into
126
+ one deduplication key.
127
+
101
128
  ### Success and failure tracking
102
129
 
103
130
  ```python
@@ -562,17 +589,46 @@ events_total = Counter(
562
589
  "redis-message-queue lifecycle events",
563
590
  ["queue", "operation", "outcome", "exception_type"],
564
591
  )
592
+ SPAN_SINK_TRUSTED = False
565
593
 
566
594
  def observe(event: QueueEvent) -> None:
567
595
  events_total.labels(
568
596
  event.queue, event.operation, event.outcome, event.exception_type or ""
569
597
  ).inc()
570
- if event.error is not None:
598
+ if event.error is not None and SPAN_SINK_TRUSTED:
571
599
  trace.get_current_span().record_exception(event.error)
572
600
 
573
601
  queue = RedisMessageQueue("jobs", client=client, on_event=observe)
574
602
  ```
575
603
 
604
+ #### ⚠ Secrets in `event.error`
605
+
606
+ `event.error` is the actual exception object — it retains the exception
607
+ message, `__cause__` chain, and traceback. These can contain sensitive content:
608
+ Redis credentials in connection-error messages, message payloads in handler
609
+ exceptions, environment values in stack-frame locals.
610
+
611
+ When exporting to telemetry sinks (OpenTelemetry, Sentry, Datadog), prefer the
612
+ redaction-friendly `event.exception_type` for metrics and labels. Use
613
+ `event.error` for full structured error data ONLY if your sink is
614
+ trust-equivalent to your application logs and is access-controlled.
615
+
616
+ Recommended pattern:
617
+
618
+ ```python
619
+ def on_event(event: QueueEvent) -> None:
620
+ # Metric labels — always safe (just the exception class name)
621
+ metric_counter.labels(
622
+ operation=event.operation,
623
+ outcome=event.outcome,
624
+ exception_type=event.exception_type or "none",
625
+ ).inc()
626
+
627
+ # Full exception — only if your span sink is trusted
628
+ if event.error is not None and SPAN_SINK_TRUSTED:
629
+ span.record_exception(event.error)
630
+ ```
631
+
576
632
  #### Event dispatch context
577
633
 
578
634
  Callbacks fire inline:
@@ -678,10 +734,39 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
678
734
 
679
735
  ### v6 to v7 migration
680
736
 
681
- v7.0.0 changes explicit drain shutdown semantics. After `queue.drain()` /
682
- `queue.close()` (sync) or `await queue.drain()` / `await queue.aclose()`
683
- (async), the same queue instance rejects `publish()` with
684
- `QueueDrainedError("queue is drained")`.
737
+ v7.0.0 has four breaking changes to check during upgrade.
738
+
739
+ **AC-02: queue event operation/outcome types are `StrEnum` members.**
740
+ Runtime string comparisons keep working because `StrEnum` subclasses `str`,
741
+ but type-strict code should replace old `Literal[...]` annotations and raw
742
+ string constants with enum members.
743
+
744
+ Before:
745
+
746
+ ```python
747
+ from typing import Literal
748
+
749
+ QueueOperation = Literal["publish", "claim", "ack"]
750
+
751
+ def record(operation: QueueOperation) -> None:
752
+ if operation == "publish":
753
+ print("published")
754
+ ```
755
+
756
+ After:
757
+
758
+ ```python
759
+ from redis_message_queue import EventOperation
760
+
761
+ def record(operation: EventOperation) -> None:
762
+ if operation is EventOperation.PUBLISH:
763
+ print("published")
764
+ ```
765
+
766
+ **AC-03: drained queue instances refuse new publishes.** After
767
+ `queue.drain()` / `queue.close()` (sync) or `await queue.drain()` /
768
+ `await queue.aclose()` (async), the same queue instance rejects `publish()`
769
+ with `QueueDrainedError("queue is drained")`.
685
770
 
686
771
  This state is queue-local and process-local; it is not stored in Redis. If a
687
772
  producer must continue publishing after a worker has drained, use a separate
@@ -689,6 +774,42 @@ producer must continue publishing after a worker has drained, use a separate
689
774
  shutdown, catch `QueueDrainedError` only at boundaries where late publishes are
690
775
  expected and safe to drop or reschedule.
691
776
 
777
+ ```python
778
+ from redis_message_queue import QueueDrainedError
779
+
780
+ try:
781
+ queue.publish("late shutdown audit event")
782
+ except QueueDrainedError:
783
+ # The queue instance already began draining; drop or reschedule elsewhere.
784
+ pass
785
+ ```
786
+
787
+ **AC-09: unsafe `drop_oldest` combinations now fail at construction.** These
788
+ configurations raise `ConfigurationError` before the queue or gateway is
789
+ created:
790
+
791
+ - `pending_overload_policy="drop_oldest"` with `max_pending_length=None`:
792
+ `drop_oldest requires max_pending_length to be set. Use a positive
793
+ max_pending_length to define what can be dropped, or use
794
+ pending_overload_policy='raise' or 'block' for unbounded queues.`
795
+ - `pending_overload_policy="drop_oldest"` with deduplication enabled or
796
+ `get_deduplication_key` configured:
797
+ `'pending_overload_policy=drop_oldest' cannot be used with deduplication
798
+ because dropped messages leave their deduplication keys in Redis, causing
799
+ future publishes of the same payload to be silently suppressed. Use 'raise'
800
+ or 'block' for deduplicated queues, or disable deduplication if 'drop_oldest'
801
+ is required.`
802
+ - `pending_overload_policy="drop_oldest"` with `max_delivery_count` set:
803
+ `drop_oldest is incompatible with max_delivery_count (set
804
+ max_delivery_count=None or pick another policy to avoid silent loss of
805
+ pending DLQ candidates). Use pending_overload_policy='raise' or 'block' when
806
+ dead-letter handling is required.`
807
+
808
+ **AC-16: redis-py is capped below 8.0.0.** The package dependency is
809
+ `redis>=5.0.0,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
810
+ Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
811
+ 8.0.0 beta explicitly, downgrade with `pip install "redis<8.0.0"`.
812
+
692
813
  ### Configuration changes on live queues
693
814
 
694
815
  > **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "7.0.0"
3
+ version = "7.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"
@@ -237,11 +237,14 @@ def validate_gateway_parameters(
237
237
  )
238
238
  if message_deduplication_log_ttl_seconds <= 0:
239
239
  raise ConfigurationError(
240
- f"'message_deduplication_log_ttl_seconds' must be positive, got {message_deduplication_log_ttl_seconds}"
240
+ f"'message_deduplication_log_ttl_seconds' must be positive, "
241
+ f"got {message_deduplication_log_ttl_seconds}. Use a positive int in seconds "
242
+ "(e.g., 3600) to keep deduplication keys for that window."
241
243
  )
242
244
  if message_wait_interval_seconds < 0:
243
245
  raise ConfigurationError(
244
- f"'message_wait_interval_seconds' must be non-negative, got {message_wait_interval_seconds}"
246
+ f"'message_wait_interval_seconds' must be non-negative, got {message_wait_interval_seconds}. "
247
+ "Use 0 for non-blocking polls or a positive int in seconds to wait for messages."
245
248
  )
246
249
  if message_visibility_timeout_seconds is not None:
247
250
  if not isinstance(message_visibility_timeout_seconds, int) or isinstance(
@@ -255,14 +258,18 @@ def validate_gateway_parameters(
255
258
  if message_visibility_timeout_seconds <= 0:
256
259
  raise ConfigurationError(
257
260
  "'message_visibility_timeout_seconds' must be positive when provided, "
258
- f"got {message_visibility_timeout_seconds}"
261
+ f"got {message_visibility_timeout_seconds}. Use a positive int in seconds for at-least-once "
262
+ "redelivery or None for at-most-once processing."
259
263
  )
260
264
 
261
265
  if not isinstance(retry_budget_seconds, int) or isinstance(retry_budget_seconds, bool):
262
266
  bool_hint = " (use True or False, not 1/0)" if isinstance(retry_budget_seconds, bool) else ""
263
267
  raise TypeError(f"'retry_budget_seconds' must be an int, got {type(retry_budget_seconds).__name__}{bool_hint}")
264
268
  if retry_budget_seconds < 0:
265
- raise ConfigurationError(f"'retry_budget_seconds' must be non-negative, got {retry_budget_seconds}")
269
+ raise ConfigurationError(
270
+ f"'retry_budget_seconds' must be non-negative, got {retry_budget_seconds}. "
271
+ "Use 0 to disable redis-message-queue retries or a positive int in seconds to bound retry time."
272
+ )
266
273
 
267
274
  if isinstance(retry_max_delay_seconds, bool) or not isinstance(retry_max_delay_seconds, (int, float)):
268
275
  bool_hint = " (use True or False, not 1/0)" if isinstance(retry_max_delay_seconds, bool) else ""
@@ -271,7 +278,8 @@ def validate_gateway_parameters(
271
278
  )
272
279
  if not math.isfinite(retry_max_delay_seconds) or retry_max_delay_seconds <= 0:
273
280
  raise ConfigurationError(
274
- f"'retry_max_delay_seconds' must be a finite positive number, got {retry_max_delay_seconds}"
281
+ f"'retry_max_delay_seconds' must be a finite positive number, got {retry_max_delay_seconds}. "
282
+ "Use a positive number of seconds (e.g., 5.0) for the maximum retry backoff delay."
275
283
  )
276
284
 
277
285
  if isinstance(retry_initial_delay_seconds, bool) or not isinstance(retry_initial_delay_seconds, (int, float)):
@@ -282,12 +290,14 @@ def validate_gateway_parameters(
282
290
  )
283
291
  if not math.isfinite(retry_initial_delay_seconds) or retry_initial_delay_seconds <= 0:
284
292
  raise ConfigurationError(
285
- f"'retry_initial_delay_seconds' must be a finite positive number, got {retry_initial_delay_seconds}"
293
+ f"'retry_initial_delay_seconds' must be a finite positive number, got {retry_initial_delay_seconds}. "
294
+ "Use a positive number of seconds (e.g., 0.01) for the first retry backoff delay."
286
295
  )
287
296
  if retry_initial_delay_seconds > retry_max_delay_seconds:
288
297
  raise ConfigurationError(
289
298
  "'retry_initial_delay_seconds' must be <= 'retry_max_delay_seconds', "
290
- f"got {retry_initial_delay_seconds} > {retry_max_delay_seconds}"
299
+ f"got {retry_initial_delay_seconds} > {retry_max_delay_seconds}. "
300
+ "Use an initial delay less than or equal to the max delay."
291
301
  )
292
302
 
293
303
 
@@ -307,16 +317,24 @@ def validate_pending_backpressure_parameters(
307
317
  f"'max_pending_length' must be an int or None, got {type(max_pending_length).__name__}{bool_hint}"
308
318
  )
309
319
  if max_pending_length <= 0:
310
- raise ConfigurationError(f"'max_pending_length' must be positive when provided, got {max_pending_length}")
320
+ raise ConfigurationError(
321
+ f"'max_pending_length' must be positive when provided, got {max_pending_length}. "
322
+ "Use a positive int (e.g., 1000) or None to disable the cap."
323
+ )
311
324
  if not isinstance(pending_overload_policy, str):
312
325
  raise TypeError(f"'pending_overload_policy' must be a string, got {type(pending_overload_policy).__name__}")
313
326
  if pending_overload_policy not in PENDING_OVERLOAD_POLICIES:
314
327
  allowed = "', '".join(PENDING_OVERLOAD_POLICIES)
315
328
  raise ConfigurationError(
316
- f"'pending_overload_policy' must be one of '{allowed}', got {pending_overload_policy!r}"
329
+ f"'pending_overload_policy' must be one of '{allowed}', got {pending_overload_policy!r}. "
330
+ "Use 'raise' to reject immediately, 'block' to wait for capacity, or 'drop_oldest' only for lossy queues."
317
331
  )
318
332
  if pending_overload_policy == "drop_oldest" and max_pending_length is None:
319
- raise ConfigurationError("drop_oldest requires max_pending_length to be set")
333
+ raise ConfigurationError(
334
+ "drop_oldest requires max_pending_length to be set. "
335
+ "Use a positive max_pending_length to define what can be dropped, or use "
336
+ "pending_overload_policy='raise' or 'block' for unbounded queues."
337
+ )
320
338
  if pending_overload_policy == "drop_oldest" and (deduplication or get_deduplication_key_configured):
321
339
  raise ConfigurationError(
322
340
  "'pending_overload_policy=drop_oldest' cannot be used with deduplication because dropped messages "
@@ -328,7 +346,8 @@ def validate_pending_backpressure_parameters(
328
346
  raise ConfigurationError(
329
347
  "drop_oldest is incompatible with max_delivery_count "
330
348
  "(set max_delivery_count=None or pick another policy "
331
- "to avoid silent loss of pending DLQ candidates)"
349
+ "to avoid silent loss of pending DLQ candidates). Use pending_overload_policy='raise' or 'block' "
350
+ "when dead-letter handling is required."
332
351
  )
333
352
  if isinstance(pending_overload_block_timeout_seconds, bool) or not isinstance(
334
353
  pending_overload_block_timeout_seconds, (int, float)
@@ -341,7 +360,8 @@ def validate_pending_backpressure_parameters(
341
360
  if not math.isfinite(pending_overload_block_timeout_seconds) or pending_overload_block_timeout_seconds < 0:
342
361
  raise ConfigurationError(
343
362
  "'pending_overload_block_timeout_seconds' must be a finite non-negative number, "
344
- f"got {pending_overload_block_timeout_seconds}"
363
+ f"got {pending_overload_block_timeout_seconds}. Use 0 to fail immediately under 'block' policy "
364
+ "or a positive number of seconds to wait for capacity."
345
365
  )
346
366
 
347
367
 
@@ -357,20 +377,33 @@ def validate_dead_letter_parameters(
357
377
  f"'max_delivery_count' must be an int or None, got {type(max_delivery_count).__name__}{bool_hint}"
358
378
  )
359
379
  if max_delivery_count <= 0:
360
- raise ConfigurationError(f"'max_delivery_count' must be positive, got {max_delivery_count}")
380
+ raise ConfigurationError(
381
+ f"'max_delivery_count' must be positive, got {max_delivery_count}. "
382
+ "Use a positive int (e.g., 5) to dead-letter poison messages or None to disable delivery limits."
383
+ )
361
384
  if message_visibility_timeout_seconds is None:
362
- raise ConfigurationError("'max_delivery_count' requires 'message_visibility_timeout_seconds' to be set.")
385
+ raise ConfigurationError(
386
+ "'max_delivery_count' requires 'message_visibility_timeout_seconds' to be set. "
387
+ "Use a positive visibility timeout so failed deliveries can be counted before DLQ routing."
388
+ )
363
389
  if dead_letter_queue is not None and not isinstance(dead_letter_queue, str):
364
390
  bool_hint = " (use True or False, not 1/0)" if isinstance(dead_letter_queue, bool) else ""
365
391
  raise TypeError(f"'dead_letter_queue' must be a str or None, got {type(dead_letter_queue).__name__}{bool_hint}")
366
392
  if isinstance(dead_letter_queue, str) and dead_letter_queue and not dead_letter_queue.strip():
367
393
  raise ConfigurationError(
368
- f"'dead_letter_queue' must contain non-whitespace characters; got {dead_letter_queue!r}"
394
+ f"'dead_letter_queue' must contain non-whitespace characters; got {dead_letter_queue!r}. "
395
+ "Use a real Redis list key name (e.g., 'jobs:dead') or None to disable DLQ routing."
369
396
  )
370
397
  if max_delivery_count is not None and not dead_letter_queue:
371
- raise ConfigurationError("'dead_letter_queue' is required when 'max_delivery_count' is set.")
398
+ raise ConfigurationError(
399
+ "'dead_letter_queue' is required when 'max_delivery_count' is set. "
400
+ "Use a Redis list key name for poison messages or set max_delivery_count=None."
401
+ )
372
402
  if dead_letter_queue and max_delivery_count is None:
373
- raise ConfigurationError("'max_delivery_count' is required when 'dead_letter_queue' is set.")
403
+ raise ConfigurationError(
404
+ "'max_delivery_count' is required when 'dead_letter_queue' is set. "
405
+ "Use a positive max_delivery_count to route poison messages or set dead_letter_queue=None."
406
+ )
374
407
 
375
408
 
376
409
  DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL = 60 * 60 # 1 hour = 60 seconds * 60 minutes
@@ -24,6 +24,20 @@ class CleanupFailedError(RedisMessageQueueError):
24
24
  class QueueBackpressureError(RedisMessageQueueError):
25
25
  """Publish rejected because the pending queue is at its configured limit."""
26
26
 
27
+ _REMEDIATION = (
28
+ "consider increasing `max_pending_length`, switching to "
29
+ "`pending_overload_policy='block'`, or adding consumer capacity."
30
+ )
31
+
32
+ def __init__(self, *args: object) -> None:
33
+ message = "Pending queue reached its configured limit" if not args else args[0]
34
+ if not isinstance(message, str) or len(args) > 1:
35
+ super().__init__(*args)
36
+ return
37
+ if self._REMEDIATION not in message:
38
+ message = f"{message}; {self._REMEDIATION}"
39
+ super().__init__(message)
40
+
27
41
 
28
42
  class QueueDrainedError(RedisMessageQueueError):
29
43
  """Raised when publish() is called after drain() or aclose()."""
@@ -32,6 +46,19 @@ class QueueDrainedError(RedisMessageQueueError):
32
46
  class RetryBudgetExhaustedError(redis.exceptions.RedisError, RedisMessageQueueError):
33
47
  """Tenacity retry budget exhausted; underlying redis-py exception is .__cause__."""
34
48
 
49
+ _REMEDIATION = (
50
+ "verify Redis connectivity and consider increasing `retry_budget_seconds` if transient failures are expected."
51
+ )
52
+
53
+ def __init__(self, *args: object) -> None:
54
+ message = "Redis retry budget exhausted" if not args else args[0]
55
+ if not isinstance(message, str) or len(args) > 1:
56
+ super().__init__(*args)
57
+ return
58
+ if self._REMEDIATION not in message:
59
+ message = f"{message}; {self._REMEDIATION}"
60
+ super().__init__(message)
61
+
35
62
 
36
63
  def wrap_lua_response_error(exc: redis.exceptions.ResponseError) -> LuaScriptError | None:
37
64
  message = str(exc)
@@ -1,6 +1,21 @@
1
1
  from redis_message_queue._exceptions import ConfigurationError
2
2
 
3
3
 
4
+ def validate_callable_deduplication_key(dedup_key: object, message: str | dict) -> str:
5
+ if dedup_key is None:
6
+ raise ConfigurationError(
7
+ f"get_deduplication_key returned None for message {message!r}; the callable must return a non-empty string"
8
+ )
9
+ if not isinstance(dedup_key, str):
10
+ raise TypeError(f"get_deduplication_key must return a str, got {type(dedup_key).__name__}")
11
+ if dedup_key == "":
12
+ raise ConfigurationError(
13
+ f"get_deduplication_key returned an empty string for message {message!r}; "
14
+ "the callable must return a non-empty, high-cardinality key"
15
+ )
16
+ return dedup_key
17
+
18
+
4
19
  class QueueKeyManager:
5
20
  # Logs message existence to prevent duplication.
6
21
  # Messages are marked for the duration of their lifecycle.
@@ -52,8 +67,16 @@ class QueueKeyManager:
52
67
  self._key_separator = key_separator
53
68
 
54
69
  def deduplication(self, message: str) -> str:
70
+ if not isinstance(message, str):
71
+ raise TypeError(f"'deduplication_key' must be a str, got {type(message).__name__}")
72
+ if message == "":
73
+ raise ConfigurationError("'deduplication_key' must be a non-empty string")
55
74
  return f"{self._queue_name}{self._key_separator}{self._MESSAGE_DEDUPLICATION_LOG}{self._key_separator}{message}"
56
75
 
76
+ @property
77
+ def deduplication_prefix(self) -> str:
78
+ return f"{self._queue_name}{self._key_separator}{self._MESSAGE_DEDUPLICATION_LOG}{self._key_separator}"
79
+
57
80
  @property
58
81
  def pending(self) -> str:
59
82
  return f"{self._queue_name}{self._key_separator}{self._PENDING_MESSAGES}"
@@ -18,7 +18,7 @@ def validate_queue_keys_for_redis_cluster(
18
18
  dead_letter_queue: str | None = None,
19
19
  ) -> None:
20
20
  queue_name = getattr(key_manager, "_queue_name", "<unknown>")
21
- deduplication_prefix = key_manager.deduplication("")
21
+ deduplication_prefix = key_manager.deduplication_prefix
22
22
  if _HASH_TAG_PATTERN.search(deduplication_prefix) is None:
23
23
  raise ConfigurationError(
24
24
  "Redis Cluster requires queue keys to share a hash tag; "
@@ -37,6 +37,7 @@ from redis_message_queue._config import (
37
37
  )
38
38
  from redis_message_queue._event import EventOperation, EventOutcome
39
39
  from redis_message_queue._exceptions import (
40
+ ConfigurationError,
40
41
  QueueBackpressureError,
41
42
  RetryBudgetExhaustedError,
42
43
  wrap_lua_response_error,
@@ -200,6 +201,7 @@ class RedisGateway(AbstractRedisGateway):
200
201
  self._pending_claim_ids: dict[str, list[str]] = {}
201
202
  self._recovering_claim_ids: dict[str, set[str]] = {}
202
203
  self._pending_claim_ids_lock = threading.Lock()
204
+ self._drain_pending_claim_ids_lock = threading.Lock()
203
205
  self._event_queue_name: str | None = None
204
206
  self._event_emitter: Callable[..., None] | None = None
205
207
 
@@ -289,6 +291,13 @@ class RedisGateway(AbstractRedisGateway):
289
291
  return isinstance(self._redis_client, redis.RedisCluster)
290
292
 
291
293
  def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
294
+ if not isinstance(dedup_key, str):
295
+ raise TypeError(f"'dedup_key' must be a str, got {type(dedup_key).__name__}")
296
+ if dedup_key == "":
297
+ raise ConfigurationError(
298
+ "'dedup_key' must be a non-empty string; "
299
+ "an empty key would create a bare-prefix Redis marker that silently suppresses unrelated messages"
300
+ )
292
301
  stored_message = encode_stored_message(message)
293
302
  operation_id = uuid.uuid4().hex
294
303
  operation_result_key = self._publish_operation_result_key(dedup_key, operation_id)
@@ -961,6 +970,19 @@ class RedisGateway(AbstractRedisGateway):
961
970
  exit (AA-05-F2). Returns True if no pending ids remain; False if
962
971
  the deadline fired or transient Redis errors prevented full drain.
963
972
  """
973
+ with self._drain_pending_claim_ids_lock:
974
+ return self._drain_pending_claim_ids_unlocked(
975
+ processing_queue,
976
+ deadline_monotonic=deadline_monotonic,
977
+ )
978
+
979
+ def _drain_pending_claim_ids_unlocked(
980
+ self,
981
+ processing_queue: str,
982
+ *,
983
+ deadline_monotonic: float | None,
984
+ ) -> bool:
985
+ """Recover every in-memory pending claim id for ``processing_queue``."""
964
986
  if self._message_visibility_timeout_seconds is not None:
965
987
  recover = self._recover_pending_visibility_timeout_claim
966
988
  else:
@@ -36,6 +36,7 @@ from redis_message_queue._config import (
36
36
  )
37
37
  from redis_message_queue._event import EventOperation, EventOutcome
38
38
  from redis_message_queue._exceptions import (
39
+ ConfigurationError,
39
40
  QueueBackpressureError,
40
41
  RetryBudgetExhaustedError,
41
42
  wrap_lua_response_error,
@@ -191,6 +192,7 @@ class RedisGateway(AbstractRedisGateway):
191
192
  self._pending_claim_ids: dict[str, list[str]] = {}
192
193
  self._recovering_claim_ids: dict[str, set[str]] = {}
193
194
  self._pending_claim_ids_lock = threading.Lock()
195
+ self._drain_pending_claim_ids_lock = asyncio.Lock()
194
196
  self._event_queue_name: str | None = None
195
197
  self._event_emitter: Callable[..., Awaitable[None]] | None = None
196
198
 
@@ -285,6 +287,13 @@ class RedisGateway(AbstractRedisGateway):
285
287
  return isinstance(self._redis_client, redis.asyncio.RedisCluster)
286
288
 
287
289
  async def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
290
+ if not isinstance(dedup_key, str):
291
+ raise TypeError(f"'dedup_key' must be a str, got {type(dedup_key).__name__}")
292
+ if dedup_key == "":
293
+ raise ConfigurationError(
294
+ "'dedup_key' must be a non-empty string; "
295
+ "an empty key would create a bare-prefix Redis marker that silently suppresses unrelated messages"
296
+ )
288
297
  stored_message = encode_stored_message(message)
289
298
  operation_id = uuid.uuid4().hex
290
299
  operation_result_key = self._publish_operation_result_key(dedup_key, operation_id)
@@ -958,6 +967,19 @@ class RedisGateway(AbstractRedisGateway):
958
967
  soft shutdown can flush ambiguous-claim state. Returns True if no
959
968
  pending ids remain; False on deadline expiry or transient errors.
960
969
  """
970
+ async with self._drain_pending_claim_ids_lock:
971
+ return await self._drain_pending_claim_ids_unlocked(
972
+ processing_queue,
973
+ deadline_monotonic=deadline_monotonic,
974
+ )
975
+
976
+ async def _drain_pending_claim_ids_unlocked(
977
+ self,
978
+ processing_queue: str,
979
+ *,
980
+ deadline_monotonic: float | None,
981
+ ) -> bool:
982
+ """Recover every in-memory pending claim id for ``processing_queue``."""
961
983
  if self._message_visibility_timeout_seconds is not None:
962
984
  recover = self._recover_pending_visibility_timeout_claim
963
985
  else:
@@ -24,7 +24,7 @@ from redis_message_queue._exceptions import (
24
24
  GatewayContractError,
25
25
  QueueDrainedError,
26
26
  )
27
- from redis_message_queue._queue_key_manager import QueueKeyManager
27
+ from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
28
28
  from redis_message_queue._redis_cluster import validate_queue_keys_for_redis_cluster
29
29
  from redis_message_queue._stored_message import (
30
30
  ClaimedMessage,
@@ -816,8 +816,7 @@ class RedisMessageQueue:
816
816
  dedup_key = self._get_deduplication_key(message)
817
817
  if inspect.isawaitable(dedup_key):
818
818
  dedup_key = await dedup_key
819
- if not isinstance(dedup_key, str):
820
- raise TypeError(f"'get_deduplication_key' must return a string, got {type(dedup_key).__name__}")
819
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
821
820
  else:
822
821
  dedup_key = message_str
823
822
  dedup_key = self.key.deduplication(dedup_key)
@@ -1136,10 +1135,14 @@ class RedisMessageQueue:
1136
1135
  return self._aclose_result
1137
1136
  loop = asyncio.get_running_loop()
1138
1137
  deadline_monotonic = None if timeout is None else (loop.time() + float(timeout))
1139
- self._aclose_result = await _await_preserving_cancellation(
1138
+ result = await _await_preserving_cancellation(
1140
1139
  drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1141
1140
  )
1142
- return self._aclose_result
1141
+ if result is True:
1142
+ self._aclose_result = True
1143
+ else:
1144
+ self._aclose_result = None
1145
+ return result
1143
1146
 
1144
1147
  async def drain(self, timeout: float | None = None) -> bool:
1145
1148
  """Alias of :meth:`aclose` for explicit async drain naming."""
@@ -25,7 +25,7 @@ from redis_message_queue._exceptions import (
25
25
  GatewayContractError,
26
26
  QueueDrainedError,
27
27
  )
28
- from redis_message_queue._queue_key_manager import QueueKeyManager
28
+ from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
29
29
  from redis_message_queue._redis_cluster import validate_queue_keys_for_redis_cluster
30
30
  from redis_message_queue._redis_gateway import RedisGateway
31
31
  from redis_message_queue._stored_message import (
@@ -566,6 +566,8 @@ class RedisMessageQueue:
566
566
  self._draining = False
567
567
  self._drained = threading.Event()
568
568
  self._publish_lock = threading.Lock()
569
+ self._drain_lock = threading.Lock()
570
+ self._drain_result: bool | None = None
569
571
  self._deduplication = deduplication
570
572
  self._enable_completed_queue = enable_completed_queue
571
573
  self._enable_failed_queue = enable_failed_queue
@@ -777,8 +779,7 @@ class RedisMessageQueue:
777
779
  raise TypeError(
778
780
  "'get_deduplication_key' returned an awaitable; use the async RedisMessageQueue for async callables"
779
781
  )
780
- if not isinstance(dedup_key, str):
781
- raise TypeError(f"'get_deduplication_key' must return a string, got {type(dedup_key).__name__}")
782
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
782
783
  else:
783
784
  dedup_key = message_str
784
785
  dedup_key = self.key.deduplication(dedup_key)
@@ -1080,14 +1081,24 @@ class RedisMessageQueue:
1080
1081
  raise TypeError(f"'timeout' must be a number or None, got {type(timeout).__name__}")
1081
1082
  if timeout is not None and timeout < 0:
1082
1083
  raise ConfigurationError(f"'timeout' must be non-negative when provided, got {timeout}")
1083
- with self._publish_lock:
1084
- self._draining = True
1085
- self._drained.set()
1086
- drainer = getattr(self._redis, "_drain_pending_claim_ids", None)
1087
- if drainer is None:
1088
- return True
1089
- deadline_monotonic = None if timeout is None else (time.monotonic() + float(timeout))
1090
- return drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1084
+ with self._drain_lock:
1085
+ if self._drain_result is True:
1086
+ return True
1087
+
1088
+ with self._publish_lock:
1089
+ self._draining = True
1090
+ self._drained.set()
1091
+ drainer = getattr(self._redis, "_drain_pending_claim_ids", None)
1092
+ if drainer is None:
1093
+ self._drain_result = True
1094
+ return True
1095
+ deadline_monotonic = None if timeout is None else (time.monotonic() + float(timeout))
1096
+ result = drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1097
+ if result is True:
1098
+ self._drain_result = True
1099
+ else:
1100
+ self._drain_result = None
1101
+ return result
1091
1102
 
1092
1103
  def close(self, timeout: float | None = None) -> bool:
1093
1104
  """Alias of :meth:`drain` for consistency with redis-py naming.