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