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