Skip to main content

cranpose_core/
retention.rs

1use std::cmp::Ordering;
2
3#[cfg(any(test, debug_assertions))]
4use crate::slot::{AnchorState, PayloadAnchorLifecycle, SlotInvariantError};
5use crate::{
6    collections::map::HashMap,
7    slot::{DetachedSubtree, GroupKey, NodeLifecycle},
8    ScopeId,
9};
10#[cfg(any(test, debug_assertions))]
11use crate::{AnchorId, SlotTable};
12
13// Retained subtrees follow docs/SLOT_TABLE_LIFECYCLE.md: anchors stay detached,
14// scopes stay out of the active scope index, and nodes use RetainedDetached until
15// the subtree is restored.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum RetentionMode {
18    #[default]
19    DisposeWhenInactive,
20    RetainWhenInactive,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct RetentionBudget {
25    pub max_retained_subtrees: Option<usize>,
26    pub max_retained_bytes: Option<usize>,
27    pub max_age_passes: Option<u64>,
28}
29
30impl RetentionBudget {
31    pub const UNBOUNDED: Self = Self {
32        max_retained_subtrees: None,
33        max_retained_bytes: None,
34        max_age_passes: None,
35    };
36}
37
38impl Default for RetentionBudget {
39    fn default() -> Self {
40        Self::UNBOUNDED
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum RetentionEvictionPolicy {
46    #[default]
47    LeastRecentlyDetached,
48    LeastRecentlyRestored,
49    LargestFirst,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct RetentionPolicy {
54    pub budget: RetentionBudget,
55    pub eviction: RetentionEvictionPolicy,
56}
57
58impl RetentionPolicy {
59    pub const UNBOUNDED: Self = Self {
60        budget: RetentionBudget::UNBOUNDED,
61        eviction: RetentionEvictionPolicy::LeastRecentlyDetached,
62    };
63}
64
65impl Default for RetentionPolicy {
66    fn default() -> Self {
67        Self::UNBOUNDED
68    }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub(crate) struct RetainKey {
73    pub(crate) parent_scope: Option<ScopeId>,
74    pub(crate) key: GroupKey,
75}
76
77pub(crate) struct RetainedGroup {
78    pub(crate) subtree: DetachedSubtree,
79    detached_pass: u64,
80    detached_order: u64,
81    last_restored_order: u64,
82}
83
84impl RetainedGroup {
85    fn node_count(&self) -> usize {
86        self.subtree.node_count()
87    }
88
89    fn payload_count(&self) -> usize {
90        self.subtree.payload_count()
91    }
92
93    fn scope_count(&self) -> usize {
94        self.subtree.scope_count()
95    }
96
97    fn anchor_count(&self) -> usize {
98        self.subtree.anchor_count()
99    }
100
101    fn heap_bytes(&self) -> usize {
102        self.subtree.heap_bytes()
103    }
104}
105
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub(crate) struct RetentionDebugStats {
108    pub(crate) subtree_count: usize,
109    pub(crate) group_count: usize,
110    pub(crate) payload_count: usize,
111    pub(crate) node_count: usize,
112    pub(crate) scope_count: usize,
113    pub(crate) anchor_count: usize,
114    pub(crate) heap_bytes: usize,
115    pub(crate) evictions_total: usize,
116}
117
118pub(crate) struct RetentionManager {
119    groups: HashMap<RetainKey, RetainedGroup>,
120    restored_at_by_key: HashMap<RetainKey, u64>,
121    policy: RetentionPolicy,
122    pass_clock: u64,
123    operation_clock: u64,
124    evictions_total: usize,
125}
126
127impl Default for RetentionManager {
128    fn default() -> Self {
129        Self::new(RetentionPolicy::default())
130    }
131}
132
133impl RetentionManager {
134    pub(crate) fn new(policy: RetentionPolicy) -> Self {
135        Self {
136            groups: HashMap::default(),
137            restored_at_by_key: HashMap::default(),
138            policy,
139            pass_clock: 0,
140            operation_clock: 0,
141            evictions_total: 0,
142        }
143    }
144
145    pub(crate) fn set_policy(&mut self, policy: RetentionPolicy) {
146        self.policy = policy;
147    }
148
149    pub(crate) fn take(&mut self, key: RetainKey) -> Option<DetachedSubtree> {
150        let retained = self.groups.get(&key)?;
151        if !retained_subtree_matches_key(&retained.subtree, key, "restore") {
152            return None;
153        }
154        if !retained_subtree_root_is_detached(&retained.subtree, key, "restore") {
155            return None;
156        }
157        if !retained_subtree_nodes_have_lifecycle(
158            &retained.subtree,
159            key,
160            NodeLifecycle::RetainedDetached,
161            "restore",
162        ) {
163            return None;
164        }
165        let restored_order = self.tick_operation();
166        let retained = self.groups.remove(&key)?;
167        self.restored_at_by_key.insert(key, restored_order);
168        Some(retained.subtree)
169    }
170
171    pub(crate) fn take_after_restore_preflight(
172        &mut self,
173        key: RetainKey,
174        preflight: impl FnOnce(&DetachedSubtree) -> bool,
175    ) -> Option<DetachedSubtree> {
176        if !preflight(&self.groups.get(&key)?.subtree) {
177            log::error!(
178                "retention restore preflight rejected subtree for parent_scope={:?} key={:?}",
179                key.parent_scope,
180                key.key
181            );
182            return None;
183        }
184        self.take(key)
185    }
186
187    pub(crate) fn insert(
188        &mut self,
189        key: RetainKey,
190        mut subtree: DetachedSubtree,
191    ) -> Vec<DetachedSubtree> {
192        if self.groups.contains_key(&key) {
193            log::error!(
194                "retention insert rejected duplicate key for parent_scope={:?} key={:?}",
195                key.parent_scope,
196                key.key
197            );
198            return vec![subtree];
199        }
200        if !retained_subtree_matches_key(&subtree, key, "insert") {
201            return vec![subtree];
202        }
203        if !retained_subtree_root_is_detached(&subtree, key, "insert") {
204            return vec![subtree];
205        }
206        let detached_order = self.tick_operation();
207        let last_restored_order = self.restored_at_by_key.remove(&key).unwrap_or_default();
208        subtree.mark_nodes_retained_detached();
209        self.groups.insert(
210            key,
211            RetainedGroup {
212                subtree,
213                detached_pass: self.pass_clock,
214                detached_order,
215                last_restored_order,
216            },
217        );
218        self.evict_to_budget()
219    }
220
221    pub(crate) fn is_empty(&self) -> bool {
222        self.groups.is_empty()
223    }
224
225    pub(crate) fn debug_stats(&self) -> RetentionDebugStats {
226        RetentionDebugStats {
227            subtree_count: self.groups.len(),
228            group_count: self
229                .groups
230                .values()
231                .map(|retained| retained.subtree.group_count())
232                .sum(),
233            payload_count: self.groups.values().map(RetainedGroup::payload_count).sum(),
234            node_count: self.groups.values().map(RetainedGroup::node_count).sum(),
235            scope_count: self.groups.values().map(RetainedGroup::scope_count).sum(),
236            anchor_count: self.groups.values().map(RetainedGroup::anchor_count).sum(),
237            heap_bytes: self.groups.values().map(RetainedGroup::heap_bytes).sum(),
238            evictions_total: self.evictions_total,
239        }
240    }
241
242    pub(crate) fn into_subtrees(self) -> Vec<DetachedSubtree> {
243        self.groups
244            .into_values()
245            .map(|retained| retained.subtree)
246            .collect()
247    }
248
249    pub(crate) fn subtrees(&self) -> impl Iterator<Item = &DetachedSubtree> + '_ {
250        self.groups.values().map(|retained| &retained.subtree)
251    }
252
253    #[cfg(test)]
254    pub(crate) fn subtrees_mut(&mut self) -> impl Iterator<Item = &mut DetachedSubtree> + '_ {
255        self.groups
256            .values_mut()
257            .map(|retained| &mut retained.subtree)
258    }
259
260    #[cfg(any(test, debug_assertions))]
261    pub(crate) fn validate(&self, table: &SlotTable) -> Result<(), SlotInvariantError> {
262        for (key, retained) in &self.groups {
263            let subtree = &retained.subtree;
264            subtree.validate_detached()?;
265            let Some(root_key) = subtree.root_key_checked() else {
266                return Err(SlotInvariantError::DetachedSubtreeEmpty);
267            };
268            if root_key != key.key {
269                return Err(SlotInvariantError::RetainedRootKeyMismatch {
270                    parent_scope: key.parent_scope,
271                    expected: key.key,
272                    actual: root_key,
273                });
274            }
275
276            let root_parent_anchor = subtree
277                .root_parent_anchor_checked()
278                .unwrap_or(AnchorId::INVALID);
279            if root_parent_anchor.is_valid() {
280                return Err(SlotInvariantError::RetainedRootHasActiveParent {
281                    root_key,
282                    parent_anchor: root_parent_anchor,
283                });
284            }
285
286            for anchor in subtree.group_anchors() {
287                match table.anchor_state(anchor) {
288                    Some(AnchorState::Detached) => {}
289                    Some(AnchorState::Active(active_index)) => {
290                        return Err(SlotInvariantError::RetainedSubtreeAnchorStillActive {
291                            root_key,
292                            anchor,
293                            active_index,
294                        });
295                    }
296                    actual => {
297                        return Err(SlotInvariantError::RetainedAnchorStateMismatch {
298                            root_key,
299                            anchor,
300                            actual,
301                        });
302                    }
303                }
304            }
305
306            for scope_id in subtree.scope_ids_iter() {
307                if let Some(active_anchor) = table.scope_index_anchor(scope_id) {
308                    return Err(SlotInvariantError::RetainedScopeStillActive {
309                        root_key,
310                        scope_id,
311                        active_anchor,
312                    });
313                }
314            }
315
316            for payload_anchor in subtree.payload_anchors() {
317                match table.payload_anchor_lifecycle(payload_anchor) {
318                    Some(PayloadAnchorLifecycle::Detached) => {}
319                    Some(PayloadAnchorLifecycle::Active) => {
320                        let Some((active_owner, active_index)) =
321                            table.payload_anchor_active_location(payload_anchor)
322                        else {
323                            return Err(SlotInvariantError::RetainedPayloadAnchorStateMismatch {
324                                root_key,
325                                payload_anchor,
326                                actual: Some(PayloadAnchorLifecycle::Active),
327                            });
328                        };
329                        return Err(SlotInvariantError::RetainedPayloadAnchorStillActive {
330                            root_key,
331                            payload_anchor,
332                            active_owner,
333                            active_index,
334                        });
335                    }
336                    actual => {
337                        return Err(SlotInvariantError::RetainedPayloadAnchorStateMismatch {
338                            root_key,
339                            payload_anchor,
340                            actual,
341                        });
342                    }
343                }
344            }
345
346            for (node_id, lifecycle) in subtree.node_states() {
347                if lifecycle != NodeLifecycle::RetainedDetached {
348                    return Err(SlotInvariantError::RetainedNodeLifecycleMismatch {
349                        root_key,
350                        node_id,
351                        actual: lifecycle,
352                    });
353                }
354            }
355        }
356        Ok(())
357    }
358
359    #[cfg(any(test, debug_assertions))]
360    pub(crate) fn debug_verify(&self, table: &SlotTable) {
361        if crate::slot_validation_diagnostics_enabled() {
362            if let Err(err) = self.validate(table) {
363                panic!("retention invariant violation: {err:?}");
364            }
365        }
366    }
367
368    pub(crate) fn evictions_total(&self) -> usize {
369        self.evictions_total
370    }
371
372    pub(crate) fn advance_pass(&mut self) -> Vec<DetachedSubtree> {
373        self.pass_clock = self.pass_clock.saturating_add(1);
374        self.evict_to_budget()
375    }
376
377    fn tick_operation(&mut self) -> u64 {
378        self.operation_clock = self.operation_clock.saturating_add(1);
379        self.operation_clock
380    }
381
382    fn evict_to_budget(&mut self) -> Vec<DetachedSubtree> {
383        let mut evicted = Vec::new();
384        while let Some(key) = self.budget_eviction_key() {
385            let Some(retained) = self.groups.remove(&key) else {
386                break;
387            };
388            self.restored_at_by_key.remove(&key);
389            self.evictions_total = self.evictions_total.saturating_add(1);
390            if !retained_subtree_matches_key(&retained.subtree, key, "eviction")
391                || !retained_subtree_root_is_detached(&retained.subtree, key, "eviction")
392                || !retained_subtree_nodes_have_lifecycle(
393                    &retained.subtree,
394                    key,
395                    NodeLifecycle::RetainedDetached,
396                    "eviction",
397                )
398            {
399                log::error!(
400                    "retention eviction returned malformed retained subtree for caller-owned disposal"
401                );
402                evicted.push(retained.subtree);
403                continue;
404            }
405            evicted.push(retained.subtree);
406        }
407        evicted
408    }
409
410    fn budget_eviction_key(&self) -> Option<RetainKey> {
411        if self.groups.is_empty() {
412            return None;
413        }
414
415        if let Some(max_age_passes) = self.policy.budget.max_age_passes {
416            if let Some(key) = self.age_eviction_key(max_age_passes) {
417                return Some(key);
418            }
419        }
420
421        let over_count = self
422            .policy
423            .budget
424            .max_retained_subtrees
425            .is_some_and(|max| self.groups.len() > max);
426        let over_bytes = self
427            .policy
428            .budget
429            .max_retained_bytes
430            .is_some_and(|max| self.retained_heap_bytes() > max);
431
432        (over_count || over_bytes)
433            .then(|| self.policy_eviction_key())
434            .flatten()
435    }
436
437    fn age_eviction_key(&self, max_age_passes: u64) -> Option<RetainKey> {
438        self.groups
439            .iter()
440            .filter(|(_, retained)| {
441                self.pass_clock.saturating_sub(retained.detached_pass) > max_age_passes
442            })
443            .min_by(|(left_key, left), (right_key, right)| {
444                left.detached_pass
445                    .cmp(&right.detached_pass)
446                    .then_with(|| left.detached_order.cmp(&right.detached_order))
447                    .then_with(|| retain_key_cmp(left_key, right_key))
448            })
449            .map(|(key, _)| *key)
450    }
451
452    fn policy_eviction_key(&self) -> Option<RetainKey> {
453        match self.policy.eviction {
454            RetentionEvictionPolicy::LeastRecentlyDetached => self.least_recently_detached_key(),
455            RetentionEvictionPolicy::LeastRecentlyRestored => self.least_recently_restored_key(),
456            RetentionEvictionPolicy::LargestFirst => self.largest_first_key(),
457        }
458    }
459
460    fn least_recently_detached_key(&self) -> Option<RetainKey> {
461        self.groups
462            .iter()
463            .min_by(|(left_key, left), (right_key, right)| {
464                left.detached_order
465                    .cmp(&right.detached_order)
466                    .then_with(|| retain_key_cmp(left_key, right_key))
467            })
468            .map(|(key, _)| *key)
469    }
470
471    fn least_recently_restored_key(&self) -> Option<RetainKey> {
472        self.groups
473            .iter()
474            .min_by(|(left_key, left), (right_key, right)| {
475                left.last_restored_order
476                    .cmp(&right.last_restored_order)
477                    .then_with(|| left.detached_order.cmp(&right.detached_order))
478                    .then_with(|| retain_key_cmp(left_key, right_key))
479            })
480            .map(|(key, _)| *key)
481    }
482
483    fn largest_first_key(&self) -> Option<RetainKey> {
484        self.groups
485            .iter()
486            .max_by(|(left_key, left), (right_key, right)| {
487                left.heap_bytes()
488                    .cmp(&right.heap_bytes())
489                    .then_with(|| retain_key_cmp(left_key, right_key))
490            })
491            .map(|(key, _)| *key)
492    }
493
494    fn retained_heap_bytes(&self) -> usize {
495        self.groups.values().map(RetainedGroup::heap_bytes).sum()
496    }
497}
498
499fn retained_subtree_matches_key(
500    subtree: &DetachedSubtree,
501    key: RetainKey,
502    context: &'static str,
503) -> bool {
504    let Some(root_key) = subtree.root_key_checked() else {
505        log::error!(
506            "retention {context} rejected empty subtree for parent_scope={:?} key={:?}",
507            key.parent_scope,
508            key.key
509        );
510        return false;
511    };
512    if root_key != key.key {
513        log::error!(
514            "retention {context} rejected root key mismatch for parent_scope={:?}: expected {:?}, actual {:?}",
515            key.parent_scope,
516            key.key,
517            root_key
518        );
519        return false;
520    }
521    true
522}
523
524fn retained_subtree_root_is_detached(
525    subtree: &DetachedSubtree,
526    key: RetainKey,
527    context: &'static str,
528) -> bool {
529    let Some(parent_anchor) = subtree.root_parent_anchor_checked() else {
530        log::error!(
531            "retention {context} rejected empty subtree for parent_scope={:?} key={:?}",
532            key.parent_scope,
533            key.key
534        );
535        return false;
536    };
537    if parent_anchor.is_valid() {
538        log::error!(
539            "retention {context} rejected attached root parent {:?} for parent_scope={:?} key={:?}",
540            parent_anchor,
541            key.parent_scope,
542            key.key
543        );
544        return false;
545    }
546    true
547}
548
549fn retained_subtree_nodes_have_lifecycle(
550    subtree: &DetachedSubtree,
551    key: RetainKey,
552    expected: NodeLifecycle,
553    context: &'static str,
554) -> bool {
555    if let Some((node_id, actual)) = subtree.first_node_lifecycle_mismatch(expected) {
556        log::error!(
557            "retention {context} rejected node lifecycle mismatch for node {node_id} parent_scope={:?} key={:?}: expected {:?}, actual {:?}",
558            key.parent_scope,
559            key.key,
560            expected,
561            actual
562        );
563        return false;
564    }
565    true
566}
567
568fn retain_key_cmp(left: &RetainKey, right: &RetainKey) -> Ordering {
569    (
570        left.parent_scope,
571        left.key.static_key,
572        left.key.explicit_key,
573        left.key.ordinal,
574    )
575        .cmp(&(
576            right.parent_scope,
577            right.key.static_key,
578            right.key.explicit_key,
579            right.key.ordinal,
580        ))
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn retention_budget_default_is_unbounded() {
589        assert_eq!(RetentionBudget::default(), RetentionBudget::UNBOUNDED);
590        assert_eq!(RetentionBudget::default().max_retained_subtrees, None);
591        assert_eq!(RetentionBudget::default().max_retained_bytes, None);
592        assert_eq!(RetentionBudget::default().max_age_passes, None);
593    }
594
595    #[test]
596    fn retention_policy_default_uses_unbounded_budget_and_detach_lru() {
597        assert_eq!(RetentionPolicy::default(), RetentionPolicy::UNBOUNDED);
598        assert_eq!(
599            RetentionPolicy::default().budget,
600            RetentionBudget::UNBOUNDED
601        );
602        assert_eq!(
603            RetentionPolicy::default().eviction,
604            RetentionEvictionPolicy::LeastRecentlyDetached
605        );
606    }
607
608    #[test]
609    fn retention_budget_can_express_all_limits() {
610        let budget = RetentionBudget {
611            max_retained_subtrees: Some(3),
612            max_retained_bytes: Some(4096),
613            max_age_passes: Some(5),
614        };
615
616        assert_eq!(budget.max_retained_subtrees, Some(3));
617        assert_eq!(budget.max_retained_bytes, Some(4096));
618        assert_eq!(budget.max_age_passes, Some(5));
619    }
620}