redis-message-queue 7.0.0__tar.gz → 8.0.0__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-8.0.0}/PKG-INFO +212 -37
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/README.md +211 -36
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/pyproject.toml +1 -1
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_config.py +64 -17
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_exceptions.py +27 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_queue_key_manager.py +23 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_redis_cluster.py +1 -1
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_redis_gateway.py +22 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_redis_gateway.py +22 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/redis_message_queue.py +22 -30
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/redis_message_queue.py +42 -43
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/LICENSE +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_event.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/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:
|
|
3
|
+
Version: 8.0.0
|
|
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,60 @@ 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>=8.0.0,<9.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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
queue = RedisMessageQueue(
|
|
56
|
+
"quickstart",
|
|
57
|
+
client=client,
|
|
58
|
+
deduplication=True,
|
|
59
|
+
get_deduplication_key=lambda msg: msg["id"],
|
|
60
|
+
)
|
|
61
|
+
queue.publish({"id": "msg-1", "text": "hello"})
|
|
62
|
+
with queue.process_message() as message:
|
|
63
|
+
if message is not None:
|
|
64
|
+
print(f"got {message['text']}")
|
|
65
|
+
# Expected output: got hello
|
|
59
66
|
```
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
`RedisMessageQueue` itself is not a context manager. Use
|
|
69
|
+
`with queue.process_message() as message:` for each message.
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
from redis import Redis
|
|
65
|
-
from redis_message_queue import RedisMessageQueue
|
|
71
|
+
### Async quickstart
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
```python
|
|
74
|
+
import asyncio
|
|
75
|
+
from redis.asyncio import Redis
|
|
76
|
+
from redis_message_queue.asyncio import RedisMessageQueue
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
async def main():
|
|
79
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
80
|
+
queue = RedisMessageQueue(
|
|
81
|
+
"quickstart",
|
|
82
|
+
client=client,
|
|
83
|
+
deduplication=True,
|
|
84
|
+
get_deduplication_key=lambda msg: msg["id"],
|
|
85
|
+
)
|
|
86
|
+
await queue.publish({"id": "msg-1", "text": "hello"})
|
|
87
|
+
async with queue.process_message() as message:
|
|
88
|
+
print(f"got {message['text']}")
|
|
89
|
+
await client.aclose()
|
|
90
|
+
|
|
91
|
+
asyncio.run(main()) # Expected output: got hello
|
|
75
92
|
```
|
|
76
93
|
|
|
77
|
-
`RedisMessageQueue` itself is not a context manager. Use
|
|
78
|
-
`with queue.process_message() as message:` for each message.
|
|
79
|
-
|
|
80
94
|
## Why redis-message-queue
|
|
81
95
|
|
|
82
96
|
**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.
|
|
@@ -85,7 +99,7 @@ while True:
|
|
|
85
99
|
|
|
86
100
|
| Feature | Details |
|
|
87
101
|
|---------|---------|
|
|
88
|
-
| **Deduplicated publish** | Lua-scripted atomic SET NX + LPUSH prevents duplicate enqueues within a configurable TTL window (default: 1 hour), even with producer retries.
|
|
102
|
+
| **Deduplicated publish** | Lua-scripted atomic SET NX + LPUSH prevents duplicate enqueues within a configurable TTL window (default: 1 hour), even with producer retries. Requires an explicit `get_deduplication_key` callable so your application defines what counts as a duplicate. Note: deduplication is publish-side only and does not prevent duplicate *delivery* under at-least-once visibility-timeout reclaim |
|
|
89
103
|
| **Visibility-timeout redelivery** | Crashed or stalled consumers' messages are reclaimed and redelivered when a visibility timeout is configured |
|
|
90
104
|
| **Success & failure logs** | Optional completed/failed queues for auditing and reprocessing, with configurable max length to prevent unbounded growth |
|
|
91
105
|
| **Dead-letter queue** | Poison messages that exceed a configurable delivery count are automatically routed to a dead-letter queue instead of being redelivered indefinitely |
|
|
@@ -110,10 +124,7 @@ See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-tim
|
|
|
110
124
|
### Deduplication
|
|
111
125
|
|
|
112
126
|
```python
|
|
113
|
-
#
|
|
114
|
-
queue = RedisMessageQueue("q", client=client, deduplication=True)
|
|
115
|
-
|
|
116
|
-
# Custom dedup key (e.g., deduplicate by order ID only)
|
|
127
|
+
# Deduplicate by order ID for a 1-hour TTL
|
|
117
128
|
queue = RedisMessageQueue(
|
|
118
129
|
"q", client=client,
|
|
119
130
|
deduplication=True,
|
|
@@ -124,6 +135,30 @@ queue = RedisMessageQueue(
|
|
|
124
135
|
queue = RedisMessageQueue("q", client=client, deduplication=False)
|
|
125
136
|
```
|
|
126
137
|
|
|
138
|
+
#### Dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
|
|
139
|
+
|
|
140
|
+
When `deduplication=True`, `get_deduplication_key` is required. The callable is
|
|
141
|
+
called once per publish and must return a `str` that uniquely represents the
|
|
142
|
+
deduplication scope for that message. Returning `None` or `""` raises
|
|
143
|
+
`ConfigurationError` at publish time; returning a non-`str` value raises
|
|
144
|
+
`TypeError`.
|
|
145
|
+
|
|
146
|
+
Use stable, high-cardinality keys that include any tenant or account boundary
|
|
147
|
+
needed by your system:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
queue = RedisMessageQueue(
|
|
151
|
+
"orders",
|
|
152
|
+
client=client,
|
|
153
|
+
deduplication=True,
|
|
154
|
+
get_deduplication_key=lambda msg: f"{msg['tenant_id']}:{msg['order_id']}",
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Avoid fallback patterns such as `lambda msg: msg.get("order_id", "")`.
|
|
159
|
+
Missing fields should fail loudly instead of collapsing unrelated messages into
|
|
160
|
+
one deduplication key.
|
|
161
|
+
|
|
127
162
|
### Success and failure tracking
|
|
128
163
|
|
|
129
164
|
```python
|
|
@@ -524,9 +559,9 @@ avoids the parent-construct hazard entirely.
|
|
|
524
559
|
### Redis memory sizing for deduplication and replay metadata
|
|
525
560
|
|
|
526
561
|
When deduplication is enabled, each distinct dedup key creates one Redis string
|
|
527
|
-
for `message_deduplication_log_ttl_seconds` (default: 3600 seconds). The
|
|
528
|
-
|
|
529
|
-
|
|
562
|
+
for `message_deduplication_log_ttl_seconds` (default: 3600 seconds). The dedup
|
|
563
|
+
key is whatever your `get_deduplication_key` callable returns, so choose a
|
|
564
|
+
short, stable logical ID and size Redis for:
|
|
530
565
|
|
|
531
566
|
```text
|
|
532
567
|
peak_unique_publish_rate_per_second
|
|
@@ -588,17 +623,46 @@ events_total = Counter(
|
|
|
588
623
|
"redis-message-queue lifecycle events",
|
|
589
624
|
["queue", "operation", "outcome", "exception_type"],
|
|
590
625
|
)
|
|
626
|
+
SPAN_SINK_TRUSTED = False
|
|
591
627
|
|
|
592
628
|
def observe(event: QueueEvent) -> None:
|
|
593
629
|
events_total.labels(
|
|
594
630
|
event.queue, event.operation, event.outcome, event.exception_type or ""
|
|
595
631
|
).inc()
|
|
596
|
-
if event.error is not None:
|
|
632
|
+
if event.error is not None and SPAN_SINK_TRUSTED:
|
|
597
633
|
trace.get_current_span().record_exception(event.error)
|
|
598
634
|
|
|
599
635
|
queue = RedisMessageQueue("jobs", client=client, on_event=observe)
|
|
600
636
|
```
|
|
601
637
|
|
|
638
|
+
#### ⚠ Secrets in `event.error`
|
|
639
|
+
|
|
640
|
+
`event.error` is the actual exception object — it retains the exception
|
|
641
|
+
message, `__cause__` chain, and traceback. These can contain sensitive content:
|
|
642
|
+
Redis credentials in connection-error messages, message payloads in handler
|
|
643
|
+
exceptions, environment values in stack-frame locals.
|
|
644
|
+
|
|
645
|
+
When exporting to telemetry sinks (OpenTelemetry, Sentry, Datadog), prefer the
|
|
646
|
+
redaction-friendly `event.exception_type` for metrics and labels. Use
|
|
647
|
+
`event.error` for full structured error data ONLY if your sink is
|
|
648
|
+
trust-equivalent to your application logs and is access-controlled.
|
|
649
|
+
|
|
650
|
+
Recommended pattern:
|
|
651
|
+
|
|
652
|
+
```python
|
|
653
|
+
def on_event(event: QueueEvent) -> None:
|
|
654
|
+
# Metric labels — always safe (just the exception class name)
|
|
655
|
+
metric_counter.labels(
|
|
656
|
+
operation=event.operation,
|
|
657
|
+
outcome=event.outcome,
|
|
658
|
+
exception_type=event.exception_type or "none",
|
|
659
|
+
).inc()
|
|
660
|
+
|
|
661
|
+
# Full exception — only if your span sink is trusted
|
|
662
|
+
if event.error is not None and SPAN_SINK_TRUSTED:
|
|
663
|
+
span.record_exception(event.error)
|
|
664
|
+
```
|
|
665
|
+
|
|
602
666
|
#### Event dispatch context
|
|
603
667
|
|
|
604
668
|
Callbacks fire inline:
|
|
@@ -702,12 +766,87 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
|
|
|
702
766
|
|
|
703
767
|
## Upgrading
|
|
704
768
|
|
|
769
|
+
### v7 to v8 migration
|
|
770
|
+
|
|
771
|
+
v8.0.0 removes implicit dedup key generation. Deduplication is opt-in and
|
|
772
|
+
`deduplication=True` now requires `get_deduplication_key`. If you were relying
|
|
773
|
+
on v7's automatic content hash, provide the equivalent callable explicitly.
|
|
774
|
+
|
|
775
|
+
Before:
|
|
776
|
+
|
|
777
|
+
```python
|
|
778
|
+
queue = RedisMessageQueue(
|
|
779
|
+
"orders",
|
|
780
|
+
client=client,
|
|
781
|
+
deduplication=True,
|
|
782
|
+
)
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
After, prefer a stable logical ID:
|
|
786
|
+
|
|
787
|
+
```python
|
|
788
|
+
queue = RedisMessageQueue(
|
|
789
|
+
"orders",
|
|
790
|
+
client=client,
|
|
791
|
+
deduplication=True,
|
|
792
|
+
get_deduplication_key=lambda msg: msg["order_id"],
|
|
793
|
+
)
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
To preserve v7 content-hash behavior mechanically:
|
|
797
|
+
|
|
798
|
+
```python
|
|
799
|
+
import hashlib
|
|
800
|
+
import json
|
|
801
|
+
|
|
802
|
+
queue = RedisMessageQueue(
|
|
803
|
+
"orders",
|
|
804
|
+
client=client,
|
|
805
|
+
deduplication=True,
|
|
806
|
+
get_deduplication_key=lambda msg: hashlib.sha256(
|
|
807
|
+
json.dumps(msg, sort_keys=True).encode()
|
|
808
|
+
).hexdigest(),
|
|
809
|
+
)
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
If you do not need publish-side deduplication, omit `deduplication` or set
|
|
813
|
+
`deduplication=False`.
|
|
814
|
+
|
|
705
815
|
### v6 to v7 migration
|
|
706
816
|
|
|
707
|
-
v7.0.0
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
`
|
|
817
|
+
v7.0.0 has four breaking changes to check during upgrade.
|
|
818
|
+
|
|
819
|
+
**AC-02: queue event operation/outcome types are `StrEnum` members.**
|
|
820
|
+
Runtime string comparisons keep working because `StrEnum` subclasses `str`,
|
|
821
|
+
but type-strict code should replace old `Literal[...]` annotations and raw
|
|
822
|
+
string constants with enum members.
|
|
823
|
+
|
|
824
|
+
Before:
|
|
825
|
+
|
|
826
|
+
```python
|
|
827
|
+
from typing import Literal
|
|
828
|
+
|
|
829
|
+
QueueOperation = Literal["publish", "claim", "ack"]
|
|
830
|
+
|
|
831
|
+
def record(operation: QueueOperation) -> None:
|
|
832
|
+
if operation == "publish":
|
|
833
|
+
print("published")
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
After:
|
|
837
|
+
|
|
838
|
+
```python
|
|
839
|
+
from redis_message_queue import EventOperation
|
|
840
|
+
|
|
841
|
+
def record(operation: EventOperation) -> None:
|
|
842
|
+
if operation is EventOperation.PUBLISH:
|
|
843
|
+
print("published")
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
**AC-03: drained queue instances refuse new publishes.** After
|
|
847
|
+
`queue.drain()` / `queue.close()` (sync) or `await queue.drain()` /
|
|
848
|
+
`await queue.aclose()` (async), the same queue instance rejects `publish()`
|
|
849
|
+
with `QueueDrainedError("queue is drained")`.
|
|
711
850
|
|
|
712
851
|
This state is queue-local and process-local; it is not stored in Redis. If a
|
|
713
852
|
producer must continue publishing after a worker has drained, use a separate
|
|
@@ -715,6 +854,42 @@ producer must continue publishing after a worker has drained, use a separate
|
|
|
715
854
|
shutdown, catch `QueueDrainedError` only at boundaries where late publishes are
|
|
716
855
|
expected and safe to drop or reschedule.
|
|
717
856
|
|
|
857
|
+
```python
|
|
858
|
+
from redis_message_queue import QueueDrainedError
|
|
859
|
+
|
|
860
|
+
try:
|
|
861
|
+
queue.publish("late shutdown audit event")
|
|
862
|
+
except QueueDrainedError:
|
|
863
|
+
# The queue instance already began draining; drop or reschedule elsewhere.
|
|
864
|
+
pass
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
**AC-09: unsafe `drop_oldest` combinations now fail at construction.** These
|
|
868
|
+
configurations raise `ConfigurationError` before the queue or gateway is
|
|
869
|
+
created:
|
|
870
|
+
|
|
871
|
+
- `pending_overload_policy="drop_oldest"` with `max_pending_length=None`:
|
|
872
|
+
`drop_oldest requires max_pending_length to be set. Use a positive
|
|
873
|
+
max_pending_length to define what can be dropped, or use
|
|
874
|
+
pending_overload_policy='raise' or 'block' for unbounded queues.`
|
|
875
|
+
- `pending_overload_policy="drop_oldest"` with deduplication enabled or
|
|
876
|
+
`get_deduplication_key` configured:
|
|
877
|
+
`'pending_overload_policy=drop_oldest' cannot be used with deduplication
|
|
878
|
+
because dropped messages leave their deduplication keys in Redis, causing
|
|
879
|
+
future publishes of the same payload to be silently suppressed. Use 'raise'
|
|
880
|
+
or 'block' for deduplicated queues, or disable deduplication if 'drop_oldest'
|
|
881
|
+
is required.`
|
|
882
|
+
- `pending_overload_policy="drop_oldest"` with `max_delivery_count` set:
|
|
883
|
+
`drop_oldest is incompatible with max_delivery_count (set
|
|
884
|
+
max_delivery_count=None or pick another policy to avoid silent loss of
|
|
885
|
+
pending DLQ candidates). Use pending_overload_policy='raise' or 'block' when
|
|
886
|
+
dead-letter handling is required.`
|
|
887
|
+
|
|
888
|
+
**AC-16: redis-py is capped below 8.0.0.** The package dependency is
|
|
889
|
+
`redis>=5.0.0,<8.0.0` until redis-py 8 RESP3-default behavior is verified.
|
|
890
|
+
Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
|
|
891
|
+
8.0.0 beta explicitly, downgrade with `pip install "redis<8.0.0"`.
|
|
892
|
+
|
|
718
893
|
### Configuration changes on live queues
|
|
719
894
|
|
|
720
895
|
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
@@ -724,7 +899,7 @@ expected and safe to drop or reschedule.
|
|
|
724
899
|
- **Do not toggle visibility timeout in either direction with messages in processing.** Messages claimed by non-VT consumers have no lease metadata, so VT-enabled consumers cannot reclaim them. Disabling VT later orphans existing lease deadline, lease token, and delivery count metadata and removes crash recovery for those in-flight messages. Drain the processing queue first.
|
|
725
900
|
- **Reducing `max_delivery_count` retroactively DLQs messages.** The delivery count hash persists across restarts. Messages whose accumulated count exceeds the new limit are immediately dead-lettered on next claim.
|
|
726
901
|
- **Changing `max_delivery_count` from a number to `None` leaves delivery metadata behind.** The delivery count hash continues to exist but is no longer consulted. Use this only after draining or after planning manual cleanup of the delivery-count hash.
|
|
727
|
-
- **Changing `get_deduplication_key` changes the dedup keyspace.** Existing dedup records become inert for the duration of their TTL. Drain the queue or clear the old deduplication keys before switching
|
|
902
|
+
- **Changing `get_deduplication_key` changes the dedup keyspace.** Existing dedup records become inert for the duration of their TTL. Drain the queue or clear the old deduplication keys before switching key functions.
|
|
728
903
|
- **Disabling `deduplication` has a retention-window overlap.** Existing dedup records remain in Redis until their TTL expires, but new publishes bypass them. Republishes that would have been suppressed under the old setting can enqueue during that window.
|
|
729
904
|
- **Disabling `enable_failed_queue` stops recording handler failures.** Existing failed entries remain in Redis, but new failures are removed from `processing` without being appended to the failed queue. If `max_delivery_count=None` is also set, repeated handler failures can be dropped with no DLQ or failed-queue record; see [Dead-letter queue](#dead-letter-queue).
|
|
730
905
|
- **Lowering `max_completed_length` or `max_failed_length` trims existing history.** The next completed or failed move calls `LTRIM`, so changing `None` to `N` or lowering `N` can immediately reduce historical entries to the new cap.
|