redis-message-queue 8.2.1__tar.gz → 8.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/.gitignore +10 -1
  2. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/PKG-INFO +47 -13
  3. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/README.md +46 -12
  4. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/pyproject.toml +8 -2
  5. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_gateway.py +3 -3
  6. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/_redis_gateway.py +3 -3
  7. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/redis_message_queue.py +26 -14
  8. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/redis_message_queue.py +24 -8
  9. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/LICENSE +0 -0
  10. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/__init__.py +0 -0
  11. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  12. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_callable_utils.py +0 -0
  13. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_config.py +0 -0
  14. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_event.py +0 -0
  15. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_exceptions.py +0 -0
  16. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_payload_limits.py +0 -0
  17. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/_stored_message.py +0 -0
  20. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/__init__.py +0 -0
  21. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  22. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {redis_message_queue-8.2.1 → redis_message_queue-8.2.2}/redis_message_queue/py.typed +0 -0
@@ -90,7 +90,7 @@ ipython_config.py
90
90
  # pyenv
91
91
  # For a library or package, you might want to ignore these files since the code is
92
92
  # intended to run in multiple environments; otherwise, check them in:
93
- # .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.2
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
6
6
  Project-URL: Repository, https://github.com/Elijas/redis-message-queue
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
35
35
 
36
36
  ```bash
37
- pip install "redis-message-queue>=8.2.1,<9.0.0"
37
+ pip install "redis-message-queue>=8.2.2,<9.0.0"
38
38
  ```
39
39
 
40
40
  Requires Redis server >= 6.2.
@@ -45,6 +45,7 @@ Redis must be running locally first: use `redis-server` or
45
45
  `docker run -p 6379:6379 redis:7`.
46
46
 
47
47
  ```python
48
+ import json
48
49
  from redis import Redis
49
50
  from redis_message_queue import RedisMessageQueue
50
51
 
@@ -58,7 +59,8 @@ queue = RedisMessageQueue(
58
59
  queue.publish({"id": "msg-1", "text": "hello"})
59
60
  with queue.process_message() as message:
60
61
  if message is not None:
61
- 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.2,<9.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -22,6 +22,7 @@ Redis must be running locally first: use `redis-server` or
22
22
  `docker run -p 6379:6379 redis:7`.
23
23
 
24
24
  ```python
25
+ import json
25
26
  from redis import Redis
26
27
  from redis_message_queue import RedisMessageQueue
27
28
 
