Skip to main content

oxirs_arq/executor/
streaming.rs

1//! Streaming Execution and Spilling Support
2//!
3//! This module provides streaming execution capabilities for large result sets
4//! and memory-efficient spilling strategies for operations that exceed memory limits.
5
6use crate::algebra::{Binding, Solution, Term, Variable};
7use anyhow::{anyhow, Result};
8use oxirs_core::model::NamedNode;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11use std::fs::{remove_file, File};
12use std::io::{BufReader, BufWriter, Read, Write};
13use std::path::PathBuf;
14use std::sync::{Arc, Mutex};
15use tempfile::NamedTempFile;
16
17/// Configuration for streaming and spilling operations
18#[derive(Debug, Clone)]
19pub struct StreamingConfig {
20    /// Maximum memory usage before spilling (in bytes)
21    pub memory_limit: usize,
22    /// Temporary directory for spill files
23    pub temp_dir: Option<PathBuf>,
24    /// Maximum number of solutions to buffer
25    pub buffer_size: usize,
26    /// Enable compression for spill files
27    pub compress_spills: bool,
28    /// Spilling strategy
29    pub spill_strategy: SpillStrategy,
30    /// Enable adaptive buffering
31    pub adaptive_buffering: bool,
32    /// Enable parallel spilling
33    pub parallel_spilling: bool,
34    /// Compression algorithm choice
35    pub compression_algorithm: CompressionAlgorithm,
36}
37
38/// Spilling strategy options
39#[derive(Debug, Clone)]
40pub enum SpillStrategy {
41    /// Spill oldest data first (FIFO)
42    Fifo,
43    /// Spill largest chunks first
44    LargestFirst,
45    /// Spill based on access frequency (LRU)
46    LeastRecentlyUsed,
47    /// Adaptive strategy based on workload
48    Adaptive,
49}
50
51/// Compression algorithm options
52#[derive(Debug, Clone)]
53pub enum CompressionAlgorithm {
54    /// No compression
55    None,
56    /// Fast LZ4 compression
57    Lz4,
58    /// Balanced gzip compression
59    Gzip,
60    /// High compression zstd
61    Zstd,
62}
63
64impl Default for StreamingConfig {
65    fn default() -> Self {
66        Self {
67            memory_limit: 1024 * 1024 * 1024, // 1GB default
68            temp_dir: None,
69            buffer_size: 10000,
70            compress_spills: true,
71            spill_strategy: SpillStrategy::Adaptive,
72            adaptive_buffering: true,
73            parallel_spilling: true,
74            compression_algorithm: CompressionAlgorithm::Zstd,
75        }
76    }
77}
78
79/// Memory usage tracker with adaptive management
80#[derive(Debug, Clone)]
81pub struct MemoryTracker {
82    current_usage: Arc<Mutex<usize>>,
83    peak_usage: Arc<Mutex<usize>>,
84    limit: usize,
85    allocation_history: Arc<Mutex<Vec<AllocationEvent>>>,
86    pressure_threshold: f64,
87    prediction_window: usize,
88}
89
90/// Allocation event for tracking patterns
91#[derive(Debug, Clone)]
92struct AllocationEvent {
93    timestamp: std::time::Instant,
94    size: usize,
95    operation: AllocationType,
96}
97
98#[derive(Debug, Clone)]
99enum AllocationType {
100    Allocate,
101    Deallocate,
102}
103
104impl MemoryTracker {
105    pub fn new(limit: usize) -> Self {
106        Self {
107            current_usage: Arc::new(Mutex::new(0)),
108            peak_usage: Arc::new(Mutex::new(0)),
109            limit,
110            allocation_history: Arc::new(Mutex::new(Vec::new())),
111            pressure_threshold: 0.8, // Start adaptive behavior at 80%
112            prediction_window: 100,  // Track last 100 allocations
113        }
114    }
115
116    /// Create tracker with custom pressure threshold
117    pub fn with_pressure_threshold(limit: usize, threshold: f64) -> Self {
118        Self {
119            current_usage: Arc::new(Mutex::new(0)),
120            peak_usage: Arc::new(Mutex::new(0)),
121            limit,
122            allocation_history: Arc::new(Mutex::new(Vec::new())),
123            pressure_threshold: threshold,
124            prediction_window: 100,
125        }
126    }
127
128    pub fn allocate(&self, size: usize) -> Result<bool> {
129        let mut current = self.current_usage.lock().expect("lock poisoned");
130        let new_usage = *current + size;
131
132        // Record allocation attempt
133        self.record_allocation_event(size, AllocationType::Allocate);
134
135        if new_usage > self.limit {
136            return Ok(false); // Cannot allocate, need to spill
137        }
138
139        *current = new_usage;
140
141        let mut peak = self.peak_usage.lock().expect("lock poisoned");
142        if new_usage > *peak {
143            *peak = new_usage;
144        }
145
146        Ok(true)
147    }
148
149    pub fn deallocate(&self, size: usize) {
150        let mut current = self.current_usage.lock().expect("lock poisoned");
151        *current = current.saturating_sub(size);
152
153        // Record deallocation
154        self.record_allocation_event(size, AllocationType::Deallocate);
155    }
156
157    /// Record allocation event for pattern analysis
158    fn record_allocation_event(&self, size: usize, operation: AllocationType) {
159        let mut history = self.allocation_history.lock().expect("lock poisoned");
160
161        history.push(AllocationEvent {
162            timestamp: std::time::Instant::now(),
163            size,
164            operation,
165        });
166
167        // Keep only recent events
168        let history_len = history.len();
169        if history_len > self.prediction_window {
170            history.drain(0..history_len - self.prediction_window);
171        }
172    }
173
174    pub fn current_usage(&self) -> usize {
175        *self.current_usage.lock().expect("lock poisoned")
176    }
177
178    pub fn peak_usage(&self) -> usize {
179        *self.peak_usage.lock().expect("lock poisoned")
180    }
181
182    pub fn should_spill(&self) -> bool {
183        let usage_ratio = self.current_usage() as f64 / self.limit as f64;
184        usage_ratio > self.pressure_threshold
185    }
186
187    /// Adaptive spilling decision based on allocation patterns
188    pub fn should_spill_adaptive(&self) -> bool {
189        let current_ratio = self.current_usage() as f64 / self.limit as f64;
190
191        // Basic threshold check
192        if current_ratio > self.pressure_threshold {
193            return true;
194        }
195
196        // Check allocation velocity (rate of growth)
197        let allocation_velocity = self.calculate_allocation_velocity();
198        let predicted_usage = self.predict_memory_usage(allocation_velocity);
199
200        // Spill early if we predict memory pressure
201        if predicted_usage > self.limit as f64 * 0.9 {
202            return true;
203        }
204
205        false
206    }
207
208    /// Calculate recent allocation velocity (bytes per second)
209    fn calculate_allocation_velocity(&self) -> f64 {
210        let history = self.allocation_history.lock().expect("lock poisoned");
211
212        if history.len() < 2 {
213            return 0.0;
214        }
215
216        let now = std::time::Instant::now();
217        let window_duration = std::time::Duration::from_secs(5); // 5 second window
218
219        let mut net_allocation = 0i64;
220        let mut oldest_timestamp = now;
221
222        for event in history.iter().rev() {
223            if now.duration_since(event.timestamp) > window_duration {
224                break;
225            }
226
227            match event.operation {
228                AllocationType::Allocate => net_allocation += event.size as i64,
229                AllocationType::Deallocate => net_allocation -= event.size as i64,
230            }
231
232            oldest_timestamp = event.timestamp;
233        }
234
235        let elapsed = now.duration_since(oldest_timestamp).as_secs_f64();
236        if elapsed > 0.0 {
237            net_allocation as f64 / elapsed
238        } else {
239            0.0
240        }
241    }
242
243    /// Predict memory usage based on current velocity
244    fn predict_memory_usage(&self, velocity: f64) -> f64 {
245        let current = self.current_usage() as f64;
246        let prediction_horizon = 2.0; // 2 seconds ahead
247
248        current + (velocity * prediction_horizon).max(0.0)
249    }
250
251    /// Get detailed memory statistics
252    pub fn get_detailed_stats(&self) -> MemoryStats {
253        let history = self.allocation_history.lock().expect("lock poisoned");
254
255        let total_allocations = history
256            .iter()
257            .filter(|e| matches!(e.operation, AllocationType::Allocate))
258            .count();
259
260        let total_deallocations = history
261            .iter()
262            .filter(|e| matches!(e.operation, AllocationType::Deallocate))
263            .count();
264
265        let avg_allocation_size = history
266            .iter()
267            .filter(|e| matches!(e.operation, AllocationType::Allocate))
268            .map(|e| e.size)
269            .sum::<usize>()
270            .checked_div(total_allocations)
271            .unwrap_or(0);
272
273        MemoryStats {
274            current_usage: self.current_usage(),
275            peak_usage: self.peak_usage(),
276            total_allocations,
277            total_deallocations,
278            avg_allocation_size,
279            allocation_velocity: self.calculate_allocation_velocity(),
280            pressure_ratio: self.current_usage() as f64 / self.limit as f64,
281        }
282    }
283
284    /// Adaptive pressure threshold based on workload
285    pub fn adjust_pressure_threshold(&mut self, workload_intensity: f64) {
286        // Lower threshold for high-intensity workloads to spill earlier
287        self.pressure_threshold = (0.6 + (0.3 * (1.0 - workload_intensity))).clamp(0.5, 0.9);
288    }
289}
290
291/// Detailed memory statistics
292#[derive(Debug, Clone)]
293pub struct MemoryStats {
294    pub current_usage: usize,
295    pub peak_usage: usize,
296    pub total_allocations: usize,
297    pub total_deallocations: usize,
298    pub avg_allocation_size: usize,
299    pub allocation_velocity: f64,
300    pub pressure_ratio: f64,
301}
302
303/// Streaming solution iterator with memory management
304pub struct StreamingSolution {
305    solutions: VecDeque<Solution>,
306    spill_files: Vec<SpillFile>,
307    current_spill_idx: usize,
308    memory_tracker: MemoryTracker,
309    config: StreamingConfig,
310    finished: bool,
311}
312
313/// Spill file for temporary storage
314#[derive(Debug)]
315struct SpillFile {
316    path: PathBuf,
317    size: usize,
318    compressed: bool,
319}
320
321impl StreamingSolution {
322    pub fn new(config: StreamingConfig) -> Self {
323        let memory_tracker = MemoryTracker::new(config.memory_limit);
324
325        Self {
326            solutions: VecDeque::new(),
327            spill_files: Vec::new(),
328            current_spill_idx: 0,
329            memory_tracker,
330            config,
331            finished: false,
332        }
333    }
334
335    /// Add a solution to the stream
336    pub fn add_solution(&mut self, solution: Solution) -> Result<()> {
337        let solution_size = self.estimate_solution_size(&solution);
338
339        if !self.memory_tracker.allocate(solution_size)? {
340            // Need to spill to disk
341            self.spill_to_disk()?;
342            // Try again after spilling
343            if !self.memory_tracker.allocate(solution_size)? {
344                return Err(anyhow!("Cannot allocate memory even after spilling"));
345            }
346        }
347
348        self.solutions.push_back(solution);
349
350        // Check if we need to spill proactively using adaptive strategy
351        let should_spill = if self.config.adaptive_buffering {
352            self.memory_tracker.should_spill_adaptive()
353        } else {
354            self.memory_tracker.should_spill()
355        };
356
357        if self.solutions.len() >= self.config.buffer_size || should_spill {
358            self.spill_to_disk()?;
359        }
360
361        Ok(())
362    }
363
364    /// Estimate the memory size of a solution
365    fn estimate_solution_size(&self, solution: &Solution) -> usize {
366        let mut size = std::mem::size_of::<Solution>();
367        for binding in solution {
368            size += binding
369                .iter()
370                .map(|(var, term)| var.as_str().len() + self.estimate_term_size(term))
371                .sum::<usize>();
372        }
373        size
374    }
375
376    /// Estimate the memory size of a term
377    #[allow(clippy::only_used_in_recursion)]
378    fn estimate_term_size(&self, term: &Term) -> usize {
379        match term {
380            Term::Iri(iri) => iri.as_str().len(),
381            Term::Literal(lit) => lit.value.len() + lit.language.as_ref().map_or(0, |l| l.len()),
382            Term::BlankNode(bn) => bn.len(),
383            Term::Variable(var) => var.as_str().len(),
384            Term::QuotedTriple(triple) => {
385                // Estimate size of quoted triple as sum of its parts
386                self.estimate_term_size(&triple.subject)
387                    + self.estimate_term_size(&triple.predicate)
388                    + self.estimate_term_size(&triple.object)
389                    + 6 // << >> brackets
390            }
391            Term::PropertyPath(path) => {
392                // Estimate property path size based on complexity
393                match path.complexity() {
394                    c if c < 10 => 20,
395                    c if c < 100 => 50,
396                    _ => 100,
397                }
398            }
399        }
400    }
401
402    /// Spill current solutions to disk
403    fn spill_to_disk(&mut self) -> Result<()> {
404        if self.solutions.is_empty() {
405            return Ok(());
406        }
407
408        let temp_file = if let Some(ref temp_dir) = self.config.temp_dir {
409            NamedTempFile::new_in(temp_dir)?
410        } else {
411            NamedTempFile::new()?
412        };
413
414        // Serialize solutions to temporary file
415        let serialized_solutions: Vec<SerializableSolution> = self
416            .solutions
417            .iter()
418            .map(SerializableSolution::from_solution)
419            .collect();
420
421        let data = if self.config.compress_spills {
422            self.compress_data(&serialized_solutions)?
423        } else {
424            oxicode::serde::encode_to_vec(&serialized_solutions, oxicode::config::standard())?
425        };
426
427        {
428            let mut writer = BufWriter::new(&temp_file);
429            writer.write_all(&data)?;
430            writer.flush()?;
431        }
432
433        // Persist the temp file to prevent automatic deletion
434        let original_path = temp_file.path().to_path_buf();
435        let new_path = original_path.with_extension("spill");
436        temp_file.persist(&new_path)?;
437        let path = new_path;
438
439        // Track spill file
440        let spill_file = SpillFile {
441            path,
442            size: data.len(),
443            compressed: self.config.compress_spills,
444        };
445        self.spill_files.push(spill_file);
446
447        // Clear in-memory solutions and deallocate memory
448        let total_size: usize = self
449            .solutions
450            .iter()
451            .map(|sol| self.estimate_solution_size(sol))
452            .sum();
453        self.memory_tracker.deallocate(total_size);
454        self.solutions.clear();
455
456        Ok(())
457    }
458
459    /// Compress data using configured compression algorithm
460    fn compress_data(&self, data: &[SerializableSolution]) -> Result<Vec<u8>> {
461        let serialized = oxicode::serde::encode_to_vec(&data, oxicode::config::standard())?;
462
463        match self.config.compression_algorithm {
464            CompressionAlgorithm::None => Ok(serialized),
465            CompressionAlgorithm::Lz4 => {
466                // Fast LZ4 compression for performance-critical scenarios
467                oxiarc_lz4::compress(&serialized)
468                    .map_err(|e| anyhow!("LZ4 compression failed: {}", e))
469            }
470            CompressionAlgorithm::Gzip => {
471                // Balanced gzip compression (RFC 1952) via Pure-Rust oxiarc-deflate.
472                // Level 6 is the balanced default.
473                Ok(oxiarc_deflate::gzip_compress(&serialized, 6)?)
474            }
475            CompressionAlgorithm::Zstd => {
476                // High compression zstd for space-critical scenarios
477                oxiarc_zstd::encode_all(&serialized, 3)
478                    .map_err(|e| anyhow!("Zstd compression failed: {}", e))
479            }
480        }
481    }
482
483    /// Decompress data using configured compression algorithm
484    fn decompress_data(&self, compressed: &[u8]) -> Result<Vec<SerializableSolution>> {
485        let decompressed = match self.config.compression_algorithm {
486            CompressionAlgorithm::None => {
487                // Data is not compressed, use directly
488                compressed.to_vec()
489            }
490            CompressionAlgorithm::Lz4 => {
491                // LZ4 decompression
492                oxiarc_lz4::decompress(compressed, 100 * 1024 * 1024)
493                    .map_err(|e| anyhow!("LZ4 decompression failed: {}", e))?
494            }
495            CompressionAlgorithm::Gzip => {
496                // Gzip decompression (RFC 1952) via Pure-Rust oxiarc-deflate.
497                oxiarc_deflate::gzip_decompress(compressed)?
498            }
499            CompressionAlgorithm::Zstd => {
500                // Zstd decompression
501                oxiarc_zstd::decode_all(compressed)
502                    .map_err(|e| anyhow!("Zstd decompression failed: {}", e))?
503            }
504        };
505
506        Ok(
507            oxicode::serde::decode_from_slice(&decompressed, oxicode::config::standard())
508                .map(|(v, _)| v)?,
509        )
510    }
511
512    /// Load solutions from next spill file
513    fn load_from_spill(&mut self) -> Result<bool> {
514        if self.current_spill_idx >= self.spill_files.len() {
515            return Ok(false);
516        }
517
518        let spill_file = &self.spill_files[self.current_spill_idx];
519        let file = File::open(&spill_file.path)?;
520        let mut reader = BufReader::new(file);
521
522        let mut data = Vec::new();
523        reader.read_to_end(&mut data)?;
524
525        let serialized_solutions = if spill_file.compressed {
526            self.decompress_data(&data)?
527        } else {
528            oxicode::serde::decode_from_slice(&data, oxicode::config::standard()).map(|(v, _)| v)?
529        };
530
531        // Convert back to solutions
532        for serialized in serialized_solutions {
533            let solution = serialized.to_solution();
534            self.solutions.push_back(solution);
535        }
536
537        self.current_spill_idx += 1;
538        Ok(true)
539    }
540
541    /// Mark the stream as finished (no more solutions will be added)
542    pub fn finish(&mut self) {
543        self.finished = true;
544    }
545
546    /// Get statistics about memory usage and spilling
547    pub fn get_stats(&self) -> StreamingStats {
548        StreamingStats {
549            current_memory: self.memory_tracker.current_usage(),
550            peak_memory: self.memory_tracker.peak_usage(),
551            spill_files: self.spill_files.len(),
552            total_spill_size: self.spill_files.iter().map(|f| f.size).sum(),
553            in_memory_solutions: self.solutions.len(),
554        }
555    }
556}
557
558impl Iterator for StreamingSolution {
559    type Item = Result<Solution>;
560
561    fn next(&mut self) -> Option<Self::Item> {
562        // First try to get from in-memory solutions
563        if let Some(solution) = self.solutions.pop_front() {
564            // Deallocate memory for this solution
565            let size = self.estimate_solution_size(&solution);
566            self.memory_tracker.deallocate(size);
567            return Some(Ok(solution));
568        }
569
570        // If no in-memory solutions, try to load from spill files only if they exist
571        if self.current_spill_idx < self.spill_files.len() {
572            match self.load_from_spill() {
573                Ok(true) => {
574                    // Successfully loaded from spill, try again
575                    if let Some(solution) = self.solutions.pop_front() {
576                        let size = self.estimate_solution_size(&solution);
577                        self.memory_tracker.deallocate(size);
578                        return Some(Ok(solution));
579                    }
580                }
581                Ok(false) => {
582                    // No more spill files
583                }
584                Err(e) => {
585                    return Some(Err(e));
586                }
587            }
588        }
589
590        None
591    }
592}
593
594impl Drop for StreamingSolution {
595    fn drop(&mut self) {
596        // Clean up spill files
597        for spill_file in &self.spill_files {
598            let _ = remove_file(&spill_file.path);
599        }
600    }
601}
602
603/// Serializable version of Solution for disk storage
604#[derive(Serialize, Deserialize)]
605struct SerializableSolution {
606    bindings: Vec<SerializableBinding>,
607}
608
609#[derive(Serialize, Deserialize)]
610struct SerializableBinding {
611    variable: String,
612    term: SerializableTerm,
613}
614
615#[derive(Serialize, Deserialize)]
616enum SerializableTerm {
617    Iri(String),
618    Literal {
619        value: String,
620        language: Option<String>,
621        datatype: Option<String>,
622    },
623    BlankNode(String),
624    Variable(String),
625}
626
627impl SerializableSolution {
628    fn from_solution(solution: &Solution) -> Self {
629        let mut bindings = Vec::new();
630        for binding in solution {
631            for (var, term) in binding {
632                bindings.push(SerializableBinding {
633                    variable: var.as_str().to_string(),
634                    term: SerializableTerm::from_term(term),
635                });
636            }
637        }
638        Self { bindings }
639    }
640
641    fn to_solution(&self) -> Solution {
642        let mut solution = Solution::new();
643        let mut current_binding = Binding::new();
644
645        for binding in &self.bindings {
646            current_binding.insert(
647                Variable::new(&binding.variable).expect("variable name should be valid"),
648                binding.term.to_term(),
649            );
650        }
651
652        if !current_binding.is_empty() {
653            solution.push(current_binding);
654        }
655
656        solution
657    }
658}
659
660impl SerializableTerm {
661    fn from_term(term: &Term) -> Self {
662        match term {
663            Term::Iri(iri) => Self::Iri(iri.as_str().to_string()),
664            Term::Literal(lit) => Self::Literal {
665                value: lit.value.clone(),
666                language: lit.language.clone(),
667                datatype: lit.datatype.as_ref().map(|dt| dt.as_str().to_string()),
668            },
669            Term::BlankNode(bn) => Self::BlankNode(bn.clone()),
670            Term::Variable(var) => Self::Variable(var.as_str().to_string()),
671            Term::QuotedTriple(triple) => {
672                // For quoted triples, serialize as a string representation
673                Self::Literal {
674                    value: format!(
675                        "<<{} {} {}>>",
676                        triple.subject, triple.predicate, triple.object
677                    ),
678                    language: None,
679                    datatype: Some("http://example.org/quoted-triple".to_string()),
680                }
681            }
682            Term::PropertyPath(path) => {
683                // For property paths, serialize as a string representation
684                Self::Literal {
685                    value: path.to_string(),
686                    language: None,
687                    datatype: Some("http://example.org/property-path".to_string()),
688                }
689            }
690        }
691    }
692
693    fn to_term(&self) -> Term {
694        match self {
695            Self::Iri(iri) => Term::Iri(NamedNode::new(iri).expect("IRI should be valid")),
696            Self::Literal {
697                value,
698                language,
699                datatype,
700            } => Term::Literal(crate::algebra::Literal {
701                value: value.clone(),
702                language: language.clone(),
703                datatype: datatype
704                    .as_ref()
705                    .map(|dt| NamedNode::new(dt).expect("datatype IRI should be valid")),
706            }),
707            Self::BlankNode(bn) => Term::BlankNode(bn.clone()),
708            Self::Variable(var) => {
709                Term::Variable(Variable::new(var).expect("variable name should be valid"))
710            }
711        }
712    }
713}
714
715/// Spillable grace hash join for memory-efficient joins.
716///
717/// When a build-side (left) bucket is spilled to disk, the corresponding
718/// probe-side (right) rows are partitioned and spilled alongside it so that
719/// the two partitions can be replayed and joined in the cleanup phase. This is
720/// a classic grace hash join: spilled buckets produce correct join output
721/// rather than unmatched left rows.
722pub struct SpillableHashJoin {
723    config: StreamingConfig,
724    memory_tracker: MemoryTracker,
725    hash_buckets: Vec<HashMap<String, Vec<Solution>>>,
726    spill_buckets: Vec<Vec<SpillFile>>,
727    /// Right-side rows deferred for spilled buckets (in-memory portion).
728    right_buffers: Vec<Vec<Solution>>,
729    /// Right-side rows spilled to disk for spilled buckets.
730    right_spill_buckets: Vec<Vec<SpillFile>>,
731    num_buckets: usize,
732}
733
734impl SpillableHashJoin {
735    pub fn new(config: StreamingConfig) -> Self {
736        let num_buckets = 16; // Can be made configurable
737        let memory_tracker = MemoryTracker::new(config.memory_limit);
738
739        Self {
740            config,
741            memory_tracker,
742            hash_buckets: (0..num_buckets).map(|_| HashMap::new()).collect(),
743            spill_buckets: (0..num_buckets).map(|_| Vec::new()).collect(),
744            right_buffers: (0..num_buckets).map(|_| Vec::new()).collect(),
745            right_spill_buckets: (0..num_buckets).map(|_| Vec::new()).collect(),
746            num_buckets,
747        }
748    }
749
750    /// Execute spillable hash join
751    pub fn execute(
752        &mut self,
753        left: Vec<Solution>,
754        right: Vec<Solution>,
755        join_vars: &[Variable],
756    ) -> Result<Vec<Solution>> {
757        // Phase 1: Build hash table from left side with spilling
758        self.build_phase(left, join_vars)?;
759
760        // Phase 2: Probe with right side
761        let mut results = Vec::new();
762        self.probe_phase(right, join_vars, &mut results)?;
763
764        // Phase 3: Handle spilled buckets
765        self.handle_spilled_buckets(join_vars, &mut results)?;
766
767        Ok(results)
768    }
769
770    /// Build phase: create hash table from left relations
771    fn build_phase(&mut self, left: Vec<Solution>, join_vars: &[Variable]) -> Result<()> {
772        for solution in left {
773            let hash_key = self.create_hash_key(&solution, join_vars);
774            let bucket_idx = self.hash_to_bucket(&hash_key);
775
776            let solution_size = self.estimate_solution_size(&solution);
777
778            if !self.memory_tracker.allocate(solution_size)? {
779                // Spill this bucket
780                self.spill_bucket(bucket_idx)?;
781                // Try to allocate again
782                if !self.memory_tracker.allocate(solution_size)? {
783                    return Err(anyhow!("Cannot allocate memory even after spilling bucket"));
784                }
785            }
786
787            self.hash_buckets[bucket_idx]
788                .entry(hash_key)
789                .or_default()
790                .push(solution);
791        }
792
793        Ok(())
794    }
795
796    /// Probe phase: join with right relations.
797    ///
798    /// For buckets that were never spilled the whole build side is in memory,
799    /// so we probe immediately. For buckets that spilled at least once the
800    /// in-memory build side is incomplete (part of it is on disk), so probing
801    /// it now would miss matches with the spilled rows AND double-count the
802    /// residual rows joined again in the cleanup phase. Instead we partition
803    /// the right row to the same bucket (buffering, then spilling under memory
804    /// pressure) and defer the join to [`Self::handle_spilled_buckets`].
805    fn probe_phase(
806        &mut self,
807        right: Vec<Solution>,
808        join_vars: &[Variable],
809        results: &mut Vec<Solution>,
810    ) -> Result<()> {
811        for right_solution in right {
812            let hash_key = self.create_hash_key(&right_solution, join_vars);
813            let bucket_idx = self.hash_to_bucket(&hash_key);
814
815            if self.spill_buckets[bucket_idx].is_empty() {
816                // Fully in-memory bucket: probe now.
817                if let Some(left_solutions) = self.hash_buckets[bucket_idx].get(&hash_key) {
818                    for left_solution in left_solutions {
819                        if let Some(joined) =
820                            self.join_solutions(left_solution, &right_solution, join_vars)
821                        {
822                            results.push(joined);
823                        }
824                    }
825                }
826            } else {
827                // Spilled bucket: defer to grace-join over the full partition.
828                self.right_buffers[bucket_idx].push(right_solution);
829                if self.right_buffers[bucket_idx].len() >= self.config.buffer_size.max(1) {
830                    self.spill_right_buffer(bucket_idx)?;
831                }
832            }
833        }
834
835        Ok(())
836    }
837
838    /// Handle spilled buckets with a classic grace hash join.
839    ///
840    /// For every bucket that spilled at least once, the full left partition
841    /// (spilled files plus the in-memory residual) is loaded and hashed, then
842    /// the full right partition (spilled files plus the in-memory buffer) is
843    /// probed against it and joined. Both partitions' spill files are deleted
844    /// afterwards.
845    fn handle_spilled_buckets(
846        &mut self,
847        join_vars: &[Variable],
848        results: &mut Vec<Solution>,
849    ) -> Result<()> {
850        for bucket_idx in 0..self.num_buckets {
851            if self.spill_buckets[bucket_idx].is_empty() {
852                // Never spilled: already fully joined in-memory during probe.
853                continue;
854            }
855
856            // Left partition: spilled files + in-memory residual.
857            let mut left_solutions = Vec::new();
858            for spill_file in &self.spill_buckets[bucket_idx] {
859                left_solutions.extend(self.load_spilled_solutions(spill_file)?);
860            }
861            for (_key, sols) in self.hash_buckets[bucket_idx].drain() {
862                left_solutions.extend(sols);
863            }
864
865            // Right partition: in-memory buffer + spilled files.
866            let mut right_solutions = std::mem::take(&mut self.right_buffers[bucket_idx]);
867            for spill_file in &self.right_spill_buckets[bucket_idx] {
868                right_solutions.extend(self.load_spilled_solutions(spill_file)?);
869            }
870
871            self.join_partition(&left_solutions, &right_solutions, join_vars, results);
872
873            // Delete this bucket's spill files now that it is fully joined.
874            for spill_file in self.spill_buckets[bucket_idx].drain(..) {
875                let _ = remove_file(&spill_file.path);
876            }
877            for spill_file in self.right_spill_buckets[bucket_idx].drain(..) {
878                let _ = remove_file(&spill_file.path);
879            }
880        }
881
882        Ok(())
883    }
884
885    /// Join a fully-materialized left and right partition of a single spilled
886    /// bucket, appending matched rows to `results`. Left rows are indexed by
887    /// their join key so probing is O(right).
888    fn join_partition(
889        &self,
890        left: &[Solution],
891        right: &[Solution],
892        join_vars: &[Variable],
893        results: &mut Vec<Solution>,
894    ) {
895        let mut table: HashMap<String, Vec<&Solution>> = HashMap::new();
896        for left_solution in left {
897            let key = self.create_hash_key(left_solution, join_vars);
898            table.entry(key).or_default().push(left_solution);
899        }
900
901        for right_solution in right {
902            let key = self.create_hash_key(right_solution, join_vars);
903            if let Some(left_matches) = table.get(&key) {
904                for &left_solution in left_matches {
905                    if let Some(joined) =
906                        self.join_solutions(left_solution, right_solution, join_vars)
907                    {
908                        results.push(joined);
909                    }
910                }
911            }
912        }
913    }
914
915    /// Create hash key from solution using join variables
916    fn create_hash_key(&self, solution: &Solution, join_vars: &[Variable]) -> String {
917        let mut key_parts = Vec::new();
918
919        for binding in solution {
920            for join_var in join_vars {
921                if let Some(term) = binding.get(join_var) {
922                    key_parts.push(format!("{join_var}:{term:?}"));
923                }
924            }
925        }
926
927        key_parts.join("|")
928    }
929
930    /// Hash key to bucket index
931    fn hash_to_bucket(&self, key: &str) -> usize {
932        use std::collections::hash_map::DefaultHasher;
933        use std::hash::{Hash, Hasher};
934
935        let mut hasher = DefaultHasher::new();
936        key.hash(&mut hasher);
937        (hasher.finish() as usize) % self.num_buckets
938    }
939
940    /// Serialize a batch of solutions to a persisted spill file on disk.
941    ///
942    /// The file is persisted via [`NamedTempFile::keep`] (not `into_parts`, whose
943    /// `TempPath` guard would delete the file as soon as it drops — before the
944    /// grace-join cleanup phase can read it back). Callers are responsible for
945    /// deleting the returned file once the partition has been joined.
946    fn write_solutions_to_spill(&self, solutions: &[Solution]) -> Result<SpillFile> {
947        let temp_file = if let Some(ref temp_dir) = self.config.temp_dir {
948            NamedTempFile::new_in(temp_dir)?
949        } else {
950            NamedTempFile::new()?
951        };
952
953        let serialized_solutions: Vec<SerializableSolution> = solutions
954            .iter()
955            .map(SerializableSolution::from_solution)
956            .collect();
957        let data =
958            oxicode::serde::encode_to_vec(&serialized_solutions, oxicode::config::standard())?;
959
960        let (file, path) = temp_file
961            .keep()
962            .map_err(|e| anyhow!("failed to persist spill file: {e}"))?;
963        let mut writer = BufWriter::new(file);
964        writer.write_all(&data)?;
965        writer.flush()?;
966
967        Ok(SpillFile {
968            path,
969            size: data.len(),
970            compressed: false,
971        })
972    }
973
974    /// Spill a build-side (left) bucket to disk, freeing its memory.
975    fn spill_bucket(&mut self, bucket_idx: usize) -> Result<()> {
976        if self.hash_buckets[bucket_idx].is_empty() {
977            return Ok(());
978        }
979
980        // Flatten bucket solutions for serialization.
981        let all_solutions: Vec<Solution> = self.hash_buckets[bucket_idx]
982            .values()
983            .flat_map(|solutions| solutions.iter().cloned())
984            .collect();
985
986        let total_size: usize = all_solutions
987            .iter()
988            .map(|sol| self.estimate_solution_size(sol))
989            .sum();
990
991        let spill_file = self.write_solutions_to_spill(&all_solutions)?;
992        self.spill_buckets[bucket_idx].push(spill_file);
993
994        self.memory_tracker.deallocate(total_size);
995        self.hash_buckets[bucket_idx].clear();
996
997        Ok(())
998    }
999
1000    /// Spill the in-memory right-side buffer for a bucket to disk.
1001    fn spill_right_buffer(&mut self, bucket_idx: usize) -> Result<()> {
1002        if self.right_buffers[bucket_idx].is_empty() {
1003            return Ok(());
1004        }
1005        let solutions = std::mem::take(&mut self.right_buffers[bucket_idx]);
1006        let spill_file = self.write_solutions_to_spill(&solutions)?;
1007        self.right_spill_buckets[bucket_idx].push(spill_file);
1008        Ok(())
1009    }
1010
1011    /// Load solutions from spill file
1012    fn load_spilled_solutions(&self, spill_file: &SpillFile) -> Result<Vec<Solution>> {
1013        let file = File::open(&spill_file.path)?;
1014        let mut reader = BufReader::new(file);
1015
1016        let mut data = Vec::new();
1017        reader.read_to_end(&mut data)?;
1018
1019        let serialized_solutions: Vec<SerializableSolution> =
1020            oxicode::serde::decode_from_slice(&data, oxicode::config::standard())
1021                .map(|(v, _)| v)?;
1022
1023        Ok(serialized_solutions
1024            .into_iter()
1025            .map(|s| s.to_solution())
1026            .collect())
1027    }
1028
1029    /// Join two solutions
1030    fn join_solutions(
1031        &self,
1032        left: &Solution,
1033        right: &Solution,
1034        join_vars: &[Variable],
1035    ) -> Option<Solution> {
1036        // Check if join variables have compatible values
1037        for left_binding in left {
1038            for right_binding in right {
1039                let mut compatible = true;
1040                for join_var in join_vars {
1041                    let left_val = left_binding.get(join_var);
1042                    let right_val = right_binding.get(join_var);
1043
1044                    match (left_val, right_val) {
1045                        (Some(l), Some(r)) if l != r => {
1046                            compatible = false;
1047                            break;
1048                        }
1049                        _ => {}
1050                    }
1051                }
1052
1053                if compatible {
1054                    // Create joined solution
1055                    let mut joined = Solution::new();
1056                    let mut new_binding = Binding::new();
1057
1058                    // Add all bindings from left
1059                    for (var, term) in left_binding {
1060                        new_binding.insert(var.clone(), term.clone());
1061                    }
1062
1063                    // Add non-conflicting bindings from right
1064                    for (var, term) in right_binding {
1065                        if !new_binding.contains_key(var) {
1066                            new_binding.insert(var.clone(), term.clone());
1067                        }
1068                    }
1069
1070                    joined.push(new_binding);
1071                    return Some(joined);
1072                }
1073            }
1074        }
1075
1076        None
1077    }
1078
1079    /// Estimate memory size of a solution
1080    fn estimate_solution_size(&self, solution: &Solution) -> usize {
1081        let mut size = std::mem::size_of::<Solution>();
1082        for binding in solution {
1083            size += binding.len() * (std::mem::size_of::<Variable>() + std::mem::size_of::<Term>());
1084            size += binding
1085                .iter()
1086                .map(|(var, term)| var.as_str().len() + self.estimate_term_size(term))
1087                .sum::<usize>();
1088        }
1089        size
1090    }
1091
1092    /// Estimate memory size of a term
1093    fn estimate_term_size(&self, term: &Term) -> usize {
1094        match term {
1095            Term::Iri(iri) => iri.as_str().len(),
1096            Term::Literal(lit) => lit.value.len() + lit.language.as_ref().map_or(0, |l| l.len()),
1097            Term::BlankNode(bn) => bn.len(),
1098            Term::Variable(var) => var.as_str().len(),
1099            Term::QuotedTriple(_) => 100, // Estimate for quoted triple
1100            Term::PropertyPath(_) => 50,  // Estimate for property path
1101        }
1102    }
1103}
1104
1105/// Statistics about streaming execution
1106#[derive(Debug, Clone)]
1107pub struct StreamingStats {
1108    pub current_memory: usize,
1109    pub peak_memory: usize,
1110    pub spill_files: usize,
1111    pub total_spill_size: usize,
1112    pub in_memory_solutions: usize,
1113}
1114
1115impl Default for SpillableHashJoin {
1116    fn default() -> Self {
1117        Self::new(StreamingConfig::default())
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    #[test]
1126    fn test_streaming_solution_basic() {
1127        let config = StreamingConfig {
1128            memory_limit: 1024, // Small limit to force spilling
1129            buffer_size: 2,
1130            ..Default::default()
1131        };
1132
1133        let mut stream = StreamingSolution::new(config);
1134
1135        // Add some test solutions
1136        let mut solution1 = Solution::new();
1137        let mut binding1 = Binding::new();
1138        binding1.insert(
1139            Variable::new("x").unwrap(),
1140            Term::Iri(NamedNode::new("http://example.org/1").unwrap()),
1141        );
1142        solution1.push(binding1);
1143
1144        let mut solution2 = Solution::new();
1145        let mut binding2 = Binding::new();
1146        binding2.insert(
1147            Variable::new("y").unwrap(),
1148            Term::Iri(NamedNode::new("http://example.org/2").unwrap()),
1149        );
1150        solution2.push(binding2);
1151
1152        stream.add_solution(solution1).unwrap();
1153        stream.add_solution(solution2).unwrap();
1154        stream.finish();
1155
1156        // Should be able to iterate through solutions
1157        let mut count = 0;
1158        for result in &mut stream {
1159            assert!(result.is_ok());
1160            count += 1;
1161        }
1162
1163        assert_eq!(count, 2);
1164    }
1165
1166    #[test]
1167    fn test_memory_tracker() {
1168        let tracker = MemoryTracker::new(1000);
1169
1170        assert!(tracker.allocate(500).unwrap());
1171        assert_eq!(tracker.current_usage(), 500);
1172
1173        assert!(tracker.allocate(400).unwrap());
1174        assert_eq!(tracker.current_usage(), 900);
1175
1176        assert!(!tracker.allocate(200).unwrap()); // Should exceed limit
1177
1178        tracker.deallocate(400);
1179        assert_eq!(tracker.current_usage(), 500);
1180
1181        assert!(tracker.allocate(200).unwrap());
1182    }
1183
1184    #[test]
1185    fn test_spillable_hash_join() {
1186        let config = StreamingConfig {
1187            memory_limit: 2048,
1188            ..Default::default()
1189        };
1190
1191        let mut join = SpillableHashJoin::new(config);
1192
1193        // Create proper Solution structures
1194        let mut left_solution = Solution::new();
1195        let mut left_binding = Binding::new();
1196        left_binding.insert(
1197            Variable::new("x").unwrap(),
1198            Term::Iri(NamedNode::new("http://example.org/1").unwrap()),
1199        );
1200        left_solution.push(left_binding);
1201
1202        let mut right_solution = Solution::new();
1203        let mut right_binding = Binding::new();
1204        right_binding.insert(
1205            Variable::new("x").unwrap(),
1206            Term::Iri(NamedNode::new("http://example.org/1").unwrap()),
1207        );
1208        right_solution.push(right_binding);
1209
1210        let left = vec![left_solution];
1211        let right = vec![right_solution];
1212        let join_vars = vec![Variable::new("x").unwrap()];
1213        let results = join.execute(left, right, &join_vars).unwrap();
1214
1215        assert!(!results.is_empty());
1216    }
1217
1218    #[test]
1219    fn grace_hash_join_spilled_buckets_produce_correct_results() {
1220        // Size the memory budget from one real solution so the build side is
1221        // forced to spill, exercising the grace-hash-join replay path.
1222        let mut sample = Solution::new();
1223        let mut sample_binding = Binding::new();
1224        sample_binding.insert(
1225            Variable::new_unchecked("x"),
1226            Term::Iri(NamedNode::new_unchecked("http://example.org/x/1")),
1227        );
1228        sample_binding.insert(
1229            Variable::new_unchecked("y"),
1230            Term::Iri(NamedNode::new_unchecked("http://example.org/y/000")),
1231        );
1232        sample.push(sample_binding);
1233
1234        let sizer = SpillableHashJoin::new(StreamingConfig::default());
1235        let one = sizer.estimate_solution_size(&sample).max(1);
1236
1237        let config = StreamingConfig {
1238            memory_limit: one * 2, // holds ~2 rows -> 6 same-bucket rows must spill
1239            buffer_size: 2,
1240            ..Default::default()
1241        };
1242        let mut join = SpillableHashJoin::new(config);
1243
1244        // 6 left rows all sharing the join key x=1 -> one bucket -> spills.
1245        let mut left = Vec::new();
1246        for i in 0..6 {
1247            let mut sol = Solution::new();
1248            let mut binding = Binding::new();
1249            binding.insert(
1250                Variable::new_unchecked("x"),
1251                Term::Iri(NamedNode::new_unchecked("http://example.org/x/1")),
1252            );
1253            binding.insert(
1254                Variable::new_unchecked("y"),
1255                Term::Iri(NamedNode::new_unchecked(format!(
1256                    "http://example.org/y/{i:03}"
1257                ))),
1258            );
1259            sol.push(binding);
1260            left.push(sol);
1261        }
1262        // 3 right rows, same key.
1263        let mut right = Vec::new();
1264        for j in 0..3 {
1265            let mut sol = Solution::new();
1266            let mut binding = Binding::new();
1267            binding.insert(
1268                Variable::new_unchecked("x"),
1269                Term::Iri(NamedNode::new_unchecked("http://example.org/x/1")),
1270            );
1271            binding.insert(
1272                Variable::new_unchecked("z"),
1273                Term::Iri(NamedNode::new_unchecked(format!(
1274                    "http://example.org/z/{j:03}"
1275                ))),
1276            );
1277            sol.push(binding);
1278            right.push(sol);
1279        }
1280
1281        let join_vars = vec![Variable::new_unchecked("x")];
1282        let results = join
1283            .execute(left, right, &join_vars)
1284            .expect("grace hash join should succeed");
1285
1286        // Each of the 6 left rows joins each of the 3 right rows on x=1 => 18
1287        // matched rows, each carrying both y and z. Before the fix, spilled
1288        // left rows were emitted unmatched (wrong count, missing z bindings).
1289        assert_eq!(
1290            results.len(),
1291            18,
1292            "grace hash join must join spilled left and right partitions"
1293        );
1294        for sol in &results {
1295            for binding in sol {
1296                assert!(binding.contains_key(&Variable::new_unchecked("y")));
1297                assert!(binding.contains_key(&Variable::new_unchecked("z")));
1298            }
1299        }
1300    }
1301}