redis-message-queue 3.1.0__tar.gz → 3.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redis_message_queue-3.1.0/README.md → redis_message_queue-3.1.2/PKG-INFO +47 -3
- redis_message_queue-3.1.0/PKG-INFO → redis_message_queue-3.1.2/README.md +20 -20
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/pyproject.toml +17 -1
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/__init__.py +2 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_abstract_redis_gateway.py +8 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_config.py +33 -16
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_queue_key_manager.py +2 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_gateway.py +5 -5
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/__init__.py +2 -1
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_abstract_redis_gateway.py +8 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/_redis_gateway.py +5 -5
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/redis_message_queue.py +2 -2
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_implementation.py +10 -2
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/redis_message_queue.py +3 -3
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/LICENSE +0 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/interrupt_handler/_interface.py +0 -0
|
@@ -1,6 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redis-message-queue
|
|
3
|
+
Version: 3.1.2
|
|
4
|
+
Summary: Python message queuing with Redis and message deduplication
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: redis,message-queue,deduplication,task-queue
|
|
8
|
+
Author: Elijas
|
|
9
|
+
Author-email: 4084885+Elijas@users.noreply.github.com
|
|
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
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Requires-Dist: redis (>=5.0.0)
|
|
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
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
1
27
|
# redis-message-queue
|
|
2
28
|
|
|
3
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
30
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
31
|
[](LICENSE)
|
|
6
32
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -134,7 +160,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
|
|
|
134
160
|
Tradeoffs:
|
|
135
161
|
- delivery becomes at-least-once after lease expiry
|
|
136
162
|
- 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
|
|
163
|
+
- if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
|
|
138
164
|
- recovery happens on consumer polling cadence rather than instantly
|
|
139
165
|
- heartbeats add background renewal work for active messages
|
|
140
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
|
|
@@ -223,7 +249,7 @@ entirely (single attempt; exceptions propagate). The library uses
|
|
|
223
249
|
`retry_budget_seconds` to size the operation-result cache TTL automatically,
|
|
224
250
|
so the previous footgun of an over-long retry budget out-living the cache
|
|
225
251
|
and producing misleading "cleanup was a no-op" warnings is now structurally
|
|
226
|
-
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.
|
|
227
253
|
|
|
228
254
|
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
229
255
|
own logic) or fundamentally different semantics, subclass
|
|
@@ -284,11 +310,28 @@ await client.aclose()
|
|
|
284
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).
|
|
285
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.
|
|
286
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
|
+
- **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
|
|
314
|
+
- **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
315
|
- **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.
|
|
316
|
+
- **Non-ASCII payloads use ~2x storage.** The default `ensure_ascii=True` in JSON serialization encodes non-ASCII characters as `\uXXXX` escape sequences. This is a deliberate compatibility choice.
|
|
288
317
|
- **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
318
|
|
|
290
319
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
291
320
|
|
|
321
|
+
## Upgrading
|
|
322
|
+
|
|
323
|
+
### Configuration changes on live queues
|
|
324
|
+
|
|
325
|
+
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
326
|
+
|
|
327
|
+
- **Do not change `key_separator` on a live queue.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
|
|
328
|
+
- **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
|
|
329
|
+
- **Reducing `max_delivery_count` retroactively DLQs messages.** The delivery count hash persists across restarts. Messages whose accumulated count exceeds the new limit are immediately dead-lettered on next claim.
|
|
330
|
+
|
|
331
|
+
### v2 to v3 migration
|
|
332
|
+
|
|
333
|
+
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)).
|
|
334
|
+
|
|
292
335
|
## Running locally
|
|
293
336
|
|
|
294
337
|
You'll need a Redis server:
|
|
@@ -311,3 +354,4 @@ poetry run python -m examples.receive_messages
|
|
|
311
354
|
```
|
|
312
355
|
|
|
313
356
|

|
|
357
|
+
|
|
@@ -1,22 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: redis-message-queue
|
|
3
|
-
Version: 3.1.0
|
|
4
|
-
Summary: Python message queuing with Redis and message deduplication
|
|
5
|
-
License-File: LICENSE
|
|
6
|
-
Author: Elijas
|
|
7
|
-
Author-email: 4084885+Elijas@users.noreply.github.com
|
|
8
|
-
Requires-Python: >=3.12,<4.0
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
-
Requires-Dist: redis (>=5.0.0)
|
|
14
|
-
Requires-Dist: tenacity (>=8.1.0)
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
|
|
17
1
|
# redis-message-queue
|
|
18
2
|
|
|
19
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
20
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
21
5
|
[](LICENSE)
|
|
22
6
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -150,7 +134,7 @@ This enables lease-based redelivery for messages left in `processing` by a crash
|
|
|
150
134
|
Tradeoffs:
|
|
151
135
|
- delivery becomes at-least-once after lease expiry
|
|
152
136
|
- 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
|
|
137
|
+
- if you do use heartbeats, the heartbeat interval must be less than half of the visibility timeout
|
|
154
138
|
- recovery happens on consumer polling cadence rather than instantly
|
|
155
139
|
- heartbeats add background renewal work for active messages
|
|
156
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
|
|
@@ -239,7 +223,7 @@ entirely (single attempt; exceptions propagate). The library uses
|
|
|
239
223
|
`retry_budget_seconds` to size the operation-result cache TTL automatically,
|
|
240
224
|
so the previous footgun of an over-long retry budget out-living the cache
|
|
241
225
|
and producing misleading "cleanup was a no-op" warnings is now structurally
|
|
242
|
-
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.
|
|
243
227
|
|
|
244
228
|
To plug in a different retry library (`backoff`, `asyncstdlib.retry`, or your
|
|
245
229
|
own logic) or fundamentally different semantics, subclass
|
|
@@ -300,11 +284,28 @@ await client.aclose()
|
|
|
300
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).
|
|
301
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.
|
|
302
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
|
+
- **Default dedup key is the full message.** Without a custom `get_deduplication_key`, the entire serialized message becomes a Redis key name for dedup tracking. For large messages (>1KB), provide a custom key function to avoid excessive Redis memory usage.
|
|
288
|
+
- **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
289
|
- **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.
|
|
290
|
+
- **Non-ASCII payloads use ~2x storage.** The default `ensure_ascii=True` in JSON serialization encodes non-ASCII characters as `\uXXXX` escape sequences. This is a deliberate compatibility choice.
|
|
304
291
|
- **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
292
|
|
|
306
293
|
For a full analysis, see [docs/production-readiness.md](docs/production-readiness.md).
|
|
307
294
|
|
|
295
|
+
## Upgrading
|
|
296
|
+
|
|
297
|
+
### Configuration changes on live queues
|
|
298
|
+
|
|
299
|
+
> **Warning:** These changes are destructive on live queues. Drain the queue completely before applying them.
|
|
300
|
+
|
|
301
|
+
- **Do not change `key_separator` on a live queue.** All existing Redis keys become invisible to the new key scheme. Drain the queue completely before changing separators.
|
|
302
|
+
- **Do not switch from no-VT to VT with messages in processing.** Messages claimed by non-VT consumers have no lease deadline entries. VT-enabled consumers cannot reclaim them. Drain the processing queue first.
|
|
303
|
+
- **Reducing `max_delivery_count` retroactively DLQs messages.** The delivery count hash persists across restarts. Messages whose accumulated count exceeds the new limit are immediately dead-lettered on next claim.
|
|
304
|
+
|
|
305
|
+
### v2 to v3 migration
|
|
306
|
+
|
|
307
|
+
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)).
|
|
308
|
+
|
|
308
309
|
## Running locally
|
|
309
310
|
|
|
310
311
|
You'll need a Redis server:
|
|
@@ -327,4 +328,3 @@ poetry run python -m examples.receive_messages
|
|
|
327
328
|
```
|
|
328
329
|
|
|
329
330
|

