redis-message-queue 6.0.0__tar.gz → 6.0.1__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 (23) hide show
  1. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/PKG-INFO +40 -2
  2. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/README.md +39 -1
  3. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/pyproject.toml +1 -1
  4. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_config.py +11 -1
  5. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_redis_gateway.py +15 -5
  6. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/asyncio/_redis_gateway.py +15 -5
  7. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/asyncio/redis_message_queue.py +23 -9
  8. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/redis_message_queue.py +29 -8
  9. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/LICENSE +0 -0
  10. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/__init__.py +0 -0
  11. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  12. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_callable_utils.py +0 -0
  13. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_event.py +0 -0
  14. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_exceptions.py +0 -0
  15. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_queue_key_manager.py +0 -0
  16. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_redis_cluster.py +0 -0
  17. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  20. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  21. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  22. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  23. {redis_message_queue-6.0.0 → redis_message_queue-6.0.1}/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: 6.0.0
3
+ Version: 6.0.1
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # redis-message-queue
28
28
 
29
- [![PyPI Version](https://img.shields.io/badge/v6.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v6.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
30
30
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
31
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
32
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -587,6 +587,44 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
587
587
  - **Do not switch sync and async gateway instances mid-process while claims are active.** Redis state is compatible across deploys, but each gateway instance keeps its own pending claim-recovery IDs. In-flight claim recovery state does not transfer between instances.
588
588
  - **Switching between `gateway=` and `client=` can retarget the DLQ.** The built-in `client=` path derives the DLQ from the queue name. If a custom gateway used a different `dead_letter_queue`, switching paths has the same orphaning impact as renaming the DLQ.
589
589
 
590
+ ### v5 to v6 migration
591
+
592
+ v6.0.0 is a non-breaking-defaults release that adds new public APIs. v5 code continues to work; v6 adds opt-in features.
593
+
594
+ **New APIs (opt in as needed):**
595
+
596
+ - `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.
597
+ - `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.
598
+ - `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.
599
+
600
+ **New constructor rejections:**
601
+
602
+ - Passing a `redis.sentinel.Sentinel` manager object now raises at construction. Use `sentinel.master_for(name)` instead.
603
+ - Passing `redis.Redis(single_connection_client=True)` to the sync built-in gateway now raises. Use a normal pooled `redis.Redis` client.
604
+
605
+ **Custom gateway migration:**
606
+
607
+ If you subclass `AbstractRedisGateway` and override `renew_message_lease`, add a keyword argument:
608
+
609
+ ```python
610
+ def renew_message_lease(
611
+ self,
612
+ queue,
613
+ message,
614
+ lease_token,
615
+ *,
616
+ is_interrupted=None, # NEW in v6: heartbeat passes a stop-signal observer
617
+ ) -> bool:
618
+ ...
619
+ ```
620
+
621
+ Async gateways need the same signature on `async def`. Honor `is_interrupted.is_interrupted()` in your retry loops to stop renewing when the queue is shutting down.
622
+
623
+ **Exception handling:**
624
+
625
+ - Catch `QueueBackpressureError` around publish if you opt into backpressure.
626
+ - A new base class `RedisMessageQueueError` lets you catch all library-owned exceptions in one place. Existing catches for `ValueError`, `TypeError`, `redis.RedisError`, etc. continue to work because the new subclasses preserve those bases.
627
+
590
628
  ### v2 to v3 migration
591
629
 
592
630
  v3.0.0 replaced the `retry_strategy: Callable` constructor parameter with `retry_budget_seconds`, `retry_max_delay_seconds`, and `retry_initial_delay_seconds`. Users with custom retry strategies should subclass `AbstractRedisGateway` instead (see [Custom gateway](#custom-gateway)).
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v6.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v6.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
4
4
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
6
6
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -561,6 +561,44 @@ For a full analysis, see [docs/production-readiness.md](docs/production-readines
561
561
  - **Do not switch sync and async gateway instances mid-process while claims are active.** Redis state is compatible across deploys, but each gateway instance keeps its own pending claim-recovery IDs. In-flight claim recovery state does not transfer between instances.
562
562
  - **Switching between `gateway=` and `client=` can retarget the DLQ.** The built-in `client=` path derives the DLQ from the queue name. If a custom gateway used a different `dead_letter_queue`, switching paths has the same orphaning impact as renaming the DLQ.
563
563
 
564
+ ### v5 to v6 migration
565
+
566
+ v6.0.0 is a non-breaking-defaults release that adds new public APIs. v5 code continues to work; v6 adds opt-in features.
567
+
568
+ **New APIs (opt in as needed):**
569
+
570
+ - `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.
571
+ - `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.
572
+ - `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.
573
+
574
+ **New constructor rejections:**
575
+
576
+ - Passing a `redis.sentinel.Sentinel` manager object now raises at construction. Use `sentinel.master_for(name)` instead.
577
+ - Passing `redis.Redis(single_connection_client=True)` to the sync built-in gateway now raises. Use a normal pooled `redis.Redis` client.
578
+
579
+ **Custom gateway migration:**
580
+
581
+ If you subclass `AbstractRedisGateway` and override `renew_message_lease`, add a keyword argument:
582
+
583
+ ```python
584
+ def renew_message_lease(
585
+ self,
586
+ queue,
587
+ message,
588
+ lease_token,
589
+ *,
590
+ is_interrupted=None, # NEW in v6: heartbeat passes a stop-signal observer
591
+ ) -> bool:
592
+ ...
593
+ ```
594
+
595
+ Async gateways need the same signature on `async def`. Honor `is_interrupted.is_interrupted()` in your retry loops to stop renewing when the queue is shutting down.
596
+
597
+ **Exception handling:**
598
+
599
+ - Catch `QueueBackpressureError` around publish if you opt into backpressure.
600
+ - A new base class `RedisMessageQueueError` lets you catch all library-owned exceptions in one place. Existing catches for `ValueError`, `TypeError`, `redis.RedisError`, etc. continue to work because the new subclasses preserve those bases.
601
+
564
602
  ### v2 to v3 migration
565
603
 
566
604
  v3.0.0 replaced the `retry_strategy: Callable` constructor parameter with `retry_budget_seconds`, `retry_max_delay_seconds`, and `retry_initial_delay_seconds`. Users with custom retry strategies should subclass `AbstractRedisGateway` instead (see [Custom gateway](#custom-gateway)).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "6.0.0"
3
+ version = "6.0.1"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
6
6
  readme = "README.md"
@@ -14,7 +14,7 @@ from tenacity import (
14
14
  wait_exponential_jitter,
15
15
  )
16
16
 
17
- from redis_message_queue._exceptions import ConfigurationError
17
+ from redis_message_queue._exceptions import ConfigurationError, RetryBudgetExhaustedError
18
18
  from redis_message_queue.interrupt_handler._interface import (
19
19
  BaseGracefulInterruptHandler,
20
20
  )
@@ -91,6 +91,15 @@ def _noop_retry(func):
91
91
  return func
92
92
 
93
93
 
94
+ def _raise_retry_budget_exhausted(retry_state: RetryCallState) -> typing.NoReturn:
95
+ exc = retry_state.outcome.exception() if retry_state.outcome is not None else None
96
+ if exc is None:
97
+ raise RetryBudgetExhaustedError("Redis retry budget exhausted")
98
+ raise RetryBudgetExhaustedError(
99
+ f"Redis retry budget exhausted after {retry_state.attempt_number} attempts"
100
+ ) from exc
101
+
102
+
94
103
  def build_retry_strategy(
95
104
  *,
96
105
  retry_budget_seconds: int,
@@ -113,6 +122,7 @@ def build_retry_strategy(
113
122
  get_parent_retry=lambda: retry_if_exception(is_redis_retryable_exception),
114
123
  ),
115
124
  after=after_log(logger, logging.WARNING),
125
+ retry_error_callback=_raise_retry_budget_exhausted,
116
126
  reraise=True,
117
127
  )
118
128
 
@@ -35,7 +35,11 @@ from redis_message_queue._config import (
35
35
  validate_pending_backpressure_parameters,
36
36
  )
37
37
  from redis_message_queue._event import EventOperation, EventOutcome
38
- from redis_message_queue._exceptions import QueueBackpressureError, wrap_lua_response_error
38
+ from redis_message_queue._exceptions import (
39
+ QueueBackpressureError,
40
+ RetryBudgetExhaustedError,
41
+ wrap_lua_response_error,
42
+ )
39
43
  from redis_message_queue._stored_message import (
40
44
  ClaimedMessage,
41
45
  MessageData,
@@ -555,7 +559,9 @@ class RedisGateway(AbstractRedisGateway):
555
559
  claim_id=claim_id,
556
560
  exception_type=type(retry_exc).__name__,
557
561
  )
558
- raise
562
+ raise RetryBudgetExhaustedError(
563
+ "Redis retry budget exhausted during message claim"
564
+ ) from retry_exc
559
565
  except BaseException:
560
566
  pending_claim_id_to_share = claim_id
561
567
  raise
@@ -624,7 +630,9 @@ class RedisGateway(AbstractRedisGateway):
624
630
  claim_id=claim_id,
625
631
  exception_type=type(last_retryable_exception).__name__,
626
632
  )
627
- raise last_retryable_exception
633
+ raise RetryBudgetExhaustedError(
634
+ "Redis retry budget exhausted during message claim"
635
+ ) from last_retryable_exception
628
636
  return None
629
637
  time.sleep(min(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS, remaining))
630
638
  finally:
@@ -796,13 +804,15 @@ class RedisGateway(AbstractRedisGateway):
796
804
  try:
797
805
  self._redis_client.delete(claim_result_key)
798
806
  except Exception:
799
- logger.debug("Failed to delete claim result key %s", claim_result_key, exc_info=True)
807
+ # Claim-result keys have bounded TTLs; this cleanup is intentionally best-effort.
808
+ logger.warning("Failed to delete claim result key %s", claim_result_key, exc_info=True)
800
809
 
801
810
  def _delete_claim_result_ref(self, claim_result_refs_key: str, lease_token: str) -> None:
802
811
  try:
803
812
  self._redis_client.hdel(claim_result_refs_key, lease_token)
804
813
  except Exception:
805
- logger.debug(
814
+ # Claim-result refs have bounded TTLs; this cleanup is intentionally best-effort.
815
+ logger.warning(
806
816
  "Failed to delete claim result reference %s[%s]",
807
817
  claim_result_refs_key,
808
818
  lease_token,
@@ -34,7 +34,11 @@ from redis_message_queue._config import (
34
34
  validate_pending_backpressure_parameters,
35
35
  )
36
36
  from redis_message_queue._event import EventOperation, EventOutcome
37
- from redis_message_queue._exceptions import QueueBackpressureError, wrap_lua_response_error
37
+ from redis_message_queue._exceptions import (
38
+ QueueBackpressureError,
39
+ RetryBudgetExhaustedError,
40
+ wrap_lua_response_error,
41
+ )
38
42
  from redis_message_queue._stored_message import (
39
43
  ClaimedMessage,
40
44
  MessageData,
@@ -551,7 +555,9 @@ class RedisGateway(AbstractRedisGateway):
551
555
  claim_id=claim_id,
552
556
  exception_type=type(retry_exc).__name__,
553
557
  )
554
- raise
558
+ raise RetryBudgetExhaustedError(
559
+ "Redis retry budget exhausted during message claim"
560
+ ) from retry_exc
555
561
  except BaseException:
556
562
  pending_claim_id_to_share = claim_id
557
563
  raise
@@ -621,7 +627,9 @@ class RedisGateway(AbstractRedisGateway):
621
627
  claim_id=claim_id,
622
628
  exception_type=type(last_retryable_exception).__name__,
623
629
  )
624
- raise last_retryable_exception
630
+ raise RetryBudgetExhaustedError(
631
+ "Redis retry budget exhausted during message claim"
632
+ ) from last_retryable_exception
625
633
  return None
626
634
  await asyncio.sleep(min(_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS, remaining))
627
635
  finally:
@@ -793,13 +801,15 @@ class RedisGateway(AbstractRedisGateway):
793
801
  try:
794
802
  await self._redis_client.delete(claim_result_key)
795
803
  except Exception:
796
- logger.debug("Failed to delete claim result key %s", claim_result_key, exc_info=True)
804
+ # Claim-result keys have bounded TTLs; this cleanup is intentionally best-effort.
805
+ logger.warning("Failed to delete claim result key %s", claim_result_key, exc_info=True)
797
806
 
798
807
  async def _delete_claim_result_ref(self, claim_result_refs_key: str, lease_token: str) -> None:
799
808
  try:
800
809
  await self._redis_client.hdel(claim_result_refs_key, lease_token)
801
810
  except Exception:
802
- logger.debug(
811
+ # Claim-result refs have bounded TTLs; this cleanup is intentionally best-effort.
812
+ logger.warning(
803
813
  "Failed to delete claim result reference %s[%s]",
804
814
  claim_result_refs_key,
805
815
  lease_token,
@@ -18,7 +18,7 @@ from redis_message_queue._config import (
18
18
  validate_pending_backpressure_parameters,
19
19
  )
20
20
  from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
21
- from redis_message_queue._exceptions import ConfigurationError, GatewayContractError
21
+ from redis_message_queue._exceptions import CleanupFailedError, ConfigurationError, GatewayContractError
22
22
  from redis_message_queue._queue_key_manager import QueueKeyManager
23
23
  from redis_message_queue._redis_cluster import validate_queue_keys_for_redis_cluster
24
24
  from redis_message_queue._stored_message import (
@@ -573,6 +573,17 @@ class RedisMessageQueue:
573
573
  " Expected a function that takes the message (str | dict) and returns a str (or an awaitable thereof)."
574
574
  " Example: get_deduplication_key=lambda msg: msg['user_id']"
575
575
  )
576
+ if (
577
+ max_pending_length is not None
578
+ and pending_overload_policy == "drop_oldest"
579
+ and (deduplication or get_deduplication_key_was_configured)
580
+ ):
581
+ raise ConfigurationError(
582
+ "'pending_overload_policy=drop_oldest' cannot be used with deduplication because dropped messages "
583
+ "leave their deduplication keys in Redis, causing future publishes of the same payload to be "
584
+ "silently suppressed. Use 'raise' or 'block' for deduplicated queues, or disable deduplication if "
585
+ "'drop_oldest' is required."
586
+ )
576
587
  if not deduplication and get_deduplication_key_was_configured:
577
588
  raise ConfigurationError("'get_deduplication_key' cannot be provided when 'deduplication' is disabled.")
578
589
  if on_heartbeat_failure is not None and not callable(on_heartbeat_failure):
@@ -725,11 +736,13 @@ class RedisMessageQueue:
725
736
  await result
726
737
  except Exception as exc:
727
738
  logger.exception("on_event callback raised an exception")
728
- warnings.warn(
729
- f"on_event callback raised {type(exc).__name__}",
730
- RuntimeWarning,
731
- stacklevel=2,
732
- )
739
+ with warnings.catch_warnings():
740
+ warnings.simplefilter("always", RuntimeWarning)
741
+ warnings.warn(
742
+ f"on_event callback raised {type(exc).__name__}",
743
+ RuntimeWarning,
744
+ stacklevel=2,
745
+ )
733
746
 
734
747
  async def publish(self, message: str | dict) -> bool:
735
748
  """Publish a message.
@@ -926,6 +939,7 @@ class RedisMessageQueue:
926
939
  )
927
940
  warnings.warn(_STALE_LEASE_NACK_WARNING, RuntimeWarning, stacklevel=2)
928
941
  except BaseException as cleanup_exc:
942
+ # The handler exception is the user-visible failure; cleanup failure is secondary.
929
943
  logger.exception("Failed to clean up message from processing queue")
930
944
  await self._emit_event(
931
945
  "cleanup_failed",
@@ -952,7 +966,7 @@ class RedisMessageQueue:
952
966
  cleanup_operation = self._remove_processed_message(stored_message, lease_token)
953
967
  try:
954
968
  applied = await _await_preserving_cancellation(cleanup_operation)
955
- except BaseException as cleanup_exc:
969
+ except Exception as cleanup_exc:
956
970
  await self._emit_event(
957
971
  "cleanup_failed",
958
972
  "failure",
@@ -961,7 +975,7 @@ class RedisMessageQueue:
961
975
  exception_type=type(cleanup_exc).__name__,
962
976
  duration_ms=_duration_ms(cleanup_started_at),
963
977
  )
964
- raise
978
+ raise CleanupFailedError("Cleanup after successful processing failed") from cleanup_exc
965
979
  if self._enable_completed_queue:
966
980
  await self._emit_event(
967
981
  "completed",
@@ -1083,7 +1097,7 @@ class RedisMessageQueue:
1083
1097
  return True
1084
1098
  loop = asyncio.get_running_loop()
1085
1099
  deadline_monotonic = None if timeout is None else (loop.time() + float(timeout))
1086
- return await drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1100
+ return await _await_preserving_cancellation(drainer(self.key.processing, deadline_monotonic=deadline_monotonic))
1087
1101
 
1088
1102
  def _build_lease_heartbeat(
1089
1103
  self,
@@ -19,7 +19,7 @@ from redis_message_queue._config import (
19
19
  validate_pending_backpressure_parameters,
20
20
  )
21
21
  from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
22
- from redis_message_queue._exceptions import ConfigurationError, GatewayContractError
22
+ from redis_message_queue._exceptions import CleanupFailedError, ConfigurationError, GatewayContractError
23
23
  from redis_message_queue._queue_key_manager import QueueKeyManager
24
24
  from redis_message_queue._redis_cluster import validate_queue_keys_for_redis_cluster
25
25
  from redis_message_queue._redis_gateway import RedisGateway
@@ -518,6 +518,17 @@ class RedisMessageQueue:
518
518
  "'get_deduplication_key' is an async callable; "
519
519
  "use the async RedisMessageQueue from redis_message_queue.asyncio instead"
520
520
  )
521
+ if (
522
+ max_pending_length is not None
523
+ and pending_overload_policy == "drop_oldest"
524
+ and (deduplication or get_deduplication_key_was_configured)
525
+ ):
526
+ raise ConfigurationError(
527
+ "'pending_overload_policy=drop_oldest' cannot be used with deduplication because dropped messages "
528
+ "leave their deduplication keys in Redis, causing future publishes of the same payload to be "
529
+ "silently suppressed. Use 'raise' or 'block' for deduplicated queues, or disable deduplication if "
530
+ "'drop_oldest' is required."
531
+ )
521
532
  if not deduplication and get_deduplication_key_was_configured:
522
533
  raise ConfigurationError("'get_deduplication_key' cannot be provided when 'deduplication' is disabled.")
523
534
  if on_heartbeat_failure is not None and not callable(on_heartbeat_failure):
@@ -682,11 +693,13 @@ class RedisMessageQueue:
682
693
  )
683
694
  except Exception as exc:
684
695
  logger.exception("on_event callback raised an exception")
685
- warnings.warn(
686
- f"on_event callback raised {type(exc).__name__}",
687
- RuntimeWarning,
688
- stacklevel=2,
689
- )
696
+ with warnings.catch_warnings():
697
+ warnings.simplefilter("always", RuntimeWarning)
698
+ warnings.warn(
699
+ f"on_event callback raised {type(exc).__name__}",
700
+ RuntimeWarning,
701
+ stacklevel=2,
702
+ )
690
703
 
691
704
  def publish(self, message: str | dict) -> bool:
692
705
  """Publish a message.
@@ -888,6 +901,7 @@ class RedisMessageQueue:
888
901
  )
889
902
  warnings.warn(_STALE_LEASE_NACK_WARNING, RuntimeWarning, stacklevel=2)
890
903
  except BaseException as cleanup_exc:
904
+ # The handler exception is the user-visible failure; cleanup failure is secondary.
891
905
  logger.exception("Failed to clean up message from processing queue")
892
906
  self._emit_event(
893
907
  "cleanup_failed",
@@ -913,7 +927,7 @@ class RedisMessageQueue:
913
927
  applied = self._move_processed_message(self.key.completed, stored_message, lease_token)
914
928
  else:
915
929
  applied = self._remove_processed_message(stored_message, lease_token)
916
- except BaseException as cleanup_exc:
930
+ except Exception as cleanup_exc:
917
931
  self._emit_event(
918
932
  "cleanup_failed",
919
933
  "failure",
@@ -922,7 +936,7 @@ class RedisMessageQueue:
922
936
  exception_type=type(cleanup_exc).__name__,
923
937
  duration_ms=_duration_ms(cleanup_started_at),
924
938
  )
925
- raise
939
+ raise CleanupFailedError("Cleanup after successful processing failed") from cleanup_exc
926
940
  if self._enable_completed_queue:
927
941
  self._emit_event(
928
942
  "completed",
@@ -1047,6 +1061,13 @@ class RedisMessageQueue:
1047
1061
  deadline_monotonic = None if timeout is None else (time.monotonic() + float(timeout))
1048
1062
  return drainer(self.key.processing, deadline_monotonic=deadline_monotonic)
1049
1063
 
1064
+ def close(self, timeout: float | None = None) -> bool:
1065
+ """Alias of :meth:`drain` for consistency with redis-py naming.
1066
+
1067
+ See :meth:`drain` for full semantics.
1068
+ """
1069
+ return self.drain(timeout)
1070
+
1050
1071
  def _build_lease_heartbeat(
1051
1072
  self,
1052
1073
  stored_message: MessageData,