Skip to main content

nodedb_mem/
spill.rs

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