use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{Empty, Full};
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use plecto_control::{Control, Host, Manifest, MemoryStore};
use plecto_host::test_support::TestSigner;
use plecto_server::serve;
async fn spawn_labeled_upstream(label: &'static str) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(move |_req| async move {
Ok::<Response<Full<Bytes>>, Infallible>(
Response::builder()
.status(200)
.header("x-instance", label)
.body(Full::new(Bytes::from_static(b"ok")))
.unwrap(),
)
}),
)
.await;
});
}
});
addr
}
async fn spawn_proxy(control: Arc<Control>) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(control, listener).await;
});
addr
}
fn control_for(addresses: &[SocketAddr], lb: &str) -> Arc<Control> {
let signer = TestSigner::new().unwrap();
let addrs = addresses
.iter()
.map(|a| format!("\"{a}\""))
.collect::<Vec<_>>()
.join(", ");
let toml = format!(
r#"
[[upstream]]
name = "pool"
addresses = [{addrs}]
{lb}
[upstream.health]
path = "/healthz"
interval_ms = 50
timeout_ms = 100
[[route]]
upstream = "pool"
[route.match]
path_prefix = "/api"
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
Arc::new(Control::load(host, &manifest, Box::new(MemoryStore::new())).unwrap())
}
fn client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
async fn get(
client: &Client<HttpConnector, Empty<Bytes>>,
proxy: SocketAddr,
user: Option<&str>,
) -> (StatusCode, Option<String>) {
let mut req = Request::builder()
.method("GET")
.uri(format!("http://{proxy}/api/x"));
if let Some(u) = user {
req = req.header("x-user", u);
}
let resp = client
.request(req.body(Empty::<Bytes>::new()).unwrap())
.await
.expect("proxy request");
let instance = resp
.headers()
.get("x-instance")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
(resp.status(), instance)
}
async fn warm_up(client: &Client<HttpConnector, Empty<Bytes>>, proxy: SocketAddr, n: usize) {
let mut seen = HashSet::new();
for i in 0..400 {
let (status, instance) = get(client, proxy, Some(&format!("warm-{i}"))).await;
if status == StatusCode::OK
&& let Some(label) = instance
{
seen.insert(label);
}
if seen.len() >= n {
return;
}
tokio::time::sleep(Duration::from_millis(15)).await;
}
panic!("only {}/{n} instances became healthy in time", seen.len());
}
#[tokio::test]
async fn least_request_forwards_and_spreads_under_concurrency() {
let a = spawn_labeled_upstream("a").await;
let b = spawn_labeled_upstream("b").await;
let proxy = spawn_proxy(control_for(&[a, b], "lb_algorithm = \"least_request\"")).await;
let client = client();
for _ in 0..400 {
if get(&client, proxy, None).await.0 == StatusCode::OK {
break;
}
tokio::time::sleep(Duration::from_millis(15)).await;
}
let mut seen = HashSet::new();
for _ in 0..20 {
let handles: Vec<_> = (0..16)
.map(|_| {
let c = client.clone();
tokio::spawn(async move { get(&c, proxy, None).await })
})
.collect();
for h in handles {
let (status, instance) = h.await.unwrap();
assert_eq!(status, StatusCode::OK, "least-request forwards a 200");
if let Some(label) = instance {
seen.insert(label);
}
}
if seen.len() >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
seen,
HashSet::from(["a".to_string(), "b".to_string()]),
"under concurrent load least-request spreads across both healthy instances"
);
}
#[tokio::test]
async fn maglev_header_key_pins_each_user_to_one_instance() {
let a = spawn_labeled_upstream("a").await;
let b = spawn_labeled_upstream("b").await;
let c = spawn_labeled_upstream("c").await;
let lb = "lb_algorithm = \"maglev\"\n[upstream.hash]\nkey = \"header\"\nheader = \"x-user\"\ntable_size = 97";
let proxy = spawn_proxy(control_for(&[a, b, c], lb)).await;
let client = client();
warm_up(&client, proxy, 3).await;
let users = ["alice", "bob", "carol", "dave", "erin", "frank"];
let mut pinned: HashMap<&str, String> = HashMap::new();
for u in users {
let (status, instance) = get(&client, proxy, Some(u)).await;
assert_eq!(status, StatusCode::OK);
pinned.insert(u, instance.expect("a labeled instance served"));
}
for _ in 0..5 {
for u in users {
let (_, instance) = get(&client, proxy, Some(u)).await;
assert_eq!(
instance.as_deref(),
Some(pinned[u].as_str()),
"user {u} must always reach its pinned instance (affinity)"
);
}
}
let distinct: HashSet<&String> = pinned.values().collect();
assert!(
distinct.len() >= 2,
"distinct users must spread across the pool, got {pinned:?}"
);
}
#[tokio::test]
async fn maglev_falls_back_when_the_header_is_absent() {
let a = spawn_labeled_upstream("a").await;
let b = spawn_labeled_upstream("b").await;
let lb = "lb_algorithm = \"maglev\"\n[upstream.hash]\nkey = \"header\"\nheader = \"x-user\"\ntable_size = 97";
let proxy = spawn_proxy(control_for(&[a, b], lb)).await;
let client = client();
warm_up(&client, proxy, 2).await;
let mut seen = HashSet::new();
for _ in 0..40 {
let (status, instance) = get(&client, proxy, None).await;
assert_eq!(
status,
StatusCode::OK,
"keyless maglev still serves (no 503)"
);
if let Some(label) = instance {
seen.insert(label);
}
}
assert_eq!(
seen.len(),
2,
"without a hash key maglev round-robins across the pool"
);
}