Repository navigation
Expand file tree
/
Copy pathoverhead.rs
More file actions
242 lines (223 loc) · 8.03 KB
/
Copy pathoverhead.rs
File metadata and controls
242 lines (223 loc) · 8.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
//! End-to-end HTTP overhead benchmark harness.
//!
//! Measures what putting mcp-proxy in front of a backend costs, over real
//! HTTP on loopback, with protocol-correct MCP clients:
//!
//! 1. `direct`: client -> backend
//! 2. `proxied-minimal`: client -> mcp-proxy (no middleware) -> backend
//! 3. `proxied-loaded`: client -> mcp-proxy (timeout, retry, circuit
//! breaker, cache, coalescing) -> backend
//!
//! Each scenario runs at several concurrency levels; every request carries
//! distinct arguments so caching and coalescing cannot short-circuit the
//! measurement. Methodology and baseline numbers: docs/benchmarks.md.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example overhead
//! ```
use std::time::{Duration, Instant};
use tower_mcp::client::{HttpClientTransport, McpClient};
use tower_mcp::transport::http::HttpTransport;
use tower_mcp::{CallToolResult, McpRouter, ToolBuilder};
use mcp_proxy::{Proxy, ProxyConfig};
const WARMUP_PER_WORKER: usize = 20;
const REQUESTS_PER_CELL: usize = 2000;
const CONCURRENCY_LEVELS: &[usize] = &[1, 8, 32];
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct EchoInput {
v: u64,
}
fn echo_router() -> McpRouter {
let echo = ToolBuilder::new("echo")
.description("Echo a number")
.handler(|input: EchoInput| async move { Ok(CallToolResult::text(input.v.to_string())) })
.build();
McpRouter::new()
.server_info("bench-backend", "1.0.0")
.tool(echo)
}
/// Serve an axum router on an ephemeral loopback port; returns the base URL.
async fn serve(router: axum::Router) -> anyhow::Result<String> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
axum::serve(listener, router).await.expect("server runs");
});
Ok(format!("http://{addr}"))
}
async fn start_backend() -> anyhow::Result<String> {
serve(HttpTransport::new(echo_router()).into_router()).await
}
async fn start_proxy(backend_url: &str, loaded: bool) -> anyhow::Result<String> {
let middleware = if loaded {
r#"
[backends.timeout]
seconds = 5
[backends.retry]
max_retries = 2
initial_backoff_ms = 10
max_backoff_ms = 100
[backends.circuit_breaker]
failure_rate_threshold = 0.5
minimum_calls = 100
wait_duration_seconds = 5
[backends.cache]
tool_ttl_seconds = 60
max_entries = 10000
"#
} else {
""
};
let toml = format!(
r#"
[proxy]
name = "bench-proxy"
[proxy.listen]
host = "127.0.0.1"
port = 0
[performance]
coalesce_requests = {coalesce}
[[backends]]
name = "b"
transport = "http"
url = "{backend_url}"
{middleware}
"#,
coalesce = loaded,
);
let config = ProxyConfig::parse(&toml)?;
let proxy = Proxy::from_config(config).await?;
let (router, _session_handle) = proxy.into_router();
serve(router).await
}
/// The tool name differs by target: the backend exposes `echo`, the proxy
/// namespaces it as `b/echo`.
async fn run_cell(
url: &str,
tool: &str,
concurrency: usize,
) -> anyhow::Result<(Duration, Vec<Duration>, usize)> {
let per_worker = REQUESTS_PER_CELL / concurrency;
// Workers settle their sessions first and rendezvous at the barrier, so
// throughput measures steady state, not session-establishment storms
// (those show up in the resets column instead).
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(concurrency + 1));
let mut handles = Vec::new();
for worker in 0..concurrency {
let url = url.to_string();
let tool = tool.to_string();
let barrier = barrier.clone();
handles.push(tokio::spawn(async move {
// Under concurrent session creation the initialized notification
// can be lost and the session stranded (tower-mcp#1174); connect
// like a real client would: settle the session or reconnect.
// Reconnects are counted and reported.
let mut reconnects = 0usize;
let client = 'connect: loop {
let client = McpClient::connect(HttpClientTransport::new(&url))
.await
.expect("client connects");
client
.initialize("bench-client", "1.0.0")
.await
.expect("initialize");
for _ in 0..20 {
if client.list_tools().await.is_ok() {
break 'connect client;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
reconnects += 1;
assert!(
reconnects <= 5,
"worker {worker}: session never settled after 5 connects"
);
};
barrier.wait().await;
let mut latencies = Vec::with_capacity(per_worker);
let mut handshake_retries = 0usize;
for i in 0..(WARMUP_PER_WORKER + per_worker) {
let arg = (worker * 1_000_000 + i) as u64;
let t = Instant::now();
let mut attempts = 0;
let result = loop {
attempts += 1;
match client
.call_tool(&tool, serde_json::json!({ "v": arg }))
.await
{
Ok(r) => break r,
// The initialized-notification race (tower-mcp#1174)
// can also surface under load; count and retry it so
// the run completes AND quantifies the rate.
Err(e)
if attempts <= 10
&& e.to_string().contains("notifications/initialized") =>
{
handshake_retries += 1;
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(e) => panic!("call failed: {e}"),
}
};
let elapsed = t.elapsed();
assert!(!result.is_error, "echo must not fail");
if i >= WARMUP_PER_WORKER {
latencies.push(elapsed);
}
}
(latencies, handshake_retries + reconnects)
}));
}
barrier.wait().await;
let started = Instant::now();
let mut all = Vec::with_capacity(REQUESTS_PER_CELL);
let mut retries = 0usize;
for h in handles {
let (latencies, worker_retries) = h.await?;
all.extend(latencies);
retries += worker_retries;
}
let elapsed = started.elapsed();
all.sort();
Ok((elapsed, all, retries))
}
fn pct(sorted: &[Duration], p: f64) -> Duration {
let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
sorted[idx]
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let backend_url = start_backend().await?;
let minimal_url = start_proxy(&backend_url, false).await?;
let loaded_url = start_proxy(&backend_url, true).await?;
tokio::time::sleep(Duration::from_millis(100)).await;
let scenarios: &[(&str, &str, &str)] = &[
("direct", backend_url.as_str(), "echo"),
("proxied-minimal", minimal_url.as_str(), "b/echo"),
("proxied-loaded", loaded_url.as_str(), "b/echo"),
];
println!(
"{:<16} {:>4} {:>10} {:>10} {:>10} {:>10} {:>8}",
"scenario", "conc", "req/s", "p50", "p95", "p99", "resets"
);
for (name, url, tool) in scenarios {
for &concurrency in CONCURRENCY_LEVELS {
let (elapsed, latencies, retries) = run_cell(url, tool, concurrency).await?;
let throughput = latencies.len() as f64 / elapsed.as_secs_f64();
println!(
"{:<16} {:>4} {:>10.0} {:>10.2?} {:>10.2?} {:>10.2?} {:>8}",
name,
concurrency,
throughput,
pct(&latencies, 0.50),
pct(&latencies, 0.95),
pct(&latencies, 0.99),
retries,
);
}
}
Ok(())
}