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)]
15pub enum RetentionMode {
16 #[default]
18 DisposeWhenInactive,
19 RetainWhenInactive,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct RetentionBudget {
28 pub max_retained_subtrees: Option<usize>,
30 pub max_retained_bytes: Option<usize>,
32 pub max_age_passes: Option<u64>,
35}
36
37impl RetentionBudget {
38 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum RetentionEvictionPolicy {
55 #[default]
57 LeastRecentlyDetached,
58 LeastRecentlyRestored,
60 LargestFirst,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct RetentionPolicy {
68 pub budget: RetentionBudget,
70 pub eviction: RetentionEvictionPolicy,
72}
73
74impl RetentionPolicy {
75 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;