Skip to main content

nodedb_mem/
reservation_token.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! RAII reservation token for the four-level memory hierarchy.
4//!
5//! A [`ReservationToken`] is produced by
6//! [`MemoryGovernor::try_reserve`](crate::governor::MemoryGovernor::try_reserve)
7//! and holds references to all four budget layers:
8//! global counter, optional per-database counter, optional per-tenant counter,
9//! and the engine identifier for engine-budget release.
10//!
11//! Dropping the token releases all four layers atomically.
12//!
13//! # Panic safety
14//!
15//! `Drop` uses atomic operations and a `tracing::warn!` on over-release.
16//! Neither path panics.
17//!
18//! # `mem::forget`
19//!
20//! Calling `mem::forget` on a token prevents release. This is intentional:
21//! the token represents live allocations that must not be double-freed.
22
23use std::sync::Arc;
24use std::sync::atomic::AtomicUsize;
25
26use nodedb_types::{DatabaseId, TenantId};
27
28use crate::engine::EngineId;
29use crate::governor::GlobalCounter;
30use crate::over_release::{Layer, ReleaseIdentity, release_layer};
31
32/// Holds a memory reservation across the four budget layers.
33///
34/// Releasing happens in reverse order (engine → tenant → database → global)
35/// on drop.
36#[must_use = "dropping a ReservationToken immediately releases the reservation; bind it to a variable"]
37pub struct ReservationToken {
38    /// Shared global-ceiling atomic. Drop decrements this.
39    pub(crate) global_counter: Arc<GlobalCounter>,
40    /// Per-database allocated counter. `None` if no database budget.
41    pub(crate) database_counter: Option<Arc<AtomicUsize>>,
42    /// Per-tenant allocated counter. `None` if no tenant budget.
43    pub(crate) tenant_counter: Option<Arc<AtomicUsize>>,
44    /// Per-engine allocated counter. `None` if no engine budget (unusual —
45    /// `try_reserve` always requires a registered engine).
46    pub(crate) engine_counter: Option<Arc<AtomicUsize>>,
47    /// Bytes reserved at every layer.
48    pub(crate) size: usize,
49    /// Identity carried for `Debug` and metrics.
50    db: DatabaseId,
51    tenant: TenantId,
52    engine: EngineId,
53}
54
55/// Parameters for constructing a [`ReservationToken`].
56///
57/// Used by [`MemoryGovernor::try_reserve`] to avoid a too-many-arguments
58/// constructor.
59pub(crate) struct ReservationParams {
60    pub global_counter: Arc<GlobalCounter>,
61    pub database_counter: Option<Arc<AtomicUsize>>,
62    pub tenant_counter: Option<Arc<AtomicUsize>>,
63    pub engine_counter: Option<Arc<AtomicUsize>>,
64    pub size: usize,
65    pub db: DatabaseId,
66    pub tenant: TenantId,
67    pub engine: EngineId,
68}
69
70impl ReservationToken {
71    /// Construct a new token. Called only by [`MemoryGovernor::try_reserve`].
72    pub(crate) fn new(params: ReservationParams) -> Self {
73        Self {
74            global_counter: params.global_counter,
75            database_counter: params.database_counter,
76            tenant_counter: params.tenant_counter,
77            engine_counter: params.engine_counter,
78            size: params.size,
79            db: params.db,
80            tenant: params.tenant,
81            engine: params.engine,
82        }
83    }
84
85    /// Number of bytes reserved by this token.
86    pub fn size(&self) -> usize {
87        self.size
88    }
89
90    /// The database this reservation is scoped to.
91    pub fn database_id(&self) -> DatabaseId {
92        self.db
93    }
94
95    /// The tenant this reservation is scoped to.
96    pub fn tenant_id(&self) -> TenantId {
97        self.tenant
98    }
99
100    /// The engine this reservation is scoped to.
101    pub fn engine(&self) -> EngineId {
102        self.engine
103    }
104}
105
106impl Drop for ReservationToken {
107    fn drop(&mut self) {
108        let size = self.size;
109        if size == 0 {
110            return;
111        }
112
113        // Release in reverse order: engine → tenant → database → global.
114        //
115        // Each decrement saturates at zero. A concurrent token on the same
116        // engine counter can legitimately drive it below this token's
117        // `size` by the time this token drops (e.g. a timeseries flush's
118        // token released the memtable footprint while a per-batch token
119        // was still in scope). A plain `fetch_sub` would wrap such a
120        // counter to ~usize::MAX, which every utilization reader treats
121        // as 100 % → permanent Emergency pressure → suspended SPSC reads
122        // → schema-register barrier deadlock. Clamping keeps an
123        // over-release a harmless zero instead, and the shortfall the
124        // clamp would otherwise hide is counted against the matching
125        // layer on `self.global_counter.over_release`.
126        let identity = ReleaseIdentity {
127            db: self.db,
128            tenant: self.tenant,
129            engine: self.engine,
130        };
131        let over_release = &self.global_counter.over_release;
132        if let Some(ref counter) = self.engine_counter {
133            release_layer(counter, size, Layer::Engine, over_release, identity);
134        }
135        if let Some(ref counter) = self.tenant_counter {
136            release_layer(counter, size, Layer::Tenant, over_release, identity);
137        }
138        if let Some(ref counter) = self.database_counter {
139            release_layer(counter, size, Layer::Database, over_release, identity);
140        }
141        release_layer(
142            &self.global_counter.allocated,
143            size,
144            Layer::Global,
145            over_release,
146            identity,
147        );
148    }
149}
150
151impl std::fmt::Debug for ReservationToken {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("ReservationToken")
154            .field("size", &self.size)
155            .field("db", &self.db)
156            .field("tenant", &self.tenant)
157            .field("engine", &self.engine)
158            .finish()
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use std::sync::Arc;
165    use std::sync::atomic::AtomicUsize;
166
167    use nodedb_types::{DatabaseId, TenantId};
168
169    use super::{ReservationParams, ReservationToken};
170    use crate::engine::EngineId;
171    use crate::governor::GlobalCounter;
172
173    fn make_counter(val: usize) -> Arc<AtomicUsize> {
174        Arc::new(AtomicUsize::new(val))
175    }
176
177    fn make_global(val: usize) -> Arc<GlobalCounter> {
178        let global = GlobalCounter::new(1024 * 1024);
179        global
180            .allocated
181            .store(val, std::sync::atomic::Ordering::Relaxed);
182        Arc::new(global)
183    }
184
185    #[test]
186    fn drop_releases_all_four_levels() {
187        let global = make_global(100);
188        let db_ctr = make_counter(100);
189        let tenant_ctr = make_counter(100);
190        let engine_ctr = make_counter(100);
191
192        let token = ReservationToken::new(ReservationParams {
193            global_counter: Arc::clone(&global),
194            database_counter: Some(Arc::clone(&db_ctr)),
195            tenant_counter: Some(Arc::clone(&tenant_ctr)),
196            engine_counter: Some(Arc::clone(&engine_ctr)),
197            size: 100,
198            db: DatabaseId::DEFAULT,
199            tenant: TenantId::new(1),
200            engine: EngineId::Vector,
201        });
202
203        assert_eq!(
204            global.allocated.load(std::sync::atomic::Ordering::Relaxed),
205            100
206        );
207
208        drop(token);
209
210        assert_eq!(
211            global.allocated.load(std::sync::atomic::Ordering::Relaxed),
212            0
213        );
214        assert_eq!(db_ctr.load(std::sync::atomic::Ordering::Relaxed), 0);
215        assert_eq!(tenant_ctr.load(std::sync::atomic::Ordering::Relaxed), 0);
216        assert_eq!(engine_ctr.load(std::sync::atomic::Ordering::Relaxed), 0);
217    }
218
219    #[test]
220    fn drop_with_no_scoped_counters_releases_global() {
221        let global = make_global(200);
222        let token = ReservationToken::new(ReservationParams {
223            global_counter: Arc::clone(&global),
224            database_counter: None,
225            tenant_counter: None,
226            engine_counter: None,
227            size: 200,
228            db: DatabaseId::DEFAULT,
229            tenant: TenantId::new(1),
230            engine: EngineId::Query,
231        });
232        drop(token);
233        assert_eq!(
234            global.allocated.load(std::sync::atomic::Ordering::Relaxed),
235            0
236        );
237    }
238
239    #[test]
240    fn drop_does_not_underflow_a_counter_released_below_size() {
241        // Two live tokens can credit the same engine budget. When one
242        // drops first — e.g. a timeseries flush's token releasing the
243        // full memtable footprint while a live per-batch token still
244        // holds a small reservation — the budget can be driven to zero
245        // before the second token drops. The second token's `fetch_sub`
246        // on drop must NOT wrap that counter into the multi-exabyte
247        // range: a wrapped engine or tenant counter reads as 100%
248        // utilization (Emergency) forever, suspends the core's SPSC
249        // reads, and deadlocks every subsequent DDL on the
250        // schema-register barrier — the exact "healthy /healthz, every
251        // query fails" failure mode. Drop must saturate at zero.
252        let global = make_global(40);
253        let engine_ctr = make_counter(40);
254        let tenant_ctr = make_counter(40);
255
256        let token = ReservationToken::new(ReservationParams {
257            global_counter: Arc::clone(&global),
258            database_counter: None,
259            tenant_counter: Some(Arc::clone(&tenant_ctr)),
260            engine_counter: Some(Arc::clone(&engine_ctr)),
261            size: 40,
262            db: DatabaseId::DEFAULT,
263            tenant: TenantId::new(1),
264            engine: EngineId::Timeseries,
265        });
266
267        // A concurrent token's drop drains the engine + global counters
268        // past what this token reserved (a flush releasing the full
269        // memtable footprint while the small per-batch token is alive).
270        engine_ctr.store(0, std::sync::atomic::Ordering::Relaxed);
271        global
272            .allocated
273            .store(0, std::sync::atomic::Ordering::Relaxed);
274
275        drop(token);
276
277        let engine = engine_ctr.load(std::sync::atomic::Ordering::Relaxed);
278        let glob = global.allocated.load(std::sync::atomic::Ordering::Relaxed);
279        let tenant = tenant_ctr.load(std::sync::atomic::Ordering::Relaxed);
280        assert_eq!(
281            engine, 0,
282            "engine counter underflowed to {engine} on token drop — a wrapped \
283             counter reads as 100% utilization (Emergency) forever"
284        );
285        assert_eq!(
286            glob, 0,
287            "global counter underflowed to {glob} on token drop"
288        );
289        // The tenant layer was not touched by the concurrent drop, so it
290        // returns to zero normally — proving the drop still works where
291        // the counter is consistent.
292        assert_eq!(tenant, 0, "tenant counter should release normally to 0");
293    }
294
295    /// Build a governor whose global ceiling covers every engine's uniform
296    /// per-engine limit — the shape the governor's config check demands
297    /// (`EngineLimits::total() <= global_ceiling`).
298    fn generous_governor(per_engine: usize) -> crate::governor::MemoryGovernor {
299        crate::governor::MemoryGovernor::new(crate::governor::GovernorConfig {
300            global_ceiling: per_engine * EngineId::ALL.len(),
301            engine_limits: crate::engine_limits::EngineLimits::uniform(per_engine),
302        })
303        .expect("uniform limits always satisfy the global-ceiling check")
304    }
305
306    #[test]
307    fn second_drop_over_the_same_engine_counter_is_counted() {
308        // Two tokens share one engine counter that was only ever credited
309        // once (100 bytes) — the shape of a double-release: the first
310        // token's drop is the legitimate release, so by the time the
311        // second token drops, the bytes it asks to release are already
312        // gone. The live release path must count that, not silently clamp
313        // it away.
314        let global = make_global(100);
315        let engine_ctr = make_counter(100);
316
317        let first = ReservationToken::new(ReservationParams {
318            global_counter: Arc::clone(&global),
319            database_counter: None,
320            tenant_counter: None,
321            engine_counter: Some(Arc::clone(&engine_ctr)),
322            size: 100,
323            db: DatabaseId::DEFAULT,
324            tenant: TenantId::new(1),
325            engine: EngineId::Vector,
326        });
327        let second = ReservationToken::new(ReservationParams {
328            global_counter: Arc::clone(&global),
329            database_counter: None,
330            tenant_counter: None,
331            engine_counter: Some(Arc::clone(&engine_ctr)),
332            size: 100,
333            db: DatabaseId::DEFAULT,
334            tenant: TenantId::new(1),
335            engine: EngineId::Vector,
336        });
337
338        drop(first);
339        assert_eq!(
340            global.over_release.total(),
341            0,
342            "the first drop is a clean release of the only 100 bytes credited"
343        );
344
345        drop(second);
346        assert!(
347            global.over_release.engine() > 0,
348            "the second drop releases 100 bytes the engine counter no \
349             longer holds — the live-path over-release the detector exists \
350             to catch"
351        );
352        assert_eq!(
353            global.over_release.global(),
354            1,
355            "the global counter is credited once and released twice, so it \
356             over-releases alongside the engine layer"
357        );
358        assert_eq!(
359            global.over_release.total(),
360            2,
361            "total() sums the layers, and this drop drifts both the engine \
362             and the global counter"
363        );
364        assert_eq!(
365            global.over_release.database(),
366            0,
367            "a token with no database counter cannot drift that layer"
368        );
369        assert_eq!(global.over_release.tenant(), 0);
370    }
371
372    #[test]
373    fn clean_reserve_and_drop_leaves_every_over_release_counter_at_zero() {
374        let gov = generous_governor(1024);
375        let token = gov
376            .try_reserve(DatabaseId::DEFAULT, TenantId::new(1), EngineId::Vector, 100)
377            .unwrap();
378        drop(token);
379
380        assert_eq!(gov.total_over_release_count(), 0);
381        assert_eq!(gov.global_over_release_count(), 0);
382        assert_eq!(gov.database_over_release_count(), 0);
383        assert_eq!(gov.tenant_over_release_count(), 0);
384        assert_eq!(gov.engine_over_release_count(), 0);
385    }
386
387    #[test]
388    fn over_release_still_clamps_the_counter_to_zero_never_wrapping() {
389        // The detector counts the event; it must not change the clamp
390        // behaviour the counter itself relies on to avoid reading as a
391        // wrapped-to-usize::MAX 100% utilization.
392        let global = make_global(100);
393        let engine_ctr = make_counter(100);
394
395        let first = ReservationToken::new(ReservationParams {
396            global_counter: Arc::clone(&global),
397            database_counter: None,
398            tenant_counter: None,
399            engine_counter: Some(Arc::clone(&engine_ctr)),
400            size: 100,
401            db: DatabaseId::DEFAULT,
402            tenant: TenantId::new(1),
403            engine: EngineId::Vector,
404        });
405        let second = ReservationToken::new(ReservationParams {
406            global_counter: Arc::clone(&global),
407            database_counter: None,
408            tenant_counter: None,
409            engine_counter: Some(Arc::clone(&engine_ctr)),
410            size: 100,
411            db: DatabaseId::DEFAULT,
412            tenant: TenantId::new(1),
413            engine: EngineId::Vector,
414        });
415        drop(first);
416        drop(second);
417
418        assert_eq!(
419            engine_ctr.load(std::sync::atomic::Ordering::Relaxed),
420            0,
421            "an over-release must saturate at zero, never wrap toward usize::MAX"
422        );
423    }
424
425    #[test]
426    fn zero_size_drop_is_noop() {
427        let global = make_global(0);
428        let token = ReservationToken::new(ReservationParams {
429            global_counter: Arc::clone(&global),
430            database_counter: None,
431            tenant_counter: None,
432            engine_counter: None,
433            size: 0,
434            db: DatabaseId::DEFAULT,
435            tenant: TenantId::new(1),
436            engine: EngineId::Query,
437        });
438        drop(token);
439        assert_eq!(
440            global.allocated.load(std::sync::atomic::Ordering::Relaxed),
441            0
442        );
443    }
444}