Skip to main content

oxirs_arq/executor/
spill_manager.rs

1//! Disk Spilling Manager for Memory-Efficient Query Execution
2//!
3//! This module provides disk spilling capabilities when memory pressure is high,
4//! enabling execution of queries that would otherwise exceed available memory.
5
6use crate::algebra::{Binding, Solution, Term, Variable};
7use anyhow::{anyhow, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs::{remove_file, File};
11use std::io::{BufReader, BufWriter, Read, Write};
12use std::path::PathBuf;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, RwLock};
15
16/// JSON-serializable wrapper for Solution
17#[derive(Debug, Clone, Serialize, Deserialize)]
18struct SerializableSolution(Vec<SerializableBinding>);
19
20/// JSON-serializable wrapper for Binding
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct SerializableBinding(Vec<(String, Term)>);
23
24impl From<&Solution> for SerializableSolution {
25    fn from(solution: &Solution) -> Self {
26        SerializableSolution(solution.iter().map(SerializableBinding::from).collect())
27    }
28}
29
30impl From<SerializableSolution> for Solution {
31    fn from(ser: SerializableSolution) -> Self {
32        ser.0
33            .into_iter()
34            .map(|ser_binding| {
35                let mut binding = Binding::new();
36                for (var_name, term) in ser_binding.0 {
37                    if let Ok(var) = Variable::new(&var_name) {
38                        binding.insert(var, term);
39                    }
40                }
41                binding
42            })
43            .collect()
44    }
45}
46
47impl From<&Binding> for SerializableBinding {
48    fn from(binding: &Binding) -> Self {
49        SerializableBinding(
50            binding
51                .iter()
52                .map(|(var, term)| (var.to_string(), term.clone()))
53                .collect(),
54        )
55    }
56}
57
58/// Spill manager for disk-based overflow handling
59pub struct SpillManager {
60    spill_dir: PathBuf,
61    config: SpillConfig,
62    current_memory_bytes: Arc<AtomicU64>,
63    active_spills: Arc<RwLock<HashMap<SpillId, SpillFile>>>,
64}
65
66/// Configuration for spilling operations
67#[derive(Debug, Clone)]
68pub struct SpillConfig {
69    pub spill_threshold_percent: f64, // 0.8 (80% of memory)
70    pub max_memory_mb: usize,         // 2048 MB
71    pub spill_dir: PathBuf,           // /tmp/oxirs_spill
72    pub compression: bool,            // true
73}
74
75impl Default for SpillConfig {
76    fn default() -> Self {
77        Self {
78            spill_threshold_percent: 0.8,
79            max_memory_mb: 2048,
80            spill_dir: std::env::temp_dir().join("oxirs_spill"),
81            compression: true,
82        }
83    }
84}
85
86/// Metadata for a spilled file
87#[derive(Debug, Clone)]
88pub struct SpillFile {
89    pub id: SpillId,
90    pub path: PathBuf,
91    pub size: usize,
92    pub num_rows: usize,
93    pub compressed: bool,
94}
95
96/// Unique identifier for a spill file
97#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
98pub struct SpillId(u64);
99
100impl SpillId {
101    fn new() -> Self {
102        static COUNTER: AtomicU64 = AtomicU64::new(1);
103        SpillId(COUNTER.fetch_add(1, Ordering::Relaxed))
104    }
105}
106
107impl SpillManager {
108    /// Create a new spill manager
109    pub fn new(config: SpillConfig) -> Result<Self> {
110        // Create a unique subdirectory for this manager instance to avoid conflicts
111        // between parallel tests
112        let unique_id = SpillId::new();
113        let spill_dir = config.spill_dir.join(format!("instance_{}", unique_id.0));
114
115        // Ensure spill directory exists
116        std::fs::create_dir_all(&spill_dir)?;
117
118        Ok(Self {
119            spill_dir,
120            config,
121            current_memory_bytes: Arc::new(AtomicU64::new(0)),
122            active_spills: Arc::new(RwLock::new(HashMap::new())),
123        })
124    }
125
126    /// Check if should spill to disk
127    pub fn should_spill(&self) -> bool {
128        let current_memory_mb = self.current_memory_usage_mb();
129        let threshold_mb =
130            (self.config.max_memory_mb as f64 * self.config.spill_threshold_percent) as usize;
131
132        current_memory_mb > threshold_mb
133    }
134
135    /// Get current memory usage in MB
136    pub fn current_memory_usage_mb(&self) -> usize {
137        (self.current_memory_bytes.load(Ordering::Relaxed) / (1024 * 1024)) as usize
138    }
139
140    /// Spill solution data to disk
141    pub fn spill(&mut self, data: &Solution) -> Result<SpillId> {
142        if data.is_empty() {
143            return Err(anyhow!("Cannot spill empty solution"));
144        }
145
146        let spill_id = SpillId::new();
147        let path = self.spill_dir.join(format!("spill_{}.bin", spill_id.0));
148
149        let spill = if self.config.compression {
150            // Compress before writing
151            let compressed = self.compress_solution(data)?;
152
153            {
154                let file = File::create(&path)?;
155                let mut writer = BufWriter::new(file);
156                writer.write_all(&compressed)?;
157                writer.flush()?;
158                // Explicitly drop writer to ensure file is closed
159                drop(writer);
160            }
161
162            let size = compressed.len();
163            let spill = SpillFile {
164                id: spill_id,
165                path: path.clone(),
166                size,
167                num_rows: data.len(),
168                compressed: true,
169            };
170
171            tracing::info!(
172                "Spilled {} rows to {} (compressed, {} bytes)",
173                data.len(),
174                path.display(),
175                size
176            );
177
178            spill
179        } else {
180            // Write directly without compression using serde_json
181            let serializable = SerializableSolution::from(data);
182
183            {
184                let file = File::create(&path)?;
185                let mut writer = BufWriter::new(file);
186                serde_json::to_writer(&mut writer, &serializable)?;
187                writer.flush()?;
188                // Explicitly drop writer to ensure file is closed
189                drop(writer);
190            }
191
192            let size = std::fs::metadata(&path)?.len() as usize;
193            let spill = SpillFile {
194                id: spill_id,
195                path: path.clone(),
196                size,
197                num_rows: data.len(),
198                compressed: false,
199            };
200
201            tracing::info!(
202                "Spilled {} rows to {} ({} bytes)",
203                data.len(),
204                path.display(),
205                size
206            );
207
208            spill
209        };
210
211        self.active_spills
212            .write()
213            .map_err(|_| anyhow!("Failed to acquire write lock"))?
214            .insert(spill_id, spill);
215
216        Ok(spill_id)
217    }
218
219    /// Spill individual bindings to disk
220    pub fn spill_bindings(&mut self, data: &[Binding]) -> Result<SpillId> {
221        let solution: Solution = data.to_vec();
222        self.spill(&solution)
223    }
224
225    /// Read spilled data back from disk
226    pub fn read_spill(&self, spill_id: SpillId) -> Result<Solution> {
227        let spills = self
228            .active_spills
229            .read()
230            .map_err(|_| anyhow!("Failed to acquire read lock"))?;
231        let spill = spills
232            .get(&spill_id)
233            .ok_or_else(|| anyhow!("Spill {} not found", spill_id.0))?;
234
235        let file = File::open(&spill.path)?;
236        let mut reader = BufReader::new(file);
237
238        let data = if spill.compressed {
239            let mut compressed = Vec::new();
240            reader.read_to_end(&mut compressed)?;
241            self.decompress_solution(&compressed)?
242        } else {
243            let serializable: SerializableSolution = serde_json::from_reader(&mut reader)?;
244            Solution::from(serializable)
245        };
246
247        tracing::debug!("Read {} rows from spill {}", data.len(), spill_id.0);
248        Ok(data)
249    }
250
251    /// Read spill as iterator for streaming
252    pub fn read_spill_streaming(&self, spill_id: SpillId) -> Result<SpillIterator> {
253        let spills = self
254            .active_spills
255            .read()
256            .map_err(|_| anyhow!("Failed to acquire read lock"))?;
257        let spill = spills
258            .get(&spill_id)
259            .ok_or_else(|| anyhow!("Spill {} not found", spill_id.0))?
260            .clone();
261
262        SpillIterator::new(spill)
263    }
264
265    /// Clean up spill file
266    pub fn cleanup(&mut self, spill_id: SpillId) -> Result<()> {
267        let mut spills = self
268            .active_spills
269            .write()
270            .map_err(|_| anyhow!("Failed to acquire write lock"))?;
271        if let Some(spill) = spills.remove(&spill_id) {
272            if spill.path.exists() {
273                remove_file(&spill.path)?;
274            }
275            tracing::debug!("Cleaned up spill {}", spill_id.0);
276        }
277        Ok(())
278    }
279
280    /// Clean up all spills
281    pub fn cleanup_all(&mut self) -> Result<()> {
282        let mut spills = self
283            .active_spills
284            .write()
285            .map_err(|_| anyhow!("Failed to acquire write lock"))?;
286
287        for (_, spill) in spills.drain() {
288            if spill.path.exists() {
289                if let Err(e) = remove_file(&spill.path) {
290                    tracing::warn!(
291                        "Failed to remove spill file {}: {}",
292                        spill.path.display(),
293                        e
294                    );
295                }
296            }
297        }
298
299        tracing::info!("Cleaned up all spill files");
300        Ok(())
301    }
302
303    /// Get statistics about active spills
304    pub fn statistics(&self) -> Result<SpillStatistics> {
305        let spills = self
306            .active_spills
307            .read()
308            .map_err(|_| anyhow!("Failed to acquire read lock"))?;
309
310        let total_size = spills.values().map(|s| s.size).sum();
311        let total_rows = spills.values().map(|s| s.num_rows).sum();
312
313        Ok(SpillStatistics {
314            num_spills: spills.len(),
315            total_size_bytes: total_size,
316            total_rows,
317            average_compression_ratio: self.calculate_average_compression_ratio(&spills),
318        })
319    }
320
321    fn calculate_average_compression_ratio(&self, spills: &HashMap<SpillId, SpillFile>) -> f64 {
322        let compressed_spills: Vec<_> = spills.values().filter(|s| s.compressed).collect();
323
324        if compressed_spills.is_empty() {
325            return 1.0;
326        }
327
328        // Estimate: assume 80% compression for compressed files
329        0.2
330    }
331
332    /// Compress solution data
333    fn compress_solution(&self, data: &Solution) -> Result<Vec<u8>> {
334        // Gzip (RFC 1952) compression via Pure-Rust oxiarc-deflate.
335        // Level 6 is the balanced default.
336        let serializable = SerializableSolution::from(data);
337        let json_data = serde_json::to_vec(&serializable)?;
338        let compressed = oxiarc_deflate::gzip_compress(&json_data, 6)?;
339        Ok(compressed)
340    }
341
342    /// Decompress solution data
343    fn decompress_solution(&self, compressed: &[u8]) -> Result<Solution> {
344        // Gzip (RFC 1952) decompression via Pure-Rust oxiarc-deflate.
345        let json_data = oxiarc_deflate::gzip_decompress(compressed)?;
346        let serializable: SerializableSolution = serde_json::from_slice(&json_data)?;
347        Ok(Solution::from(serializable))
348    }
349
350    /// Get number of active spills
351    pub fn num_active_spills(&self) -> usize {
352        self.active_spills.read().map(|s| s.len()).unwrap_or(0)
353    }
354}
355
356impl Drop for SpillManager {
357    fn drop(&mut self) {
358        // Clean up all spill files on drop
359        if let Err(e) = self.cleanup_all() {
360            tracing::warn!("Failed to clean up spill files in Drop: {}", e);
361        }
362    }
363}
364
365/// Statistics about spilling operations
366#[derive(Debug, Clone)]
367pub struct SpillStatistics {
368    pub num_spills: usize,
369    pub total_size_bytes: usize,
370    pub total_rows: usize,
371    pub average_compression_ratio: f64,
372}
373
374/// Iterator over spilled data for streaming reads
375pub struct SpillIterator {
376    spill: SpillFile,
377    buffer: Option<Solution>,
378    current_index: usize,
379}
380
381impl SpillIterator {
382    fn new(spill: SpillFile) -> Result<Self> {
383        Ok(Self {
384            spill,
385            buffer: None,
386            current_index: 0,
387        })
388    }
389
390    fn load_buffer(&mut self) -> Result<()> {
391        let file = File::open(&self.spill.path)?;
392        let mut reader = BufReader::new(file);
393
394        let data = if self.spill.compressed {
395            // Gzip (RFC 1952) decompression via Pure-Rust oxiarc-deflate.
396            let mut compressed = Vec::new();
397            reader.read_to_end(&mut compressed)?;
398            let json_data = oxiarc_deflate::gzip_decompress(&compressed)?;
399            let serializable: SerializableSolution = serde_json::from_slice(&json_data)?;
400            Solution::from(serializable)
401        } else {
402            let serializable: SerializableSolution = serde_json::from_reader(&mut reader)?;
403            Solution::from(serializable)
404        };
405
406        self.buffer = Some(data);
407        self.current_index = 0;
408        Ok(())
409    }
410}
411
412impl Iterator for SpillIterator {
413    type Item = Result<Binding>;
414
415    fn next(&mut self) -> Option<Self::Item> {
416        // Load buffer on first access
417        if self.buffer.is_none() {
418            if let Err(e) = self.load_buffer() {
419                return Some(Err(e));
420            }
421        }
422
423        if let Some(ref buffer) = self.buffer {
424            if self.current_index < buffer.len() {
425                let binding = buffer[self.current_index].clone();
426                self.current_index += 1;
427                Some(Ok(binding))
428            } else {
429                None
430            }
431        } else {
432            None
433        }
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::algebra::Variable;
441
442    fn create_test_solution(size: usize) -> Solution {
443        let mut solution = Solution::new();
444        for i in 0..size {
445            let mut binding = Binding::new();
446            binding.insert(
447                Variable::new(format!("x{}", i)).expect("valid variable name"),
448                crate::algebra::Term::Iri(
449                    oxirs_core::model::NamedNode::new(format!("http://example.org/item{}", i))
450                        .expect("valid IRI"),
451                ),
452            );
453            solution.push(binding);
454        }
455        solution
456    }
457
458    #[test]
459    fn test_spill_manager_creation() {
460        let config = SpillConfig::default();
461        let manager = SpillManager::new(config);
462        assert!(manager.is_ok());
463    }
464
465    #[test]
466    fn test_spill_and_read() {
467        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
468        let config = SpillConfig {
469            spill_dir: temp_dir.path().to_path_buf(),
470            ..SpillConfig::default()
471        };
472        let mut manager = SpillManager::new(config).expect("Failed to create manager");
473
474        let test_data = create_test_solution(100);
475        let spill_id = manager.spill(&test_data).expect("Failed to spill");
476
477        let read_data = manager.read_spill(spill_id).expect("Failed to read spill");
478        assert_eq!(test_data.len(), read_data.len());
479
480        manager.cleanup(spill_id).expect("Failed to cleanup");
481    }
482
483    #[test]
484    fn test_spill_statistics() {
485        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
486        let config = SpillConfig {
487            spill_dir: temp_dir.path().to_path_buf(),
488            ..SpillConfig::default()
489        };
490        let mut manager = SpillManager::new(config).expect("Failed to create manager");
491
492        let test_data = create_test_solution(50);
493        let _spill_id = manager.spill(&test_data).expect("Failed to spill");
494
495        let stats = manager.statistics().expect("Failed to get statistics");
496        assert_eq!(stats.num_spills, 1);
497        assert_eq!(stats.total_rows, 50);
498
499        manager.cleanup_all().expect("Failed to cleanup");
500    }
501}