|
|
330
|
-
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "redis-message-queue"
|
|
3
|
-
version = "3.1.
|
|
3
|
+
version = "3.1.2"
|
|
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"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
|
|
2
|
+
from redis_message_queue._redis_gateway import RedisGateway
|
|
2
3
|
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
3
4
|
from redis_message_queue.interrupt_handler import (
|
|
4
5
|
BaseGracefulInterruptHandler,
|
|
@@ -8,6 +9,7 @@ from redis_message_queue.redis_message_queue import RedisMessageQueue
|
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"RedisMessageQueue",
|
|
12
|
+
"RedisGateway",
|
|
11
13
|
"AbstractRedisGateway",
|
|
12
14
|
"ClaimedMessage",
|
|
13
15
|
"MessageData",
|
|
@@ -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.
|
|
@@ -124,6 +128,10 @@ class AbstractRedisGateway(ABC):
|
|
|
124
128
|
use leases.
|
|
125
129
|
|
|
126
130
|
Return None if no message was available (e.g. timeout or interrupt).
|
|
131
|
+
|
|
132
|
+
Implementations MUST respect a reasonable timeout or return None
|
|
133
|
+
periodically so the consumer can check for interrupts. Blocking
|
|
134
|
+
indefinitely without returning prevents graceful shutdown.
|
|
127
135
|
"""
|
|
128
136
|
|
|
129
137
|
@abstractmethod
|
|
@@ -36,13 +36,13 @@ def is_redis_retryable_exception(exception):
|
|
|
36
36
|
),
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
-
# 2. Explicit retryable exceptions
|
|
39
|
+
# 2. Explicit retryable exceptions (BusyLoadingError is a ConnectionError
|
|
40
|
+
# subclass, so it is already handled by branch 1 above)
|
|
40
41
|
return isinstance(
|
|
41
42
|
exception,
|
|
42
43
|
(
|
|
43
44
|
# Network/availability issues
|
|
44
45
|
redis.exceptions.TimeoutError, # Socket or server-side timeout
|
|
45
|
-
redis.exceptions.BusyLoadingError, # Server loading data
|
|
46
46
|
# Cluster transient failures
|
|
47
47
|
redis.exceptions.ClusterDownError, # Covers ClusterDown + MasterDown
|
|
48
48
|
redis.exceptions.TryAgainError, # Cluster state requires retry
|
|
@@ -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:
|
|
@@ -273,6 +275,7 @@ if cached_result then
|
|
|
273
275
|
return tonumber(cached_result)
|
|
274
276
|
end
|
|
275
277
|
|
|
278
|
+
redis.call('LPUSH', KEYS[2], ARGV[2])
|
|
276
279
|
local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
|
|
277
280
|
if removed == 1 then
|
|
278
281
|
local claim_id = redis.call('HGET', KEYS[4], ARGV[1])
|
|
@@ -280,7 +283,8 @@ if removed == 1 then
|
|
|
280
283
|
redis.call('HDEL', KEYS[3], claim_id)
|
|
281
284
|
redis.call('HDEL', KEYS[4], ARGV[1])
|
|
282
285
|
end
|
|
283
|
-
|
|
286
|
+
else
|
|
287
|
+
redis.call('LREM', KEYS[2], 1, ARGV[2])
|
|
284
288
|
end
|
|
285
289
|
|
|
286
290
|
redis.call('SET', KEYS[5], tostring(removed), 'PX', tonumber(ARGV[3]))
|
|
@@ -453,6 +457,9 @@ local function redis_message_queue_decode_claim(cached_claim)
|
|
|
453
457
|
return nil
|
|
454
458
|
end
|
|
455
459
|
|
|
460
|
+
local time = redis.call('TIME')
|
|
461
|
+
local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
462
|
+
|
|
456
463
|
-- Cache replay paths below return the ORIGINAL claim (same lease_token) even if
|
|
457
464
|
-- the lease deadline has passed in wall-clock time. Safe because ack is gated by
|
|
458
465
|
-- the server-side HGET lease_tokens check in MOVE/REMOVE_WITH_LEASE_TOKEN: if
|
|
@@ -467,6 +474,7 @@ if cached_claim then
|
|
|
467
474
|
redis.call('HSET', KEYS[10], ARGV[4], cached_claim)
|
|
468
475
|
redis.call('HSET', KEYS[11], claim[2], ARGV[4])
|
|
469
476
|
redis.call('HSET', KEYS[9], claim[2], KEYS[8])
|
|
477
|
+
redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
|
|
470
478
|
return {claim[1], claim[2]}
|
|
471
479
|
end
|
|
472
480
|
redis.call('DEL', KEYS[8])
|
|
@@ -479,14 +487,12 @@ if cached_recovery then
|
|
|
479
487
|
redis.call('SET', KEYS[8], cached_recovery, 'PX', tonumber(ARGV[3]))
|
|
480
488
|
redis.call('HSET', KEYS[11], claim[2], ARGV[4])
|
|
481
489
|
redis.call('HSET', KEYS[9], claim[2], KEYS[8])
|
|
490
|
+
redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), claim[1])
|
|
482
491
|
return {claim[1], claim[2]}
|
|
483
492
|
end
|
|
484
493
|
redis.call('HDEL', KEYS[10], ARGV[4])
|
|
485
494
|
end
|
|
486
495
|
|
|
487
|
-
local time = redis.call('TIME')
|
|
488
|
-
local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
489
|
-
|
|
490
496
|
-- Cap at 100 to bound Lua execution time (Redis blocks during scripts).
|
|
491
497
|
-- With a single consumer polling at default interval, 1000 expired leases drain in ~2.5s.
|
|
492
498
|
local expired = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now_ms, 'LIMIT', 0, 100)
|
|
@@ -516,15 +522,24 @@ if #to_requeue > 0 then
|
|
|
516
522
|
end
|
|
517
523
|
|
|
518
524
|
local function store_claim_and_return(stored)
|
|
519
|
-
|
|
520
|
-
local
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
525
|
+
-- pcall guards against OOM mid-write: compensate by returning message to pending
|
|
526
|
+
local ok, result = pcall(function()
|
|
527
|
+
local lease_token = tostring(redis.call('INCR', KEYS[5]))
|
|
528
|
+
local claim_payload = cjson.encode({stored, lease_token})
|
|
529
|
+
redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
|
|
530
|
+
redis.call('HSET', KEYS[4], stored, lease_token)
|
|
531
|
+
redis.call('SET', KEYS[8], claim_payload, 'PX', tonumber(ARGV[3]))
|
|
532
|
+
redis.call('HSET', KEYS[9], lease_token, KEYS[8])
|
|
533
|
+
redis.call('HSET', KEYS[10], ARGV[4], claim_payload)
|
|
534
|
+
redis.call('HSET', KEYS[11], lease_token, ARGV[4])
|
|
535
|
+
return {stored, lease_token}
|
|
536
|
+
end)
|
|
537
|
+
if not ok then
|
|
538
|
+
redis.call('LREM', KEYS[2], 1, stored)
|
|
539
|
+
redis.pcall('RPUSH', KEYS[1], stored)
|
|
540
|
+
return false
|
|
541
|
+
end
|
|
542
|
+
return result
|
|
528
543
|
end
|
|
529
544
|
|
|
530
545
|
local claim_attempts = 0
|
|
@@ -703,6 +718,7 @@ end
|
|
|
703
718
|
|
|
704
719
|
-- See REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT for the bounded-leak rationale
|
|
705
720
|
-- on the removed == 0 branch (externally-removed message + valid lease token).
|
|
721
|
+
redis.call('LPUSH', KEYS[2], ARGV[2])
|
|
706
722
|
local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
|
|
707
723
|
if removed == 1 then
|
|
708
724
|
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
@@ -718,8 +734,9 @@ if removed == 1 then
|
|
|
718
734
|
redis.call('HDEL', KEYS[8], ARGV[3])
|
|
719
735
|
end
|
|
720
736
|
redis.call('HDEL', KEYS[5], ARGV[1])
|
|
721
|
-
redis.call('LPUSH', KEYS[2], ARGV[2])
|
|
722
737
|
redis.call('SET', KEYS[9], '1', 'PX', tonumber(ARGV[4]))
|
|
738
|
+
else
|
|
739
|
+
redis.call('LREM', KEYS[2], 1, ARGV[2])
|
|
723
740
|
end
|
|
724
741
|
|
|
725
742
|
return removed
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -23,6 +23,8 @@ class QueueKeyManager:
|
|
|
23
23
|
raise TypeError(f"'name' must be a string, got {type(queue_name).__name__}")
|
|
24
24
|
if not queue_name.strip():
|
|
25
25
|
raise ValueError("'name' must be a non-empty string")
|
|
26
|
+
if "\x00" in queue_name:
|
|
27
|
+
raise ValueError("queue name must not contain null bytes")
|
|
26
28
|
if not isinstance(key_separator, str):
|
|
27
29
|
raise TypeError(f"'key_separator' must be a string, got {type(key_separator).__name__}")
|
|
28
30
|
if not key_separator.strip():
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -88,7 +88,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
88
88
|
if isinstance(redis_client, redis.asyncio.Redis):
|
|
89
89
|
raise TypeError(
|
|
90
90
|
"'redis_client' is an async Redis client (redis.asyncio.Redis); "
|
|
91
|
-
"use the async
|
|
91
|
+
"use the async RedisMessageQueue from redis_message_queue.asyncio instead"
|
|
92
92
|
)
|
|
93
93
|
if isinstance(redis_client, (redis.client.Pipeline, redis.asyncio.client.Pipeline)):
|
|
94
94
|
raise TypeError(
|
|
@@ -380,9 +380,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
380
380
|
claimed_message = claim_message(from_queue, to_queue, claim_id)
|
|
381
381
|
except Exception as exc:
|
|
382
382
|
if not is_redis_retryable_exception(exc):
|
|
383
|
+
pending_claim_id_to_share = claim_id
|
|
383
384
|
raise
|
|
384
385
|
claim_may_need_recovery = True
|
|
385
|
-
logger.warning(non_blocking_retry_log, exc)
|
|
386
|
+
logger.warning(non_blocking_retry_log, type(exc).__name__)
|
|
386
387
|
if self._is_interrupted():
|
|
387
388
|
pending_claim_id_to_share = claim_id
|
|
388
389
|
return None
|
|
@@ -414,11 +415,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
414
415
|
claimed_message = claim_message(from_queue, to_queue, claim_id)
|
|
415
416
|
except Exception as exc:
|
|
416
417
|
if not is_redis_retryable_exception(exc):
|
|
417
|
-
|
|
418
|
-
pending_claim_id_to_share = claim_id
|
|
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
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/asyncio/__init__.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
2
2
|
from redis_message_queue.asyncio._abstract_redis_gateway import AbstractRedisGateway
|
|
3
|
+
from redis_message_queue.asyncio._redis_gateway import RedisGateway
|
|
3
4
|
from redis_message_queue.asyncio.redis_message_queue import RedisMessageQueue
|
|
4
5
|
|
|
5
|
-
__all__ = ["RedisMessageQueue", "AbstractRedisGateway", "ClaimedMessage", "MessageData"]
|
|
6
|
+
__all__ = ["RedisMessageQueue", "RedisGateway", "AbstractRedisGateway", "ClaimedMessage", "MessageData"]
|
|
@@ -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.
|
|
@@ -124,6 +128,10 @@ class AbstractRedisGateway(ABC):
|
|
|
124
128
|
use leases.
|
|
125
129
|
|
|
126
130
|
Return None if no message was available (e.g. timeout or interrupt).
|
|
131
|
+
|
|
132
|
+
Implementations MUST respect a reasonable timeout or return None
|
|
133
|
+
periodically so the consumer can check for interrupts. Blocking
|
|
134
|
+
indefinitely without returning prevents graceful shutdown.
|
|
127
135
|
"""
|
|
128
136
|
|
|
129
137
|
@abstractmethod
|
|
@@ -88,7 +88,7 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
88
88
|
if isinstance(redis_client, redis.Redis) and not isinstance(redis_client, redis.asyncio.Redis):
|
|
89
89
|
raise TypeError(
|
|
90
90
|
"'redis_client' is a sync Redis client (redis.Redis); "
|
|
91
|
-
"use the sync
|
|
91
|
+
"use the sync RedisMessageQueue from redis_message_queue instead"
|
|
92
92
|
)
|
|
93
93
|
if isinstance(redis_client, (redis.client.Pipeline, redis.asyncio.client.Pipeline)):
|
|
94
94
|
raise TypeError(
|
|
@@ -380,9 +380,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
380
380
|
claimed_message = await claim_message(from_queue, to_queue, claim_id)
|
|
381
381
|
except Exception as exc:
|
|
382
382
|
if not is_redis_retryable_exception(exc):
|
|
383
|
+
pending_claim_id_to_share = claim_id
|
|
383
384
|
raise
|
|
384
385
|
claim_may_need_recovery = True
|
|
385
|
-
logger.warning(non_blocking_retry_log, exc)
|
|
386
|
+
logger.warning(non_blocking_retry_log, type(exc).__name__)
|
|
386
387
|
if self._is_interrupted():
|
|
387
388
|
pending_claim_id_to_share = claim_id
|
|
388
389
|
return None
|
|
@@ -415,11 +416,10 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
415
416
|
claimed_message = await claim_message(from_queue, to_queue, claim_id)
|
|
416
417
|
except Exception as exc:
|
|
417
418
|
if not is_redis_retryable_exception(exc):
|
|
418
|
-
|
|
419
|
-
pending_claim_id_to_share = claim_id
|
|
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
|
|
@@ -327,7 +327,7 @@ class RedisMessageQueue:
|
|
|
327
327
|
max_failed_length: int | None = None,
|
|
328
328
|
max_delivery_count: int | None = None,
|
|
329
329
|
key_separator: str = "::",
|
|
330
|
-
get_deduplication_key: Optional[Callable] = None,
|
|
330
|
+
get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
|
|
331
331
|
interrupt: BaseGracefulInterruptHandler | None = None,
|
|
332
332
|
on_heartbeat_failure: Callable[[], Awaitable[None] | None] | None = None,
|
|
333
333
|
):
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -270,7 +270,7 @@ class RedisMessageQueue:
|
|
|
270
270
|
max_failed_length: int | None = None,
|
|
271
271
|
max_delivery_count: int | None = None,
|
|
272
272
|
key_separator: str = "::",
|
|
273
|
-
get_deduplication_key: Optional[Callable] = None,
|
|
273
|
+
get_deduplication_key: Optional[Callable[[str | dict], str]] = None,
|
|
274
274
|
interrupt: BaseGracefulInterruptHandler | None = None,
|
|
275
275
|
on_heartbeat_failure: Callable[[], None] | None = None,
|
|
276
276
|
):
|
|
@@ -314,7 +314,7 @@ class RedisMessageQueue:
|
|
|
314
314
|
)
|
|
315
315
|
if get_deduplication_key is not None and not callable(get_deduplication_key):
|
|
316
316
|
raise TypeError(f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}")
|
|
317
|
-
if get_deduplication_key is not None and
|
|
317
|
+
if get_deduplication_key is not None and is_async_callable(get_deduplication_key):
|
|
318
318
|
raise TypeError(
|
|
319
319
|
"'get_deduplication_key' is an async callable; "
|
|
320
320
|
"use the async RedisMessageQueue from redis_message_queue.asyncio instead"
|
|
@@ -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
|
|
|
File without changes
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
{redis_message_queue-3.1.0 → redis_message_queue-3.1.2}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|