Skip to main content

steam_client/cache/
persona.rs

1//! Persona cache with TTL expiration.
2//!
3//! This module provides a thread-safe cache for user persona data with
4//! configurable TTL (time-to-live) expiration. The cache reduces redundant API
5//! calls when fetching the same user's profile data multiple times.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use steam_client::cache::{PersonaCache, PersonaCacheConfig};
11//! use std::time::Duration;
12//!
13//! // Create cache with custom TTL
14//! let config = PersonaCacheConfig {
15//!     ttl: Duration::from_secs(600), // 10 minutes
16//!     max_size: 500,
17//! };
18//! let cache = PersonaCache::new(config);
19//!
20//! // Insert persona data
21//! cache.insert(steam_id, persona_data);
22//!
23//! // Retrieve (returns None if expired)
24//! if let Some(persona) = cache.get(&steam_id) {
25//!     tracing::info!("Cached: {}", persona.player_name);
26//! }
27//! ```
28
29use std::{
30    sync::RwLock,
31    time::{Duration, Instant},
32};
33
34use rustc_hash::FxHashMap;
35
36use steamid::SteamID;
37
38use crate::client::UserPersona;
39
40/// Configuration for the persona cache.
41#[derive(Debug, Clone)]
42pub struct PersonaCacheConfig {
43    /// Time-to-live for cached entries.
44    ///
45    /// Entries older than this duration are considered expired and will be
46    /// evicted on access or during cleanup.
47    ///
48    /// Default: 5 minutes
49    pub ttl: Duration,
50
51    /// Maximum number of entries in the cache.
52    ///
53    /// When the cache reaches this size, expired entries are evicted.
54    /// If still at capacity after eviction, the oldest entry is removed.
55    ///
56    /// Default: 1000
57    pub max_size: usize,
58}
59
60impl Default for PersonaCacheConfig {
61    fn default() -> Self {
62        Self {
63            ttl: Duration::from_secs(300), // 5 minutes
64            max_size: 1000,
65        }
66    }
67}
68
69/// A cached persona entry with timestamp.
70#[derive(Debug, Clone)]
71pub struct CachedPersona {
72    /// The persona data.
73    pub data: UserPersona,
74    /// When this entry was cached.
75    pub cached_at: Instant,
76}
77
78impl CachedPersona {
79    /// Create a new cached persona entry.
80    pub fn new(data: UserPersona) -> Self {
81        Self { data, cached_at: Instant::now() }
82    }
83
84    /// Check if this entry has expired given a TTL.
85    pub fn is_expired(&self, ttl: Duration) -> bool {
86        self.cached_at.elapsed() > ttl
87    }
88}
89
90/// Thread-safe persona cache with TTL expiration.
91///
92/// This cache stores `UserPersona` data keyed by `SteamID` and automatically
93/// considers entries expired after the configured TTL.
94#[derive(Debug)]
95pub struct PersonaCache {
96    /// The internal cache storage.
97    cache: RwLock<FxHashMap<SteamID, CachedPersona>>,
98    /// Cache configuration.
99    config: PersonaCacheConfig,
100}
101
102impl PersonaCache {
103    /// Create a new persona cache with the given configuration.
104    pub fn new(config: PersonaCacheConfig) -> Self {
105        Self { cache: RwLock::new(FxHashMap::default()), config }
106    }
107
108    /// Get persona from cache if not expired.
109    ///
110    /// Returns `None` if the entry doesn't exist or has expired.
111    pub fn get(&self, steam_id: &SteamID) -> Option<UserPersona> {
112        let cache = self.cache.read().ok()?;
113        cache.get(steam_id).and_then(|entry| {
114            if !entry.is_expired(self.config.ttl) {
115                Some(entry.data.clone())
116            } else {
117                None // Expired
118            }
119        })
120    }
121
122    /// Insert or update a persona in the cache.
123    ///
124    /// If the cache is at capacity, expired entries are evicted first.
125    pub fn insert(&self, steam_id: SteamID, data: UserPersona) {
126        if let Ok(mut cache) = self.cache.write() {
127            // Evict expired entries if at capacity
128            if cache.len() >= self.config.max_size {
129                self.evict_expired_internal(&mut cache);
130            }
131
132            // If still at capacity after eviction, remove oldest entry
133            if cache.len() >= self.config.max_size {
134                self.evict_oldest_internal(&mut cache);
135            }
136
137            cache.insert(steam_id, CachedPersona::new(data));
138        }
139    }
140
141    /// Bulk get - returns found (non-expired) entries and missing SteamIDs.
142    ///
143    /// This is useful for optimizing batch lookups where you want to know
144    /// which IDs need to be fetched from Steam.
145    ///
146    /// # Returns
147    /// A tuple of (found_personas, missing_steam_ids)
148    pub fn get_many(&self, steam_ids: &[SteamID]) -> (Vec<UserPersona>, Vec<SteamID>) {
149        let mut found = Vec::new();
150        let mut missing = Vec::new();
151
152        if let Ok(cache) = self.cache.read() {
153            for id in steam_ids {
154                if let Some(entry) = cache.get(id) {
155                    if !entry.is_expired(self.config.ttl) {
156                        found.push(entry.data.clone());
157                    } else {
158                        missing.push(*id);
159                    }
160                } else {
161                    missing.push(*id);
162                }
163            }
164        } else {
165            // If we can't acquire lock, treat all as missing
166            missing.extend(steam_ids.iter().copied());
167        }
168
169        (found, missing)
170    }
171
172    /// Clear the entire cache.
173    pub fn clear(&self) {
174        if let Ok(mut cache) = self.cache.write() {
175            cache.clear();
176        }
177    }
178
179    /// Invalidate a specific entry.
180    ///
181    /// Use this when you know a user's data has changed and the cache
182    /// should be refreshed on next access.
183    pub fn invalidate(&self, steam_id: &SteamID) {
184        if let Ok(mut cache) = self.cache.write() {
185            cache.remove(steam_id);
186        }
187    }
188
189    /// Get the current number of entries in the cache (including expired).
190    pub fn len(&self) -> usize {
191        self.cache.read().map(|c| c.len()).unwrap_or(0)
192    }
193
194    /// Check if the cache is empty.
195    pub fn is_empty(&self) -> bool {
196        self.len() == 0
197    }
198
199    /// Get the cache configuration.
200    pub fn config(&self) -> &PersonaCacheConfig {
201        &self.config
202    }
203
204    /// Evict all expired entries.
205    ///
206    /// This can be called periodically to clean up stale entries.
207    pub fn evict_expired(&self) {
208        if let Ok(mut cache) = self.cache.write() {
209            self.evict_expired_internal(&mut cache);
210        }
211    }
212
213    /// Internal helper to evict expired entries.
214    fn evict_expired_internal(&self, cache: &mut FxHashMap<SteamID, CachedPersona>) {
215        let ttl = self.config.ttl;
216        cache.retain(|_, entry| !entry.is_expired(ttl));
217    }
218
219    /// Internal helper to evict the oldest entry.
220    fn evict_oldest_internal(&self, cache: &mut FxHashMap<SteamID, CachedPersona>) {
221        if let Some((oldest_id, _)) = cache.iter().min_by_key(|(_, entry)| entry.cached_at).map(|(id, entry)| (*id, entry.cached_at)) {
222            cache.remove(&oldest_id);
223        }
224    }
225}
226
227impl Default for PersonaCache {
228    fn default() -> Self {
229        Self::new(PersonaCacheConfig::default())
230    }
231}
232
233impl Clone for PersonaCache {
234    fn clone(&self) -> Self {
235        let cache_data = self.cache.read().map(|c| c.clone()).unwrap_or_default();
236
237        Self { cache: RwLock::new(cache_data), config: self.config.clone() }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use steam_enums::EPersonaState;
244
245    use super::*;
246
247    fn create_test_persona(steam_id: SteamID, name: &str) -> UserPersona {
248        UserPersona { steam_id, player_name: name.to_string(), persona_state: EPersonaState::Online, ..Default::default() }
249    }
250
251    #[test]
252    fn test_cache_insert_and_get() {
253        let cache = PersonaCache::default();
254        let steam_id = SteamID::from_steam_id64(76561198000000001);
255        let persona = create_test_persona(steam_id, "TestUser");
256
257        cache.insert(steam_id, persona.clone());
258
259        let retrieved = cache.get(&steam_id);
260        assert!(retrieved.is_some());
261        assert_eq!(retrieved.unwrap().player_name, "TestUser");
262    }
263
264    #[test]
265    fn test_cache_miss() {
266        let cache = PersonaCache::default();
267        let steam_id = SteamID::from_steam_id64(76561198000000001);
268
269        let retrieved = cache.get(&steam_id);
270        assert!(retrieved.is_none());
271    }
272
273    #[test]
274    fn test_cache_invalidate() {
275        let cache = PersonaCache::default();
276        let steam_id = SteamID::from_steam_id64(76561198000000001);
277        let persona = create_test_persona(steam_id, "TestUser");
278
279        cache.insert(steam_id, persona);
280        assert!(cache.get(&steam_id).is_some());
281
282        cache.invalidate(&steam_id);
283        assert!(cache.get(&steam_id).is_none());
284    }
285
286    #[test]
287    fn test_cache_clear() {
288        let cache = PersonaCache::default();
289        let steam_id1 = SteamID::from_steam_id64(76561198000000001);
290        let steam_id2 = SteamID::from_steam_id64(76561198000000002);
291
292        cache.insert(steam_id1, create_test_persona(steam_id1, "User1"));
293        cache.insert(steam_id2, create_test_persona(steam_id2, "User2"));
294        assert_eq!(cache.len(), 2);
295
296        cache.clear();
297        assert!(cache.is_empty());
298    }
299
300    #[test]
301    fn test_cache_get_many_partial() {
302        let cache = PersonaCache::default();
303        let id1 = SteamID::from_steam_id64(76561198000000001);
304        let id2 = SteamID::from_steam_id64(76561198000000002);
305        let id3 = SteamID::from_steam_id64(76561198000000003);
306
307        cache.insert(id1, create_test_persona(id1, "User1"));
308        cache.insert(id2, create_test_persona(id2, "User2"));
309        // id3 not in cache
310
311        let (found, missing) = cache.get_many(&[id1, id2, id3]);
312
313        assert_eq!(found.len(), 2);
314        assert_eq!(missing.len(), 1);
315        assert_eq!(missing[0], id3);
316    }
317
318    #[test]
319    fn test_cache_expired_entry() {
320        // Create cache with very short TTL
321        let config = PersonaCacheConfig { ttl: Duration::from_millis(1), max_size: 100 };
322        let cache = PersonaCache::new(config);
323        let steam_id = SteamID::from_steam_id64(76561198000000001);
324        let persona = create_test_persona(steam_id, "TestUser");
325
326        cache.insert(steam_id, persona);
327
328        // Wait for entry to expire
329        std::thread::sleep(Duration::from_millis(10));
330
331        // Should return None for expired entry
332        assert!(cache.get(&steam_id).is_none());
333    }
334
335    #[test]
336    fn test_cache_max_size_eviction() {
337        let config = PersonaCacheConfig { ttl: Duration::from_secs(300), max_size: 3 };
338        let cache = PersonaCache::new(config);
339
340        // Insert 3 entries
341        for i in 1..=3 {
342            let id = SteamID::from_steam_id64(76561198000000000 + i);
343            cache.insert(id, create_test_persona(id, &format!("User{}", i)));
344        }
345        assert_eq!(cache.len(), 3);
346
347        // Insert 4th entry - should evict oldest
348        let id4 = SteamID::from_steam_id64(76561198000000004);
349        cache.insert(id4, create_test_persona(id4, "User4"));
350
351        assert_eq!(cache.len(), 3);
352        assert!(cache.get(&id4).is_some()); // New entry exists
353    }
354
355    #[test]
356    fn test_cached_persona_is_expired() {
357        let persona = create_test_persona(SteamID::from_steam_id64(76561198000000001), "TestUser");
358        let cached = CachedPersona::new(persona);
359
360        // Should not be expired immediately
361        assert!(!cached.is_expired(Duration::from_secs(300)));
362
363        // Should be expired with 0 TTL
364        assert!(cached.is_expired(Duration::ZERO));
365    }
366}