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.
Files changed (26) hide show
  1. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/.gitignore +10 -1
  2. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/PKG-INFO +47 -13
  3. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/README.md +46 -12
  4. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/pyproject.toml +8 -2
  5. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_config.py +49 -2
  6. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_gateway.py +89 -14
  7. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_redis_gateway.py +89 -14
  8. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/redis_message_queue.py +41 -15
  9. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/redis_message_queue.py +36 -9
  10. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/LICENSE +0 -0
  11. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/__init__.py +0 -0
  12. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  13. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_callable_utils.py +0 -0
  14. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_event.py +0 -0
  15. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_exceptions.py +0 -0
  16. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_payload_limits.py +0 -0
  17. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/_stored_message.py +0 -0
  20. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/__init__.py +0 -0
  21. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  22. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.2.1 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {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
- # .python-version
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.1
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.1,<9.0.0"
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
- print(f"got {message['text']}")
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. An ergonomic
73
- > callback API that detects this is planned for v8.1.
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
- print(f"got {message['text']}")
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, cleanup
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
- - **`drain()` / `close()` / `aclose()` lifecycle:** explicit shutdown
841
- operations do not emit lifecycle events. Pending-claim-drain recovery work
842
- counts as `claim_reclaim` events when reached.
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 every publish/claim/ack/reclaim/dedup/cleanup lifecycle event. Use it for metrics, tracing, and structured logging. See [`examples/production/observability.py`](examples/production/observability.py) for the adapter pattern.
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.1,<9.0.0"
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
- print(f"got {message['text']}")
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. An ergonomic
50
- > callback API that detects this is planned for v8.1.
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
- print(f"got {message['text']}")
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, cleanup
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
- - **`drain()` / `close()` / `aclose()` lifecycle:** explicit shutdown
818
- operations do not emit lifecycle events. Pending-claim-drain recovery work
819
- counts as `claim_reclaim` events when reached.
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 every publish/claim/ack/reclaim/dedup/cleanup lifecycle event. Use it for metrics, tracing, and structured logging. See [`examples/production/observability.py`](examples/production/observability.py) for the adapter pattern.
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.1"
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.1"
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)
@@ -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). Returns True if no pending ids remain; False if
1245
- the deadline fired or transient Redis errors prevented full drain.
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
- if self._message_visibility_timeout_seconds is not None:
1262
- recover = self._recover_pending_visibility_timeout_claim
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 skipped_transient),
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
- recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1291
- clear = True
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
- skipped_transient.add(claim_id)
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. Returns True if no
1226
- pending ids remain; False on deadline expiry or transient errors.
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
- if self._message_visibility_timeout_seconds is not None:
1243
- recover = self._recover_pending_visibility_timeout_claim
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 skipped_transient),
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
- await recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1271
- clear = True
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
- skipped_transient.add(claim_id)
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(gateway: AbstractRedisGateway, queue_pending_key: str) -> None:
336
- """Reject reuse of a dead-letter-enabled gateway across different queues.
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 async callback receiving best-effort QueueEvent
640
- lifecycle notifications. Callback failures are logged and converted to
641
- RuntimeWarning without influencing ack/nack or any other message
642
- outcome. Do not use it for correctness-critical callbacks or follow-up
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
@@ -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(gateway: AbstractRedisGateway, queue_pending_key: str) -> None:
277
- """Reject reuse of a dead-letter-enabled gateway across different queues.
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. An ergonomic callback API that detects this is
1035
- planned for v8.1.
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