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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. 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)