redis-message-queue 7.0.1__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.1 → redis_message_queue-8.0.0}/PKG-INFO +77 -23
  2. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/README.md +76 -22
  3. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/pyproject.toml +1 -1
  4. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_config.py +14 -0
  5. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/redis_message_queue.py +15 -26
  6. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/redis_message_queue.py +21 -33
  7. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/LICENSE +0 -0
  8. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/__init__.py +0 -0
  9. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  10. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_callable_utils.py +0 -0
  11. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_event.py +0 -0
  12. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_exceptions.py +0 -0
  13. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_queue_key_manager.py +0 -0
  14. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_redis_cluster.py +0 -0
  15. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_redis_gateway.py +0 -0
  16. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/_stored_message.py +0 -0
  17. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/__init__.py +0 -0
  18. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  19. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/asyncio/_redis_gateway.py +0 -0
  20. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-7.0.1 → redis_message_queue-8.0.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-7.0.1 → 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.1
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.1-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,7 +37,7 @@ 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>=7.0.0,<8.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.
@@ -52,11 +52,16 @@ from redis import Redis
52
52
  from redis_message_queue import RedisMessageQueue
53
53
 
54
54
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
55
- queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
56
- queue.publish("hello")
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"})
57
62
  with queue.process_message() as message:
58
63
  if message is not None:
59
- print(f"got {message}")
64
+ print(f"got {message['text']}")
60
65
  # Expected output: got hello
61
66
  ```
62
67
 
@@ -72,10 +77,15 @@ from redis_message_queue.asyncio import RedisMessageQueue
72
77
 
73
78
  async def main():
74
79
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
75
- queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
76
- await queue.publish("hello")
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"})
77
87
  async with queue.process_message() as message:
78
- print(f"got {message}")
88
+ print(f"got {message['text']}")
79
89
  await client.aclose()
80
90
 
81
91
  asyncio.run(main()) # Expected output: got hello
@@ -89,7 +99,7 @@ asyncio.run(main()) # Expected output: got hello
89
99
 
90
100
  | Feature | Details |
91
101
  |---------|---------|
92
- | **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 |
93
103
  | **Visibility-timeout redelivery** | Crashed or stalled consumers' messages are reclaimed and redelivered when a visibility timeout is configured |
94
104
  | **Success & failure logs** | Optional completed/failed queues for auditing and reprocessing, with configurable max length to prevent unbounded growth |
95
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 |
@@ -114,10 +124,7 @@ See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-tim
114
124
  ### Deduplication
115
125
 
116
126
  ```python
