redis-message-queue 8.2.1__tar.gz → 8.2.3__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.3}/.gitignore +10 -1
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/PKG-INFO +47 -13
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/README.md +46 -12
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/pyproject.toml +8 -2
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_config.py +49 -2
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_gateway.py +89 -14
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_redis_gateway.py +89 -14
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/redis_message_queue.py +41 -15
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/redis_message_queue.py +36 -9
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/LICENSE +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_event.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_exceptions.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_payload_limits.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_queue_key_manager.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/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.3
|
|
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.3,<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.3,<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.3"
|
|
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.3"
|
|
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 = [
|
|
@@ -585,6 +585,53 @@ return removed
|
|
|
585
585
|
"""
|
|
586
586
|
)
|
|
587
587
|
|
|
588
|
+
RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT = (
|
|
589
|
+
_LUA_KEY_TYPE_GUARD
|
|
590
|
+
+ """
|
|
591
|
+
local err = redis_message_queue_require_type(KEYS[1], 'list')
|
|
592
|
+
if err then
|
|
593
|
+
return err
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
local err = redis_message_queue_require_type(KEYS[2], 'list')
|
|
597
|
+
if err then
|
|
598
|
+
return err
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
local err = redis_message_queue_require_type(KEYS[3], 'hash')
|
|
602
|
+
if err then
|
|
603
|
+
return err
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
local err = redis_message_queue_require_type(KEYS[4], 'hash')
|
|
607
|
+
if err then
|
|
608
|
+
return err
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
local err = redis_message_queue_require_type(KEYS[5], 'string')
|
|
612
|
+
if err then
|
|
613
|
+
return err
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
local cached_result = redis.call('GET', KEYS[5])
|
|
617
|
+
if cached_result then
|
|
618
|
+
return tonumber(cached_result)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
redis.call('RPUSH', KEYS[2], ARGV[1])
|
|
622
|
+
local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
|
|
623
|
+
if removed == 1 then
|
|
624
|
+
redis.call('HDEL', KEYS[3], ARGV[2])
|
|
625
|
+
redis.call('HDEL', KEYS[4], ARGV[1])
|
|
626
|
+
else
|
|
627
|
+
redis.call('LREM', KEYS[2], 1, ARGV[1])
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
redis.call('SET', KEYS[5], tostring(removed), 'PX', tonumber(ARGV[3]))
|
|
631
|
+
return removed
|
|
632
|
+
"""
|
|
633
|
+
)
|
|
634
|
+
|
|
588
635
|
REMOVE_MESSAGE_LUA_SCRIPT = (
|
|
589
636
|
_LUA_KEY_TYPE_GUARD
|
|
590
637
|
+ """
|
|
@@ -876,8 +923,6 @@ while claim_attempts < 100 do
|
|
|
876
923
|
|
|
877
924
|
local count = redis.call('HINCRBY', KEYS[6], stored, 1)
|
|
878
925
|
if max_delivery_count > 0 and count > max_delivery_count then
|
|
879
|
-
redis.call('LREM', KEYS[2], 1, stored)
|
|
880
|
-
redis.call('HDEL', KEYS[6], stored)
|
|
881
926
|
-- Strip envelope to store raw payload in DLQ, consistent with completed/failed queues.
|
|
882
927
|
-- The per-delivery UUID in the envelope is lost; see README dead-letter notes.
|
|
883
928
|
local dead_letter_value = stored
|
|
@@ -886,6 +931,8 @@ while claim_attempts < 100 do
|
|
|
886
931
|
dead_letter_value = envelope['payload']
|
|
887
932
|
end
|
|
888
933
|
redis.call('LPUSH', KEYS[7], dead_letter_value)
|
|
934
|
+
redis.call('LREM', KEYS[2], 1, stored)
|
|
935
|
+
redis.call('HDEL', KEYS[6], stored)
|
|
889
936
|
table.insert(dead_lettered_events, {redis_message_queue_message_id(stored), tostring(count)})
|
|
890
937
|
else
|
|
891
938
|
return store_claim_and_return(stored)
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -31,6 +31,7 @@ from redis_message_queue._config import (
|
|
|
31
31
|
REMOVE_MESSAGE_LUA_SCRIPT,
|
|
32
32
|
REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
33
33
|
RENEW_MESSAGE_LEASE_LUA_SCRIPT,
|
|
34
|
+
RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
|
|
34
35
|
_ChainedInterrupt,
|
|
35
36
|
build_retry_strategy,
|
|
36
37
|
is_redis_retryable_exception,
|
|
@@ -79,6 +80,8 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
|
79
80
|
_PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
|
|
80
81
|
_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
|
|
81
82
|
_CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
|
|
83
|
+
_PENDING_QUEUE_SUFFIX = "pending"
|
|
84
|
+
_PROCESSING_QUEUE_SUFFIX = "processing"
|
|
82
85
|
|
|
83
86
|
|
|
84
87
|
class _DrainDeadlineExceeded(Exception):
|
|
@@ -301,8 +304,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
301
304
|
|
|
302
305
|
def _emit_event(
|
|
303
306
|
self,
|
|
304
|
-
operation: EventOperation,
|
|
305
|
-
outcome: EventOutcome,
|
|
307
|
+
operation: EventOperation | str,
|
|
308
|
+
outcome: EventOutcome | str,
|
|
306
309
|
*,
|
|
307
310
|
message_id: str | None = None,
|
|
308
311
|
claim_id: str | None = None,
|
|
@@ -328,7 +331,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
328
331
|
|
|
329
332
|
def _emit_repeated_event(
|
|
330
333
|
self,
|
|
331
|
-
operation: EventOperation,
|
|
334
|
+
operation: EventOperation | str,
|
|
332
335
|
attempts: list[_MessageAttemptEvent],
|
|
333
336
|
*,
|
|
334
337
|
destination_queue: str | None = None,
|
|
@@ -1225,6 +1228,53 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1225
1228
|
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1226
1229
|
return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
|
|
1227
1230
|
|
|
1231
|
+
def _pending_queue_from_processing_queue(self, processing_queue: str) -> str:
|
|
1232
|
+
if not processing_queue.endswith(_PROCESSING_QUEUE_SUFFIX):
|
|
1233
|
+
raise RuntimeError(f"cannot derive pending queue key from processing queue {processing_queue!r}")
|
|
1234
|
+
return f"{processing_queue.removesuffix(_PROCESSING_QUEUE_SUFFIX)}{_PENDING_QUEUE_SUFFIX}"
|
|
1235
|
+
|
|
1236
|
+
def _return_recovered_non_visibility_timeout_claim_to_pending(
|
|
1237
|
+
self,
|
|
1238
|
+
processing_queue: str,
|
|
1239
|
+
stored_message: MessageData,
|
|
1240
|
+
claim_id: str,
|
|
1241
|
+
*,
|
|
1242
|
+
deadline_monotonic: float | None,
|
|
1243
|
+
) -> bool:
|
|
1244
|
+
pending_queue = self._pending_queue_from_processing_queue(processing_queue)
|
|
1245
|
+
operation_id = uuid.uuid4().hex
|
|
1246
|
+
operation_result_key = self._operation_result_key(processing_queue, operation_id)
|
|
1247
|
+
|
|
1248
|
+
try:
|
|
1249
|
+
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1250
|
+
result = _call_with_drain_deadline(
|
|
1251
|
+
lambda: self._eval(
|
|
1252
|
+
RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
|
|
1253
|
+
5,
|
|
1254
|
+
processing_queue,
|
|
1255
|
+
pending_queue,
|
|
1256
|
+
self._claim_result_ids_key(processing_queue),
|
|
1257
|
+
self._claim_result_backrefs_key(processing_queue),
|
|
1258
|
+
operation_result_key,
|
|
1259
|
+
stored_message,
|
|
1260
|
+
claim_id,
|
|
1261
|
+
self._operation_result_ttl_ms(),
|
|
1262
|
+
),
|
|
1263
|
+
deadline_monotonic=deadline_monotonic,
|
|
1264
|
+
)
|
|
1265
|
+
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1266
|
+
return bool(_coerce_lua_count(result))
|
|
1267
|
+
except RedisMessageQueueError as exc:
|
|
1268
|
+
_set_exception_context(
|
|
1269
|
+
exc,
|
|
1270
|
+
queue=processing_queue,
|
|
1271
|
+
message_id=extract_stored_message_id(stored_message),
|
|
1272
|
+
operation="drain",
|
|
1273
|
+
)
|
|
1274
|
+
raise
|
|
1275
|
+
finally:
|
|
1276
|
+
self._delete_operation_result_key(operation_result_key)
|
|
1277
|
+
|
|
1228
1278
|
def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
|
|
1229
1279
|
return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
|
|
1230
1280
|
is_interrupted is not None and is_interrupted.is_interrupted()
|
|
@@ -1241,8 +1291,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1241
1291
|
Walks the same recovery path as ``_wait_for_claim`` but without
|
|
1242
1292
|
gating on the interrupt flag, so a soft shutdown can flush
|
|
1243
1293
|
ambiguous-claim state that would otherwise be dropped on process
|
|
1244
|
-
exit (AA-05-F2).
|
|
1245
|
-
the
|
|
1294
|
+
exit (AA-05-F2). No-visibility-timeout recoveries are returned to
|
|
1295
|
+
the pending queue before their claim ids are cleared. Returns True
|
|
1296
|
+
if no pending ids remain; False if the deadline fired or Redis
|
|
1297
|
+
errors prevented full drain.
|
|
1246
1298
|
"""
|
|
1247
1299
|
with self._drain_pending_claim_ids_lock:
|
|
1248
1300
|
self._last_drain_error = None
|
|
@@ -1258,11 +1310,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1258
1310
|
deadline_monotonic: float | None,
|
|
1259
1311
|
) -> bool:
|
|
1260
1312
|
"""Recover every in-memory pending claim id for ``processing_queue``."""
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
else:
|
|
1264
|
-
recover = self._recover_pending_non_visibility_timeout_claim
|
|
1265
|
-
skipped_transient: set[str] = set()
|
|
1313
|
+
has_visibility_timeout = self._message_visibility_timeout_seconds is not None
|
|
1314
|
+
skipped_unresolved: set[str] = set()
|
|
1266
1315
|
last_error: BaseException | None = None
|
|
1267
1316
|
while True:
|
|
1268
1317
|
# ``>=`` (not ``>``) makes ``timeout=0`` deterministically take
|
|
@@ -1278,7 +1327,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1278
1327
|
return True
|
|
1279
1328
|
recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
|
|
1280
1329
|
claim_id = next(
|
|
1281
|
-
(cid for cid in pending if cid not in recovering and cid not in
|
|
1330
|
+
(cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
|
|
1282
1331
|
None,
|
|
1283
1332
|
)
|
|
1284
1333
|
if claim_id is None:
|
|
@@ -1287,8 +1336,34 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1287
1336
|
clear = False
|
|
1288
1337
|
try:
|
|
1289
1338
|
try:
|
|
1290
|
-
|
|
1291
|
-
|
|
1339
|
+
if has_visibility_timeout:
|
|
1340
|
+
self._recover_pending_visibility_timeout_claim(
|
|
1341
|
+
processing_queue,
|
|
1342
|
+
claim_id,
|
|
1343
|
+
deadline_monotonic=deadline_monotonic,
|
|
1344
|
+
)
|
|
1345
|
+
clear = True
|
|
1346
|
+
continue
|
|
1347
|
+
|
|
1348
|
+
recovered_claim = self._recover_pending_non_visibility_timeout_claim(
|
|
1349
|
+
processing_queue,
|
|
1350
|
+
claim_id,
|
|
1351
|
+
deadline_monotonic=deadline_monotonic,
|
|
1352
|
+
)
|
|
1353
|
+
if recovered_claim is None:
|
|
1354
|
+
clear = True
|
|
1355
|
+
elif self._return_recovered_non_visibility_timeout_claim_to_pending(
|
|
1356
|
+
processing_queue,
|
|
1357
|
+
recovered_claim,
|
|
1358
|
+
claim_id,
|
|
1359
|
+
deadline_monotonic=deadline_monotonic,
|
|
1360
|
+
):
|
|
1361
|
+
clear = True
|
|
1362
|
+
else:
|
|
1363
|
+
last_error = RuntimeError(
|
|
1364
|
+
f"drain recovered claim {claim_id!r} but message was not present in processing queue"
|
|
1365
|
+
)
|
|
1366
|
+
skipped_unresolved.add(claim_id)
|
|
1292
1367
|
except _DrainDeadlineExceeded:
|
|
1293
1368
|
last_error = TimeoutError("drain pending-claim recovery deadline expired")
|
|
1294
1369
|
break
|
|
@@ -1301,7 +1376,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1301
1376
|
claim_id,
|
|
1302
1377
|
type(exc).__name__,
|
|
1303
1378
|
)
|
|
1304
|
-
|
|
1379
|
+
skipped_unresolved.add(claim_id)
|
|
1305
1380
|
finally:
|
|
1306
1381
|
self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
|
|
1307
1382
|
with self._pending_claim_ids_lock:
|
|
@@ -29,6 +29,7 @@ from redis_message_queue._config import (
|
|
|
29
29
|
REMOVE_MESSAGE_LUA_SCRIPT,
|
|
30
30
|
REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
|
|
31
31
|
RENEW_MESSAGE_LEASE_LUA_SCRIPT,
|
|
32
|
+
RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
|
|
32
33
|
_ChainedInterrupt,
|
|
33
34
|
build_retry_strategy,
|
|
34
35
|
is_redis_retryable_exception,
|
|
@@ -78,6 +79,8 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
|
78
79
|
_PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
|
|
79
80
|
_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
|
|
80
81
|
_CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
|
|
82
|
+
_PENDING_QUEUE_SUFFIX = "pending"
|
|
83
|
+
_PROCESSING_QUEUE_SUFFIX = "processing"
|
|
81
84
|
|
|
82
85
|
|
|
83
86
|
class _DrainDeadlineExceeded(Exception):
|
|
@@ -275,8 +278,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
275
278
|
|
|
276
279
|
async def _emit_event(
|
|
277
280
|
self,
|
|
278
|
-
operation: EventOperation,
|
|
279
|
-
outcome: EventOutcome,
|
|
281
|
+
operation: EventOperation | str,
|
|
282
|
+
outcome: EventOutcome | str,
|
|
280
283
|
*,
|
|
281
284
|
message_id: str | None = None,
|
|
282
285
|
claim_id: str | None = None,
|
|
@@ -302,7 +305,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
302
305
|
|
|
303
306
|
async def _emit_repeated_event(
|
|
304
307
|
self,
|
|
305
|
-
operation: EventOperation,
|
|
308
|
+
operation: EventOperation | str,
|
|
306
309
|
attempts: list[_MessageAttemptEvent],
|
|
307
310
|
*,
|
|
308
311
|
destination_queue: str | None = None,
|
|
@@ -1206,6 +1209,53 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1206
1209
|
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1207
1210
|
return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
|
|
1208
1211
|
|
|
1212
|
+
def _pending_queue_from_processing_queue(self, processing_queue: str) -> str:
|
|
1213
|
+
if not processing_queue.endswith(_PROCESSING_QUEUE_SUFFIX):
|
|
1214
|
+
raise RuntimeError(f"cannot derive pending queue key from processing queue {processing_queue!r}")
|
|
1215
|
+
return f"{processing_queue.removesuffix(_PROCESSING_QUEUE_SUFFIX)}{_PENDING_QUEUE_SUFFIX}"
|
|
1216
|
+
|
|
1217
|
+
async def _return_recovered_non_visibility_timeout_claim_to_pending(
|
|
1218
|
+
self,
|
|
1219
|
+
processing_queue: str,
|
|
1220
|
+
stored_message: MessageData,
|
|
1221
|
+
claim_id: str,
|
|
1222
|
+
*,
|
|
1223
|
+
deadline_monotonic: float | None,
|
|
1224
|
+
) -> bool:
|
|
1225
|
+
pending_queue = self._pending_queue_from_processing_queue(processing_queue)
|
|
1226
|
+
operation_id = uuid.uuid4().hex
|
|
1227
|
+
operation_result_key = self._operation_result_key(processing_queue, operation_id)
|
|
1228
|
+
|
|
1229
|
+
try:
|
|
1230
|
+
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1231
|
+
result = await _call_with_drain_deadline(
|
|
1232
|
+
lambda: self._eval(
|
|
1233
|
+
RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
|
|
1234
|
+
5,
|
|
1235
|
+
processing_queue,
|
|
1236
|
+
pending_queue,
|
|
1237
|
+
self._claim_result_ids_key(processing_queue),
|
|
1238
|
+
self._claim_result_backrefs_key(processing_queue),
|
|
1239
|
+
operation_result_key,
|
|
1240
|
+
stored_message,
|
|
1241
|
+
claim_id,
|
|
1242
|
+
self._operation_result_ttl_ms(),
|
|
1243
|
+
),
|
|
1244
|
+
deadline_monotonic=deadline_monotonic,
|
|
1245
|
+
)
|
|
1246
|
+
_raise_if_drain_deadline_expired(deadline_monotonic)
|
|
1247
|
+
return bool(_coerce_lua_count(result))
|
|
1248
|
+
except RedisMessageQueueError as exc:
|
|
1249
|
+
_set_exception_context(
|
|
1250
|
+
exc,
|
|
1251
|
+
queue=processing_queue,
|
|
1252
|
+
message_id=extract_stored_message_id(stored_message),
|
|
1253
|
+
operation="drain",
|
|
1254
|
+
)
|
|
1255
|
+
raise
|
|
1256
|
+
finally:
|
|
1257
|
+
await self._delete_operation_result_key(operation_result_key)
|
|
1258
|
+
|
|
1209
1259
|
def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
|
|
1210
1260
|
return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
|
|
1211
1261
|
is_interrupted is not None and is_interrupted.is_interrupted()
|
|
@@ -1222,8 +1272,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1222
1272
|
Kept separate from the sync implementation per AF11 (sync/async
|
|
1223
1273
|
gateways stay duplicated). Walks the same recovery path as
|
|
1224
1274
|
``_wait_for_claim`` but without gating on the interrupt flag so a
|
|
1225
|
-
soft shutdown can flush ambiguous-claim state.
|
|
1226
|
-
|
|
1275
|
+
soft shutdown can flush ambiguous-claim state. No-visibility-timeout
|
|
1276
|
+
recoveries are returned to the pending queue before their claim ids
|
|
1277
|
+
are cleared. Returns True if no pending ids remain; False on deadline
|
|
1278
|
+
expiry or Redis errors.
|
|
1227
1279
|
"""
|
|
1228
1280
|
async with self._drain_pending_claim_ids_lock:
|
|
1229
1281
|
self._last_drain_error = None
|
|
@@ -1239,11 +1291,8 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1239
1291
|
deadline_monotonic: float | None,
|
|
1240
1292
|
) -> bool:
|
|
1241
1293
|
"""Recover every in-memory pending claim id for ``processing_queue``."""
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
else:
|
|
1245
|
-
recover = self._recover_pending_non_visibility_timeout_claim
|
|
1246
|
-
skipped_transient: set[str] = set()
|
|
1294
|
+
has_visibility_timeout = self._message_visibility_timeout_seconds is not None
|
|
1295
|
+
skipped_unresolved: set[str] = set()
|
|
1247
1296
|
loop = asyncio.get_running_loop()
|
|
1248
1297
|
last_error: BaseException | None = None
|
|
1249
1298
|
while True:
|
|
@@ -1258,7 +1307,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1258
1307
|
return True
|
|
1259
1308
|
recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
|
|
1260
1309
|
claim_id = next(
|
|
1261
|
-
(cid for cid in pending if cid not in recovering and cid not in
|
|
1310
|
+
(cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
|
|
1262
1311
|
None,
|
|
1263
1312
|
)
|
|
1264
1313
|
if claim_id is None:
|
|
@@ -1267,8 +1316,34 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1267
1316
|
clear = False
|
|
1268
1317
|
try:
|
|
1269
1318
|
try:
|
|
1270
|
-
|
|
1271
|
-
|
|
1319
|
+
if has_visibility_timeout:
|
|
1320
|
+
await self._recover_pending_visibility_timeout_claim(
|
|
1321
|
+
processing_queue,
|
|
1322
|
+
claim_id,
|
|
1323
|
+
deadline_monotonic=deadline_monotonic,
|
|
1324
|
+
)
|
|
1325
|
+
clear = True
|
|
1326
|
+
continue
|
|
1327
|
+
|
|
1328
|
+
recovered_claim = await self._recover_pending_non_visibility_timeout_claim(
|
|
1329
|
+
processing_queue,
|
|
1330
|
+
claim_id,
|
|
1331
|
+
deadline_monotonic=deadline_monotonic,
|
|
1332
|
+
)
|
|
1333
|
+
if recovered_claim is None:
|
|
1334
|
+
clear = True
|
|
1335
|
+
elif await self._return_recovered_non_visibility_timeout_claim_to_pending(
|
|
1336
|
+
processing_queue,
|
|
1337
|
+
recovered_claim,
|
|
1338
|
+
claim_id,
|
|
1339
|
+
deadline_monotonic=deadline_monotonic,
|
|
1340
|
+
):
|
|
1341
|
+
clear = True
|
|
1342
|
+
else:
|
|
1343
|
+
last_error = RuntimeError(
|
|
1344
|
+
f"drain recovered claim {claim_id!r} but message was not present in processing queue"
|
|
1345
|
+
)
|
|
1346
|
+
skipped_unresolved.add(claim_id)
|
|
1272
1347
|
except _DrainDeadlineExceeded:
|
|
1273
1348
|
last_error = TimeoutError("drain pending-claim recovery deadline expired")
|
|
1274
1349
|
break
|
|
@@ -1281,7 +1356,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
1281
1356
|
claim_id,
|
|
1282
1357
|
type(exc).__name__,
|
|
1283
1358
|
)
|
|
1284
|
-
|
|
1359
|
+
skipped_unresolved.add(claim_id)
|
|
1285
1360
|
finally:
|
|
1286
1361
|
self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
|
|
1287
1362
|
with self._pending_claim_ids_lock:
|
|
@@ -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,
|
|
@@ -908,6 +920,18 @@ class RedisMessageQueue:
|
|
|
908
920
|
"Use an async callable or return an awaitable."
|
|
909
921
|
)
|
|
910
922
|
await result
|
|
923
|
+
except asyncio.CancelledError as exc:
|
|
924
|
+
current_task = asyncio.current_task()
|
|
925
|
+
if current_task is not None and current_task.cancelling() > 0:
|
|
926
|
+
raise
|
|
927
|
+
logger.exception("on_event callback raised an exception")
|
|
928
|
+
with warnings.catch_warnings():
|
|
929
|
+
warnings.simplefilter("always", RuntimeWarning)
|
|
930
|
+
warnings.warn(
|
|
931
|
+
f"on_event callback raised {type(exc).__name__}",
|
|
932
|
+
RuntimeWarning,
|
|
933
|
+
stacklevel=2,
|
|
934
|
+
)
|
|
911
935
|
except Exception as exc:
|
|
912
936
|
logger.exception("on_event callback raised an exception")
|
|
913
937
|
with warnings.catch_warnings():
|
|
@@ -1402,7 +1426,9 @@ class RedisMessageQueue:
|
|
|
1402
1426
|
calls raise ``QueueDrainedError``. It then awaits the gateway's
|
|
1403
1427
|
pending-claim-id recovery loop. Returns ``True`` if all pending claim
|
|
1404
1428
|
ids were recovered, ``False`` if the deadline fired or a transient
|
|
1405
|
-
Redis error left ids pending.
|
|
1429
|
+
Redis error left ids pending. In no-visibility-timeout queues,
|
|
1430
|
+
recovered messages are returned to pending before the claim id is
|
|
1431
|
+
cleared.
|
|
1406
1432
|
|
|
1407
1433
|
Unlike ``asyncio.CancelledError`` (hard-abort, leaves messages
|
|
1408
1434
|
claimed for VT-reclaim), ``aclose()`` is the explicit-drain
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import hashlib
|
|
2
3
|
import inspect
|
|
3
4
|
import logging
|
|
@@ -273,8 +274,12 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
|
|
|
273
274
|
return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
|
|
274
275
|
|
|
275
276
|
|
|
276
|
-
def _bind_dead_letter_gateway_to_queue(
|
|
277
|
-
|
|
277
|
+
def _bind_dead_letter_gateway_to_queue(
|
|
278
|
+
gateway: AbstractRedisGateway,
|
|
279
|
+
queue_pending_key: str,
|
|
280
|
+
queue_processing_key: str,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Validate and bind a dead-letter-enabled gateway to this queue.
|
|
278
283
|
|
|
279
284
|
The check is not thread-safe: constructing ``RedisMessageQueue`` instances
|
|
280
285
|
concurrently on multiple threads with the same DLQ-enabled gateway can
|
|
@@ -287,6 +292,12 @@ def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pend
|
|
|
287
292
|
if max_delivery_count is None:
|
|
288
293
|
return
|
|
289
294
|
|
|
295
|
+
if gateway.dead_letter_queue in (queue_pending_key, queue_processing_key):
|
|
296
|
+
raise ConfigurationError(
|
|
297
|
+
"'dead_letter_queue' must be distinct from the queue's pending and processing Redis keys. "
|
|
298
|
+
"Use a separate Redis list key for poison messages."
|
|
299
|
+
)
|
|
300
|
+
|
|
290
301
|
bound_pending_key = getattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, None)
|
|
291
302
|
if bound_pending_key is None:
|
|
292
303
|
setattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, queue_pending_key)
|
|
@@ -407,7 +418,7 @@ class _LeaseHeartbeat:
|
|
|
407
418
|
stacklevel=2,
|
|
408
419
|
)
|
|
409
420
|
|
|
410
|
-
def _emit(self, operation: EventOperation, outcome: EventOutcome, **kwargs: object) -> None:
|
|
421
|
+
def _emit(self, operation: EventOperation | str, outcome: EventOutcome | str, **kwargs: object) -> None:
|
|
411
422
|
if self._emit_event is not None:
|
|
412
423
|
self._emit_event(operation, outcome, **kwargs)
|
|
413
424
|
|
|
@@ -549,6 +560,11 @@ class RedisMessageQueue:
|
|
|
549
560
|
auto-derived dead-letter queue. Set it to ``None`` for unlimited
|
|
550
561
|
redelivery.
|
|
551
562
|
|
|
563
|
+
When ``gateway=`` is supplied, queue-level defaults are not transferred
|
|
564
|
+
to the gateway. Configure lease, dead-letter, and backpressure settings
|
|
565
|
+
such as ``message_visibility_timeout_seconds``, ``max_delivery_count``,
|
|
566
|
+
and ``max_pending_length`` on the gateway itself.
|
|
567
|
+
|
|
552
568
|
``deduplication=True`` requires ``get_deduplication_key`` to be a
|
|
553
569
|
callable that returns a non-empty string. Use a stable logical ID for
|
|
554
570
|
the deduplication keyspace.
|
|
@@ -786,7 +802,7 @@ class RedisMessageQueue:
|
|
|
786
802
|
"'max_pending_length' cannot be provided alongside 'gateway'."
|
|
787
803
|
" Configure publish backpressure on the gateway directly instead."
|
|
788
804
|
)
|
|
789
|
-
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
|
|
805
|
+
_bind_dead_letter_gateway_to_queue(gateway, self.key.pending, self.key.processing)
|
|
790
806
|
self._max_delivery_count = None
|
|
791
807
|
self._redis = gateway
|
|
792
808
|
elif client is None:
|
|
@@ -827,8 +843,8 @@ class RedisMessageQueue:
|
|
|
827
843
|
|
|
828
844
|
def _emit_event(
|
|
829
845
|
self,
|
|
830
|
-
operation: EventOperation,
|
|
831
|
-
outcome: EventOutcome,
|
|
846
|
+
operation: EventOperation | str,
|
|
847
|
+
outcome: EventOutcome | str,
|
|
832
848
|
*,
|
|
833
849
|
message_id: str | None = None,
|
|
834
850
|
claim_id: str | None = None,
|
|
@@ -868,6 +884,15 @@ class RedisMessageQueue:
|
|
|
868
884
|
"'on_event' returned an awaitable; use the async RedisMessageQueue "
|
|
869
885
|
"from redis_message_queue.asyncio instead"
|
|
870
886
|
)
|
|
887
|
+
except asyncio.CancelledError as exc:
|
|
888
|
+
logger.exception("on_event callback raised an exception")
|
|
889
|
+
with warnings.catch_warnings():
|
|
890
|
+
warnings.simplefilter("always", RuntimeWarning)
|
|
891
|
+
warnings.warn(
|
|
892
|
+
f"on_event callback raised {type(exc).__name__}",
|
|
893
|
+
RuntimeWarning,
|
|
894
|
+
stacklevel=2,
|
|
895
|
+
)
|
|
871
896
|
except Exception as exc:
|
|
872
897
|
logger.exception("on_event callback raised an exception")
|
|
873
898
|
with warnings.catch_warnings():
|
|
@@ -1031,8 +1056,9 @@ class RedisMessageQueue:
|
|
|
1031
1056
|
does not inspect handler return values; if your handler returns a
|
|
1032
1057
|
coroutine or other awaitable, the awaitable can be dropped while the
|
|
1033
1058
|
message is acked. Use ``redis_message_queue.asyncio.RedisMessageQueue``
|
|
1034
|
-
for async handlers.
|
|
1035
|
-
|
|
1059
|
+
for async handlers. For sync callback-style handlers, use
|
|
1060
|
+
``process_message_callback(handler)`` so awaitable returns are detected
|
|
1061
|
+
before acking.
|
|
1036
1062
|
|
|
1037
1063
|
If the process is killed mid-handler, the claimed message and lease
|
|
1038
1064
|
metadata remain in Redis until a later consumer claim triggers
|
|
@@ -1358,7 +1384,8 @@ class RedisMessageQueue:
|
|
|
1358
1384
|
calls raise ``QueueDrainedError``. It then walks the gateway's
|
|
1359
1385
|
in-memory ``_pending_claim_ids`` to recover any ambiguous claims that
|
|
1360
1386
|
an interrupt-aware shutdown would otherwise drop on process exit
|
|
1361
|
-
(AA-05-F2).
|
|
1387
|
+
(AA-05-F2). In no-visibility-timeout queues, recovered messages are
|
|
1388
|
+
returned to pending before the claim id is cleared.
|
|
1362
1389
|
|
|
1363
1390
|
``timeout`` bounds the pending-claim-id recovery loop in seconds;
|
|
1364
1391
|
``None`` waits indefinitely, ``0`` skips the loop entirely. The
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_payload_limits.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_queue_key_manager.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
{redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/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
|