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.
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/PKG-INFO +18 -6
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/README.md +7 -5
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/pyproject.toml +17 -1
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_abstract_redis_gateway.py +12 -4
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_config.py +2 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_gateway.py +3 -7
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +12 -4
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/_redis_gateway.py +3 -7
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/redis_message_queue.py +18 -15
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/_implementation.py +17 -4
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/redis_message_queue.py +20 -16
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/LICENSE +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/__init__.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_queue_key_manager.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_stored_message.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/__init__.py +0 -0
- {redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {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.
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
20
30
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
21
31
|
[](LICENSE)
|
|
22
32
|
[](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
|
|
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
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](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
|
|
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.
|
|
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)
|
|
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
|
|
19
|
-
|
|
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:
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -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)
|
|
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
|
|
20
|
-
|
|
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
|
|
124
|
+
if heartbeat_interval_seconds >= visibility_timeout_seconds / 2:
|
|
114
125
|
raise ValueError(
|
|
115
|
-
"'heartbeat_interval_seconds' must be
|
|
116
|
-
f"({heartbeat_interval_seconds}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -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
|
|
59
|
+
if heartbeat_interval_seconds >= visibility_timeout_seconds / 2:
|
|
49
60
|
raise ValueError(
|
|
50
|
-
"'heartbeat_interval_seconds' must be
|
|
51
|
-
f"({heartbeat_interval_seconds}
|
|
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()
|
|
File without changes
|
|
File without changes
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_queue_key_manager.py
RENAMED
|
File without changes
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/_stored_message.py
RENAMED
|
File without changes
|
{redis_message_queue-3.0.0 → redis_message_queue-3.1.1}/redis_message_queue/asyncio/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|