redis-message-queue 8.2.7__tar.gz → 8.2.9__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.7 → redis_message_queue-8.2.9}/PKG-INFO +35 -21
  2. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/README.md +34 -20
  3. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/pyproject.toml +2 -2
  4. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_config.py +16 -3
  5. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_exceptions.py +3 -2
  6. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_redis_gateway.py +2 -1
  7. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/asyncio/_redis_gateway.py +2 -1
  8. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/asyncio/redis_message_queue.py +13 -8
  9. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/redis_message_queue.py +17 -8
  10. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/.gitignore +0 -0
  11. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/LICENSE +0 -0
  12. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/__init__.py +0 -0
  13. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  14. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_callable_utils.py +0 -0
  15. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_event.py +0 -0
  16. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_payload_limits.py +0 -0
  17. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/_stored_message.py +0 -0
  20. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/asyncio/__init__.py +0 -0
  21. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  22. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {redis_message_queue-8.2.7 → redis_message_queue-8.2.9}/redis_message_queue/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 8.2.7
3
+ Version: 8.2.9
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.7,<9.0.0"
37
+ pip install "redis-message-queue>=8.2.9,<9.0.0"
38
38
  ```
39
39
 
40
40
  Requires Redis server >= 6.2.
@@ -129,9 +129,12 @@ All features are optional and can be enabled or disabled as needed.
129
129
  | Configuration | Delivery guarantee |
130
130
  |---|---|
131
131
  | Default (`visibility_timeout_seconds=300`) | **At-least-once** — expired messages are reclaimed and redelivered |
132
- | With `visibility_timeout_seconds=None` | **At-most-once** — a consumer crash loses the in-flight message |
132
+ | With `visibility_timeout_seconds=None, max_delivery_count=None` | **At-most-once** — a consumer crash loses the in-flight message |
133
133
 
134
134
  See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
135
+ Because delivery-count limits depend on visibility-timeout reclaim, disabling
136
+ lease-based crash recovery requires setting both `visibility_timeout_seconds=None`
137
+ and `max_delivery_count=None`.
135
138
 
136
139
  > **Important:** Handler exceptions are terminal. This library is a payload
137
140
  > queue, not a task framework: raising inside `process_message()` does not
@@ -290,7 +293,10 @@ queue = RedisMessageQueue(
290
293
 
291
294
  The callback is **advisory** — it may fire briefly after a successful `process_message` exit when a final renewal coincided with the success path. Use it for metrics or alerting, not as a correctness signal. For the async queue (`redis_message_queue.asyncio`), the callback may also be `async def`.
292
295
 
293
- Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
296
+ With `visibility_timeout_seconds=None, max_delivery_count=None`, messages
297
+ already moved to `processing` remain there indefinitely after a consumer crash
298
+ and are not redelivered, even if the crash happened before your handler
299
+ started running.
294
300
 
295
301
  Visibility deadlines use Redis server time (`TIME`), not Python process time.
296
302
  A forward step in the Redis server clock can make a live lease appear expired
@@ -473,10 +479,10 @@ without `aclose()`, or sync processes killed mid-handler, can leave the message
473
479
  and its processing/lease metadata in Redis until a later consumer claim path
474
480
  triggers visibility-timeout reclaim. With visibility timeouts enabled, this is
475
481
  the designed at-least-once recovery path: the message is delayed by the lease,
476
- not lost. With `visibility_timeout_seconds=None`, there is no automatic reclaim
477
- path. For low-visibility-timeout workloads, prefer an explicit `drain()` /
478
- `aclose()` during shutdown so local pending claim IDs are recovered before
479
- process exit.
482
+ not lost. With `visibility_timeout_seconds=None, max_delivery_count=None`,
483
+ there is no automatic reclaim path. For low-visibility-timeout workloads,
484
+ prefer an explicit `drain()` / `aclose()` during shutdown so local pending claim
485
+ IDs are recovered before process exit.
480
486
 
481
487
  `drain()` / `aclose()` timeouts are measured with Python monotonic clocks, but
482
488
  any lease deadlines they recover were created from Redis server time. The same
@@ -842,6 +848,11 @@ Pre-commit and mid-flight exceptions:
842
848
  exceptions. Under an ambiguous lost response, Redis may have committed
843
849
  despite the exception. Treat them as "operation did not succeed from the
844
850
  caller's perspective", not "Redis did not commit".
851
+ - Visibility-timeout claim-store write failures raise
852
+ `ClaimStoreFailedError` and emit `claim/failure`. When the compensating
853
+ return-to-pending write succeeds, the payload is back in pending; if
854
+ return-to-pending also fails, the payload remains in processing so there is
855
+ still a live queue copy.
845
856
 
846
857
  #### Drain events
847
858
 
@@ -869,11 +880,6 @@ The following operations have no `on_event` surface by design:
869
880
  preserves queue safety on Cluster `CROSSSLOT` rejection but cannot be
870
881
  observed through `on_event`. Operators watching key-TTL behavior or Redis
871
882
  slow logs can detect orphans.
872
- - **VT claim-store OOM compensation:** if the visibility-timeout Lua script
873
- cannot store the claim result, it removes the message from processing, pushes
874
- it back to pending, and returns `false`. Python translates that into
875
- `claim_empty/skipped`, the same shape as an empty poll. This is intentional
876
- fail-safe behavior; the message is not lost.
877
883
  - **`drop_oldest` evictions:** when publish backpressure uses
878
884
  `pending_overload_policy="drop_oldest"`, the oldest pending message is
879
885
  discarded before the new message is enqueued. The successful enqueue emits
@@ -884,14 +890,22 @@ The following operations have no `on_event` surface by design:
884
890
  terminal operation's failure event. There is no per-attempt event for those
885
891
  paths.
886
892
 
887
- The public exception hierarchy is rooted at `RedisMessageQueueError`.
888
- Configuration value/combinations raise `ConfigurationError` (also a
889
- `ValueError`), custom gateway contract violations raise `GatewayContractError`
890
- (also a `TypeError`), and Lua `redis.error_reply(...)` failures raise
891
- `LuaScriptError` (also a redis-py `ResponseError`). Publish overload raises
892
- `QueueBackpressureError`; publish after explicit drain raises
893
- `QueueDrainedError`. `CleanupFailedError` and `RetryBudgetExhaustedError` are
894
- reserved categories for cleanup and retry surfaces.
893
+ The public exception hierarchy is rooted at `RedisMessageQueueError`. The
894
+ current exported queue-owned exception classes are:
895
+
896
+ - `RedisMessageQueueError` (base)
897
+ - `ClaimStoreFailedError` - visibility-timeout claim metadata could not be stored
898
+ - `ConfigurationError` - invalid constructor args; also a `ValueError`
899
+ - `DrainFailedError` - drain pending-claim recovery failed
900
+ - `GatewayContractError` - custom gateway protocol violation; also a `TypeError`
901
+ - `LuaScriptError` - Lua `redis.error_reply(...)`; also a redis-py `ResponseError`
902
+ - `MalformedStoredMessageError` - stored value is not a valid RMQ envelope
903
+ - `PayloadTooLargeError` - serialized payload exceeds `max_payload_bytes`; also a `ValueError`
904
+ - `PayloadTooDeepError` - payload nesting exceeds `max_payload_depth`; also a `ValueError`
905
+ - `QueueBackpressureError` - `pending_overload_policy="raise"` rejected enqueue
906
+ - `QueueDrainedError` - `publish()` called after explicit drain/aclose
907
+ - `CleanupFailedError` - cleanup after handler completion failed
908
+ - `RetryBudgetExhaustedError` - Redis retry budget exhausted; also a redis-py `RedisError`
895
909
 
896
910
  ## Known limitations
897
911
 
@@ -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.7,<9.0.0"
14
+ pip install "redis-message-queue>=8.2.9,<9.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -106,9 +106,12 @@ All features are optional and can be enabled or disabled as needed.
106
106
  | Configuration | Delivery guarantee |
107
107
  |---|---|
108
108
  | Default (`visibility_timeout_seconds=300`) | **At-least-once** — expired messages are reclaimed and redelivered |
109
- | With `visibility_timeout_seconds=None` | **At-most-once** — a consumer crash loses the in-flight message |
109
+ | With `visibility_timeout_seconds=None, max_delivery_count=None` | **At-most-once** — a consumer crash loses the in-flight message |
110
110
 
111
111
  See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
112
+ Because delivery-count limits depend on visibility-timeout reclaim, disabling
113
+ lease-based crash recovery requires setting both `visibility_timeout_seconds=None`
114
+ and `max_delivery_count=None`.
112
115
 
113
116
  > **Important:** Handler exceptions are terminal. This library is a payload
114
117
  > queue, not a task framework: raising inside `process_message()` does not
@@ -267,7 +270,10 @@ queue = RedisMessageQueue(
267
270
 
268
271
  The callback is **advisory** — it may fire briefly after a successful `process_message` exit when a final renewal coincided with the success path. Use it for metrics or alerting, not as a correctness signal. For the async queue (`redis_message_queue.asyncio`), the callback may also be `async def`.
269
272
 
270
- Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
273
+ With `visibility_timeout_seconds=None, max_delivery_count=None`, messages
274
+ already moved to `processing` remain there indefinitely after a consumer crash
275
+ and are not redelivered, even if the crash happened before your handler
276
+ started running.
271
277
 
272
278
  Visibility deadlines use Redis server time (`TIME`), not Python process time.
273
279
  A forward step in the Redis server clock can make a live lease appear expired
@@ -450,10 +456,10 @@ without `aclose()`, or sync processes killed mid-handler, can leave the message
450
456
  and its processing/lease metadata in Redis until a later consumer claim path
451
457
  triggers visibility-timeout reclaim. With visibility timeouts enabled, this is
452
458
  the designed at-least-once recovery path: the message is delayed by the lease,
453
- not lost. With `visibility_timeout_seconds=None`, there is no automatic reclaim
454
- path. For low-visibility-timeout workloads, prefer an explicit `drain()` /
455
- `aclose()` during shutdown so local pending claim IDs are recovered before
456
- process exit.
459
+ not lost. With `visibility_timeout_seconds=None, max_delivery_count=None`,
460
+ there is no automatic reclaim path. For low-visibility-timeout workloads,
461
+ prefer an explicit `drain()` / `aclose()` during shutdown so local pending claim
462
+ IDs are recovered before process exit.
457
463
 
458
464
  `drain()` / `aclose()` timeouts are measured with Python monotonic clocks, but
459
465
  any lease deadlines they recover were created from Redis server time. The same
@@ -819,6 +825,11 @@ Pre-commit and mid-flight exceptions:
819
825
  exceptions. Under an ambiguous lost response, Redis may have committed
820
826
  despite the exception. Treat them as "operation did not succeed from the
821
827
  caller's perspective", not "Redis did not commit".
828
+ - Visibility-timeout claim-store write failures raise
829
+ `ClaimStoreFailedError` and emit `claim/failure`. When the compensating
830
+ return-to-pending write succeeds, the payload is back in pending; if
831
+ return-to-pending also fails, the payload remains in processing so there is
832
+ still a live queue copy.
822
833
 
823
834
  #### Drain events
824
835
 
@@ -846,11 +857,6 @@ The following operations have no `on_event` surface by design:
846
857
  preserves queue safety on Cluster `CROSSSLOT` rejection but cannot be
847
858
  observed through `on_event`. Operators watching key-TTL behavior or Redis
848
859
  slow logs can detect orphans.
849
- - **VT claim-store OOM compensation:** if the visibility-timeout Lua script
850
- cannot store the claim result, it removes the message from processing, pushes
851
- it back to pending, and returns `false`. Python translates that into
852
- `claim_empty/skipped`, the same shape as an empty poll. This is intentional
853
- fail-safe behavior; the message is not lost.
854
860
  - **`drop_oldest` evictions:** when publish backpressure uses
855
861
  `pending_overload_policy="drop_oldest"`, the oldest pending message is
856
862
  discarded before the new message is enqueued. The successful enqueue emits
@@ -861,14 +867,22 @@ The following operations have no `on_event` surface by design:
861
867
  terminal operation's failure event. There is no per-attempt event for those
862
868
  paths.
863
869
 
864
- The public exception hierarchy is rooted at `RedisMessageQueueError`.
865
- Configuration value/combinations raise `ConfigurationError` (also a
866
- `ValueError`), custom gateway contract violations raise `GatewayContractError`
867
- (also a `TypeError`), and Lua `redis.error_reply(...)` failures raise
868
- `LuaScriptError` (also a redis-py `ResponseError`). Publish overload raises
869
- `QueueBackpressureError`; publish after explicit drain raises
870
- `QueueDrainedError`. `CleanupFailedError` and `RetryBudgetExhaustedError` are
871
- reserved categories for cleanup and retry surfaces.
870
+ The public exception hierarchy is rooted at `RedisMessageQueueError`. The
871
+ current exported queue-owned exception classes are:
872
+
873
+ - `RedisMessageQueueError` (base)
874
+ - `ClaimStoreFailedError` - visibility-timeout claim metadata could not be stored
875
+ - `ConfigurationError` - invalid constructor args; also a `ValueError`
876
+ - `DrainFailedError` - drain pending-claim recovery failed
877
+ - `GatewayContractError` - custom gateway protocol violation; also a `TypeError`
878
+ - `LuaScriptError` - Lua `redis.error_reply(...)`; also a redis-py `ResponseError`
879
+ - `MalformedStoredMessageError` - stored value is not a valid RMQ envelope
880
+ - `PayloadTooLargeError` - serialized payload exceeds `max_payload_bytes`; also a `ValueError`
881
+ - `PayloadTooDeepError` - payload nesting exceeds `max_payload_depth`; also a `ValueError`
882
+ - `QueueBackpressureError` - `pending_overload_policy="raise"` rejected enqueue
883
+ - `QueueDrainedError` - `publish()` called after explicit drain/aclose
884
+ - `CleanupFailedError` - cleanup after handler completion failed
885
+ - `RetryBudgetExhaustedError` - Redis retry budget exhausted; also a redis-py `RedisError`
872
886
 
873
887
  ## Known limitations
874
888
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-message-queue"
3
- version = "8.2.7"
3
+ version = "8.2.9"
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.7"
51
+ current_version = "8.2.9"
52
52
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
53
53
  serialize = ["{major}.{minor}.{patch}"]
54
54
  search = "{current_version}"
@@ -891,10 +891,11 @@ local dead_lettered_events = {}
891
891
  local claim_store_failed_sentinel = string.char(0) .. '__rmq_claim_store_failed__'
892
892
 
893
893
  local function store_claim_and_return(stored)
894
- -- pcall guards against OOM mid-write: compensate by returning message to pending
894
+ -- pcall guards against OOM mid-write: fail fast while preserving a live payload copy.
895
+ local lease_token = nil
895
896
  local ok, result = pcall(function()
896
897
  redis.call('INCR', KEYS[5])
897
- local lease_token = redis.call('GET', KEYS[5])
898
+ lease_token = redis.call('GET', KEYS[5])
898
899
  local claim_payload = cjson.encode({stored, lease_token})
899
900
  redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
900
901
  redis.call('HSET', KEYS[4], stored, lease_token)
@@ -906,8 +907,20 @@ local function store_claim_and_return(stored)
906
907
  end)
907
908
  if not ok then
908
909
  redis.call('HINCRBY', KEYS[6], stored, -1)
910
+ local return_result = redis.pcall('RPUSH', KEYS[1], stored)
911
+ if type(return_result) == 'table' and return_result['err'] then
912
+ local failure = tostring(result) .. '; return-to-pending failed: ' .. tostring(return_result['err'])
913
+ return {claim_store_failed_sentinel, failure, stored}
914
+ end
909
915
  redis.call('LREM', KEYS[2], 1, stored)
910
- redis.pcall('RPUSH', KEYS[1], stored)
916
+ redis.call('ZREM', KEYS[3], stored)
917
+ redis.call('HDEL', KEYS[4], stored)
918
+ redis.call('DEL', KEYS[8])
919
+ redis.call('HDEL', KEYS[10], ARGV[4])
920
+ if lease_token then
921
+ redis.call('HDEL', KEYS[9], lease_token)
922
+ redis.call('HDEL', KEYS[11], lease_token)
923
+ end
911
924
  return {claim_store_failed_sentinel, tostring(result), stored}
912
925
  end
913
926
  return result
@@ -51,8 +51,9 @@ class CleanupFailedError(RedisMessageQueueError):
51
51
  class ClaimStoreFailedError(RedisMessageQueueError):
52
52
  """Raised when the VT-claim Lua store_claim_and_return pcall failed.
53
53
 
54
- The script decremented the speculative delivery_count increment and
55
- compensated by returning the message to pending before surfacing this error.
54
+ The script decremented the speculative delivery_count increment. It returns
55
+ the message to pending when that write succeeds; if that write fails, it
56
+ leaves the message in processing so the payload still has a live queue copy.
56
57
  """
57
58
 
58
59
 
@@ -987,7 +987,8 @@ class RedisGateway(AbstractRedisGateway):
987
987
  stored_message = result[2] if len(result) > 2 else None
988
988
  message_id = extract_stored_message_id(stored_message) if isinstance(stored_message, (str, bytes)) else None
989
989
  raise ClaimStoreFailedError(
990
- f"VT claim store failed after delivery_count rollback: {_decode_lua_error(result[1])}",
990
+ f"VT claim store failed after delivery_count rollback and payload preservation: "
991
+ f"{_decode_lua_error(result[1])}",
991
992
  queue=from_queue,
992
993
  message_id=message_id,
993
994
  operation="claim",
@@ -967,7 +967,8 @@ class RedisGateway(AbstractRedisGateway):
967
967
  stored_message = result[2] if len(result) > 2 else None
968
968
  message_id = extract_stored_message_id(stored_message) if isinstance(stored_message, (str, bytes)) else None
969
969
  raise ClaimStoreFailedError(
970
- f"VT claim store failed after delivery_count rollback: {_decode_lua_error(result[1])}",
970
+ f"VT claim store failed after delivery_count rollback and payload preservation: "
971
+ f"{_decode_lua_error(result[1])}",
971
972
  queue=from_queue,
972
973
  message_id=message_id,
973
974
  operation="claim",
@@ -87,6 +87,12 @@ def _warning_exception_name(exc: BaseException) -> str:
87
87
  return type(exc).__name__
88
88
 
89
89
 
90
+ def _warn_runtime_warning(message: str, *, stacklevel: int) -> None:
91
+ with warnings.catch_warnings():
92
+ warnings.simplefilter("always", RuntimeWarning)
93
+ warnings.warn(message, RuntimeWarning, stacklevel=stacklevel + 1)
94
+
95
+
90
96
  def _find_non_string_dict_keys(value: object) -> list[object]:
91
97
  non_str_keys: list[object] = []
92
98
  seen: set[int] = set()
@@ -607,8 +613,9 @@ class RedisMessageQueue:
607
613
  ):
608
614
  """Create a queue bound to an async Redis client or custom gateway.
609
615
 
610
- ``visibility_timeout_seconds`` defaults to 300. Set it to ``None`` to
611
- disable lease-based crash recovery; messages left in ``processing`` by a
616
+ ``visibility_timeout_seconds`` defaults to 300. To disable
617
+ lease-based crash recovery, set both ``visibility_timeout_seconds=None``
618
+ and ``max_delivery_count=None``; messages left in ``processing`` by a
612
619
  crashed worker are then not reclaimed automatically.
613
620
 
614
621
  ``visibility_timeout_seconds`` is a Redis server-time lease, not a
@@ -1272,7 +1279,7 @@ class RedisMessageQueue:
1272
1279
  message_id=message_id,
1273
1280
  lease_token_hash=lease_token_hash,
1274
1281
  )
1275
- warnings.warn(_STALE_LEASE_NACK_WARNING, RuntimeWarning, stacklevel=2)
1282
+ _warn_runtime_warning(_STALE_LEASE_NACK_WARNING, stacklevel=2)
1276
1283
  except BaseException as cleanup_exc:
1277
1284
  # The handler exception is the user-visible failure; cleanup failure is secondary.
1278
1285
  logger.exception("Failed to clean up message from processing queue")
@@ -1285,10 +1292,9 @@ class RedisMessageQueue:
1285
1292
  error=cleanup_exc,
1286
1293
  duration_ms=_duration_ms(cleanup_started_at),
1287
1294
  )
1288
- warnings.warn(
1295
+ _warn_runtime_warning(
1289
1296
  f"Cleanup raised after handler exception ({_warning_exception_name(cleanup_exc)}); "
1290
1297
  "see logs for both tracebacks",
1291
- RuntimeWarning,
1292
1298
  stacklevel=2,
1293
1299
  )
1294
1300
  raise
@@ -1348,7 +1354,7 @@ class RedisMessageQueue:
1348
1354
  message_id=message_id,
1349
1355
  lease_token_hash=lease_token_hash,
1350
1356
  )
1351
- warnings.warn(_STALE_LEASE_ACK_WARNING, RuntimeWarning, stacklevel=2)
1357
+ _warn_runtime_warning(_STALE_LEASE_ACK_WARNING, stacklevel=2)
1352
1358
  finished_without_error = True
1353
1359
  finally:
1354
1360
  if lease_heartbeat is not None:
@@ -1437,9 +1443,8 @@ class RedisMessageQueue:
1437
1443
  exception_type=type(exc).__name__,
1438
1444
  error=exc,
1439
1445
  )
1440
- warnings.warn(
1446
+ _warn_runtime_warning(
1441
1447
  f"Failed to trim queue {destination_queue} ({type(exc).__name__}); list may exceed max_*_length",
1442
- RuntimeWarning,
1443
1448
  stacklevel=3,
1444
1449
  )
1445
1450
 
@@ -100,6 +100,12 @@ def _warning_exception_name(exc: BaseException) -> str:
100
100
  return type(exc).__name__
101
101
 
102
102
 
103
+ def _warn_runtime_warning(message: str, *, stacklevel: int) -> None:
104
+ with warnings.catch_warnings():
105
+ warnings.simplefilter("always", RuntimeWarning)
106
+ warnings.warn(message, RuntimeWarning, stacklevel=stacklevel + 1)
107
+
108
+
103
109
  def _find_non_string_dict_keys(value: object) -> list[object]:
104
110
  non_str_keys: list[object] = []
105
111
  seen: set[int] = set()
@@ -339,12 +345,16 @@ def _close_or_cancel_awaitable(awaitable: object) -> None:
339
345
  try:
340
346
  close()
341
347
  return
348
+ except asyncio.CancelledError:
349
+ pass
342
350
  except Exception:
343
351
  pass
344
352
  cancel = getattr(awaitable, "cancel", None)
345
353
  if callable(cancel):
346
354
  try:
347
355
  cancel()
356
+ except asyncio.CancelledError:
357
+ pass
348
358
  except Exception:
349
359
  pass
350
360
 
@@ -566,8 +576,9 @@ class RedisMessageQueue:
566
576
  ):
567
577
  """Create a queue bound to a Redis client or custom gateway.
568
578
 
569
- ``visibility_timeout_seconds`` defaults to 300. Set it to ``None`` to
570
- disable lease-based crash recovery; messages left in ``processing`` by a
579
+ ``visibility_timeout_seconds`` defaults to 300. To disable
580
+ lease-based crash recovery, set both ``visibility_timeout_seconds=None``
581
+ and ``max_delivery_count=None``; messages left in ``processing`` by a
571
582
  crashed worker are then not reclaimed automatically.
572
583
 
573
584
  ``visibility_timeout_seconds`` is a Redis server-time lease, not a
@@ -1234,7 +1245,7 @@ class RedisMessageQueue:
1234
1245
  message_id=message_id,
1235
1246
  lease_token_hash=lease_token_hash,
1236
1247
  )
1237
- warnings.warn(_STALE_LEASE_NACK_WARNING, RuntimeWarning, stacklevel=2)
1248
+ _warn_runtime_warning(_STALE_LEASE_NACK_WARNING, stacklevel=2)
1238
1249
  except BaseException as cleanup_exc:
1239
1250
  # The handler exception is the user-visible failure; cleanup failure is secondary.
1240
1251
  logger.exception("Failed to clean up message from processing queue")
@@ -1247,10 +1258,9 @@ class RedisMessageQueue:
1247
1258
  error=cleanup_exc,
1248
1259
  duration_ms=_duration_ms(cleanup_started_at),
1249
1260
  )
1250
- warnings.warn(
1261
+ _warn_runtime_warning(
1251
1262
  f"Cleanup raised after handler exception ({_warning_exception_name(cleanup_exc)}); "
1252
1263
  "see logs for both tracebacks",
1253
- RuntimeWarning,
1254
1264
  stacklevel=2,
1255
1265
  )
1256
1266
  raise
@@ -1309,7 +1319,7 @@ class RedisMessageQueue:
1309
1319
  message_id=message_id,
1310
1320
  lease_token_hash=lease_token_hash,
1311
1321
  )
1312
- warnings.warn(_STALE_LEASE_ACK_WARNING, RuntimeWarning, stacklevel=2)
1322
+ _warn_runtime_warning(_STALE_LEASE_ACK_WARNING, stacklevel=2)
1313
1323
  finally:
1314
1324
  if lease_heartbeat is not None:
1315
1325
  lease_heartbeat.stop()
@@ -1399,9 +1409,8 @@ class RedisMessageQueue:
1399
1409
  exception_type=type(exc).__name__,
1400
1410
  error=exc,
1401
1411
  )
1402
- warnings.warn(
1412
+ _warn_runtime_warning(
1403
1413
  f"Failed to trim queue {destination_queue} ({type(exc).__name__}); list may exceed max_*_length",
1404
- RuntimeWarning,
1405
1414
  stacklevel=3,
1406
1415
  )
1407
1416