Skip to main content

akar_storage/
node_group.rs

1//! NodeGroup — a fixed-size collection of ColumnChunks (one per column).
2//!
3//! A `NodeGroup` holds up to `NODE_GROUP_SIZE` rows of data across all
4//! columns of a table. When the group is full it can be flushed to a set
5//! of persistent `Column` instances. This mirrors the C++ Akar
6//! `ChunkedNodeGroup` / `NodeGroup` concept.
7//!
8//! # Row → Column mapping
9//!
10//! Each row is a `Vec<Value>` with one element per column. The NodeGroup
11//! distributes values column-wise: `columns[col_idx].append(values[col_idx])`.
12
13use crate::column::Column;
14use crate::column_chunk::{ColumnChunk, NODE_GROUP_SIZE};
15use crate::spiller::{MultiWayStreamMerge, SpillFile, Spiller};
16use crate::version_info::VersionInfo;
17use akar_common::error::StorageError;
18use akar_common::types::Value;
19use std::collections::HashMap;
20use std::sync::Arc;
21
22/// A node group stores up to `NODE_GROUP_SIZE` rows in columnar format.
23///
24/// `start_offset` is the global row index within the owning table where
25/// this group's data begins. `num_nodes` counts how many rows have been
26/// appended so far (≤ `NODE_GROUP_SIZE`).
27///
28/// `version_info` tracks MVCC insert/delete visibility for concurrent
29/// writers. It is `None` for single-writer mode (backward compat).
30///
31/// # Disk Spilling
32///
33/// When `spiller` is set and the memory threshold is exceeded, the group
34/// automatically spills its contents to temp files during `append_row()`.
35/// After ingestion is complete, call `flush_with_spiller()` instead of
36/// `flush()` to merge all spills + final in-memory data into the columns.
37#[derive(Debug, Clone)]
38pub struct NodeGroup {
39    /// One in-memory ColumnChunk per column of the table.
40    pub columns: Vec<ColumnChunk>,
41    /// Global row offset within the owning table.
42    pub start_offset: u64,
43    /// Number of rows currently stored in this group.
44    pub num_nodes: u64,
45    /// Optional MVCC version tracker for this group.
46    pub version_info: Option<VersionInfo>,
47    /// Optional spiller for disk-based memory management.
48    spiller: Option<Arc<Spiller>>,
49    /// List of spill files created during append operations.
50    spill_files: Vec<SpillFile>,
51}
52
53impl NodeGroup {
54    /// Create a new empty node group for `num_columns` columns.
55    ///
56    /// All columns start with the default `NODE_GROUP_SIZE` capacity.
57    /// `start_offset` is the global row index in the owning table where
58    /// this group begins.
59    pub fn new(num_columns: usize, start_offset: u64) -> Self {
60        let columns = (0..num_columns).map(|_| ColumnChunk::new()).collect();
61        Self {
62            columns,
63            start_offset,
64            num_nodes: 0,
65            version_info: None,
66            spiller: None,
67            spill_files: Vec::new(),
68        }
69    }
70
71    /// Create a new node group with a custom chunk capacity per column.
72    pub fn with_capacity(num_columns: usize, start_offset: u64, capacity: usize) -> Self {
73        let columns = (0..num_columns).map(|_| ColumnChunk::with_capacity(capacity)).collect();
74        Self {
75            columns,
76            start_offset,
77            num_nodes: 0,
78            version_info: None,
79            spiller: None,
80            spill_files: Vec::new(),
81        }
82    }
83
84    /// Attach a spiller to this node group for disk-based memory management.
85    ///
86    /// When a spiller is attached, `append_row()` automatically spills the
87    /// current buffer to disk when the memory threshold is exceeded, then
88    /// continues appending. Call `flush_with_spiller()` instead of `flush()`
89    /// to merge all spill files + final in-memory data.
90    pub fn with_spiller(mut self, spiller: Arc<Spiller>) -> Self {
91        self.spiller = Some(spiller);
92        self
93    }
94
95    /// Set the spiller on an existing node group.
96    pub fn set_spiller(&mut self, spiller: Arc<Spiller>) {
97        self.spiller = Some(spiller);
98    }
99
100    /// Enable MVCC version tracking for this node group.
101    /// Must be called before any inserts if concurrent writes are expected.
102    pub fn enable_version_info(&mut self) {
103        if self.version_info.is_none() {
104            self.version_info = Some(VersionInfo::new(NODE_GROUP_SIZE));
105        }
106    }
107
108    // ------------------------------------------------------------------
109    // Public API
110    // ------------------------------------------------------------------
111
112    /// Append a single row (one value per column) to the group.
113    ///
114    /// Returns an error if the number of values does not match the number
115    /// of columns, or if the group is already full.
116    ///
117    /// If `txn_id` is `Some(...)`, the insert is recorded in the version
118    /// info for MVCC visibility tracking.
119    pub fn append_row(&mut self, row: Vec<Value>) -> Result<(), StorageError> {
120        self.append_row_with_txn(row, None)
121    }
122
123    /// Append a row with an optional transaction ID for MVCC tracking.
124    ///
125    /// If a spiller is attached and the in-memory data exceeds the configured
126    /// memory threshold, the current buffer is automatically spilled to disk
127    /// before appending the new row. This keeps memory usage bounded during
128    /// large batch operations like `COPY FROM`.
129    pub fn append_row_with_txn(&mut self, row: Vec<Value>, txn_id: Option<u64>) -> Result<(), StorageError> {
130        if row.len() != self.columns.len() {
131            return Err(StorageError::Page(format!(
132                "column count mismatch: expected {} values, got {}",
133                self.columns.len(),
134                row.len()
135            )));
136        }
137        if self.is_full() {
138            return Err(StorageError::Page("node group is already full".to_string()));
139        }
140
141        // Auto-spill if the memory threshold is exceeded
142        if let Some(ref spiller) = self.spiller
143            && !self.columns.is_empty()
144            && spiller.should_spill(&self.columns[0])
145        {
146            self.spill_and_clear()?;
147        }
148
149        for (col_idx, value) in row.into_iter().enumerate() {
150            self.columns[col_idx].append(value);
151        }
152        // Record insert in version info if MVCC tracking is enabled
153        if let Some(ref vi) = self.version_info
154            && let Some(txn) = txn_id
155        {
156            vi.insert(txn, self.num_nodes as u32);
157        }
158        self.num_nodes += 1;
159        Ok(())
160    }
161
162    /// Spill all column chunks to disk and reset the group to empty.
163    ///
164    /// The spill file is tracked so that `flush_with_spiller()` can later
165    /// merge all spilled data back into the persistent columns.
166    ///
167    /// Version info is reset together with the buffer: the records reference
168    /// local row offsets that are about to be reused, so carrying them over
169    /// would mis-label rows appended after the spill at the same offsets.
170    pub fn spill_and_clear(&mut self) -> Result<(), StorageError> {
171        let spiller = self
172            .spiller
173            .as_ref()
174            .ok_or_else(|| StorageError::Spiller("No spiller attached to NodeGroup".to_string()))?;
175
176        if self.is_empty() {
177            return Ok(());
178        }
179
180        let spill = spiller.spill_columns(&mut self.columns)?;
181        if let Some(sf) = spill {
182            self.spill_files.push(sf);
183        }
184        self.num_nodes = 0;
185        if let Some(ref vi) = self.version_info {
186            vi.reset();
187        }
188        Ok(())
189    }
190
191    /// Restore all spilled rows back into the in-memory columns.
192    ///
193    /// Merges every tracked spill file (in creation order) followed by the
194    /// rows appended since the last spill, so the group's columns again hold
195    /// the complete row set. Spill files are cleaned up on success. This is
196    /// the ingest-time counterpart of `flush_with_spiller()`: it keeps the
197    /// in-memory node group authoritative for scans and the column mirror
198    /// after a memory-bounded bulk ingest (P51.44).
199    pub fn restore_spilled(&mut self) -> Result<(), StorageError> {
200        if self.spill_files.is_empty() {
201            return Ok(());
202        }
203        let spiller = self
204            .spiller
205            .clone()
206            .ok_or_else(|| StorageError::Spiller("No spiller attached to NodeGroup".to_string()))?;
207        let num_cols = self.columns.len();
208
209        let mut rows: Vec<Vec<Value>> = Vec::new();
210        let files = std::mem::take(&mut self.spill_files);
211        for sf in &files {
212            let chunks = spiller.restore_columns(sf, num_cols)?;
213            let n = chunks.first().map(|c| c.num_values()).unwrap_or(0);
214            for r in 0..n {
215                let mut row = Vec::with_capacity(num_cols);
216                for c in &chunks {
217                    row.push(c.get(r).cloned().unwrap_or(Value::Null));
218                }
219                rows.push(row);
220            }
221        }
222        for row in self.scan() {
223            rows.push(row);
224        }
225
226        let mut columns: Vec<ColumnChunk> = (0..num_cols).map(|_| ColumnChunk::new()).collect();
227        for row in &rows {
228            for (ci, value) in row.iter().enumerate() {
229                columns[ci].append(value.clone());
230            }
231        }
232        self.columns = columns;
233        self.num_nodes = rows.len() as u64;
234        // The rebuild re-numbers rows (spill files first, then in-memory), so
235        // any version-info records still referencing pre-restore offsets are
236        // stale. Reset them rather than mis-labelling the restored rows.
237        if let Some(ref vi) = self.version_info {
238            vi.reset();
239        }
240        for sf in &files {
241            let _ = spiller.cleanup(sf);
242        }
243        Ok(())
244    }
245
246    /// Flush all data to persistent columns, merging any spilled data.
247    ///
248    /// This is the spill-aware alternative to `flush()`. It merges all
249    /// previously spilled files + the current in-memory buffer into the
250    /// target columns using a streaming merge. If no spilling occurred,
251    /// this falls back to the regular `flush()`.
252    ///
253    /// The optional `sort_key_column` is the column index to use for
254    /// merge ordering and PK deduplication. Pass `None` for unordered
255    /// append (no dedup).
256    pub fn flush_with_spiller(
257        &mut self,
258        columns: &mut [Column],
259        sort_key_column: Option<usize>,
260        dedup: bool,
261    ) -> std::io::Result<usize> {
262        if self.spill_files.is_empty() {
263            // No spilling occurred — regular flush
264            return self.flush(columns);
265        }
266
267        assert_eq!(
268            columns.len(),
269            self.columns.len(),
270            "NodeGroup::flush_with_spiller: column count mismatch"
271        );
272
273        // Capture in-memory rows before clearing
274        let in_memory_rows = self.scan();
275        self.clear();
276
277        // Build the merger
278        let sort_col = sort_key_column.unwrap_or(0);
279        let mut merger = MultiWayStreamMerge::new(&self.spill_files, Some(in_memory_rows), sort_col, dedup)
280            .map_err(std::io::Error::other)?;
281
282        // Stream all merged rows into the target columns
283        let mut total: usize = 0;
284        while let Some(row) = merger.next_tuple() {
285            for (col_idx, value) in row.into_iter().enumerate() {
286                if col_idx < columns.len() {
287                    columns[col_idx].append_value(&value)?;
288                }
289            }
290            total += 1;
291        }
292
293        // Clean up spill files
294        if let Some(ref spiller) = self.spiller {
295            let files = std::mem::take(&mut self.spill_files);
296            for sf in &files {
297                let _ = spiller.cleanup(sf);
298            }
299        }
300
301        Ok(total)
302    }
303
304    /// Whether the group has reached capacity.
305    pub fn is_full(&self) -> bool {
306        self.num_nodes as usize >= NODE_GROUP_SIZE
307    }
308
309    /// Whether the group is empty.
310    pub fn is_empty(&self) -> bool {
311        self.num_nodes == 0
312    }
313
314    /// Number of columns in this group.
315    pub fn num_columns(&self) -> usize {
316        self.columns.len()
317    }
318
319    /// Whether any spill files are still pending merge-back into memory.
320    pub fn has_spill_files(&self) -> bool {
321        !self.spill_files.is_empty()
322    }
323
324    /// Remaining capacity (number of additional rows that can be appended).
325    pub fn remaining(&self) -> usize {
326        NODE_GROUP_SIZE.saturating_sub(self.num_nodes as usize)
327    }
328
329    /// Flush all buffered data to persistent `Column` instances.
330    ///
331    /// Each `ColumnChunk` is flushed to the corresponding `Column` in the
332    /// slice via `flush_to_column()`. After flushing, the chunks are
333    /// cleared and ready for reuse.
334    ///
335    /// Returns the total number of rows flushed.
336    ///
337    /// # Panics
338    ///
339    /// Panics if `columns.len() != self.columns.len()`.
340    pub fn flush(&mut self, columns: &mut [Column]) -> std::io::Result<usize> {
341        assert_eq!(
342            columns.len(),
343            self.columns.len(),
344            "NodeGroup::flush: column count mismatch"
345        );
346        let mut total = 0;
347        for (chunk, col) in self.columns.iter_mut().zip(columns.iter_mut()) {
348            let n = chunk.flush_to_column(col)?;
349            // All chunks should flush the same number of values.
350            if total == 0 {
351                total = n;
352            }
353            debug_assert!(n == 0 || n == total, "inconsistent flush count");
354        }
355        self.num_nodes = 0;
356        Ok(total)
357    }
358
359    /// Flush data to columns but keep the in-memory buffer intact.
360    pub fn flush_copy(&self, columns: &mut [Column]) -> std::io::Result<usize> {
361        assert_eq!(
362            columns.len(),
363            self.columns.len(),
364            "NodeGroup::flush_copy: column count mismatch"
365        );
366        let mut total = 0;
367        for (chunk, col) in self.columns.iter().zip(columns.iter_mut()) {
368            let n = chunk.flush_copy_to_column(col)?;
369            if total == 0 {
370                total = n;
371            }
372        }
373        Ok(total)
374    }
375
376    /// Scan all rows currently buffered in the group.
377    ///
378    /// Returns a `Vec<Vec<Value>>` where `result[row][col]` is the value
379    /// at the given row and column.
380    pub fn scan(&self) -> Vec<Vec<Value>> {
381        let n_rows = self.num_nodes as usize;
382        let n_cols = self.columns.len();
383        let mut result = Vec::with_capacity(n_rows);
384
385        for row in 0..n_rows {
386            let mut row_data = Vec::with_capacity(n_cols);
387            for chunk in &self.columns {
388                match chunk.get(row) {
389                    Some(v) => row_data.push(v.clone()),
390                    None => row_data.push(Value::Null),
391                }
392            }
393            result.push(row_data);
394        }
395        result
396    }
397
398    /// Scan a range of buffered rows `[start, start + count)`.
399    ///
400    /// Returns `Vec<Vec<Value>>` in row-major order.
401    pub fn scan_range(&self, start: usize, count: usize) -> Vec<Vec<Value>> {
402        let end = (start + count).min(self.num_nodes as usize);
403        if start >= end {
404            return Vec::new();
405        }
406        let n_cols = self.columns.len();
407        let mut result = Vec::with_capacity(end - start);
408
409        for row in start..end {
410            let mut row_data = Vec::with_capacity(n_cols);
411            for chunk in &self.columns {
412                match chunk.get(row) {
413                    Some(v) => row_data.push(v.clone()),
414                    None => row_data.push(Value::Null),
415                }
416            }
417            result.push(row_data);
418        }
419        result
420    }
421
422    /// Access a single value at the given local row and column index.
423    pub fn get_value(&self, local_row: usize, col_idx: usize) -> Option<&Value> {
424        self.columns.get(col_idx).and_then(|chunk| chunk.get(local_row))
425    }
426
427    /// Access a single value with MVCC snapshot isolation.
428    ///
429    /// Checks `VersionInfo` for insert/delete visibility first. If the row
430    /// is not visible at `snapshot_ts`, returns `None`. Then checks
431    /// `UpdateInfo` version chain on the column chunk for versioned updates.
432    pub fn get_value_with_snapshot(
433        &self,
434        local_row: usize,
435        col_idx: usize,
436        snapshot_ts: Option<u64>,
437        commit_history: &HashMap<u64, u64>,
438    ) -> Option<&Value> {
439        // Check version info visibility (inserts/deletes)
440        if let Some(ts) = snapshot_ts
441            && !self.is_row_visible(local_row, ts, commit_history)
442        {
443            return None;
444        }
445        // Get value with update version chain check
446        self.columns
447            .get(col_idx)
448            .and_then(|chunk| chunk.get_value_with_snapshot(local_row, snapshot_ts, commit_history))
449    }
450
451    /// Access a single value with MVCC snapshot isolation (owned variant).
452    ///
453    /// Like `get_value_with_snapshot` but returns `Option<Value>` instead of
454    /// `Option<&Value>`, enabling proper version chain traversal with
455    /// deserialized old values from `UpdateInfo`.
456    pub fn get_value_owned_with_snapshot(
457        &self,
458        local_row: usize,
459        col_idx: usize,
460        snapshot_ts: Option<u64>,
461        commit_history: &HashMap<u64, u64>,
462    ) -> Option<Value> {
463        // Check version info visibility (inserts/deletes)
464        if let Some(ts) = snapshot_ts
465            && !self.is_row_visible(local_row, ts, commit_history)
466        {
467            return None;
468        }
469        // Get value with update version chain check (owned)
470        self.columns
471            .get(col_idx)
472            .and_then(|chunk| chunk.get_value_owned_with_snapshot(local_row, snapshot_ts, commit_history))
473    }
474
475    /// Check whether a row is visible at the given snapshot timestamp.
476    /// Returns `true` if no version tracking is active (backward compat).
477    pub fn is_row_visible(&self, local_row: usize, snapshot_ts: u64, commit_history: &HashMap<u64, u64>) -> bool {
478        match &self.version_info {
479            Some(vi) => vi.is_visible(local_row as u32, snapshot_ts, commit_history),
480            None => true, // No version tracking → always visible
481        }
482    }
483
484    /// Reset the group to empty without flushing.
485    pub fn clear(&mut self) {
486        for chunk in &mut self.columns {
487            chunk.clear();
488        }
489        self.num_nodes = 0;
490        // The buffer is being emptied for reuse; stale version-info records
491        // would mis-label future rows appended at the same offsets.
492        if let Some(ref vi) = self.version_info {
493            vi.reset();
494        }
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Tests
500// ---------------------------------------------------------------------------
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::buffer_manager::BufferManagerConfig;
506    use crate::page::DEFAULT_PAGE_SIZE;
507    use akar_common::memory::MemoryManager;
508    use akar_common::types::LogicalTypeID;
509    use std::sync::{Arc, Mutex};
510
511    fn setup_columns(num_cols: usize, db_path: &std::path::Path) -> Vec<Column> {
512        let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
513        let config = BufferManagerConfig::default();
514        let bm = Arc::new(Mutex::new(crate::buffer_manager::BufferManager::new(
515            db_path.to_path_buf(),
516            mm,
517            config,
518        )));
519        (0..num_cols)
520            .map(|i| {
521                Column::new(
522                    LogicalTypeID::Int64,
523                    0,
524                    i as u32,
525                    db_path,
526                    bm.clone(),
527                    DEFAULT_PAGE_SIZE,
528                )
529            })
530            .collect()
531    }
532
533    #[test]
534    fn test_empty_group() {
535        let group = NodeGroup::new(3, 0);
536        assert_eq!(group.num_columns(), 3);
537        assert_eq!(group.num_nodes, 0);
538        assert!(group.is_empty());
539        assert!(!group.is_full());
540        assert_eq!(group.start_offset, 0);
541    }
542
543    #[test]
544    fn test_append_row() {
545        let mut group = NodeGroup::new(2, 100);
546        group.append_row(vec![Value::Int64(1), Value::Int64(2)]).unwrap();
547        assert_eq!(group.num_nodes, 1);
548        assert!(!group.is_empty());
549
550        group.append_row(vec![Value::Int64(3), Value::Int64(4)]).unwrap();
551        assert_eq!(group.num_nodes, 2);
552    }
553
554    #[test]
555    fn test_append_wrong_column_count() {
556        let mut group = NodeGroup::new(2, 0);
557        let result = group.append_row(vec![Value::Int64(1)]);
558        assert!(result.is_err());
559        assert!(result.unwrap_err().to_string().contains("column count mismatch"));
560    }
561
562    #[test]
563    fn test_append_when_full() {
564        // NodeGroup fullness is based on NODE_GROUP_SIZE (4096).
565        // Test that is_full returns false when not full:
566        let mut group = NodeGroup::with_capacity(1, 0, 5);
567        for _ in 0..10 {
568            group.append_row(vec![Value::Int64(1)]).unwrap();
569        }
570        assert!(!group.is_full());
571        assert_eq!(group.num_nodes, 10);
572    }
573
574    #[test]
575    fn test_scan() {
576        let mut group = NodeGroup::new(3, 0);
577        group
578            .append_row(vec![Value::Int64(10), Value::Int64(20), Value::Int64(30)])
579            .unwrap();
580        group
581            .append_row(vec![Value::Int64(11), Value::Int64(21), Value::Int64(31)])
582            .unwrap();
583
584        let data = group.scan();
585        assert_eq!(data.len(), 2);
586        assert_eq!(data[0][0], Value::Int64(10));
587        assert_eq!(data[0][1], Value::Int64(20));
588        assert_eq!(data[1][2], Value::Int64(31));
589    }
590
591    #[test]
592    fn test_scan_range() {
593        let mut group = NodeGroup::new(2, 0);
594        for i in 0..10 {
595            group.append_row(vec![Value::Int64(i), Value::Int64(i * 10)]).unwrap();
596        }
597
598        let slice = group.scan_range(3, 4);
599        assert_eq!(slice.len(), 4);
600        assert_eq!(slice[0][0], Value::Int64(3));
601        assert_eq!(slice[3][0], Value::Int64(6));
602    }
603
604    #[test]
605    fn test_get_value() {
606        let mut group = NodeGroup::new(2, 50);
607        group.append_row(vec![Value::Int64(100), Value::Int64(200)]).unwrap();
608
609        assert_eq!(group.get_value(0, 0), Some(&Value::Int64(100)));
610        assert_eq!(group.get_value(0, 1), Some(&Value::Int64(200)));
611        assert_eq!(group.get_value(1, 0), None);
612    }
613
614    #[test]
615    fn test_flush_to_columns() {
616        let dir = tempfile::tempdir().unwrap();
617        let mut cols = setup_columns(2, dir.path());
618        let mut group = NodeGroup::new(2, 0);
619
620        for i in 0i64..50 {
621            group.append_row(vec![Value::Int64(i), Value::Int64(i * 10)]).unwrap();
622        }
623
624        let flushed = group.flush(&mut cols).unwrap();
625        assert_eq!(flushed, 50);
626        assert_eq!(group.num_nodes, 0);
627        assert!(group.is_empty());
628
629        // Verify data persisted in columns
630        for i in 0i64..50 {
631            assert_eq!(cols[0].get_value(i as u64).unwrap(), Value::Int64(i));
632            assert_eq!(cols[1].get_value(i as u64).unwrap(), Value::Int64(i * 10));
633        }
634    }
635
636    #[test]
637    fn test_flush_copy_preserves_buffer() {
638        let dir = tempfile::tempdir().unwrap();
639        let mut cols = setup_columns(2, dir.path());
640        let mut group = NodeGroup::new(2, 0);
641
642        group.append_row(vec![Value::Int64(1), Value::Int64(2)]).unwrap();
643
644        let flushed = group.flush_copy(&mut cols).unwrap();
645        assert_eq!(flushed, 1);
646        // Buffer should still be intact
647        assert_eq!(group.num_nodes, 1);
648        assert_eq!(cols[0].get_value(0).unwrap(), Value::Int64(1));
649    }
650
651    #[test]
652    fn test_restore_spilled_reconstructs_full_group() {
653        let dir = tempfile::tempdir().unwrap();
654        let spiller = Arc::new(crate::spiller::Spiller::new(dir.path(), 64));
655        let mut group = NodeGroup::new(2, 0);
656        group.set_spiller(spiller.clone());
657
658        // Append enough rows that the group spills mid-way, then more rows.
659        for i in 0i64..20 {
660            group.append_row(vec![Value::Int64(i), Value::Int64(i * 10)]).unwrap();
661        }
662        assert!(!group.spill_files.is_empty(), "low threshold must spill");
663        assert!(group.num_nodes < 20, "spill must have evicted rows from memory");
664
665        group.restore_spilled().unwrap();
666        assert_eq!(group.num_nodes, 20, "restore must recover the full row set");
667        assert!(group.spill_files.is_empty(), "spill files cleaned up after restore");
668        for i in 0i64..20 {
669            assert_eq!(group.get_value(i as usize, 0), Some(&Value::Int64(i)));
670            assert_eq!(group.get_value(i as usize, 1), Some(&Value::Int64(i * 10)));
671        }
672    }
673
674    #[test]
675    fn test_clear() {
676        let mut group = NodeGroup::new(2, 0);
677        group.append_row(vec![Value::Int64(1), Value::Int64(2)]).unwrap();
678        group.clear();
679        assert_eq!(group.num_nodes, 0);
680        assert!(group.is_empty());
681    }
682
683    #[test]
684    fn test_clear_resets_version_info() {
685        let mut group = NodeGroup::new(2, 0);
686        group.enable_version_info();
687        group
688            .append_row_with_txn(vec![Value::Int64(1), Value::Int64(2)], Some(7))
689            .unwrap();
690        assert_eq!(group.version_info.as_ref().unwrap().num_inserters(), 1);
691
692        group.clear();
693
694        // Buffer reuse must not carry stale version records into new rows.
695        assert_eq!(group.version_info.as_ref().unwrap().num_inserters(), 0);
696        assert!(group.is_row_visible(0, 0, &HashMap::new()));
697    }
698
699    #[test]
700    fn test_spill_and_clear_resets_version_info() {
701        let dir = tempfile::tempdir().unwrap();
702        let spiller = Arc::new(crate::spiller::Spiller::new(dir.path(), 64));
703        let mut group = NodeGroup::new(2, 0);
704        group.set_spiller(spiller.clone());
705        group.enable_version_info();
706
707        // Large batch with MVCC tracking: the low threshold forces an
708        // auto-spill mid-way, then more rows are appended at reused offsets.
709        for i in 0i64..20 {
710            group
711                .append_row_with_txn(vec![Value::Int64(i), Value::Int64(i * 10)], Some(7))
712                .unwrap();
713        }
714        assert!(!group.spill_files.is_empty(), "low threshold must spill");
715
716        // After restore (which re-numbers rows), the group holds the full row
717        // set and no stale version records survive. Every row must be visible
718        // at any snapshot — a lingering pre-spill record at a reused offset
719        // would wrongly hide rows until a commit timestamp that never arrives.
720        group.restore_spilled().unwrap();
721        assert_eq!(group.num_nodes, 20);
722        let vi = group.version_info.as_ref().unwrap();
723        assert_eq!(vi.num_inserters(), 0, "stale spill records must be dropped");
724        for i in 0usize..20 {
725            assert!(
726                group.is_row_visible(i, 0, &HashMap::new()),
727                "row {i} visible by default"
728            );
729        }
730    }
731
732    #[test]
733    fn test_remaining() {
734        // `remaining()` is based on NODE_GROUP_SIZE (4096), not chunk capacity.
735        let mut group = NodeGroup::with_capacity(3, 0, 10);
736        assert_eq!(group.remaining(), NODE_GROUP_SIZE);
737        group
738            .append_row(vec![Value::Int64(1), Value::Int64(2), Value::Int64(3)])
739            .unwrap();
740        assert_eq!(group.remaining(), NODE_GROUP_SIZE - 1);
741        group
742            .append_row(vec![Value::Int64(4), Value::Int64(5), Value::Int64(6)])
743            .unwrap();
744        assert_eq!(group.remaining(), NODE_GROUP_SIZE - 2);
745    }
746
747    #[test]
748    fn test_start_offset() {
749        let group = NodeGroup::new(2, 12345);
750        assert_eq!(group.start_offset, 12345);
751    }
752
753    #[test]
754    fn test_multi_column_scan() {
755        let mut group = NodeGroup::new(4, 0);
756        group
757            .append_row(vec![
758                Value::String("Alice".into()),
759                Value::Int64(30),
760                Value::Double(1.65),
761                Value::Bool(true),
762            ])
763            .unwrap();
764
765        let data = group.scan();
766        assert_eq!(data.len(), 1);
767        assert_eq!(data[0][0], Value::String("Alice".into()));
768        assert_eq!(data[0][1], Value::Int64(30));
769        assert_eq!(data[0][3], Value::Bool(true));
770    }
771
772    #[test]
773    fn test_multiple_flush_cycles() {
774        let dir = tempfile::tempdir().unwrap();
775        let mut cols = setup_columns(2, dir.path());
776        let mut group = NodeGroup::with_capacity(2, 0, 20);
777
778        // Flush cycle 1
779        for i in 0i64..15 {
780            group.append_row(vec![Value::Int64(i), Value::Int64(-i)]).unwrap();
781        }
782        assert_eq!(group.flush(&mut cols).unwrap(), 15);
783
784        // Flush cycle 2
785        for i in 15i64..30 {
786            group.append_row(vec![Value::Int64(i), Value::Int64(-i)]).unwrap();
787        }
788        assert_eq!(group.flush(&mut cols).unwrap(), 15);
789
790        // Verify all 30 rows
791        assert_eq!(cols[0].num_values, 30);
792        for i in 0i64..30 {
793            assert_eq!(cols[0].get_value(i as u64).unwrap(), Value::Int64(i));
794            assert_eq!(cols[1].get_value(i as u64).unwrap(), Value::Int64(-i));
795        }
796    }
797}