redis-message-queue 8.2.1__tar.gz → 8.2.2__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-8.2.1 → redis_message_queue-8.2.2}/.gitignore +10 -1
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/PKG-INFO +47 -13
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/README.md +46 -12
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/pyproject.toml +8 -2
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_gateway.py +3 -3
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/_redis_gateway.py +3 -3
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/redis_message_queue.py +26 -14
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/redis_message_queue.py +24 -8
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/LICENSE +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_config.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_event.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_exceptions.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_payload_limits.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_queue_key_manager.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/py.typed +0 -0
|
@@ -90,7 +90,7 @@ ipython_config.py
|
|
|
90
90
|
# pyenv
|
|
91
91
|
# For a library or package, you might want to ignore these files since the code is
|
|
92
92
|
# intended to run in multiple environments; otherwise, check them in:
|
|
93
|
-
|
|
93
|
+
.python-version
|
|
94
94
|
|
|
95
95
|
# pipenv
|
|
96
96
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
@@ -141,6 +141,15 @@ venv.bak/
|
|
|
141
141
|
.dmypy.json
|
|
142
142
|
dmypy.json
|
|
143
143
|
|
|
144
|
+
# Ruff
|
|
145
|
+
.ruff_cache/
|
|
146
|
+
|
|
147
|
+
# Coverage tooling output
|
|
148
|
+
lcov.info
|
|
149
|
+
|
|
150
|
+
# PyPI upload config — credentials, never commit
|
|
151
|
+
.pypirc
|
|
152
|
+
|
|
144
153
|
# Pyre type checker
|
|
145
154
|
.pyre/
|
|
146
155
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redis-message-queue
|
|
3
|
-
Version: 8.2.
|
|
3
|
+
Version: 8.2.2
|
|
4
4
|
Summary: Python message queuing with Redis and message deduplication
|
|
5
5
|
Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
|
|
6
6
|
Project-URL: Repository, https://github.com/Elijas/redis-message-queue
|
|
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
**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.
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
pip install "redis-message-queue>=8.2.
|
|
37
|
+
pip install "redis-message-queue>=8.2.2,<9.0.0"
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Requires Redis server >= 6.2.
|
|
@@ -45,6 +45,7 @@ Redis must be running locally first: use `redis-server` or
|
|
|
45
45
|
`docker run -p 6379:6379 redis:7`.
|
|
46
46
|
|
|
47
47
|
```python
|
|
48
|
+
import json
|
|
48
49
|
from redis import Redis
|
|
49
50
|
from redis_message_queue import RedisMessageQueue
|
|
50
51
|
|
|
@@ -58,7 +59,8 @@ queue = RedisMessageQueue(
|
|
|
58
59
|
queue.publish({"id": "msg-1", "text": "hello"})
|
|
59
60
|
with queue.process_message() as message:
|
|
60
61
|
if message is not None:
|
|
61
|
-
|
|
62
|
+
payload = json.loads(message)
|
|
63
|
+
print(f"got {payload['text']}")
|
|
62
64
|
# Expected output: got hello
|
|
63
65
|
```
|
|
64
66
|
|
|
@@ -69,13 +71,15 @@ with queue.process_message() as message:
|
|
|
69
71
|
> synchronous. If your handler is `async def`, returns a coroutine, or returns
|
|
70
72
|
> any other awaitable, use `redis_message_queue.asyncio.RedisMessageQueue`.
|
|
71
73
|
> The sync context manager does not inspect the handler's return value; an
|
|
72
|
-
> unawaited coroutine can be dropped while the message is acked.
|
|
73
|
-
> callback
|
|
74
|
+
> unawaited coroutine can be dropped while the message is acked. For sync
|
|
75
|
+
> callback-style handlers, use `process_message_callback(handler)`: it checks
|
|
76
|
+
> for awaitable returns before acking and raises `TypeError` if one is returned.
|
|
74
77
|
|
|
75
78
|
### Async quickstart
|
|
76
79
|
|
|
77
80
|
```python
|
|
78
81
|
import asyncio
|
|
82
|
+
import json
|
|
79
83
|
from redis.asyncio import Redis
|
|
80
84
|
from redis_message_queue.asyncio import RedisMessageQueue
|
|
81
85
|
|
|
@@ -89,7 +93,8 @@ async def main():
|
|
|
89
93
|
)
|
|
90
94
|
await queue.publish({"id": "msg-1", "text": "hello"})
|
|
91
95
|
async with queue.process_message() as message:
|
|
92
|
-
|
|
96
|
+
payload = json.loads(message)
|
|
97
|
+
print(f"got {payload['text']}")
|
|
93
98
|
await client.aclose()
|
|
94
99
|
|
|
95
100
|
asyncio.run(main()) # Expected output: got hello
|
|
@@ -225,7 +230,9 @@ so concurrent publishers cannot race above the configured cap. Overload policies
|
|
|
225
230
|
- `drop_oldest` removes the oldest pending message (`RPOP`) before enqueueing the
|
|
226
231
|
new message. This is silent data loss by design; deduplication markers for
|
|
227
232
|
dropped messages are not removed, so a dropped duplicate may still be
|
|
228
|
-
suppressed until its dedup TTL expires.
|
|
233
|
+
suppressed until its dedup TTL expires. The current event contract emits
|
|
234
|
+
`publish/success` for the new message, but no separate `on_event` signal for
|
|
235
|
+
the dropped message.
|
|
229
236
|
- `block` retries the atomic check until space opens or
|
|
230
237
|
`pending_overload_block_timeout_seconds` elapses (default: 1.0), then raises
|
|
231
238
|
`QueueBackpressureError`.
|
|
@@ -497,6 +504,14 @@ gateway = RedisGateway(
|
|
|
497
504
|
queue = RedisMessageQueue("q", gateway=gateway)
|
|
498
505
|
```
|
|
499
506
|
|
|
507
|
+
When `gateway=` is supplied, queue-level constructor defaults are not copied
|
|
508
|
+
into the gateway. For example, `RedisMessageQueue(..., gateway=gateway)`
|
|
509
|
+
leaves visibility timeout and dead-letter routing disabled unless
|
|
510
|
+
`message_visibility_timeout_seconds` and `max_delivery_count` are configured on
|
|
511
|
+
the gateway itself. Passing the queue-level default values
|
|
512
|
+
`visibility_timeout_seconds=300` or `max_delivery_count=10` with `gateway=`
|
|
513
|
+
does not transfer those settings to the gateway.
|
|
514
|
+
|
|
500
515
|
The retry knobs configure an internal `tenacity` strategy: exponential
|
|
501
516
|
backoff with jitter, retry on transient Redis errors only, capped at
|
|
502
517
|
`retry_budget_seconds`. The budget is monotonic elapsed time from the first attempt (including attempt duration), not inter-attempt delay; it is unaffected by Python-host NTP jumps. A single attempt that takes longer than the budget results in zero retries. Setting `retry_budget_seconds=0` disables retry
|
|
@@ -729,8 +744,8 @@ queue = RedisMessageQueue("jobs", client=client, on_event=on_event)
|
|
|
729
744
|
```
|
|
730
745
|
|
|
731
746
|
Events cover publish, dedup hits, claim/empty polls, reclaim, ack/nack,
|
|
732
|
-
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases,
|
|
733
|
-
and trim failures, and retry attempts. Callback exceptions are logged and
|
|
747
|
+
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases, drain,
|
|
748
|
+
cleanup and trim failures, and retry attempts. Callback exceptions are logged and
|
|
734
749
|
reported with `RuntimeWarning`, but never propagate into queue operations.
|
|
735
750
|
`on_event` is telemetry only: use it for metrics, tracing, and logging, not for
|
|
736
751
|
sagas, follow-up writes, billing callbacks, or other correctness-critical
|
|
@@ -823,6 +838,23 @@ Pre-commit and mid-flight exceptions:
|
|
|
823
838
|
despite the exception. Treat them as "operation did not succeed from the
|
|
824
839
|
caller's perspective", not "Redis did not commit".
|
|
825
840
|
|
|
841
|
+
#### Drain events
|
|
842
|
+
|
|
843
|
+
`drain()` and `close()` on the sync queue, and `drain()` and `aclose()` on the
|
|
844
|
+
async queue, emit `drain` events:
|
|
845
|
+
|
|
846
|
+
- `drain/start` when the queue-local drain flag is set.
|
|
847
|
+
- `drain/success` when pending claim IDs were recovered or no gateway drain
|
|
848
|
+
hook is present.
|
|
849
|
+
- `drain/skipped` when the queue was already drained and the cached successful
|
|
850
|
+
result is returned.
|
|
851
|
+
- `drain/failure` when pending claim recovery times out or otherwise leaves
|
|
852
|
+
unresolved claim IDs.
|
|
853
|
+
|
|
854
|
+
Drain events use `timeout_seconds` for the caller-supplied timeout,
|
|
855
|
+
`pending_claim_ids` for the number of unresolved local claim IDs when known,
|
|
856
|
+
and `exception_type` / `error` on failure.
|
|
857
|
+
|
|
826
858
|
#### Intentionally silent paths
|
|
827
859
|
|
|
828
860
|
The following operations have no `on_event` surface by design:
|
|
@@ -837,9 +869,11 @@ The following operations have no `on_event` surface by design:
|
|
|
837
869
|
it back to pending, and returns `false`. Python translates that into
|
|
838
870
|
`claim_empty/skipped`, the same shape as an empty poll. This is intentional
|
|
839
871
|
fail-safe behavior; the message is not lost.
|
|
840
|
-
- **`
|
|
841
|
-
|
|
842
|
-
|
|
872
|
+
- **`drop_oldest` evictions:** when publish backpressure uses
|
|
873
|
+
`pending_overload_policy="drop_oldest"`, the oldest pending message is
|
|
874
|
+
discarded before the new message is enqueued. The successful enqueue emits
|
|
875
|
+
`publish/success`, but there is no separate drop event for the discarded
|
|
876
|
+
message in the current feature set.
|
|
843
877
|
- **Non-claim-loop retry attempts:** tenacity retries in deduplicated publish,
|
|
844
878
|
ack/remove, move-to-completed/failed, and lease renewal collapse into the
|
|
845
879
|
terminal operation's failure event. There is no per-attempt event for those
|
|
@@ -1038,7 +1072,7 @@ v6.0.0 is a non-breaking-defaults release that adds new public APIs. v5 code con
|
|
|
1038
1072
|
|
|
1039
1073
|
- `max_pending_length=N` caps pending-list depth; with `pending_overload_policy="raise"` (default) producers see `QueueBackpressureError` when the cap is hit; `"block"` waits up to `pending_overload_block_timeout_seconds`; `"drop_oldest"` evicts silently, so use it only when data loss is acceptable.
|
|
1040
1074
|
- `queue.drain(timeout=...)` (sync) and `await queue.aclose(timeout=...)` (async) are explicit graceful-shutdown hooks. They refuse new claims and recover pending claim IDs but do not cancel in-flight handlers; join or await your worker separately.
|
|
1041
|
-
- `on_event=callback` receives a `QueueEvent` dataclass for
|
|
1075
|
+
- `on_event=callback` receives a `QueueEvent` dataclass for publish/claim/ack/reclaim/dedup/cleanup/drain lifecycle events. Use it for metrics, tracing, and structured logging. See [`examples/production/observability.py`](examples/production/observability.py) for the adapter pattern.
|
|
1042
1076
|
- See [`examples/production/backpressure.py`](examples/production/backpressure.py) and [`examples/production/graceful_shutdown.py`](examples/production/graceful_shutdown.py) for sync production patterns, with async siblings under [`examples/production/asyncio/`](examples/production/asyncio/).
|
|
1043
1077
|
|
|
1044
1078
|
> When using a pre-fork app server (gunicorn `--preload`, uvicorn workers that import the app at master startup), call `make_queue()` from your worker startup hook - NOT at module import. See [Fork safety](#fork-safety-and-pre-fork-servers) for why.
|
|
@@ -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>=8.2.
|
|
14
|
+
pip install "redis-message-queue>=8.2.2,<9.0.0"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires Redis server >= 6.2.
|
|
@@ -22,6 +22,7 @@ Redis must be running locally first: use `redis-server` or
|
|
|
22
22
|
`docker run -p 6379:6379 redis:7`.
|
|
23
23
|
|
|
24
24
|
```python
|
|
25
|
+
import json
|
|
25
26
|
from redis import Redis
|
|
26
27
|
from redis_message_queue import RedisMessageQueue
|
|
27
28
|
|
|
@@ -35,7 +36,8 @@ queue = RedisMessageQueue(
|
|
|
35
36
|
queue.publish({"id": "msg-1", "text": "hello"})
|
|
36
37
|
with queue.process_message() as message:
|
|
37
38
|
if message is not None:
|
|
38
|
-
|
|
39
|
+
payload = json.loads(message)
|
|
40
|
+
print(f"got {payload['text']}")
|
|
39
41
|
# Expected output: got hello
|
|
40
42
|
```
|
|
41
43
|
|
|
@@ -46,13 +48,15 @@ with queue.process_message() as message:
|
|
|
46
48
|
> synchronous. If your handler is `async def`, returns a coroutine, or returns
|
|
47
49
|
> any other awaitable, use `redis_message_queue.asyncio.RedisMessageQueue`.
|
|
48
50
|
> The sync context manager does not inspect the handler's return value; an
|
|
49
|
-
> unawaited coroutine can be dropped while the message is acked.
|
|
50
|
-
> callback
|
|
51
|
+
> unawaited coroutine can be dropped while the message is acked. For sync
|
|
52
|
+
> callback-style handlers, use `process_message_callback(handler)`: it checks
|
|
53
|
+
> for awaitable returns before acking and raises `TypeError` if one is returned.
|
|
51
54
|
|
|
52
55
|
### Async quickstart
|
|
53
56
|
|
|
54
57
|
```python
|
|
55
58
|
import asyncio
|
|
59
|
+
import json
|
|
56
60
|
from redis.asyncio import Redis
|
|
57
61
|
from redis_message_queue.asyncio import RedisMessageQueue
|
|
58
62
|
|
|
@@ -66,7 +70,8 @@ async def main():
|
|
|
66
70
|
)
|
|
67
71
|
await queue.publish({"id": "msg-1", "text": "hello"})
|
|
68
72
|
async with queue.process_message() as message:
|
|
69
|
-
|
|
73
|
+
payload = json.loads(message)
|
|
74
|
+
print(f"got {payload['text']}")
|
|
70
75
|
await client.aclose()
|
|
71
76
|
|
|
72
77
|
asyncio.run(main()) # Expected output: got hello
|
|
@@ -202,7 +207,9 @@ so concurrent publishers cannot race above the configured cap. Overload policies
|
|
|
202
207
|
- `drop_oldest` removes the oldest pending message (`RPOP`) before enqueueing the
|
|
203
208
|
new message. This is silent data loss by design; deduplication markers for
|
|
204
209
|
dropped messages are not removed, so a dropped duplicate may still be
|
|
205
|
-
suppressed until its dedup TTL expires.
|
|
210
|
+
suppressed until its dedup TTL expires. The current event contract emits
|
|
211
|
+
`publish/success` for the new message, but no separate `on_event` signal for
|
|
212
|
+
the dropped message.
|
|
206
213
|
- `block` retries the atomic check until space opens or
|
|
207
214
|
`pending_overload_block_timeout_seconds` elapses (default: 1.0), then raises
|
|
208
215
|
`QueueBackpressureError`.
|
|
@@ -474,6 +481,14 @@ gateway = RedisGateway(
|
|
|
474
481
|
queue = RedisMessageQueue("q", gateway=gateway)
|
|
475
482
|
```
|
|
476
483
|
|
|
484
|
+
When `gateway=` is supplied, queue-level constructor defaults are not copied
|
|
485
|
+
into the gateway. For example, `RedisMessageQueue(..., gateway=gateway)`
|
|
486
|
+
leaves visibility timeout and dead-letter routing disabled unless
|
|
487
|
+
`message_visibility_timeout_seconds` and `max_delivery_count` are configured on
|
|
488
|
+
the gateway itself. Passing the queue-level default values
|
|
489
|
+
`visibility_timeout_seconds=300` or `max_delivery_count=10` with `gateway=`
|
|
490
|
+
does not transfer those settings to the gateway.
|
|
491
|
+
|
|
477
492
|
The retry knobs configure an internal `tenacity` strategy: exponential
|
|
478
493
|
backoff with jitter, retry on transient Redis errors only, capped at
|
|
479
494
|
`retry_budget_seconds`. The budget is monotonic elapsed time from the first attempt (including attempt duration), not inter-attempt delay; it is unaffected by Python-host NTP jumps. A single attempt that takes longer than the budget results in zero retries. Setting `retry_budget_seconds=0` disables retry
|
|
@@ -706,8 +721,8 @@ queue = RedisMessageQueue("jobs", client=client, on_event=on_event)
|
|
|
706
721
|
```
|
|
707
722
|
|
|
708
723
|
Events cover publish, dedup hits, claim/empty polls, reclaim, ack/nack,
|
|
709
|
-
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases,
|
|
710
|
-
and trim failures, and retry attempts. Callback exceptions are logged and
|
|
724
|
+
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases, drain,
|
|
725
|
+
cleanup and trim failures, and retry attempts. Callback exceptions are logged and
|
|
711
726
|
reported with `RuntimeWarning`, but never propagate into queue operations.
|
|
712
727
|
`on_event` is telemetry only: use it for metrics, tracing, and logging, not for
|
|
713
728
|
sagas, follow-up writes, billing callbacks, or other correctness-critical
|
|
@@ -800,6 +815,23 @@ Pre-commit and mid-flight exceptions:
|
|
|
800
815
|
despite the exception. Treat them as "operation did not succeed from the
|
|
801
816
|
caller's perspective", not "Redis did not commit".
|
|
802
817
|
|
|
818
|
+
#### Drain events
|
|
819
|
+
|
|
820
|
+
`drain()` and `close()` on the sync queue, and `drain()` and `aclose()` on the
|
|
821
|
+
async queue, emit `drain` events:
|
|
822
|
+
|
|
823
|
+
- `drain/start` when the queue-local drain flag is set.
|
|
824
|
+
- `drain/success` when pending claim IDs were recovered or no gateway drain
|
|
825
|
+
hook is present.
|
|
826
|
+
- `drain/skipped` when the queue was already drained and the cached successful
|
|
827
|
+
result is returned.
|
|
828
|
+
- `drain/failure` when pending claim recovery times out or otherwise leaves
|
|
829
|
+
unresolved claim IDs.
|
|
830
|
+
|
|
831
|
+
Drain events use `timeout_seconds` for the caller-supplied timeout,
|
|
832
|
+
`pending_claim_ids` for the number of unresolved local claim IDs when known,
|
|
833
|
+
and `exception_type` / `error` on failure.
|
|
834
|
+
|
|
803
835
|
#### Intentionally silent paths
|
|
804
836
|
|
|
805
837
|
The following operations have no `on_event` surface by design:
|
|
@@ -814,9 +846,11 @@ The following operations have no `on_event` surface by design:
|
|
|
814
846
|
it back to pending, and returns `false`. Python translates that into
|
|
815
847
|
`claim_empty/skipped`, the same shape as an empty poll. This is intentional
|
|
816
848
|
fail-safe behavior; the message is not lost.
|
|
817
|
-
- **`
|
|
818
|
-
|
|
819
|
-
|
|
849
|
+
- **`drop_oldest` evictions:** when publish backpressure uses
|
|
850
|
+
`pending_overload_policy="drop_oldest"`, the oldest pending message is
|
|
851
|
+
discarded before the new message is enqueued. The successful enqueue emits
|
|
852
|
+
`publish/success`, but there is no separate drop event for the discarded
|
|
853
|
+
message in the current feature set.
|
|
820
854
|
- **Non-claim-loop retry attempts:** tenacity retries in deduplicated publish,
|
|
821
855
|
ack/remove, move-to-completed/failed, and lease renewal collapse into the
|
|
822
856
|
terminal operation's failure event. There is no per-attempt event for those
|
|
@@ -1015,7 +1049,7 @@ v6.0.0 is a non-breaking-defaults release that adds new public APIs. v5 code con
|
|
|
1015
1049
|
|
|
1016
1050
|
- `max_pending_length=N` caps pending-list depth; with `pending_overload_policy="raise"` (default) producers see `QueueBackpressureError` when the cap is hit; `"block"` waits up to `pending_overload_block_timeout_seconds`; `"drop_oldest"` evicts silently, so use it only when data loss is acceptable.
|
|
1017
1051
|
- `queue.drain(timeout=...)` (sync) and `await queue.aclose(timeout=...)` (async) are explicit graceful-shutdown hooks. They refuse new claims and recover pending claim IDs but do not cancel in-flight handlers; join or await your worker separately.
|
|
1018
|
-
- `on_event=callback` receives a `QueueEvent` dataclass for
|
|
1052
|
+
- `on_event=callback` receives a `QueueEvent` dataclass for publish/claim/ack/reclaim/dedup/cleanup/drain lifecycle events. Use it for metrics, tracing, and structured logging. See [`examples/production/observability.py`](examples/production/observability.py) for the adapter pattern.
|
|
1019
1053
|
- See [`examples/production/backpressure.py`](examples/production/backpressure.py) and [`examples/production/graceful_shutdown.py`](examples/production/graceful_shutdown.py) for sync production patterns, with async siblings under [`examples/production/asyncio/`](examples/production/asyncio/).
|
|
1020
1054
|
|
|
1021
1055
|
> When using a pre-fork app server (gunicorn `--preload`, uvicorn workers that import the app at master startup), call `make_queue()` from your worker startup hook - NOT at module import. See [Fork safety](#fork-safety-and-pre-fork-servers) for why.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "redis-message-queue"
|
|
3
|
-
version = "8.2.
|
|
3
|
+
version = "8.2.2"
|
|
4
4
|
description = "Python message queuing with Redis and message deduplication"
|
|
5
5
|
authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -48,7 +48,7 @@ default-groups = ["dev", "test"]
|
|
|
48
48
|
##############################
|
|
49
49
|
|
|
50
50
|
[tool.bumpversion]
|
|
51
|
-
current_version = "8.2.
|
|
51
|
+
current_version = "8.2.2"
|
|
52
52
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
53
53
|
serialize = ["{major}.{minor}.{patch}"]
|
|
54
54
|
search = "{current_version}"
|
|
@@ -84,6 +84,12 @@ line-length = 120
|
|
|
84
84
|
select = ["E", "F", "I", "W"]
|
|
85
85
|
ignore = ["E731"]
|
|
86
86
|
|
|
87
|
+
[tool.mypy]
|
|
88
|
+
python_version = "3.12"
|
|
89
|
+
files = ["redis_message_queue/"]
|
|
90
|
+
explicit_package_bases = true
|
|
91
|
+
show_error_codes = true
|
|
92
|
+
|
|
87
93
|
[tool.pytest.ini_options]
|
|
88
94
|
asyncio_default_fixture_loop_scope = "function"
|
|
89
95
|
filterwarnings = [
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -301,8 +301,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
301
301
|
|
|
302
302
|
def _emit_event(
|
|
303
303
|
self,
|
|
304
|
-
operation: EventOperation,
|
|
305
|
-
outcome: EventOutcome,
|
|
304
|
+
operation: EventOperation | str,
|
|
305
|
+
outcome: EventOutcome | str,
|
|
306
306
|
*,
|
|
307
307
|
message_id: str | None = None,
|
|
308
308
|
claim_id: str | None = None,
|
|
@@ -328,7 +328,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
328
328
|
|
|
329
329
|
def _emit_repeated_event(
|
|
330
330
|
self,
|
|
331
|
-
operation: EventOperation,
|
|
331
|
+
operation: EventOperation | str,
|
|
332
332
|
attempts: list[_MessageAttemptEvent],
|
|
333
333
|
*,
|
|
334
334
|
destination_queue: str | None = None,
|
|
@@ -275,8 +275,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
275
275
|
|
|
276
276
|
async def _emit_event(
|
|
277
277
|
self,
|
|
278
|
-
operation: EventOperation,
|
|
279
|
-
outcome: EventOutcome,
|
|
278
|
+
operation: EventOperation | str,
|
|
279
|
+
outcome: EventOutcome | str,
|
|
280
280
|
*,
|
|
281
281
|
message_id: str | None = None,
|
|
282
282
|
claim_id: str | None = None,
|
|
@@ -302,7 +302,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
302
302
|
|
|
303
303
|
async def _emit_repeated_event(
|
|
304
304
|
self,
|
|
305
|
-
operation: EventOperation,
|
|
305
|
+
operation: EventOperation | str,
|
|
306
306
|
attempts: list[_MessageAttemptEvent],
|
|
307
307
|
*,
|
|
308
308
|
destination_queue: str | None = None,
|
|
@@ -11,7 +11,6 @@ from typing import AsyncIterator, Awaitable, Callable, Literal, Optional, TypeVa
|
|
|
11
11
|
import redis.asyncio
|
|
12
12
|
import redis.exceptions
|
|
13
13
|
|
|
14
|
-
from redis_message_queue._callable_utils import is_async_callable
|
|
15
14
|
from redis_message_queue._config import (
|
|
16
15
|
DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
|
|
17
16
|
validate_dedup_configuration,
|
|
@@ -332,8 +331,12 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
|
|
|
332
331
|
return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
|
|
333
332
|
|
|
334
333
|
|
|
335
|
-
def _bind_dead_letter_gateway_to_queue(
|
|
336
|
-
|
|
334
|
+
def _bind_dead_letter_gateway_to_queue(
|
|
335
|
+
gateway: AbstractRedisGateway,
|
|
336
|
+
queue_pending_key: str,
|
|
337
|
+
queue_processing_key: str,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Validate and bind a dead-letter-enabled gateway to this queue.
|
|
337
340
|
|
|
338
341
|
The check is not thread-safe: constructing ``RedisMessageQueue`` instances
|
|
339
342
|
concurrently on multiple threads with the same DLQ-enabled gateway can
|
|
@@ -346,6 +349,12 @@ def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pend
|
|
|
346
349
|
if max_delivery_count is None:
|
|
347
350
|
return
|
|
348
351
|
|
|
352
|
+
if gateway.dead_letter_queue in (queue_pending_key, queue_processing_key):
|
|
353
|
+
raise ConfigurationError(
|
|
354
|
+
"'dead_letter_queue' must be distinct from the queue's pending and processing Redis keys. "
|
|
355
|
+
"Use a separate Redis list key for poison messages."
|
|
356
|
+
)
|
|
357
|
+
|
|
349
358
|
bound_pending_key = getattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, None)
|
|
350
359
|
if bound_pending_key is None:
|
|
351
360
|
setattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, queue_pending_key)
|
|
@@ -451,7 +460,7 @@ class _LeaseHeartbeat:
|
|
|
451
460
|
stacklevel=2,
|
|
452
461
|
)
|
|
453
462
|
|
|
454
|
-
async def _emit(self, operation: EventOperation, outcome: EventOutcome, **kwargs: object) -> None:
|
|
463
|
+
async def _emit(self, operation: EventOperation | str, outcome: EventOutcome | str, **kwargs: object) -> None:
|
|
455
464
|
if self._emit_event is not None:
|
|
456
465
|
await self._emit_event(operation, outcome, **kwargs)
|
|
457
466
|
|
|
@@ -597,6 +606,11 @@ class RedisMessageQueue:
|
|
|
597
606
|
auto-derived dead-letter queue. Set it to ``None`` for unlimited
|
|
598
607
|
redelivery.
|
|
599
608
|
|
|
609
|
+
When ``gateway=`` is supplied, queue-level defaults are not transferred
|
|
610
|
+
to the gateway. Configure lease, dead-letter, and backpressure settings
|
|
611
|
+
such as ``message_visibility_timeout_seconds``, ``max_delivery_count``,
|
|
612
|
+
and ``max_pending_length`` on the gateway itself.
|
|
613
|
+
|
|
600
614
|
``deduplication=True`` requires ``get_deduplication_key`` to be a
|
|
601
615
|
callable that returns a non-empty string. Use a stable logical ID for
|
|
602
616
|
the deduplication keyspace.
|
|
@@ -636,11 +650,11 @@ class RedisMessageQueue:
|
|
|
636
650
|
``GracefulInterruptHandler()`` for prompt Ctrl-C / termination handling
|
|
637
651
|
in polling waits. ``on_heartbeat_failure`` is a zero-argument callable
|
|
638
652
|
or coroutine callable invoked when lease renewal fails. ``on_event`` is
|
|
639
|
-
telemetry only: an
|
|
640
|
-
lifecycle notifications. Callback failures are
|
|
641
|
-
RuntimeWarning without influencing ack/nack or
|
|
642
|
-
outcome. Do not use it for correctness-critical
|
|
643
|
-
writes.
|
|
653
|
+
telemetry only: a callable returning an awaitable and receiving
|
|
654
|
+
best-effort QueueEvent lifecycle notifications. Callback failures are
|
|
655
|
+
logged and converted to RuntimeWarning without influencing ack/nack or
|
|
656
|
+
any other message outcome. Do not use it for correctness-critical
|
|
657
|
+
callbacks or follow-up writes.
|
|
644
658
|
"""
|
|
645
659
|
self.key = QueueKeyManager(name, key_separator=key_separator)
|
|
646
660
|
if not isinstance(deduplication, bool):
|
|
@@ -742,8 +756,6 @@ class RedisMessageQueue:
|
|
|
742
756
|
)
|
|
743
757
|
if on_event is not None and not callable(on_event):
|
|
744
758
|
raise TypeError(f"'on_event' must be callable, got {type(on_event).__name__}.")
|
|
745
|
-
if on_event is not None and not is_async_callable(on_event):
|
|
746
|
-
raise TypeError("'on_event' must be an async callable.")
|
|
747
759
|
self._queue_name = name
|
|
748
760
|
self._on_event = on_event
|
|
749
761
|
# Queue-local soft-drain flag. See sync queue ``_draining`` docstring
|
|
@@ -825,7 +837,7 @@ class RedisMessageQueue:
|
|
|
825
837
|
"'max_pending_length' cannot be provided alongside 'gateway'."
|
|
826
838
|
" Configure publish backpressure on the gateway directly instead."
|
|
827
839
|
)
|
|
828
|
-
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
|
|
840
|
+
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending, self.key.processing)
|
|
829
841
|
self._max_delivery_count = None
|
|
830
842
|
self._redis = gateway
|
|
831
843
|
elif client is None:
|
|
@@ -867,8 +879,8 @@ class RedisMessageQueue:
|
|
|
867
879
|
|
|
868
880
|
async def _emit_event(
|
|
869
881
|
self,
|
|
870
|
-
operation: EventOperation,
|
|
871
|
-
outcome: EventOutcome,
|
|
882
|
+
operation: EventOperation | str,
|
|
883
|
+
outcome: EventOutcome | str,
|
|
872
884
|
*,
|
|
873
885
|
message_id: str | None = None,
|
|
874
886
|
claim_id: str | None = None,
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -273,8 +273,12 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
|
|
|
273
273
|
return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
|
|
274
274
|
|
|
275
275
|
|
|
276
|
-
def _bind_dead_letter_gateway_to_queue(
|
|
277
|
-
|
|
276
|
+
def _bind_dead_letter_gateway_to_queue(
|
|
277
|
+
gateway: AbstractRedisGateway,
|
|
278
|
+
queue_pending_key: str,
|
|
279
|
+
queue_processing_key: str,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Validate and bind a dead-letter-enabled gateway to this queue.
|
|
278
282
|
|
|
279
283
|
The check is not thread-safe: constructing ``RedisMessageQueue`` instances
|
|
280
284
|
concurrently on multiple threads with the same DLQ-enabled gateway can
|
|
@@ -287,6 +291,12 @@ def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pend
|
|
|
287
291
|
if max_delivery_count is None:
|
|
288
292
|
return
|
|
289
293
|
|
|
294
|
+
if gateway.dead_letter_queue in (queue_pending_key, queue_processing_key):
|
|
295
|
+
raise ConfigurationError(
|
|
296
|
+
"'dead_letter_queue' must be distinct from the queue's pending and processing Redis keys. "
|
|
297
|
+
"Use a separate Redis list key for poison messages."
|
|
298
|
+
)
|
|
299
|
+
|
|
290
300
|
bound_pending_key = getattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, None)
|
|
291
301
|
if bound_pending_key is None:
|
|
292
302
|
setattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, queue_pending_key)
|
|
@@ -407,7 +417,7 @@ class _LeaseHeartbeat:
|
|
|
407
417
|
stacklevel=2,
|
|
408
418
|
)
|
|
409
419
|
|
|
410
|
-
def _emit(self, operation: EventOperation, outcome: EventOutcome, **kwargs: object) -> None:
|
|
420
|
+
def _emit(self, operation: EventOperation | str, outcome: EventOutcome | str, **kwargs: object) -> None:
|
|
411
421
|
if self._emit_event is not None:
|
|
412
422
|
self._emit_event(operation, outcome, **kwargs)
|
|
413
423
|
|
|
@@ -549,6 +559,11 @@ class RedisMessageQueue:
|
|
|
549
559
|
auto-derived dead-letter queue. Set it to ``None`` for unlimited
|
|
550
560
|
redelivery.
|
|
551
561
|
|
|
562
|
+
When ``gateway=`` is supplied, queue-level defaults are not transferred
|
|
563
|
+
to the gateway. Configure lease, dead-letter, and backpressure settings
|
|
564
|
+
such as ``message_visibility_timeout_seconds``, ``max_delivery_count``,
|
|
565
|
+
and ``max_pending_length`` on the gateway itself.
|
|
566
|
+
|
|
552
567
|
``deduplication=True`` requires ``get_deduplication_key`` to be a
|
|
553
568
|
callable that returns a non-empty string. Use a stable logical ID for
|
|
554
569
|
the deduplication keyspace.
|
|
@@ -786,7 +801,7 @@ class RedisMessageQueue:
|
|
|
786
801
|
"'max_pending_length' cannot be provided alongside 'gateway'."
|
|
787
802
|
" Configure publish backpressure on the gateway directly instead."
|
|
788
803
|
)
|
|
789
|
-
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
|
|
804
|
+
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending, self.key.processing)
|
|
790
805
|
self._max_delivery_count = None
|
|
791
806
|
self._redis = gateway
|
|
792
807
|
elif client is None:
|
|
@@ -827,8 +842,8 @@ class RedisMessageQueue:
|
|
|
827
842
|
|
|
828
843
|
def _emit_event(
|
|
829
844
|
self,
|
|
830
|
-
operation: EventOperation,
|
|
831
|
-
outcome: EventOutcome,
|
|
845
|
+
operation: EventOperation | str,
|
|
846
|
+
outcome: EventOutcome | str,
|
|
832
847
|
*,
|
|
833
848
|
message_id: str | None = None,
|
|
834
849
|
claim_id: str | None = None,
|
|
@@ -1031,8 +1046,9 @@ class RedisMessageQueue:
|
|
|
1031
1046
|
does not inspect handler return values; if your handler returns a
|
|
1032
1047
|
coroutine or other awaitable, the awaitable can be dropped while the
|
|
1033
1048
|
message is acked. Use ``redis_message_queue.asyncio.RedisMessageQueue``
|
|
1034
|
-
for async handlers.
|
|
1035
|
-
|
|
1049
|
+
for async handlers. For sync callback-style handlers, use
|
|
1050
|
+
``process_message_callback(handler)`` so awaitable returns are detected
|
|
1051
|
+
before acking.
|
|
1036
1052
|
|
|
1037
1053
|
If the process is killed mid-handler, the claimed message and lease
|
|
1038
1054
|
metadata remain in Redis until a later consumer claim triggers
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_payload_limits.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_queue_key_manager.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/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
|
|
File without changes
|