Skip to main content

alopex_core/sql/
stream.rs

1//! Generic streaming memory tracking primitives.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::{Error, Result};
7
8/// Default memory threshold used before spilling streaming SQL state.
9pub const DEFAULT_SPILL_THRESHOLD_BYTES: u64 = 256 * 1024 * 1024;
10
11/// Policy for handling memory-limit crossings.
12#[derive(Clone, Debug)]
13pub enum SpillPolicy {
14    /// Return an error immediately when memory usage exceeds the configured limit.
15    FailFast,
16    /// Allow the caller to spill buffered state to files in `directory`.
17    SpillToDisk {
18        /// Directory where spill files are allocated.
19        directory: PathBuf,
20    },
21}
22
23/// Sink for spill metrics emitted by generic streaming helpers.
24pub trait SpillMetricsSink: Send + Sync {
25    /// Record the total bytes and file count written by a spill operation.
26    fn record_spill(&self, bytes: u64, files: u64);
27}
28
29/// Values that can report an estimated in-memory byte footprint.
30pub trait ByteSized {
31    /// Return the estimated memory footprint in bytes.
32    fn estimated_bytes(&self) -> u64;
33}
34
35/// Memory accounting policy used by streaming SQL operators.
36#[derive(Clone)]
37pub struct MemoryPolicy {
38    limit_bytes: Option<u64>,
39    spill_policy: SpillPolicy,
40    metrics: Option<Arc<dyn SpillMetricsSink>>,
41}
42
43impl MemoryPolicy {
44    /// Create a memory policy with an optional byte limit and spill behavior.
45    pub fn new(limit_bytes: Option<u64>, spill_policy: SpillPolicy) -> Self {
46        Self {
47            limit_bytes,
48            spill_policy,
49            metrics: None,
50        }
51    }
52
53    /// Return the configured byte limit, if any.
54    pub fn limit_bytes(&self) -> Option<u64> {
55        self.limit_bytes
56    }
57
58    /// Return the configured spill behavior.
59    pub fn spill_policy(&self) -> &SpillPolicy {
60        &self.spill_policy
61    }
62
63    /// Attach a metrics sink to this policy.
64    pub fn with_metrics(mut self, metrics: Arc<dyn SpillMetricsSink>) -> Self {
65        self.metrics = Some(metrics);
66        self
67    }
68
69    /// Return the spill directory when disk spilling is enabled.
70    pub fn spill_directory(&self) -> Option<&Path> {
71        match &self.spill_policy {
72            SpillPolicy::SpillToDisk { directory } => Some(directory.as_path()),
73            SpillPolicy::FailFast => None,
74        }
75    }
76
77    /// Record a spill through the configured metrics sink, when present.
78    pub fn record_spill(&self, bytes: u64, files: u64) {
79        if let Some(metrics) = &self.metrics {
80            metrics.record_spill(bytes, files);
81        }
82    }
83
84    /// Return whether `used_bytes` exceeds the configured limit.
85    pub fn over_limit(&self, used_bytes: u64) -> bool {
86        self.limit_bytes
87            .map(|limit| used_bytes > limit)
88            .unwrap_or(false)
89    }
90
91    /// Enforce this memory policy for `used_bytes`.
92    pub fn enforce(&self, used_bytes: u64) -> Result<()> {
93        let Some(limit) = self.limit_bytes else {
94            return Ok(());
95        };
96        if used_bytes <= limit {
97            return Ok(());
98        }
99        match &self.spill_policy {
100            SpillPolicy::FailFast => Err(Error::MemoryLimitExceeded {
101                limit: saturating_usize(limit),
102                requested: saturating_usize(used_bytes),
103            }),
104            SpillPolicy::SpillToDisk { .. } => Ok(()),
105        }
106    }
107}
108
109impl std::fmt::Debug for MemoryPolicy {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("MemoryPolicy")
112            .field("limit_bytes", &self.limit_bytes)
113            .field("spill_policy", &self.spill_policy)
114            .finish()
115    }
116}
117
118/// Tracks estimated memory usage for a streaming operator.
119#[derive(Clone, Debug)]
120pub struct MemoryTracker {
121    policy: MemoryPolicy,
122    used_bytes: u64,
123}
124
125impl MemoryTracker {
126    /// Create an empty tracker using `policy`.
127    pub fn new(policy: MemoryPolicy) -> Self {
128        Self {
129            policy,
130            used_bytes: 0,
131        }
132    }
133
134    /// Return the currently tracked byte count.
135    pub fn used_bytes(&self) -> u64 {
136        self.used_bytes
137    }
138
139    /// Return the memory policy used by this tracker.
140    pub fn policy(&self) -> &MemoryPolicy {
141        &self.policy
142    }
143
144    /// Return whether the tracked byte count exceeds the policy limit.
145    pub fn over_limit(&self) -> bool {
146        self.policy.over_limit(self.used_bytes)
147    }
148
149    /// Reset tracked memory to zero after a successful spill or drain.
150    pub fn reset(&mut self) {
151        self.used_bytes = 0;
152    }
153
154    /// Add a raw byte estimate and enforce the policy.
155    pub fn add_bytes(&mut self, bytes: u64) -> Result<()> {
156        self.used_bytes = self.used_bytes.saturating_add(bytes);
157        self.policy.enforce(self.used_bytes)
158    }
159
160    /// Add a row-like slice of byte-sized values.
161    pub fn add_row<T: ByteSized>(&mut self, values: &[T]) -> Result<()> {
162        self.add_values(values)
163    }
164
165    /// Add a slice of byte-sized values.
166    pub fn add_values<T: ByteSized>(&mut self, values: &[T]) -> Result<()> {
167        let bytes: u64 = values.iter().map(ByteSized::estimated_bytes).sum();
168        self.add_bytes(bytes)
169    }
170
171    /// Add one byte-sized value.
172    pub fn add_value<T: ByteSized>(&mut self, value: &T) -> Result<()> {
173        self.add_bytes(value.estimated_bytes())
174    }
175}
176
177fn saturating_usize(value: u64) -> usize {
178    usize::try_from(value).unwrap_or(usize::MAX)
179}
180
181#[cfg(all(test, not(target_arch = "wasm32")))]
182mod tests {
183    use super::{ByteSized, MemoryPolicy, MemoryTracker, SpillPolicy};
184    use crate::Error;
185
186    #[derive(Clone)]
187    struct TestValue(u64);
188
189    impl ByteSized for TestValue {
190        fn estimated_bytes(&self) -> u64 {
191            self.0
192        }
193    }
194
195    #[test]
196    fn memory_tracker_detects_fail_fast_limit_exceeded() {
197        let policy = MemoryPolicy::new(Some(10), SpillPolicy::FailFast);
198        let mut tracker = MemoryTracker::new(policy);
199
200        tracker.add_value(&TestValue(6)).unwrap();
201        let err = tracker.add_value(&TestValue(5)).unwrap_err();
202
203        assert!(tracker.over_limit());
204        assert_eq!(tracker.used_bytes(), 11);
205        assert!(matches!(
206            err,
207            Error::MemoryLimitExceeded {
208                limit: 10,
209                requested: 11
210            }
211        ));
212    }
213
214    #[test]
215    fn spill_to_disk_policy_allows_limit_exceeded_until_caller_spills() {
216        let dir = tempfile::tempdir().unwrap();
217        let policy = MemoryPolicy::new(
218            Some(4),
219            SpillPolicy::SpillToDisk {
220                directory: dir.path().to_path_buf(),
221            },
222        );
223        let mut tracker = MemoryTracker::new(policy);
224
225        tracker.add_values(&[TestValue(2), TestValue(3)]).unwrap();
226
227        assert!(tracker.over_limit());
228        assert_eq!(tracker.used_bytes(), 5);
229        tracker.reset();
230        assert_eq!(tracker.used_bytes(), 0);
231    }
232}