Skip to main content

network_protocol/transport/
session_cache.rs

1//! # TLS Session Cache
2//!
3//! This module provides in-memory TLS session ticket caching for session resumption.
4//! Session resumption reduces the handshake overhead by ~50-70%, allowing clients to
5//! reconnect without full TLS 1.3 handshakes.
6//!
7//! ## Features
8//! - **Thread-safe**: Uses Arc<Mutex<>> for safe concurrent access
9//! - **TTL-based expiration**: Sessions expire after configurable duration
10//! - **Memory-bounded**: Configurable maximum entries to prevent unbounded growth
11//! - **Non-blocking lookups**: Fast session retrieval without blocking other operations
12//!
13//! ## Performance
14//! - Lookup: ~100-200ns (HashMap)
15//! - Insertion: ~500-1000ns (with lock contention)
16//! - Eviction: O(n) for expired entries, but typically O(1) for normal lookups
17//!
18//! ## Usage
19//! ```ignore
20//! use network_protocol::transport::session_cache::SessionCache;
21//! use std::time::Duration;
22//!
23//! // Create a cache with 1000 max entries and 1-hour TTL
24//! let cache = SessionCache::new(1000, Duration::from_secs(3600));
25//!
26//! // Store a session (typically handled internally by TLS layer)
27//! cache.store(session_id.clone(), ticket.clone()).await;
28//!
29//! // Retrieve for resumption (returned as Arc<Vec<u8>>)
30//! if let Some(ticket) = cache.get(&session_id).await {
31//!     // Use ticket for session resumption
32//! }
33//! ```
34
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::{Duration, SystemTime};
38
39use tokio::sync::Mutex;
40use tracing::{debug, trace};
41
42/// A cached TLS session ticket with metadata
43#[derive(Clone, Debug)]
44struct SessionEntry {
45    /// The serialized session ticket from rustls
46    ticket: Arc<Vec<u8>>,
47    /// When this session was cached
48    created_at: SystemTime,
49    /// Time-to-live for this session
50    ttl: Duration,
51}
52
53impl SessionEntry {
54    /// Check if this entry has expired
55    fn is_expired(&self) -> bool {
56        match self.created_at.elapsed() {
57            Ok(elapsed) => elapsed > self.ttl,
58            Err(_) => true, // System time went backward, treat as expired
59        }
60    }
61}
62
63/// Thread-safe in-memory TLS session cache
64///
65/// Stores session tickets for TLS 1.3 resumption. This enables clients to reconnect
66/// without performing full handshakes, reducing latency by 50-70%.
67///
68/// Pass the same cache (in an `Arc`) to every
69/// [`TlsClient::connect_with_session`](crate::service::tls_client::TlsClient::connect_with_session)
70/// call: the TLS layer stores the server's session tickets in it and offers them on
71/// the next connection to the same server name. Those TLS tickets are kept in a
72/// rustls session store bounded by `max_entries` and expire as the server's ticket
73/// lifetime says; `default_ttl` applies to entries added with [`store`](Self::store).
74///
75/// rustls only resumes a session with the same certificate verifier that checked
76/// the original connection, so the cache also keeps the TLS client configuration
77/// built for each distinct `TlsClientConfig` and reuses it. Certificate, key and CA
78/// files are therefore read on the first connection with a given configuration; call
79/// [`clear`](Self::clear) after replacing those files.
80#[derive(Clone)]
81pub struct SessionCache {
82    /// Maximum number of sessions to cache
83    max_entries: usize,
84    /// Default TTL for new sessions
85    default_ttl: Duration,
86    /// Inner cache protected by mutex
87    inner: Arc<Mutex<SessionCacheInner>>,
88    /// rustls session store and client configs used for TLS resumption. Reset by
89    /// `clear()`.
90    tls: Arc<std::sync::Mutex<TlsResumption>>,
91}
92
93/// TLS resumption state shared by every connection that uses one `SessionCache`.
94struct TlsResumption {
95    store: Arc<rustls::client::ClientSessionMemoryCache>,
96    /// Built client configs, keyed by `TlsClientConfig::resumption_key()`
97    configs: HashMap<String, Arc<rustls::ClientConfig>>,
98}
99
100impl TlsResumption {
101    fn new(max_entries: usize) -> Self {
102        Self {
103            store: Arc::new(rustls::client::ClientSessionMemoryCache::new(
104                max_entries.max(1),
105            )),
106            configs: HashMap::new(),
107        }
108    }
109}
110
111struct SessionCacheInner {
112    /// Session ID -> cached ticket
113    sessions: HashMap<String, SessionEntry>,
114    /// Metadata for eviction policies
115    total_inserts: u64,
116}
117
118impl SessionCache {
119    /// Create a new session cache
120    ///
121    /// # Arguments
122    /// * `max_entries` - Maximum number of sessions to cache (e.g., 1000)
123    /// * `default_ttl` - Default time-to-live for each session (e.g., 1 hour)
124    ///
125    /// # Example
126    /// ```ignore
127    /// let cache = SessionCache::new(1000, Duration::from_secs(3600));
128    /// ```
129    pub fn new(max_entries: usize, default_ttl: Duration) -> Self {
130        Self {
131            max_entries,
132            default_ttl,
133            tls: Arc::new(std::sync::Mutex::new(TlsResumption::new(max_entries))),
134            inner: Arc::new(Mutex::new(SessionCacheInner {
135                sessions: HashMap::with_capacity(max_entries),
136                total_inserts: 0,
137            })),
138        }
139    }
140
141    /// Store a session ticket in the cache
142    ///
143    /// This is typically called by the TLS layer after establishing a connection.
144    /// Automatically manages eviction when cache is full.
145    ///
146    /// # Arguments
147    /// * `session_id` - Unique session identifier
148    /// * `ticket` - Serialized TLS session ticket
149    pub async fn store<S: Into<String>>(&self, session_id: S, ticket: Vec<u8>) {
150        let mut inner = self.inner.lock().await;
151
152        let session_id = session_id.into();
153        let entry = SessionEntry {
154            ticket: Arc::new(ticket),
155            created_at: SystemTime::now(),
156            ttl: self.default_ttl,
157        };
158
159        // Clean expired entries before checking capacity
160        self.evict_expired(&mut inner);
161
162        // Store the session
163        inner.sessions.insert(session_id.clone(), entry);
164        inner.total_inserts += 1;
165
166        // Evict oldest if we exceed capacity
167        if inner.sessions.len() > self.max_entries {
168            self.evict_oldest(&mut inner);
169        }
170
171        trace!(
172            session_count = inner.sessions.len(),
173            "Session stored in cache"
174        );
175    }
176
177    /// Retrieve a session ticket from the cache
178    ///
179    /// Returns the ticket if found and not expired, None otherwise.
180    ///
181    /// # Example
182    /// ```ignore
183    /// if let Some(ticket) = cache.get("session-123").await {
184    ///     // Use ticket for resumption
185    /// }
186    /// ```
187    pub async fn get(&self, session_id: &str) -> Option<Arc<Vec<u8>>> {
188        let mut inner = self.inner.lock().await;
189
190        // Check if session exists and is not expired
191        if let Some(entry) = inner.sessions.get(session_id) {
192            if !entry.is_expired() {
193                trace!("Session cache hit");
194                return Some(entry.ticket.clone());
195            }
196        }
197
198        // Remove expired session
199        inner.sessions.remove(session_id);
200        trace!("Session cache miss or expired");
201        None
202    }
203
204    /// Clear all sessions from the cache
205    pub async fn clear(&self) {
206        *self
207            .tls
208            .lock()
209            .unwrap_or_else(std::sync::PoisonError::into_inner) =
210            TlsResumption::new(self.max_entries);
211        let mut inner = self.inner.lock().await;
212        let count = inner.sessions.len();
213        inner.sessions.clear();
214        debug!(cleared_count = count, "Session cache cleared");
215    }
216
217    /// The rustls client config for `config`, built on first use and then shared, with
218    /// resumption backed by this cache's session store.
219    pub(crate) fn tls_client_config(
220        &self,
221        config: &crate::transport::tls::TlsClientConfig,
222    ) -> crate::error::Result<Arc<rustls::ClientConfig>> {
223        let key = config.resumption_key();
224        let mut tls = self
225            .tls
226            .lock()
227            .unwrap_or_else(std::sync::PoisonError::into_inner);
228        if let Some(built) = tls.configs.get(&key) {
229            return Ok(built.clone());
230        }
231        let mut built = config.load_client_config()?;
232        built.resumption = rustls::client::Resumption::store(tls.store.clone());
233        let built = Arc::new(built);
234        if tls.configs.len() >= self.max_entries.max(1) {
235            tls.configs.clear();
236        }
237        tls.configs.insert(key, built.clone());
238        Ok(built)
239    }
240
241    /// Get current cache statistics
242    pub async fn stats(&self) -> SessionCacheStats {
243        let inner = self.inner.lock().await;
244
245        let expired_count = inner.sessions.values().filter(|e| e.is_expired()).count();
246
247        SessionCacheStats {
248            total_entries: inner.sessions.len(),
249            max_entries: self.max_entries,
250            expired_count,
251            total_inserts: inner.total_inserts,
252        }
253    }
254
255    /// Evict all expired entries from the cache
256    #[allow(dead_code)]
257    async fn evict_expired_async(&self) {
258        let mut inner = self.inner.lock().await;
259        self.evict_expired(&mut inner);
260    }
261
262    /// Internal: Evict expired entries (called with lock held)
263    fn evict_expired(&self, inner: &mut SessionCacheInner) {
264        let before = inner.sessions.len();
265        inner.sessions.retain(|_, entry| !entry.is_expired());
266        let after = inner.sessions.len();
267
268        if before != after {
269            debug!(
270                removed_count = before - after,
271                remaining_count = after,
272                "Expired sessions evicted"
273            );
274        }
275    }
276
277    /// Internal: Evict oldest entry (called with lock held)
278    fn evict_oldest(&self, inner: &mut SessionCacheInner) {
279        if let Some(oldest_key) = inner
280            .sessions
281            .iter()
282            .min_by_key(|(_, entry)| entry.created_at)
283            .map(|(k, _)| k.clone())
284        {
285            inner.sessions.remove(&oldest_key);
286            debug!("Oldest session evicted to make room");
287        }
288    }
289}
290
291/// Statistics about the session cache
292#[derive(Debug, Clone, Copy)]
293pub struct SessionCacheStats {
294    /// Current number of valid sessions
295    pub total_entries: usize,
296    /// Maximum capacity
297    pub max_entries: usize,
298    /// Number of expired but not yet evicted entries
299    pub expired_count: usize,
300    /// Total sessions ever inserted
301    pub total_inserts: u64,
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[tokio::test]
309    #[allow(clippy::unwrap_used)]
310    async fn test_store_and_retrieve() {
311        let cache = SessionCache::new(10, Duration::from_secs(60));
312
313        cache.store("session-1", vec![1, 2, 3, 4]).await;
314        let ticket = cache.get("session-1").await;
315
316        assert!(ticket.is_some());
317        assert_eq!(*ticket.unwrap(), vec![1, 2, 3, 4]);
318    }
319
320    #[tokio::test]
321    async fn test_missing_session() {
322        let cache = SessionCache::new(10, Duration::from_secs(60));
323        let ticket = cache.get("nonexistent").await;
324        assert!(ticket.is_none());
325    }
326
327    #[tokio::test]
328    async fn test_capacity_eviction() {
329        let cache = SessionCache::new(3, Duration::from_secs(60));
330
331        for i in 0..5 {
332            cache.store(format!("session-{i}"), vec![i as u8]).await;
333        }
334
335        let stats = cache.stats().await;
336        assert_eq!(stats.total_entries, 3);
337        assert_eq!(stats.total_inserts, 5);
338    }
339
340    #[tokio::test]
341    async fn test_clear() {
342        let cache = SessionCache::new(10, Duration::from_secs(60));
343
344        cache.store("session-1", vec![1, 2, 3]).await;
345        cache.clear().await;
346
347        let ticket = cache.get("session-1").await;
348        assert!(ticket.is_none());
349    }
350}