117
- # Default: deduplicate by SHA-256 hash of canonical message content (1-hour TTL)
118
- queue = RedisMessageQueue("q", client=client, deduplication=True)
119
-
120
- # Custom dedup key (e.g., deduplicate by order ID only)
127
+ # Deduplicate by order ID for a 1-hour TTL
121
128
  queue = RedisMessageQueue(
122
129
  "q", client=client,
123
130
  deduplication=True,
@@ -128,12 +135,13 @@ queue = RedisMessageQueue(
128
135
  queue = RedisMessageQueue("q", client=client, deduplication=False)
129
136
  ```
130
137
 
131
- #### Custom dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
138
+ #### Dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
132
139
 
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`.
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`.
137
145
 
138
146
  Use stable, high-cardinality keys that include any tenant or account boundary
139
147
  needed by your system:
@@ -551,9 +559,9 @@ avoids the parent-construct hazard entirely.
551
559
  ### Redis memory sizing for deduplication and replay metadata
552
560
 
553
561
  When deduplication is enabled, each distinct dedup key creates one Redis string
554
- for `message_deduplication_log_ttl_seconds` (default: 3600 seconds). The default
555
- dedup key is a SHA-256 hash of the canonical message payload, so distinct
556
- 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:
557
565
 
558
566
  ```text
559
567
  peak_unique_publish_rate_per_second
@@ -758,6 +766,52 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
758
766
 
759
767
  ## Upgrading
760
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
+
761
815
  ### v6 to v7 migration
762
816
 
763
817
  v7.0.0 has four breaking changes to check during upgrade.
@@ -845,7 +899,7 @@ Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
845
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.
846
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.
847
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.
848
- - **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.
849
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.
850
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).
851
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.
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
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)
3
+ [![PyPI Version](https://img.shields.io/badge/v8.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
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,7 +11,7 @@
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>=7.0.0,<8.0.0"
14
+ pip install "redis-message-queue>=8.0.0,<9.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -26,11 +26,16 @@ from redis import Redis
26
26
  from redis_message_queue import RedisMessageQueue
27
27
 
28
28
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
29
- queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
30
- queue.publish("hello")
29
+ queue = RedisMessageQueue(
30
+ "quickstart",
31
+ client=client,
32
+ deduplication=True,
33
+ get_deduplication_key=lambda msg: msg["id"],
34
+ )
35
+ queue.publish({"id": "msg-1", "text": "hello"})
31
36
  with queue.process_message() as message:
32
37
  if message is not None:
33
- print(f"got {message}")
38
+ print(f"got {message['text']}")
34
39
  # Expected output: got hello
35
40
  ```
36
41
 
@@ -46,10 +51,15 @@ from redis_message_queue.asyncio import RedisMessageQueue
46
51
 
47
52
  async def main():
48
53
  client = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
49
- queue = RedisMessageQueue("quickstart", client=client, deduplication=True)
50
- await queue.publish("hello")
54
+ queue = RedisMessageQueue(
55
+ "quickstart",
56
+ client=client,
57
+ deduplication=True,
58
+ get_deduplication_key=lambda msg: msg["id"],
59
+ )
60
+ await queue.publish({"id": "msg-1", "text": "hello"})
51
61
  async with queue.process_message() as message:
52
- print(f"got {message}")
62
+ print(f"got {message['text']}")
53
63
  await client.aclose()
54
64
 
55
65
  asyncio.run(main()) # Expected output: got hello
@@ -63,7 +73,7 @@ asyncio.run(main()) # Expected output: got hello
63
73
 
64
74
  | Feature | Details |
65
75
  |---------|---------|
66
- | **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 |
76
+ | **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 |
67
77
  | **Visibility-timeout redelivery** | Crashed or stalled consumers' messages are reclaimed and redelivered when a visibility timeout is configured |
68
78
  | **Success & failure logs** | Optional completed/failed queues for auditing and reprocessing, with configurable max length to prevent unbounded growth |
69
79
  | **Dead-letter queue** | Poison messages that exceed a configurable delivery count are automatically routed to a dead-letter queue instead of being redelivered indefinitely |
@@ -88,10 +98,7 @@ See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-tim
88
98
  ### Deduplication
89
99
 
90
100
  ```python
91
- # Default: deduplicate by SHA-256 hash of canonical message content (1-hour TTL)
92
- queue = RedisMessageQueue("q", client=client, deduplication=True)
93
-
94
- # Custom dedup key (e.g., deduplicate by order ID only)
101
+ # Deduplicate by order ID for a 1-hour TTL
95
102
  queue = RedisMessageQueue(
96
103
  "q", client=client,
97
104
  deduplication=True,
@@ -102,12 +109,13 @@ queue = RedisMessageQueue(
102
109
  queue = RedisMessageQueue("q", client=client, deduplication=False)
103
110
  ```
104
111
 
105
- #### Custom dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
112
+ #### Dedup key callable must return a non-empty, high-cardinality, tenant-scoped string
106
113
 
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`.
114
+ When `deduplication=True`, `get_deduplication_key` is required. The callable is
115
+ called once per publish and must return a `str` that uniquely represents the
116
+ deduplication scope for that message. Returning `None` or `""` raises
117
+ `ConfigurationError` at publish time; returning a non-`str` value raises
118
+ `TypeError`.
111
119
 
112
120
  Use stable, high-cardinality keys that include any tenant or account boundary
113
121
  needed by your system:
@@ -525,9 +533,9 @@ avoids the parent-construct hazard entirely.
525
533
  ### Redis memory sizing for deduplication and replay metadata
526
534
 
527
535
  When deduplication is enabled, each distinct dedup key creates one Redis string
528
- for `message_deduplication_log_ttl_seconds` (default: 3600 seconds). The default
529
- dedup key is a SHA-256 hash of the canonical message payload, so distinct
530
- payloads are distinct keys. Size Redis for:
536
+ for `message_deduplication_log_ttl_seconds` (default: 3600 seconds). The dedup
537
+ key is whatever your `get_deduplication_key` callable returns, so choose a
538
+ short, stable logical ID and size Redis for:
531
539
 
532
540
  ```text
533
541
  peak_unique_publish_rate_per_second
@@ -732,6 +740,52 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
732
740
 
733
741
  ## Upgrading
734
742
 
743
+ ### v7 to v8 migration
744
+
745
+ v8.0.0 removes implicit dedup key generation. Deduplication is opt-in and
746
+ `deduplication=True` now requires `get_deduplication_key`. If you were relying
747
+ on v7's automatic content hash, provide the equivalent callable explicitly.
748
+
749
+ Before:
750
+
751
+ ```python
752
+ queue = RedisMessageQueue(
753
+ "orders",
754
+ client=client,
755
+ deduplication=True,
756
+ )
757
+ ```
758
+
759
+ After, prefer a stable logical ID:
760
+
761
+ ```python
762
+ queue = RedisMessageQueue(
763
+ "orders",
764
+ client=client,
765
+ deduplication=True,
766
+ get_deduplication_key=lambda msg: msg["order_id"],
767
+ )
768
+ ```
769
+
770
+ To preserve v7 content-hash behavior mechanically:
771
+
772
+ ```python
773
+ import hashlib
774
+ import json
775
+
776
+ queue = RedisMessageQueue(
777
+ "orders",
778
+ client=client,
779
+ deduplication=True,
780
+ get_deduplication_key=lambda msg: hashlib.sha256(
781
+ json.dumps(msg, sort_keys=True).encode()
782
+ ).hexdigest(),
783
+ )
784
+ ```
785
+
786
+ If you do not need publish-side deduplication, omit `deduplication` or set
787
+ `deduplication=False`.
788
+
735
789
  ### v6 to v7 migration
736
790
 
737
791
  v7.0.0 has four breaking changes to check during upgrade.
@@ -819,7 +873,7 @@ Users on redis-py 7.x and earlier are unaffected. If you installed a redis-py
819
873
  - **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.
820
874
  - **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.
821
875
  - **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.
822
- - **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.
876
+ - **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.
823
877
  - **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.
824
878
  - **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).
825
879
  - **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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "7.0.1"
3
+ version = "8.0.0"
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"
@@ -32,6 +32,11 @@ DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS = 1.0
32
32
  INTERRUPTIBLE_RETRY_SLEEP_POLL_SECONDS = 0.05
33
33
  PENDING_OVERLOAD_LUA_SENTINEL = -1
34
34
  PENDING_OVERLOAD_POLICIES = ("raise", "drop_oldest", "block")
35
+ DEDUPLICATION_REQUIRES_KEY_MESSAGE = (
36
+ "deduplication=True requires get_deduplication_key (callable returning a non-empty str). "
37
+ "Pass a callable like `lambda msg: msg['id']` (recommended: a stable logical ID), "
38
+ "or set deduplication=False."
39
+ )
35
40
 
36
41
 
37
42
  def is_redis_retryable_exception(exception):
@@ -301,6 +306,15 @@ def validate_gateway_parameters(
301
306
  )
302
307
 
303
308
 
309
+ def validate_dedup_configuration(
310
+ *,
311
+ deduplication: bool,
312
+ get_deduplication_key: object,
313
+ ) -> None:
314
+ if deduplication and get_deduplication_key is None:
315
+ raise ConfigurationError(DEDUPLICATION_REQUIRES_KEY_MESSAGE)
316
+
317
+
304
318
  def validate_pending_backpressure_parameters(
305
319
  max_pending_length: int | None,
306
320
  pending_overload_policy: str,
@@ -15,6 +15,7 @@ import redis.exceptions
15
15
  from redis_message_queue._callable_utils import is_async_callable
16
16
  from redis_message_queue._config import (
17
17
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
18
+ validate_dedup_configuration,
18
19
  validate_pending_backpressure_parameters,
19
20
  )
20
21
  from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
@@ -232,16 +233,6 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
232
233
  return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
233
234
 
234
235
 
235
- def _canonical_bytes(message: str | dict) -> bytes:
236
- if isinstance(message, dict):
237
- return json.dumps(message, sort_keys=True, allow_nan=False).encode("utf-8")
238
- return message.encode("utf-8")
239
-
240
-
241
- def _default_get_deduplication_key(message: str | dict) -> str:
242
- return hashlib.sha256(_canonical_bytes(message)).hexdigest()
243
-
244
-
245
236
  def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pending_key: str) -> None:
246
237
  """Reject reuse of a dead-letter-enabled gateway across different queues.
247
238
 
@@ -461,7 +452,7 @@ class RedisMessageQueue:
461
452
  *,
462
453
  gateway: Optional[AbstractRedisGateway] = None,
463
454
  client: Optional[redis.asyncio.Redis] = None,
464
- deduplication: bool = True,
455
+ deduplication: bool = False,
465
456
  enable_completed_queue: bool = False,
466
457
  enable_failed_queue: bool = False,
467
458
  visibility_timeout_seconds: int | None = _DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
@@ -473,7 +464,7 @@ class RedisMessageQueue:
473
464
  pending_overload_policy: Literal["raise", "drop_oldest", "block"] = "raise",
474
465
  pending_overload_block_timeout_seconds: float = DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
475
466
  key_separator: str = "::",
476
- get_deduplication_key: Optional[Callable[[str | dict], str]] = _default_get_deduplication_key,
467
+ get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
477
468
  interrupt: BaseGracefulInterruptHandler | None = None,
478
469
  on_heartbeat_failure: Callable[[], Awaitable[None] | None] | None = None,
479
470
  on_event: Callable[[QueueEvent], Awaitable[None]] | None = None,
@@ -489,10 +480,9 @@ class RedisMessageQueue:
489
480
  auto-derived dead-letter queue. Set it to ``None`` for unlimited
490
481
  redelivery.
491
482
 
492
- ``get_deduplication_key`` defaults to a SHA-256 hash of the canonical
493
- message string. Passing ``None`` uses the literal serialized message as
494
- the deduplication key; passing a callable or coroutine callable lets you
495
- define a custom keyspace.
483
+ ``deduplication=True`` requires ``get_deduplication_key`` to be a
484
+ callable that returns a non-empty string. Use a stable logical ID for
485
+ the deduplication keyspace.
496
486
 
497
487
  ``max_pending_length`` defaults to ``None`` (unbounded). Set it to a
498
488
  positive integer to cap pending-list depth during publish.
@@ -575,15 +565,17 @@ class RedisMessageQueue:
575
565
  raise ConfigurationError(
576
566
  f"'visibility_timeout_seconds' must be positive when provided, got {visibility_timeout_seconds}"
577
567
  )
578
- get_deduplication_key_was_configured = (
579
- get_deduplication_key is not None and get_deduplication_key is not _default_get_deduplication_key
580
- )
568
+ get_deduplication_key_was_configured = get_deduplication_key is not None
581
569
  if get_deduplication_key is not None and not callable(get_deduplication_key):
582
570
  raise TypeError(
583
571
  f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}."
584
572
  " Expected a function that takes the message (str | dict) and returns a str (or an awaitable thereof)."
585
573
  " Example: get_deduplication_key=lambda msg: msg['user_id']"
586
574
  )
575
+ validate_dedup_configuration(
576
+ deduplication=deduplication,
577
+ get_deduplication_key=get_deduplication_key,
578
+ )
587
579
  validate_pending_backpressure_parameters(
588
580
  max_pending_length,
589
581
  pending_overload_policy,
@@ -812,13 +804,10 @@ class RedisMessageQueue:
812
804
  await self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
813
805
  return True
814
806
 
815
- if self._get_deduplication_key is not None:
816
- dedup_key = self._get_deduplication_key(message)
817
- if inspect.isawaitable(dedup_key):
818
- dedup_key = await dedup_key
819
- dedup_key = validate_callable_deduplication_key(dedup_key, message)
820
- else:
821
- dedup_key = message_str
807
+ dedup_key = self._get_deduplication_key(message)
808
+ if inspect.isawaitable(dedup_key):
809
+ dedup_key = await dedup_key
810
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
822
811
  dedup_key = self.key.deduplication(dedup_key)
823
812
 
824
813
  try:
@@ -16,6 +16,7 @@ from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
16
16
  from redis_message_queue._callable_utils import is_async_callable
17
17
  from redis_message_queue._config import (
18
18
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
19
+ validate_dedup_configuration,
19
20
  validate_pending_backpressure_parameters,
20
21
  )
21
22
  from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
@@ -166,16 +167,6 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
166
167
  return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
167
168
 
168
169
 
169
- def _canonical_bytes(message: str | dict) -> bytes:
170
- if isinstance(message, dict):
171
- return json.dumps(message, sort_keys=True, allow_nan=False).encode("utf-8")
172
- return message.encode("utf-8")
173
-
174
-
175
- def _default_get_deduplication_key(message: str | dict) -> str:
176
- return hashlib.sha256(_canonical_bytes(message)).hexdigest()
177
-
178
-
179
170
  def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pending_key: str) -> None:
180
171
  """Reject reuse of a dead-letter-enabled gateway across different queues.
181
172
 
@@ -402,7 +393,7 @@ class RedisMessageQueue:
402
393
  *,
403
394
  gateway: Optional[AbstractRedisGateway] = None,
404
395
  client: Optional[redis.Redis] = None,
405
- deduplication: bool = True,
396
+ deduplication: bool = False,
406
397
  enable_completed_queue: bool = False,
407
398
  enable_failed_queue: bool = False,
408
399
  visibility_timeout_seconds: int | None = _DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
@@ -414,7 +405,7 @@ class RedisMessageQueue:
414
405
  pending_overload_policy: Literal["raise", "drop_oldest", "block"] = "raise",
415
406
  pending_overload_block_timeout_seconds: float = DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
416
407
  key_separator: str = "::",
417
- get_deduplication_key: Optional[Callable[[str | dict], str]] = _default_get_deduplication_key,
408
+ get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
418
409
  interrupt: BaseGracefulInterruptHandler | None = None,
419
410
  on_heartbeat_failure: Callable[[], None] | None = None,
420
411
  on_event: Callable[[QueueEvent], None] | None = None,
@@ -430,10 +421,9 @@ class RedisMessageQueue:
430
421
  auto-derived dead-letter queue. Set it to ``None`` for unlimited
431
422
  redelivery.
432
423
 
433
- ``get_deduplication_key`` defaults to a SHA-256 hash of the canonical
434
- message string. Passing ``None`` uses the literal serialized message as
435
- the deduplication key; passing a callable lets you define a custom
436
- keyspace.
424
+ ``deduplication=True`` requires ``get_deduplication_key`` to be a
425
+ callable that returns a non-empty string. Use a stable logical ID for
426
+ the deduplication keyspace.
437
427
 
438
428
  ``max_pending_length`` defaults to ``None`` (unbounded). Set it to a
439
429
  positive integer to cap pending-list depth during publish.
@@ -515,9 +505,7 @@ class RedisMessageQueue:
515
505
  raise ConfigurationError(
516
506
  f"'visibility_timeout_seconds' must be positive when provided, got {visibility_timeout_seconds}"
517
507
  )
518
- get_deduplication_key_was_configured = (
519
- get_deduplication_key is not None and get_deduplication_key is not _default_get_deduplication_key
520
- )
508
+ get_deduplication_key_was_configured = get_deduplication_key is not None
521
509
  if get_deduplication_key is not None and not callable(get_deduplication_key):
522
510
  raise TypeError(
523
511
  f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}."
@@ -529,6 +517,10 @@ class RedisMessageQueue:
529
517
  "'get_deduplication_key' is an async callable; "
530
518
  "use the async RedisMessageQueue from redis_message_queue.asyncio instead"
531
519
  )
520
+ validate_dedup_configuration(
521
+ deduplication=deduplication,
522
+ get_deduplication_key=get_deduplication_key,
523
+ )
532
524
  validate_pending_backpressure_parameters(
533
525
  max_pending_length,
534
526
  pending_overload_policy,
@@ -766,22 +758,18 @@ class RedisMessageQueue:
766
758
  self._emit_event("publish", "success", duration_ms=_duration_ms(started_at))
767
759
  return True
768
760
 
769
- if self._get_deduplication_key is not None:
770
- dedup_key = self._get_deduplication_key(message)
771
- if inspect.isawaitable(dedup_key):
772
- is_coroutine = inspect.iscoroutine(dedup_key)
773
- _close_or_cancel_awaitable(dedup_key)
774
- if is_coroutine:
775
- raise TypeError(
776
- "'get_deduplication_key' returned a coroutine; "
777
- "use the async RedisMessageQueue for async callables"
778
- )
761
+ dedup_key = self._get_deduplication_key(message)
762
+ if inspect.isawaitable(dedup_key):
763
+ is_coroutine = inspect.iscoroutine(dedup_key)
764
+ _close_or_cancel_awaitable(dedup_key)
765
+ if is_coroutine:
779
766
  raise TypeError(
780
- "'get_deduplication_key' returned an awaitable; use the async RedisMessageQueue for async callables"
767
+ "'get_deduplication_key' returned a coroutine; use the async RedisMessageQueue for async callables"
781
768
  )
782
- dedup_key = validate_callable_deduplication_key(dedup_key, message)
783
- else:
784
- dedup_key = message_str
769
+ raise TypeError(
770
+ "'get_deduplication_key' returned an awaitable; use the async RedisMessageQueue for async callables"
771
+ )
772
+ dedup_key = validate_callable_deduplication_key(dedup_key, message)
785
773
  dedup_key = self.key.deduplication(dedup_key)
786
774
 
787
775
  try: