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#[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}