//! Bounded, TTL-based protocol detection cache for HTTP/2 auto-detection. //! //! Caches the ALPN-negotiated protocol (H1 or H2) per backend endpoint (host:port). //! Prevents repeated ALPN probes for backends whose protocol is already known. use std::sync::Arc; use std::time::{Duration, Instant}; use dashmap::DashMap; use tracing::debug; /// TTL for cached protocol detection results. /// After this duration, the next request will re-probe the backend. const PROTOCOL_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes /// Maximum number of entries in the protocol cache. /// Prevents unbounded growth when backends come and go. const PROTOCOL_CACHE_MAX_ENTRIES: usize = 4096; /// Background cleanup interval for the protocol cache. const PROTOCOL_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); /// Detected backend protocol. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DetectedProtocol { H1, H2, } /// Key for the protocol cache: (host, port). #[derive(Clone, Debug, Hash, Eq, PartialEq)] pub struct ProtocolCacheKey { pub host: String, pub port: u16, } /// A cached protocol detection result with a timestamp. struct CachedEntry { protocol: DetectedProtocol, detected_at: Instant, } /// Bounded, TTL-based protocol detection cache. /// /// Memory safety guarantees: /// - Hard cap at `PROTOCOL_CACHE_MAX_ENTRIES` — cannot grow unboundedly. /// - TTL expiry — stale entries naturally age out on lookup. /// - Background cleanup task — proactively removes expired entries every 60s. /// - `clear()` — called on route updates to discard stale detections. /// - `Drop` — aborts the background task to prevent dangling tokio tasks. pub struct ProtocolCache { cache: Arc>, cleanup_handle: Option>, } impl ProtocolCache { /// Create a new protocol cache and start the background cleanup task. pub fn new() -> Self { let cache: Arc> = Arc::new(DashMap::new()); let cache_clone = Arc::clone(&cache); let cleanup_handle = tokio::spawn(async move { Self::cleanup_loop(cache_clone).await; }); Self { cache, cleanup_handle: Some(cleanup_handle), } } /// Look up the cached protocol for a backend endpoint. /// Returns `None` if not cached or expired (caller should probe via ALPN). pub fn get(&self, key: &ProtocolCacheKey) -> Option { let entry = self.cache.get(key)?; if entry.detected_at.elapsed() < PROTOCOL_CACHE_TTL { debug!("Protocol cache hit: {:?} for {}:{}", entry.protocol, key.host, key.port); Some(entry.protocol) } else { // Expired — remove and return None to trigger re-probe drop(entry); // release DashMap ref before remove self.cache.remove(key); None } } /// Insert a detected protocol into the cache. /// If the cache is at capacity, evict the oldest entry first. pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) { if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) { // Evict the oldest entry to stay within bounds let oldest = self.cache.iter() .min_by_key(|entry| entry.value().detected_at) .map(|entry| entry.key().clone()); if let Some(oldest_key) = oldest { self.cache.remove(&oldest_key); } } self.cache.insert(key, CachedEntry { protocol, detected_at: Instant::now(), }); } /// Clear all entries. Called on route updates to discard stale detections. pub fn clear(&self) { self.cache.clear(); } /// Background cleanup loop — removes expired entries every `PROTOCOL_CACHE_CLEANUP_INTERVAL`. async fn cleanup_loop(cache: Arc>) { let mut interval = tokio::time::interval(PROTOCOL_CACHE_CLEANUP_INTERVAL); loop { interval.tick().await; let expired: Vec = cache.iter() .filter(|entry| entry.value().detected_at.elapsed() >= PROTOCOL_CACHE_TTL) .map(|entry| entry.key().clone()) .collect(); if !expired.is_empty() { debug!("Protocol cache cleanup: removing {} expired entries", expired.len()); for key in expired { cache.remove(&key); } } } } } impl Drop for ProtocolCache { fn drop(&mut self) { if let Some(handle) = self.cleanup_handle.take() { handle.abort(); } } }