Skip to main content

linkrs_query/executor/streaming/spill/
manager.rs

1use std::io::{BufWriter, Write};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use linkrs_core::error::QueryError;
7
8use super::config::{SpillConfig, COLLECTOR_SPILL_ROWS_DEFAULT};
9use super::hash_partition::HashPartitionSpiller;
10use super::quota::DiskQuota;
11use super::run_format::{SpilledRun, RUN_HEADER_SIZE};
12use super::run_io::RunWriter;
13
14// ── SpillManager ─────────────────────────────────────────────────────────────
15
16/// Manages spill-file creation, cleanup, and tracking for one query execution.
17///
18/// Creates a unique subdirectory under `temp_dir` on construction and removes
19/// it on drop only when empty; recursive cleanup is explicit via
20/// `register_cleanup` at query end.
21#[derive(Debug)]
22pub struct SpillManager {
23    pub(crate) config: SpillConfig,
24    pub(crate) base_dir: PathBuf,
25    pub(crate) file_counter: AtomicU64,
26    pub(crate) spill_bytes: Arc<AtomicU64>,
27    pub(crate) disk_quota: DiskQuota,
28}
29
30impl SpillManager {
31    pub fn new(config: SpillConfig, query_id: u64) -> Result<Self, QueryError> {
32        Self::new_with_quota(config, query_id, DiskQuota::default_quota())
33    }
34
35    /// Create with explicit disk quota.
36    pub fn new_with_quota(
37        config: SpillConfig,
38        query_id: u64,
39        disk_quota: DiskQuota,
40    ) -> Result<Self, QueryError> {
41        let base = config
42            .temp_dir
43            .clone()
44            .unwrap_or_else(std::env::temp_dir)
45            .join(format!("linkrs_spill_{}", query_id));
46        std::fs::create_dir_all(&base)
47            .map_err(|e| QueryError::execution(format!("Failed to create spill dir: {}", e)))?;
48        Ok(Self {
49            config,
50            base_dir: base,
51            file_counter: AtomicU64::new(0),
52            spill_bytes: Arc::new(AtomicU64::new(0)),
53            disk_quota,
54        })
55    }
56
57    /// Create a run writer for spill data (versioned format with
58    /// header/checksum). This is the single spill file format.
59    ///
60    /// Enforces `max_spill_files`: once the file budget is exhausted no new
61    /// run can be created and a structured error is returned.
62    pub fn create_run_writer(&self, schema_fingerprint: u64) -> Result<RunWriter, QueryError> {
63        let id = self.file_counter.fetch_add(1, Ordering::Relaxed);
64        if id >= self.config.max_spill_files as u64 {
65            return Err(QueryError::execution(format!(
66                "spill run: too many spill files (limit {})",
67                self.config.max_spill_files,
68            )));
69        }
70        std::fs::create_dir_all(&self.base_dir)
71            .map_err(|e| QueryError::execution(format!("recreate spill dir: {}", e)))?;
72        let path = self.base_dir.join(format!("run_{:016x}.run", id));
73        let file = std::fs::File::create(&path)
74            .map_err(|e| QueryError::execution(format!("create run file: {}", e)))?;
75
76        // Reserve header space by writing dummy bytes
77        let mut writer = BufWriter::new(file);
78        let header_placeholder = [0u8; RUN_HEADER_SIZE as usize];
79        writer
80            .write_all(&header_placeholder)
81            .map_err(|e| QueryError::execution(format!("write run header placeholder: {}", e)))?;
82
83        Ok(RunWriter::new(writer, path, schema_fingerprint))
84    }
85
86    pub fn base_dir(&self) -> &Path {
87        &self.base_dir
88    }
89
90    /// Access the spill configuration.
91    pub fn config(&self) -> &SpillConfig {
92        &self.config
93    }
94
95    /// Total spilled bytes finalized through this manager.
96    pub fn spilled_bytes(&self) -> u64 {
97        self.spill_bytes.load(Ordering::Relaxed)
98    }
99
100    /// Effective terminal-collector spill threshold in logical rows.
101    ///
102    /// `SpillConfig::collect_spill_rows`: `None` selects the default,
103    /// `Some(0)` disables collector spill.
104    pub fn collector_spill_threshold(&self) -> u64 {
105        match self.config.collect_spill_rows {
106            None => COLLECTOR_SPILL_ROWS_DEFAULT,
107            Some(0) => u64::MAX,
108            Some(v) => v,
109        }
110    }
111
112    /// Finalize a run while enforcing disk quota.
113    ///
114    /// Single choke point for all spill paths: the run is finalized first
115    /// (its exact on-disk size is only known then), quota is reserved, and
116    /// on quota failure the file is removed so no orphaned run is left
117    /// behind. Callers must route new `RunWriter::finalize` uses through
118    /// here; direct `finalize` remains for tests and legacy paths.
119    pub fn finalize_run(&self, writer: RunWriter) -> Result<SpilledRun, QueryError> {
120        let path = writer.path().to_path_buf();
121        let run = writer.finalize()?;
122        if let Err(e) = self.disk_quota.try_reserve(run.byte_size) {
123            let _ = std::fs::remove_file(&path);
124            return Err(e);
125        }
126        self.spill_bytes.fetch_add(run.byte_size, Ordering::Relaxed);
127        Ok(run)
128    }
129
130    /// Access the disk quota.
131    pub fn disk_quota(&self) -> &DiskQuota {
132        &self.disk_quota
133    }
134
135    /// Register recursive cleanup with an execution runtime.
136    pub fn register_cleanup(
137        &self,
138        runtime: &crate::executor::streaming::runtime::ExecutionRuntime,
139    ) {
140        let base = self.base_dir.clone();
141        runtime.on_cleanup(move || {
142            let _ = std::fs::remove_dir_all(&base);
143        });
144    }
145}
146
147/// Finalize a partition spiller through the runtime's spill manager.
148///
149/// Quota-enforcing choke point for partition spill paths (aggregate, window,
150/// group-by, distinct/materialize, join): when a manager is present each run
151/// goes through [`SpillManager::finalize_run`] and the run sizes are recorded
152/// into the runtime [`ColumnarStats`](crate::executor::streaming::runtime::ColumnarStats).
153/// Without a manager there is no quota to enforce, so runs are finalized
154/// directly.
155pub fn finalize_partitions_with_runtime(
156    mut spiller: HashPartitionSpiller,
157    runtime: Option<&Arc<crate::executor::streaming::runtime::ExecutionRuntime>>,
158) -> Result<Vec<Option<SpilledRun>>, QueryError> {
159    match runtime.and_then(|rt| rt.get_spill_manager()) {
160        Some(sm) => {
161            let runs = spiller.finalize_with_manager(&sm)?;
162            if let Some(rt) = runtime {
163                let stats = rt.columnar_stats();
164                for run in runs.iter().flatten() {
165                    stats.record_spill(run.row_count, run.byte_size);
166                }
167            }
168            Ok(runs)
169        }
170        None => {
171            let mut runs = Vec::with_capacity(spiller.writers.len());
172            for writer in spiller.writers.drain(..) {
173                match writer {
174                    Some(w) => runs.push(Some(w.finalize()?)),
175                    None => runs.push(None),
176                }
177            }
178            Ok(runs)
179        }
180    }
181}
182
183impl Drop for SpillManager {
184    fn drop(&mut self) {
185        let _ = std::fs::remove_dir(&self.base_dir);
186    }
187}