fastapi-loopguard 0.3.1__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.
- fastapi_loopguard/__init__.py +62 -0
- fastapi_loopguard/config.py +88 -0
- fastapi_loopguard/context.py +210 -0
- fastapi_loopguard/logging.py +100 -0
- fastapi_loopguard/metrics.py +204 -0
- fastapi_loopguard/middleware.py +236 -0
- fastapi_loopguard/monitor.py +412 -0
- fastapi_loopguard/py.typed +0 -0
- fastapi_loopguard/pytest_plugin.py +135 -0
- fastapi_loopguard-0.3.1.dist-info/METADATA +154 -0
- fastapi_loopguard-0.3.1.dist-info/RECORD +14 -0
- fastapi_loopguard-0.3.1.dist-info/WHEEL +4 -0
- fastapi_loopguard-0.3.1.dist-info/entry_points.txt +2 -0
- fastapi_loopguard-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""LoopGuard middleware for FastAPI/Starlette.
|
|
2
|
+
|
|
3
|
+
This is a pure ASGI middleware implementation that avoids the issues
|
|
4
|
+
with BaseHTTPMiddleware (deprecated, breaks contextvars, memory leaks).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
13
|
+
|
|
14
|
+
from .context import RequestContext, register_request, unregister_request
|
|
15
|
+
from .monitor import SentinelMonitor
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .config import LoopGuardConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoopGuardMiddleware:
|
|
22
|
+
"""Pure ASGI middleware that detects event loop blocking per-request.
|
|
23
|
+
|
|
24
|
+
This middleware:
|
|
25
|
+
1. Handles ASGI lifespan for proper startup/shutdown
|
|
26
|
+
2. Registers request contexts for attribution
|
|
27
|
+
3. Manages the sentinel monitor lifecycle
|
|
28
|
+
4. Adds debug headers in dev mode via send wrapper
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from fastapi_loopguard import LoopGuardMiddleware, LoopGuardConfig
|
|
33
|
+
|
|
34
|
+
app = FastAPI()
|
|
35
|
+
config = LoopGuardConfig(dev_mode=True)
|
|
36
|
+
app.add_middleware(LoopGuardMiddleware, config=config)
|
|
37
|
+
|
|
38
|
+
Improvements in v0.2.0:
|
|
39
|
+
- Pure ASGI implementation (no BaseHTTPMiddleware)
|
|
40
|
+
- Proper lifespan handling for monitor lifecycle
|
|
41
|
+
- Background calibration (first request not blocked)
|
|
42
|
+
- Send wrapper for header injection
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("app", "_config", "_monitor", "_started")
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
app: ASGIApp,
|
|
50
|
+
config: LoopGuardConfig | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Initialize the middleware.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
app: The ASGI application to wrap.
|
|
56
|
+
config: Optional configuration. Uses defaults if not provided.
|
|
57
|
+
"""
|
|
58
|
+
self.app = app
|
|
59
|
+
|
|
60
|
+
# Import here to avoid circular imports
|
|
61
|
+
from .config import LoopGuardConfig as ConfigClass
|
|
62
|
+
|
|
63
|
+
self._config = config or ConfigClass()
|
|
64
|
+
self._monitor: SentinelMonitor | None = None
|
|
65
|
+
self._started = False
|
|
66
|
+
|
|
67
|
+
async def __call__(
|
|
68
|
+
self,
|
|
69
|
+
scope: Scope,
|
|
70
|
+
receive: Receive,
|
|
71
|
+
send: Send,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""ASGI interface implementation.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
scope: The connection scope.
|
|
77
|
+
receive: Async callable to receive messages.
|
|
78
|
+
send: Async callable to send messages.
|
|
79
|
+
"""
|
|
80
|
+
if scope["type"] == "lifespan":
|
|
81
|
+
await self._handle_lifespan(scope, receive, send)
|
|
82
|
+
elif scope["type"] == "http":
|
|
83
|
+
await self._handle_http(scope, receive, send)
|
|
84
|
+
else:
|
|
85
|
+
# WebSocket or other types - pass through
|
|
86
|
+
await self.app(scope, receive, send)
|
|
87
|
+
|
|
88
|
+
async def _handle_lifespan(
|
|
89
|
+
self,
|
|
90
|
+
scope: Scope,
|
|
91
|
+
receive: Receive,
|
|
92
|
+
send: Send,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Handle lifespan events for proper startup/shutdown.
|
|
95
|
+
|
|
96
|
+
Intercepts lifespan messages to start/stop the monitor.
|
|
97
|
+
"""
|
|
98
|
+
started = False
|
|
99
|
+
shutdown_complete = False
|
|
100
|
+
|
|
101
|
+
async def receive_wrapper() -> Message:
|
|
102
|
+
nonlocal started
|
|
103
|
+
message = await receive()
|
|
104
|
+
|
|
105
|
+
if message["type"] == "lifespan.startup":
|
|
106
|
+
# Start monitor before signaling startup complete
|
|
107
|
+
if self._config.enabled and not self._started:
|
|
108
|
+
await self._start_monitor()
|
|
109
|
+
started = True
|
|
110
|
+
|
|
111
|
+
return message
|
|
112
|
+
|
|
113
|
+
async def send_wrapper(message: Message) -> None:
|
|
114
|
+
nonlocal shutdown_complete
|
|
115
|
+
|
|
116
|
+
if message["type"] == "lifespan.shutdown.complete":
|
|
117
|
+
# Stop monitor after app signals shutdown complete
|
|
118
|
+
if self._monitor:
|
|
119
|
+
await self._monitor.stop()
|
|
120
|
+
self._monitor = None
|
|
121
|
+
self._started = False
|
|
122
|
+
shutdown_complete = True
|
|
123
|
+
|
|
124
|
+
await send(message)
|
|
125
|
+
|
|
126
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
127
|
+
|
|
128
|
+
async def _start_monitor(self) -> None:
|
|
129
|
+
"""Start the sentinel monitor with background calibration."""
|
|
130
|
+
if self._started:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
self._monitor = SentinelMonitor(self._config)
|
|
134
|
+
# Use background calibration so first request isn't blocked
|
|
135
|
+
await self._monitor.start_with_background_calibration()
|
|
136
|
+
self._started = True
|
|
137
|
+
|
|
138
|
+
async def _handle_http(
|
|
139
|
+
self,
|
|
140
|
+
scope: Scope,
|
|
141
|
+
receive: Receive,
|
|
142
|
+
send: Send,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Handle HTTP requests with context tracking.
|
|
145
|
+
|
|
146
|
+
Registers request context, calls app, adds debug headers.
|
|
147
|
+
"""
|
|
148
|
+
path = scope.get("path", "")
|
|
149
|
+
|
|
150
|
+
# Skip monitoring for excluded paths
|
|
151
|
+
if path in self._config.exclude_paths:
|
|
152
|
+
await self.app(scope, receive, send)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Skip if disabled
|
|
156
|
+
if not self._config.enabled:
|
|
157
|
+
await self.app(scope, receive, send)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Lazy start for apps without lifespan events
|
|
161
|
+
if not self._started:
|
|
162
|
+
await self._start_monitor()
|
|
163
|
+
|
|
164
|
+
# Create and register request context
|
|
165
|
+
request_id = str(uuid.uuid4())[:8]
|
|
166
|
+
method = scope.get("method", "UNKNOWN")
|
|
167
|
+
|
|
168
|
+
ctx = RequestContext(
|
|
169
|
+
request_id=request_id,
|
|
170
|
+
path=path,
|
|
171
|
+
method=method,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Store request_id in scope state for handlers to access
|
|
175
|
+
if "state" not in scope:
|
|
176
|
+
scope["state"] = {}
|
|
177
|
+
scope["state"]["loopguard_request_id"] = request_id
|
|
178
|
+
|
|
179
|
+
register_request(ctx)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
if self._config.dev_mode:
|
|
183
|
+
# Use send wrapper to inject headers
|
|
184
|
+
await self._handle_with_headers(scope, receive, send, ctx)
|
|
185
|
+
else:
|
|
186
|
+
await self.app(scope, receive, send)
|
|
187
|
+
finally:
|
|
188
|
+
unregister_request(request_id)
|
|
189
|
+
|
|
190
|
+
async def _handle_with_headers(
|
|
191
|
+
self,
|
|
192
|
+
scope: Scope,
|
|
193
|
+
receive: Receive,
|
|
194
|
+
send: Send,
|
|
195
|
+
ctx: RequestContext,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Handle request with debug header injection.
|
|
198
|
+
|
|
199
|
+
Uses a send wrapper to add X-Request-Id, X-Blocking-Count, etc.
|
|
200
|
+
headers to the response.
|
|
201
|
+
"""
|
|
202
|
+
response_started = False
|
|
203
|
+
|
|
204
|
+
async def send_wrapper(message: Message) -> None:
|
|
205
|
+
nonlocal response_started
|
|
206
|
+
|
|
207
|
+
if message["type"] == "http.response.start" and not response_started:
|
|
208
|
+
response_started = True
|
|
209
|
+
|
|
210
|
+
# Get existing headers and add our debug headers
|
|
211
|
+
headers = list(message.get("headers", []))
|
|
212
|
+
headers.extend(
|
|
213
|
+
[
|
|
214
|
+
(b"x-request-id", ctx.request_id.encode()),
|
|
215
|
+
(b"x-blocking-count", str(ctx.blocking_count).encode()),
|
|
216
|
+
(
|
|
217
|
+
b"x-blocking-total-ms",
|
|
218
|
+
f"{ctx.total_blocking_ms:.2f}".encode(),
|
|
219
|
+
),
|
|
220
|
+
(
|
|
221
|
+
b"x-blocking-detected",
|
|
222
|
+
b"true" if ctx.blocking_count > 0 else b"false",
|
|
223
|
+
),
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Create new message with updated headers
|
|
228
|
+
message = {
|
|
229
|
+
"type": message["type"],
|
|
230
|
+
"status": message.get("status", 200),
|
|
231
|
+
"headers": headers,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await send(message)
|
|
235
|
+
|
|
236
|
+
await self.app(scope, receive, send_wrapper)
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Sentinel monitor for detecting event loop blocking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .context import get_active_requests
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .config import LoopGuardConfig
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("fastapi_loopguard")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdaptiveThreshold:
|
|
21
|
+
"""Adaptive threshold based on sliding window of recent lag samples.
|
|
22
|
+
|
|
23
|
+
Uses percentile-based calculation to automatically adjust the blocking
|
|
24
|
+
threshold based on observed latency patterns. This reduces false positives
|
|
25
|
+
in high-concurrency environments.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__slots__ = (
|
|
29
|
+
"_samples",
|
|
30
|
+
"_window_size",
|
|
31
|
+
"_percentile",
|
|
32
|
+
"_multiplier",
|
|
33
|
+
"_min_threshold_ms",
|
|
34
|
+
"_min_samples",
|
|
35
|
+
"_current_threshold_ms",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
window_size: int,
|
|
41
|
+
percentile: float,
|
|
42
|
+
multiplier: float,
|
|
43
|
+
min_threshold_ms: float,
|
|
44
|
+
min_samples: int,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize the adaptive threshold.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
window_size: Maximum samples in sliding window.
|
|
50
|
+
percentile: Percentile (0.0-1.0) for baseline calculation.
|
|
51
|
+
multiplier: Threshold = baseline × multiplier.
|
|
52
|
+
min_threshold_ms: Minimum threshold value.
|
|
53
|
+
min_samples: Minimum samples before adapting.
|
|
54
|
+
"""
|
|
55
|
+
self._samples: deque[float] = deque(maxlen=window_size)
|
|
56
|
+
self._window_size = window_size
|
|
57
|
+
self._percentile = percentile
|
|
58
|
+
self._multiplier = multiplier
|
|
59
|
+
self._min_threshold_ms = min_threshold_ms
|
|
60
|
+
self._min_samples = min_samples
|
|
61
|
+
self._current_threshold_ms = min_threshold_ms
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def current_threshold_ms(self) -> float:
|
|
65
|
+
"""Current calculated threshold in milliseconds."""
|
|
66
|
+
return self._current_threshold_ms
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def sample_count(self) -> int:
|
|
70
|
+
"""Number of samples currently in the window."""
|
|
71
|
+
return len(self._samples)
|
|
72
|
+
|
|
73
|
+
def add_sample(self, lag_ms: float) -> None:
|
|
74
|
+
"""Add a new lag sample to the sliding window.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
lag_ms: The lag value in milliseconds.
|
|
78
|
+
"""
|
|
79
|
+
self._samples.append(lag_ms)
|
|
80
|
+
|
|
81
|
+
def recalculate(self) -> float:
|
|
82
|
+
"""Recalculate the threshold based on current samples.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The new threshold in milliseconds.
|
|
86
|
+
"""
|
|
87
|
+
if len(self._samples) < self._min_samples:
|
|
88
|
+
return self._current_threshold_ms
|
|
89
|
+
|
|
90
|
+
sorted_samples = sorted(self._samples)
|
|
91
|
+
idx = int(len(sorted_samples) * self._percentile)
|
|
92
|
+
idx = min(idx, len(sorted_samples) - 1) # Bounds check
|
|
93
|
+
baseline = sorted_samples[idx]
|
|
94
|
+
|
|
95
|
+
self._current_threshold_ms = max(
|
|
96
|
+
baseline * self._multiplier,
|
|
97
|
+
self._min_threshold_ms,
|
|
98
|
+
)
|
|
99
|
+
return self._current_threshold_ms
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SentinelMonitor:
|
|
103
|
+
"""Background task that monitors event loop health.
|
|
104
|
+
|
|
105
|
+
The sentinel works by scheduling short sleeps and measuring how long
|
|
106
|
+
they actually take. If the actual time significantly exceeds the
|
|
107
|
+
expected time, it indicates the event loop was blocked.
|
|
108
|
+
|
|
109
|
+
When blocking is detected, the monitor iterates ALL active requests
|
|
110
|
+
and attributes the lag to each of them, since we cannot determine
|
|
111
|
+
which specific request caused the blocking.
|
|
112
|
+
|
|
113
|
+
Improvements in v0.2.0:
|
|
114
|
+
- Background calibration: First request is not blocked
|
|
115
|
+
- Multi-context attribution: All active requests are notified
|
|
116
|
+
- Named tasks: Easier debugging
|
|
117
|
+
- Clean shutdown: Proper task cancellation
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
__slots__ = (
|
|
121
|
+
"_config",
|
|
122
|
+
"_on_blocking",
|
|
123
|
+
"_task",
|
|
124
|
+
"_calibration_task",
|
|
125
|
+
"_running",
|
|
126
|
+
"_baseline_ms",
|
|
127
|
+
"_threshold_ms",
|
|
128
|
+
"_calibrated",
|
|
129
|
+
"_adaptive",
|
|
130
|
+
"_last_adapt_time",
|
|
131
|
+
"_lag_history",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
config: LoopGuardConfig,
|
|
137
|
+
on_blocking: Callable[[float, str | None, str | None], None] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Initialize the sentinel monitor.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
config: The LoopGuard configuration.
|
|
143
|
+
on_blocking: Optional callback called when blocking is detected.
|
|
144
|
+
Receives (lag_ms, path, method).
|
|
145
|
+
"""
|
|
146
|
+
self._config = config
|
|
147
|
+
self._on_blocking = on_blocking
|
|
148
|
+
self._task: asyncio.Task[None] | None = None
|
|
149
|
+
self._calibration_task: asyncio.Task[None] | None = None
|
|
150
|
+
self._running = False
|
|
151
|
+
self._baseline_ms: float = 0.0
|
|
152
|
+
self._threshold_ms: float = config.fallback_threshold_ms
|
|
153
|
+
self._calibrated = False
|
|
154
|
+
|
|
155
|
+
# Initialize adaptive threshold if enabled
|
|
156
|
+
if config.adaptive_threshold:
|
|
157
|
+
self._adaptive: AdaptiveThreshold | None = AdaptiveThreshold(
|
|
158
|
+
window_size=config.adaptive_window_size,
|
|
159
|
+
percentile=config.adaptive_percentile,
|
|
160
|
+
multiplier=config.threshold_multiplier,
|
|
161
|
+
min_threshold_ms=config.fallback_threshold_ms,
|
|
162
|
+
min_samples=config.adaptive_min_samples,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
self._adaptive = None
|
|
166
|
+
self._last_adapt_time: float = 0.0
|
|
167
|
+
self._lag_history: deque[tuple[float, float]] = deque()
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def is_running(self) -> bool:
|
|
171
|
+
"""Whether the monitor is currently running."""
|
|
172
|
+
return self._running
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_calibrated(self) -> bool:
|
|
176
|
+
"""Whether calibration has completed."""
|
|
177
|
+
return self._calibrated
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def threshold_ms(self) -> float:
|
|
181
|
+
"""Current blocking threshold in milliseconds."""
|
|
182
|
+
return self._threshold_ms
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def baseline_ms(self) -> float:
|
|
186
|
+
"""Calibrated baseline latency in milliseconds."""
|
|
187
|
+
return self._baseline_ms
|
|
188
|
+
|
|
189
|
+
async def calibrate(self) -> float:
|
|
190
|
+
"""Calibrate the baseline event loop latency.
|
|
191
|
+
|
|
192
|
+
Runs a series of sleep calls to measure the typical latency
|
|
193
|
+
of yielding to the event loop under normal conditions.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The calibrated threshold in milliseconds.
|
|
197
|
+
"""
|
|
198
|
+
loop = asyncio.get_running_loop()
|
|
199
|
+
interval_sec = self._config.monitor_interval_ms / 1000.0
|
|
200
|
+
samples: list[float] = []
|
|
201
|
+
|
|
202
|
+
for _ in range(self._config.calibration_iterations):
|
|
203
|
+
start = loop.time()
|
|
204
|
+
await asyncio.sleep(interval_sec)
|
|
205
|
+
elapsed = loop.time() - start
|
|
206
|
+
lag_ms = (elapsed - interval_sec) * 1000.0
|
|
207
|
+
samples.append(lag_ms)
|
|
208
|
+
|
|
209
|
+
# Use P75 as baseline to be robust against outliers
|
|
210
|
+
samples.sort()
|
|
211
|
+
p75_index = int(len(samples) * 0.75)
|
|
212
|
+
self._baseline_ms = samples[p75_index]
|
|
213
|
+
|
|
214
|
+
# Calculate threshold
|
|
215
|
+
self._threshold_ms = max(
|
|
216
|
+
self._baseline_ms * self._config.threshold_multiplier,
|
|
217
|
+
self._config.fallback_threshold_ms,
|
|
218
|
+
)
|
|
219
|
+
self._calibrated = True
|
|
220
|
+
|
|
221
|
+
logger.info(
|
|
222
|
+
"LoopGuard calibrated: baseline=%.2fms, threshold=%.2fms",
|
|
223
|
+
self._baseline_ms,
|
|
224
|
+
self._threshold_ms,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return self._threshold_ms
|
|
228
|
+
|
|
229
|
+
async def _background_calibrate(self) -> None:
|
|
230
|
+
"""Run calibration in background without blocking requests."""
|
|
231
|
+
try:
|
|
232
|
+
await self.calibrate()
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
logger.debug("LoopGuard calibration cancelled during shutdown")
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.exception(
|
|
237
|
+
"LoopGuard calibration failed, using fallback threshold=%.2fms",
|
|
238
|
+
self._threshold_ms,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def _monitor_loop(self) -> None:
|
|
242
|
+
"""The main monitoring loop."""
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
interval_sec = self._config.monitor_interval_ms / 1000.0
|
|
245
|
+
adapt_interval_sec = self._config.adaptive_update_interval_ms / 1000.0
|
|
246
|
+
|
|
247
|
+
while self._running:
|
|
248
|
+
try:
|
|
249
|
+
start = loop.time()
|
|
250
|
+
await asyncio.sleep(interval_sec)
|
|
251
|
+
elapsed = loop.time() - start
|
|
252
|
+
|
|
253
|
+
lag_ms = (elapsed - interval_sec) * 1000.0
|
|
254
|
+
|
|
255
|
+
# Adaptive threshold processing
|
|
256
|
+
if self._adaptive:
|
|
257
|
+
self._adaptive.add_sample(lag_ms)
|
|
258
|
+
now = loop.time()
|
|
259
|
+
if now - self._last_adapt_time >= adapt_interval_sec:
|
|
260
|
+
old_threshold = self._threshold_ms
|
|
261
|
+
new_threshold = self._adaptive.recalculate()
|
|
262
|
+
if new_threshold != old_threshold:
|
|
263
|
+
self._threshold_ms = new_threshold
|
|
264
|
+
logger.debug(
|
|
265
|
+
"Adaptive threshold updated: %.2fms -> %.2fms",
|
|
266
|
+
old_threshold,
|
|
267
|
+
new_threshold,
|
|
268
|
+
)
|
|
269
|
+
self._last_adapt_time = now
|
|
270
|
+
|
|
271
|
+
triggered = False
|
|
272
|
+
if lag_ms > self._threshold_ms:
|
|
273
|
+
self._handle_blocking(lag_ms)
|
|
274
|
+
triggered = True
|
|
275
|
+
|
|
276
|
+
# Cumulative blocking detection
|
|
277
|
+
if self._config.cumulative_blocking_enabled:
|
|
278
|
+
now = loop.time()
|
|
279
|
+
self._lag_history.append((now, lag_ms))
|
|
280
|
+
|
|
281
|
+
# Prune old samples
|
|
282
|
+
window_start = now - (self._config.cumulative_window_ms / 1000.0)
|
|
283
|
+
while self._lag_history and self._lag_history[0][0] < window_start:
|
|
284
|
+
self._lag_history.popleft()
|
|
285
|
+
|
|
286
|
+
# Calculate total lag in window
|
|
287
|
+
cumulative_lag = sum(lag for _, lag in self._lag_history)
|
|
288
|
+
|
|
289
|
+
if (
|
|
290
|
+
cumulative_lag > self._config.cumulative_blocking_threshold_ms
|
|
291
|
+
and not triggered
|
|
292
|
+
):
|
|
293
|
+
self._handle_blocking(cumulative_lag, is_cumulative=True)
|
|
294
|
+
# Clear history to avoid repeated triggering for the same window
|
|
295
|
+
self._lag_history.clear()
|
|
296
|
+
except Exception:
|
|
297
|
+
logger.exception("Error in LoopGuard monitor loop")
|
|
298
|
+
# Wait a bit before retrying to avoid tight loop on persistent error
|
|
299
|
+
await asyncio.sleep(1.0)
|
|
300
|
+
|
|
301
|
+
def _handle_blocking(self, lag_ms: float, is_cumulative: bool = False) -> None:
|
|
302
|
+
"""Handle a detected blocking event.
|
|
303
|
+
|
|
304
|
+
Attributes blocking to ALL currently active requests, since we
|
|
305
|
+
cannot determine which specific request caused the blocking.
|
|
306
|
+
"""
|
|
307
|
+
# Get all active request contexts
|
|
308
|
+
active_contexts = list(get_active_requests())
|
|
309
|
+
|
|
310
|
+
msg_type = (
|
|
311
|
+
"Cumulative event loop blocking" if is_cumulative else "Event loop blocked"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if not active_contexts:
|
|
315
|
+
# No active requests - log as background blocking
|
|
316
|
+
if self._config.log_blocking_events:
|
|
317
|
+
logger.warning(
|
|
318
|
+
"%s for %.2fms (no active request)",
|
|
319
|
+
msg_type,
|
|
320
|
+
lag_ms,
|
|
321
|
+
)
|
|
322
|
+
if self._on_blocking:
|
|
323
|
+
self._on_blocking(lag_ms, None, None)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
# Attribute to all active requests
|
|
327
|
+
for ctx in active_contexts:
|
|
328
|
+
ctx.record_blocking(lag_ms)
|
|
329
|
+
|
|
330
|
+
if self._config.log_blocking_events:
|
|
331
|
+
logger.warning(
|
|
332
|
+
"%s for %.2fms during %s %s (request_id=%s)",
|
|
333
|
+
msg_type,
|
|
334
|
+
lag_ms,
|
|
335
|
+
ctx.method,
|
|
336
|
+
ctx.path,
|
|
337
|
+
ctx.request_id,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if self._on_blocking:
|
|
341
|
+
self._on_blocking(lag_ms, ctx.path, ctx.method)
|
|
342
|
+
|
|
343
|
+
async def start_with_background_calibration(self) -> None:
|
|
344
|
+
"""Start monitoring immediately with background calibration.
|
|
345
|
+
|
|
346
|
+
Uses fallback threshold initially, calibrates in background.
|
|
347
|
+
First request is not blocked waiting for calibration.
|
|
348
|
+
"""
|
|
349
|
+
if self._running:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
self._running = True
|
|
353
|
+
|
|
354
|
+
# Start monitoring immediately with fallback threshold
|
|
355
|
+
self._task = asyncio.create_task(
|
|
356
|
+
self._monitor_loop(),
|
|
357
|
+
name="loopguard-monitor",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Calibrate in background
|
|
361
|
+
self._calibration_task = asyncio.create_task(
|
|
362
|
+
self._background_calibrate(),
|
|
363
|
+
name="loopguard-calibrate",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
logger.info(
|
|
367
|
+
"LoopGuard started with fallback threshold=%.2fms, "
|
|
368
|
+
"calibrating in background",
|
|
369
|
+
self._threshold_ms,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
async def start(self) -> None:
|
|
373
|
+
"""Start the sentinel monitor.
|
|
374
|
+
|
|
375
|
+
Performs calibration first (blocking), then starts the monitoring loop.
|
|
376
|
+
For non-blocking startup, use start_with_background_calibration().
|
|
377
|
+
"""
|
|
378
|
+
if self._running:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
if not self._calibrated:
|
|
382
|
+
await self.calibrate()
|
|
383
|
+
|
|
384
|
+
self._running = True
|
|
385
|
+
self._task = asyncio.create_task(
|
|
386
|
+
self._monitor_loop(),
|
|
387
|
+
name="loopguard-monitor",
|
|
388
|
+
)
|
|
389
|
+
logger.info("LoopGuard sentinel started")
|
|
390
|
+
|
|
391
|
+
async def stop(self) -> None:
|
|
392
|
+
"""Stop the sentinel monitor gracefully."""
|
|
393
|
+
if not self._running:
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
self._running = False
|
|
397
|
+
|
|
398
|
+
# Cancel calibration if still running
|
|
399
|
+
if self._calibration_task and not self._calibration_task.done():
|
|
400
|
+
self._calibration_task.cancel()
|
|
401
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
402
|
+
await self._calibration_task
|
|
403
|
+
self._calibration_task = None
|
|
404
|
+
|
|
405
|
+
# Cancel monitoring task
|
|
406
|
+
if self._task:
|
|
407
|
+
self._task.cancel()
|
|
408
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
409
|
+
await self._task
|
|
410
|
+
self._task = None
|
|
411
|
+
|
|
412
|
+
logger.info("LoopGuard sentinel stopped")
|
|
File without changes
|