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>
409 lines
12 KiB
Rust
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);
|