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.
Files changed (23) hide show
  1. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/PKG-INFO +212 -37
  2. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/README.md +211 -36
  3. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/pyproject.toml +1 -1
  4. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_config.py +64 -17
  5. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_exceptions.py +27 -0
  6. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_queue_key_manager.py +23 -0
  7. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_redis_cluster.py +1 -1
  8. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_redis_gateway.py +22 -0
  9. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_redis_gateway.py +22 -0
  10. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/redis_message_queue.py +22 -30
  11. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/redis_message_queue.py +42 -43
  12. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/LICENSE +0 -0
  13. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/__init__.py +0 -0
  14. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  15. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_callable_utils.py +0 -0
  16. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_event.py +0 -0
  17. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-7.0.0 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {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: 7.0.0
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
- [![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/v8.0.0-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,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>=6.0.0,<7.0.0"
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
- ### 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(
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
- ### Consume messages
68
+ `RedisMessageQueue` itself is not a context manager. Use
69
+ `with queue.process_message() as message:` for each message.
62
70
 
63
- ```python
64
- from redis import Redis
65
- from redis_message_queue import RedisMessageQueue
71
+ ### Async quickstart
66
72
 
67
- client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
68
- queue = RedisMessageQueue("my_queue", client=client)
73
+ ```python
74
+ import asyncio
75
+ from redis.asyncio import Redis
76
+ from redis_message_queue.asyncio import RedisMessageQueue
69
77
 
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
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. Supports custom key functions for content-based deduplication. Note: deduplication is publish-side only and does not prevent duplicate *delivery* under at-least-once visibility-timeout reclaim |
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
- # Default: deduplicate by SHA-256 hash of canonical message content (1-hour TTL)
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 default
528
- dedup key is a SHA-256 hash of the canonical message payload, so distinct
529
- payloads are distinct keys. Size Redis for:
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 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")`.
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 between the default hash, explicit `None`, or a custom key function.
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.