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;