Skip to main content

linkrs_query/executor/streaming/chunk/
collector.rs

1//! Terminal result collector: thread-local chunk accumulation.
2//!
3//! Ladybug `ResultCollector` analogue at the row level: each pipeline owns a
4//! `LocalChunkCollector`, moves visible rows out of chunks exactly once
5//! (`DataChunk::expand_visible_rows`, selection + multiplicity aware), and
6//! merges collectors by moving whole blocks instead of per-Value clones.
7//!
8//! Storage is block-partitioned ([`COLLECTOR_BLOCK_ROWS`] rows per block) so
9//! large results avoid the single-`Vec` reallocation peak and can be consumed
10//! through the [`LocalChunkCollector::iter_rows`] cursor without flattening.
11//!
12//! Spill-through collect: when a [`SpillManager`](crate::executor::streaming::spill::SpillManager)
13//! is attached via [`LocalChunkCollector::attach_spill_manager`] and the
14//! logical-row threshold is exceeded, rows are written to versioned run files
15//! (rotated every [`COLLECTOR_RUN_ROWS_MAX`] rows so neither the writer-side
16//! body buffer nor the reader-side full-run load grows without bound) and the
17//! memory blocks are released. Without an attached manager the collector is
18//! pure in-memory with zero spill overhead.
19
20use std::sync::Arc;
21
22use super::core::DataChunk;
23use crate::executor::base::MemoryBudget;
24use crate::executor::streaming::spill::{
25    schema_fingerprint, SpillManager, SpilledRun, COLLECTOR_RUN_ROWS_MAX,
26};
27use linkrs_core::error::QueryError;
28use linkrs_core::Value;
29
30use super::super::spill::{RunReader, RunWriter};
31
32/// Rows per collector block. Bounds the reallocation peak of a single push.
33pub const COLLECTOR_BLOCK_ROWS: usize = 4096;
34
35/// Open spill run held by the collector.
36#[derive(Debug)]
37struct ActiveRun {
38    writer: RunWriter,
39    rows: u64,
40    /// Disk quota pre-reserved for buffered rows (row-memory estimate).
41    /// Released and replaced by the exact file size on finalization.
42    reserved_bytes: u64,
43}
44
45/// Thread-local terminal accumulator for query results.
46#[derive(Debug, Default)]
47pub struct LocalChunkCollector {
48    blocks: Vec<Vec<Vec<Value>>>,
49    col_names: Vec<String>,
50    /// Remaining (not yet drained) rows, memory + spilled.
51    len: usize,
52    total_logical: u128,
53    spill_threshold_rows: Option<u64>,
54    spill_manager: Option<Arc<SpillManager>>,
55    spilled_runs: Vec<SpilledRun>,
56    active_writer: Option<ActiveRun>,
57    spill_fingerprint: Option<u64>,
58    spilled_rows: u64,
59    spilled_bytes: u64,
60    drain_cursor: usize,
61}
62
63impl LocalChunkCollector {
64    pub fn new(col_names: Vec<String>) -> Self {
65        Self {
66            blocks: Vec::new(),
67            col_names,
68            len: 0,
69            total_logical: 0,
70            spill_threshold_rows: None,
71            spill_manager: None,
72            spilled_runs: Vec::new(),
73            active_writer: None,
74            spill_fingerprint: None,
75            spilled_rows: 0,
76            spilled_bytes: 0,
77            drain_cursor: 0,
78        }
79    }
80
81    /// Attach a spill manager for spill-through collect.
82    ///
83    /// When no explicit threshold was configured via
84    /// [`Self::set_spill_threshold_rows`], the manager's effective threshold
85    /// (config value or default) is adopted. A threshold of `u64::MAX`
86    /// (from `Some(0)` config) disables collector spill.
87    pub fn attach_spill_manager(&mut self, manager: Arc<SpillManager>) {
88        if self.spill_threshold_rows.is_none() {
89            self.spill_threshold_rows = Some(manager.collector_spill_threshold());
90        }
91        self.spill_manager = Some(manager);
92    }
93
94    /// Move all visible (expanded) rows of `chunk` into the collector,
95    /// splitting them across fixed-size blocks or spilling them to disk once
96    /// the threshold is exceeded.
97    pub fn push_chunk(&mut self, chunk: &mut DataChunk) -> Result<(), QueryError> {
98        if self.col_names.is_empty() {
99            self.col_names = chunk.col_names();
100        }
101        self.total_logical += u128::from(chunk.logical_len());
102        let rows = chunk.expand_visible_rows();
103        if rows.is_empty() {
104            return Ok(());
105        }
106        if self.spill_manager.is_none() {
107            self.push_rows_memory(rows);
108            return Ok(());
109        }
110        if self.is_spilled() {
111            self.write_rows_spilled(rows)?;
112            return Ok(());
113        }
114        if self.spill_threshold_exceeded() {
115            self.spill_memory_blocks()?;
116            self.write_rows_spilled(rows)?;
117        } else {
118            self.push_rows_memory(rows);
119        }
120        Ok(())
121    }
122
123    /// Merge another collector by moving whole blocks (no per-Value clone).
124    ///
125    /// Spilled runs are concatenated in order; both open writers are
126    /// finalized first so ordering is preserved. Merging two spilled
127    /// collectors requires identical column names and the same spill manager
128    /// (runs live in the manager's directory); otherwise an error is
129    /// returned and the caller should drain via `into_rows` first. Pure
130    /// in-memory merges keep the previous unchecked behavior.
131    pub fn merge(&mut self, mut other: LocalChunkCollector) -> Result<(), QueryError> {
132        let self_spilled = self.is_spilled();
133        let other_spilled = other.is_spilled();
134        if self_spilled || other_spilled {
135            if !self.col_names.is_empty()
136                && !other.col_names.is_empty()
137                && self.col_names != other.col_names
138            {
139                return Err(QueryError::execution(
140                    "spill run: cannot merge collectors with different column names".to_string(),
141                ));
142            }
143            match (self.spill_manager.clone(), other.spill_manager.clone()) {
144                (Some(a), Some(b)) => {
145                    if !Arc::ptr_eq(&a, &b) {
146                        return Err(QueryError::execution(
147                            "spill run: cannot merge collectors from different spill managers"
148                                .to_string(),
149                        ));
150                    }
151                }
152                (None, Some(manager)) => {
153                    if self.len == 0 && self.total_logical == 0 {
154                        self.spill_manager = Some(manager.clone());
155                        if self.spill_threshold_rows.is_none() {
156                            self.spill_threshold_rows = other.spill_threshold_rows;
157                        }
158                    } else {
159                        return Err(QueryError::execution(
160                            "spill run: cannot merge spilled collector without a spill manager"
161                                .to_string(),
162                        ));
163                    }
164                }
165                (Some(_), None) => {
166                    debug_assert!(
167                        !other_spilled,
168                        "spilled runs without a spill manager must not exist"
169                    );
170                }
171                (None, None) => {
172                    debug_assert!(
173                        !self_spilled && !other_spilled,
174                        "spilled state requires a spill manager"
175                    );
176                }
177            }
178            if self.col_names.is_empty() {
179                self.col_names = std::mem::take(&mut other.col_names);
180            }
181            self.finalize_active_writer()?;
182            other.finalize_active_writer()?;
183            let other_remaining: Vec<SpilledRun> =
184                other.spilled_runs[other.drain_cursor..].to_vec();
185            self.spilled_runs.extend(other_remaining);
186            self.spilled_rows += other.spilled_rows;
187            self.spilled_bytes += other.spilled_bytes;
188        } else if self.col_names.is_empty() {
189            self.col_names = std::mem::take(&mut other.col_names);
190        }
191        self.total_logical += other.total_logical;
192        self.len += other.len;
193        if self.blocks.is_empty() {
194            self.blocks = other.blocks;
195            return Ok(());
196        }
197        for mut block in other.blocks {
198            if block.is_empty() {
199                continue;
200            }
201            let tail_has_room = self
202                .blocks
203                .last()
204                .is_some_and(|b| b.len() < COLLECTOR_BLOCK_ROWS);
205            if !tail_has_room {
206                self.blocks.push(std::mem::take(&mut block));
207                continue;
208            }
209            // Fill the tail first, then move the remainder as a new block.
210            let tail = self.blocks.last_mut().expect("tail block must exist");
211            while tail.len() < COLLECTOR_BLOCK_ROWS && !block.is_empty() {
212                // `block` was filled front-to-back; pop from the back into a
213                // staging vec would reverse order, so drain from the front in
214                // bulk instead.
215                let room = COLLECTOR_BLOCK_ROWS - tail.len();
216                let take = room.min(block.len());
217                tail.extend(block.drain(..take));
218            }
219            if !block.is_empty() {
220                self.blocks.push(block);
221            }
222        }
223        Ok(())
224    }
225
226    /// Expanded row count observed (selection + multiplicity applied).
227    pub fn total_logical_rows(&self) -> u128 {
228        self.total_logical
229    }
230
231    /// Remaining rows (memory + spilled, not yet drained).
232    pub fn len(&self) -> usize {
233        self.len
234    }
235
236    pub fn is_empty(&self) -> bool {
237        self.len == 0
238    }
239
240    pub fn col_names(&self) -> &[String] {
241        &self.col_names
242    }
243
244    /// Finalize the open run, if any, so all spilled rows are readable and
245    /// counters are exact. Idempotent; `into_rows` and `drain_rows` call it
246    /// automatically, while streaming `collect` paths call it explicitly
247    /// before snapshotting observability counters.
248    pub fn finish_spill(&mut self) -> Result<(), QueryError> {
249        self.finalize_active_writer()
250    }
251
252    /// Full ordered results: spilled runs replayed in write order, then
253    /// memory blocks. Peak memory is bounded by the largest single run.
254    pub fn into_rows(mut self) -> Result<(Vec<Vec<Value>>, Vec<String>), QueryError> {
255        self.finalize_active_writer()?;
256        let mut out = Vec::with_capacity(self.len);
257        for run in &self.spilled_runs[self.drain_cursor..] {
258            let mut reader = RunReader::open(run)?;
259            out.extend(reader.read_all()?);
260        }
261        for block in self.blocks {
262            out.extend(block);
263        }
264        Ok((out, self.col_names))
265    }
266
267    /// Segmented results: each call returns at most one run's rows, so
268    /// streaming consumers never hold more than a single run in memory.
269    /// After the runs are exhausted the memory blocks are returned whole,
270    /// then empty vectors signal completion.
271    pub fn drain_rows(&mut self) -> Result<Vec<Vec<Value>>, QueryError> {
272        if self.drain_cursor < self.spilled_runs.len() {
273            let run = self.spilled_runs[self.drain_cursor].clone();
274            let mut reader = RunReader::open(&run)?;
275            let rows = reader.read_all()?;
276            self.drain_cursor += 1;
277            self.len = self.len.saturating_sub(rows.len());
278            return Ok(rows);
279        }
280        if self.active_writer.is_some() {
281            self.finalize_active_writer()?;
282            return self.drain_rows();
283        }
284        if !self.blocks.is_empty() {
285            let blocks = std::mem::take(&mut self.blocks);
286            let mut out = Vec::new();
287            for block in blocks {
288                out.extend(block);
289            }
290            self.len = self.len.saturating_sub(out.len());
291            return Ok(out);
292        }
293        Ok(Vec::new())
294    }
295
296    /// Block-partitioned memory rows.
297    ///
298    /// Only valid for in-memory collectors; spilled state must be consumed
299    /// via [`Self::into_rows`] or [`Self::drain_rows`].
300    pub fn into_blocks(self) -> (Vec<Vec<Vec<Value>>>, Vec<String>) {
301        debug_assert!(
302            !self.is_spilled(),
303            "into_blocks drops spilled runs; use into_rows or drain_rows"
304        );
305        (self.blocks, self.col_names)
306    }
307
308    /// Ordered cursor over memory blocks only.
309    ///
310    /// When spilled, the disk portion is NOT included; paged consumers must
311    /// use [`Self::drain_rows`], full consumers [`Self::into_rows`].
312    pub fn iter_rows(&self) -> impl Iterator<Item = &Vec<Value>> {
313        self.blocks.iter().flatten()
314    }
315
316    pub fn num_blocks(&self) -> usize {
317        self.blocks.len()
318    }
319
320    /// Whether any row has been spilled to disk.
321    pub fn is_spilled(&self) -> bool {
322        !self.spilled_runs.is_empty() || self.active_writer.is_some()
323    }
324
325    /// Rows written to disk so far.
326    pub fn spilled_rows(&self) -> u64 {
327        self.spilled_rows
328    }
329
330    /// Bytes written to disk so far (on-disk file sizes).
331    pub fn spilled_bytes(&self) -> u64 {
332        self.spilled_bytes
333    }
334
335    /// Number of finalized spill runs.
336    pub fn spilled_run_count(&self) -> usize {
337        self.spilled_runs.len()
338    }
339
340    /// Configure the logical-row threshold that will trigger spill-through
341    /// collect once a spill manager is attached.
342    pub fn set_spill_threshold_rows(&mut self, threshold: u64) {
343        self.spill_threshold_rows = Some(threshold);
344    }
345
346    /// Whether the configured spill threshold has been exceeded.
347    pub fn spill_threshold_exceeded(&self) -> bool {
348        match self.spill_threshold_rows {
349            Some(t) => u128::from(t) < self.total_logical,
350            None => false,
351        }
352    }
353
354    fn push_rows_memory(&mut self, mut rows: Vec<Vec<Value>>) {
355        if rows.is_empty() {
356            return;
357        }
358        self.len += rows.len();
359        // Drain from the front without shifting: reverse once, then pop.
360        rows.reverse();
361        while !rows.is_empty() {
362            let tail_full = self
363                .blocks
364                .last()
365                .is_none_or(|b| b.len() >= COLLECTOR_BLOCK_ROWS);
366            if tail_full {
367                self.blocks
368                    .push(Vec::with_capacity(rows.len().min(COLLECTOR_BLOCK_ROWS)));
369            }
370            let tail = self.blocks.last_mut().expect("tail block must exist");
371            while tail.len() < COLLECTOR_BLOCK_ROWS {
372                if let Some(row) = rows.pop() {
373                    tail.push(row);
374                } else {
375                    break;
376                }
377            }
378        }
379    }
380
381    fn spill_fingerprint(&mut self) -> u64 {
382        if let Some(fp) = self.spill_fingerprint {
383            return fp;
384        }
385        let fp = schema_fingerprint(&self.col_names);
386        self.spill_fingerprint = Some(fp);
387        fp
388    }
389
390    fn ensure_active_writer(&mut self) -> Result<(), QueryError> {
391        if self.active_writer.is_some() {
392            return Ok(());
393        }
394        let manager = self.spill_manager.clone().ok_or_else(|| {
395            QueryError::execution("spill run: spill manager not available".to_string())
396        })?;
397        let fp = self.spill_fingerprint();
398        let writer = manager.create_run_writer(fp)?;
399        self.active_writer = Some(ActiveRun {
400            writer,
401            rows: 0,
402            reserved_bytes: 0,
403        });
404        Ok(())
405    }
406
407    fn rotate_active_writer_if_full(&mut self) -> Result<(), QueryError> {
408        let full = self
409            .active_writer
410            .as_ref()
411            .is_some_and(|a| a.rows >= COLLECTOR_RUN_ROWS_MAX);
412        if full {
413            self.finalize_active_writer()?;
414        }
415        Ok(())
416    }
417
418    fn finalize_active_writer(&mut self) -> Result<(), QueryError> {
419        let Some(active) = self.active_writer.take() else {
420            return Ok(());
421        };
422        let manager = self.spill_manager.clone().ok_or_else(|| {
423            QueryError::execution("spill run: spill manager not available".to_string())
424        })?;
425        let rows = active.rows;
426        let reserved = active.reserved_bytes;
427        // Release the pre-reserved estimate first so `finalize_run` accounts
428        // exactly once for the on-disk size. Releasing up front also avoids
429        // leaking the estimate when finalization itself fails.
430        manager.disk_quota().release(reserved);
431        // Single quota choke point: `finalize_run` reserves the exact file
432        // size, removes the file on quota failure, and tracks manager bytes.
433        let run = manager.finalize_run(active.writer).map_err(|e| {
434            let msg = e.to_string();
435            if msg.contains("Disk quota") {
436                e
437            } else {
438                QueryError::execution(format!("spill run: {}", msg))
439            }
440        })?;
441        self.spilled_rows += rows;
442        self.spilled_bytes += run.byte_size;
443        self.spilled_runs.push(run);
444        Ok(())
445    }
446
447    fn write_one_row(&mut self, row: Vec<Value>) -> Result<(), QueryError> {
448        self.rotate_active_writer_if_full()?;
449        self.ensure_active_writer()?;
450        let manager = self.spill_manager.clone().ok_or_else(|| {
451            QueryError::execution("spill run: spill manager not available".to_string())
452        })?;
453        // Pre-reserve the disk quota by row-memory estimate so over-quota
454        // writes fail fast at `push_chunk` time instead of only at run
455        // finalization. The estimate is reconciled against the exact file
456        // size in `finalize_active_writer`.
457        let estimate = MemoryBudget::estimate_row_memory(&row) as u64;
458        manager.disk_quota().try_reserve(estimate)?;
459        let active = self.active_writer.as_mut().ok_or_else(|| {
460            QueryError::execution("spill run: collector writer missing after ensure".to_string())
461        })?;
462        active.writer.write_row(&row)?;
463        active.rows += 1;
464        active.reserved_bytes += estimate;
465        Ok(())
466    }
467
468    fn write_rows_spilled(&mut self, rows: Vec<Vec<Value>>) -> Result<(), QueryError> {
469        self.ensure_active_writer()?;
470        self.len += rows.len();
471        for row in rows {
472            self.write_one_row(row)?;
473        }
474        Ok(())
475    }
476
477    fn spill_memory_blocks(&mut self) -> Result<(), QueryError> {
478        if self.blocks.is_empty() {
479            return Ok(());
480        }
481        self.ensure_active_writer()?;
482        let blocks = std::mem::take(&mut self.blocks);
483        for block in blocks {
484            for row in block {
485                self.write_one_row(row)?;
486            }
487        }
488        Ok(())
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::executor::streaming::slot::SlotLayout;
496    use crate::executor::streaming::spill::{DiskQuota, SpillConfig, SpillManager};
497    use std::sync::Arc;
498
499    fn chunk_with(rows: Vec<Vec<Value>>, selection: Option<Vec<usize>>, mult: u64) -> DataChunk {
500        let layout = Arc::new(SlotLayout::from_names(&["a".to_string()]));
501        let mut c = DataChunk::new_with_layout(rows, layout);
502        if let Some(sel) = selection {
503            c = c.with_selection(sel);
504        }
505        c.with_multiplicity(mult)
506    }
507
508    fn spill_manager(query_id: u64) -> Arc<SpillManager> {
509        Arc::new(SpillManager::new(SpillConfig::default(), query_id).unwrap())
510    }
511
512    #[test]
513    fn expands_selection_and_multiplicity_once() {
514        let mut c = chunk_with(
515            vec![
516                vec![Value::Int(1)],
517                vec![Value::Int(2)],
518                vec![Value::Int(3)],
519            ],
520            Some(vec![0, 2]),
521            2,
522        );
523        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
524        collector.push_chunk(&mut c).unwrap();
525        assert_eq!(collector.total_logical_rows(), 4);
526        assert_eq!(collector.len(), 4);
527        assert!(c.rows.is_empty());
528    }
529
530    #[test]
531    fn merge_sums_counts() {
532        let mut a = LocalChunkCollector::new(vec!["a".to_string()]);
533        let mut b = LocalChunkCollector::new(vec!["a".to_string()]);
534        let mut c1 = chunk_with(vec![vec![Value::Int(1)]], None, 1);
535        let mut c2 = chunk_with(vec![vec![Value::Int(2)]], None, 3);
536        a.push_chunk(&mut c1).unwrap();
537        b.push_chunk(&mut c2).unwrap();
538        a.merge(b).unwrap();
539        assert_eq!(a.total_logical_rows(), 4);
540        assert_eq!(a.len(), 4);
541    }
542
543    #[test]
544    fn splits_large_push_into_blocks() {
545        let rows: Vec<Vec<Value>> = (0..5000).map(|i| vec![Value::Int(i)]).collect();
546        let mut c = chunk_with(rows, None, 1);
547        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
548        collector.push_chunk(&mut c).unwrap();
549        assert_eq!(collector.len(), 5000);
550        assert_eq!(collector.num_blocks(), 2);
551        let (flat, _) = collector.into_rows().unwrap();
552        assert_eq!(flat.len(), 5000);
553        assert_eq!(flat[0], vec![Value::Int(0)]);
554        assert_eq!(flat[4999], vec![Value::Int(4999)]);
555    }
556
557    #[test]
558    fn merge_moves_blocks_and_iter_rows_preserves_order() {
559        let mut a = LocalChunkCollector::new(vec!["a".to_string()]);
560        let mut b = LocalChunkCollector::new(vec!["a".to_string()]);
561        let mut c1 = chunk_with(vec![vec![Value::Int(1)], vec![Value::Int(2)]], None, 1);
562        let mut c2 = chunk_with(vec![vec![Value::Int(3)]], None, 1);
563        a.push_chunk(&mut c1).unwrap();
564        b.push_chunk(&mut c2).unwrap();
565        a.merge(b).unwrap();
566        let ordered: Vec<i32> = a
567            .iter_rows()
568            .map(|r| match &r[0] {
569                Value::Int(v) => *v,
570                _ => -1,
571            })
572            .collect();
573        assert_eq!(ordered, vec![1, 2, 3]);
574        let (flat, _) = a.into_rows().unwrap();
575        assert_eq!(flat.len(), 3);
576    }
577
578    #[test]
579    fn spill_threshold_hook_defaults_off() {
580        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
581        assert!(!collector.is_spilled());
582        assert!(!collector.spill_threshold_exceeded());
583        collector.set_spill_threshold_rows(2);
584        let mut c = chunk_with(
585            vec![
586                vec![Value::Int(1)],
587                vec![Value::Int(2)],
588                vec![Value::Int(3)],
589            ],
590            None,
591            1,
592        );
593        collector.push_chunk(&mut c).unwrap();
594        assert!(collector.spill_threshold_exceeded());
595        assert!(!collector.is_spilled());
596    }
597
598    #[test]
599    fn spill_through_preserves_order() {
600        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
601        collector.attach_spill_manager(spill_manager(4201));
602        collector.set_spill_threshold_rows(10);
603        for batch in 0..5 {
604            let rows: Vec<Vec<Value>> = (0..10).map(|i| vec![Value::Int(batch * 10 + i)]).collect();
605            let mut c = chunk_with(rows, None, 1);
606            collector.push_chunk(&mut c).unwrap();
607        }
608        assert!(collector.is_spilled());
609        collector.finish_spill().unwrap();
610        assert_eq!(collector.spilled_rows(), 50);
611        assert!(collector.spilled_bytes() > 0);
612        let (flat, names) = collector.into_rows().unwrap();
613        assert_eq!(flat.len(), 50);
614        assert_eq!(names, vec!["a".to_string()]);
615        for (i, row) in flat.iter().enumerate() {
616            assert_eq!(row, &vec![Value::Int(i as i32)]);
617        }
618    }
619
620    #[test]
621    fn spill_run_rotation_keeps_writer_alive() {
622        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
623        collector.attach_spill_manager(spill_manager(4204));
624        let total = (COLLECTOR_RUN_ROWS_MAX + 10) as usize;
625        let rows: Vec<Vec<Value>> = (0..total).map(|i| vec![Value::Int(i as i32)]).collect();
626        collector.write_rows_spilled(rows).unwrap();
627        collector.finish_spill().unwrap();
628        assert!(collector.spilled_run_count() >= 2);
629        let (flat, _) = collector.into_rows().unwrap();
630        assert_eq!(flat.len(), total);
631        assert_eq!(flat[0], vec![Value::Int(0)]);
632        assert_eq!(flat[total - 1], vec![Value::Int((total - 1) as i32)]);
633    }
634
635    #[test]
636    fn spill_drain_matches_into_rows() {
637        let mut full = LocalChunkCollector::new(vec!["a".to_string()]);
638        full.attach_spill_manager(spill_manager(4202));
639        full.set_spill_threshold_rows(5);
640        let mut drained = LocalChunkCollector::new(vec!["a".to_string()]);
641        drained.attach_spill_manager(spill_manager(4203));
642        drained.set_spill_threshold_rows(5);
643        for batch in 0..3 {
644            let rows: Vec<Vec<Value>> = (0..10).map(|i| vec![Value::Int(batch * 10 + i)]).collect();
645            let mut c1 = chunk_with(rows.clone(), None, 1);
646            let mut c2 = chunk_with(rows, None, 1);
647            full.push_chunk(&mut c1).unwrap();
648            drained.push_chunk(&mut c2).unwrap();
649        }
650        let (expected, _) = full.into_rows().unwrap();
651        let mut got = Vec::new();
652        loop {
653            let segment = drained.drain_rows().unwrap();
654            if segment.is_empty() {
655                break;
656            }
657            got.extend(segment);
658        }
659        assert_eq!(got, expected);
660    }
661
662    #[test]
663    fn spill_merge_memory_and_spilled() {
664        let mut a = LocalChunkCollector::new(vec!["a".to_string()]);
665        a.attach_spill_manager(spill_manager(4204));
666        a.set_spill_threshold_rows(2);
667        let mut c1 = chunk_with(
668            vec![
669                vec![Value::Int(1)],
670                vec![Value::Int(2)],
671                vec![Value::Int(3)],
672            ],
673            None,
674            1,
675        );
676        a.push_chunk(&mut c1).unwrap();
677        assert!(a.is_spilled());
678
679        let mut b = LocalChunkCollector::new(vec!["a".to_string()]);
680        let mut c2 = chunk_with(vec![vec![Value::Int(4)]], None, 1);
681        b.push_chunk(&mut c2).unwrap();
682
683        // Different managers: spilled merge must be rejected.
684        b.attach_spill_manager(spill_manager(4205));
685        assert!(a.merge(b).is_err());
686    }
687
688    #[test]
689    fn spill_merge_same_manager_preserves_order() {
690        let manager = spill_manager(4206);
691        let mut a = LocalChunkCollector::new(vec!["a".to_string()]);
692        a.attach_spill_manager(manager.clone());
693        a.set_spill_threshold_rows(2);
694        let mut c1 = chunk_with(
695            vec![
696                vec![Value::Int(1)],
697                vec![Value::Int(2)],
698                vec![Value::Int(3)],
699            ],
700            None,
701            1,
702        );
703        a.push_chunk(&mut c1).unwrap();
704
705        let mut b = LocalChunkCollector::new(vec!["a".to_string()]);
706        b.attach_spill_manager(manager);
707        b.set_spill_threshold_rows(100);
708        let mut c2 = chunk_with(vec![vec![Value::Int(4)]], None, 1);
709        b.push_chunk(&mut c2).unwrap();
710
711        a.merge(b).unwrap();
712        let (flat, _) = a.into_rows().unwrap();
713        assert_eq!(
714            flat,
715            vec![
716                vec![Value::Int(1)],
717                vec![Value::Int(2)],
718                vec![Value::Int(3)],
719                vec![Value::Int(4)],
720            ]
721        );
722    }
723
724    #[test]
725    fn spill_merge_column_mismatch_rejected() {
726        let manager = spill_manager(4207);
727        let mut a = LocalChunkCollector::new(vec!["a".to_string()]);
728        a.attach_spill_manager(manager.clone());
729        a.set_spill_threshold_rows(1);
730        let mut c1 = chunk_with(vec![vec![Value::Int(1)], vec![Value::Int(2)]], None, 1);
731        a.push_chunk(&mut c1).unwrap();
732        assert!(a.is_spilled());
733
734        let mut b = LocalChunkCollector::new(vec!["b".to_string()]);
735        b.attach_spill_manager(manager);
736        let mut c2 = chunk_with(vec![vec![Value::Int(3)]], None, 1);
737        b.push_chunk(&mut c2).unwrap();
738
739        assert!(a.merge(b).is_err());
740    }
741
742    #[test]
743    fn spill_quota_error_surfaces() {
744        let quota = DiskQuota::new(1);
745        let config = SpillConfig {
746            temp_dir: None,
747            max_spill_files: 64,
748            collect_spill_rows: Some(1),
749        };
750        let manager = Arc::new(SpillManager::new_with_quota(config, 4208, quota).unwrap());
751        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
752        collector.attach_spill_manager(manager);
753        // Threshold comes from the manager config (1 row).
754        let rows: Vec<Vec<Value>> = (0..50).map(|i| vec![Value::Int(i)]).collect();
755        let mut c = chunk_with(rows, None, 1);
756        assert!(collector.push_chunk(&mut c).is_err());
757    }
758
759    #[test]
760    fn no_manager_keeps_memory_behavior() {
761        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
762        collector.set_spill_threshold_rows(1);
763        let mut c = chunk_with(vec![vec![Value::Int(1)], vec![Value::Int(2)]], None, 1);
764        collector.push_chunk(&mut c).unwrap();
765        assert!(!collector.is_spilled());
766        assert_eq!(collector.spilled_rows(), 0);
767        let (flat, _) = collector.into_rows().unwrap();
768        assert_eq!(flat.len(), 2);
769    }
770
771    fn spill_with_rows(query_id: u64, n: i32) -> (LocalChunkCollector, Arc<SpillManager>) {
772        let manager = spill_manager(query_id);
773        let mut collector = LocalChunkCollector::new(vec!["a".to_string()]);
774        collector.attach_spill_manager(manager.clone());
775        collector.set_spill_threshold_rows(1);
776        let rows: Vec<Vec<Value>> = (0..n).map(|i| vec![Value::Int(i)]).collect();
777        let mut c = chunk_with(rows, None, 1);
778        collector.push_chunk(&mut c).unwrap();
779        collector.finish_spill().unwrap();
780        assert!(collector.is_spilled());
781        (collector, manager)
782    }
783
784    fn corrupt_run_headers(manager: &SpillManager, offset: u64) {
785        let entries = std::fs::read_dir(manager.base_dir()).unwrap();
786        let mut corrupted = 0;
787        for entry in entries.flatten() {
788            let path = entry.path();
789            if path.extension().and_then(|e| e.to_str()) != Some("run") {
790                continue;
791            }
792            let mut bytes = std::fs::read(&path).unwrap();
793            assert!(bytes.len() > offset as usize);
794            bytes[offset as usize] ^= 0xff;
795            std::fs::write(&path, &bytes).unwrap();
796            corrupted += 1;
797        }
798        assert!(corrupted > 0, "expected at least one run file to corrupt");
799    }
800
801    fn corrupt_run_bodies(manager: &SpillManager, offset: u64) {
802        let entries = std::fs::read_dir(manager.base_dir()).unwrap();
803        let mut corrupted = 0;
804        for entry in entries.flatten() {
805            let path = entry.path();
806            if path.extension().and_then(|e| e.to_str()) != Some("run") {
807                continue;
808            }
809            let mut bytes = std::fs::read(&path).unwrap();
810            if bytes.len() <= offset as usize {
811                continue;
812            }
813            bytes[offset as usize] ^= 0xff;
814            std::fs::write(&path, &bytes).unwrap();
815            corrupted += 1;
816        }
817        assert!(corrupted > 0, "expected at least one run file to corrupt");
818    }
819
820    fn repair_run_header_checksums(manager: &SpillManager) {
821        fn fnv1a_64_local(data: &[u8]) -> u64 {
822            let mut hash = 0xcbf29ce484222325u64;
823            for &b in data {
824                hash ^= b as u64;
825                hash = hash.wrapping_mul(0x100000001b3);
826            }
827            hash
828        }
829        let entries = std::fs::read_dir(manager.base_dir()).unwrap();
830        for entry in entries.flatten() {
831            let path = entry.path();
832            if path.extension().and_then(|e| e.to_str()) != Some("run") {
833                continue;
834            }
835            let mut bytes = std::fs::read(&path).unwrap();
836            assert!(bytes.len() >= 48);
837            let checksum = fnv1a_64_local(&bytes[0..40]);
838            bytes[40..48].copy_from_slice(&checksum.to_le_bytes());
839            std::fs::write(&path, &bytes).unwrap();
840        }
841    }
842
843    #[test]
844    fn spilled_checksum_failure_surfaces_at_replay() {
845        let (collector, manager) = spill_with_rows(4211, 20);
846        // Flip a byte inside the first section payload (past the 48-byte
847        // header and 9-byte frame prefix): the frame checksum must fail.
848        corrupt_run_bodies(&manager, 57);
849        let err = collector.into_rows().unwrap_err();
850        assert!(
851            err.to_string().contains("checksum mismatch"),
852            "unexpected error: {}",
853            err
854        );
855    }
856
857    #[test]
858    fn spilled_fingerprint_failure_surfaces_at_replay() {
859        let (collector, manager) = spill_with_rows(4212, 20);
860        // Flip a byte in the stored schema fingerprint (header bytes 8..16),
861        // then repair the header checksum so replay reaches fingerprint
862        // validation instead of failing on header integrity.
863        corrupt_run_headers(&manager, 8);
864        repair_run_header_checksums(&manager);
865        let err = collector.into_rows().unwrap_err();
866        assert!(
867            err.to_string().contains("fingerprint mismatch"),
868            "unexpected error: {}",
869            err
870        );
871    }
872}