Skip to main content

reddb_server/storage/wal/
transaction.rs

1//! Transaction Manager
2//!
3//! Provides ACID transaction support for the RedDB storage engine.
4//!
5//! # Transaction Lifecycle
6//!
7//! 1. Begin: Allocate transaction ID, write Begin record to WAL
8//! 2. Read/Write: Track page reads and buffer page writes
9//! 3. Commit: Write Commit record to WAL, sync WAL
10//! 4. Rollback: Write Rollback record to WAL, discard buffered writes
11//!
12//! # Isolation Level
13//!
14//! Currently implements Read Committed isolation:
15//! - Reads see committed data at the start of the statement
16//! - No dirty reads
17//! - Possible non-repeatable reads
18//!
19//! # References
20//!
21//! - Turso `core/transaction.rs` - Transaction implementation
22//! - SQLite transaction documentation
23
24use std::collections::HashMap;
25use std::io;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
28
29use parking_lot::{Mutex, MutexGuard};
30
31use super::append_coordinator::WalAppendCoordinator;
32use super::record::WalRecord;
33use super::writer::WalWriter;
34use crate::storage::engine::{Page, Pager, PAGE_SIZE};
35use crate::storage::transaction::snapshot::SnapshotManager;
36
37/// Transaction state
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TxState {
40    /// Transaction is active and can perform operations
41    Active,
42    /// Transaction has been committed
43    Committed,
44    /// Transaction has been rolled back
45    Aborted,
46}
47
48/// Transaction error types
49#[derive(Debug)]
50pub enum TxError {
51    /// I/O error
52    Io(io::Error),
53    /// Pager error
54    Pager(String),
55    /// Internal lock was poisoned by a panic
56    LockPoisoned(&'static str),
57    /// Transaction is not active
58    NotActive,
59    /// Transaction already committed
60    AlreadyCommitted,
61    /// Transaction already aborted
62    AlreadyAborted,
63    /// Write conflict
64    WriteConflict(u32),
65    /// Invalid page data
66    InvalidPage(String),
67}
68
69impl std::fmt::Display for TxError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::Io(e) => write!(f, "I/O error: {}", e),
73            Self::Pager(msg) => write!(f, "Pager error: {}", msg),
74            Self::LockPoisoned(name) => write!(f, "Lock poisoned: {}", name),
75            Self::NotActive => write!(f, "Transaction is not active"),
76            Self::AlreadyCommitted => write!(f, "Transaction already committed"),
77            Self::AlreadyAborted => write!(f, "Transaction already aborted"),
78            Self::WriteConflict(page_id) => write!(f, "Write conflict on page {}", page_id),
79            Self::InvalidPage(msg) => write!(f, "Invalid page: {}", msg),
80        }
81    }
82}
83
84impl std::error::Error for TxError {}
85
86impl From<io::Error> for TxError {
87    fn from(e: io::Error) -> Self {
88        Self::Io(e)
89    }
90}
91
92/// A buffered page write
93#[derive(Clone)]
94struct BufferedWrite {
95    page_id: u32,
96    data: [u8; PAGE_SIZE],
97}
98
99/// A single transaction
100///
101/// Transactions buffer writes and commit them atomically to the WAL.
102pub struct Transaction {
103    /// Transaction ID
104    id: u64,
105    /// Transaction state
106    state: TxState,
107    /// Buffered page writes (page_id -> page data)
108    write_set: HashMap<u32, BufferedWrite>,
109    /// Pages read in this transaction (for conflict detection)
110    read_set: Vec<u32>,
111    /// Reference to the transaction manager
112    manager: Arc<TransactionManager>,
113}
114
115impl Transaction {
116    /// Get transaction ID
117    pub fn id(&self) -> u64 {
118        self.id
119    }
120
121    /// Get transaction state
122    pub fn state(&self) -> TxState {
123        self.state
124    }
125
126    /// Check if transaction is active
127    pub fn is_active(&self) -> bool {
128        self.state == TxState::Active
129    }
130
131    /// Read a page through this transaction
132    ///
133    /// If the page has been written in this transaction, returns the buffered version.
134    /// Otherwise, reads from the pager.
135    pub fn read_page(&mut self, page_id: u32) -> Result<Page, TxError> {
136        if self.state != TxState::Active {
137            return Err(TxError::NotActive);
138        }
139
140        // Check write set first
141        if let Some(buffered) = self.write_set.get(&page_id) {
142            return Ok(Page::from_bytes(buffered.data));
143        }
144
145        // Track the read
146        self.read_set.push(page_id);
147
148        // Read from pager
149        self.manager
150            .pager
151            .read_page(page_id)
152            .map_err(|e| TxError::Pager(e.to_string()))
153    }
154
155    /// Write a page through this transaction
156    ///
157    /// The write is buffered and will be committed to the WAL on commit.
158    pub fn write_page(&mut self, page_id: u32, page: Page) -> Result<(), TxError> {
159        if self.state != TxState::Active {
160            return Err(TxError::NotActive);
161        }
162
163        // Buffer the write
164        let mut data = [0u8; PAGE_SIZE];
165        data.copy_from_slice(page.as_bytes());
166
167        self.write_set
168            .insert(page_id, BufferedWrite { page_id, data });
169
170        Ok(())
171    }
172
173    /// Commit the transaction
174    ///
175    /// Writes all buffered pages to the WAL, then writes a Commit record.
176    ///
177    /// **Read-only fast path:** when `write_set` is empty, the
178    /// transaction wrote nothing, so there is nothing to make
179    /// durable. We skip the WAL append, the `wal.sync()` (which costs
180    /// ~100 µs of fsync), and the pager apply loop entirely. The
181    /// transaction still transitions to `Committed` and unregisters
182    /// from the manager so subsequent state checks work correctly.
183    /// This mirrors postgres' optimisation in `RecordTransactionCommit`
184    /// (`xact.c`) which skips `XLogFlush` when nothing was written.
185    pub fn commit(mut self) -> Result<(), TxError> {
186        if self.state != TxState::Active {
187            return match self.state {
188                TxState::Committed => Err(TxError::AlreadyCommitted),
189                TxState::Aborted => Err(TxError::AlreadyAborted),
190                _ => Err(TxError::NotActive),
191            };
192        }
193
194        // ── Read-only fast path ─────────────────────────────────────
195        // No writes → no WAL record → no fsync. Saves ~100 µs per
196        // read-only commit and removes contention on the WAL writer
197        // mutex for read-heavy workloads.
198        if self.write_set.is_empty() {
199            self.state = TxState::Committed;
200            self.manager.unregister_transaction(self.id);
201            self.manager.snapshot_manager.commit(self.id);
202            return Ok(());
203        }
204
205        // ── Encode phase (no lock) ──────────────────────────────────
206        // Encode every WAL record into one contiguous byte blob
207        // OUTSIDE any lock. This is the bulk of the per-commit work
208        // and pays no contention cost — the old path encoded each
209        // record while holding `Mutex<WalWriter>`, which under 16-way
210        // concurrency produced the park-convoy that bottlenecked
211        // `concurrent` and `insert_sequential`.
212        let mut blob = Vec::with_capacity(64 + self.write_set.len() * (PAGE_SIZE + 32));
213        for (page_id, buffered) in &self.write_set {
214            let record = WalRecord::PageWrite {
215                tx_id: self.id,
216                page_id: *page_id,
217                data: buffered.data.to_vec(),
218            };
219            // Encode straight into the per-call blob scratch — no fresh Vec
220            // per record. `blob` is owned by this commit frame and never
221            // shared across appenders, so this is safe on the lock-free path.
222            record.encode_into(&mut blob);
223        }
224        WalRecord::Commit { tx_id: self.id }.encode_into(&mut blob);
225
226        // ── Reserve + enqueue (lock-free) ───────────────────────────
227        // One atomic fetch_add reserves our LSN range; one
228        // SegQueue::push hands the bytes to the leader. Both
229        // operations are wait-free for the writer.
230        let commit_lsn = self.manager.coordinator.reserve_and_enqueue(blob);
231
232        // ── Wait for durability ─────────────────────────────────────
233        // If `durable_lsn >= commit_lsn` already, the leader (some
234        // earlier thread) covered us — return immediately. Otherwise
235        // we either become the leader and drive the drain, or park
236        // on the coordinator's parking_lot::Condvar until a leader
237        // publishes a `durable_lsn` past our target.
238        self.manager
239            .coordinator
240            .commit_at_least(commit_lsn, &self.manager.wal)
241            .map_err(TxError::Io)?;
242
243        // Apply writes to pager cache (for immediate visibility)
244        for (page_id, buffered) in &self.write_set {
245            let page = Page::from_bytes(buffered.data);
246            self.manager
247                .pager
248                .write_page(*page_id, page)
249                .map_err(|e| TxError::Pager(e.to_string()))?;
250        }
251
252        self.state = TxState::Committed;
253
254        // Unregister from manager
255        self.manager.unregister_transaction(self.id);
256        self.manager.snapshot_manager.commit(self.id);
257
258        Ok(())
259    }
260
261    /// Rollback the transaction
262    ///
263    /// Discards all buffered writes and writes a Rollback record to the WAL.
264    pub fn rollback(mut self) -> Result<(), TxError> {
265        if self.state != TxState::Active {
266            return match self.state {
267                TxState::Committed => Err(TxError::AlreadyCommitted),
268                TxState::Aborted => Err(TxError::AlreadyAborted),
269                _ => Err(TxError::NotActive),
270            };
271        }
272
273        // Route rollback through the coordinator so its bytes land
274        // in LSN order with any concurrent commits. Going around the
275        // coordinator (direct `wal.lock().append`) would race with
276        // the leader's `append_bytes` and corrupt the file.
277        let blob = WalRecord::Rollback { tx_id: self.id }.encode();
278        let target = self.manager.coordinator.reserve_and_enqueue(blob);
279        self.manager
280            .coordinator
281            .commit_at_least(target, &self.manager.wal)
282            .map_err(TxError::Io)?;
283
284        // Clear write set
285        self.write_set.clear();
286        self.state = TxState::Aborted;
287
288        // Unregister from manager
289        self.manager.unregister_transaction(self.id);
290        self.manager.snapshot_manager.rollback(self.id);
291
292        Ok(())
293    }
294}
295
296impl Drop for Transaction {
297    fn drop(&mut self) {
298        // If transaction is still active when dropped, it means it was neither
299        // committed nor rolled back. This is a bug, but we'll clean up anyway.
300        if self.state == TxState::Active {
301            // Best-effort rollback through the coordinator. We can't
302            // bypass the coordinator with a direct `wal.lock()` here:
303            // any in-flight reservations would still be holding LSN
304            // slots ahead of us and the file would gain a hole. The
305            // coordinator handles ordering correctly even from Drop.
306            let blob = WalRecord::Rollback { tx_id: self.id }.encode();
307            let target = self.manager.coordinator.reserve_and_enqueue(blob);
308            let _ = self
309                .manager
310                .coordinator
311                .commit_at_least(target, &self.manager.wal);
312            self.manager.unregister_transaction(self.id);
313            self.manager.snapshot_manager.rollback(self.id);
314        }
315    }
316}
317
318/// Transaction Manager
319///
320/// Coordinates transactions and manages the WAL.
321pub struct TransactionManager {
322    /// Pager for reading/writing pages
323    pager: Arc<Pager>,
324    /// WAL writer protected by a `parking_lot::Mutex`. The mutex is
325    /// taken only by the leader-flush path inside the coordinator;
326    /// concurrent writers no longer contend on it during the
327    /// encode-and-append step. See [`WalAppendCoordinator`].
328    wal: Mutex<WalWriter>,
329    /// WAL file path
330    wal_path: PathBuf,
331    /// Active transaction IDs
332    active_transactions: RwLock<Vec<u64>>,
333    /// Shared XID authority used by row MVCC and page-level WAL transactions.
334    snapshot_manager: Arc<SnapshotManager>,
335    /// Lock-free append coordinator. Replaces the per-commit
336    /// `wal.lock()` that used to serialise 16 concurrent writers
337    /// (Roadmap #2 / issue #157). Writers reserve an LSN range via
338    /// atomic fetch_add, push their encoded bytes onto a SegQueue,
339    /// then call `commit_at_least` to wait for durability. The
340    /// first thread into `commit_at_least` becomes the leader and
341    /// drives the WAL drain + fsync; everyone else parks on the
342    /// coordinator's condvar.
343    coordinator: WalAppendCoordinator,
344}
345
346impl TransactionManager {
347    /// Create a new transaction manager
348    ///
349    /// # Arguments
350    ///
351    /// * `pager` - The pager to use for page I/O
352    /// * `wal_path` - Path to the WAL file
353    pub fn new(pager: Arc<Pager>, wal_path: impl AsRef<Path>) -> io::Result<Self> {
354        Self::new_with_snapshot_manager(pager, wal_path, Arc::new(SnapshotManager::new()))
355    }
356
357    /// Create a transaction manager using the provided snapshot/XID manager.
358    pub fn new_with_snapshot_manager(
359        pager: Arc<Pager>,
360        wal_path: impl AsRef<Path>,
361        snapshot_manager: Arc<SnapshotManager>,
362    ) -> io::Result<Self> {
363        let wal_path = wal_path.as_ref().to_path_buf();
364        let wal = WalWriter::open(&wal_path)?;
365        let initial_current = wal.current_lsn();
366        let initial_durable = wal.durable_lsn();
367
368        Ok(Self {
369            pager,
370            wal: Mutex::new(wal),
371            wal_path,
372            active_transactions: RwLock::new(Vec::new()),
373            snapshot_manager,
374            coordinator: WalAppendCoordinator::new(initial_current, initial_durable),
375        })
376    }
377
378    fn wal_writer(&self) -> Result<MutexGuard<'_, WalWriter>, TxError> {
379        // parking_lot::Mutex does not poison on panic, so this is
380        // infallible. We keep the `Result` return type to avoid
381        // touching every call site, but the error variant is now
382        // unreachable in normal operation. Tests that previously
383        // poisoned the std::sync::Mutex deliberately have been
384        // adjusted to assert the new non-poisoning behaviour.
385        Ok(self.wal.lock())
386    }
387
388    fn active_transactions_write(&self) -> RwLockWriteGuard<'_, Vec<u64>> {
389        self.active_transactions
390            .write()
391            .unwrap_or_else(|poisoned| poisoned.into_inner())
392    }
393
394    fn active_transactions_read(&self) -> RwLockReadGuard<'_, Vec<u64>> {
395        self.active_transactions
396            .read()
397            .unwrap_or_else(|poisoned| poisoned.into_inner())
398    }
399
400    /// Begin a new transaction
401    pub fn begin(self: &Arc<Self>) -> Result<Transaction, TxError> {
402        let tx_id = self.snapshot_manager.begin();
403
404        // Route the Begin record through the coordinator (same
405        // ordering guarantees as commit/rollback). Begin is not
406        // strictly required to be durable before subsequent appends
407        // — recovery treats a Begin without a matching Commit as a
408        // rolled-back txn — so we don't wait on `commit_at_least`.
409        // The bytes are queued and the next leader picks them up
410        // alongside our own Commit record when we eventually commit.
411        let blob = WalRecord::Begin { tx_id }.encode();
412        let _begin_lsn = self.coordinator.reserve_and_enqueue(blob);
413
414        // Register transaction
415        {
416            let mut active = self.active_transactions_write();
417            active.push(tx_id);
418        }
419
420        Ok(Transaction {
421            id: tx_id,
422            state: TxState::Active,
423            write_set: HashMap::new(),
424            read_set: Vec::new(),
425            manager: Arc::clone(self),
426        })
427    }
428
429    /// Unregister a transaction (called on commit/rollback)
430    fn unregister_transaction(&self, tx_id: u64) {
431        let mut active = self.active_transactions_write();
432        active.retain(|&id| id != tx_id);
433    }
434
435    /// Get list of active transaction IDs
436    pub fn active_transactions(&self) -> Vec<u64> {
437        self.active_transactions_read().clone()
438    }
439
440    /// Get WAL file path
441    pub fn wal_path(&self) -> &Path {
442        &self.wal_path
443    }
444
445    /// Get reference to pager
446    pub fn pager(&self) -> &Arc<Pager> {
447        &self.pager
448    }
449
450    /// Sync WAL to disk. Drains every byte that has been reserved
451    /// via the coordinator — i.e. waits until the coordinator's
452    /// `next_lsn` is durable.
453    pub fn sync_wal(&self) -> io::Result<()> {
454        let target = self.coordinator.next_lsn();
455        self.coordinator.commit_at_least(target, &self.wal)
456    }
457
458    /// Check if there are active transactions
459    pub fn has_active_transactions(&self) -> bool {
460        !self.active_transactions_read().is_empty()
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::storage::engine::PageType;
468    use std::fs;
469    use std::time::{SystemTime, UNIX_EPOCH};
470
471    fn temp_dir() -> PathBuf {
472        let timestamp = SystemTime::now()
473            .duration_since(UNIX_EPOCH)
474            .expect("value is present")
475            .as_nanos();
476        std::env::temp_dir().join(format!("reddb_tx_test_{}", timestamp))
477    }
478
479    fn cleanup(dir: &Path) {
480        let _ = fs::remove_dir_all(dir);
481    }
482
483    fn temp_wal_path(dir: &Path, name: &str) -> PathBuf {
484        reddb_file::layout::wal_component_temp_path(dir, "transaction", name, std::process::id())
485    }
486
487    #[test]
488    fn test_transaction_commit() {
489        let dir = temp_dir();
490        let _ = fs::create_dir_all(&dir);
491        let db_path = dir.join("test.db");
492        let wal_path = temp_wal_path(&dir, "commit");
493
494        // Create pager
495        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
496
497        // Allocate a page
498        let page = pager
499            .allocate_page(PageType::BTreeLeaf)
500            .expect("allocate_page() should succeed");
501        let page_id = page.page_id();
502
503        // Create transaction manager
504        let tm = Arc::new(
505            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
506        );
507
508        // Begin transaction
509        let mut tx = tm.begin().expect("begin() should succeed");
510        assert!(tx.is_active());
511
512        // Write through transaction
513        let mut page = Page::new(PageType::BTreeLeaf, page_id);
514        page.as_bytes_mut()[100] = 0xAB;
515        tx.write_page(page_id, page)
516            .expect("write_page() should succeed");
517
518        // Read through transaction (should see buffered write)
519        let read_page = tx.read_page(page_id).expect("read_page() should succeed");
520        assert_eq!(read_page.as_bytes()[100], 0xAB);
521
522        // Commit
523        tx.commit().expect("commit() should succeed");
524
525        // Verify write is visible through pager
526        let final_page = pager
527            .read_page(page_id)
528            .expect("read_page() should succeed");
529        assert_eq!(final_page.as_bytes()[100], 0xAB);
530
531        cleanup(&dir);
532    }
533
534    #[test]
535    fn test_transaction_rollback() {
536        let dir = temp_dir();
537        let _ = fs::create_dir_all(&dir);
538        let db_path = dir.join("test.db");
539        let wal_path = temp_wal_path(&dir, "rollback");
540
541        // Create pager
542        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
543
544        // Allocate a page and write initial value
545        let mut page = pager
546            .allocate_page(PageType::BTreeLeaf)
547            .expect("allocate_page() should succeed");
548        let page_id = page.page_id();
549        page.as_bytes_mut()[100] = 0x11;
550        pager
551            .write_page(page_id, page)
552            .expect("write_page() should succeed");
553
554        // Create transaction manager
555        let tm = Arc::new(
556            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
557        );
558
559        // Begin transaction
560        let mut tx = tm.begin().expect("begin() should succeed");
561
562        // Write through transaction
563        let mut page = Page::new(PageType::BTreeLeaf, page_id);
564        page.as_bytes_mut()[100] = 0xAB;
565        tx.write_page(page_id, page)
566            .expect("write_page() should succeed");
567
568        // Read through transaction (should see buffered write)
569        let read_page = tx.read_page(page_id).expect("read_page() should succeed");
570        assert_eq!(read_page.as_bytes()[100], 0xAB);
571
572        // Rollback
573        tx.rollback().expect("rollback() should succeed");
574
575        // Original value should be preserved
576        let final_page = pager
577            .read_page(page_id)
578            .expect("read_page() should succeed");
579        assert_eq!(final_page.as_bytes()[100], 0x11);
580
581        cleanup(&dir);
582    }
583
584    #[test]
585    fn test_multiple_transactions() {
586        let dir = temp_dir();
587        let _ = fs::create_dir_all(&dir);
588        let db_path = dir.join("test.db");
589        let wal_path = temp_wal_path(&dir, "multiple");
590
591        // Create pager
592        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
593
594        // Allocate two pages
595        let page1 = pager
596            .allocate_page(PageType::BTreeLeaf)
597            .expect("allocate_page() should succeed");
598        let page2 = pager
599            .allocate_page(PageType::BTreeLeaf)
600            .expect("allocate_page() should succeed");
601        let page1_id = page1.page_id();
602        let page2_id = page2.page_id();
603
604        // Create transaction manager
605        let tm = Arc::new(
606            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
607        );
608
609        // Transaction 1: Write to page 1
610        let mut tx1 = tm.begin().expect("begin() should succeed");
611        let mut page1 = Page::new(PageType::BTreeLeaf, page1_id);
612        page1.as_bytes_mut()[100] = 0x11;
613        tx1.write_page(page1_id, page1)
614            .expect("write_page() should succeed");
615        tx1.commit().expect("commit() should succeed");
616
617        // Transaction 2: Write to page 2
618        let mut tx2 = tm.begin().expect("begin() should succeed");
619        let mut page2 = Page::new(PageType::BTreeLeaf, page2_id);
620        page2.as_bytes_mut()[100] = 0x22;
621        tx2.write_page(page2_id, page2)
622            .expect("write_page() should succeed");
623        tx2.commit().expect("commit() should succeed");
624
625        // Verify both writes
626        let final_page1 = pager
627            .read_page(page1_id)
628            .expect("read_page() should succeed");
629        let final_page2 = pager
630            .read_page(page2_id)
631            .expect("read_page() should succeed");
632        assert_eq!(final_page1.as_bytes()[100], 0x11);
633        assert_eq!(final_page2.as_bytes()[100], 0x22);
634
635        cleanup(&dir);
636    }
637
638    #[test]
639    fn test_transaction_isolation() {
640        let dir = temp_dir();
641        let _ = fs::create_dir_all(&dir);
642        let db_path = dir.join("test.db");
643        let wal_path = temp_wal_path(&dir, "isolation");
644
645        // Create pager
646        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
647
648        // Allocate a page with initial value
649        let mut page = pager
650            .allocate_page(PageType::BTreeLeaf)
651            .expect("allocate_page() should succeed");
652        let page_id = page.page_id();
653        page.as_bytes_mut()[100] = 0x00;
654        pager
655            .write_page(page_id, page)
656            .expect("write_page() should succeed");
657
658        // Create transaction manager
659        let tm = Arc::new(
660            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
661        );
662
663        // Transaction 1: Begin and write (but don't commit yet)
664        let mut tx1 = tm.begin().expect("begin() should succeed");
665        let mut page1 = Page::new(PageType::BTreeLeaf, page_id);
666        page1.as_bytes_mut()[100] = 0x11;
667        tx1.write_page(page_id, page1)
668            .expect("write_page() should succeed");
669
670        // Transaction 1 should see its own write
671        let tx1_read = tx1.read_page(page_id).expect("read_page() should succeed");
672        assert_eq!(tx1_read.as_bytes()[100], 0x11);
673
674        // Another read from pager should not see uncommitted write
675        let pager_read = pager
676            .read_page(page_id)
677            .expect("read_page() should succeed");
678        assert_eq!(pager_read.as_bytes()[100], 0x00);
679
680        // Commit tx1
681        tx1.commit().expect("commit() should succeed");
682
683        // Now pager should see the write
684        let final_read = pager
685            .read_page(page_id)
686            .expect("read_page() should succeed");
687        assert_eq!(final_read.as_bytes()[100], 0x11);
688
689        cleanup(&dir);
690    }
691
692    #[test]
693    fn test_active_transaction_tracking() {
694        let dir = temp_dir();
695        let _ = fs::create_dir_all(&dir);
696        let db_path = dir.join("test.db");
697        let wal_path = temp_wal_path(&dir, "tracking");
698
699        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
700        let tm = Arc::new(
701            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
702        );
703
704        assert!(!tm.has_active_transactions());
705
706        let tx1 = tm.begin().expect("begin() should succeed");
707        let tx1_id = tx1.id();
708        assert!(tm.has_active_transactions());
709        assert!(tm.active_transactions().contains(&tx1_id));
710
711        let tx2 = tm.begin().expect("begin() should succeed");
712        let tx2_id = tx2.id();
713        assert_eq!(tm.active_transactions().len(), 2);
714
715        tx1.commit().expect("commit() should succeed");
716        assert!(!tm.active_transactions().contains(&tx1_id));
717        assert!(tm.active_transactions().contains(&tx2_id));
718
719        tx2.rollback().expect("rollback() should succeed");
720        assert!(!tm.has_active_transactions());
721
722        cleanup(&dir);
723    }
724
725    #[test]
726    fn page_wal_transactions_allocate_from_snapshot_manager() {
727        let dir = temp_dir();
728        let _ = fs::create_dir_all(&dir);
729        let db_path = dir.join("test.db");
730        let wal_path = temp_wal_path(&dir, "snapshot-xid");
731
732        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
733        let snapshot_manager =
734            Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
735        let row_xid = snapshot_manager.begin();
736        snapshot_manager.commit(row_xid);
737
738        let tm = Arc::new(
739            TransactionManager::new_with_snapshot_manager(
740                Arc::clone(&pager),
741                &wal_path,
742                Arc::clone(&snapshot_manager),
743            )
744            .expect("new() should succeed"),
745        );
746
747        let tx = tm.begin().expect("begin() should succeed");
748        assert_eq!(tx.id(), row_xid + 1);
749        tx.commit().expect("commit() should succeed");
750        assert_eq!(snapshot_manager.peek_next_xid(), row_xid + 2);
751
752        cleanup(&dir);
753    }
754
755    #[test]
756    fn test_transaction_double_commit() {
757        let dir = temp_dir();
758        let _ = fs::create_dir_all(&dir);
759        let db_path = dir.join("test.db");
760        let wal_path = temp_wal_path(&dir, "double-commit");
761
762        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
763        let tm = Arc::new(
764            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
765        );
766
767        // The transaction is consumed on commit, so double commit is impossible at compile time
768        // This test just verifies commit works
769        let tx = tm.begin().expect("begin() should succeed");
770        tx.commit().expect("commit() should succeed");
771
772        cleanup(&dir);
773    }
774
775    #[test]
776    fn test_begin_succeeds_after_panic_in_lock_holder() {
777        // After Roadmap #2 the WAL mutex is `parking_lot::Mutex`,
778        // which does NOT poison on panic. A previous version of this
779        // test asserted that a panicking thread holding the lock
780        // produced a `TxError::LockPoisoned` on the next `begin()` —
781        // that was a property of `std::sync::Mutex`. The new
782        // contract is the opposite: writers continue uninterrupted.
783        let dir = temp_dir();
784        let _ = fs::create_dir_all(&dir);
785        let db_path = dir.join("test.db");
786        let wal_path = temp_wal_path(&dir, "panic-lock-holder");
787
788        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
789        let tm = Arc::new(
790            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
791        );
792
793        let poison_target = Arc::clone(&tm);
794        let _ = std::thread::spawn(move || {
795            let _guard = poison_target.wal.lock();
796            panic!("would-poison the wal mutex on std::sync");
797        })
798        .join();
799
800        // parking_lot recovers transparently. begin() must succeed.
801        match tm.begin() {
802            Ok(_) => {}
803            Err(err) => panic!("begin must succeed despite prior panic: {err:?}"),
804        }
805
806        cleanup(&dir);
807    }
808
809    // ---------------------------------------------------------------
810    // Perf 1.2: read-only commit fast path
811    // ---------------------------------------------------------------
812
813    #[test]
814    fn read_only_commit_does_not_advance_durable_lsn() {
815        let dir = temp_dir();
816        let _ = fs::create_dir_all(&dir);
817        let db_path = dir.join("ro_durable.db");
818        let wal_path = temp_wal_path(&dir, "ro-durable");
819
820        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
821        let tm = Arc::new(
822            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
823        );
824
825        // Snapshot the WAL durable_lsn BEFORE the txn.
826        let before = {
827            let wal = tm.wal_writer().expect("wal_writer() should succeed");
828            wal.durable_lsn()
829        };
830
831        let tx = tm.begin().expect("begin() should succeed");
832        // Empty write_set on purpose — read-only.
833        tx.commit().expect("commit() should succeed");
834
835        // After RO commit, the WAL durable_lsn must NOT have advanced.
836        // No Begin record, no Commit record, no fsync.
837        let after = {
838            let wal = tm.wal_writer().expect("wal_writer() should succeed");
839            wal.durable_lsn()
840        };
841        assert_eq!(
842            before, after,
843            "read-only commit must not advance durable_lsn (was {} → {})",
844            before, after
845        );
846
847        cleanup(&dir);
848    }
849
850    #[test]
851    fn read_only_commit_does_not_grow_wal_file() {
852        let dir = temp_dir();
853        let _ = fs::create_dir_all(&dir);
854        let db_path = dir.join("ro_size.db");
855        let wal_path = temp_wal_path(&dir, "ro-size");
856
857        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
858        let tm = Arc::new(
859            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
860        );
861
862        // Snapshot file size after WAL header.
863        let size_before = std::fs::metadata(&wal_path)
864            .expect("metadata() should succeed")
865            .len();
866        assert_eq!(
867            size_before,
868            reddb_file::WAL_FILE_HEADER_BYTES as u64,
869            "fresh WAL must be exactly the 8-byte header"
870        );
871
872        // 100 read-only commits in a loop.
873        for _ in 0..100 {
874            let tx = tm.begin().expect("begin() should succeed");
875            tx.commit().expect("commit() should succeed");
876        }
877
878        let size_after = std::fs::metadata(&wal_path)
879            .expect("metadata() should succeed")
880            .len();
881        assert_eq!(
882            size_after, size_before,
883            "100 read-only commits should not have written any WAL bytes"
884        );
885        cleanup(&dir);
886    }
887
888    #[test]
889    fn read_only_commit_marks_transaction_committed() {
890        let dir = temp_dir();
891        let _ = fs::create_dir_all(&dir);
892        let db_path = dir.join("ro_state.db");
893        let wal_path = temp_wal_path(&dir, "ro-state");
894
895        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
896        let tm = Arc::new(
897            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
898        );
899
900        let tx = tm.begin().expect("begin() should succeed");
901        let id = tx.id();
902        tx.commit().expect("commit() should succeed");
903
904        // Manager must have unregistered this txn — the active list
905        // no longer contains its id.
906        assert!(
907            !tm.active_transactions().contains(&id),
908            "RO-committed txn {id} must no longer be active in the manager"
909        );
910
911        cleanup(&dir);
912    }
913
914    #[test]
915    fn writing_commit_still_syncs_after_ro_fast_path() {
916        // Sanity: the fast path must NOT short-circuit a transaction
917        // that did write something. Verify the writing commit path
918        // still flushes WAL and the value lands in the pager.
919        let dir = temp_dir();
920        let _ = fs::create_dir_all(&dir);
921        let db_path = dir.join("rw_after_ro.db");
922        let wal_path = temp_wal_path(&dir, "rw-after-ro");
923
924        let pager = Arc::new(Pager::open_default(&db_path).expect("open_default() should succeed"));
925        let allocated = pager
926            .allocate_page(PageType::BTreeLeaf)
927            .expect("allocate_page() should succeed");
928        let page_id = allocated.page_id();
929        let tm = Arc::new(
930            TransactionManager::new(Arc::clone(&pager), &wal_path).expect("new() should succeed"),
931        );
932
933        // First a RO commit (must take the fast path).
934        let ro = tm.begin().expect("begin() should succeed");
935        ro.commit().expect("commit() should succeed");
936
937        // Then a real writing commit.
938        let mut rw = tm.begin().expect("begin() should succeed");
939        let mut page = Page::new(PageType::BTreeLeaf, page_id);
940        page.as_bytes_mut()[42] = 0x77;
941        rw.write_page(page_id, page)
942            .expect("write_page() should succeed");
943        rw.commit().expect("commit() should succeed");
944
945        // The WAL file must now contain bytes (PageWrite + Commit
946        // records, and the BufWriter has been flushed by sync()).
947        let size = std::fs::metadata(&wal_path)
948            .expect("metadata() should succeed")
949            .len();
950        assert!(size > 8, "writing commit should grow the WAL");
951
952        // The pager cache must reflect the write.
953        let read_back = pager
954            .read_page(page_id)
955            .expect("read_page() should succeed");
956        assert_eq!(read_back.as_bytes()[42], 0x77);
957
958        cleanup(&dir);
959    }
960}