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)]
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 && let Err(err) = self.validate(table)
363 {
364 panic!("retention invariant violation: {err:?}");
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 && let Some(key) = self.age_eviction_key(max_age_passes)
417 {
418 return Some(key);
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}