Skip to main content

nodedb_mem/
spill.rs

1//! Cooperative spill controller for per-core arena overflow.
2//!
3//! When a Data Plane core's arena utilization exceeds 90%, the spill
4//! controller triggers eviction of coldest unpinned allocations to a
5//! shared mmap overflow region. This prevents asymmetric OOM while
6//! preserving the zero-lock TPC property.
7
8/// Action the spill controller recommends.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SpillAction {
11    /// No action needed — utilization is healthy.
12    None,
13    /// Begin spilling coldest unpinned allocations to overflow.
14    BeginSpill,
15    /// Continue spilling (utilization still above threshold).
16    ContinueSpill,
17    /// Utilization dropped below restore threshold — can restore spilled data.
18    BeginRestore,
19}
20
21/// Spill controller configuration.
22#[derive(Debug, Clone, Copy)]
23pub struct SpillConfig {
24    /// Utilization threshold to begin spilling (default: 90%).
25    pub spill_threshold: u8,
26    /// Utilization threshold below which restoration can begin (default: 75%).
27    pub restore_threshold: u8,
28}
29
30impl Default for SpillConfig {
31    fn default() -> Self {
32        Self {
33            spill_threshold: 90,
34            restore_threshold: 75,
35        }
36    }
37}
38
39/// Per-core spill controller.
40///
41/// Call `check(utilization_percent)` periodically from the core's housekeeping
42/// loop. The controller tracks state transitions and returns the recommended action.
43///
44/// The controller implements a simple state machine:
45/// - Normal (utilization < spill_threshold) → None
46/// - Spilling (spill_threshold ≤ utilization) → BeginSpill, then ContinueSpill
47/// - Restoring (utilization < restore_threshold while spilling) → BeginRestore, then None
48///
49/// Not `Send` or `Sync` — it's single-core owned.
50pub struct SpillController {
51    config: SpillConfig,
52    /// Whether spill mode is currently active.
53    spilling: bool,
54    /// Counter: number of spill cycles initiated.
55    spill_count: u64,
56    /// Counter: number of entries spilled in current cycle.
57    entries_spilled: u64,
58}
59
60impl SpillController {
61    /// Create a new spill controller with the given configuration.
62    pub fn new(config: SpillConfig) -> Self {
63        Self {
64            config,
65            spilling: false,
66            spill_count: 0,
67            entries_spilled: 0,
68        }
69    }
70
71    /// Check utilization and return the recommended action.
72    ///
73    /// Call this periodically from the core's housekeeping loop.
74    /// `utilization` should be a percentage 0-100.
75    pub fn check(&mut self, utilization: u8) -> SpillAction {
76        match (self.spilling, utilization >= self.config.spill_threshold) {
77            // Not spilling, utilization below threshold.
78            (false, false) => SpillAction::None,
79            // Not spilling, crossed into danger zone.
80            (false, true) => {
81                self.spilling = true;
82                self.spill_count += 1;
83                self.entries_spilled = 0;
84                SpillAction::BeginSpill
85            }
86            // Spilling, still above threshold.
87            (true, true) => SpillAction::ContinueSpill,
88            // Spilling, dropped to restore threshold or below.
89            (true, false) if utilization <= self.config.restore_threshold => {
90                self.spilling = false;
91                SpillAction::BeginRestore
92            }
93            // Spilling, but still above restore threshold (wait).
94            (true, false) => SpillAction::None,
95        }
96    }
97
98    /// Whether spill mode is currently active.
99    pub fn is_spilling(&self) -> bool {
100        self.spilling
101    }
102
103    /// Total number of spill cycles initiated.
104    pub fn spill_count(&self) -> u64 {
105        self.spill_count
106    }
107
108    /// Number of entries spilled in the current cycle.
109    pub fn entries_spilled(&self) -> u64 {
110        self.entries_spilled
111    }
112
113    /// Record that `count` entries were spilled in this cycle.
114    pub fn record_spill(&mut self, count: u64) {
115        self.entries_spilled += count;
116    }
117
118    /// Reset the current cycle's spill counters (but keep total spill_count).
119    pub fn reset_cycle(&mut self) {
120        self.entries_spilled = 0;
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn default_config() {
130        let config = SpillConfig::default();
131        assert_eq!(config.spill_threshold, 90);
132        assert_eq!(config.restore_threshold, 75);
133    }
134
135    #[test]
136    fn state_none_below_threshold() {
137        let mut controller = SpillController::new(SpillConfig::default());
138        assert_eq!(controller.check(50), SpillAction::None);
139        assert!(!controller.is_spilling());
140    }
141
142    #[test]
143    fn state_begin_spill_at_threshold() {
144        let mut controller = SpillController::new(SpillConfig::default());
145        assert_eq!(controller.check(90), SpillAction::BeginSpill);
146        assert!(controller.is_spilling());
147        assert_eq!(controller.spill_count(), 1);
148        assert_eq!(controller.entries_spilled(), 0);
149    }
150
151    #[test]
152    fn state_continue_spill_above_threshold() {
153        let mut controller = SpillController::new(SpillConfig::default());
154        controller.check(90);
155        assert_eq!(controller.check(92), SpillAction::ContinueSpill);
156        assert!(controller.is_spilling());
157    }
158
159    #[test]
160    fn state_begin_restore_below_threshold() {
161        let mut controller = SpillController::new(SpillConfig::default());
162        controller.check(90);
163        assert_eq!(controller.check(74), SpillAction::BeginRestore);
164        assert!(!controller.is_spilling());
165    }
166
167    #[test]
168    fn state_wait_between_thresholds() {
169        let mut controller = SpillController::new(SpillConfig::default());
170        controller.check(90);
171        // Utilization drops to 80% — above restore threshold (75%), should wait.
172        assert_eq!(controller.check(80), SpillAction::None);
173        assert!(controller.is_spilling());
174    }
175
176    #[test]
177    fn record_spill() {
178        let mut controller = SpillController::new(SpillConfig::default());
179        controller.check(90);
180        controller.record_spill(10);
181        assert_eq!(controller.entries_spilled(), 10);
182        controller.record_spill(5);
183        assert_eq!(controller.entries_spilled(), 15);
184    }
185
186    #[test]
187    fn reset_cycle() {
188        let mut controller = SpillController::new(SpillConfig::default());
189        controller.check(90);
190        controller.record_spill(20);
191        assert_eq!(controller.spill_count(), 1);
192        assert_eq!(controller.entries_spilled(), 20);
193        controller.reset_cycle();
194        assert_eq!(controller.spill_count(), 1); // Total count unchanged
195        assert_eq!(controller.entries_spilled(), 0); // Cycle reset
196    }
197
198    #[test]
199    fn multiple_cycles() {
200        let mut controller = SpillController::new(SpillConfig::default());
201
202        // First cycle.
203        controller.check(90);
204        controller.record_spill(10);
205        assert_eq!(controller.spill_count(), 1);
206
207        // Drop below restore threshold.
208        controller.check(74);
209        assert!(!controller.is_spilling());
210
211        // Rise again.
212        controller.check(91);
213        assert_eq!(controller.spill_count(), 2);
214        assert_eq!(controller.entries_spilled(), 0); // Reset from previous restore
215    }
216
217    #[test]
218    fn threshold_boundary() {
219        let config = SpillConfig {
220            spill_threshold: 80,
221            restore_threshold: 60,
222        };
223        let mut controller = SpillController::new(config);
224
225        // Exactly at spill threshold.
226        assert_eq!(controller.check(80), SpillAction::BeginSpill);
227        assert!(controller.is_spilling());
228
229        // Just below spill threshold, above restore threshold.
230        assert_eq!(controller.check(79), SpillAction::None);
231        assert!(controller.is_spilling());
232
233        // Exactly at restore threshold.
234        assert_eq!(controller.check(60), SpillAction::BeginRestore);
235        assert!(!controller.is_spilling());
236
237        // Below restore threshold.
238        assert_eq!(controller.check(59), SpillAction::None);
239        assert!(!controller.is_spilling());
240    }
241}