@@ -35,7 +36,8 @@ queue = RedisMessageQueue(
35
36
  queue.publish({"id": "msg-1", "text": "hello"})
36
37
  with queue.process_message() as message:
37
38
  if message is not None:
38
- 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.2"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
6
6
  readme = "README.md"
@@ -48,7 +48,7 @@ default-groups = ["dev", "test"]
48
48
  ##############################
49
49
 
50
50
  [tool.bumpversion]
51
- current_version = "8.2.1"
51
+ current_version = "8.2.2"
52
52
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
53
53
  serialize = ["{major}.{minor}.{patch}"]
54
54
  search = "{current_version}"
@@ -84,6 +84,12 @@ line-length = 120
84
84
  select = ["E", "F", "I", "W"]
85
85
  ignore = ["E731"]
86
86
 
87
+ [tool.mypy]
88
+ python_version = "3.12"
89
+ files = ["redis_message_queue/"]
90
+ explicit_package_bases = true
91
+ show_error_codes = true
92
+
87
93
  [tool.pytest.ini_options]
88
94
  asyncio_default_fixture_loop_scope = "function"
89
95
  filterwarnings = [
@@ -301,8 +301,8 @@ class RedisGateway(AbstractRedisGateway):
301
301
 
302
302
  def _emit_event(
303
303
  self,
304
- operation: EventOperation,
305
- outcome: EventOutcome,
304
+ operation: EventOperation | str,
305
+ outcome: EventOutcome | str,
306
306
  *,
307
307
  message_id: str | None = None,
308
308
  claim_id: str | None = None,
@@ -328,7 +328,7 @@ class RedisGateway(AbstractRedisGateway):
328
328
 
329
329
  def _emit_repeated_event(
330
330
  self,
331
- operation: EventOperation,
331
+ operation: EventOperation | str,
332
332
  attempts: list[_MessageAttemptEvent],
333
333
  *,
334
334
  destination_queue: str | None = None,
@@ -275,8 +275,8 @@ class RedisGateway(AbstractRedisGateway):
275
275
 
276
276
  async def _emit_event(
277
277
  self,
278
- operation: EventOperation,
279
- outcome: EventOutcome,
278
+ operation: EventOperation | str,
279
+ outcome: EventOutcome | str,
280
280
  *,
281
281
  message_id: str | None = None,
282
282
  claim_id: str | None = None,
@@ -302,7 +302,7 @@ class RedisGateway(AbstractRedisGateway):
302
302
 
303
303
  async def _emit_repeated_event(
304
304
  self,
305
- operation: EventOperation,
305
+ operation: EventOperation | str,
306
306
  attempts: list[_MessageAttemptEvent],
307
307
  *,
308
308
  destination_queue: str | None = None,
@@ -11,7 +11,6 @@ from typing import AsyncIterator, Awaitable, Callable, Literal, Optional, TypeVa
11
11
  import redis.asyncio
12
12
  import redis.exceptions
13
13
 
14
- from redis_message_queue._callable_utils import is_async_callable
15
14
  from redis_message_queue._config import (
16
15
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
17
16
  validate_dedup_configuration,
@@ -332,8 +331,12 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
332
331
  return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
333
332
 
334
333
 
335
- def _bind_dead_letter_gateway_to_queue(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,
@@ -273,8 +273,12 @@ def _derive_dead_letter_queue(name: str, key_separator: str) -> str:
273
273
  return f"{name}{key_separator}{_AUTO_DEAD_LETTER_QUEUE_SUFFIX}"
274
274
 
275
275
 
276
- def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pending_key: str) -> None:
277
- """Reject reuse of a dead-letter-enabled gateway across different queues.
276
+ def _bind_dead_letter_gateway_to_queue(
277
+ gateway: AbstractRedisGateway,
278
+ queue_pending_key: str,
279
+ queue_processing_key: str,
280
+ ) -> None:
281
+ """Validate and bind a dead-letter-enabled gateway to this queue.
278
282
 
279
283
  The check is not thread-safe: constructing ``RedisMessageQueue`` instances
280
284
  concurrently on multiple threads with the same DLQ-enabled gateway can
@@ -287,6 +291,12 @@ def _bind_dead_letter_gateway_to_queue(gateway: AbstractRedisGateway, queue_pend
287
291
  if max_delivery_count is None:
288
292
  return
289
293
 
294
+ if gateway.dead_letter_queue in (queue_pending_key, queue_processing_key):
295
+ raise ConfigurationError(
296
+ "'dead_letter_queue' must be distinct from the queue's pending and processing Redis keys. "
297
+ "Use a separate Redis list key for poison messages."
298
+ )
299
+
290
300
  bound_pending_key = getattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, None)
291
301
  if bound_pending_key is None:
292
302
  setattr(gateway, _GATEWAY_BOUND_PENDING_QUEUE_ATTR, queue_pending_key)
@@ -407,7 +417,7 @@ class _LeaseHeartbeat:
407
417
  stacklevel=2,
408
418
  )
409
419
 
410
- def _emit(self, operation: EventOperation, outcome: EventOutcome, **kwargs: object) -> None:
420
+ def _emit(self, operation: EventOperation | str, outcome: EventOutcome | str, **kwargs: object) -> None:
411
421
  if self._emit_event is not None:
412
422
  self._emit_event(operation, outcome, **kwargs)
413
423
 
@@ -549,6 +559,11 @@ class RedisMessageQueue:
549
559
  auto-derived dead-letter queue. Set it to ``None`` for unlimited
550
560
  redelivery.
551
561
 
562
+ When ``gateway=`` is supplied, queue-level defaults are not transferred
563
+ to the gateway. Configure lease, dead-letter, and backpressure settings
564
+ such as ``message_visibility_timeout_seconds``, ``max_delivery_count``,
565
+ and ``max_pending_length`` on the gateway itself.
566
+
552
567
  ``deduplication=True`` requires ``get_deduplication_key`` to be a
553
568
  callable that returns a non-empty string. Use a stable logical ID for
554
569
  the deduplication keyspace.
@@ -786,7 +801,7 @@ class RedisMessageQueue:
786
801
  "'max_pending_length' cannot be provided alongside 'gateway'."
787
802
  " Configure publish backpressure on the gateway directly instead."
788
803
  )
789
- _bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
804
+ _bind_dead_letter_gateway_to_queue(gateway, self.key.pending, self.key.processing)
790
805
  self._max_delivery_count = None
791
806
  self._redis = gateway
792
807
  elif client is None:
@@ -827,8 +842,8 @@ class RedisMessageQueue:
827
842
 
828
843
  def _emit_event(
829
844
  self,
830
- operation: EventOperation,
831
- outcome: EventOutcome,
845
+ operation: EventOperation | str,
846
+ outcome: EventOutcome | str,
832
847
  *,
833
848
  message_id: str | None = None,
834
849
  claim_id: str | None = None,
@@ -1031,8 +1046,9 @@ class RedisMessageQueue:
1031
1046
  does not inspect handler return values; if your handler returns a
1032
1047
  coroutine or other awaitable, the awaitable can be dropped while the
1033
1048
  message is acked. Use ``redis_message_queue.asyncio.RedisMessageQueue``
1034
- for async handlers. An ergonomic callback API that detects this is
1035
- planned for v8.1.
1049
+ for async handlers. For sync callback-style handlers, use
1050
+ ``process_message_callback(handler)`` so awaitable returns are detected
1051
+ before acking.
1036
1052
 
1037
1053
  If the process is killed mid-handler, the claimed message and lease
1038
1054
  metadata remain in Redis until a later consumer claim triggers