redis-message-queue 3.0.0__tar.gz → 3.1.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 (20) hide show
  1. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/PKG-INFO +18 -6
  2. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/README.md +7 -5
  3. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/pyproject.toml +17 -1
  4. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_abstract_redis_gateway.py +12 -4
  5. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_config.py +2 -0
  6. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_gateway.py +3 -7
  7. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +12 -4
  8. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_redis_gateway.py +3 -7
  9. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/redis_message_queue.py +18 -15
  10. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/_implementation.py +17 -4
  11. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/redis_message_queue.py +20 -16
  12. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/LICENSE +0 -0
  13. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/__init__.py +0 -0
  14. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_callable_utils.py +0 -0
  15. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_queue_key_manager.py +0 -0
  16. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_cluster.py +0 -0
  17. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  20. {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
@@ -1,22 +1,32 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 3.0.0
3
+ Version: 3.1.1
4
4
  Summary: Python message queuing with Redis and message deduplication
5
+ License: MIT
5
6
  License-File: LICENSE
7
+ Keywords: redis,message-queue,deduplication,task-queue
6
8
  Author: Elijas
7
9
  Author-email: 4084885+Elijas@users.noreply.github.com
8
10
  Requires-Python: >=3.12,<4.0
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
9
14
  Classifier: Programming Language :: Python :: 3
10
15
  Classifier: Programming Language :: Python :: 3.12
11
16
  Classifier: Programming Language :: Python :: 3.13
12
17
  Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Topic :: System :: Distributed Computing
13
20
  Requires-Dist: redis (>=5.0.0)
14
21
  Requires-Dist: tenacity (>=8.1.0)
22
+ Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
23
+ Project-URL: Issues, https://github.com/Elijas/redis-message-queue/issues
24
+ Project-URL: Repository, https://github.com/Elijas/redis-message-queue
15
25
  Description-Content-Type: text/markdown
16
26
 
17
27
  # redis-message-queue
18
28
 
19
- [![PyPI Version](https://img.shields.io/badge/v3.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
29
+ [![PyPI Version](https://img.shields.io/badge/v3.1.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
20
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)
21
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
22
32
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](https://github.com/Elijas/redis-message-queue/issues)
@@ -150,7 +160,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
150
160
  Tradeoffs:
151
161
  - delivery becomes at-least-once after lease expiry
152
162
  - the timeout must be longer than your normal processing time if you do not use heartbeats
153
- - if you do use heartbeats, the heartbeat interval must be no more than half of the visibility timeout
163
+ - if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
154
164
  - recovery happens on consumer polling cadence rather than instantly
155
165
  - heartbeats add background renewal work for active messages
156
166
  - if a heartbeat fails (network error or stale lease), the heartbeat stops silently; the consumer continues processing but may find at ack time that the message was reclaimed by another consumer
@@ -234,12 +244,12 @@ queue = RedisMessageQueue("q", gateway=gateway)
234
244
 
235
245
  The retry knobs configure an internal `tenacity` strategy: exponential
236
246
  backoff with jitter, retry on transient Redis errors only, capped at
237
- `retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
247
+ `retry_budget_seconds`. The budget is wall-clock time from the first attempt (including attempt duration), not inter-attempt delay; a single attempt that takes longer than the budget results in zero retries. Setting `retry_budget_seconds=0` disables retry
238
248
  entirely (single attempt; exceptions propagate). The library uses
239
249
  `retry_budget_seconds` to size the operation-result cache TTL automatically,
240
250
  so the previous footgun of an over-long retry budget out-living the cache
241
251
  and producing misleading "cleanup was a no-op" warnings is now structurally
242
- impossible.
252
+ impossible. Note: tenacity may allow one additional attempt beyond the budget if the budget check passes at attempt start — total wall-clock time can exceed `retry_budget_seconds` by the duration of that final attempt.
243
253
 
244
254
  To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
245
255
  own logic) or fundamentally different semantics, subclass
@@ -299,8 +309,10 @@ await client.aclose()
299
309
  - **Timed waits use polling claim loops.** To make claims recoverable after ambiguous connection drops, `wait_for_message_and_move()` uses idempotent Lua claim polling instead of raw blocking list-move commands. This adds a small polling cadence during timed waits.
300
310
  - **Redis Lua is atomic, not rollback-transactional.** The built-in scripts now preflight queue key types and fail closed on `WRONGTYPE` before mutating queue state, but Redis does not undo earlier writes if a later script command fails for another reason (for example `OOM` under severe memory pressure).
301
311
  - **Batch reclaim limit of 100.** The visibility-timeout reclaim Lua script processes at most 100 expired messages per consumer poll. Under extreme backlog this may delay recovery, but prevents any single poll from blocking Redis.
312
+ - **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
313
+ - **Cluster detection uses `isinstance(client, RedisCluster)`.** Wrapped or instrumented cluster clients that delegate without inheriting will bypass hash-tag validation. Custom gateways should set `is_redis_cluster = True` explicitly.
302
314
  - **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
303
- - **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk.
315
+ - **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk. More broadly, any non-idempotent `LPUSH` path is vulnerable if the connection drops after server execution but before the client receives the response; all other built-in operations (deduplicated publish, lease-scoped ack/move, lease renewal) use replay markers and are safe under client-level `Retry`.
304
316
 
305
317
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
306
318
 
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v3.0.0-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/badge/v3.1.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)
@@ -134,7 +134,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
134
134
  Tradeoffs:
135
135
  - delivery becomes at-least-once after lease expiry
136
136
  - the timeout must be longer than your normal processing time if you do not use heartbeats
137
- - if you do use heartbeats, the heartbeat interval must be no more than half of the visibility timeout
137
+ - if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
138
138
  - recovery happens on consumer polling cadence rather than instantly
139
139
  - heartbeats add background renewal work for active messages
140
140
  - if a heartbeat fails (network error or stale lease), the heartbeat stops silently; the consumer continues processing but may find at ack time that the message was reclaimed by another consumer
@@ -218,12 +218,12 @@ queue = RedisMessageQueue("q", gateway=gateway)
218
218
 
219
219
  The retry knobs configure an internal `tenacity` strategy: exponential
220
220
  backoff with jitter, retry on transient Redis errors only, capped at
221
- `retry_budget_seconds`. Setting `retry_budget_seconds=0` disables retry
221
+ `retry_budget_seconds`. The budget is wall-clock time from the first attempt (including attempt duration), not inter-attempt delay; a single attempt that takes longer than the budget results in zero retries. Setting `retry_budget_seconds=0` disables retry
222
222
  entirely (single attempt; exceptions propagate). The library uses
223
223
  `retry_budget_seconds` to size the operation-result cache TTL automatically,
224
224
  so the previous footgun of an over-long retry budget out-living the cache
225
225
  and producing misleading "cleanup was a no-op" warnings is now structurally
226
- impossible.
226
+ impossible. Note: tenacity may allow one additional attempt beyond the budget if the budget check passes at attempt start — total wall-clock time can exceed `retry_budget_seconds` by the duration of that final attempt.
227
227
 
228
228
  To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
229
229
  own logic) or fundamentally different semantics, subclass
@@ -283,8 +283,10 @@ await client.aclose()
283
283
  - **Timed waits use polling claim loops.** To make claims recoverable after ambiguous connection drops, `wait_for_message_and_move()` uses idempotent Lua claim polling instead of raw blocking list-move commands. This adds a small polling cadence during timed waits.
284
284
  - **Redis Lua is atomic, not rollback-transactional.** The built-in scripts now preflight queue key types and fail closed on `WRONGTYPE` before mutating queue state, but Redis does not undo earlier writes if a later script command fails for another reason (for example `OOM` under severe memory pressure).
285
285
  - **Batch reclaim limit of 100.** The visibility-timeout reclaim Lua script processes at most 100 expired messages per consumer poll. Under extreme backlog this may delay recovery, but prevents any single poll from blocking Redis.
286
+ - **Claim-attempt loop limit of 100 per poll.** The VT claim Lua script attempts at most 100 LMOVE+delivery-count checks per invocation. Under pathological conditions (>100 consecutive poison messages in pending), a single poll returns no message even though non-poison messages exist deeper in the queue. Subsequent polls drain the poison batch 100 at a time.
287
+ - **Cluster detection uses `isinstance(client, RedisCluster)`.** Wrapped or instrumented cluster clients that delegate without inheriting will bypass hash-tag validation. Custom gateways should set `is_redis_cluster = True` explicitly.
286
288
  - **Redis Cluster requires hash tags.** The built-in queue uses multiple Redis keys per operation. Wrap the queue name in hash tags (for example `{myqueue}`) so every generated key lands in the same slot. When you pass a Redis Cluster client to the built-in queue/gateway path, incompatible names are rejected early.
287
- - **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk.
289
+ - **Client-side `Retry` can duplicate non-deduplicated publishes.** If you construct your `redis.Redis` client with `retry=Retry(...)`, redis-py retries `ConnectionError` / `TimeoutError` at the connection layer — *below* this library. Idempotent operations (deduplicated `publish()`, lease-scoped cleanup) are safe because their Lua scripts replay the original result. `add_message()` (used by `publish()` when `deduplication=False`) is a bare `LPUSH`: this library deliberately does not retry it, but a client-level `Retry` will, and if the server executed the command before the response was lost the message is enqueued twice. Leave `retry=None` (the default) if you need strict at-most-once semantics for non-deduplicated publishes, or accept the duplication risk. More broadly, any non-idempotent `LPUSH` path is vulnerable if the connection drops after server execution but before the client receives the response; all other built-in operations (deduplicated publish, lease-scoped ack/move, lease renewal) use replay markers and are safe under client-level `Retry`.
288
290
 
289
291
  For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
290
292
 
@@ -1,9 +1,25 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "3.0.0"
3
+ version = "3.1.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"
7
+ license = "MIT"
8
+ keywords = ["redis", "message-queue", "deduplication", "task-queue"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Software Development :: Libraries",
16
+ "Topic :: System :: Distributed Computing",
17
+ ]
18
+
19
+ [tool.poetry.urls]
20
+ Homepage = "https://github.com/Elijas/redis-message-queue"
21
+ Repository = "https://github.com/Elijas/redis-message-queue"
22
+ Issues = "https://github.com/Elijas/redis-message-queue/issues"
7
23
 
8
24
  [tool.poetry.dependencies]
9
25
  python = "^3.12"
@@ -11,13 +11,21 @@ class AbstractRedisGateway(ABC):
11
11
  gateways MUST uphold the same behavioral contracts documented on each method
12
12
  to avoid phantom heartbeats, undetected lease conflicts, or silent data loss.
13
13
 
14
- Gateways that support visibility timeouts (lease-based claiming) should expose
14
+ Gateways that support visibility timeouts (lease-based claiming) MUST expose
15
15
  a ``message_visibility_timeout_seconds`` property (int or None). This is not
16
16
  abstract because it is configuration rather than protocol, but it is required
17
17
  when the queue is configured with ``heartbeat_interval_seconds``.
18
- Lease-capable custom gateways should always expose this property; otherwise
19
- the queue cannot enforce lease-specific fail-closed checks and will treat the
20
- gateway as a non-lease implementation.
18
+ Lease-capable custom gateways MUST expose this property; omitting it
19
+ silently disables heartbeat validation and lease-token safety checks,
20
+ causing the queue to treat the gateway as a non-lease implementation.
21
+
22
+ The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
23
+ from the gateway via ``getattr``. Avoid using these attribute names for
24
+ unrelated purposes on custom gateway implementations.
25
+
26
+ Gateways that wrap a Redis Cluster client should expose an
27
+ ``is_redis_cluster`` property returning ``True`` so the queue can apply
28
+ hash-tag validation at construction time.
21
29
 
22
30
  Concurrency
23
31
  -----------
@@ -179,6 +179,8 @@ def validate_dead_letter_parameters(
179
179
  raise ValueError("'max_delivery_count' requires 'message_visibility_timeout_seconds' to be set.")
180
180
  if dead_letter_queue is not None and not isinstance(dead_letter_queue, str):
181
181
  raise TypeError(f"'dead_letter_queue' must be a str or None, got {type(dead_letter_queue).__name__}")
182
+ if isinstance(dead_letter_queue, str) and dead_letter_queue and not dead_letter_queue.strip():
183
+ raise ValueError("'dead_letter_queue' must be a non-empty string")
182
184
  if max_delivery_count is not None and not dead_letter_queue:
183
185
  raise ValueError("'dead_letter_queue' is required when 'max_delivery_count' is set.")
184
186
  if dead_letter_queue and max_delivery_count is None:
@@ -382,7 +382,7 @@ class RedisGateway(AbstractRedisGateway):
382
382
  if not is_redis_retryable_exception(exc):
383
383
  raise
384
384
  claim_may_need_recovery = True
385
- logger.warning(non_blocking_retry_log, exc)
385
+ logger.warning(non_blocking_retry_log, type(exc).__name__)
386
386
  if self._is_interrupted():
387
387
  pending_claim_id_to_share = claim_id
388
388
  return None
@@ -418,7 +418,7 @@ class RedisGateway(AbstractRedisGateway):
418
418
  pending_claim_id_to_share = claim_id
419
419
  raise
420
420
  claim_may_need_recovery = True
421
- logger.warning(polling_retry_log, exc)
421
+ logger.warning(polling_retry_log, type(exc).__name__)
422
422
  last_retryable_exception = exc
423
423
  except BaseException:
424
424
  pending_claim_id_to_share = claim_id
@@ -589,7 +589,7 @@ class RedisGateway(AbstractRedisGateway):
589
589
  return f"{processing_queue}{_OPERATION_RESULT_SUFFIX}:{lease_token}:{operation_id}"
590
590
 
591
591
  def _publish_operation_result_ttl_ms(self) -> str:
592
- return str(max(self._message_deduplication_log_ttl_seconds, 3600) * 1000)
592
+ return str(max(self._message_deduplication_log_ttl_seconds, 3600, self._retry_budget_seconds + 180) * 1000)
593
593
 
594
594
  def _operation_result_ttl_ms(self) -> str:
595
595
  # Floor is derived from the configured retry budget so the cached
@@ -685,8 +685,6 @@ class RedisGateway(AbstractRedisGateway):
685
685
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
686
686
  cached_claim = self._redis_client.get(claim_result_key)
687
687
  if cached_claim is None:
688
- if self._is_interrupted():
689
- return None
690
688
  cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
691
689
  if cached_claim is None:
692
690
  return None
@@ -701,8 +699,6 @@ class RedisGateway(AbstractRedisGateway):
701
699
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
702
700
  cached_claim = self._redis_client.get(claim_result_key)
703
701
  if cached_claim is None:
704
- if self._is_interrupted():
705
- return None
706
702
  cached_claim = self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
707
703
  if cached_claim is None:
708
704
  return None
@@ -12,13 +12,21 @@ class AbstractRedisGateway(ABC):
12
12
  documented on each method to avoid phantom heartbeats, undetected lease conflicts,
13
13
  or silent data loss.
14
14
 
15
- Gateways that support visibility timeouts (lease-based claiming) should expose
15
+ Gateways that support visibility timeouts (lease-based claiming) MUST expose
16
16
  a ``message_visibility_timeout_seconds`` property (int or None). This is not
17
17
  abstract because it is configuration rather than protocol, but it is required
18
18
  when the queue is configured with ``heartbeat_interval_seconds``.
19
- Lease-capable custom gateways should always expose this property; otherwise
20
- the queue cannot enforce lease-specific fail-closed checks and will treat the
21
- gateway as a non-lease implementation.
19
+ Lease-capable custom gateways MUST expose this property; omitting it
20
+ silently disables heartbeat validation and lease-token safety checks,
21
+ causing the queue to treat the gateway as a non-lease implementation.
22
+
23
+ The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
24
+ from the gateway via ``getattr``. Avoid using these attribute names for
25
+ unrelated purposes on custom gateway implementations.
26
+
27
+ Gateways that wrap a Redis Cluster client should expose an
28
+ ``is_redis_cluster`` property returning ``True`` so the queue can apply
29
+ hash-tag validation at construction time.
22
30
 
23
31
  Concurrency
24
32
  -----------
@@ -382,7 +382,7 @@ class RedisGateway(AbstractRedisGateway):
382
382
  if not is_redis_retryable_exception(exc):
383
383
  raise
384
384
  claim_may_need_recovery = True
385
- logger.warning(non_blocking_retry_log, exc)
385
+ logger.warning(non_blocking_retry_log, type(exc).__name__)
386
386
  if self._is_interrupted():
387
387
  pending_claim_id_to_share = claim_id
388
388
  return None
@@ -419,7 +419,7 @@ class RedisGateway(AbstractRedisGateway):
419
419
  pending_claim_id_to_share = claim_id
420
420
  raise
421
421
  claim_may_need_recovery = True
422
- logger.warning(polling_retry_log, exc)
422
+ logger.warning(polling_retry_log, type(exc).__name__)
423
423
  last_retryable_exception = exc
424
424
  except BaseException:
425
425
  pending_claim_id_to_share = claim_id
@@ -590,7 +590,7 @@ class RedisGateway(AbstractRedisGateway):
590
590
  return f"{processing_queue}{_OPERATION_RESULT_SUFFIX}:{lease_token}:{operation_id}"
591
591
 
592
592
  def _publish_operation_result_ttl_ms(self) -> str:
593
- return str(max(self._message_deduplication_log_ttl_seconds, 3600) * 1000)
593
+ return str(max(self._message_deduplication_log_ttl_seconds, 3600, self._retry_budget_seconds + 180) * 1000)
594
594
 
595
595
  def _operation_result_ttl_ms(self) -> str:
596
596
  # Floor is derived from the configured retry budget so the cached
@@ -686,8 +686,6 @@ class RedisGateway(AbstractRedisGateway):
686
686
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
687
687
  cached_claim = await self._redis_client.get(claim_result_key)
688
688
  if cached_claim is None:
689
- if self._is_interrupted():
690
- return None
691
689
  cached_claim = await self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
692
690
  if cached_claim is None:
693
691
  return None
@@ -702,8 +700,6 @@ class RedisGateway(AbstractRedisGateway):
702
700
  claim_result_key = self._claim_result_key(processing_queue, claim_id)
703
701
  cached_claim = await self._redis_client.get(claim_result_key)
704
702
  if cached_claim is None:
705
- if self._is_interrupted():
706
- return None
707
703
  cached_claim = await self._redis_client.hget(self._claim_result_ids_key(processing_queue), claim_id)
708
704
  if cached_claim is None:
709
705
  return None
@@ -20,6 +20,17 @@ logger = logging.getLogger(__name__)
20
20
  _T = TypeVar("_T")
21
21
  _GATEWAY_BOUND_PENDING_QUEUE_ATTR = "_rmq_bound_pending_queue"
22
22
 
23
+ _STALE_LEASE_ACK_WARNING = (
24
+ "Message cleanup after successful processing was a no-op: "
25
+ "the lease expired and the message was likely reclaimed by another consumer. "
26
+ "This is expected at-least-once delivery behavior under visibility timeout."
27
+ )
28
+ _STALE_LEASE_NACK_WARNING = (
29
+ "Message cleanup after failed processing was a no-op: "
30
+ "the lease expired and the message was likely reclaimed by another consumer. "
31
+ "This is expected at-least-once delivery behavior under visibility timeout."
32
+ )
33
+
23
34
 
24
35
  class _TaskBaseException(Exception):
25
36
  def __init__(self, original: BaseException):
@@ -110,10 +121,10 @@ def _validate_heartbeat_interval_seconds(
110
121
  "'heartbeat_interval_seconds' requires a configured visibility timeout."
111
122
  )
112
123
  raise ValueError(require_visibility_timeout_message)
113
- if heartbeat_interval_seconds > visibility_timeout_seconds / 2:
124
+ if heartbeat_interval_seconds >= visibility_timeout_seconds / 2:
114
125
  raise ValueError(
115
- "'heartbeat_interval_seconds' must be no more than half of 'visibility_timeout_seconds' "
116
- f"({heartbeat_interval_seconds} > {visibility_timeout_seconds / 2})"
126
+ "'heartbeat_interval_seconds' must be less than half of 'visibility_timeout_seconds' "
127
+ f"({heartbeat_interval_seconds} >= {visibility_timeout_seconds / 2})"
117
128
  )
118
129
  return heartbeat_interval_seconds
119
130
 
@@ -385,7 +396,6 @@ class RedisMessageQueue:
385
396
  raise TypeError(f"'gateway' must be an AbstractRedisGateway, got {type(gateway).__name__}")
386
397
  gateway_visibility_timeout_seconds = _get_optional_gateway_visibility_timeout_seconds(gateway)
387
398
  self._requires_claimed_message = gateway_visibility_timeout_seconds is not None
388
- _bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
389
399
  _validate_cluster_configuration(self.key, gateway=gateway)
390
400
  if heartbeat_interval_seconds is not None:
391
401
  gateway_visibility_timeout_seconds = _get_gateway_visibility_timeout_seconds(gateway)
@@ -402,6 +412,7 @@ class RedisMessageQueue:
402
412
  "'max_delivery_count' cannot be provided alongside 'gateway'."
403
413
  " Configure 'max_delivery_count' and 'dead_letter_queue' on the gateway directly instead."
404
414
  )
415
+ _bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
405
416
  self._redis = gateway
406
417
  elif client is None:
407
418
  raise ValueError("Either 'client' or 'gateway' must be provided.")
@@ -449,7 +460,7 @@ class RedisMessageQueue:
449
460
  "'message' dict keys must all be strings; "
450
461
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
451
462
  )
452
- message_str = json.dumps(message, sort_keys=True)
463
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
453
464
  else:
454
465
  message_str = message
455
466
 
@@ -535,11 +546,7 @@ class RedisMessageQueue:
535
546
  self._remove_processed_message(stored_message, lease_token)
536
547
  )
537
548
  if lease_token is not None and not applied:
538
- logger.warning(
539
- "Message cleanup after failed processing was a no-op: "
540
- "the lease expired and the message was likely reclaimed by another consumer. "
541
- "This is expected at-least-once delivery behavior under visibility timeout."
542
- )
549
+ logger.warning(_STALE_LEASE_NACK_WARNING)
543
550
  except BaseException:
544
551
  logger.exception("Failed to clean up message from processing queue")
545
552
  raise
@@ -555,11 +562,7 @@ class RedisMessageQueue:
555
562
  self._remove_processed_message(stored_message, lease_token)
556
563
  )
557
564
  if lease_token is not None and not applied:
558
- logger.warning(
559
- "Message cleanup after successful processing was a no-op: "
560
- "the lease expired and the message was likely reclaimed by another consumer. "
561
- "This is expected at-least-once delivery behavior under visibility timeout."
562
- )
565
+ logger.warning(_STALE_LEASE_ACK_WARNING)
563
566
  finished_without_error = True
564
567
  finally:
565
568
  if lease_heartbeat is not None:
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import signal
3
+ import sys
3
4
  from typing import Iterable
4
5
 
5
6
  from redis_message_queue.interrupt_handler._interface import (
@@ -60,7 +61,10 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
60
61
  f"'signals' must contain signal.Signals members, got {type(sig).__name__} at position {i}"
61
62
  )
62
63
  for sig in signals:
63
- current = signal.getsignal(sig)
64
+ try:
65
+ current = signal.getsignal(sig)
66
+ except OSError:
67
+ raise ValueError(f"Signal {sig.name} cannot be caught or handled by user code.")
64
68
  if _is_graceful_interrupt_handler(current):
65
69
  raise ValueError(
66
70
  f"Signal {sig.name} is already owned by another GracefulInterruptHandler."
@@ -70,13 +74,19 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
70
74
  raise ValueError(
71
75
  f"Signal {sig.name} already has a non-default handler installed."
72
76
  " GracefulInterruptHandler refuses to replace existing handlers."
77
+ " If running inside asyncio.run(), create the handler before asyncio.run() starts."
73
78
  )
74
79
  self._interrupted = False
75
80
  self._verbose = verbose
76
81
  self._signals = signals
77
82
  self._previous_handlers = {sig: signal.getsignal(sig) for sig in self._signals}
78
83
  for sig in self._signals:
79
- signal.signal(sig, self._signal_handler)
84
+ try:
85
+ signal.signal(sig, self._signal_handler)
86
+ except ValueError as e:
87
+ if "main thread" in str(e):
88
+ raise ValueError("GracefulInterruptHandler must be created on the main thread.") from e
89
+ raise
80
90
 
81
91
  def is_interrupted(self) -> bool:
82
92
  return self._interrupted
@@ -91,6 +101,9 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
91
101
  return
92
102
  os.kill(os.getpid(), signum)
93
103
  return
94
- if self._verbose:
95
- print(f"Received signal: {signal.strsignal(signum)}")
96
104
  self._interrupted = True
105
+ if self._verbose:
106
+ try:
107
+ print(f"Received signal: {signal.strsignal(signum)}", file=sys.stderr)
108
+ except Exception:
109
+ pass
@@ -20,6 +20,17 @@ from redis_message_queue.interrupt_handler import BaseGracefulInterruptHandler
20
20
  logger = logging.getLogger(__name__)
21
21
  _GATEWAY_BOUND_PENDING_QUEUE_ATTR = "_rmq_bound_pending_queue"
22
22
 
23
+ _STALE_LEASE_ACK_WARNING = (
24
+ "Message cleanup after successful processing was a no-op: "
25
+ "the lease expired and the message was likely reclaimed by another consumer. "
26
+ "This is expected at-least-once delivery behavior under visibility timeout."
27
+ )
28
+ _STALE_LEASE_NACK_WARNING = (
29
+ "Message cleanup after failed processing was a no-op: "
30
+ "the lease expired and the message was likely reclaimed by another consumer. "
31
+ "This is expected at-least-once delivery behavior under visibility timeout."
32
+ )
33
+
23
34
 
24
35
  def _validate_heartbeat_interval_seconds(
25
36
  heartbeat_interval_seconds: int | float | None,
@@ -45,10 +56,10 @@ def _validate_heartbeat_interval_seconds(
45
56
  "'heartbeat_interval_seconds' requires a configured visibility timeout."
46
57
  )
47
58
  raise ValueError(require_visibility_timeout_message)
48
- if heartbeat_interval_seconds > visibility_timeout_seconds / 2:
59
+ if heartbeat_interval_seconds >= visibility_timeout_seconds / 2:
49
60
  raise ValueError(
50
- "'heartbeat_interval_seconds' must be no more than half of 'visibility_timeout_seconds' "
51
- f"({heartbeat_interval_seconds} > {visibility_timeout_seconds / 2})"
61
+ "'heartbeat_interval_seconds' must be less than half of 'visibility_timeout_seconds' "
62
+ f"({heartbeat_interval_seconds} >= {visibility_timeout_seconds / 2})"
52
63
  )
53
64
  return heartbeat_interval_seconds
54
65
 
@@ -338,7 +349,6 @@ class RedisMessageQueue:
338
349
  raise TypeError(f"'gateway' must be an AbstractRedisGateway, got {type(gateway).__name__}")
339
350
  gateway_visibility_timeout_seconds = _get_optional_gateway_visibility_timeout_seconds(gateway)
340
351
  self._requires_claimed_message = gateway_visibility_timeout_seconds is not None
341
- _bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
342
352
  _validate_cluster_configuration(self.key, gateway=gateway)
343
353
  if heartbeat_interval_seconds is not None:
344
354
  gateway_visibility_timeout_seconds = _get_gateway_visibility_timeout_seconds(gateway)
@@ -355,6 +365,7 @@ class RedisMessageQueue:
355
365
  "'max_delivery_count' cannot be provided alongside 'gateway'."
356
366
  " Configure 'max_delivery_count' and 'dead_letter_queue' on the gateway directly instead."
357
367
  )
368
+ _bind_dead_letter_gateway_to_queue(gateway, self.key.pending)
358
369
  self._redis = gateway
359
370
  elif client is None:
360
371
  raise ValueError("Either 'client' or 'gateway' must be provided.")
@@ -391,7 +402,8 @@ class RedisMessageQueue:
391
402
  ``TypeError`` to avoid silent ``json.dumps`` coercion that would
392
403
  collapse distinct keys into the same dedup key (e.g. ``{1: "x"}``
393
404
  vs ``{"1": "x"}``). Only top-level keys are validated; nested
394
- dicts follow ``json.dumps`` defaults.
405
+ dicts follow ``json.dumps`` defaults (e.g. nested non-string keys
406
+ are silently coerced: integer keys become strings).
395
407
  """
396
408
  if not isinstance(message, (str, dict)):
397
409
  raise TypeError(f"'message' must be a str or dict, got {type(message).__name__}")
@@ -402,7 +414,7 @@ class RedisMessageQueue:
402
414
  "'message' dict keys must all be strings; "
403
415
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
404
416
  )
405
- message_str = json.dumps(message, sort_keys=True)
417
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
406
418
  else:
407
419
  message_str = message
408
420
 
@@ -492,11 +504,7 @@ class RedisMessageQueue:
492
504
  else:
493
505
  applied = self._remove_processed_message(stored_message, lease_token)
494
506
  if lease_token is not None and not applied:
495
- logger.warning(
496
- "Message cleanup after failed processing was a no-op: "
497
- "the lease expired and the message was likely reclaimed by another consumer. "
498
- "This is expected at-least-once delivery behavior under visibility timeout."
499
- )
507
+ logger.warning(_STALE_LEASE_NACK_WARNING)
500
508
  except BaseException:
501
509
  logger.exception("Failed to clean up message from processing queue")
502
510
  raise
@@ -508,11 +516,7 @@ class RedisMessageQueue:
508
516
  else:
509
517
  applied = self._remove_processed_message(stored_message, lease_token)
510
518
  if lease_token is not None and not applied:
511
- logger.warning(
512
- "Message cleanup after successful processing was a no-op: "
513
- "the lease expired and the message was likely reclaimed by another consumer. "
514
- "This is expected at-least-once delivery behavior under visibility timeout."
515
- )
519
+ logger.warning(_STALE_LEASE_ACK_WARNING)
516
520
  finally:
517
521
  if lease_heartbeat is not None:
518
522
  lease_heartbeat.stop()