Skip to main content

cranpose_core/
retention.rs

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