Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ jobs:
run: cargo test --locked -j 2 --no-default-features --features rustls,http2,json,query,form,cookies,multipart,stream,blocking -- --test-threads=2
- name: Optional QUIC tracing
run: cargo check --locked -j 2 --features http3-qlog
- name: Compile cache benchmark
run: cargo check --locked -j 2 --no-default-features --features http3 --bench alt_svc_lookup
- name: Documentation
env:
RUSTDOCFLAGS: -D warnings
Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ include = [
"LICENSE-MIT",
"src/**/*.rs",
"examples/**/*.rs",
"benches/**/*.rs",
"tests/**"
]

Expand Down Expand Up @@ -188,6 +189,11 @@ num_cpus = "1.0"
libc = "0"
rcgen = "0.14"

[[bench]]
name = "alt_svc_lookup"
harness = false
required-features = ["http3"]

[[example]]
name = "blocking"
path = "examples/blocking.rs"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ Tests use local HTTPS/QUIC servers and SOCKS5 relays. External service tests are
explicitly ignored. [CI](.github/workflows/ci.yml) defines Linux, macOS, Windows,
MSRV, native TLS, and feature-profile checks.

Measure Alt-Svc candidate lookups with the production cache implementation:

```sh
cargo bench --locked -j 2 --no-default-features --features http3 --bench alt_svc_lookup -- --iterations 100000 --samples 9
```

The benchmark emits JSON records for empty, single-origin, and populated caches,
including four-thread shared-cache cases. Timings are aggregate wall time per
lookup; they exclude cache population, thread creation, and HTTP network I/O.

The [local dependency crates](crates/README.md) are committed in this workspace.
Quinn, its protocol engine, and its UDP adapter are sibling workspace crates. See [UPSTREAM.md](UPSTREAM.md) for pinned
source revisions, licenses, and maintenance boundaries.
Expand Down
186 changes: 186 additions & 0 deletions benches/alt_svc_lookup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Measure the production cache lookup without exposing a benchmark-only API.

#[allow(dead_code)]
#[path = "../src/alt_svc.rs"]
mod alt_svc;

use std::hint::black_box;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};

use alt_svc::AltSvcCache;
use http::{HeaderMap, StatusCode, Uri};

struct Case {
name: &'static str,
origins: usize,
alternatives: usize,
hit: bool,
threads: usize,
}

impl Case {
fn cache(&self) -> (AltSvcCache, Uri) {
let cache = AltSvcCache::with_capacity(self.origins);
let mut headers = HeaderMap::new();
let alternatives = (0..self.alternatives)
.map(|index| format!("h3=\":{}\";ma=3600", 8443 + index))
.collect::<Vec<_>>()
.join(", ");
headers.insert("alt-svc", alternatives.parse().unwrap());
for index in 0..self.origins {
cache.observe(
&format!("https://origin{index}.bench.invalid/")
.parse()
.unwrap(),
StatusCode::OK,
&headers,
Duration::ZERO,
);
}
let origin = if self.hit {
"https://origin0.bench.invalid/"
} else {
"https://missing.bench.invalid/"
}
.parse()
.unwrap();
assert_eq!(cache.len(), self.origins);
assert_eq!(
cache.candidates(&origin).len(),
if self.hit { self.alternatives } else { 0 }
);
(cache, origin)
}

fn sample(&self, cache: &AltSvcCache, origin: &Uri, iterations: usize) -> f64 {
let elapsed = if self.threads == 1 {
let started = Instant::now();
lookups(cache, origin, iterations);
started.elapsed()
} else {
std::thread::scope(|scope| {
let ready = Arc::new(Barrier::new(self.threads + 1));
let start = Arc::new(Barrier::new(self.threads + 1));
let workers: Vec<_> = (0..self.threads)
.map(|_| {
let ready = ready.clone();
let start = start.clone();
scope.spawn(move || {
ready.wait();
start.wait();
lookups(cache, origin, iterations);
})
})
.collect();
ready.wait();
let started = Instant::now();
start.wait();
for worker in workers {
worker.join().unwrap();
}
started.elapsed()
})
};
elapsed.as_secs_f64() * 1e9 / (iterations * self.threads) as f64
}
}

fn lookups(cache: &AltSvcCache, origin: &Uri, iterations: usize) {
for _ in 0..iterations {
black_box(cache.candidates(black_box(origin)));
}
}

fn main() {
let mut iterations = 50_000;
let mut samples = 7;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--iterations" => {
iterations = args
.next()
.expect("missing iteration count")
.parse()
.unwrap()
}
"--samples" => samples = args.next().expect("missing sample count").parse().unwrap(),
"--bench" => {}
_ => panic!("unknown argument: {arg}"),
}
}
assert!(iterations > 0 && samples > 0);

