Skip to main content

akar_storage/
lib.rs

1//! Akar storage engine.
2//!
3//! Disk-based columnar storage with buffer management, WAL, compression, and indexing.
4
5pub mod art_index;
6pub mod art_key;
7pub mod art_node;
8pub mod buffer_manager;
9pub mod checkpoint;
10pub mod column;
11pub mod column_chunk;
12pub mod compression;
13pub mod csr;
14pub mod csv_reader;
15pub mod free_space_manager;
16pub mod hyperloglog;
17pub mod ice_format;
18pub mod index;
19pub mod lazy_scanner;
20pub mod local_storage;
21pub mod local_wal;
22pub mod node_group;
23pub mod npy_reader;
24pub mod page;
25pub mod page_manager;
26#[cfg(feature = "parquet")]
27pub mod parquet_reader;
28#[cfg(feature = "parquet")]
29pub mod parquet_writer;
30pub mod persistence;
31pub mod predicate;
32pub mod roaring_bitmap;
33pub mod shadow_file;
34pub mod spiller;
35pub mod stats;
36pub mod string_dictionary;
37pub mod table;
38pub mod undo_buffer;
39pub mod update_info;
40pub mod vector_index;
41pub mod version_info;
42pub mod wal;
43pub mod wal_replayer;
44
45use akar_common::error::StorageError;
46use akar_common::memory::MemoryManager;
47use akar_common::types::Value;
48use akar_vector::hnsw::DistanceMetric;
49use buffer_manager::{BufferManager, BufferManagerConfig};
50use checkpoint::checkpoint;
51use std::path::PathBuf;
52use std::sync::{Arc, Mutex};
53use wal::WAL;
54
55pub use art_index::ArtPrimaryKeyIndex;
56pub use art_key::ArtKey;
57pub use column_chunk::{ColumnChunk, NODE_GROUP_SIZE};
58pub use index::{HashIndex, IndexKey, OnDiskHashIndex};
59pub use local_storage::LocalStorage;
60pub use local_wal::LocalWAL;
61pub use node_group::NodeGroup;
62pub use page_manager::PageManager;
63pub use persistence::TablePersistence;
64pub use shadow_file::ShadowFile;
65pub use spiller::{MultiWayStreamMerge, SpillFile, Spiller};
66pub use string_dictionary::StringDictionary;
67pub use table::{ColumnDefinition, NodeTable, RelTable, TableCatalog};
68pub use undo_buffer::UndoBuffer;
69pub use vector_index::{VectorIndexTable, extract_f64_list_from_value};
70pub use wal::WalSink;
71pub use wal_replayer::{ReplayResult, WALReplayer};
72
73/// Shared sink that write-path operators push typed [`WALRecord`]s into
74/// during execution (P60.2). Drained into the transaction's `LocalWAL` by
75/// the connection layer; bulk-copied into the global WAL at commit.
76pub use wal::log_delete_record;
77pub use wal::log_insert_record;
78pub use wal::log_rel_insert_record;
79pub use wal::log_update_record;
80
81/// Serialize a row of values into the tagged binary format expected by
82/// `deserialize_values_from_bytes` (the `WALRecord::Insert`/`Update` payload
83/// format used for SQL-path WAL replay, P60.2).
84pub fn serialize_values_to_bytes(values: &[Value]) -> Vec<u8> {
85    let mut out = Vec::with_capacity(values.len() * 16);
86    for v in values {
87        out.extend_from_slice(&column::Column::serialize_value(v));
88    }
89    out
90}
91
92/// Convert a catalog column definition into a storage-level column definition.
93///
94/// `CatalogColumn.default_value` is handled at the binder/schema layer and does
95/// not need to be materialized in the storage table.
96impl From<&akar_catalog::CatalogColumn> for ColumnDefinition {
97    fn from(c: &akar_catalog::CatalogColumn) -> Self {
98        ColumnDefinition {
99            name: c.name.clone(),
100            logical_type: c.logical_type,
101            is_primary_key: c.is_primary_key,
102            compression: c.compression,
103        }
104    }
105}
106
107/// The storage manager — root of the storage engine.
108#[allow(dead_code)]
109pub struct StorageManager {
110    db_path: PathBuf,
111    buffer_manager: Arc<Mutex<BufferManager>>,
112    wal: Arc<Mutex<WAL>>,
113    memory_manager: Arc<MemoryManager>,
114    /// Page manager for allocation/deallocation.
115    page_manager: Option<Arc<PageManager>>,
116    /// Lock-free table catalog using DashMap internally.
117    pub(crate) table_catalog: Arc<TableCatalog>,
118    /// Durable column mirrors for in-memory tables (P45.4).
119    table_persistence: TablePersistence,
120    /// Optional spiller attached to newly created node tables so bulk ingest
121    /// spills to disk once a NodeGroup exceeds the memory threshold (P51.44).
122    spiller: std::sync::RwLock<Option<Arc<Spiller>>>,
123}
124
125/// Storage info returned by CALL storage_info().
126#[derive(Debug, Clone)]
127pub struct StorageInfo {
128    pub db_path: String,
129    pub page_size: usize,
130    pub total_pages: u64,
131    pub free_pages: u64,
132}
133
134/// Buffer manager info returned by CALL bm_info().
135#[derive(Debug, Clone)]
136pub struct BufferInfo {
137    pub total_memory: usize,
138    pub used_memory: usize,
139    pub num_pinned: usize,
140}
141
142/// File info returned by CALL file_info() / CALL disk_size_info().
143#[derive(Debug, Clone)]
144pub struct FileInfo {
145    pub total_file_size: u64,
146    pub num_data_pages: u64,
147    pub wal_size: u64,
148}
149
150/// FSM info returned by CALL free_space_info().
151#[derive(Debug, Clone)]
152pub struct FsmInfo {
153    pub total_free_pages: u64,
154    pub num_entries: usize,
155}
156
157impl StorageManager {
158    pub fn new(db_path: PathBuf, memory_manager: Arc<MemoryManager>) -> Self {
159        // Ensure the database directory exists (ignore error for :memory: mode)
160        let _ = std::fs::create_dir_all(&db_path);
161
162        let config = BufferManagerConfig::default();
163        let bm = BufferManager::new(db_path.clone(), memory_manager.clone(), config);
164        let wal_path = if db_path.to_string_lossy() == ":memory:" {
165            // In-memory mode: use a temp dir for the WAL
166            let tmp = std::env::temp_dir().join("akar-wal");
167            let _ = std::fs::create_dir_all(&tmp);
168            tmp.join("wal.log")
169        } else {
170            db_path.join("wal.log")
171        };
172        // Do NOT delete the WAL file — it may contain un-recovered data from
173        // a previous session. Recovery is triggered later by `Database::new()`
174        // via the `recover()` method.
175        let wal = WAL::new(wal_path);
176        let fsm = Arc::new(free_space_manager::FreeSpaceManager::new());
177        let existing_pages = 0u64; // Will be determined by file metadata
178        let pm = PageManager::new(db_path.clone(), page::DEFAULT_PAGE_SIZE, existing_pages, fsm);
179        Self {
180            db_path,
181            buffer_manager: Arc::new(Mutex::new(bm)),
182            wal: Arc::new(Mutex::new(wal)),
183            memory_manager,
184            page_manager: Some(Arc::new(pm)),
185            table_catalog: Arc::new(TableCatalog::new()),
186            table_persistence: TablePersistence::new(),
187            spiller: std::sync::RwLock::new(None),
188        }
189    }
190
191    /// Attach a spiller so node tables spill during bulk ingest once a
192    /// NodeGroup's buffer exceeds the memory threshold (P51.44). Applies to
193    /// tables that already exist as well as future ones. A `None` clears the
194    /// spiller.
195    pub fn set_spiller(&self, spiller: Option<Arc<Spiller>>) {
196        *self.spiller.write().unwrap() = spiller.clone();
197        // Collect IDs first: `all_node_tables()` holds shard read-locks for
198        // the returned refs, so a subsequent `get_node_table_mut` (write-lock
199        // on the same shard) would deadlock (P51.44).
200        let table_ids: Vec<u64> = self.table_catalog.all_node_tables().iter().map(|r| *r.key()).collect();
201        for table_id in table_ids {
202            if let Some(mut table) = self.table_catalog.get_node_table_mut(table_id) {
203                table.set_spiller(spiller.clone());
204            }
205        }
206    }
207
208    /// The currently attached spiller (if any).
209    pub fn spiller(&self) -> Option<Arc<Spiller>> {
210        self.spiller.read().unwrap().clone()
211    }
212
213    /// Open (or create) a database at `db_path`, initializing all storage
214    /// subsystems and replaying the WAL if necessary.
215    ///
216    /// This is the primary entry point for storage initialization.
217    /// After opening, call `recover()` to replay any uncommitted WAL records.
218    pub fn open(db_path: PathBuf, memory_manager: Arc<MemoryManager>) -> Self {
219        Self::new(db_path, memory_manager)
220    }
221
222    /// Get a reference to the page manager, if available.
223    pub fn page_manager(&self) -> Option<&Arc<PageManager>> {
224        self.page_manager.as_ref()
225    }
226
227    pub fn buffer_manager(&self) -> &Arc<Mutex<BufferManager>> {
228        &self.buffer_manager
229    }
230
231    pub fn wal(&self) -> &Arc<Mutex<WAL>> {
232        &self.wal
233    }
234
235    pub fn db_path(&self) -> &PathBuf {
236        &self.db_path
237    }
238
239    /// Get a reference to the table catalog for reading/writing table data.
240    pub fn table_catalog(&self) -> Arc<TableCatalog> {
241        self.table_catalog.clone()
242    }
243
244    /// Flush all node + rel tables into their durable column mirrors.
245    ///
246    /// Called after every write (commit or single-writer DML) and at
247    /// checkpoint time so committed rows survive restarts (P45.4).
248    pub fn persist_all_tables(&self) -> Result<(), StorageError> {
249        if self.db_path.to_string_lossy() == ":memory:" {
250            return Ok(()); // In-memory databases have nothing to persist
251        }
252        let page_size = self.buffer_manager.lock().unwrap().page_size();
253        self.table_persistence
254            .persist_all(&self.table_catalog, &self.db_path, &self.buffer_manager, page_size)
255    }
256
257    /// Load all persisted tables from their durable column mirrors.
258    ///
259    /// Called during `Database::new()` AFTER tables are restored from the
260    /// persisted catalog. Returns the number of tables that had persisted data.
261    pub fn load_persisted_tables(&self) -> Result<usize, StorageError> {
262        if self.db_path.to_string_lossy() == ":memory:" {
263            return Ok(0); // In-memory databases have no persisted mirrors
264        }
265        let page_size = self.buffer_manager.lock().unwrap().page_size();
266        self.table_persistence
267            .load_all(&self.table_catalog, &self.db_path, &self.buffer_manager, page_size)
268    }
269
270    /// Delete the durable column mirror for a dropped table.
271    pub fn drop_table_persistence(&self, table_id: u64) {
272        self.table_persistence
273            .remove(table_id, &self.db_path, &self.buffer_manager);
274    }
275
276    /// Log a column write to the WAL before applying it to the BufferManager.
277    pub fn log_column_write(&self, table_id: u64, col_id: u32, page_id: u64, data: &[u8]) {
278        let mut wal = self.wal.lock().unwrap();
279        wal.log_column_write(table_id, col_id, page_id, data);
280    }
281
282    /// Create a node table in the catalog and return its ID.
283    pub fn create_node_table(&self, name: String, columns: Vec<ColumnDefinition>) -> NodeTable {
284        let table = self.table_catalog.create_node_table(name, columns);
285        self.attach_spiller(&table);
286        table
287    }
288
289    /// Attach the current spiller (if any) to a freshly created node table so
290    /// bulk ingest spills to disk once a NodeGroup exceeds the memory
291    /// threshold (P51.44).
292    fn attach_spiller(&self, table: &NodeTable) {
293        if let Some(spiller) = self.spiller() {
294            if let Some(mut stored) = self.table_catalog.get_node_table_mut(table.table_id) {
295                stored.set_spiller(Some(spiller));
296            }
297        }
298    }
299
300    /// Restore a node table at a specific table ID during recovery from a
301    /// persisted catalog. Optionally recreates an ART primary-key index and
302    /// registers its file with the BufferManager.
303    pub fn restore_node_table(
304        &self,
305        table_id: u64,
306        name: String,
307        columns: Vec<ColumnDefinition>,
308        index_name: Option<&str>,
309    ) -> NodeTable {
310        let table = self
311            .table_catalog
312            .create_node_table_with_id(table_id, name.clone(), columns);
313
314        if let Some(index_name) = index_name {
315            // Register the index file with the BufferManager for persistence
316            let mut bm = self.buffer_manager.lock().unwrap();
317            let full_path = self.db_path.join(format!("{index_name}.art"));
318            if !bm.is_file_registered(index_name) {
319                bm.register_file(index_name, full_path);
320            }
321            drop(bm);
322
323            let _ = self.table_catalog.create_art_index(&name, index_name);
324        }
325        self.attach_spiller(&table);
326        table
327    }
328
329    /// Restore a rel table at a specific table ID during recovery from a
330    /// persisted catalog.
331    pub fn restore_rel_table(
332        &self,
333        table_id: u64,
334        name: String,
335        src_table_id: u64,
336        dst_table_id: u64,
337        columns: Vec<ColumnDefinition>,
338    ) -> RelTable {
339        self.table_catalog
340            .create_rel_table_with_id(table_id, name, src_table_id, dst_table_id, columns)
341    }
342
343    /// Create a vector index in the catalog and register its file with the BufferManager.
344    pub fn create_vector_index(
345        &self,
346        name: String,
347        table_name: String,
348        column_name: String,
349        metric: DistanceMetric,
350        dimensions: u32,
351    ) -> VectorIndexTable {
352        let table = self
353            .table_catalog
354            .create_vector_index(name, table_name, column_name, metric, dimensions);
355
356        // Register the index file with the BufferManager
357        let mut bm = self.buffer_manager.lock().unwrap();
358        table.register_file(&mut bm, &self.db_path);
359
360        table
361    }
362
363    /// Get a vector index by name.
364    pub fn get_vector_index_by_name(&self, name: &str) -> Option<dashmap::mapref::one::Ref<'_, u64, VectorIndexTable>> {
365        self.table_catalog.get_vector_index_by_name(name)
366    }
367
368    /// Get a mutable vector index by name.
369    pub fn get_vector_index_by_name_mut(
370        &self,
371        name: &str,
372    ) -> Option<dashmap::mapref::one::RefMut<'_, u64, VectorIndexTable>> {
373        self.table_catalog.get_vector_index_by_name_mut(name)
374    }
375
376    /// Create an ART (Adaptive Radix Tree) index on a node table.
377    /// Delegates to TableCatalog and registers the index file with BufferManager.
378    pub fn create_art_index(&self, table_name: &str, index_name: &str) -> Result<(), StorageError> {
379        self.table_catalog.create_art_index(table_name, index_name)?;
380
381        // Register the index file with the BufferManager for persistence
382        let mut bm = self
383            .buffer_manager
384            .lock()
385            .map_err(|e| StorageError::BufferManager(format!("Lock poisoned: {e}")))?;
386        let full_path = self.db_path.join(format!("{index_name}.art"));
387        let file_name = index_name.to_string();
388        if !bm.is_file_registered(&file_name) {
389            bm.register_file(&file_name, full_path);
390        }
391        drop(bm);
392
393        Ok(())
394    }
395
396    /// Drop an ART index from a node table.
397    pub fn drop_art_index(&self, table_name: &str, _index_name: &str) -> Result<(), StorageError> {
398        self.table_catalog.drop_art_index(table_name)
399    }
400
401    /// Get the ART index for a node table (cloned copy for read-only access).
402    pub fn get_art_index(&self, table_name: &str) -> Option<crate::ArtPrimaryKeyIndex> {
403        self.table_catalog.get_art_index(table_name)
404    }
405
406    /// Create a rel table in the catalog.
407    pub fn create_rel_table(
408        &self,
409        name: String,
410        src_table_id: u64,
411        dst_table_id: u64,
412        columns: Vec<ColumnDefinition>,
413    ) -> RelTable {
414        self.table_catalog
415            .create_rel_table(name, src_table_id, dst_table_id, columns)
416    }
417
418    /// Get the total size of the WAL in bytes.
419    pub fn wal_size(&self) -> usize {
420        self.wal.lock().unwrap().total_size()
421    }
422
423    /// Perform a checkpoint: flush WAL + dirty pages to disk.
424    pub fn checkpoint(&self) -> std::io::Result<checkpoint::CheckpointResult> {
425        let mut wal = self
426            .wal
427            .lock()
428            .map_err(|e| std::io::Error::other(format!("Lock poisoned: {e}")))?;
429        checkpoint(&mut wal, &self.buffer_manager)
430    }
431
432    /// Conditionally trigger a checkpoint based on the given threshold.
433    ///
434    /// This is called after every DML/DDL operation from `Connection::query()`.
435    ///
436    /// Semantics:
437    /// - `threshold < 0` (e.g., -1): checkpoint after every write (every DML/DDL).
438    /// - `threshold == 0`: never auto-checkpoint (manual only via `CHECKPOINT`).
439    /// - `threshold > 0`: checkpoint when `wal_size() > threshold` (bytes).
440    ///
441    /// Returns `true` if a checkpoint was triggered.
442    pub fn maybe_checkpoint(
443        &self,
444        threshold: i64,
445        drain_fn: Option<&dyn Fn(std::time::Duration) -> bool>,
446    ) -> std::io::Result<bool> {
447        if threshold == 0 {
448            return Ok(false); // Auto-checkpoint disabled
449        }
450
451        let should_checkpoint = if threshold < 0 {
452            // Always checkpoint after every write (default behavior)
453            true
454        } else {
455            self.wal_size() > threshold as usize
456        };
457
458        if should_checkpoint {
459            let _ = self.checkpoint_with_drain(drain_fn)?;
460            Ok(true)
461        } else {
462            Ok(false)
463        }
464    }
465
466    /// Perform a checkpoint with transaction drain.
467    ///
468    /// Two-phase drain:
469    /// 1. Call the `drain_fn` callback to stop new transactions and wait for active ones
470    /// 2. Perform the checkpoint (WAL flush + BM flush)
471    ///
472    /// This is the concurrent-writer-safe checkpoint. Use this instead of
473    /// plain `checkpoint()` when concurrent writes are enabled.
474    ///
475    /// If `drain_fn` is `None`, the drain is skipped (backwards-compatible default).
476    /// If the drain times out, the checkpoint proceeds anyway — this is safe because
477    /// the WAL will capture any in-flight writes.
478    pub fn checkpoint_with_drain(
479        &self,
480        drain_fn: Option<&dyn Fn(std::time::Duration) -> bool>,
481    ) -> std::io::Result<crate::checkpoint::CheckpointResult> {
482        // Phase 1: Stop new transactions and drain active ones
483        if let Some(drain) = drain_fn {
484            let drained = drain(std::time::Duration::from_secs(30));
485            if !drained {
486                tracing::warn!("Checkpoint drain timed out — proceeding with best-effort checkpoint");
487            }
488        }
489
490        // Persist in-memory tables to their durable column mirrors so the
491        // checkpoint's BufferManager flush writes them to disk too (P45.4).
492        // Done before the checkpoint so a crash after WAL truncation still
493        // leaves the mirror consistent with the tables.
494        // (P61.3) If persisting any table fails we MUST abort BEFORE the WAL is
495        // cleared: clearing the WAL here would permanently lose any committed
496        // write that lived only in the log. Fail loud instead of silently
497        // truncating away the last durable copy.
498        self.persist_all_tables()
499            .map_err(|e| std::io::Error::other(format!("Persist tables before checkpoint failed: {e}")))?;
500
501        // Phase 2: Do the actual checkpoint
502        let mut wal = self
503            .wal
504            .lock()
505            .map_err(|e| std::io::Error::other(format!("Lock poisoned: {e}")))?;
506        crate::checkpoint::checkpoint(&mut wal, &self.buffer_manager)
507    }
508
509    /// Get storage-level information for diagnostics.
510    pub fn storage_info(&self) -> StorageInfo {
511        let total_pages = self.page_manager.as_ref().map(|pm| pm.total_pages()).unwrap_or(0);
512        let free_pages = 0u64; // FSM query could be added later
513        StorageInfo {
514            db_path: self.db_path.to_string_lossy().to_string(),
515            page_size: self
516                .page_manager
517                .as_ref()
518                .map(|pm| pm.page_size())
519                .unwrap_or(page::DEFAULT_PAGE_SIZE),
520            total_pages,
521            free_pages,
522        }
523    }
524
525    /// Buffer manager statistics for CALL bm_info().
526    pub fn buffer_info(&self) -> BufferInfo {
527        let bm = self.buffer_manager.lock().unwrap();
528        let stats = bm.stats();
529        let page_size = bm.page_size();
530        BufferInfo {
531            total_memory: stats.num_frames * page_size,
532            used_memory: (stats.num_frames - (stats.num_frames - stats.pinned_frames - stats.dirty_frames)) * page_size,
533            num_pinned: stats.pinned_frames,
534        }
535    }
536
537    /// File-level statistics for CALL file_info() / CALL disk_size_info().
538    pub fn file_info(&self) -> FileInfo {
539        let db_path = &self.db_path;
540        let wal_path = db_path.join("wal.log");
541        let wal_size = std::fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
542        let data_size = std::fs::read_dir(db_path)
543            .map(|entries| {
544                entries
545                    .filter_map(|e| e.ok())
546                    .filter(|e| e.path().extension().map(|x| x == "data").unwrap_or(false))
547                    .map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
548                    .sum::<u64>()
549            })
550            .unwrap_or(0);
551        let page_size = self
552            .page_manager
553            .as_ref()
554            .map(|pm| pm.page_size())
555            .unwrap_or(page::DEFAULT_PAGE_SIZE) as u64;
556        FileInfo {
557            total_file_size: data_size + wal_size,
558            num_data_pages: data_size / page_size.max(1),
559            wal_size,
560        }
561    }
562
563    /// FSM statistics for CALL free_space_info().
564    pub fn fsm_info(&self) -> FsmInfo {
565        let total_pages = self.page_manager.as_ref().map(|pm| pm.total_pages()).unwrap_or(0);
566        let free_pages = 0u64; // FSM query later
567        FsmInfo {
568            total_free_pages: free_pages,
569            num_entries: total_pages as usize,
570        }
571    }
572
573    /// Commit a write transaction's data to storage.
574    ///
575    /// Orchestrates the full commit pipeline:
576    /// 1. Append `Commit` record to the WAL (write-ahead log) + fsync
577    /// 2. Flush `LocalStorage` buffered writes to the actual tables
578    /// 3. Apply `ShadowFile` copy-on-write pages to the BufferManager
579    /// 4. Optionally checkpoint if the WAL threshold is met
580    ///
581    /// Since P60.2 the SQL write path emits typed Insert/Delete/Update WAL
582    /// records, so committed data is durable from the WAL alone; the durable
583    /// column mirrors are written only by checkpoints and by `recover()`.
584    ///
585    /// # Arguments
586    ///
587    /// * `local_storage` — the transaction's write buffer (consumed on success).
588    /// * `shadow_file` — the transaction's COW page buffer.
589    /// * `checkpoint_threshold` — passed to `maybe_checkpoint()`; use -1 for
590    ///   always-checkpoint, 0 for never, N for byte-based threshold.
591    /// * `drain_fn` — optional callback to drain active transactions before checkpoint.
592    ///
593    /// Returns `Ok(())` if the commit pipeline succeeded.
594    pub fn commit_transaction(
595        &self,
596        local_storage: &crate::local_storage::LocalStorage,
597        shadow_file: &crate::shadow_file::ShadowFile,
598        checkpoint_threshold: i64,
599        txn_id: u64,
600        drain_fn: Option<&dyn Fn(std::time::Duration) -> bool>,
601    ) -> Result<(), StorageError> {
602        // Step 1: Write-ahead log the commit
603        {
604            let mut wal = self
605                .wal
606                .lock()
607                .map_err(|e| StorageError::Wal(format!("Lock poisoned: {e}")))?;
608            wal.append(crate::wal::WALRecord::Commit { transaction_id: txn_id });
609            wal.flush_to_disk()
610                .map_err(|e| StorageError::Wal(format!("WAL flush failed during commit: {e}")))?;
611        }
612
613        // Step 2: Flush local storage buffers to the actual tables.
614        // P60.2: the standalone mirror persist is GONE — the SQL write path
615        // now emits typed Insert/Delete/Update WAL records (bulk-copied into
616        // the global WAL at commit and replayed on top of the checkpoint
617        // mirrors by `recover()`), so per-commit mirror I/O is unnecessary in
618        // every threshold mode. Mirrors are written only by checkpoints
619        // (`checkpoint_with_drain`) and by `recover()` after a replay.
620        // Pass txn_id so inserts/deletes are recorded in VersionInfo for MVCC
621        let _commit_undo_records = local_storage.flush_to_tables(&self.table_catalog, Some(txn_id))?;
622        // Undo records generated during commit for potential rollback-on-failure.
623        // Currently unused since commit is atomic, but stored for future use
624        // (e.g., partial-commit recovery).
625        tracing::debug!(
626            "commit_transaction: generated {} undo records for txn#{}",
627            _commit_undo_records.len(),
628            txn_id
629        );
630
631        // Step 3: Apply shadow pages to the BufferManager
632        shadow_file
633            .apply(&self.buffer_manager)
634            .map_err(|e| StorageError::ShadowFile(format!("ShadowFile apply failed during commit: {e}")))?;
635
636        // Step 4: Auto-checkpoint if needed. When it fires it persists the
637        // durable column mirrors before truncating the WAL (see
638        // `checkpoint_with_drain`).
639        if let Err(e) = self.maybe_checkpoint(checkpoint_threshold, drain_fn) {
640            tracing::warn!("Checkpoint after commit failed: {e}");
641            // Non-fatal — data is already in tables and WAL
642        }
643
644        Ok(())
645    }
646
647    /// Roll back a write transaction, discarding all pending changes.
648    ///
649    /// Clears the local storage buffer, discards shadow pages, and
650    /// applies undo records to restore pre-write state.
651    /// The caller should also call `TransactionManager::rollback()` to
652    /// update the transaction's status and release locks.
653    ///
654    /// `undo_records` — accumulated undo records from the transaction.
655    ///   Applied in reverse order to restore overwritten data.
656    ///
657    /// Returns `Ok(())` on success.
658    pub fn rollback_transaction(
659        &self,
660        local_storage: &mut crate::local_storage::LocalStorage,
661        shadow_file: &mut crate::shadow_file::ShadowFile,
662        txn_id: u64,
663        undo_records: &[akar_transaction::UndoRecord],
664    ) -> Result<(), StorageError> {
665        // Log the rollback to WAL
666        {
667            let mut wal = self
668                .wal
669                .lock()
670                .map_err(|e| StorageError::Wal(format!("Lock poisoned: {e}")))?;
671            wal.append(crate::wal::WALRecord::Rollback { transaction_id: txn_id });
672            let _ = wal.flush_to_disk();
673        }
674
675        // Apply undo records in reverse order to restore pre-write state
676        for record in undo_records.iter().rev() {
677            if let Some(mut table) = self.table_catalog.get_node_table_mut(record.table_id) {
678                match record.undo_type {
679                    akar_transaction::UndoType::Update => {
680                        let values = deserialize_values_from_bytes(&record.old_data, 1);
681                        if let Some(val) = values.into_iter().next() {
682                            table
683                                .update_cell(record.row_id, record.column as usize, val)
684                                .map_err(|e| {
685                                    StorageError::Undo(format!(
686                                        "Undo failed for table {} row {}: {e}",
687                                        record.table_id, record.row_id
688                                    ))
689                                })?;
690                        }
691                    }
692                    akar_transaction::UndoType::Insert => {
693                        // Rollback an insert: delete the row
694                        let _ = table.delete_row(record.row_id);
695                    }
696                    akar_transaction::UndoType::Delete => {
697                        // Rollback a delete: restore all column values
698                        let num_cols = table.columns.len();
699                        let values = deserialize_values_from_bytes(&record.old_data, num_cols);
700                        for (col_idx, val) in values.into_iter().enumerate() {
701                            let _ = table.update_cell(record.row_id, col_idx, val);
702                        }
703                    }
704                }
705            } else if let Some(mut rel) = self.table_catalog.get_rel_table_mut(record.table_id) {
706                // Rel-table undo (P52.18): inserts delete the edge, updates
707                // restore the property cell, deletes restore src/dst + props.
708                match record.undo_type {
709                    akar_transaction::UndoType::Update => {
710                        let values = deserialize_values_from_bytes(&record.old_data, 1);
711                        if let Some(val) = values.into_iter().next() {
712                            let _ = rel.update_cell(record.row_id as usize, record.column as usize, val);
713                        }
714                    }
715                    akar_transaction::UndoType::Insert => {
716                        // Rollback an edge insert: tombstone the edge.
717                        let _ = rel.delete_edge(record.row_id as usize);
718                    }
719                    akar_transaction::UndoType::Delete => {
720                        // Rollback an edge delete: restore src/dst + properties.
721                        let num_cols = rel.columns.len();
722                        let values = deserialize_values_from_bytes(&record.old_data, num_cols + 2);
723                        let mut iter = values.into_iter();
724                        let src = match iter.next() {
725                            Some(Value::UInt64(v)) => v,
726                            Some(Value::Int64(v)) if v >= 0 => v as u64,
727                            _ => u64::MAX,
728                        };
729                        let dst = match iter.next() {
730                            Some(Value::UInt64(v)) => v,
731                            Some(Value::Int64(v)) if v >= 0 => v as u64,
732                            _ => u64::MAX,
733                        };
734                        let props: Vec<_> = iter.collect();
735                        let _ = rel.restore_deleted_edge(record.row_id as usize, src, dst, props);
736                    }
737                }
738            }
739        }
740
741        // Discard buffered writes
742        local_storage.clear();
743        shadow_file.discard();
744
745        Ok(())
746    }
747
748    /// Recover state after a crash or unclean shutdown.
749    ///
750    /// Recovery source order (P45.4, amended P60.2):
751    /// 1. **Durable column mirrors** — the state at the last checkpoint — are
752    ///    loaded first;
753    /// 2. **WAL replay** then applies every committed delta since that
754    ///    checkpoint: typed `Insert`/`Delete`/`Update` records emitted by the
755    ///    SQL write path, plus records decoded out of bulk-copied
756    ///    `LocalWALData` blobs. Replaying on top of the mirrors preserves
757    ///    row-id continuity and reconstructs full state even when no
758    ///    checkpoint ever ran (mirrors absent → whole log is replayed).
759    ///
760    /// Finally the recovered tables are re-persisted and a checkpoint resets
761    /// the WAL, so a subsequent startup restores from mirrors alone.
762    ///
763    /// Call this once during `Database::new()`, **after** table schemas
764    /// have been re-created from the persisted catalog (same table IDs).
765    ///
766    /// Returns the number of data records applied during replay (0 when the
767    /// WAL was empty), or an error if recovery fails (database is corrupt).
768    pub fn recover(&self) -> std::io::Result<usize> {
769        // Phase 1: restore checkpoint state from the durable column mirrors.
770        match self.load_persisted_tables() {
771            Ok(n) if n > 0 => tracing::info!("Restored {n} table(s) from durable column mirrors"),
772            Ok(_) => {}
773            Err(e) => tracing::warn!("Failed to restore tables from column mirrors: {e}"),
774        }
775
776        let mut wal = self
777            .wal
778            .lock()
779            .map_err(|e| std::io::Error::other(format!("Lock poisoned: {e}")))?;
780
781        // Load WAL records from disk
782        wal.load_from_disk()?;
783
784        if wal.is_empty() {
785            return Ok(0); // Nothing to recover
786        }
787
788        let mut data_records = 0usize;
789        let catalog = self.table_catalog.clone();
790
791        // Replay each record on top of the mirror state.
792        wal.replay(|record| replay_data_record(record, &catalog, &mut data_records))?;
793
794        // After successful replay, mirror the recovered rows into the durable
795        // column mirrors as well, so a subsequent startup with an empty WAL
796        // restores from the mirror instead of the (now truncated) log.
797        // (P61.3) Persisting the mirrors is mandatory: if it fails, recovery
798        // aborts with an error and the WAL is left intact so the replay state
799        // stays recoverable instead of silently vanishing.
800        if data_records > 0 {
801            self.persist_all_tables()
802                .map_err(|e| std::io::Error::other(format!("WAL recovery: persist tables failed: {e}")))?;
803        }
804
805        // Take a checkpoint to reset the WAL and make all recovered data durable.
806        // (P61.3) A failure here surfaces as an error — never proceed with a
807        // cleared WAL unless both mirrors and pages were flushed successfully.
808        checkpoint(&mut wal, &self.buffer_manager)?;
809
810        Ok(data_records)
811    }
812}
813
814/// Helper: deserialize binary data back into a `Vec<Value>`.
815///
816/// Each value is stored as a tag byte followed by type-specific data
817/// (see `column.rs` for the tag format). This is a simplified version
818/// that handles the common primary-key and property types.
819/// Apply one replayed WAL record to the catalog tables.
820///
821/// Handles node **and** rel tables (P60.2): rel inserts carry a
822/// `[src, dst, props…]` payload that rebuilds the adjacency entry via
823/// `RelTable::insert_rel`; deletes/updates dispatch by table kind.
824/// `LocalWALData` blobs are decoded into their individual records and
825/// applied recursively — this is how SQL-path DML reaches recovery, since
826/// commit bulk-copies each transaction's typed records as one blob.
827pub(crate) fn replay_data_record(
828    record: &crate::wal::WALRecord,
829    catalog: &Arc<TableCatalog>,
830    data_records: &mut usize,
831) -> std::io::Result<()> {
832    use crate::wal::WALRecord;
833
834    let as_u64 = |v: &Value| -> Option<u64> {
835        match v {
836            Value::UInt64(x) => Some(*x),
837            Value::Int64(x) => (*x >= 0).then_some(*x as u64),
838            _ => None,
839        }
840    };
841
842    match record {
843        WALRecord::Insert { table_id, data } => {
844            if let Some(mut table) = catalog.get_node_table_mut(*table_id) {
845                let values = deserialize_values_from_bytes(data, table.columns.len());
846                if let Err(e) = table.insert_row(values) {
847                    return Err(std::io::Error::other(format!("WAL recovery insert failed: {e}")));
848                }
849                *data_records += 1;
850            } else if let Some(mut rel) = catalog.get_rel_table_mut(*table_id) {
851                let values = deserialize_values_from_bytes(data, rel.columns.len() + 2);
852                if values.len() < 2 {
853                    return Err(std::io::Error::other("WAL recovery: rel insert payload too short"));
854                }
855                let (Some(src), Some(dst)) = (as_u64(&values[0]), as_u64(&values[1])) else {
856                    return Err(std::io::Error::other("WAL recovery: rel insert endpoints missing"));
857                };
858                if let Err(e) = rel.insert_rel(src, dst, values[2..].to_vec()) {
859                    return Err(std::io::Error::other(format!("WAL recovery rel insert failed: {e}")));
860                }
861                *data_records += 1;
862            } else {
863                tracing::debug!("WAL recovery: table {table_id} not found; skipping Insert");
864            }
865        }
866        WALRecord::Delete { table_id, row_id } => {
867            if let Some(mut table) = catalog.get_node_table_mut(*table_id) {
868                if let Err(e) = table.delete_row(*row_id) {
869                    return Err(std::io::Error::other(format!("WAL recovery delete failed: {e}")));
870                }
871                *data_records += 1;
872            } else if let Some(mut rel) = catalog.get_rel_table_mut(*table_id) {
873                if let Err(e) = rel.delete_edge(*row_id as usize) {
874                    return Err(std::io::Error::other(format!("WAL recovery edge delete failed: {e}")));
875                }
876                *data_records += 1;
877            } else {
878                tracing::debug!("WAL recovery: table {table_id} not found; skipping Delete");
879            }
880        }
881        WALRecord::Update {
882            table_id,
883            row_id,
884            column,
885            data,
886        } => {
887            let values = deserialize_values_from_bytes(data, 1);
888            if let Some(val) = values.into_iter().next() {
889                if let Some(mut table) = catalog.get_node_table_mut(*table_id) {
890                    if let Err(e) = table.update_cell(*row_id, *column as usize, val) {
891                        return Err(std::io::Error::other(format!("WAL recovery update failed: {e}")));
892                    }
893                    *data_records += 1;
894                } else if let Some(mut rel) = catalog.get_rel_table_mut(*table_id) {
895                    if let Err(e) = rel.update_cell(*row_id as usize, *column as usize, val) {
896                        return Err(std::io::Error::other(format!("WAL recovery edge update failed: {e}")));
897                    }
898                    *data_records += 1;
899                } else {
900                    tracing::debug!("WAL recovery: table {table_id} not found; skipping Update");
901                }
902            }
903        }
904        WALRecord::LocalWALData { data } => {
905            for sub in crate::wal::decode_wal_buffer(data)? {
906                replay_data_record(&sub, catalog, data_records)?;
907            }
908        }
909        WALRecord::UpdateFsm { .. } => {
910            // UpdateFsm records are handled by the FSM recovery directly,
911            // we can ignore them during the table-level replay.
912        }
913        WALRecord::ColumnWrite { .. } => {
914            // ColumnWrite records are for the BufferManager-level
915            // page writes. At the table level, data is already in
916            // NodeGroup memory, so we skip these during recovery
917            // (the checkpoint handles page-level persistence).
918        }
919        WALRecord::Commit { .. } | WALRecord::Rollback { .. } => {
920            // Transaction markers — ignore during recovery since
921            // all records in the WAL at startup are from already-
922            // committed transactions (uncommitted ones were lost
923            // in the crash).
924        }
925        WALRecord::Checkpoint => {
926            // Checkpoint marker — all data before this is already
927            // durable. In practice, a checkpoint clears the WAL so this
928            // marker should rarely appear during recovery.
929        }
930        // DDL records — metadata-only, no data to replay for now.
931        // DDL operations (CREATE/DROP TABLE, etc.) are captured via
932        // Catalog serialization separately.
933        WALRecord::CreateTable { .. }
934        | WALRecord::DropTable { .. }
935        | WALRecord::AlterTable { .. }
936        | WALRecord::CreateIndex { .. }
937        | WALRecord::DropIndex { .. }
938        | WALRecord::CreateSequence { .. } => {
939            // DDL records are replayed via catalog snapshot, not
940            // individual WAL entries. Skip during table-level replay.
941        }
942    }
943    Ok(())
944}
945
946pub(crate) fn deserialize_values_from_bytes(data: &[u8], expected_count: usize) -> Vec<Value> {
947    use crate::column::Column;
948
949    if data.is_empty() || expected_count == 0 {
950        return Vec::new();
951    }
952
953    // Delegate to the full `Column` value parser so every tag round-trips.
954    // The previous hand-rolled subset silently turned unhandled tags (notably
955    // the UInt64 encoding used for rel endpoints after PK coercion) into Null
956    // AND desynced the cursor, aborting WAL recovery mid-log (P60.2).
957    let mut values = Vec::with_capacity(expected_count);
958    let mut pos = 0usize;
959    for _ in 0..expected_count {
960        if pos >= data.len() {
961            values.push(Value::Null);
962            continue;
963        }
964        match Column::deserialize_value(data, &mut pos) {
965            Ok(v) => values.push(v),
966            Err(_) => {
967                // Lenient like the old parser: pad rather than fail recovery.
968                while values.len() < expected_count {
969                    values.push(Value::Null);
970                }
971                break;
972            }
973        }
974    }
975
976    values
977}
978
979// =========================================================================
980// Phase 1 integration tests — full pipeline: table → column → buffer
981// manager → WAL → checkpoint → compression → multi-node-group
982// =========================================================================
983
984#[cfg(test)]
985mod integration_tests {
986    use super::*;
987    use crate::column::{Column, TAG_INT64, TAG_STRING};
988    use crate::page::DEFAULT_PAGE_SIZE;
989    use crate::wal::WALRecord;
990    use akar_common::enums::CompressionType;
991    use akar_common::types::{LogicalTypeID, Value};
992    use std::collections::HashMap;
993
994    // -----------------------------------------------------------------
995    // Helper: create a StorageManager + column pair
996    // -----------------------------------------------------------------
997    fn setup_integration() -> (StorageManager, tempfile::TempDir) {
998        let dir = tempfile::tempdir().unwrap();
999        let mm = Arc::new(MemoryManager::new(128 * 1024 * 1024));
1000        let sm = StorageManager::new(dir.path().to_path_buf(), mm);
1001        (sm, dir)
1002    }
1003
1004    // =================================================================
1005    // Test 1: Create table → insert rows → flush → reopen → verify
1006    // =================================================================
1007    #[test]
1008    fn test_table_full_persistence_cycle() {
1009        let (sm, _dir) = setup_integration();
1010
1011        // 1. Create a node table with two columns
1012        let mut table = sm.create_node_table(
1013            "Person".into(),
1014            vec![
1015                ColumnDefinition {
1016                    compression: akar_common::enums::CompressionType::Uncompressed,
1017                    name: "name".into(),
1018                    logical_type: LogicalTypeID::String,
1019                    is_primary_key: true,
1020                },
1021                ColumnDefinition {
1022                    compression: akar_common::enums::CompressionType::Uncompressed,
1023                    name: "age".into(),
1024                    logical_type: LogicalTypeID::Int64,
1025                    is_primary_key: false,
1026                },
1027            ],
1028        );
1029        assert_eq!(table.table_id, 0);
1030
1031        // 2. Insert rows into the table
1032        table
1033            .insert_row(vec![Value::String("Alice".into()), Value::Int64(30)])
1034            .unwrap();
1035        table
1036            .insert_row(vec![Value::String("Bob".into()), Value::Int64(25)])
1037            .unwrap();
1038        table
1039            .insert_row(vec![Value::String("Charlie".into()), Value::Int64(35)])
1040            .unwrap();
1041        assert_eq!(table.num_rows, 3);
1042
1043        // 3. Verify data before checkpoint
1044        assert_eq!(table.get_value(0, 0), Some(&Value::String("Alice".into())));
1045        assert_eq!(table.get_value(1, 1), Some(&Value::Int64(25)));
1046
1047        // 4. Read back via scan_column across node groups
1048        let names = table.scan_column(0, 0, 3, None, &HashMap::new());
1049        assert_eq!(names.len(), 3);
1050        assert_eq!(names[0], Value::String("Alice".into()));
1051
1052        let ages = table.scan_column(1, 1, 2, None, &HashMap::new());
1053        assert_eq!(ages.len(), 2);
1054        assert_eq!(ages[0], Value::Int64(25));
1055    }
1056
1057    // =================================================================
1058    // Test 2: WAL crash recovery — log writes, flush to disk, replay
1059    // =================================================================
1060    #[test]
1061    fn test_wal_recovery_cycle() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let wal_path = dir.path().join("wal.log");
1064
1065        // Phase 1: Write data with WAL logging
1066        #[allow(unused_variables)]
1067        let (wal_records_count, column_count) = {
1068            let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1069            let config = BufferManagerConfig::default();
1070            let bm = Arc::new(Mutex::new(BufferManager::new(dir.path().to_path_buf(), mm, config)));
1071            let mut wal = WAL::new(wal_path.clone());
1072
1073            let mut col = Column::new(LogicalTypeID::Int64, 0, 0, dir.path(), bm.clone(), DEFAULT_PAGE_SIZE);
1074
1075            // Write data and log each write to WAL
1076            for i in 0i64..10 {
1077                col.append_value(&Value::Int64(i)).unwrap();
1078                wal.log_column_write(0, 0, 0, &i.to_le_bytes());
1079            }
1080            wal.append(WALRecord::Commit { transaction_id: 1 });
1081            let count = wal.len();
1082
1083            // Flush WAL to disk
1084            wal.flush_to_disk().unwrap();
1085
1086            // Also flush BM pages
1087            {
1088                let mut bm_lock = bm.lock().unwrap();
1089                bm_lock.flush_all().unwrap();
1090            }
1091
1092            // Verify column data is correct before "crash"
1093            for i in 0i64..10 {
1094                let v = col.get_value(i as u64).unwrap();
1095                assert_eq!(v, Value::Int64(i), "Pre-crash data mismatch at {}", i);
1096            }
1097
1098            (count, 10)
1099        }; // Drop everything — simulate crash
1100
1101        // Phase 2: Verify the on-disk WAL file exists and has content
1102        assert!(wal_path.exists(), "WAL file should exist after flush");
1103        let file_len = std::fs::metadata(&wal_path).unwrap().len();
1104        assert!(file_len > 0, "WAL file should have content, got {} bytes", file_len);
1105
1106        // Verify that a fresh WAL created from the file would contain
1107        // the right number of records. Since WAL::new() starts in-memory,
1108        // we create a new one, and verify the file has the right data.
1109        // In a real recovery scenario, we'd implement WAL::load_from_disk().
1110        assert_eq!(
1111            wal_records_count, 11,
1112            "Expected 10 ColumnWrite + 1 Commit = 11 records, got {}",
1113            wal_records_count
1114        );
1115        assert_eq!(column_count, 10);
1116    }
1117
1118    // =================================================================
1119    // Test 3: Compression round-trip — write compressed → read back
1120    // =================================================================
1121    #[test]
1122    fn test_compression_full_roundtrip() {
1123        // Test IntegerBitpacking: small values compress, large values preserved
1124        let dir = tempfile::tempdir().unwrap();
1125        let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1126        let config = BufferManagerConfig::default();
1127        let bm = Arc::new(Mutex::new(BufferManager::new(dir.path().to_path_buf(), mm, config)));
1128
1129        let mut col_int = Column::with_compression(
1130            LogicalTypeID::Int64,
1131            0,
1132            0,
1133            dir.path(),
1134            bm.clone(),
1135            DEFAULT_PAGE_SIZE,
1136            CompressionType::IntegerBitpacking,
1137        );
1138
1139        // Write a range of values from small to large
1140        let test_values: Vec<i64> = vec![0, 1, 42, 127, 255, 65535, 1_000_000, i64::MAX, i64::MIN, -1];
1141        for v in &test_values {
1142            col_int.append_value(&Value::Int64(*v)).unwrap();
1143        }
1144
1145        // Read back and verify
1146        for (i, expected) in test_values.iter().enumerate() {
1147            let v = col_int.get_value(i as u64).unwrap();
1148            assert_eq!(v, Value::Int64(*expected), "IntegerBitpacking mismatch at index {}", i);
1149        }
1150
1151        // Verify that setting compression doesn't break existing data
1152        let mut col_float = Column::with_compression(
1153            LogicalTypeID::Double,
1154            0,
1155            1,
1156            dir.path(),
1157            bm.clone(),
1158            DEFAULT_PAGE_SIZE,
1159            CompressionType::Float,
1160        );
1161
1162        let floats: Vec<f64> = vec![1.0, std::f64::consts::PI, -2.5e10, 0.0, f64::MIN_POSITIVE, f64::MAX];
1163        for v in &floats {
1164            col_float.append_value(&Value::Double(*v)).unwrap();
1165        }
1166
1167        for (i, expected) in floats.iter().enumerate() {
1168            let v = col_float.get_value(i as u64).unwrap();
1169            match v {
1170                Value::Double(d) => assert!(
1171                    (d - expected).abs() < 1e-10 || (d / expected - 1.0).abs() < 1e-10,
1172                    "Float compression mismatch at {}: got {}, expected {}",
1173                    i,
1174                    d,
1175                    expected
1176                ),
1177                _ => panic!("Expected Double, got {:?}", v),
1178            }
1179        }
1180
1181        // Write compressed values via existing Column API and verify roundtrip
1182        // with buffer manager flush
1183        col_int.flush().unwrap();
1184        col_float.flush().unwrap();
1185
1186        // Read again after flush
1187        for (i, expected) in test_values.iter().enumerate() {
1188            let v = col_int.get_value(i as u64).unwrap();
1189            assert_eq!(
1190                v,
1191                Value::Int64(*expected),
1192                "After flush: IntegerBitpacking mismatch at {}",
1193                i
1194            );
1195        }
1196        for (i, expected) in floats.iter().enumerate() {
1197            let v = col_float.get_value(i as u64).unwrap();
1198            match v {
1199                Value::Double(d) => assert!(
1200                    (d - expected).abs() < 1e-10 || (d / expected - 1.0).abs() < 1e-10,
1201                    "After flush: Float mismatch at {}",
1202                    i
1203                ),
1204                _ => panic!("Expected Double after flush"),
1205            }
1206        }
1207    }
1208
1209    // =================================================================
1210    // Test 4: Multi-node-group scan — insert > NODE_GROUP_SIZE rows
1211    // =================================================================
1212    #[test]
1213    fn test_multi_node_group_scan() {
1214        let (_sm, _dir) = setup_integration();
1215
1216        // Create a NodeTable (not via StorageManager, directly for test control)
1217        let mut table = NodeTable::new(
1218            1,
1219            "BigTable".into(),
1220            vec![
1221                ColumnDefinition {
1222                    compression: akar_common::enums::CompressionType::Uncompressed,
1223                    name: "id".into(),
1224                    logical_type: LogicalTypeID::Int64,
1225                    is_primary_key: false,
1226                },
1227                ColumnDefinition {
1228                    compression: akar_common::enums::CompressionType::Uncompressed,
1229                    name: "value".into(),
1230                    logical_type: LogicalTypeID::Int64,
1231                    is_primary_key: false,
1232                },
1233            ],
1234        );
1235
1236        // Insert NODE_GROUP_SIZE + 500 rows to span across multiple node groups
1237        let total_rows = NODE_GROUP_SIZE + 500;
1238        for i in 0..total_rows {
1239            table
1240                .insert_row(vec![Value::Int64(i as i64), Value::Int64((i * 2) as i64)])
1241                .unwrap();
1242        }
1243
1244        // Verify total row count
1245        assert_eq!(table.num_rows, total_rows as u64);
1246
1247        // Verify multiple node groups were created
1248        let expected_groups = 2; // 4096 fits in first group, 500 in second
1249        assert_eq!(
1250            table.node_groups.len(),
1251            expected_groups,
1252            "Expected {} node groups for {} rows",
1253            expected_groups,
1254            total_rows
1255        );
1256
1257        // Verify node group boundaries
1258        assert_eq!(table.node_groups[0].num_nodes, NODE_GROUP_SIZE as u64);
1259        assert_eq!(table.node_groups[1].num_nodes, 500);
1260        assert_eq!(table.node_groups[0].start_offset, 0);
1261        assert_eq!(table.node_groups[1].start_offset, NODE_GROUP_SIZE as u64);
1262
1263        // Verify scanning across group boundaries
1264        // Row at boundary: last row of group 0
1265        let row_at_boundary = (NODE_GROUP_SIZE - 1) as u64;
1266        assert_eq!(
1267            table.get_value(row_at_boundary as usize, 0),
1268            Some(&Value::Int64(row_at_boundary as i64))
1269        );
1270
1271        // First row of group 1
1272        let row_in_group1 = NODE_GROUP_SIZE as u64;
1273        assert_eq!(
1274            table.get_value(row_in_group1 as usize, 0),
1275            Some(&Value::Int64(row_in_group1 as i64))
1276        );
1277
1278        // Scan column 0 across the entire table
1279        let scanned = table.scan_column(0, 0, total_rows as u64, None, &HashMap::new());
1280        assert_eq!(scanned.len(), total_rows);
1281        assert_eq!(scanned[0], Value::Int64(0));
1282        assert_eq!(scanned[NODE_GROUP_SIZE], Value::Int64(NODE_GROUP_SIZE as i64));
1283        assert_eq!(scanned[total_rows - 1], Value::Int64((total_rows - 1) as i64));
1284
1285        // Scan column 1 with offset and count spanning both groups
1286        let scan_mid = table.scan_column(1, (NODE_GROUP_SIZE - 100) as u64, 200, None, &HashMap::new());
1287        assert_eq!(scan_mid.len(), 200);
1288        assert_eq!(scan_mid[0], Value::Int64(((NODE_GROUP_SIZE - 100) * 2) as i64));
1289        assert_eq!(scan_mid[199], Value::Int64(((NODE_GROUP_SIZE + 99) * 2) as i64));
1290
1291        // Verify to_column_major_data correctness
1292        let data = table.to_column_major_data();
1293        assert_eq!(data.len(), 2); // 2 columns
1294        assert_eq!(data[0].len(), total_rows);
1295        assert_eq!(data[1].len(), total_rows);
1296        assert_eq!(data[0][NODE_GROUP_SIZE], Value::Int64(NODE_GROUP_SIZE as i64));
1297        assert_eq!(data[1][0], Value::Int64(0));
1298        assert_eq!(data[1][total_rows - 1], Value::Int64(((total_rows - 1) * 2) as i64));
1299    }
1300
1301    // =================================================================
1302    // Test 5: Combined — WAL-logged compressed multi-node-group write
1303    // =================================================================
1304    #[test]
1305    fn test_compressed_multi_group_with_checkpoint() {
1306        let dir = tempfile::tempdir().unwrap();
1307        let mm = Arc::new(MemoryManager::new(128 * 1024 * 1024));
1308
1309        // Use explicit BM + WAL for full control
1310        let config = BufferManagerConfig::default();
1311        let bm = Arc::new(Mutex::new(BufferManager::new(dir.path().to_path_buf(), mm, config)));
1312        let wal_path = dir.path().join("wal.log");
1313        let mut wal = WAL::new(wal_path);
1314
1315        let mut col = Column::with_compression(
1316            LogicalTypeID::Int64,
1317            0,
1318            0,
1319            dir.path(),
1320            bm.clone(),
1321            DEFAULT_PAGE_SIZE,
1322            CompressionType::IntegerBitpacking,
1323        );
1324
1325        // Write enough values to span multiple pages
1326        let num_values = 500;
1327        for i in 0i64..num_values {
1328            col.append_value(&Value::Int64(i)).unwrap();
1329            wal.log_column_write(0, 0, 0, &i.to_le_bytes());
1330        }
1331        wal.append(WALRecord::Commit { transaction_id: 1 });
1332
1333        // Read back before checkpoint
1334        for i in 0i64..num_values {
1335            let v = col.get_value(i as u64).unwrap();
1336            assert_eq!(v, Value::Int64(i), "Pre-checkpoint mismatch at {}", i);
1337        }
1338
1339        // Checkpoint: flush WAL + dirty pages
1340        let mut bm_lock = bm.lock().unwrap();
1341        bm_lock.flush_all().unwrap();
1342        drop(bm_lock);
1343
1344        wal.flush_to_disk().unwrap();
1345
1346        // Read back after checkpoint
1347        for i in 0i64..num_values {
1348            let v = col.get_value(i as u64).unwrap();
1349            assert_eq!(v, Value::Int64(i), "Post-checkpoint mismatch at {}", i);
1350        }
1351
1352        // Verify multiple pages were allocated
1353        assert!(
1354            col.num_pages > 1,
1355            "Expected multiple pages for {} values, got {}",
1356            num_values,
1357            col.num_pages
1358        );
1359    }
1360
1361    // =================================================================
1362    // Test 6: Stress — 10k rows via column with checkpoint
1363    // =================================================================
1364    #[test]
1365    fn test_10k_row_stress() {
1366        let dir = tempfile::tempdir().unwrap();
1367        let mm = Arc::new(MemoryManager::new(256 * 1024 * 1024));
1368        let config = BufferManagerConfig::default();
1369        let bm = Arc::new(Mutex::new(BufferManager::new(dir.path().to_path_buf(), mm, config)));
1370
1371        let mut col = Column::new(LogicalTypeID::Int64, 0, 0, dir.path(), bm.clone(), DEFAULT_PAGE_SIZE);
1372
1373        // Write 10,000 values
1374        for i in 0i64..10_000 {
1375            col.append_value(&Value::Int64(i)).unwrap();
1376        }
1377        assert_eq!(col.num_values, 10_000);
1378
1379        // Read back all values
1380        for i in 0i64..10_000 {
1381            let v = col.get_value(i as u64).unwrap();
1382            assert_eq!(v, Value::Int64(i), "Stress test mismatch at {}", i);
1383        }
1384
1385        // Flush and re-verify
1386        col.flush().unwrap();
1387        for i in 0i64..10_000 {
1388            let v = col.get_value(i as u64).unwrap();
1389            assert_eq!(v, Value::Int64(i), "Post-flush stress mismatch at {}", i);
1390        }
1391
1392        // Verify multiple pages were used
1393        assert!(
1394            col.num_pages > 1,
1395            "Stress test should use multiple pages, got {}",
1396            col.num_pages
1397        );
1398    }
1399
1400    // =================================================================
1401    // Test 7: WAL recovery — insert data, simulate crash, recover
1402    // =================================================================
1403    #[test]
1404    fn test_wal_recovery_insert_then_recover() {
1405        let dir = tempfile::tempdir().unwrap();
1406
1407        // Phase 1: Create DB, insert data, flush WAL, then "crash"
1408        let _row_count = {
1409            let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1410            let sm = StorageManager::new(dir.path().to_path_buf(), mm);
1411
1412            // Create a node table
1413            let mut table = sm.create_node_table(
1414                "Person".into(),
1415                vec![
1416                    ColumnDefinition {
1417                        compression: akar_common::enums::CompressionType::Uncompressed,
1418                        name: "name".into(),
1419                        logical_type: LogicalTypeID::String,
1420                        is_primary_key: true,
1421                    },
1422                    ColumnDefinition {
1423                        compression: akar_common::enums::CompressionType::Uncompressed,
1424                        name: "age".into(),
1425                        logical_type: LogicalTypeID::Int64,
1426                        is_primary_key: false,
1427                    },
1428                ],
1429            );
1430
1431            // Insert rows
1432            table
1433                .insert_row(vec![Value::String("Alice".into()), Value::Int64(30)])
1434                .unwrap();
1435            table
1436                .insert_row(vec![Value::String("Bob".into()), Value::Int64(25)])
1437                .unwrap();
1438            table
1439                .insert_row(vec![Value::String("Charlie".into()), Value::Int64(35)])
1440                .unwrap();
1441
1442            // Put the table back into the catalog so WAL knows about it.
1443            // We re-create the table entry via the catalog API.
1444            {
1445                // Re-create the table entry with the same schema so the
1446                // catalog has a valid entry for recovery to target.
1447                sm.table_catalog.create_node_table(
1448                    "Person".into(),
1449                    vec![
1450                        ColumnDefinition {
1451                            compression: akar_common::enums::CompressionType::Uncompressed,
1452                            name: "name".into(),
1453                            logical_type: LogicalTypeID::String,
1454                            is_primary_key: true,
1455                        },
1456                        ColumnDefinition {
1457                            compression: akar_common::enums::CompressionType::Uncompressed,
1458                            name: "age".into(),
1459                            logical_type: LogicalTypeID::Int64,
1460                            is_primary_key: false,
1461                        },
1462                    ],
1463                );
1464            }
1465
1466            let count = table.num_rows;
1467            assert_eq!(count, 3);
1468
1469            // Write WAL records and flush to disk
1470            {
1471                let mut wal = sm.wal.lock().unwrap();
1472                wal.append(WALRecord::Insert {
1473                    table_id: table.table_id,
1474                    data: vec![
1475                        TAG_STRING, 5, 0, 0, 0, b'A', b'l', b'i', b'c', b'e', TAG_INT64, 30, 0, 0, 0, 0, 0, 0, 0,
1476                    ],
1477                });
1478                wal.append(WALRecord::Insert {
1479                    table_id: table.table_id,
1480                    data: vec![
1481                        TAG_STRING, 3, 0, 0, 0, b'B', b'o', b'b', TAG_INT64, 25, 0, 0, 0, 0, 0, 0, 0,
1482                    ],
1483                });
1484                wal.append(WALRecord::Insert {
1485                    table_id: table.table_id,
1486                    data: vec![
1487                        TAG_STRING, 7, 0, 0, 0, b'C', b'h', b'a', b'r', b'l', b'i', b'e', TAG_INT64, 35, 0, 0, 0, 0, 0,
1488                        0, 0,
1489                    ],
1490                });
1491                wal.flush_to_disk().unwrap();
1492            }
1493
1494            count
1495        }; // "Crash" — all state dropped
1496
1497        // Phase 2: Recover — create a new StorageManager, it should NOT delete
1498        // the WAL. Then manually trigger recovery.
1499        {
1500            let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1501            let sm = StorageManager::new(dir.path().to_path_buf(), mm);
1502
1503            // Verify WAL file exists and has records
1504            let wal_path = dir.path().join("wal.log");
1505            assert!(wal_path.exists(), "WAL file should exist for recovery");
1506
1507            // Create the same table schema so recovery has a target
1508            // (the catalog create_node_table stores the table internally).
1509            sm.create_node_table(
1510                "Person".into(),
1511                vec![
1512                    ColumnDefinition {
1513                        compression: akar_common::enums::CompressionType::Uncompressed,
1514                        name: "name".into(),
1515                        logical_type: LogicalTypeID::String,
1516                        is_primary_key: true,
1517                    },
1518                    ColumnDefinition {
1519                        compression: akar_common::enums::CompressionType::Uncompressed,
1520                        name: "age".into(),
1521                        logical_type: LogicalTypeID::Int64,
1522                        is_primary_key: false,
1523                    },
1524                ],
1525            );
1526
1527            // Recover — the WAL has table_id = 0 (the first table created).
1528            let recovered = sm.recover().unwrap();
1529            assert_eq!(recovered, 3, "Should recover 3 WAL records");
1530
1531            // Verify data survived — the table was re-created empty,
1532            // and recovery inserted exactly 3 rows from the WAL.
1533            {
1534                let recovered_table = sm.table_catalog.get_node_table_by_name("Person").unwrap();
1535                assert_eq!(recovered_table.num_rows, 3, "Should have recovered 3 rows");
1536                assert_eq!(recovered_table.get_value(0, 0), Some(&Value::String("Alice".into())));
1537                assert_eq!(recovered_table.get_value(1, 0), Some(&Value::String("Bob".into())));
1538                assert_eq!(recovered_table.get_value(2, 0), Some(&Value::String("Charlie".into())));
1539                assert_eq!(recovered_table.get_value(0, 1), Some(&Value::Int64(30)));
1540                assert_eq!(recovered_table.get_value(1, 1), Some(&Value::Int64(25)));
1541                assert_eq!(recovered_table.get_value(2, 1), Some(&Value::Int64(35)));
1542            }
1543
1544            // Verify WAL was checkpointed — after checkpoint the WAL has
1545            // exactly 1 record (the Checkpoint marker).
1546            {
1547                let wal = sm.wal.lock().unwrap();
1548                assert_eq!(
1549                    wal.len(),
1550                    1,
1551                    "WAL should have only the checkpoint marker after recovery"
1552                );
1553                assert!(matches!(wal.records()[0], crate::wal::WALRecord::Checkpoint));
1554            }
1555        }
1556    }
1557
1558    // =================================================================
1559    // Test 8: WAL recovery — no-op when no WAL exists
1560    // =================================================================
1561    #[test]
1562    fn test_wal_recovery_no_wal() {
1563        let dir = tempfile::tempdir().unwrap();
1564
1565        let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1566        let sm = StorageManager::new(dir.path().to_path_buf(), mm);
1567
1568        // No WAL file = recovery returns 0
1569        let recovered = sm.recover().unwrap();
1570        assert_eq!(recovered, 0, "No WAL = no records recovered");
1571    }
1572
1573    // =================================================================
1574    // Test 9: WAL recovery — empty WAL file
1575    // =================================================================
1576    #[test]
1577    fn test_wal_recovery_empty_wal() {
1578        let dir = tempfile::tempdir().unwrap();
1579
1580        // Create an empty WAL file on disk
1581        let wal_path = dir.path().join("wal.log");
1582        std::fs::write(&wal_path, b"").unwrap();
1583
1584        let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
1585        let sm = StorageManager::new(dir.path().to_path_buf(), mm);
1586
1587        let recovered = sm.recover().unwrap();
1588        assert_eq!(recovered, 0, "Empty WAL = no records recovered");
1589    }
1590
1591    // =================================================================
1592    // Test 10: WAL load_from_disk roundtrip
1593    // =================================================================
1594    #[test]
1595    fn test_wal_load_from_disk_roundtrip() {
1596        use crate::wal::WALRecord;
1597        let dir = tempfile::tempdir().unwrap();
1598        let wal_path = dir.path().join("wal.log");
1599
1600        // Write records
1601        {
1602            let mut wal = WAL::new(wal_path.clone());
1603            wal.append(WALRecord::Insert {
1604                table_id: 42,
1605                data: vec![1, 2, 3, 4],
1606            });
1607            wal.append(WALRecord::Delete {
1608                table_id: 42,
1609                row_id: 0,
1610            });
1611            wal.append(WALRecord::Update {
1612                table_id: 42,
1613                row_id: 1,
1614                column: 2,
1615                data: vec![5, 6],
1616            });
1617            wal.append(WALRecord::ColumnWrite {
1618                table_id: 42,
1619                col_id: 0,
1620                page_id: 1,
1621                data: vec![7, 8, 9],
1622            });
1623            wal.append(WALRecord::Commit { transaction_id: 100 });
1624            wal.append(WALRecord::Rollback { transaction_id: 101 });
1625            wal.append(WALRecord::Checkpoint);
1626            wal.flush_to_disk().unwrap();
1627        }
1628
1629        // Load from disk
1630        {
1631            let mut wal = WAL::new(wal_path.clone());
1632            wal.load_from_disk().unwrap();
1633            assert_eq!(wal.len(), 7, "Should load 7 records from disk");
1634            assert!(wal.is_dirty());
1635
1636            // Verify each record type
1637            match &wal.records()[0] {
1638                WALRecord::Insert { table_id, data } => {
1639                    assert_eq!(*table_id, 42);
1640                    assert_eq!(data, &[1, 2, 3, 4]);
1641                }
1642                _ => panic!("Expected Insert"),
1643            }
1644            match &wal.records()[1] {
1645                WALRecord::Delete { table_id, row_id } => {
1646                    assert_eq!(*table_id, 42);
1647                    assert_eq!(*row_id, 0);
1648                }
1649                _ => panic!("Expected Delete"),
1650            }
1651            match &wal.records()[4] {
1652                WALRecord::Commit { transaction_id } => {
1653                    assert_eq!(*transaction_id, 100);
1654                }
1655                _ => panic!("Expected Commit"),
1656            }
1657            match &wal.records()[5] {
1658                WALRecord::Rollback { transaction_id } => {
1659                    assert_eq!(*transaction_id, 101);
1660                }
1661                _ => panic!("Expected Rollback"),
1662            }
1663            match &wal.records()[6] {
1664                WALRecord::Checkpoint => {}
1665                _ => panic!("Expected Checkpoint"),
1666            }
1667        }
1668    }
1669
1670    // =================================================================
1671    // Test: Commit pipeline — LocalStorage flush → ShadowFile apply
1672    // =================================================================
1673    #[test]
1674    fn test_commit_pipeline_local_storage_flush() {
1675        let (sm, _dir) = setup_integration();
1676
1677        // Create table via catalog directly so we know the table_id
1678        let table_id;
1679        {
1680            let table = sm.table_catalog.create_node_table(
1681                "Person".into(),
1682                vec![
1683                    ColumnDefinition {
1684                        compression: akar_common::enums::CompressionType::Uncompressed,
1685                        name: "name".into(),
1686                        logical_type: LogicalTypeID::String,
1687                        is_primary_key: true,
1688                    },
1689                    ColumnDefinition {
1690                        compression: akar_common::enums::CompressionType::Uncompressed,
1691                        name: "age".into(),
1692                        logical_type: LogicalTypeID::Int64,
1693                        is_primary_key: false,
1694                    },
1695                ],
1696            );
1697            table_id = table.table_id;
1698        }
1699
1700        // Simulate a transaction: buffer a row in LocalStorage
1701        let mut local_storage = crate::local_storage::LocalStorage::new();
1702        {
1703            let txn_table = local_storage.get_or_create_table(table_id);
1704
1705            // Encode a row: name="Alice"(String), age=30(Int64)
1706            let mut row_bytes = Vec::new();
1707            row_bytes.push(13 /* TAG_STRING */);
1708            let name = "Alice";
1709            let name_bytes = name.as_bytes();
1710            row_bytes.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1711            row_bytes.extend_from_slice(name_bytes);
1712            row_bytes.push(2 /* TAG_INT64 */);
1713            row_bytes.extend_from_slice(&30i64.to_le_bytes());
1714
1715            txn_table.insert(row_bytes);
1716        }
1717
1718        assert_eq!(local_storage.len(), 1, "Should have 1 table in local storage");
1719
1720        // Commit via StorageManager
1721        let shadow = crate::shadow_file::ShadowFile::new();
1722        sm.commit_transaction(
1723            &local_storage,
1724            &shadow,
1725            -1, /* checkpoint */
1726            1,  /* txn_id */
1727            None,
1728        )
1729        .unwrap();
1730
1731        // Verify data was flushed to the table
1732        {
1733            let t = sm.table_catalog.get_node_table_by_name("Person").unwrap();
1734            assert_eq!(t.num_rows, 1, "Should have 1 row after commit");
1735            assert_eq!(t.get_value(0, 0), Some(&Value::String("Alice".into())));
1736            assert_eq!(t.get_value(0, 1), Some(&Value::Int64(30)));
1737        }
1738
1739        // Verify WAL has the commit record (and was checkpointed)
1740        {
1741            let wal = sm.wal.lock().unwrap();
1742            // After checkpoint, WAL has 1 record (Checkpoint marker)
1743            assert_eq!(wal.len(), 1, "WAL should have Checkpoint marker after commit");
1744        }
1745    }
1746
1747    // =================================================================
1748    // Test: Rollback pipeline — LocalStorage clear, no data written
1749    // =================================================================
1750    #[test]
1751    fn test_rollback_pipeline_no_data_written() {
1752        let (sm, _dir) = setup_integration();
1753        let mut local_storage = crate::local_storage::LocalStorage::new();
1754        let mut shadow = crate::shadow_file::ShadowFile::new();
1755
1756        // Buffer some data
1757        {
1758            let txn_table = local_storage.get_or_create_table(0);
1759            txn_table.insert(vec![2 /* TAG_INT64 */, 42, 0, 0, 0, 0, 0, 0, 0]);
1760        }
1761
1762        assert!(!local_storage.is_empty(), "LocalStorage should have buffered data");
1763
1764        // Rollback
1765        sm.rollback_transaction(&mut local_storage, &mut shadow, 1 /* txn_id */, &[])
1766            .unwrap();
1767
1768        // Verify buffers are cleared
1769        assert!(local_storage.is_empty(), "LocalStorage should be empty after rollback");
1770        assert!(shadow.is_empty(), "ShadowFile should be empty after rollback");
1771    }
1772
1773    // =================================================================
1774    // Test: Multiple buffered rows commit correctly
1775    // =================================================================
1776    #[test]
1777    fn test_commit_multiple_rows() {
1778        let (sm, _dir) = setup_integration();
1779        sm.create_node_table(
1780            "Item".into(),
1781            vec![
1782                ColumnDefinition {
1783                    compression: akar_common::enums::CompressionType::Uncompressed,
1784                    name: "name".into(),
1785                    logical_type: LogicalTypeID::String,
1786                    is_primary_key: true,
1787                },
1788                ColumnDefinition {
1789                    compression: akar_common::enums::CompressionType::Uncompressed,
1790                    name: "price".into(),
1791                    logical_type: LogicalTypeID::Double,
1792                    is_primary_key: false,
1793                },
1794            ],
1795        );
1796
1797        // Buffer multiple rows
1798        let mut local = crate::local_storage::LocalStorage::new();
1799        {
1800            let txn_table = local.get_or_create_table(0); // table_id = 0
1801
1802            // Row 1: "Widget", 19.99
1803            let mut row = Vec::new();
1804            row.push(13);
1805            row.extend_from_slice(&6u32.to_le_bytes());
1806            row.extend_from_slice(b"Widget");
1807            row.push(11);
1808            row.extend_from_slice(&19.99f64.to_le_bytes());
1809            txn_table.insert(row);
1810
1811            // Row 2: "Gadget", 29.99
1812            let mut row = Vec::new();
1813            row.push(13);
1814            row.extend_from_slice(&6u32.to_le_bytes());
1815            row.extend_from_slice(b"Gadget");
1816            row.push(11);
1817            row.extend_from_slice(&29.99f64.to_le_bytes());
1818            txn_table.insert(row);
1819        }
1820
1821        let shadow = crate::shadow_file::ShadowFile::new();
1822        sm.commit_transaction(&local, &shadow, 0 /* no checkpoint */, 2 /* txn_id */, None)
1823            .unwrap();
1824
1825        // Verify both rows
1826        {
1827            let t = sm.table_catalog.get_node_table_by_name("Item").unwrap();
1828            assert_eq!(t.num_rows, 2, "Should have 2 rows after commit");
1829        }
1830    }
1831    #[test]
1832    fn test_zone_map_pushdown() {
1833        use crate::column_chunk::NODE_GROUP_SIZE;
1834        use crate::table::{ColumnDefinition, NodeTable};
1835        use akar_common::types::{LogicalTypeID, Value};
1836
1837        let db_path = "test_zone_map_pushdown.db";
1838        let _ = std::fs::remove_file(db_path);
1839
1840        let mut table = NodeTable::new(
1841            0,
1842            db_path.to_string(),
1843            vec![
1844                ColumnDefinition {
1845                    compression: akar_common::enums::CompressionType::Uncompressed,
1846                    name: "id".into(),
1847                    logical_type: LogicalTypeID::Int64,
1848                    is_primary_key: true,
1849                },
1850                ColumnDefinition {
1851                    compression: akar_common::enums::CompressionType::Uncompressed,
1852                    name: "value".into(),
1853                    logical_type: LogicalTypeID::Int64,
1854                    is_primary_key: false,
1855                },
1856            ],
1857        );
1858
1859        // Insert exactly one node group of elements with values 0 to 4095
1860        for i in 0..NODE_GROUP_SIZE as i64 {
1861            table.insert_row(vec![Value::Int64(i), Value::Int64(i)]).unwrap();
1862        }
1863
1864        // Insert a second node group of elements with values 4096 to 8191
1865        for i in 0..NODE_GROUP_SIZE as i64 {
1866            let val = i + NODE_GROUP_SIZE as i64;
1867            table.insert_row(vec![Value::Int64(val), Value::Int64(val)]).unwrap();
1868        }
1869
1870        // Query with a predicate: id > 5000.
1871        // The first node group (max id = 4095) should be completely skipped.
1872        let predicate = Some((0, ">", &Value::Int64(5000)));
1873        let data = table.to_column_major_data_with_predicate(predicate);
1874
1875        // data is Vec<Vec<Value>> where data[col][row].
1876        // Total rows should be 4096 instead of 8192 because the first node group is skipped.
1877        assert_eq!(
1878            data[0].len(),
1879            NODE_GROUP_SIZE,
1880            "Only the second chunk should be returned"
1881        );
1882        assert_eq!(
1883            data[0][0],
1884            Value::Int64(NODE_GROUP_SIZE as i64),
1885            "First element should be from the second chunk"
1886        );
1887
1888        let _ = std::fs::remove_file(db_path);
1889    }
1890}