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, Weak};
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    retired: Vec<(Weak<ResolvedScene>, usize)>,
28}
29
30struct CacheEntry {
31    scene: Arc<ResolvedScene>,
32    bytes: usize,
33}
34
35impl SceneCache {
36    /// Default aggregate serialized scene budget.
37    pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;
38
39    /// Creates a cache with an explicit non-zero entry bound.
40    pub fn new(capacity: usize) -> Result<Self> {
41        Self::with_byte_capacity(capacity, Self::DEFAULT_MAX_BYTES)
42    }
43
44    /// Bounds cached and evicted-but-consumer-held scenes by entries and
45    /// aggregate serialized bytes. Returned `Arc` copies outside this cache,
46    /// including `Arc::make_mut` allocations, are not process memory accounting.
47    pub fn with_byte_capacity(capacity: usize, max_bytes: usize) -> Result<Self> {
48        if capacity == 0 || max_bytes == 0 {
49            return Err(FileMakerError::new(
50                ErrorCode::LimitExceeded,
51                "scene cache entry and byte bounds must be non-zero",
52            ));
53        }
54        Ok(Self {
55            capacity,
56            max_bytes,
57            used_bytes: 0,
58            entries: BTreeMap::new(),
59            insertion_order: VecDeque::new(),
60            retired: Vec::new(),
61        })
62    }
63
64    /// Returns an immutable cached scene without changing eviction order.
65    #[must_use]
66    pub fn get(&self, key: &DocumentFingerprint) -> Option<Arc<ResolvedScene>> {
67        self.entries.get(key).map(|entry| entry.scene.clone())
68    }
69
70    /// Returns a cached scene or resolves and inserts it exactly once on a miss.
71    /// Rejects a miss before resolving when consumer-held scenes exhaust the
72    /// entry or serialized-byte bound. This is not a scratch-memory reservation.
73    pub fn get_or_try_insert_with<F>(
74        &mut self,
75        key: DocumentFingerprint,
76        limits: &ResourceLimits,
77        resolve: F,
78    ) -> Result<Arc<ResolvedScene>>
79    where
80        F: FnOnce() -> Result<ResolvedScene>,
81    {
82        if let Some(scene) = self.get(&key) {
83            return Ok(scene);
84        }
85        self.check_miss_admission()?;
86        self.insert(key, resolve()?, limits)
87    }
88
89    fn check_miss_admission(&mut self) -> Result<()> {
90        self.retired.retain(|(scene, _)| scene.strong_count() != 0);
91        let (count, bytes) = self
92            .entries
93            .values()
94            .filter(|entry| Arc::strong_count(&entry.scene) > 1)
95            .fold(
96                (self.retired.len(), self.retired_bytes()),
97                |(count, bytes), entry| {
98                    (count.saturating_add(1), bytes.saturating_add(entry.bytes))
99                },
100            );
101        if count >= self.capacity || bytes >= self.max_bytes {
102            return Err(FileMakerError::new(
103                ErrorCode::LimitExceeded,
104                "consumer-held scenes leave no capacity for resolving a cache miss",
105            ));
106        }
107        Ok(())
108    }
109
110    /// Inserts a fully resolved scene and evicts the oldest inserted key.
111    /// Evicted scenes held by consumers still consume entry/byte admission.
112    /// If leases prevent admission, returns `LimitExceeded`; earlier FIFO
113    /// evictions may already have occurred. Compilation scratch is not covered.
114    pub fn insert(
115        &mut self,
116        key: DocumentFingerprint,
117        scene: ResolvedScene,
118        limits: &ResourceLimits,
119    ) -> Result<Arc<ResolvedScene>> {
120        if scene.engine_version != crate::ENGINE_VERSION {
121            return Err(cache_error(
122                "cached scene engine version does not match the active engine",
123            ));
124        }
125        let elements = scene
126            .pages
127            .iter()
128            .try_fold(0_usize, |total, page| {
129                total.checked_add(page.elements.len())
130            })
131            .ok_or_else(|| cache_error("cached scene element count overflow"))?;
132        if scene.pages.len() > limits.max_pages || elements > limits.max_elements {
133            return Err(FileMakerError::new(
134                ErrorCode::LimitExceeded,
135                "cached scene exceeds configured page or element budget",
136            ));
137        }
138        if let Some(existing) = self.entries.get(&key) {
139            return Ok(existing.scene.clone());
140        }
141        let bytes = crate::memory::serialized_size_bounded(&scene, self.max_bytes)?;
142        self.retired.retain(|(scene, _)| scene.strong_count() != 0);
143        let mut retired_bytes = self.retired_bytes();
144        while self.entries.len().saturating_add(self.retired.len()) >= self.capacity
145            || self
146                .used_bytes
147                .saturating_add(retired_bytes)
148                .saturating_add(bytes)
149                > self.max_bytes
150        {
151            let Some(oldest) = self.insertion_order.pop_front() else {
152                return Err(FileMakerError::new(
153                    ErrorCode::LimitExceeded,
154                    "consumer-held scenes prevent cache admission",
155                ));
156            };
157            if let Some(entry) = self.entries.remove(&oldest) {
158                self.used_bytes = self.used_bytes.saturating_sub(entry.bytes);
159                let retired = (Arc::downgrade(&entry.scene), entry.bytes);
160                drop(entry);
161                // Count after releasing the cache owner: zero cannot be upgraded
162                // again, including races with consumer Weak::upgrade calls.
163                if retired.0.strong_count() != 0 {
164                    retired_bytes = retired_bytes.saturating_add(retired.1);
165                    self.retired.push(retired);
166                }
167            }
168        }
169        let scene = Arc::new(scene);
170        self.entries.insert(
171            key,
172            CacheEntry {
173                scene: scene.clone(),
174                bytes,
175            },
176        );
177        self.used_bytes += bytes;
178        self.insertion_order.push_back(key);
179        Ok(scene)
180    }
181
182    /// Number of retained scenes.
183    #[must_use]
184    pub fn len(&self) -> usize {
185        self.entries.len()
186    }
187
188    /// Returns whether no scenes are retained.
189    #[must_use]
190    pub fn is_empty(&self) -> bool {
191        self.entries.is_empty()
192    }
193
194    /// Aggregate serialized bytes retained by cached scenes.
195    #[must_use]
196    pub const fn used_bytes(&self) -> usize {
197        self.used_bytes
198    }
199
200    /// Serialized bytes of evicted scenes still held by consumers (not RSS).
201    /// Counts are observed per entry; concurrent consumer drops may lower them.
202    #[must_use]
203    pub fn retired_bytes(&self) -> usize {
204        self.retired
205            .iter()
206            .filter(|(scene, _)| scene.strong_count() != 0)
207            .fold(0_usize, |sum, (_, bytes)| sum.saturating_add(*bytes))
208    }
209
210    /// Configured aggregate serialized scene byte budget.
211    #[must_use]
212    pub const fn max_bytes(&self) -> usize {
213        self.max_bytes
214    }
215}
216
217fn cache_error(message: impl Into<String>) -> FileMakerError {
218    FileMakerError::new(ErrorCode::Validation, message)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn fingerprint(byte: u8) -> DocumentFingerprint {
226        let mut builder = crate::FingerprintBuilder::new();
227        builder.field("test", &[byte]).unwrap();
228        builder.finish()
229    }
230
231    #[test]
232    fn evicts_oldest_without_unbounded_growth() {
233        let mut cache = SceneCache::new(1).unwrap();
234        let scene = ResolvedScene {
235            template_id: "cache".to_owned(),
236            pages: Vec::new(),
237            engine_version: crate::ENGINE_VERSION.to_owned(),
238        };
239        cache
240            .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
241            .unwrap();
242        cache
243            .insert(fingerprint(2), scene, &ResourceLimits::default())
244            .unwrap();
245        assert!(cache.get(&fingerprint(1)).is_none());
246        assert_eq!(cache.len(), 1);
247    }
248
249    #[test]
250    fn resolves_only_once_for_a_repeated_fingerprint() {
251        let mut cache = SceneCache::new(1).unwrap();
252        let key = fingerprint(1);
253        let mut calls = 0;
254        for _ in 0..2 {
255            cache
256                .get_or_try_insert_with(key, &ResourceLimits::default(), || {
257                    calls += 1;
258                    Ok(ResolvedScene {
259                        template_id: "cache".to_owned(),
260                        pages: Vec::new(),
261                        engine_version: crate::ENGINE_VERSION.to_owned(),
262                    })
263                })
264                .unwrap();
265        }
266        assert_eq!(calls, 1);
267    }
268
269    #[test]
270    fn rejects_a_scene_from_another_engine_version() {
271        let mut cache = SceneCache::new(1).unwrap();
272        let error = cache
273            .insert(
274                fingerprint(1),
275                ResolvedScene {
276                    template_id: "stale".to_owned(),
277                    pages: Vec::new(),
278                    engine_version: "other".to_owned(),
279                },
280                &ResourceLimits::default(),
281            )
282            .unwrap_err();
283        assert_eq!(error.code(), ErrorCode::Validation);
284        assert!(cache.is_empty());
285    }
286
287    #[test]
288    fn rejects_one_scene_larger_than_the_byte_budget() {
289        let mut cache = SceneCache::with_byte_capacity(2, 1).unwrap();
290        let error = cache
291            .insert(
292                fingerprint(1),
293                ResolvedScene {
294                    template_id: "oversized".to_owned(),
295                    pages: Vec::new(),
296                    engine_version: crate::ENGINE_VERSION.to_owned(),
297                },
298                &ResourceLimits::default(),
299            )
300            .unwrap_err();
301        assert_eq!(error.code(), ErrorCode::LimitExceeded);
302        assert_eq!(cache.used_bytes(), 0);
303    }
304
305    #[test]
306    fn byte_budget_evicts_even_below_the_entry_capacity() {
307        let scene = ResolvedScene {
308            template_id: "bounded".to_owned(),
309            pages: Vec::new(),
310            engine_version: crate::ENGINE_VERSION.to_owned(),
311        };
312        let bytes = crate::memory::serialized_size(&scene).unwrap();
313        let mut cache = SceneCache::with_byte_capacity(4, bytes).unwrap();
314        cache
315            .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
316            .unwrap();
317        cache
318            .insert(fingerprint(2), scene, &ResourceLimits::default())
319            .unwrap();
320        assert!(cache.get(&fingerprint(1)).is_none());
321        assert_eq!(cache.len(), 1);
322        assert!(cache.used_bytes() <= cache.max_bytes());
323    }
324
325    #[test]
326    fn consumer_lease_blocks_admission_until_released() {
327        let scene = ResolvedScene {
328            template_id: "leased".to_owned(),
329            pages: Vec::new(),
330            engine_version: crate::ENGINE_VERSION.to_owned(),
331        };
332        let bytes = crate::memory::serialized_size(&scene).unwrap();
333        for (capacity, max_bytes) in [(1, 2 * bytes), (4, bytes)] {
334            let mut cache = SceneCache::with_byte_capacity(capacity, max_bytes).unwrap();
335            let held = cache
336                .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
337                .unwrap();
338            let weak = Arc::downgrade(&held);
339            let error = cache
340                .insert(fingerprint(2), scene.clone(), &ResourceLimits::default())
341                .unwrap_err();
342            assert_eq!(error.code(), ErrorCode::LimitExceeded);
343            assert_eq!(cache.used_bytes(), 0);
344            assert_eq!(cache.retired_bytes(), bytes);
345            assert!(weak.upgrade().is_some());
346            drop(held);
347            assert!(weak.upgrade().is_none());
348            assert_eq!(cache.retired_bytes(), 0);
349            assert!(cache
350                .insert(fingerprint(3), scene.clone(), &ResourceLimits::default())
351                .is_ok());
352            assert_eq!(cache.len(), 1);
353        }
354    }
355
356    #[test]
357    fn saturated_consumer_leases_reject_miss_before_resolver_runs() {
358        let scene = ResolvedScene {
359            template_id: "leased".to_owned(),
360            pages: Vec::new(),
361            engine_version: crate::ENGINE_VERSION.to_owned(),
362        };
363        let bytes = crate::memory::serialized_size(&scene).unwrap();
364        for (capacity, max_bytes) in [(1, 2 * bytes), (4, bytes)] {
365            let mut cache = SceneCache::with_byte_capacity(capacity, max_bytes).unwrap();
366            let held = cache
367                .insert(fingerprint(1), scene.clone(), &ResourceLimits::default())
368                .unwrap();
369            let mut calls = 0;
370            let rejected =
371                cache.get_or_try_insert_with(fingerprint(2), &ResourceLimits::default(), || {
372                    calls += 1;
373                    Ok(scene.clone())
374                });
375            assert_eq!(rejected.unwrap_err().code(), ErrorCode::LimitExceeded);
376            assert_eq!(calls, 0);
377            assert!(cache.get(&fingerprint(1)).is_some());
378            drop(held);
379            assert!(cache
380                .get_or_try_insert_with(fingerprint(2), &ResourceLimits::default(), || {
381                    calls += 1;
382                    Ok(scene.clone())
383                })
384                .is_ok());
385            assert_eq!(calls, 1);
386        }
387    }
388}