Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
12 KiB
Rust
383 lines
12 KiB
Rust
//! Revocation Cache Performance Benchmark
|
|
//!
|
|
//! Measures the performance impact of local revocation cache:
|
|
//! - Cache hit latency: TARGET <10ns
|
|
//! - Cache miss latency: ~500μs (Redis network)
|
|
//! - Cache hit rate: TARGET >95%
|
|
//! - Memory overhead: Minimal with TTL expiration
|
|
//!
|
|
//! This benchmark compares:
|
|
//! 1. Direct Redis calls (no cache)
|
|
//! 2. Local DashMap cache with 60s TTL
|
|
//! 3. Cache behavior under different access patterns
|
|
|
|
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
|
use dashmap::DashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Simulated revocation result
|
|
#[derive(Debug, Clone)]
|
|
struct CachedRevocationResult {
|
|
is_revoked: bool,
|
|
cached_at: Instant,
|
|
}
|
|
|
|
/// Local revocation cache implementation
|
|
struct LocalRevocationCache {
|
|
cache: Arc<DashMap<String, CachedRevocationResult>>,
|
|
ttl: Duration,
|
|
hits: Arc<std::sync::atomic::AtomicU64>,
|
|
misses: Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
impl LocalRevocationCache {
|
|
fn new(ttl: Duration) -> Self {
|
|
Self {
|
|
cache: Arc::new(DashMap::new()),
|
|
ttl,
|
|
hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
misses: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
}
|
|
}
|
|
|
|
fn check_revoked(&self, token_id: &str, simulate_redis_latency: bool) -> bool {
|
|
// Check cache first
|
|
if let Some(entry) = self.cache.get(token_id) {
|
|
if entry.cached_at.elapsed() < self.ttl {
|
|
// Cache hit
|
|
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return entry.is_revoked;
|
|
} else {
|
|
// Expired - remove it
|
|
drop(entry);
|
|
self.cache.remove(token_id);
|
|
}
|
|
}
|
|
|
|
// Cache miss - simulate Redis lookup
|
|
self.misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
if simulate_redis_latency {
|
|
// Simulate 500μs Redis network latency
|
|
std::thread::sleep(Duration::from_micros(500));
|
|
}
|
|
|
|
// Simulate Redis result (99% not revoked in production)
|
|
let is_revoked = token_id.contains("revoked");
|
|
|
|
// Update cache
|
|
self.cache.insert(
|
|
token_id.to_string(),
|
|
CachedRevocationResult {
|
|
is_revoked,
|
|
cached_at: Instant::now(),
|
|
},
|
|
);
|
|
|
|
is_revoked
|
|
}
|
|
|
|
fn stats(&self) -> (u64, u64, f64) {
|
|
let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
|
|
let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed);
|
|
let total = hits + misses;
|
|
let hit_rate = if total > 0 {
|
|
(hits as f64 / total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
(hits, misses, hit_rate)
|
|
}
|
|
}
|
|
|
|
/// Benchmark 1: Cache hit latency (TARGET: <10ns)
|
|
fn bench_cache_hit_latency(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Prepopulate cache with 1000 tokens
|
|
for i in 0..1000 {
|
|
let token_id = format!("token_{}", i);
|
|
cache.check_revoked(&token_id, false); // Prime cache without latency
|
|
}
|
|
|
|
c.bench_function("revocation_cache_hit", |b| {
|
|
b.iter(|| {
|
|
let token_id = format!("token_{}", black_box(500));
|
|
let is_revoked = cache.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Benchmark 2: Cache miss latency (with simulated Redis)
|
|
fn bench_cache_miss_latency(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
c.bench_function("revocation_cache_miss_with_redis", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
let token_id = format!("new_token_{}", black_box(counter));
|
|
let is_revoked = cache.check_revoked(&token_id, true); // Simulate Redis latency
|
|
black_box(is_revoked);
|
|
counter += 1;
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Benchmark 3: Hot token pattern (95% cache hit rate)
|
|
fn bench_hot_token_pattern(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Prepopulate with hot tokens
|
|
for i in 0..10 {
|
|
let token_id = format!("hot_token_{}", i);
|
|
cache.check_revoked(&token_id, false);
|
|
}
|
|
|
|
c.bench_function("hot_token_pattern_95pct_hits", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
// 95% hits (tokens 0-9), 5% misses (tokens 10-19)
|
|
let token_num = if counter % 20 < 19 {
|
|
counter % 10
|
|
} else {
|
|
10 + (counter % 10)
|
|
};
|
|
let token_id = format!("hot_token_{}", black_box(token_num));
|
|
let is_revoked = cache.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
counter += 1;
|
|
});
|
|
});
|
|
|
|
// Report statistics
|
|
let (hits, misses, hit_rate) = cache.stats();
|
|
println!(
|
|
"\nHot token pattern stats: {} hits, {} misses, {:.2}% hit rate",
|
|
hits, misses, hit_rate
|
|
);
|
|
}
|
|
|
|
/// Benchmark 4: TTL expiration behavior
|
|
fn bench_ttl_expiration(c: &mut Criterion) {
|
|
let mut group = c.benchmark_group("ttl_expiration");
|
|
|
|
// Short TTL (1ms) - high expiration rate
|
|
let cache_short = LocalRevocationCache::new(Duration::from_millis(1));
|
|
for i in 0..100 {
|
|
cache_short.check_revoked(&format!("token_{}", i), false);
|
|
}
|
|
|
|
group.bench_function("1ms_ttl", |b| {
|
|
b.iter(|| {
|
|
let token_id = format!("token_{}", black_box(50));
|
|
let is_revoked = cache_short.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
|
|
// Long TTL (60s) - low expiration rate
|
|
let cache_long = LocalRevocationCache::new(Duration::from_secs(60));
|
|
for i in 0..100 {
|
|
cache_long.check_revoked(&format!("token_{}", i), false);
|
|
}
|
|
|
|
group.bench_function("60s_ttl", |b| {
|
|
b.iter(|| {
|
|
let token_id = format!("token_{}", black_box(50));
|
|
let is_revoked = cache_long.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
|
|
group.finish();
|
|
}
|
|
|
|
/// Benchmark 5: Cache size impact
|
|
fn bench_cache_size_impact(c: &mut Criterion) {
|
|
let mut group = c.benchmark_group("cache_size_impact");
|
|
|
|
for size in &[100, 1_000, 10_000, 100_000] {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Prepopulate to target size
|
|
for i in 0..*size {
|
|
cache.check_revoked(&format!("token_{}", i), false);
|
|
}
|
|
|
|
group.bench_with_input(BenchmarkId::new("lookup", size), size, |b, &n| {
|
|
b.iter(|| {
|
|
let token_id = format!("token_{}", black_box(n / 2));
|
|
let is_revoked = cache.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
}
|
|
|
|
group.finish();
|
|
}
|
|
|
|
/// Benchmark 6: Concurrent access pattern
|
|
fn bench_concurrent_access(c: &mut Criterion) {
|
|
let cache = Arc::new(LocalRevocationCache::new(Duration::from_secs(60)));
|
|
|
|
// Prepopulate
|
|
for i in 0..1000 {
|
|
cache.check_revoked(&format!("token_{}", i), false);
|
|
}
|
|
|
|
c.bench_function("concurrent_cache_access", |b| {
|
|
b.iter(|| {
|
|
let cache_clone = cache.clone();
|
|
let token_id = format!("token_{}", black_box(500));
|
|
let is_revoked = cache_clone.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Benchmark 7: Mixed revoked/valid token pattern
|
|
fn bench_mixed_revocation(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Prepopulate with mix of revoked and valid tokens
|
|
for i in 0..1000 {
|
|
let token_id = if i % 10 == 0 {
|
|
format!("revoked_token_{}", i) // 10% revoked
|
|
} else {
|
|
format!("valid_token_{}", i) // 90% valid
|
|
};
|
|
cache.check_revoked(&token_id, false);
|
|
}
|
|
|
|
c.bench_function("mixed_revocation_pattern", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
let token_id = if counter % 10 == 0 {
|
|
format!("revoked_token_{}", black_box(counter))
|
|
} else {
|
|
format!("valid_token_{}", black_box(counter))
|
|
};
|
|
let is_revoked = cache.check_revoked(&token_id, false);
|
|
black_box(is_revoked);
|
|
counter = (counter + 1) % 1000;
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Benchmark 8: Cache vs no-cache comparison
|
|
fn bench_cache_vs_no_cache(c: &mut Criterion) {
|
|
let mut group = c.benchmark_group("cache_vs_no_cache");
|
|
|
|
// No cache - direct Redis simulation
|
|
group.bench_function("no_cache_direct_redis", |b| {
|
|
b.iter(|| {
|
|
// Simulate 500μs Redis latency every time
|
|
std::thread::sleep(Duration::from_micros(500));
|
|
let is_revoked = false; // Simulate result
|
|
black_box(is_revoked);
|
|
});
|
|
});
|
|
|
|
// With cache - 95% hit rate
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
for i in 0..10 {
|
|
cache.check_revoked(&format!("token_{}", i), false);
|
|
}
|
|
|
|
group.bench_function("with_cache_95pct_hits", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
let token_num = if counter % 20 < 19 {
|
|
counter % 10 // Hit
|
|
} else {
|
|
10 + (counter % 10) // Miss
|
|
};
|
|
let token_id = format!("token_{}", black_box(token_num));
|
|
let is_revoked = cache.check_revoked(&token_id, counter % 20 >= 19); // Only simulate latency on misses
|
|
black_box(is_revoked);
|
|
counter += 1;
|
|
});
|
|
});
|
|
|
|
group.finish();
|
|
}
|
|
|
|
/// Benchmark 9: Memory overhead measurement
|
|
fn bench_memory_overhead(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
c.bench_function("cache_entry_insertion", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
let token_id = format!("token_{}", black_box(counter));
|
|
cache.check_revoked(&token_id, false);
|
|
counter += 1;
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Benchmark 10: Realistic production workload
|
|
fn bench_production_workload(c: &mut Criterion) {
|
|
let cache = LocalRevocationCache::new(Duration::from_secs(60));
|
|
|
|
// Simulate production: 1000 active users, 95% cache hit rate, 1% revoked
|
|
for i in 0..1000 {
|
|
let token_id = if i % 100 == 0 {
|
|
format!("revoked_token_{}", i)
|
|
} else {
|
|
format!("valid_token_{}", i)
|
|
};
|
|
cache.check_revoked(&token_id, false);
|
|
}
|
|
|
|
c.bench_function("production_workload_simulation", |b| {
|
|
let mut counter = 0;
|
|
b.iter(|| {
|
|
// 95% hits to existing tokens, 5% new tokens
|
|
let token_id = if counter % 20 < 19 {
|
|
// Cache hit - existing token
|
|
let idx = counter % 1000;
|
|
if idx % 100 == 0 {
|
|
format!("revoked_token_{}", idx)
|
|
} else {
|
|
format!("valid_token_{}", idx)
|
|
}
|
|
} else {
|
|
// Cache miss - new token
|
|
format!("new_token_{}", counter)
|
|
};
|
|
|
|
let simulate_latency = counter % 20 >= 19;
|
|
let is_revoked = cache.check_revoked(&token_id, simulate_latency);
|
|
black_box(is_revoked);
|
|
counter += 1;
|
|
});
|
|
});
|
|
|
|
// Report final statistics
|
|
let (hits, misses, hit_rate) = cache.stats();
|
|
println!(
|
|
"\nProduction workload stats: {} hits, {} misses, {:.2}% hit rate",
|
|
hits, misses, hit_rate
|
|
);
|
|
}
|
|
|
|
criterion_group!(
|
|
revocation_cache_benches,
|
|
bench_cache_hit_latency,
|
|
bench_cache_miss_latency,
|
|
bench_hot_token_pattern,
|
|
bench_ttl_expiration,
|
|
bench_cache_size_impact,
|
|
bench_concurrent_access,
|
|
bench_mixed_revocation,
|
|
bench_cache_vs_no_cache,
|
|
bench_memory_overhead,
|
|
bench_production_workload
|
|
);
|
|
|
|
criterion_main!(revocation_cache_benches);
|