Skip to main content

linkrs_query/executor/base/
memory_budget.rs

1//! Per-query memory budget for blocking operators.
2//!
3//! Replaces the hardcoded 100MB limit with a configurable budget
4//! passed through ExecutionContext.
5
6use linkrs_core::error::QueryError;
7use linkrs_core::Value;
8use std::sync::Arc;
9
10static BUDGET_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11
12/// Memory budget for a single query execution.
13///
14/// Each blocking operator should call `try_reserve` before
15/// buffering data. When the budget is exhausted the operator
16/// returns an error, preventing OOM.
17#[derive(Clone)]
18pub struct MemoryBudget {
19    /// Maximum bytes this query may use in blocking operators.
20    pub max_bytes: usize,
21    /// Number of bytes already accounted for.
22    allocated: Arc<std::sync::atomic::AtomicUsize>,
23    /// Unique ID for debugging budget leaks.
24    pub(crate) id: usize,
25}
26
27impl std::fmt::Debug for MemoryBudget {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("MemoryBudget")
30            .field("max_bytes", &self.max_bytes)
31            .field(
32                "allocated",
33                &self.allocated.load(std::sync::atomic::Ordering::Relaxed),
34            )
35            .field("id", &self.id)
36            .finish()
37    }
38}
39
40impl MemoryBudget {
41    /// Default per-query budget (512 MB).
42    pub const DEFAULT_MAX: usize = 512 * 1024 * 1024;
43
44    /// Create a new budget with `max_bytes` limit.
45    pub fn new(max_bytes: usize) -> Self {
46        Self {
47            max_bytes,
48            allocated: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
49            id: BUDGET_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
50        }
51    }
52
53    /// Create a budget with the default limit.
54    pub fn default_budget() -> Self {
55        Self::new(Self::DEFAULT_MAX)
56    }
57
58    /// Try to reserve `bytes` additional memory.
59    ///
60    /// Uses compare-and-swap so that the counter is only updated when the
61    /// new total does not exceed the budget.  On overflow or over-budget
62    /// the counter is left unchanged (no observable side-effect).
63    ///
64    /// Returns `Ok(true)` on success, `Err` when the budget would be exceeded
65    /// or the request causes integer overflow.
66    pub fn try_reserve(&self, bytes: usize) -> Result<bool, QueryError> {
67        let mut prev = self.allocated.load(std::sync::atomic::Ordering::Relaxed);
68        loop {
69            let total = prev.checked_add(bytes).ok_or_else(|| {
70                QueryError::execution(format!(
71                    "Memory budget overflow: request {} bytes overflows usize",
72                    bytes,
73                ))
74            })?;
75            if total > self.max_bytes {
76                return Err(QueryError::execution(format!(
77                    "Memory budget exceeded: request {} bytes, total {} > budget {} bytes",
78                    bytes, total, self.max_bytes,
79                )));
80            }
81            match self.allocated.compare_exchange_weak(
82                prev,
83                total,
84                std::sync::atomic::Ordering::Relaxed,
85                std::sync::atomic::Ordering::Relaxed,
86            ) {
87                Ok(_) => return Ok(true),
88                Err(current) => prev = current,
89            }
90        }
91    }
92
93    /// Release `bytes` from the budget (called when data is freed).
94    pub fn release(&self, bytes: usize) {
95        self.allocated
96            .fetch_sub(bytes, std::sync::atomic::Ordering::Relaxed);
97    }
98
99    /// Current allocated bytes.
100    pub fn current(&self) -> usize {
101        self.allocated.load(std::sync::atomic::Ordering::Relaxed)
102    }
103
104    /// Rough estimate of the memory used by a single row (Vec<Value> heap + Value deep data).
105    pub fn estimate_row_memory(row: &[Value]) -> usize {
106        row.iter().map(|v| v.estimated_size()).sum()
107    }
108
109    /// Rough estimate of the memory used by a slice of rows.
110    pub fn estimate_rows_memory(rows: &[Vec<Value>]) -> usize {
111        rows.iter().map(|row| Self::estimate_row_memory(row)).sum()
112    }
113}
114
115/// Per-operator memory tracker wrapping a shared `MemoryBudget`.
116///
117/// Each blocking operator should hold its own `MemoryTracker` and call
118/// `try_reserve` before buffering data. The tracker records per-operator
119/// peak memory so it can later be reported to the `ProfileCollector`.
120#[derive(Debug, Clone)]
121pub struct MemoryTracker {
122    budget: MemoryBudget,
123    peak_bytes: usize,
124    current_bytes: usize,
125}
126
127impl MemoryTracker {
128    /// Create a new tracker backed by `budget`.
129    pub fn new(budget: MemoryBudget) -> Self {
130        Self {
131            budget,
132            peak_bytes: 0,
133            current_bytes: 0,
134        }
135    }
136
137    /// Reserve `bytes` additional memory through the shared budget.
138    ///
139    /// Updates the per-operator peak tracker and returns an error when
140    /// the global budget is exceeded.
141    pub fn try_reserve(&mut self, bytes: usize) -> Result<(), QueryError> {
142        self.budget.try_reserve(bytes)?;
143        self.current_bytes += bytes;
144        self.peak_bytes = self.peak_bytes.max(self.current_bytes);
145        Ok(())
146    }
147
148    /// Release `bytes` from both the global budget and this tracker.
149    pub fn release(&mut self, bytes: usize) {
150        self.budget.release(bytes);
151        self.current_bytes = self.current_bytes.saturating_sub(bytes);
152    }
153
154    /// Peak memory observed by this tracker.
155    pub fn peak(&self) -> usize {
156        self.peak_bytes
157    }
158
159    /// Current tracked bytes.
160    pub fn current(&self) -> usize {
161        self.current_bytes
162    }
163
164    /// Convenience: reserve memory for a single row (includes Value deep heap data).
165    pub fn try_reserve_row(&mut self, row: &[Value]) -> Result<(), QueryError> {
166        let mem = MemoryBudget::estimate_row_memory(row);
167        self.try_reserve(mem)
168    }
169
170    /// Convenience: reserve memory for many rows.
171    pub fn try_reserve_rows(&mut self, rows: &[Vec<Value>]) -> Result<(), QueryError> {
172        let mem = MemoryBudget::estimate_rows_memory(rows);
173        self.try_reserve(mem)
174    }
175
176    /// Release all tracked bytes and reset the tracker to zero.
177    ///
178    /// Called by operator `close()` instead of re-estimating current state.
179    pub fn reset(&mut self) {
180        self.budget.release(self.current_bytes);
181        self.current_bytes = 0;
182        // peak_bytes intentionally preserved for profile reporting
183    }
184}
185
186/// RAII guard that releases reserved memory when dropped.
187///
188/// Created by [`MemoryBudget::reserve`]. The caller can `forget()` this
189/// guard to transfer release responsibility to an explicit `release()` call.
190#[derive(Debug)]
191pub struct MemoryReservation {
192    budget: MemoryBudget,
193    bytes: usize,
194}
195
196/// Scoped reservation associated with one [`MemoryTracker`].
197///
198/// Unlike [`MemoryReservation`], dropping this guard updates both the
199/// query-wide budget and the per-operator tracker. The mutable borrow keeps
200/// the tracker from being reset while the reservation is still outstanding.
201#[derive(Debug)]
202pub struct MemoryTrackerReservation<'a> {
203    tracker: &'a mut MemoryTracker,
204    bytes: usize,
205}
206
207impl MemoryReservation {
208    fn new(budget: MemoryBudget, bytes: usize) -> Self {
209        Self { budget, bytes }
210    }
211
212    /// Forget the reservation — the memory is not released on drop.
213    ///
214    /// Use when the caller takes over release responsibility (e.g. after
215    /// transferring ownership of the allocated data).
216    pub fn forget(mut self) {
217        self.bytes = 0;
218    }
219
220    /// The amount of memory currently reserved by this guard.
221    pub fn bytes(&self) -> usize {
222        self.bytes
223    }
224}
225
226impl Drop for MemoryReservation {
227    fn drop(&mut self) {
228        if self.bytes > 0 {
229            self.budget.release(self.bytes);
230        }
231    }
232}
233
234impl Drop for MemoryTrackerReservation<'_> {
235    fn drop(&mut self) {
236        if self.bytes > 0 {
237            self.tracker.release(self.bytes);
238        }
239    }
240}
241
242impl MemoryBudget {
243    /// Reserve `bytes` and return a [`MemoryReservation`] guard.
244    ///
245    /// The guard releases the memory on drop.  This is the recommended
246    /// RAII alternative to the raw `try_reserve` / `release` pair.
247    pub fn reserve(&self, bytes: usize) -> Result<MemoryReservation, QueryError> {
248        self.try_reserve(bytes)?;
249        Ok(MemoryReservation::new(self.clone(), bytes))
250    }
251}
252
253impl MemoryTracker {
254    /// Reserve `bytes` through the shared budget and return a scoped
255    /// reservation guard.  Unlike the raw `try_reserve` the guard
256    /// automatically releases on drop, protecting against leaks.
257    pub fn reserve_guarded(
258        &mut self,
259        bytes: usize,
260    ) -> Result<MemoryTrackerReservation<'_>, QueryError> {
261        self.try_reserve(bytes)?;
262        Ok(MemoryTrackerReservation {
263            tracker: self,
264            bytes,
265        })
266    }
267}
268
269/// Trait for operators that can spill intermediate data to disk.
270///
271/// Each blocking operator that may exceed the memory budget implements
272/// spill by partitioning buffered rows to managed spill files.
273pub trait Spillable {
274    /// Spill in-memory data to disk to free memory.
275    fn spill_to_disk(&mut self) -> Result<(), QueryError>;
276
277    /// Number of bytes currently spilled to disk.
278    fn spilled_size(&self) -> u64;
279
280    /// Number of discrete spill operations (runs/files) written to disk.
281    fn spill_count(&self) -> u64 {
282        0
283    }
284
285    /// Number of logical rows written to spill runs.
286    fn spilled_rows(&self) -> u64 {
287        0
288    }
289
290    /// Whether this operator has spilled data to disk.
291    fn has_spilled(&self) -> bool {
292        self.spilled_size() > 0
293    }
294
295    /// Spill data if not already spilled, returning whether any data was spilled.
296    fn try_spill(&mut self) -> Result<bool, QueryError> {
297        if !self.has_spilled() {
298            self.spill_to_disk()?;
299        }
300        Ok(self.has_spilled())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn tracked_reservation_releases_budget_and_tracker() {
310        let budget = MemoryBudget::new(64);
311        let mut tracker = MemoryTracker::new(budget.clone());
312
313        {
314            let reservation = tracker.reserve_guarded(32).expect("reserve memory");
315            assert_eq!(reservation.bytes, 32);
316        }
317
318        assert_eq!(budget.current(), 0);
319        assert_eq!(tracker.current(), 0);
320        assert_eq!(tracker.peak(), 32);
321    }
322}