redis-message-queue 3.1.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.1.0 → redis_message_queue-3.1.1}/PKG-INFO +15 -4
  2. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/README.md +4 -3
  3. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/pyproject.toml +17 -1
  4. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_abstract_redis_gateway.py +4 -0
  5. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_config.py +2 -0
  6. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_gateway.py +2 -2
  7. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +4 -0
  8. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_redis_gateway.py +2 -2
  9. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/redis_message_queue.py +1 -1
  10. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/_implementation.py +10 -2
  11. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/redis_message_queue.py +1 -1
  12. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/LICENSE +0 -0
  13. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/__init__.py +0 -0
  14. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_callable_utils.py +0 -0
  15. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_queue_key_manager.py +0 -0
  16. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_cluster.py +0 -0
  17. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/_stored_message.py +0 -0
  18. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/__init__.py +0 -0
  19. {redis_message_queue-3.1.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  20. {redis_message_queue-3.1.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.1.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.1.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
@@ -239,7 +249,7 @@ 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
@@ -300,6 +310,7 @@ await client.aclose()
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.
302
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.
303
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.
304
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`.
305
316
 
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v3.1.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
@@ -223,7 +223,7 @@ 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
@@ -284,6 +284,7 @@ await client.aclose()
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
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.
287
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.
288
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`.
289
290
 
@@ -1,9 +1,25 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "3.1.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"
@@ -19,6 +19,10 @@ class AbstractRedisGateway(ABC):
19
19
  silently disables heartbeat validation and lease-token safety checks,
20
20
  causing the queue to treat the gateway as a non-lease implementation.
21
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
+
22
26
  Gateways that wrap a Redis Cluster client should expose an
23
27
  ``is_redis_cluster`` property returning ``True`` so the queue can apply
24
28
  hash-tag validation at construction time.
@@ -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
@@ -20,6 +20,10 @@ class AbstractRedisGateway(ABC):
20
20
  silently disables heartbeat validation and lease-token safety checks,
21
21
  causing the queue to treat the gateway as a non-lease implementation.
22
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
+
23
27
  Gateways that wrap a Redis Cluster client should expose an
24
28
  ``is_redis_cluster`` property returning ``True`` so the queue can apply
25
29
  hash-tag validation at construction time.
@@ -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
@@ -460,7 +460,7 @@ class RedisMessageQueue:
460
460
  "'message' dict keys must all be strings; "
461
461
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
462
462
  )
463
- message_str = json.dumps(message, sort_keys=True)
463
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
464
464
  else:
465
465
  message_str = message
466
466
 
@@ -61,7 +61,10 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
61
61
  f"'signals' must contain signal.Signals members, got {type(sig).__name__} at position {i}"
62
62
  )
63
63
  for sig in signals:
64
- 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.")
65
68
  if _is_graceful_interrupt_handler(current):
66
69
  raise ValueError(
67
70
  f"Signal {sig.name} is already owned by another GracefulInterruptHandler."
@@ -78,7 +81,12 @@ class GracefulInterruptHandler(BaseGracefulInterruptHandler):
78
81
  self._signals = signals
79
82
  self._previous_handlers = {sig: signal.getsignal(sig) for sig in self._signals}
80
83
  for sig in self._signals:
81
- 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
82
90
 
83
91
  def is_interrupted(self) -> bool:
84
92
  return self._interrupted
@@ -414,7 +414,7 @@ class RedisMessageQueue:
414
414
  "'message' dict keys must all be strings; "
415
415
  f"got non-string keys: {non_str_keys[:3]}" + (" (and more)" if len(non_str_keys) > 3 else "")
416
416
  )
417
- message_str = json.dumps(message, sort_keys=True)
417
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
418
418
  else:
419
419
  message_str = message
420
420