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.
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/PKG-INFO +148 -27
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/README.md +147 -26
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/pyproject.toml +1 -1
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_config.py +50 -17
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_exceptions.py +27 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_queue_key_manager.py +23 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_cluster.py +1 -1
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_gateway.py +22 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/_redis_gateway.py +22 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/redis_message_queue.py +8 -5
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/redis_message_queue.py +22 -11
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/LICENSE +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_event.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {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.
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
30
30
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
31
31
|
[](LICENSE)
|
|
32
32
|
[](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>=
|
|
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
|
-
|
|
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("
|
|
55
|
-
|
|
56
|
-
queue.
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
from
|
|
69
|
+
import asyncio
|
|
70
|
+
from redis.asyncio import Redis
|
|
71
|
+
from redis_message_queue.asyncio import RedisMessageQueue
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
`
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](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>=
|
|
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
|
-
|
|
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("
|
|
29
|
-
|
|
30
|
-
queue.
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
from
|
|
43
|
+
import asyncio
|
|
44
|
+
from redis.asyncio import Redis
|
|
45
|
+
from redis_message_queue.asyncio import RedisMessageQueue
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
`
|
|
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.
|
|
@@ -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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -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}"
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_cluster.py
RENAMED
|
@@ -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.
|
|
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; "
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
1138
|
+
result = await _await_preserving_cancellation(
|
|
1140
1139
|
drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
|
|
1141
1140
|
)
|
|
1142
|
-
|
|
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."""
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -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
|
-
|
|
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.
|
|
1084
|
-
self.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
{redis_message_queue-7.0.0 → redis_message_queue-7.0.1}/redis_message_queue/asyncio/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|