fastapi-calf 0.1.2__tar.gz → 0.2.0__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.
- fastapi_calf-0.2.0/PKG-INFO +196 -0
- fastapi_calf-0.2.0/README.md +185 -0
- fastapi_calf-0.2.0/fastapi_calf/autostart.py +101 -0
- fastapi_calf-0.2.0/fastapi_calf/blocking.py +126 -0
- fastapi_calf-0.2.0/fastapi_calf/calf.py +70 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/config.py +4 -3
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/daemon.py +215 -43
- fastapi_calf-0.2.0/fastapi_calf/dashboard.py +201 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/decorator.py +9 -7
- fastapi_calf-0.2.0/fastapi_calf/emitter.py +38 -0
- fastapi_calf-0.2.0/fastapi_calf/lifecycle.py +231 -0
- fastapi_calf-0.2.0/fastapi_calf.egg-info/PKG-INFO +196 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/SOURCES.txt +9 -1
- fastapi_calf-0.2.0/fastapi_calf.egg-info/requires.txt +4 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/pyproject.toml +23 -21
- fastapi_calf-0.2.0/tests/test_api.py +41 -0
- fastapi_calf-0.2.0/tests/test_autostart.py +32 -0
- fastapi_calf-0.2.0/tests/test_blocking.py +74 -0
- fastapi_calf-0.2.0/tests/test_dashboard.py +95 -0
- fastapi_calf-0.2.0/tests/test_lifecycle.py +94 -0
- fastapi_calf-0.1.2/PKG-INFO +0 -234
- fastapi_calf-0.1.2/README.md +0 -225
- fastapi_calf-0.1.2/fastapi_calf/calf.py +0 -45
- fastapi_calf-0.1.2/fastapi_calf/emitter.py +0 -21
- fastapi_calf-0.1.2/fastapi_calf.egg-info/PKG-INFO +0 -234
- fastapi_calf-0.1.2/fastapi_calf.egg-info/requires.txt +0 -2
- fastapi_calf-0.1.2/tests/test.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/__init__.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/cleanup.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/history/__init__.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/history/history.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/metrics/__init__.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/metrics/metrics.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/sparkline/__init__.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/sparkline/sparkline.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/__init__.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/cpu.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/gpu.py +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/dependency_links.txt +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/entry_points.txt +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/top_level.txt +0 -0
- {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-calf
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight real-time terminal monitor for FastAPI servers
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi
|
|
8
|
+
Requires-Dist: rich
|
|
9
|
+
Requires-Dist: psutil
|
|
10
|
+
Requires-Dist: uvicorn
|
|
11
|
+
|
|
12
|
+
# 🐮 fastapi-calf
|
|
13
|
+
|
|
14
|
+
**A lightweight, real-time observability for FastAPI.**
|
|
15
|
+
|
|
16
|
+
`fastapi-calf` watches decorated API routes and presents request activity,
|
|
17
|
+
latency, throughput, failures, CPU, memory, and worker information in a compact
|
|
18
|
+
terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
|
|
19
|
+
available, your API continues serving requests normally.
|
|
20
|
+
|
|
21
|
+
## Highlights
|
|
22
|
+
|
|
23
|
+
- ⚡ Live requests-per-second and latency sparklines
|
|
24
|
+
- 🧭 Per-route request, failure, status, and response-time statistics
|
|
25
|
+
- 🖥️ CPU and memory usage across multiple application workers
|
|
26
|
+
- 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
|
|
27
|
+
- 🛡️ Short-timeout event delivery that never takes down the application
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Install the runtime dependencies from the repository root:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
python -m pip install fastapi uvicorn rich psutil
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start the included example API with four workers:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
python -m uvicorn server.app:app --port 8000 --workers 4
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`Calf.listen()` detects whether its collector is running on port `8765`. If it
|
|
44
|
+
is missing, the first application worker starts it automatically; the other
|
|
45
|
+
workers reuse it. The auto-started collector exits after the application
|
|
46
|
+
workers stop.
|
|
47
|
+
|
|
48
|
+
The `port` passed to `Calf.listen()` is the Calf collector/dashboard port. The
|
|
49
|
+
FastAPI application port remains whatever you pass to Uvicorn (`8000` above).
|
|
50
|
+
|
|
51
|
+
Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
|
|
52
|
+
dashboard updates in real time.
|
|
53
|
+
|
|
54
|
+
Open `http://127.0.0.1:8000/calf` to redirect to the aggregated dashboard at
|
|
55
|
+
`http://127.0.0.1:8765/`. It refreshes once per second and combines route
|
|
56
|
+
traffic, failures, latency, event-loop stalls, and lifecycle timing from every
|
|
57
|
+
Uvicorn worker.
|
|
58
|
+
|
|
59
|
+
The automatically started collector serves the web dashboard without a terminal
|
|
60
|
+
UI. To use the terminal view, start the collector explicitly before Uvicorn:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
python -m fastapi_calf.daemon --port 8765
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The web dashboard is enabled by default and can be moved or disabled:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
Calf.listen(app, port=8765, server="jobs_api", dashboard_path="/observability")
|
|
70
|
+
# Or: Calf.listen(..., dashboard=False)
|
|
71
|
+
# Or manage the collector yourself: Calf.listen(..., auto_start_collector=False)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Ignore health checks, metrics collectors, or other noisy paths with exact paths
|
|
75
|
+
or shell-style wildcards. Calf ignores `/favicon.ico` and its own dashboard
|
|
76
|
+
routes automatically.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
Calf.listen(
|
|
80
|
+
app,
|
|
81
|
+
port=8005,
|
|
82
|
+
server="jobs_api",
|
|
83
|
+
ignore_paths=("/health", "/ready", "/metrics", "/internal/*"),
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Workers send best-effort HTTP events to the collector's `/api/events` endpoint.
|
|
88
|
+
The collector exposes aggregate JSON at `/api/stats`; both the web and terminal
|
|
89
|
+
views read the same in-memory process-wide aggregation.
|
|
90
|
+
|
|
91
|
+
## Instrumenting an API
|
|
92
|
+
|
|
93
|
+
Configure the monitor once, then decorate the routes you want to observe:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from fastapi import FastAPI, Request
|
|
97
|
+
from fastapi_calf import Calf, lookout
|
|
98
|
+
|
|
99
|
+
app = FastAPI()
|
|
100
|
+
Calf.listen(app, port=8005, server="jobs_api")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/health")
|
|
104
|
+
@lookout
|
|
105
|
+
def health(request: Request):
|
|
106
|
+
return {"status": "healthy"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.post("/jobs")
|
|
110
|
+
@lookout
|
|
111
|
+
async def create_job(request: Request):
|
|
112
|
+
payload = await request.json()
|
|
113
|
+
return {"accepted": True, "job": payload}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `Request` parameter allows `@lookout` to capture the method, path, query
|
|
117
|
+
parameters, request size, status, and timing without changing the response.
|
|
118
|
+
Metrics are kept in memory by the live dashboard and reset when it stops.
|
|
119
|
+
|
|
120
|
+
### Event-loop blocking detection
|
|
121
|
+
|
|
122
|
+
Calf watches each worker's asyncio event loop and warns when it cannot run for
|
|
123
|
+
more than 100 ms. This catches blocking calls such as `time.sleep()`, synchronous
|
|
124
|
+
HTTP clients, filesystem operations, and CPU-heavy work used inside `async def`
|
|
125
|
+
endpoints. Overlapping requests are shown as affected rather than definitively
|
|
126
|
+
identified as the cause.
|
|
127
|
+
|
|
128
|
+
Tune or disable the detector when configuring the application:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
Calf.listen(
|
|
132
|
+
app,
|
|
133
|
+
port=8005,
|
|
134
|
+
server="jobs_api",
|
|
135
|
+
detect_blocking=True,
|
|
136
|
+
block_threshold=100,
|
|
137
|
+
block_check_interval=50,
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Both values are milliseconds. `block_check_interval` controls how often the
|
|
142
|
+
watchdog asks the event loop to run. If it wakes at least `block_threshold`
|
|
143
|
+
milliseconds later than expected, Calf records a blocking event. A smaller
|
|
144
|
+
check interval detects stalls more precisely but wakes the watchdog more often.
|
|
145
|
+
|
|
146
|
+
The example server exposes `/block-event-loop` as an intentional blocking case
|
|
147
|
+
and `/cooperative-sleep` as its non-blocking comparison.
|
|
148
|
+
|
|
149
|
+
### Request lifecycle timing
|
|
150
|
+
|
|
151
|
+
Every HTTP event includes a `lifecycle` breakdown in milliseconds for request
|
|
152
|
+
body parsing, dependency injection and validation, endpoint execution, response
|
|
153
|
+
serialization, middleware/framework overhead, and background tasks. The live
|
|
154
|
+
dashboard shows the most recent breakdown for each route.
|
|
155
|
+
|
|
156
|
+
`middleware` is the residual request time after the directly measured phases;
|
|
157
|
+
it includes routing, middleware work, response construction, and response
|
|
158
|
+
transport. Streaming generator work is also included there because it occurs
|
|
159
|
+
after the endpoint has returned its iterator.
|
|
160
|
+
|
|
161
|
+
Try all lifecycle phases with the example endpoint:
|
|
162
|
+
|
|
163
|
+
```powershell
|
|
164
|
+
curl -X POST http://127.0.0.1:8000/lifecycle `
|
|
165
|
+
-H "Content-Type: application/json" `
|
|
166
|
+
-d '{"message":"hello"}'
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Tests and traffic simulation
|
|
170
|
+
|
|
171
|
+
Install the testing tools and run the correctness suite:
|
|
172
|
+
|
|
173
|
+
```powershell
|
|
174
|
+
python -m pip install -r requirements-test.txt
|
|
175
|
+
python -m pytest tests/test_api.py -q
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The load runner starts four Uvicorn workers, verifies every sample endpoint,
|
|
179
|
+
and pushes **100 concurrent API calls** in a short burst:
|
|
180
|
+
|
|
181
|
+
```powershell
|
|
182
|
+
python -m tests.load_test
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Customize a run with `--requests`, `--concurrency`, and `--workers`. See
|
|
186
|
+
[TESTING.md](TESTING.md) for additional examples.
|
|
187
|
+
|
|
188
|
+
## Project layout
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
fastapi_calf/ monitoring library and terminal dashboard
|
|
192
|
+
server/ instrumented example FastAPI application
|
|
193
|
+
tests/ endpoint and concurrent load tests
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# 🐮 fastapi-calf
|
|
2
|
+
|
|
3
|
+
**A lightweight, real-time observability for FastAPI.**
|
|
4
|
+
|
|
5
|
+
`fastapi-calf` watches decorated API routes and presents request activity,
|
|
6
|
+
latency, throughput, failures, CPU, memory, and worker information in a compact
|
|
7
|
+
terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
|
|
8
|
+
available, your API continues serving requests normally.
|
|
9
|
+
|
|
10
|
+
## Highlights
|
|
11
|
+
|
|
12
|
+
- ⚡ Live requests-per-second and latency sparklines
|
|
13
|
+
- 🧭 Per-route request, failure, status, and response-time statistics
|
|
14
|
+
- 🖥️ CPU and memory usage across multiple application workers
|
|
15
|
+
- 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
|
|
16
|
+
- 🛡️ Short-timeout event delivery that never takes down the application
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
Install the runtime dependencies from the repository root:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install fastapi uvicorn rich psutil
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Start the included example API with four workers:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
python -m uvicorn server.app:app --port 8000 --workers 4
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`Calf.listen()` detects whether its collector is running on port `8765`. If it
|
|
33
|
+
is missing, the first application worker starts it automatically; the other
|
|
34
|
+
workers reuse it. The auto-started collector exits after the application
|
|
35
|
+
workers stop.
|
|
36
|
+
|
|
37
|
+
The `port` passed to `Calf.listen()` is the Calf collector/dashboard port. The
|
|
38
|
+
FastAPI application port remains whatever you pass to Uvicorn (`8000` above).
|
|
39
|
+
|
|
40
|
+
Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
|
|
41
|
+
dashboard updates in real time.
|
|
42
|
+
|
|
43
|
+
Open `http://127.0.0.1:8000/calf` to redirect to the aggregated dashboard at
|
|
44
|
+
`http://127.0.0.1:8765/`. It refreshes once per second and combines route
|
|
45
|
+
traffic, failures, latency, event-loop stalls, and lifecycle timing from every
|
|
46
|
+
Uvicorn worker.
|
|
47
|
+
|
|
48
|
+
The automatically started collector serves the web dashboard without a terminal
|
|
49
|
+
UI. To use the terminal view, start the collector explicitly before Uvicorn:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
python -m fastapi_calf.daemon --port 8765
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The web dashboard is enabled by default and can be moved or disabled:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
Calf.listen(app, port=8765, server="jobs_api", dashboard_path="/observability")
|
|
59
|
+
# Or: Calf.listen(..., dashboard=False)
|
|
60
|
+
# Or manage the collector yourself: Calf.listen(..., auto_start_collector=False)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Ignore health checks, metrics collectors, or other noisy paths with exact paths
|
|
64
|
+
or shell-style wildcards. Calf ignores `/favicon.ico` and its own dashboard
|
|
65
|
+
routes automatically.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
Calf.listen(
|
|
69
|
+
app,
|
|
70
|
+
port=8005,
|
|
71
|
+
server="jobs_api",
|
|
72
|
+
ignore_paths=("/health", "/ready", "/metrics", "/internal/*"),
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Workers send best-effort HTTP events to the collector's `/api/events` endpoint.
|
|
77
|
+
The collector exposes aggregate JSON at `/api/stats`; both the web and terminal
|
|
78
|
+
views read the same in-memory process-wide aggregation.
|
|
79
|
+
|
|
80
|
+
## Instrumenting an API
|
|
81
|
+
|
|
82
|
+
Configure the monitor once, then decorate the routes you want to observe:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from fastapi import FastAPI, Request
|
|
86
|
+
from fastapi_calf import Calf, lookout
|
|
87
|
+
|
|
88
|
+
app = FastAPI()
|
|
89
|
+
Calf.listen(app, port=8005, server="jobs_api")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.get("/health")
|
|
93
|
+
@lookout
|
|
94
|
+
def health(request: Request):
|
|
95
|
+
return {"status": "healthy"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.post("/jobs")
|
|
99
|
+
@lookout
|
|
100
|
+
async def create_job(request: Request):
|
|
101
|
+
payload = await request.json()
|
|
102
|
+
return {"accepted": True, "job": payload}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The `Request` parameter allows `@lookout` to capture the method, path, query
|
|
106
|
+
parameters, request size, status, and timing without changing the response.
|
|
107
|
+
Metrics are kept in memory by the live dashboard and reset when it stops.
|
|
108
|
+
|
|
109
|
+
### Event-loop blocking detection
|
|
110
|
+
|
|
111
|
+
Calf watches each worker's asyncio event loop and warns when it cannot run for
|
|
112
|
+
more than 100 ms. This catches blocking calls such as `time.sleep()`, synchronous
|
|
113
|
+
HTTP clients, filesystem operations, and CPU-heavy work used inside `async def`
|
|
114
|
+
endpoints. Overlapping requests are shown as affected rather than definitively
|
|
115
|
+
identified as the cause.
|
|
116
|
+
|
|
117
|
+
Tune or disable the detector when configuring the application:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
Calf.listen(
|
|
121
|
+
app,
|
|
122
|
+
port=8005,
|
|
123
|
+
server="jobs_api",
|
|
124
|
+
detect_blocking=True,
|
|
125
|
+
block_threshold=100,
|
|
126
|
+
block_check_interval=50,
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Both values are milliseconds. `block_check_interval` controls how often the
|
|
131
|
+
watchdog asks the event loop to run. If it wakes at least `block_threshold`
|
|
132
|
+
milliseconds later than expected, Calf records a blocking event. A smaller
|
|
133
|
+
check interval detects stalls more precisely but wakes the watchdog more often.
|
|
134
|
+
|
|
135
|
+
The example server exposes `/block-event-loop` as an intentional blocking case
|
|
136
|
+
and `/cooperative-sleep` as its non-blocking comparison.
|
|
137
|
+
|
|
138
|
+
### Request lifecycle timing
|
|
139
|
+
|
|
140
|
+
Every HTTP event includes a `lifecycle` breakdown in milliseconds for request
|
|
141
|
+
body parsing, dependency injection and validation, endpoint execution, response
|
|
142
|
+
serialization, middleware/framework overhead, and background tasks. The live
|
|
143
|
+
dashboard shows the most recent breakdown for each route.
|
|
144
|
+
|
|
145
|
+
`middleware` is the residual request time after the directly measured phases;
|
|
146
|
+
it includes routing, middleware work, response construction, and response
|
|
147
|
+
transport. Streaming generator work is also included there because it occurs
|
|
148
|
+
after the endpoint has returned its iterator.
|
|
149
|
+
|
|
150
|
+
Try all lifecycle phases with the example endpoint:
|
|
151
|
+
|
|
152
|
+
```powershell
|
|
153
|
+
curl -X POST http://127.0.0.1:8000/lifecycle `
|
|
154
|
+
-H "Content-Type: application/json" `
|
|
155
|
+
-d '{"message":"hello"}'
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Tests and traffic simulation
|
|
159
|
+
|
|
160
|
+
Install the testing tools and run the correctness suite:
|
|
161
|
+
|
|
162
|
+
```powershell
|
|
163
|
+
python -m pip install -r requirements-test.txt
|
|
164
|
+
python -m pytest tests/test_api.py -q
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The load runner starts four Uvicorn workers, verifies every sample endpoint,
|
|
168
|
+
and pushes **100 concurrent API calls** in a short burst:
|
|
169
|
+
|
|
170
|
+
```powershell
|
|
171
|
+
python -m tests.load_test
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Customize a run with `--requests`, `--concurrency`, and `--workers`. See
|
|
175
|
+
[TESTING.md](TESTING.md) for additional examples.
|
|
176
|
+
|
|
177
|
+
## Project layout
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
fastapi_calf/ monitoring library and terminal dashboard
|
|
181
|
+
server/ instrumented example FastAPI application
|
|
182
|
+
tests/ endpoint and concurrent load tests
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
> **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def collector_is_running(host: str, port: int, timeout: float = 0.15) -> bool:
|
|
16
|
+
connection = None
|
|
17
|
+
try:
|
|
18
|
+
connection = http.client.HTTPConnection(host, port, timeout=timeout)
|
|
19
|
+
connection.request("GET", "/api/health")
|
|
20
|
+
response = connection.getresponse()
|
|
21
|
+
response.read()
|
|
22
|
+
return response.status == 200
|
|
23
|
+
except (OSError, TimeoutError, http.client.HTTPException):
|
|
24
|
+
return False
|
|
25
|
+
finally:
|
|
26
|
+
if connection is not None:
|
|
27
|
+
connection.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _wait_for_collector(host: str, port: int, timeout: float = 4.0) -> bool:
|
|
31
|
+
deadline = time.monotonic() + timeout
|
|
32
|
+
while time.monotonic() < deadline:
|
|
33
|
+
if collector_is_running(host, port):
|
|
34
|
+
return True
|
|
35
|
+
time.sleep(0.05)
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _spawn_collector(host: str, port: int) -> None:
|
|
40
|
+
command = [
|
|
41
|
+
sys.executable,
|
|
42
|
+
"-m",
|
|
43
|
+
"fastapi_calf.daemon",
|
|
44
|
+
"--host",
|
|
45
|
+
host,
|
|
46
|
+
"--port",
|
|
47
|
+
str(port),
|
|
48
|
+
"--no-terminal",
|
|
49
|
+
"--auto-started",
|
|
50
|
+
]
|
|
51
|
+
options = {
|
|
52
|
+
"stdin": subprocess.DEVNULL,
|
|
53
|
+
"stdout": subprocess.DEVNULL,
|
|
54
|
+
"stderr": subprocess.DEVNULL,
|
|
55
|
+
"close_fds": True,
|
|
56
|
+
}
|
|
57
|
+
if os.name == "nt":
|
|
58
|
+
options["creationflags"] = (
|
|
59
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
options["start_new_session"] = True
|
|
63
|
+
subprocess.Popen(command, **options)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_collector(host: str, port: int) -> bool:
|
|
67
|
+
"""Start one local collector when none is already listening."""
|
|
68
|
+
if collector_is_running(host, port):
|
|
69
|
+
return True
|
|
70
|
+
if host not in LOCAL_HOSTS:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
lock_path = Path(tempfile.gettempdir()) / f"fastapi-calf-{port}.starting"
|
|
74
|
+
owns_lock = False
|
|
75
|
+
try:
|
|
76
|
+
try:
|
|
77
|
+
lock_path.mkdir()
|
|
78
|
+
owns_lock = True
|
|
79
|
+
except FileExistsError:
|
|
80
|
+
# Another Uvicorn worker is starting the collector.
|
|
81
|
+
if _wait_for_collector(host, port):
|
|
82
|
+
return True
|
|
83
|
+
# Recover a lock left behind by a worker that was force-killed.
|
|
84
|
+
try:
|
|
85
|
+
if time.time() - lock_path.stat().st_mtime > 4:
|
|
86
|
+
lock_path.rmdir()
|
|
87
|
+
return ensure_collector(host, port)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if collector_is_running(host, port):
|
|
93
|
+
return True
|
|
94
|
+
_spawn_collector(host, port)
|
|
95
|
+
return _wait_for_collector(host, port)
|
|
96
|
+
finally:
|
|
97
|
+
if owns_lock:
|
|
98
|
+
try:
|
|
99
|
+
lock_path.rmdir()
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from typing import Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ActiveRequest:
|
|
14
|
+
request_id: str
|
|
15
|
+
method: str
|
|
16
|
+
path: str
|
|
17
|
+
started_at: float
|
|
18
|
+
task_name: str | None
|
|
19
|
+
ended_at: float | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BlockingDetector:
|
|
23
|
+
"""Detect periods during which an asyncio event loop cannot run.
|
|
24
|
+
|
|
25
|
+
Event-loop lag proves that the worker was stalled, but when requests overlap
|
|
26
|
+
it cannot prove which request caused the stall. Events therefore contain all
|
|
27
|
+
requests that overlapped the blocked interval as ``affected_requests``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
emit: Callable[[dict], Awaitable[None]],
|
|
33
|
+
*,
|
|
34
|
+
interval: float = 0.05,
|
|
35
|
+
threshold: float = 0.10,
|
|
36
|
+
) -> None:
|
|
37
|
+
if interval <= 0:
|
|
38
|
+
raise ValueError("interval must be greater than zero")
|
|
39
|
+
if threshold <= 0:
|
|
40
|
+
raise ValueError("threshold must be greater than zero")
|
|
41
|
+
|
|
42
|
+
self.emit = emit
|
|
43
|
+
self.interval = interval
|
|
44
|
+
self.threshold = threshold
|
|
45
|
+
self.active: dict[str, ActiveRequest] = {}
|
|
46
|
+
self.recently_finished: deque[ActiveRequest] = deque()
|
|
47
|
+
self._task: asyncio.Task | None = None
|
|
48
|
+
|
|
49
|
+
def register(self, method: str, path: str) -> str:
|
|
50
|
+
request_id = uuid.uuid4().hex
|
|
51
|
+
task = asyncio.current_task()
|
|
52
|
+
self.active[request_id] = ActiveRequest(
|
|
53
|
+
request_id=request_id,
|
|
54
|
+
method=method,
|
|
55
|
+
path=path,
|
|
56
|
+
started_at=time.perf_counter(),
|
|
57
|
+
task_name=task.get_name() if task else None,
|
|
58
|
+
)
|
|
59
|
+
return request_id
|
|
60
|
+
|
|
61
|
+
def unregister(self, request_id: str) -> None:
|
|
62
|
+
request = self.active.pop(request_id, None)
|
|
63
|
+
if request is not None:
|
|
64
|
+
request.ended_at = time.perf_counter()
|
|
65
|
+
self.recently_finished.append(request)
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
if self._task is None or self._task.done():
|
|
69
|
+
self._task = asyncio.create_task(
|
|
70
|
+
self._watch(), name="fastapi-calf-loop-watchdog"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def stop(self) -> None:
|
|
74
|
+
if self._task is None:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
self._task.cancel()
|
|
78
|
+
try:
|
|
79
|
+
await self._task
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
pass
|
|
82
|
+
self._task = None
|
|
83
|
+
|
|
84
|
+
def _requests_overlapping(self, blocked_since: float) -> list[dict]:
|
|
85
|
+
while (
|
|
86
|
+
self.recently_finished
|
|
87
|
+
and self.recently_finished[0].ended_at is not None
|
|
88
|
+
and self.recently_finished[0].ended_at < blocked_since
|
|
89
|
+
):
|
|
90
|
+
self.recently_finished.popleft()
|
|
91
|
+
|
|
92
|
+
requests = [
|
|
93
|
+
request
|
|
94
|
+
for request in (*self.active.values(), *self.recently_finished)
|
|
95
|
+
if request.started_at <= time.perf_counter()
|
|
96
|
+
and (request.ended_at is None or request.ended_at >= blocked_since)
|
|
97
|
+
]
|
|
98
|
+
return [asdict(request) for request in requests]
|
|
99
|
+
|
|
100
|
+
async def _watch(self) -> None:
|
|
101
|
+
loop = asyncio.get_running_loop()
|
|
102
|
+
expected = loop.time() + self.interval
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
await asyncio.sleep(self.interval)
|
|
106
|
+
now = loop.time()
|
|
107
|
+
lag = max(0.0, now - expected)
|
|
108
|
+
blocked_since = time.perf_counter() - lag
|
|
109
|
+
expected = now + self.interval
|
|
110
|
+
|
|
111
|
+
if lag < self.threshold:
|
|
112
|
+
self._requests_overlapping(time.perf_counter())
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
await self.emit(
|
|
116
|
+
{
|
|
117
|
+
"type": "loop_blocked",
|
|
118
|
+
"pid": os.getpid(),
|
|
119
|
+
"blocked_ms": lag * 1000,
|
|
120
|
+
"affected_requests": self._requests_overlapping(blocked_since),
|
|
121
|
+
"timestamp": time.time(),
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
# Event delivery is deliberately outside the measured interval. A
|
|
125
|
+
# slow or unavailable dashboard must not look like application lag.
|
|
126
|
+
expected = loop.time() + self.interval
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from .config import config
|
|
4
|
+
from .blocking import BlockingDetector
|
|
5
|
+
from .emitter import emit_event_async
|
|
6
|
+
from .lifecycle import CalfLifecycleMiddleware, install_lifecycle_hooks
|
|
7
|
+
from .dashboard import install_dashboard_redirect
|
|
8
|
+
from .autostart import ensure_collector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Calf:
|
|
12
|
+
@staticmethod
|
|
13
|
+
def listen(
|
|
14
|
+
app,
|
|
15
|
+
port: int,
|
|
16
|
+
server: str,
|
|
17
|
+
host: str = "127.0.0.1",
|
|
18
|
+
*,
|
|
19
|
+
detect_blocking: bool = True,
|
|
20
|
+
block_threshold: float = 100,
|
|
21
|
+
block_check_interval: float = 50,
|
|
22
|
+
dashboard: bool = True,
|
|
23
|
+
dashboard_path: str = "/calf",
|
|
24
|
+
ignore_paths: tuple[str, ...] | list[str] = (),
|
|
25
|
+
auto_start_collector: bool = True,
|
|
26
|
+
):
|
|
27
|
+
config.host = host
|
|
28
|
+
config.port = port
|
|
29
|
+
config.server = server
|
|
30
|
+
|
|
31
|
+
cuda_devices = os.getenv("CUDA_VISIBLE_DEVICES")
|
|
32
|
+
|
|
33
|
+
detector = None
|
|
34
|
+
if detect_blocking:
|
|
35
|
+
detector = BlockingDetector(
|
|
36
|
+
emit_event_async,
|
|
37
|
+
interval=block_check_interval / 1000,
|
|
38
|
+
threshold=block_threshold / 1000,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@app.on_event("shutdown")
|
|
42
|
+
async def stop_blocking_detector():
|
|
43
|
+
await detector.stop()
|
|
44
|
+
|
|
45
|
+
@app.on_event("startup")
|
|
46
|
+
async def start_calf():
|
|
47
|
+
if auto_start_collector:
|
|
48
|
+
await asyncio.to_thread(ensure_collector, host, port)
|
|
49
|
+
await emit_event_async({
|
|
50
|
+
"pid": os.getpid(),
|
|
51
|
+
"cuda_visible_devices": cuda_devices,
|
|
52
|
+
})
|
|
53
|
+
if detector is not None:
|
|
54
|
+
detector.start()
|
|
55
|
+
|
|
56
|
+
install_lifecycle_hooks()
|
|
57
|
+
if dashboard:
|
|
58
|
+
install_dashboard_redirect(
|
|
59
|
+
app,
|
|
60
|
+
dashboard_path,
|
|
61
|
+
f"http://{host}:{port}/",
|
|
62
|
+
)
|
|
63
|
+
app.add_middleware(
|
|
64
|
+
CalfLifecycleMiddleware,
|
|
65
|
+
emit=emit_event_async,
|
|
66
|
+
detector=detector,
|
|
67
|
+
cuda_devices=cuda_devices,
|
|
68
|
+
dashboard_path=dashboard_path if dashboard else None,
|
|
69
|
+
ignore_paths=("/favicon.ico", *ignore_paths),
|
|
70
|
+
)
|