Skip to main content

appcore_filemaker/
cache.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: cache.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded cache contracts and behavior for this crate.
12
13use std::collections::{BTreeMap, VecDeque};
14use std::sync::Arc;
15
16use crate::{
17    DocumentFingerprint, ErrorCode, FileMakerError, ResolvedScene, ResourceLimits, Result,
18};
19
20/// Bounded deterministic compile-once/render-many scene cache.
21pub struct SceneCache {
22    capacity: usize,
23    max_bytes: usize,
24    used_bytes: usize,
25    entries: BTreeMap<DocumentFingerprint, CacheEntry>,
26    insertion_order: VecDeque<DocumentFingerprint>,
27}
28
29struct CacheEntry {
30    scene: Arc<ResolvedScene>,
31    bytes: usize,
32}
33
34impl SceneCache {
35    /// Default aggregate serialized scene budget.
36    pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;
37
38    /// Creates a cache with an explicit non-zero entry bound.
39    pub fn new(capacity: usize) -> Result<Self> {
40        Self::with_byte_capacity(capacity, Self::DEFAULT_MAX_BYTES)
41    }
42
43    /// Creates a cache bounded by entries and aggregate serialized bytes.
44    pub fn with_byte_capacity(capacity: usize, max_bytes: usize) -> Result<Self> {
45        if capacity == 0 || max_bytes == 0 {
46            return Err(FileMakerError::new(
47                ErrorCode::LimitExceeded,
48                "scene cache entry and byte bounds must be non-zero",
49            ));
50        }
51        Ok(Self {
52            capacity,
53            max_bytes,
54            used_bytes: 0,
55            entries: BTreeMap::new(),
56            insertion_order: VecDeque::new(),
57        })
58    }
59
60    /// Returns an immutable cached scene without changing eviction order.
61    #[must_use]
62    pub fn get(&self, key: &DocumentFingerprint) -> Option<Arc<ResolvedScene>> {
63        self.entries.get(key).map(|entry| entry.scene.clone())
64    }
65
66    /// Returns a cached scene or resolves and inserts it exactly once on a miss.
67    pub fn get_or_try_insert_with<F>(
68        &mut self,
69        key: DocumentFingerprint,
70        limits: &ResourceLimits,
71        resolve: F,
72    ) -> Result<Arc<ResolvedScene>>
73    where
74        F: FnOnce() -> Result<ResolvedScene>,
75    {
76        if let Some(scene) = self.get(&key) {
77            return Ok(scene);
78        }
79        self.insert(key, resolve()?, limits)
80    }
81
82    /// Inserts a fully resolved scene and evicts the oldest inserted key.
83    pub fn insert(
84        &mut self,
85        key: DocumentFingerprint,
86        scene: ResolvedScene,
87        limits: &ResourceLimits,
88    ) -> Result<Arc<ResolvedScene>> {
89        if scene.engine_version != crate::ENGINE_VERSION {
90            return Err(cache_error(
91                "cached scene engine version does not match the active engine",
92            ));
93        }
94        let elements = scene
95            .pages
96            .iter()
97            .try_fold(0_usize, |total, page| {
98                total.checked_add(page.elements.len())
99            })
100            .ok_or_else(|| cache_error("cached scene element count overflow"))?;
101        if scene.pages.len() > limits.max_pages || elements > limits.max_elements {
102            return Err(FileMakerError::new(
103                ErrorCode::LimitExceeded,
104                "cached scene exceeds configured page or element budget",
105            ));
106        }
107        if let Some(existing) = self.entries.get(&key) {
108            return Ok(existing.scene.clone());
109        }
110        let bytes = crate::memory::serialized_size(&scene)?;
111        if bytes > self.max_bytes {
112            return Err(FileMakerError::new(
113                ErrorCode::LimitExceeded,
114                "resolved scene exceeds the cache byte budget",
115            ));
116        }
117        while self.entries.len() >= self.capacity
118            || self.used_bytes.saturating_add(bytes) > self.max_bytes
119        {
120            if let Some(oldest) = self.insertion_order.pop_front() {
121                if let Some(entry) = self.entries.remove(&oldest) {
122                    self.used_bytes = self.used_bytes.saturating_sub(entry.bytes);
123                }
124            }
125        }
126        let scene = Arc::new(scene);
127        self.entries.insert(
128            key,
129            CacheEntry {
130                scene: scene.clone(),
131                bytes,
132            },
133        );
134        self.used_bytes += bytes;
135        self.insertion_order.push_back(key);
136        Ok(scene)
137    }
138
139    /// Number of retained scenes.
140    #[must_use]
141    pub fn len(&self) -> usize {
142        self.entries.len()
143    }
144
145    /// Returns whether no scenes are retained.
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.entries.is_empty()
149    }
150
151    /// Aggregate serialized bytes retained by cached scenes.
152    #[must_use]
153    pub const fn used_bytes(&self) -> usize {
154        self.used_bytes
155    }
156
157    /// Configured aggregate serialized scene byte budget.
158    #[must_use]
159    pub const fn max_bytes(&self) -> usize {
160        self.max_bytes
161    }
162}
163
164fn cache_error(message: impl Into<String>) -> FileMakerError {
165    FileMakerError::new(ErrorCode::Validation, message)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn fingerprint(byte: u8) -> DocumentFingerprint {
173        let mut builder = crate::FingerprintBuilder::new();
174        builder.field("test", &[byte]).unwrap();
175        builder.finish()
176    }
177
178    #[test]
179    fn evicts_oldest_without_unbounded_growth() {
180        let mut cache = SceneCache::new(1).unwrap();
181        let scene = ResolvedScene {
182            template_id: "cache".to_owned(),
183            pages: Vec::new(),
184            engine_version: crate::ENGINE_VERSION.to_owned(),
185        };
186        cache
187            .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
188            .unwrap();
189        cache
190            .insert(fingerprint(2), scene, &ResourceLimits::default())
191            .unwrap();
192        assert!(cache.get(&fingerprint(1)).is_none());
193        assert_eq!(cache.len(), 1);
194    }
195
196    #[test]
197    fn resolves_only_once_for_a_repeated_fingerprint() {
198        let mut cache = SceneCache::new(1).unwrap();
199        let key = fingerprint(1);
200        let mut calls = 0;
201        for _ in 0..2 {
202            cache
203                .get_or_try_insert_with(key, &ResourceLimits::default(), || {
204                    calls += 1;
205                    Ok(ResolvedScene {
206                        template_id: "cache".to_owned(),
207                        pages: Vec::new(),
208                        engine_version: crate::ENGINE_VERSION.to_owned(),
209                    })
210                })
211                .unwrap();
212        }
213        assert_eq!(calls, 1);
214    }
215
216    #[test]
217    fn rejects_a_scene_from_another_engine_version() {
218        let mut cache = SceneCache::new(1).unwrap();
219        let error = cache
220            .insert(
221                fingerprint(1),
222                ResolvedScene {
223                    template_id: "stale".to_owned(),
224                    pages: Vec::new(),
225                    engine_version: "other".to_owned(),
226                },
227                &ResourceLimits::default(),
228            )
229            .unwrap_err();
230        assert_eq!(error.code(), ErrorCode::Validation);
231        assert!(cache.is_empty());
232    }
233
234    #[test]
235    fn rejects_one_scene_larger_than_the_byte_budget() {
236        let mut cache = SceneCache::with_byte_capacity(2, 1).unwrap();
237        let error = cache
238            .insert(
239                fingerprint(1),
240                ResolvedScene {
241                    template_id: "oversized".to_owned(),
242                    pages: Vec::new(),
243                    engine_version: crate::ENGINE_VERSION.to_owned(),
244                },
245                &ResourceLimits::default(),
246            )
247            .unwrap_err();
248        assert_eq!(error.code(), ErrorCode::LimitExceeded);
249        assert_eq!(cache.used_bytes(), 0);
250    }
251
252    #[test]
253    fn byte_budget_evicts_even_below_the_entry_capacity() {
254        let scene = ResolvedScene {
255            template_id: "bounded".to_owned(),
256            pages: Vec::new(),
257            engine_version: crate::ENGINE_VERSION.to_owned(),
258        };
259        let bytes = crate::memory::serialized_size(&scene).unwrap();
260        let mut cache = SceneCache::with_byte_capacity(4, bytes).unwrap();
261        cache
262            .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
263            .unwrap();
264        cache
265            .insert(fingerprint(2), scene, &ResourceLimits::default())
266            .unwrap();
267        assert!(cache.get(&fingerprint(1)).is_none());
268        assert_eq!(cache.len(), 1);
269        assert!(cache.used_bytes() <= cache.max_bytes());
270    }
271}