let cases = [
Case {
name: "empty_miss",
origins: 0,
alternatives: 0,
hit: false,
threads: 1,
},
Case {
name: "single_hit",
origins: 1,
alternatives: 1,
hit: true,
threads: 1,
},
Case {
name: "64_origins_hit",
origins: 64,
alternatives: 1,
hit: true,
threads: 1,
},
Case {
name: "256_origins_hit",
origins: 256,
alternatives: 1,
hit: true,
threads: 1,
},
Case {
name: "full_hit",
origins: 256,
alternatives: 8,
hit: true,
threads: 1,
},
Case {
name: "full_miss",
origins: 256,
alternatives: 8,
hit: false,
threads: 1,
},
Case {
name: "shared_full_hit",
origins: 256,
alternatives: 8,
hit: true,
threads: 4,
},
Case {
name: "shared_full_miss",
origins: 256,
alternatives: 8,
hit: false,
threads: 4,
},
];
for case in cases {
let (cache, origin) = case.cache();
lookups(&cache, &origin, 1_000);
let mut times: Vec<_> = (0..samples)
.map(|_| case.sample(&cache, &origin, iterations))
.collect();
times.sort_by(f64::total_cmp);
println!(
"{{\"case\":\"{}\",\"origins\":{},\"alternatives\":{},\"threads\":{},\"iterations_per_thread\":{},\"samples\":{},\"median_ns_per_lookup\":{:.2},\"min_ns_per_lookup\":{:.2},\"max_ns_per_lookup\":{:.2},\"ns_per_lookup_samples\":{:?}}}",
case.name, case.origins, case.alternatives, case.threads, iterations,
samples, times[samples / 2], times[0], times[samples - 1], times
);
}
}
94 changes: 88 additions & 6 deletions src/alt_svc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,18 @@ impl AltSvcCache {
return Vec::new();
};
let mut state = self.inner.lock().unwrap();
prune(&mut state, Instant::now());
state
.origins
.get(&key)
.map(|entry| entry.alternatives.clone())
.unwrap_or_default()
let Some(entry) = state.origins.get_mut(&key) else {
return Vec::new();
};
// Route selection only needs this origin. Writes and cache observers
// reclaim expired entries across the full, bounded cache.
let now = Instant::now();
entry.alternatives.retain(|alt| alt.expires > now);
if entry.alternatives.is_empty() {
state.origins.remove(&key);
return Vec::new();
}
entry.alternatives.clone()
}

#[cfg(test)]
Expand Down Expand Up @@ -551,6 +557,82 @@ mod tests {
);
}

fn expire(cache: &AltSvcCache, origin: &Uri, index: usize) {
cache
.inner
.lock()
.unwrap()
.origins
.get_mut(&origin_key(origin).unwrap())
.unwrap()
.alternatives[index]
.expires = Instant::now();
}

#[test]
fn candidate_lookup_expires_alternatives_and_preserves_backoff() {
let cache = AltSvcCache::new();
let origin: Uri = "https://example.test/".parse().unwrap();
learn(
&cache,
&origin.to_string(),
"h3=\":443\";ma=60, h3=\":8443\";ma=60;persist=1",
);
let alternatives = cache.candidates(&origin);
cache.mark_broken(&origin, &alternatives[1]);
expire(&cache, &origin, 0);

let live = cache.candidates(&"https://EXAMPLE.test:443/path".parse().unwrap());
assert_eq!(live.len(), 1);
assert_eq!(live[0].authority, "example.test:8443");
assert!(live[0].persist);
assert!(live[0].broken_until.unwrap() > Instant::now());

expire(&cache, &origin, 0);
assert!(cache.candidates(&origin).is_empty());
assert!(cache.is_empty());
}

#[test]
fn insertion_reclaims_unrelated_expired_origins_before_eviction() {
let cache = AltSvcCache::with_capacity(2);
let fresh: Uri = "https://fresh.test/".parse().unwrap();
let expired: Uri = "https://expired.test/".parse().unwrap();
learn(&cache, &fresh.to_string(), "h3=\":443\"");
learn(&cache, &expired.to_string(), "h3=\":443\"");
expire(&cache, &expired, 0);
assert_eq!(cache.candidates(&fresh).len(), 1);

let added: Uri = "https://added.test/".parse().unwrap();
learn(&cache, &added.to_string(), "h3=\":443\"");
assert_eq!(cache.len(), 2);
assert_eq!(cache.candidates(&fresh).len(), 1);
assert_eq!(cache.candidates(&added).len(), 1);
assert!(cache.candidates(&expired).is_empty());
}

#[test]
fn observers_prune_expired_origins_after_lookup_misses() {
for snapshot_first in [false, true] {
let cache = AltSvcCache::with_capacity(2);
learn(&cache, "https://fresh.test/", "h3=\":443\"");
let expired: Uri = "https://expired.test/".parse().unwrap();
learn(&cache, &expired.to_string(), "h3=\":443\"");
expire(&cache, &expired, 0);
assert!(cache
.candidates(&"https://missing.test/".parse().unwrap())
.is_empty());

if snapshot_first {
assert_eq!(cache.snapshot().len(), 1);
}
assert_eq!(cache.len(), 1);
let records = cache.snapshot();
assert_eq!(records.len(), 1);
assert_eq!(records[0].origin, "https://fresh.test:443");
}
}

#[test]
fn origin_scope_and_default_port_normalization() {
let cache = AltSvcCache::new();
Expand Down
Loading