Skip to main content

nodedb_mem/governor/
reserve.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The two reservation entry points: [`MemoryGovernor::try_reserve`] and
4//! [`MemoryGovernor::charge`].
5
6use std::sync::Arc;
7
8use nodedb_types::{DatabaseId, TenantId};
9
10use super::core::MemoryGovernor;
11use crate::engine::EngineId;
12use crate::error::{MemError, Result};
13use crate::over_release::ReleaseIdentity;
14use crate::reservation_token::{ReservationParams, ReservationToken};
15use crate::reserve_scope::{ReserveScope, ReservedLayers};
16
17/// Build the token both entry points return. They differ in how they reach
18/// a committed [`ReservedLayers`], never in what they build from one.
19fn token_from_layers(
20    layers: ReservedLayers,
21    size: usize,
22    db: DatabaseId,
23    tenant: TenantId,
24    engine: EngineId,
25) -> ReservationToken {
26    ReservationToken::new(ReservationParams {
27        global_counter: layers.global,
28        database_counter: layers.database,
29        tenant_counter: layers.tenant,
30        engine_counter: layers.engine,
31        size,
32        db,
33        tenant,
34        engine,
35    })
36}
37
38impl MemoryGovernor {
39    // ── 4-arity reservation ───────────────────────────────────────────────────
40
41    /// Reserve `size` bytes for one (database, tenant, engine) triple.
42    ///
43    /// Checks global, database, tenant, then engine. Widest scope first
44    /// fails fast, leaving the fewest layers to roll back.
45    ///
46    /// Databases and tenants without a budget are uncapped and skipped. A
47    /// zero engine limit denies with [`MemError::BudgetExhausted`].
48    ///
49    /// # Errors
50    ///
51    /// Names the exhausted layer. Every partial credit rolls back.
52    pub fn try_reserve(
53        &self,
54        db: DatabaseId,
55        tenant: TenantId,
56        engine: EngineId,
57        size: usize,
58    ) -> Result<ReservationToken> {
59        let identity = ReleaseIdentity { db, tenant, engine };
60        let mut scope = ReserveScope::new(Arc::clone(&self.global_counter), size, identity);
61
62        scope.try_credit_global()?;
63
64        {
65            let map = self
66                .database_budgets
67                .read()
68                .unwrap_or_else(|p| p.into_inner());
69            if let Some(budget) = map.get(&db) {
70                match budget.try_reserve(size) {
71                    Ok(arc) => scope.credit_database(arc),
72                    Err(denied) => {
73                        return Err(MemError::DatabaseBudgetExhausted {
74                            db,
75                            requested: size,
76                            available: budget.available(),
77                            limit: denied.limit,
78                        });
79                    }
80                }
81            }
82        }
83
84        {
85            let map = self
86                .tenant_budgets
87                .read()
88                .unwrap_or_else(|p| p.into_inner());
89            if let Some(budget) = map.get(&(db, tenant)) {
90                match budget.try_reserve(size) {
91                    Ok(arc) => scope.credit_tenant(arc),
92                    Err(denied) => {
93                        return Err(MemError::TenantBudgetExhausted {
94                            db,
95                            tenant,
96                            requested: size,
97                            available: budget.available(),
98                            limit: denied.limit,
99                        });
100                    }
101                }
102            }
103        }
104
105        let engine_budget = &self.budgets[engine.index()];
106        let engine_counter = match engine_budget.try_reserve_arc(size) {
107            Some(arc) => arc,
108            None => {
109                return Err(MemError::BudgetExhausted {
110                    engine,
111                    requested: size,
112                    available: engine_budget.available(),
113                    limit: engine_budget.limit(),
114                });
115            }
116        };
117        scope.credit_engine(engine_counter);
118
119        let layers = scope.commit();
120        Ok(token_from_layers(layers, size, db, tenant, engine))
121    }
122
123    /// Account `size` bytes already resident across all four layers.
124    ///
125    /// Denying a charge hides allocated memory, it never frees it. Ignores
126    /// every limit and never fails. The token releases all four layers on
127    /// drop, as [`try_reserve`](Self::try_reserve)'s does.
128    pub fn charge(
129        &self,
130        db: DatabaseId,
131        tenant: TenantId,
132        engine: EngineId,
133        size: usize,
134    ) -> ReservationToken {
135        let identity = ReleaseIdentity { db, tenant, engine };
136        let mut scope = ReserveScope::new(Arc::clone(&self.global_counter), size, identity);
137
138        scope.credit_global_unchecked();
139
140        {
141            let map = self
142                .database_budgets
143                .read()
144                .unwrap_or_else(|p| p.into_inner());
145            if let Some(budget) = map.get(&db) {
146                scope.credit_database(budget.credit(size));
147            }
148        }
149
150        {
151            let map = self
152                .tenant_budgets
153                .read()
154                .unwrap_or_else(|p| p.into_inner());
155            if let Some(budget) = map.get(&(db, tenant)) {
156                scope.credit_tenant(budget.credit(size));
157            }
158        }
159
160        let engine_budget = &self.budgets[engine.index()];
161        scope.credit_engine(engine_budget.credit_arc(size));
162
163        let layers = scope.commit();
164        token_from_layers(layers, size, db, tenant, engine)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use std::sync::Arc;
171    use std::sync::atomic::Ordering;
172    use std::thread;
173
174    use nodedb_types::{DatabaseId, TenantId};
175
176    use super::*;
177    use crate::engine_limits::EngineLimits;
178    use crate::governor::config::GovernorConfig;
179    use crate::governor::test_support::{db, tenant, test_config};
180
181    // ── Basic 4-arity reservation ────────────────────────────────────────────
182
183    #[test]
184    fn reserve_within_budget() {
185        let gov = MemoryGovernor::new(test_config()).unwrap();
186        let tok = gov
187            .try_reserve(db(), tenant(), EngineId::Vector, 1000)
188            .unwrap();
189        assert_eq!(gov.budget(EngineId::Vector).allocated(), 1000);
190        assert_eq!(tok.size(), 1000);
191    }
192
193    #[test]
194    fn reserve_exceeds_engine_budget() {
195        let gov = MemoryGovernor::new(test_config()).unwrap();
196        let err = gov
197            .try_reserve(db(), tenant(), EngineId::Query, 3000)
198            .unwrap_err();
199        assert!(matches!(err, MemError::BudgetExhausted { .. }));
200    }
201
202    #[test]
203    fn reserve_exceeds_global_ceiling() {
204        let gov = MemoryGovernor::new(test_config()).unwrap();
205        // Fill up global ceiling by filling all engines.
206        let _t1 = gov
207            .try_reserve(db(), tenant(), EngineId::Vector, 4096)
208            .unwrap();
209        let _t2 = gov
210            .try_reserve(db(), tenant(), EngineId::Query, 2048)
211            .unwrap();
212        let _t3 = gov
213            .try_reserve(db(), tenant(), EngineId::Timeseries, 1024)
214            .unwrap();
215        // All engine budgets are also exhausted, so either error is valid.
216        let err = gov
217            .try_reserve(db(), tenant(), EngineId::Timeseries, 2000)
218            .unwrap_err();
219        assert!(matches!(
220            err,
221            MemError::BudgetExhausted { .. } | MemError::GlobalCeilingExceeded { .. }
222        ));
223    }
224
225    // ── RAII release ──────────────────────────────────────────────────────────
226
227    #[test]
228    fn raii_release_returns_to_baseline() {
229        let gov = MemoryGovernor::new(test_config()).unwrap();
230
231        {
232            let tok = gov
233                .try_reserve(db(), tenant(), EngineId::Vector, 1000)
234                .unwrap();
235            assert_eq!(gov.budget(EngineId::Vector).allocated(), 1000);
236            assert_eq!(tok.size(), 1000);
237        } // token dropped here
238
239        assert_eq!(
240            gov.budget(EngineId::Vector).allocated(),
241            0,
242            "engine counter must be returned on drop"
243        );
244    }
245
246    // ── Database-cap hierarchical denial ─────────────────────────────────────
247
248    #[test]
249    fn database_cap_denies_even_with_tenant_headroom() {
250        let gov = MemoryGovernor::new(test_config()).unwrap();
251        // Database budget: 500 bytes.
252        gov.set_database_budget(db(), 500);
253        // Tenant budget: generous.
254        gov.set_tenant_budget(db(), tenant(), 4096);
255
256        // Reservation of 600 must fail at the database layer even though
257        // both global and tenant have headroom.
258        let err = gov
259            .try_reserve(db(), tenant(), EngineId::Vector, 600)
260            .unwrap_err();
261        assert!(
262            matches!(err, MemError::DatabaseBudgetExhausted { .. }),
263            "expected DatabaseBudgetExhausted, got {err:?}"
264        );
265    }
266
267    #[test]
268    fn global_cap_denies_even_with_database_and_tenant_headroom() {
269        // Global ceiling of 200. Engine limit also 200 (passes validation since
270        // sum ≤ global). DB and tenant budgets are generous. Request 300 bytes —
271        // global layer fires first and denies.
272        let engine_limits = EngineLimits::zeroed().with(EngineId::Vector, 200);
273        let gov = MemoryGovernor::new(GovernorConfig {
274            global_ceiling: 200,
275            engine_limits,
276        })
277        .unwrap();
278        gov.set_database_budget(db(), 1024);
279        gov.set_tenant_budget(db(), tenant(), 1024);
280
281        let err = gov
282            .try_reserve(db(), tenant(), EngineId::Vector, 300)
283            .unwrap_err();
284        assert!(
285            matches!(err, MemError::GlobalCeilingExceeded { .. }),
286            "expected GlobalCeilingExceeded, got {err:?}"
287        );
288    }
289
290    #[test]
291    fn tenant_cap_denies_with_db_headroom() {
292        let gov = MemoryGovernor::new(test_config()).unwrap();
293        gov.set_database_budget(db(), 4096);
294        gov.set_tenant_budget(db(), tenant(), 300);
295
296        let err = gov
297            .try_reserve(db(), tenant(), EngineId::Vector, 400)
298            .unwrap_err();
299        assert!(
300            matches!(err, MemError::TenantBudgetExhausted { .. }),
301            "expected TenantBudgetExhausted, got {err:?}"
302        );
303    }
304
305    // ── Rollback correctness: partial increments must be undone on failure ────
306
307    #[test]
308    fn partial_increments_rolled_back_on_db_failure() {
309        let gov = MemoryGovernor::new(test_config()).unwrap();
310        gov.set_database_budget(db(), 50);
311
312        // Request 100 bytes → fails at DB layer. Global should stay at 0.
313        let _ = gov
314            .try_reserve(db(), tenant(), EngineId::Vector, 100)
315            .unwrap_err();
316
317        // Global counter must be 0 (rolled back).
318        assert_eq!(
319            gov.global_counter.allocated.load(Ordering::Relaxed),
320            0,
321            "global counter must be rolled back on database-layer failure"
322        );
323    }
324
325    #[test]
326    fn partial_increments_rolled_back_on_tenant_failure() {
327        let gov = MemoryGovernor::new(test_config()).unwrap();
328        gov.set_database_budget(db(), 4096);
329        gov.set_tenant_budget(db(), tenant(), 50);
330
331        let _ = gov
332            .try_reserve(db(), tenant(), EngineId::Vector, 100)
333            .unwrap_err();
334
335        // Both global and db counters must be 0.
336        assert_eq!(
337            gov.global_counter.allocated.load(Ordering::Relaxed),
338            0,
339            "global counter must be rolled back on tenant-layer failure"
340        );
341        let db_map = gov.database_budgets.read().unwrap();
342        let db_alloc = db_map[&db()].allocated.load(Ordering::Relaxed);
343        assert_eq!(db_alloc, 0, "database counter must be rolled back");
344    }
345
346    // ── Concurrent reserves ───────────────────────────────────────────────────
347
348    #[test]
349    fn concurrent_reserves_never_exceed_cap() {
350        let limits = EngineLimits::zeroed().with(EngineId::Vector, 10_000);
351        let gov = Arc::new(
352            MemoryGovernor::new(GovernorConfig {
353                global_ceiling: 10_000,
354                engine_limits: limits,
355            })
356            .unwrap(),
357        );
358        gov.set_database_budget(DatabaseId::DEFAULT, 10_000);
359
360        // N threads each try to reserve S bytes.
361        let n_threads = 8;
362        let reserve_size = 1_000;
363        let mut handles = Vec::new();
364
365        for i in 0..n_threads {
366            let gov_clone = Arc::clone(&gov);
367            handles.push(thread::spawn(move || {
368                gov_clone.try_reserve(
369                    DatabaseId::DEFAULT,
370                    TenantId::new(i as u64),
371                    EngineId::Vector,
372                    reserve_size,
373                )
374            }));
375        }
376
377        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
378        let successful: Vec<_> = results.into_iter().filter_map(|r| r.ok()).collect();
379
380        // At most 10 successful reservations of 1000 bytes each against a 10000 cap.
381        assert!(
382            successful.len() <= 10,
383            "expected at most 10 successful reservations, got {}",
384            successful.len()
385        );
386
387        let engine_alloc = gov.budget(EngineId::Vector).allocated();
388        assert!(
389            engine_alloc <= 10_000,
390            "engine total {engine_alloc} must not exceed cap 10000"
391        );
392
393        let global_alloc = gov.global_counter.allocated.load(Ordering::Relaxed);
394        assert!(
395            global_alloc <= 10_000,
396            "global total {global_alloc} must not exceed ceiling 10000"
397        );
398    }
399
400    // ── Denied engine budget rejection and rollback ──────────────────────────
401    //
402    // `EngineId::Crdt` keeps `test_config`'s default zero limit — it still
403    // carries a real `Budget` entry, so a 1000-byte reservation against it
404    // denies with `MemError::BudgetExhausted`, never a missing-engine error.
405
406    #[test]
407    fn denied_engine_budget_leaves_the_global_counter_untouched() {
408        let gov = MemoryGovernor::new(test_config()).unwrap();
409
410        let err = gov
411            .try_reserve(db(), tenant(), EngineId::Crdt, 1000)
412            .unwrap_err();
413        assert!(matches!(err, MemError::BudgetExhausted { .. }));
414
415        assert_eq!(
416            gov.global_counter.allocated.load(Ordering::Relaxed),
417            0,
418            "global counter must be rolled back when the engine budget denies"
419        );
420    }
421
422    #[test]
423    fn denied_engine_budget_leaves_the_database_counter_untouched() {
424        let gov = MemoryGovernor::new(test_config()).unwrap();
425        gov.set_database_budget(db(), 4096);
426
427        let _ = gov
428            .try_reserve(db(), tenant(), EngineId::Crdt, 1000)
429            .unwrap_err();
430
431        let db_map = gov.database_budgets.read().unwrap();
432        assert_eq!(
433            db_map[&db()].allocated.load(Ordering::Relaxed),
434            0,
435            "database counter must be rolled back when the engine budget denies"
436        );
437    }
438
439    #[test]
440    fn denied_engine_budget_leaves_the_tenant_counter_untouched() {
441        let gov = MemoryGovernor::new(test_config()).unwrap();
442        gov.set_database_budget(db(), 4096);
443        gov.set_tenant_budget(db(), tenant(), 4096);
444
445        let _ = gov
446            .try_reserve(db(), tenant(), EngineId::Crdt, 1000)
447            .unwrap_err();
448
449        let tenant_map = gov.tenant_budgets.read().unwrap();
450        assert_eq!(
451            tenant_map[&(db(), tenant())]
452                .allocated
453                .load(Ordering::Relaxed),
454            0,
455            "tenant counter must be rolled back when the engine budget denies"
456        );
457    }
458
459    #[test]
460    fn rejected_reservations_do_not_consume_the_global_ceiling() {
461        let gov = MemoryGovernor::new(test_config()).unwrap();
462
463        // Nine rejected 1000-byte requests against Crdt's zero-byte engine budget.
464        for _ in 0..9 {
465            let _ = gov
466                .try_reserve(db(), tenant(), EngineId::Crdt, 1000)
467                .unwrap_err();
468        }
469
470        let tok = gov.try_reserve(db(), tenant(), EngineId::Vector, 1000);
471        assert!(
472            tok.is_ok(),
473            "rejected reservations must not exhaust the global ceiling, got {:?}",
474            tok.err()
475        );
476    }
477
478    #[test]
479    fn rejected_reservations_do_not_consume_a_database_quota() {
480        let gov = MemoryGovernor::new(test_config()).unwrap();
481        gov.set_database_budget(db(), 2000);
482
483        for _ in 0..2 {
484            let _ = gov
485                .try_reserve(db(), tenant(), EngineId::Crdt, 1000)
486                .unwrap_err();
487        }
488
489        let tok = gov.try_reserve(db(), tenant(), EngineId::Vector, 1000);
490        assert!(
491            tok.is_ok(),
492            "rejected reservations must not exhaust the database quota, got {:?}",
493            tok.err()
494        );
495    }
496}