Skip to main content

chronix_query/
memory.rs

1//! Per-query memory tracking and budget enforcement.
2//!
3//! The [`MemoryTracker`] provides a lightweight mechanism to enforce
4//! per-query memory limits. Query operators call `try_allocate` before
5//! materializing intermediate results; if the cumulative allocation
6//! exceeds the configured budget, the query fails with
7//! [`QueryError::QueryMemoryExceeded`] rather than causing OOM.
8//!
9//! # Incremental budget enforcement
10//!
11//! All query operators (aggregate, dedup, window) perform pre-allocation
12//! memory estimates before building results. For grouped aggregation,
13//! the budget is checked incrementally as new groups are created.
14//! For dedup and window, the estimated output size is checked before
15//! materialization. Full spill-to-disk is in the backlog.
16//!
17//! # Usage
18//!
19//! ```no_run
20//! use chronix_query::memory::MemoryTracker;
21//!
22//! let tracker = MemoryTracker::new(1024 * 1024); // 1 MiB budget
23//! tracker.try_allocate(512 * 1024).unwrap();      // OK — 512 KiB used
24//! tracker.try_allocate(256 * 1024).unwrap();      // OK — 768 KiB used
25//! assert_eq!(tracker.allocated(), 768 * 1024);
26//! tracker.deallocate(512 * 1024);                  // release 512 KiB
27//! assert_eq!(tracker.allocated(), 256 * 1024);
28//! ```
29
30use std::sync::atomic::{AtomicUsize, Ordering};
31
32use crate::error::{QueryError, Result};
33
34/// Tracks memory allocations against a per-query budget.
35///
36/// Thread-safe via atomic operations — can be shared across parallel
37/// query operators via `Arc<MemoryTracker>`.
38#[derive(Debug)]
39pub struct MemoryTracker {
40    /// Maximum allowed allocation in bytes.
41    budget: usize,
42    /// Current allocation in bytes.
43    allocated: AtomicUsize,
44}
45
46impl MemoryTracker {
47    /// Create a new tracker with the given memory budget in bytes.
48    #[must_use]
49    pub fn new(budget_bytes: usize) -> Self {
50        Self {
51            budget: budget_bytes,
52            allocated: AtomicUsize::new(0),
53        }
54    }
55
56    /// Try to allocate `bytes` from the budget.
57    ///
58    /// Returns `Ok(())` if the allocation succeeds, or
59    /// [`QueryError::QueryMemoryExceeded`] if the budget would be
60    /// exceeded.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if `allocated + bytes > budget`.
65    pub fn try_allocate(&self, bytes: usize) -> Result<()> {
66        // CAS loop to atomically check-and-increment.
67        loop {
68            let current = self.allocated.load(Ordering::Relaxed);
69            let new =
70                current
71                    .checked_add(bytes)
72                    .ok_or_else(|| QueryError::QueryMemoryExceeded {
73                        requested: bytes,
74                        allocated: current,
75                        budget: self.budget,
76                    })?;
77
78            if new > self.budget {
79                return Err(QueryError::QueryMemoryExceeded {
80                    requested: bytes,
81                    allocated: current,
82                    budget: self.budget,
83                });
84            }
85
86            if self
87                .allocated
88                .compare_exchange_weak(current, new, Ordering::AcqRel, Ordering::Relaxed)
89                .is_ok()
90            {
91                return Ok(());
92            }
93            // CAS failed — another thread updated concurrently; retry.
94        }
95    }
96
97    /// Release `bytes` back to the budget.
98    ///
99    /// # Panics
100    ///
101    /// Debug-asserts that `bytes` does not exceed the current allocation.
102    pub fn deallocate(&self, bytes: usize) {
103        let prev = self.allocated.fetch_sub(bytes, Ordering::AcqRel);
104        debug_assert!(
105            prev >= bytes,
106            "deallocate({bytes}) but only {prev} bytes were allocated"
107        );
108    }
109
110    /// Returns the current number of allocated bytes.
111    #[must_use]
112    pub fn allocated(&self) -> usize {
113        self.allocated.load(Ordering::Relaxed)
114    }
115
116    /// Returns the configured budget in bytes.
117    #[must_use]
118    pub fn budget(&self) -> usize {
119        self.budget
120    }
121
122    /// Returns the number of bytes remaining before the budget is
123    /// exhausted.
124    #[must_use]
125    pub fn remaining(&self) -> usize {
126        self.budget
127            .saturating_sub(self.allocated.load(Ordering::Relaxed))
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn basic_allocate_deallocate() {
137        let t = MemoryTracker::new(1000);
138        assert_eq!(t.budget(), 1000);
139        assert_eq!(t.allocated(), 0);
140        assert_eq!(t.remaining(), 1000);
141
142        t.try_allocate(400).unwrap();
143        assert_eq!(t.allocated(), 400);
144        assert_eq!(t.remaining(), 600);
145
146        t.try_allocate(600).unwrap();
147        assert_eq!(t.allocated(), 1000);
148        assert_eq!(t.remaining(), 0);
149
150        t.deallocate(300);
151        assert_eq!(t.allocated(), 700);
152        assert_eq!(t.remaining(), 300);
153    }
154
155    #[test]
156    fn exceeds_budget() {
157        let t = MemoryTracker::new(100);
158        t.try_allocate(60).unwrap();
159
160        let err = t.try_allocate(50).unwrap_err();
161        match err {
162            QueryError::QueryMemoryExceeded {
163                requested,
164                allocated,
165                budget,
166            } => {
167                assert_eq!(requested, 50);
168                assert_eq!(allocated, 60);
169                assert_eq!(budget, 100);
170            }
171            other => panic!("unexpected error: {other}"),
172        }
173
174        // Original allocation unchanged after failure.
175        assert_eq!(t.allocated(), 60);
176    }
177
178    #[test]
179    fn exact_budget() {
180        let t = MemoryTracker::new(100);
181        t.try_allocate(100).unwrap();
182        assert_eq!(t.allocated(), 100);
183        assert_eq!(t.remaining(), 0);
184
185        // One more byte fails.
186        assert!(t.try_allocate(1).is_err());
187    }
188
189    #[test]
190    fn zero_budget() {
191        let t = MemoryTracker::new(0);
192        assert!(t.try_allocate(1).is_err());
193        // Zero allocation always succeeds.
194        t.try_allocate(0).unwrap();
195    }
196
197    #[test]
198    fn deallocate_full() {
199        let t = MemoryTracker::new(500);
200        t.try_allocate(500).unwrap();
201        t.deallocate(500);
202        assert_eq!(t.allocated(), 0);
203        assert_eq!(t.remaining(), 500);
204
205        // Can allocate again.
206        t.try_allocate(500).unwrap();
207    }
208
209    #[test]
210    fn concurrent_allocations() {
211        use std::sync::Arc;
212        use std::thread;
213
214        let t = Arc::new(MemoryTracker::new(10_000));
215        let mut handles = vec![];
216
217        for _ in 0..10 {
218            let tracker = Arc::clone(&t);
219            handles.push(thread::spawn(move || {
220                for _ in 0..100 {
221                    // Each thread tries to allocate 1 byte.
222                    let _ = tracker.try_allocate(1);
223                }
224            }));
225        }
226
227        for h in handles {
228            h.join().unwrap();
229        }
230
231        // At most 1000 bytes allocated (10 threads × 100 allocations × 1 byte).
232        assert!(t.allocated() <= 1000);
233    }
234
235    #[test]
236    fn error_display() {
237        let t = MemoryTracker::new(100);
238        t.try_allocate(80).unwrap();
239        let err = t.try_allocate(30).unwrap_err();
240        let msg = err.to_string();
241        assert!(msg.contains("30"), "should mention requested bytes");
242        assert!(msg.contains("80"), "should mention allocated bytes");
243        assert!(msg.contains("100"), "should mention budget");
244    }
245}