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
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Publish-side flow control — backpressure, rate limiting, in-flight tracking.
|
|
2
|
+
|
|
3
|
+
``FlowController`` is NOT a middleware — it is used by transports/brokers
|
|
4
|
+
to throttle outgoing publishes when the system is under pressure.
|
|
5
|
+
|
|
6
|
+
Three pressure signals:
|
|
7
|
+
1. **connection.blocked** — RabbitMQ signals memory/disk alarm
|
|
8
|
+
2. **in-flight limit** — max concurrent unconfirmed publishes
|
|
9
|
+
3. **rate limit** — token-bucket limiter (messages per second)
|
|
10
|
+
|
|
11
|
+
Configurable behaviour when blocked:
|
|
12
|
+
- ``"wait"`` — block until unblocked / slot available / token available
|
|
13
|
+
- ``"raise"`` — raise ``BackpressureError`` immediately
|
|
14
|
+
- ``"drop"`` — return ``False`` (caller should discard the message)
|
|
15
|
+
|
|
16
|
+
The ``on_blocked`` string is translated ONCE in ``FlowController.__init__``
|
|
17
|
+
into a ``_BlockedPolicy`` strategy, eliminating the stringly-typed
|
|
18
|
+
``if self._config.on_blocked == ...`` dispatch that was sprinkled through
|
|
19
|
+
``acquire`` / ``acquire_async``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
from rabbitkit.core.config import BackpressureConfig
|
|
31
|
+
from rabbitkit.core.errors import BackpressureError
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Token bucket rate limiter ────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _TokenBucket:
|
|
40
|
+
"""Simple token-bucket rate limiter (sync, threading.Lock).
|
|
41
|
+
|
|
42
|
+
Refills ``rate`` tokens per second. ``acquire()`` consumes one token.
|
|
43
|
+
Thread-safe via ``threading.Lock``. Use in sync contexts only.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, rate: int, poll_interval: float = 0.01) -> None:
|
|
47
|
+
self._rate = rate
|
|
48
|
+
self._poll_interval = poll_interval
|
|
49
|
+
self._tokens = float(rate)
|
|
50
|
+
self._max_tokens = float(rate)
|
|
51
|
+
self._last_refill = time.monotonic()
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
def _refill(self) -> None:
|
|
55
|
+
now = time.monotonic()
|
|
56
|
+
elapsed = now - self._last_refill
|
|
57
|
+
self._tokens = min(self._max_tokens, self._tokens + elapsed * self._rate)
|
|
58
|
+
self._last_refill = now
|
|
59
|
+
|
|
60
|
+
def acquire(self) -> bool:
|
|
61
|
+
"""Try to consume one token. Returns True if available."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._refill()
|
|
64
|
+
if self._tokens >= 1.0:
|
|
65
|
+
self._tokens -= 1.0
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
70
|
+
"""Block until a token is available or timeout expires.
|
|
71
|
+
|
|
72
|
+
Returns True if token acquired, False on timeout.
|
|
73
|
+
"""
|
|
74
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
75
|
+
while True:
|
|
76
|
+
if self.acquire():
|
|
77
|
+
return True
|
|
78
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
79
|
+
return False
|
|
80
|
+
# Sleep a short interval before retry
|
|
81
|
+
time.sleep(self._poll_interval)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _AsyncTokenBucket:
|
|
85
|
+
"""Async-native token-bucket rate limiter.
|
|
86
|
+
|
|
87
|
+
Same algorithm as ``_TokenBucket`` but uses ``asyncio.Lock`` so it never
|
|
88
|
+
blocks the event loop. Instantiated lazily inside the event loop.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, rate: int) -> None:
|
|
92
|
+
self._rate = rate
|
|
93
|
+
self._tokens = float(rate)
|
|
94
|
+
self._max_tokens = float(rate)
|
|
95
|
+
self._last_refill = time.monotonic()
|
|
96
|
+
self._lock: asyncio.Lock | None = None # created lazily inside loop
|
|
97
|
+
|
|
98
|
+
def _ensure_lock(self) -> asyncio.Lock:
|
|
99
|
+
if self._lock is None:
|
|
100
|
+
self._lock = asyncio.Lock()
|
|
101
|
+
return self._lock
|
|
102
|
+
|
|
103
|
+
def _refill(self) -> None:
|
|
104
|
+
now = time.monotonic()
|
|
105
|
+
elapsed = now - self._last_refill
|
|
106
|
+
self._tokens = min(self._max_tokens, self._tokens + elapsed * self._rate)
|
|
107
|
+
self._last_refill = now
|
|
108
|
+
|
|
109
|
+
async def acquire(self) -> bool:
|
|
110
|
+
"""Try to consume one token (non-blocking). Returns True if available."""
|
|
111
|
+
async with self._ensure_lock():
|
|
112
|
+
self._refill()
|
|
113
|
+
if self._tokens >= 1.0:
|
|
114
|
+
self._tokens -= 1.0
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
async def wait(self, timeout: float | None = None) -> bool:
|
|
119
|
+
"""Async-wait until a token is available or timeout expires.
|
|
120
|
+
|
|
121
|
+
Calculates the exact time until the next token refills instead of
|
|
122
|
+
polling every 10 ms, reducing unnecessary wakeups at high rates.
|
|
123
|
+
"""
|
|
124
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
125
|
+
while True:
|
|
126
|
+
async with self._ensure_lock():
|
|
127
|
+
self._refill()
|
|
128
|
+
if self._tokens >= 1.0:
|
|
129
|
+
self._tokens -= 1.0
|
|
130
|
+
return True
|
|
131
|
+
# Calculate how long until one token is available
|
|
132
|
+
tokens_needed = 1.0 - self._tokens
|
|
133
|
+
sleep_for = tokens_needed / self._rate
|
|
134
|
+
if deadline is not None:
|
|
135
|
+
remaining = deadline - time.monotonic()
|
|
136
|
+
if remaining <= 0:
|
|
137
|
+
return False
|
|
138
|
+
sleep_for = min(sleep_for, remaining)
|
|
139
|
+
await asyncio.sleep(sleep_for)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Blocked-policy strategies ────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ``reason`` values passed to the policy strategies. These are *semantic*
|
|
146
|
+
# inputs (not stringly-typed dispatch): each names the pressure signal that
|
|
147
|
+
# fired so the wait policy knows which primitive to block on.
|
|
148
|
+
_REASON_BLOCKED = "blocked" # connection.blocked notification
|
|
149
|
+
_REASON_RATE = "rate" # rate-limiter token unavailable
|
|
150
|
+
_REASON_SLOT = "slot" # in-flight limit reached
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _BlockedPolicy(Protocol):
|
|
154
|
+
"""Strategy encapsulating the ``on_blocked`` behaviour.
|
|
155
|
+
|
|
156
|
+
``handle`` / ``handle_async`` perform the full action (raise, drop, or
|
|
157
|
+
block) and are called OUTSIDE the transport lock so the lock discipline
|
|
158
|
+
(C-5) is preserved. Returns ``True`` when the wait policy succeeds and
|
|
159
|
+
the caller may proceed; ``False`` when the message should be dropped
|
|
160
|
+
(drop policy, or wait-policy timeout); raises ``BackpressureError`` for
|
|
161
|
+
the raise policy.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool: ...
|
|
165
|
+
|
|
166
|
+
async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool: ...
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _WaitPolicy:
|
|
170
|
+
"""Block until the pressure clears. Returns False on timeout."""
|
|
171
|
+
|
|
172
|
+
def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
173
|
+
if reason == _REASON_BLOCKED:
|
|
174
|
+
return fc._unblock_event.wait(timeout=timeout)
|
|
175
|
+
if reason == _REASON_RATE:
|
|
176
|
+
rl = fc._rate_limiter
|
|
177
|
+
return rl is not None and rl.wait(timeout=timeout)
|
|
178
|
+
# _REASON_SLOT — the only remaining reason (reasons are internal and
|
|
179
|
+
# exhaustive; a former _REASON_RATE_RETRY "give up" reason was removed
|
|
180
|
+
# because dropping a waiter with deadline remaining violated the
|
|
181
|
+
# on_blocked="wait" contract).
|
|
182
|
+
return fc._slot_event.wait(timeout=timeout)
|
|
183
|
+
|
|
184
|
+
async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
185
|
+
if reason == _REASON_BLOCKED:
|
|
186
|
+
assert fc._async_unblock_event is not None
|
|
187
|
+
try:
|
|
188
|
+
async with asyncio.timeout(timeout):
|
|
189
|
+
await fc._async_unblock_event.wait()
|
|
190
|
+
return True
|
|
191
|
+
except TimeoutError:
|
|
192
|
+
return False
|
|
193
|
+
if reason == _REASON_RATE:
|
|
194
|
+
rl = fc._async_rate_limiter
|
|
195
|
+
if rl is None:
|
|
196
|
+
return False
|
|
197
|
+
return await rl.wait(timeout=timeout)
|
|
198
|
+
# _REASON_SLOT — see the sync counterpart above.
|
|
199
|
+
assert fc._async_slot_event is not None
|
|
200
|
+
try:
|
|
201
|
+
async with asyncio.timeout(timeout):
|
|
202
|
+
await fc._async_slot_event.wait()
|
|
203
|
+
return True
|
|
204
|
+
except TimeoutError:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _RaisePolicy:
|
|
209
|
+
"""Raise ``BackpressureError`` immediately for any pressure signal."""
|
|
210
|
+
|
|
211
|
+
def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
212
|
+
raise BackpressureError(_raise_message(reason, fc))
|
|
213
|
+
|
|
214
|
+
async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
215
|
+
raise BackpressureError(_raise_message(reason, fc))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _DropPolicy:
|
|
219
|
+
"""Return False immediately — the caller should discard the message."""
|
|
220
|
+
|
|
221
|
+
def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _raise_message(reason: str, fc: FlowController) -> str:
|
|
229
|
+
if reason == _REASON_BLOCKED:
|
|
230
|
+
return "Connection is blocked by RabbitMQ"
|
|
231
|
+
if reason == _REASON_SLOT:
|
|
232
|
+
return f"In-flight limit reached ({fc._config.max_in_flight})"
|
|
233
|
+
# _REASON_RATE
|
|
234
|
+
return "Rate limit exceeded"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── FlowController ──────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class FlowController:
|
|
241
|
+
"""Publish-side flow control.
|
|
242
|
+
|
|
243
|
+
Usage::
|
|
244
|
+
|
|
245
|
+
fc = FlowController(BackpressureConfig(max_in_flight=500, rate_limit=1000))
|
|
246
|
+
|
|
247
|
+
# Before publish:
|
|
248
|
+
if fc.acquire(timeout=5.0):
|
|
249
|
+
transport.publish(envelope)
|
|
250
|
+
fc.release()
|
|
251
|
+
else:
|
|
252
|
+
# message dropped or error raised (depends on on_blocked)
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
# Register with transport callbacks:
|
|
256
|
+
transport.on_blocked(fc.on_blocked)
|
|
257
|
+
transport.on_unblocked(fc.on_unblocked)
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, config: BackpressureConfig | None = None) -> None:
|
|
261
|
+
self._config = config or BackpressureConfig()
|
|
262
|
+
self._blocked = False
|
|
263
|
+
self._in_flight = 0
|
|
264
|
+
self._lock = threading.Lock()
|
|
265
|
+
self._unblock_event = threading.Event()
|
|
266
|
+
self._unblock_event.set() # start unblocked
|
|
267
|
+
self._slot_event = threading.Event()
|
|
268
|
+
self._slot_event.set() # start with slots available
|
|
269
|
+
|
|
270
|
+
# Async equivalents
|
|
271
|
+
self._async_lock: asyncio.Lock | None = None # lazily created
|
|
272
|
+
self._async_unblock_event: asyncio.Event | None = None # lazily created
|
|
273
|
+
self._async_slot_event: asyncio.Event | None = None # lazily created
|
|
274
|
+
|
|
275
|
+
# Rate limiter (optional)
|
|
276
|
+
# Sync path uses threading.Lock-based bucket; async path uses asyncio.Lock
|
|
277
|
+
# so acquiring tokens never blocks the event loop.
|
|
278
|
+
self._rate_limiter: _TokenBucket | None = None
|
|
279
|
+
self._async_rate_limiter: _AsyncTokenBucket | None = None
|
|
280
|
+
if self._config.rate_limit is not None:
|
|
281
|
+
poll = self._config.poll_interval_ms / 1000.0
|
|
282
|
+
self._rate_limiter = _TokenBucket(self._config.rate_limit, poll_interval=poll)
|
|
283
|
+
self._async_rate_limiter = _AsyncTokenBucket(self._config.rate_limit)
|
|
284
|
+
|
|
285
|
+
# Select the on_blocked strategy ONCE (public API stays a string for
|
|
286
|
+
# backward compat — the Strategy selection happens here).
|
|
287
|
+
_strategies: dict[str, type[_BlockedPolicy]] = {
|
|
288
|
+
"wait": _WaitPolicy,
|
|
289
|
+
"raise": _RaisePolicy,
|
|
290
|
+
"drop": _DropPolicy,
|
|
291
|
+
}
|
|
292
|
+
self._policy = _strategies[self._config.on_blocked]()
|
|
293
|
+
|
|
294
|
+
def _ensure_async_primitives(self) -> None:
|
|
295
|
+
"""Lazily create asyncio primitives (must be called in event loop)."""
|
|
296
|
+
if self._async_lock is None:
|
|
297
|
+
self._async_lock = asyncio.Lock()
|
|
298
|
+
if self._async_unblock_event is None:
|
|
299
|
+
self._async_unblock_event = asyncio.Event()
|
|
300
|
+
if not self._blocked:
|
|
301
|
+
self._async_unblock_event.set()
|
|
302
|
+
if self._async_slot_event is None:
|
|
303
|
+
self._async_slot_event = asyncio.Event()
|
|
304
|
+
self._async_slot_event.set()
|
|
305
|
+
|
|
306
|
+
# ── Connection blocked/unblocked callbacks ───────────────────────────
|
|
307
|
+
|
|
308
|
+
def on_blocked(self) -> None:
|
|
309
|
+
"""Called when RabbitMQ signals connection.blocked."""
|
|
310
|
+
self._blocked = True
|
|
311
|
+
self._unblock_event.clear()
|
|
312
|
+
if self._async_unblock_event is not None:
|
|
313
|
+
self._async_unblock_event.clear()
|
|
314
|
+
logger.warning("Connection blocked — backpressure active")
|
|
315
|
+
|
|
316
|
+
def on_unblocked(self) -> None:
|
|
317
|
+
"""Called when RabbitMQ signals connection.unblocked."""
|
|
318
|
+
self._blocked = False
|
|
319
|
+
self._unblock_event.set()
|
|
320
|
+
if self._async_unblock_event is not None:
|
|
321
|
+
self._async_unblock_event.set()
|
|
322
|
+
logger.info("Connection unblocked — backpressure released")
|
|
323
|
+
|
|
324
|
+
# ── Properties ───────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def is_blocked(self) -> bool:
|
|
328
|
+
"""True if connection is currently blocked by RabbitMQ."""
|
|
329
|
+
return self._blocked
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def in_flight(self) -> int:
|
|
333
|
+
"""Current number of in-flight (unconfirmed) publishes."""
|
|
334
|
+
return self._in_flight
|
|
335
|
+
|
|
336
|
+
# ── Sync acquire / release ───────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
def acquire(self, timeout: float | None = None) -> bool:
|
|
339
|
+
"""Acquire a publish slot.
|
|
340
|
+
|
|
341
|
+
Checks (in order): not blocked, in-flight < max, rate OK.
|
|
342
|
+
|
|
343
|
+
Returns True if slot acquired, False if dropped.
|
|
344
|
+
Raises BackpressureError if ``on_blocked == "raise"`` and blocked.
|
|
345
|
+
|
|
346
|
+
With ``on_blocked="wait"`` a race loss (the slot we waited for was
|
|
347
|
+
taken by another contender) re-loops instead of silently dropping —
|
|
348
|
+
mirroring the async path (I-9). The loop is bounded by the deadline
|
|
349
|
+
derived from *timeout* / ``blocked_timeout``.
|
|
350
|
+
"""
|
|
351
|
+
effective_timeout = timeout if timeout is not None else self._config.blocked_timeout
|
|
352
|
+
|
|
353
|
+
# 1. Check blocked state
|
|
354
|
+
if self._blocked:
|
|
355
|
+
if not self._policy.handle(self, _REASON_BLOCKED, effective_timeout):
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
# 2. Check in-flight limit + rate limiter. The rate-limiter wait (which
|
|
359
|
+
# sleeps) must happen OUTSIDE self._lock (C-5): under the lock we only do
|
|
360
|
+
# non-blocking checks, then release the lock before any blocking wait.
|
|
361
|
+
# On a race loss (the slot/token we waited for was taken by another
|
|
362
|
+
# contender) we re-loop instead of dropping (I-9), bounded by deadline.
|
|
363
|
+
deadline = None if effective_timeout is None else time.monotonic() + effective_timeout
|
|
364
|
+
|
|
365
|
+
def _remaining() -> float | None:
|
|
366
|
+
return None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
367
|
+
|
|
368
|
+
while True:
|
|
369
|
+
rate_needed = False
|
|
370
|
+
at_limit = False
|
|
371
|
+
with self._lock:
|
|
372
|
+
if self._in_flight < self._config.max_in_flight:
|
|
373
|
+
if self._rate_limiter is not None and not self._rate_limiter.acquire():
|
|
374
|
+
# No token right now; the (possibly blocking) wait happens
|
|
375
|
+
# outside the lock below. Fall through to the rate policy.
|
|
376
|
+
rate_needed = True
|
|
377
|
+
else:
|
|
378
|
+
self._in_flight += 1
|
|
379
|
+
if self._in_flight >= self._config.max_in_flight:
|
|
380
|
+
self._slot_event.clear()
|
|
381
|
+
return True
|
|
382
|
+
else:
|
|
383
|
+
at_limit = True
|
|
384
|
+
self._slot_event.clear()
|
|
385
|
+
|
|
386
|
+
if rate_needed:
|
|
387
|
+
# Policy dispatch outside the lock (C-5): wait/raise/drop.
|
|
388
|
+
if not self._policy.handle(self, _REASON_RATE, _remaining()):
|
|
389
|
+
return False
|
|
390
|
+
# wait() consumed a token atomically; now claim the in-flight slot.
|
|
391
|
+
with self._lock:
|
|
392
|
+
if self._in_flight < self._config.max_in_flight:
|
|
393
|
+
self._in_flight += 1
|
|
394
|
+
if self._in_flight >= self._config.max_in_flight:
|
|
395
|
+
self._slot_event.clear()
|
|
396
|
+
return True
|
|
397
|
+
# The slot was taken while we waited for a token; wait for a slot
|
|
398
|
+
# then re-loop and re-claim under the lock (I-9: was a silent drop).
|
|
399
|
+
if not self._slot_event.wait(timeout=_remaining()):
|
|
400
|
+
return False
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
if at_limit:
|
|
404
|
+
# Policy dispatch outside the lock (C-5): wait/raise/drop.
|
|
405
|
+
if not self._policy.handle(self, _REASON_SLOT, _remaining()):
|
|
406
|
+
return False
|
|
407
|
+
# Re-loop and re-claim under the lock. If we lose the race again
|
|
408
|
+
# (still at limit), the loop clears the event and re-waits — this
|
|
409
|
+
# is the I-9 fix (previously we returned False on a single loss).
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
def release(self) -> None:
|
|
413
|
+
"""Release a publish slot after confirm/nack/timeout."""
|
|
414
|
+
with self._lock:
|
|
415
|
+
if self._in_flight > 0:
|
|
416
|
+
self._in_flight -= 1
|
|
417
|
+
self._slot_event.set()
|
|
418
|
+
|
|
419
|
+
# ── Async acquire / release ──────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
async def acquire_async(self, timeout: float | None = None) -> bool:
|
|
422
|
+
"""Async variant of ``acquire``."""
|
|
423
|
+
self._ensure_async_primitives()
|
|
424
|
+
assert self._async_lock is not None
|
|
425
|
+
assert self._async_unblock_event is not None
|
|
426
|
+
effective_timeout = timeout if timeout is not None else self._config.blocked_timeout
|
|
427
|
+
|
|
428
|
+
# 1. Check blocked state
|
|
429
|
+
if self._blocked:
|
|
430
|
+
if not await self._policy.handle_async(self, _REASON_BLOCKED, effective_timeout):
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
# 2. Check in-flight limit + rate. Rate-token waits and in-flight slot
|
|
434
|
+
# waits happen OUTSIDE the lock; on a race loss we re-wait (loop) instead
|
|
435
|
+
# of dropping (H-P4). With on_blocked="wait" a missing rate token now
|
|
436
|
+
# actually waits, mirroring the sync semantics.
|
|
437
|
+
deadline = None if effective_timeout is None else time.monotonic() + effective_timeout
|
|
438
|
+
|
|
439
|
+
def _remaining() -> float | None:
|
|
440
|
+
return None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
441
|
+
|
|
442
|
+
while True:
|
|
443
|
+
rate_needed = False
|
|
444
|
+
at_limit = False
|
|
445
|
+
async with self._async_lock:
|
|
446
|
+
if self._in_flight < self._config.max_in_flight:
|
|
447
|
+
if self._async_rate_limiter is not None and not await self._async_rate_limiter.acquire():
|
|
448
|
+
# No token right now; the (possibly blocking) wait happens
|
|
449
|
+
# outside the lock below. Fall through to the rate policy.
|
|
450
|
+
rate_needed = True
|
|
451
|
+
else:
|
|
452
|
+
self._in_flight += 1
|
|
453
|
+
if self._in_flight >= self._config.max_in_flight:
|
|
454
|
+
assert self._async_slot_event is not None
|
|
455
|
+
self._async_slot_event.clear()
|
|
456
|
+
return True
|
|
457
|
+
else:
|
|
458
|
+
at_limit = True
|
|
459
|
+
assert self._async_slot_event is not None
|
|
460
|
+
self._async_slot_event.clear()
|
|
461
|
+
|
|
462
|
+
if rate_needed:
|
|
463
|
+
# Policy dispatch outside the lock (C-5): wait/raise/drop.
|
|
464
|
+
if not await self._policy.handle_async(self, _REASON_RATE, _remaining()):
|
|
465
|
+
return False
|
|
466
|
+
# Token consumed by wait(); claim the slot without another acquire.
|
|
467
|
+
async with self._async_lock:
|
|
468
|
+
if self._in_flight < self._config.max_in_flight:
|
|
469
|
+
self._in_flight += 1
|
|
470
|
+
if self._in_flight >= self._config.max_in_flight:
|
|
471
|
+
assert self._async_slot_event is not None
|
|
472
|
+
self._async_slot_event.clear()
|
|
473
|
+
return True
|
|
474
|
+
# The slot was taken while we waited for a token; wait for a slot
|
|
475
|
+
# then RE-LOOP and re-claim under the lock, exactly like the sync
|
|
476
|
+
# path (I-9/perf-M-1). The re-loop re-checks the rate bucket
|
|
477
|
+
# non-blockingly and, if it's empty again (another contender took
|
|
478
|
+
# the refill), re-enters the bounded _REASON_RATE wait — it must
|
|
479
|
+
# NOT drop. A previous version demanded a second token HERE, under
|
|
480
|
+
# the lock, and dropped (_REASON_RATE_RETRY) when the bucket was
|
|
481
|
+
# momentarily empty — silently failing a waiter with almost its
|
|
482
|
+
# whole deadline remaining, the exact single-race-loss drop this
|
|
483
|
+
# loop exists to prevent (caught by the contender stress test
|
|
484
|
+
# failing ~1% of runs under CPU load, in ~70ms of a 10s budget).
|
|
485
|
+
assert self._async_slot_event is not None
|
|
486
|
+
_rem = _remaining()
|
|
487
|
+
if _rem is not None and _rem <= 0:
|
|
488
|
+
return False
|
|
489
|
+
try:
|
|
490
|
+
async with asyncio.timeout(_rem):
|
|
491
|
+
await self._async_slot_event.wait()
|
|
492
|
+
except TimeoutError:
|
|
493
|
+
return False
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
if at_limit:
|
|
497
|
+
# Policy dispatch outside the lock (C-5): wait/raise/drop.
|
|
498
|
+
assert self._async_slot_event is not None
|
|
499
|
+
if not await self._policy.handle_async(self, _REASON_SLOT, _remaining()):
|
|
500
|
+
return False
|
|
501
|
+
# Re-loop and re-claim under the lock. If we lose the race again
|
|
502
|
+
# (still at limit), the loop clears the event and re-waits - this
|
|
503
|
+
# is the H-P4 fix (previously we returned False on a single loss).
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
async def release_async(self) -> None:
|
|
507
|
+
"""Async variant of ``release``."""
|
|
508
|
+
self._ensure_async_primitives()
|
|
509
|
+
assert self._async_lock is not None
|
|
510
|
+
assert self._async_slot_event is not None
|
|
511
|
+
async with self._async_lock:
|
|
512
|
+
if self._in_flight > 0:
|
|
513
|
+
self._in_flight -= 1
|
|
514
|
+
self._async_slot_event.set()
|