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