rabbitkit 0.9.0__py3-none-any.whl
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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
rabbitkit/locking.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Distributed locking — protocol + Redis implementation + middleware.
|
|
2
|
+
|
|
3
|
+
Ensures that only **one consumer across the entire cluster** processes a
|
|
4
|
+
message for a given key at a time. Useful for preventing duplicate processing
|
|
5
|
+
when multiple instances consume the same queue.
|
|
6
|
+
|
|
7
|
+
Architecture
|
|
8
|
+
------------
|
|
9
|
+
``DistributedLock`` — ``@runtime_checkable`` protocol any lock implementation
|
|
10
|
+
must satisfy. Provides both sync (``acquire`` / ``release``) and async
|
|
11
|
+
(``acquire_async`` / ``release_async``) variants.
|
|
12
|
+
|
|
13
|
+
``RedisLock`` — reference implementation using Redis ``SET NX EX``:
|
|
14
|
+
* Generates a per-acquisition UUID as the lock value.
|
|
15
|
+
* ``release()`` fetches the stored value first and only deletes if it still
|
|
16
|
+
matches, preventing another holder's lock from being deleted by a stale
|
|
17
|
+
release call.
|
|
18
|
+
|
|
19
|
+
``LockMiddleware`` — ``BaseMiddleware`` that acquires a lock before invoking
|
|
20
|
+
the handler and releases it in a ``finally`` block.
|
|
21
|
+
|
|
22
|
+
Quick start
|
|
23
|
+
-----------
|
|
24
|
+
import redis
|
|
25
|
+
from rabbitkit.locking import RedisLock, LockMiddleware
|
|
26
|
+
|
|
27
|
+
r = redis.Redis(host="redis", decode_responses=False)
|
|
28
|
+
lock = RedisLock(r, prefix="myapp:lock:", ttl=30)
|
|
29
|
+
lock_mw = LockMiddleware(lock, timeout=5.0)
|
|
30
|
+
|
|
31
|
+
@broker.subscriber(queue="orders", middlewares=[lock_mw])
|
|
32
|
+
async def handle_order(body: bytes) -> None:
|
|
33
|
+
# Guaranteed: only one instance handles the same routing_key at once
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
Custom lock key
|
|
37
|
+
---------------
|
|
38
|
+
By default the lock key is ``message.routing_key``. Supply ``key_fn`` for
|
|
39
|
+
finer-grained control:
|
|
40
|
+
|
|
41
|
+
# Lock per order ID extracted from body (JSON)
|
|
42
|
+
import json
|
|
43
|
+
|
|
44
|
+
lock_mw = LockMiddleware(
|
|
45
|
+
lock,
|
|
46
|
+
key_fn=lambda msg: json.loads(msg.body)["order_id"],
|
|
47
|
+
timeout=10.0,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
When the lock cannot be acquired
|
|
51
|
+
---------------------------------
|
|
52
|
+
The message is nacked with ``requeue=True`` so another consumer or a later
|
|
53
|
+
retry attempt can process it. With a retry topology this becomes a natural
|
|
54
|
+
wait-and-retry loop without busy-polling.
|
|
55
|
+
|
|
56
|
+
Bring your own lock
|
|
57
|
+
-------------------
|
|
58
|
+
Any object satisfying the ``DistributedLock`` protocol works:
|
|
59
|
+
|
|
60
|
+
class ZooKeeperLock:
|
|
61
|
+
def acquire(self, key: str, timeout: float = 10.0) -> bool: ...
|
|
62
|
+
def release(self, key: str) -> None: ...
|
|
63
|
+
async def acquire_async(self, key: str, timeout: float = 10.0) -> bool: ...
|
|
64
|
+
async def release_async(self, key: str) -> None: ...
|
|
65
|
+
|
|
66
|
+
lock_mw = LockMiddleware(ZooKeeperLock(...))
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import asyncio
|
|
72
|
+
import logging
|
|
73
|
+
import threading
|
|
74
|
+
import time
|
|
75
|
+
import uuid
|
|
76
|
+
from typing import Any, Protocol, runtime_checkable
|
|
77
|
+
|
|
78
|
+
from rabbitkit.core.message import RabbitMessage
|
|
79
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
_POLL_INTERVAL = 0.05 # seconds between lock-acquire retries when waiting
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@runtime_checkable
|
|
87
|
+
class DistributedLock(Protocol):
|
|
88
|
+
"""Protocol for distributed lock implementations."""
|
|
89
|
+
|
|
90
|
+
def acquire(self, key: str, timeout: float = 10.0) -> bool: ...
|
|
91
|
+
def release(self, key: str) -> None: ...
|
|
92
|
+
async def acquire_async(self, key: str, timeout: float = 10.0) -> bool: ...
|
|
93
|
+
async def release_async(self, key: str) -> None: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RedisLock:
|
|
97
|
+
"""Redis-based distributed lock using SET NX EX.
|
|
98
|
+
|
|
99
|
+
Release uses an atomic Lua compare-and-delete so a stale holder can never
|
|
100
|
+
delete another owner's lock. The per-acquisition UUID is also exposed as a
|
|
101
|
+
*fencing token* via :attr:`fencing_token` for use in downstream writes that
|
|
102
|
+
need to guard against reordered operations.
|
|
103
|
+
|
|
104
|
+
L3 — ``ttl`` has no auto-renewal: if a handler runs longer than ``ttl``,
|
|
105
|
+
the lock expires while the handler is still working, and a second
|
|
106
|
+
consumer can acquire the same key and start processing concurrently —
|
|
107
|
+
the exact condition this lock exists to prevent. There is no watchdog
|
|
108
|
+
here that periodically extends the TTL. Set ``ttl`` comfortably above
|
|
109
|
+
your worst-case handler time (including retries/timeouts on the handler
|
|
110
|
+
side), and for any downstream write that must not be applied twice even
|
|
111
|
+
under a lost lock, use :meth:`fencing_token` — pass it along with the
|
|
112
|
+
write and have the downstream store reject a token older than the one it
|
|
113
|
+
already recorded, so a "lock expired, second holder also wrote" race is
|
|
114
|
+
caught at the write itself rather than relying on the lock alone.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Atomic compare-and-delete: only deletes the key if the stored value
|
|
118
|
+
# matches the caller's lock value. Returns 1 on delete, 0 otherwise.
|
|
119
|
+
_RELEASE_SCRIPT = (
|
|
120
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
|
|
121
|
+
)
|
|
122
|
+
_RELEASE_SCRIPT_ASYNC = _RELEASE_SCRIPT
|
|
123
|
+
|
|
124
|
+
def __init__(self, redis_client: Any, prefix: str = "rabbitkit:lock:", ttl: int = 30) -> None:
|
|
125
|
+
self._redis = redis_client
|
|
126
|
+
self._prefix = prefix
|
|
127
|
+
self._ttl = ttl
|
|
128
|
+
self._lock_values: dict[str, str] = {}
|
|
129
|
+
self._guard = threading.Lock() # protects _lock_values across threads
|
|
130
|
+
# SHA1 digest of the loaded release script (cached after first eval).
|
|
131
|
+
self._release_sha: str | None = None
|
|
132
|
+
|
|
133
|
+
def _key(self, key: str) -> str:
|
|
134
|
+
return f"{self._prefix}{key}"
|
|
135
|
+
|
|
136
|
+
def fencing_token(self, key: str) -> str | None:
|
|
137
|
+
"""Return the lock value (UUID) for *key* as a fencing token.
|
|
138
|
+
|
|
139
|
+
``None`` if this lock does not currently hold *key*.
|
|
140
|
+
"""
|
|
141
|
+
with self._guard:
|
|
142
|
+
return self._lock_values.get(key)
|
|
143
|
+
|
|
144
|
+
def acquire(self, key: str, timeout: float = 10.0) -> bool:
|
|
145
|
+
"""Acquire the lock. Waits up to ``timeout`` seconds (polling); ``timeout
|
|
146
|
+
<= 0`` makes a single non-blocking attempt."""
|
|
147
|
+
lock_value = uuid.uuid4().hex
|
|
148
|
+
redis_key = self._key(key)
|
|
149
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
150
|
+
while True:
|
|
151
|
+
if self._redis.set(redis_key, lock_value, nx=True, ex=self._ttl):
|
|
152
|
+
with self._guard:
|
|
153
|
+
self._lock_values[key] = lock_value
|
|
154
|
+
return True
|
|
155
|
+
if timeout <= 0 or time.monotonic() >= deadline:
|
|
156
|
+
return False
|
|
157
|
+
time.sleep(_POLL_INTERVAL)
|
|
158
|
+
|
|
159
|
+
def release(self, key: str) -> None:
|
|
160
|
+
with self._guard:
|
|
161
|
+
lock_value = self._lock_values.get(key)
|
|
162
|
+
if lock_value is None:
|
|
163
|
+
return
|
|
164
|
+
deleted = self._eval_release(self._key(key), lock_value)
|
|
165
|
+
# L-5: only drop local tracking after a successful delete. On a transport
|
|
166
|
+
# error the value stays tracked so the TTL (or a later retry) cleans up,
|
|
167
|
+
# rather than silently stranding the lock and losing the fencing token.
|
|
168
|
+
if deleted:
|
|
169
|
+
with self._guard:
|
|
170
|
+
# Re-check the value hasn't been replaced by a re-acquire in the
|
|
171
|
+
# meantime before popping.
|
|
172
|
+
if self._lock_values.get(key) == lock_value:
|
|
173
|
+
self._lock_values.pop(key, None)
|
|
174
|
+
|
|
175
|
+
def _eval_release(self, redis_key: str, lock_value: str) -> bool:
|
|
176
|
+
"""Atomically delete the lock only if its stored value matches.
|
|
177
|
+
|
|
178
|
+
Returns True when the lock was deleted, False when the script returned 0
|
|
179
|
+
(stale/foreign lock) or the client lacks ``eval``. A real transport error
|
|
180
|
+
is logged and reported as "not deleted" so the caller keeps the local
|
|
181
|
+
tracking intact (L-5) rather than silently swallowing it.
|
|
182
|
+
"""
|
|
183
|
+
# Prefer EVALSHA with the cached SHA, falling back to EVAL. Many test
|
|
184
|
+
# doubles only implement `eval`, so keep `eval` as the primary path and
|
|
185
|
+
# treat any AttributeError / failure as "use eval".
|
|
186
|
+
try:
|
|
187
|
+
result = self._redis.eval(self._RELEASE_SCRIPT, 1, redis_key, lock_value)
|
|
188
|
+
except AttributeError:
|
|
189
|
+
# No eval support at all — nothing we can do safely.
|
|
190
|
+
return False
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
# Some clients raise when the script returns 0 (no delete); that's
|
|
193
|
+
# expected for a stale/foreign lock. A real transport error, however,
|
|
194
|
+
# must not be silently swallowed — log it so operators notice, and
|
|
195
|
+
# signal "not deleted" so the local tracking is preserved (L-5).
|
|
196
|
+
logger.warning("Redis EVAL failed during lock release: %s", exc)
|
|
197
|
+
return False
|
|
198
|
+
return bool(result)
|
|
199
|
+
|
|
200
|
+
async def acquire_async(self, key: str, timeout: float = 10.0) -> bool:
|
|
201
|
+
"""Async variant of :meth:`acquire` (polls with ``asyncio.sleep``)."""
|
|
202
|
+
lock_value = uuid.uuid4().hex
|
|
203
|
+
redis_key = self._key(key)
|
|
204
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
205
|
+
while True:
|
|
206
|
+
if await self._redis.set(redis_key, lock_value, nx=True, ex=self._ttl):
|
|
207
|
+
with self._guard:
|
|
208
|
+
self._lock_values[key] = lock_value
|
|
209
|
+
return True
|
|
210
|
+
if timeout <= 0 or time.monotonic() >= deadline:
|
|
211
|
+
return False
|
|
212
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
213
|
+
|
|
214
|
+
async def release_async(self, key: str) -> None:
|
|
215
|
+
with self._guard:
|
|
216
|
+
lock_value = self._lock_values.get(key)
|
|
217
|
+
if lock_value is None:
|
|
218
|
+
return
|
|
219
|
+
deleted = await self._eval_release_async(self._key(key), lock_value)
|
|
220
|
+
# L-5: only drop local tracking after a successful delete.
|
|
221
|
+
if deleted:
|
|
222
|
+
with self._guard:
|
|
223
|
+
if self._lock_values.get(key) == lock_value:
|
|
224
|
+
self._lock_values.pop(key, None)
|
|
225
|
+
|
|
226
|
+
async def _eval_release_async(self, redis_key: str, lock_value: str) -> bool:
|
|
227
|
+
"""Async variant of :meth:`_eval_release`."""
|
|
228
|
+
try:
|
|
229
|
+
result = await self._redis.eval(self._RELEASE_SCRIPT_ASYNC, 1, redis_key, lock_value)
|
|
230
|
+
except AttributeError:
|
|
231
|
+
return False
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
logger.warning("Redis EVAL failed during async lock release: %s", exc)
|
|
234
|
+
return False
|
|
235
|
+
return bool(result)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class LockMiddleware(BaseMiddleware):
|
|
239
|
+
"""Acquire a lock before processing, release after.
|
|
240
|
+
|
|
241
|
+
If lock cannot be acquired, nacks message with requeue=True.
|
|
242
|
+
Default key_fn uses routing_key.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
lock: DistributedLock,
|
|
248
|
+
key_fn: Any | None = None,
|
|
249
|
+
timeout: float = 0.0,
|
|
250
|
+
) -> None:
|
|
251
|
+
# Default 0.0 = non-blocking: on contention, nack(requeue=True) immediately
|
|
252
|
+
# rather than blocking the consumer. Set timeout > 0 to wait for the lock.
|
|
253
|
+
self._lock = lock
|
|
254
|
+
self._key_fn = key_fn or (lambda m: m.routing_key)
|
|
255
|
+
self._timeout = timeout
|
|
256
|
+
|
|
257
|
+
def consume_scope(self, call_next: Any, message: RabbitMessage) -> Any:
|
|
258
|
+
key = self._key_fn(message)
|
|
259
|
+
if not self._lock.acquire(key, self._timeout):
|
|
260
|
+
if not message.is_settled:
|
|
261
|
+
message.nack(requeue=True)
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
return call_next(message)
|
|
265
|
+
finally:
|
|
266
|
+
self._lock.release(key)
|
|
267
|
+
|
|
268
|
+
async def consume_scope_async(self, call_next: Any, message: RabbitMessage) -> Any:
|
|
269
|
+
key = self._key_fn(message)
|
|
270
|
+
if not await self._lock.acquire_async(key, self._timeout):
|
|
271
|
+
if not message.is_settled:
|
|
272
|
+
await message.nack_async(requeue=True)
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
return await call_next(message)
|
|
276
|
+
finally:
|
|
277
|
+
await self._lock.release_async(key)
|