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