Files
foxhunt/services/api/benches/cache_performance.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
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>
2026-03-04 23:32:46 +01:00

409 lines
12 KiB
Rust

//! Cache Performance Benchmark
//!
//! Measures caching layer performance for:
//! - JWT validation cache (decoded tokens)
//! - RBAC permission cache
//! - Revocation list cache
//! - User session cache
//!
//! Targets:
//! - Cache hit: <100ns
//! - Cache miss: <1μs (with lookup)
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// Simple LRU cache implementation
struct LruCache<K: Clone + Eq + std::hash::Hash, V: Clone> {
cache: HashMap<K, (V, Instant)>,
capacity: usize,
ttl: Duration,
}
impl<K: Clone + Eq + std::hash::Hash, V: Clone> LruCache<K, V> {
fn new(capacity: usize, ttl: Duration) -> Self {
Self {
cache: HashMap::with_capacity(capacity),
capacity,
ttl,
}
}
fn get(&mut self, key: &K) -> Option<V> {
if let Some((value, timestamp)) = self.cache.get(key) {
// Check TTL
if timestamp.elapsed() < self.ttl {
return Some(value.clone());
} else {
// Expired
self.cache.remove(key);
}
}
None
}
fn put(&mut self, key: K, value: V) {
// Evict if at capacity
if self.cache.len() >= self.capacity {
// Simple eviction: remove first entry
if let Some(k) = self.cache.keys().next().cloned() {
self.cache.remove(&k);
}
}
self.cache.insert(key, (value, Instant::now()));
}
}
/// Thread-safe cache wrapper
struct ThreadSafeCache<K: Clone + Eq + std::hash::Hash, V: Clone> {
cache: Arc<RwLock<LruCache<K, V>>>,
}
impl<K: Clone + Eq + std::hash::Hash, V: Clone> ThreadSafeCache<K, V> {
fn new(capacity: usize, ttl: Duration) -> Self {
Self {
cache: Arc::new(RwLock::new(LruCache::new(capacity, ttl))),
}
}
fn get(&self, key: &K) -> Option<V> {
self.cache.write().expect("INVARIANT: RwLock should not be poisoned").get(key)
}
fn put(&self, key: K, value: V) {
self.cache.write().expect("INVARIANT: RwLock should not be poisoned").put(key, value);
}
}
/// JWT cache entry
#[derive(Clone, Debug)]
struct JwtCacheEntry {
user_id: String,
roles: Vec<String>,
permissions: Vec<String>,
exp: u64,
}
/// RBAC cache entry
#[derive(Clone, Debug)]
struct RbacCacheEntry {
permissions: Vec<String>,
}
/// Benchmark 1: JWT cache hit (TARGET: <100ns)
fn bench_jwt_cache_hit(c: &mut Criterion) {
let mut cache = LruCache::new(10_000, Duration::from_secs(300));
// Prepopulate cache
for i in 0..1000 {
let entry = JwtCacheEntry {
user_id: format!("user{}", i),
roles: vec!["trader".to_string()],
permissions: vec!["trade:read".to_string(), "trade:write".to_string()],
exp: 1234567890,
};
cache.put(format!("jwt_token_{}", i), entry);
}
c.bench_function("jwt_cache_hit", |b| {
b.iter(|| {
let key = format!("jwt_token_{}", black_box(500));
let entry = cache.get(&key);
black_box(entry);
});
});
}
/// Benchmark 2: JWT cache miss (with decode simulation)
fn bench_jwt_cache_miss(c: &mut Criterion) {
let mut cache = LruCache::new(10_000, Duration::from_secs(300));
c.bench_function("jwt_cache_miss_with_decode", |b| {
b.iter(|| {
let key = format!("jwt_token_{}", black_box(9999));
let entry = cache.get(&key);
if entry.is_none() {
// Simulate JWT decode (expensive operation)
let decoded = JwtCacheEntry {
user_id: "user9999".to_string(),
roles: vec!["trader".to_string()],
permissions: vec!["trade:read".to_string()],
exp: 1234567890,
};
cache.put(key.clone(), decoded.clone());
black_box(decoded);
}
});
});
}
/// Benchmark 3: RBAC cache hit (TARGET: <100ns)
fn bench_rbac_cache_hit(c: &mut Criterion) {
let mut cache = LruCache::new(10_000, Duration::from_secs(300));
// Prepopulate with permissions
for i in 0..1000 {
let entry = RbacCacheEntry {
permissions: vec![
"trade:read".to_string(),
"trade:write".to_string(),
"portfolio:read".to_string(),
],
};
cache.put(format!("user{}", i), entry);
}
c.bench_function("rbac_cache_hit", |b| {
b.iter(|| {
let key = format!("user{}", black_box(500));
let entry = cache.get(&key);
black_box(entry);
});
});
}
/// Benchmark 4: Different cache sizes
fn bench_cache_sizes(c: &mut Criterion) {
let mut group = c.benchmark_group("cache_size_impact");
for size in &[100, 1_000, 10_000, 100_000] {
let mut cache = LruCache::new(*size, Duration::from_secs(300));
// Prepopulate to capacity
for i in 0..*size {
cache.put(format!("key{}", i), format!("value{}", i));
}
group.bench_with_input(BenchmarkId::new("cache_lookup", size), size, |b, &n| {
b.iter(|| {
let key = format!("key{}", black_box(n / 2));
let value = cache.get(&key);
black_box(value);
});
});
}
group.finish();
}
/// Benchmark 5: Cache eviction overhead
fn bench_cache_eviction(c: &mut Criterion) {
let mut cache = LruCache::new(100, Duration::from_secs(300));
// Fill to capacity
for i in 0..100 {
cache.put(format!("key{}", i), format!("value{}", i));
}
c.bench_function("cache_eviction_on_insert", |b| {
let mut counter = 100;
b.iter(|| {
cache.put(
format!("key{}", black_box(counter)),
format!("value{}", counter),
);
counter += 1;
});
});
}
/// Benchmark 6: TTL expiration check overhead
fn bench_ttl_expiration(c: &mut Criterion) {
let mut cache_short = LruCache::new(1000, Duration::from_millis(1));
let mut cache_long = LruCache::new(1000, Duration::from_secs(300));
// Prepopulate
for i in 0..100 {
cache_short.put(format!("key{}", i), format!("value{}", i));
cache_long.put(format!("key{}", i), format!("value{}", i));
}
let mut group = c.benchmark_group("ttl_expiration");
group.bench_function("short_ttl_1ms", |b| {
b.iter(|| {
let key = format!("key{}", black_box(50));
let value = cache_short.get(&key);
black_box(value);
});
});
group.bench_function("long_ttl_300s", |b| {
b.iter(|| {
let key = format!("key{}", black_box(50));
let value = cache_long.get(&key);
black_box(value);
});
});
group.finish();
}
/// Benchmark 7: Thread-safe cache with RwLock
fn bench_thread_safe_cache(c: &mut Criterion) {
let cache = ThreadSafeCache::new(10_000, Duration::from_secs(300));
// Prepopulate
for i in 0..1000 {
cache.put(format!("key{}", i), format!("value{}", i));
}
let mut group = c.benchmark_group("thread_safe_cache");
group.bench_function("read_lock_cache_hit", |b| {
b.iter(|| {
let key = format!("key{}", black_box(500));
let value = cache.get(&key);
black_box(value);
});
});
group.bench_function("write_lock_cache_miss", |b| {
let mut counter = 1000;
b.iter(|| {
let key = format!("key{}", black_box(counter));
let value = cache.get(&key);
if value.is_none() {
cache.put(key.clone(), format!("value{}", counter));
}
counter += 1;
});
});
group.finish();
}
/// Benchmark 8: Hot/cold cache patterns
fn bench_hot_cold_patterns(c: &mut Criterion) {
let mut group = c.benchmark_group("hot_cold_patterns");
// Hot cache: Small working set
let mut hot_cache = LruCache::new(10_000, Duration::from_secs(300));
for i in 0..10 {
hot_cache.put(format!("hot_key{}", i), format!("value{}", i));
}
group.bench_function("hot_cache_10_keys", |b| {
b.iter(|| {
let key = format!("hot_key{}", black_box(5));
let value = hot_cache.get(&key);
black_box(value);
});
});
// Cold cache: Large working set with misses
let mut cold_cache = LruCache::new(100, Duration::from_secs(300));
for i in 0..100 {
cold_cache.put(format!("cold_key{}", i), format!("value{}", i));
}
let mut counter = 0;
group.bench_function("cold_cache_100_keys_rotating", |b| {
b.iter(|| {
counter += 1;
let key = format!("cold_key{}", black_box(counter % 150));
let value = cold_cache.get(&key);
if value.is_none() {
cold_cache.put(key.clone(), format!("value{}", counter));
}
black_box(value);
});
});
group.finish();
}
/// Benchmark 9: Multi-tier cache (L1 + L2)
fn bench_multi_tier_cache(c: &mut Criterion) {
let l1_cache = Arc::new(RwLock::new(LruCache::<String, String>::new(
100,
Duration::from_secs(60),
)));
let l2_cache = Arc::new(RwLock::new(LruCache::<String, String>::new(
10_000,
Duration::from_secs(300),
)));
// Prepopulate L2
for i in 0..1000 {
l2_cache
.write()
.unwrap()
.put(format!("key{}", i), format!("value{}", i));
}
// Prepopulate L1 with hot keys
for i in 0..50 {
l1_cache
.write()
.unwrap()
.put(format!("key{}", i), format!("value{}", i));
}
c.bench_function("multi_tier_cache_l1_hit", |b| {
b.iter(|| {
let key = format!("key{}", black_box(25));
// Check L1
let value = l1_cache.write().expect("INVARIANT: RwLock should not be poisoned").get(&key);
if value.is_none() {
// Check L2
if let Some(v) = l2_cache.write().expect("INVARIANT: RwLock should not be poisoned").get(&key) {
l1_cache.write().expect("INVARIANT: RwLock should not be poisoned").put(key, v.clone());
black_box(v);
}
} else {
black_box(value);
}
});
});
}
/// Benchmark 10: Revocation list lookup
fn bench_revocation_list(c: &mut Criterion) {
// Simulate revocation list as HashSet
let mut revoked_tokens = std::collections::HashSet::new();
for i in 0..10000 {
revoked_tokens.insert(format!("revoked_token_{}", i));
}
let mut group = c.benchmark_group("revocation_list");
group.bench_function("revoked_token_hit", |b| {
b.iter(|| {
let jti = format!("revoked_token_{}", black_box(5000));
let is_revoked = revoked_tokens.contains(&jti);
black_box(is_revoked);
});
});
group.bench_function("revoked_token_miss", |b| {
b.iter(|| {
let jti = format!("valid_token_{}", black_box(5000));
let is_revoked = revoked_tokens.contains(&jti);
black_box(is_revoked);
});
});
group.finish();
}
criterion_group!(
cache_benches,
bench_jwt_cache_hit,
bench_jwt_cache_miss,
bench_rbac_cache_hit,
bench_cache_sizes,
bench_cache_eviction,
bench_ttl_expiration,
bench_thread_safe_cache,
bench_hot_cold_patterns,
bench_multi_tier_cache,
bench_revocation_list
);
criterion_main!(cache_benches);