Skip to main content

reddb_server/storage/wal/
checkpoint.rs

1//! Checkpoint Manager
2//!
3//! Responsible for transferring committed transactions from the WAL to the main
4//! database file. Checkpointing ensures durability and allows WAL truncation.
5//!
6//! # Algorithm
7//!
8//! 1. Read all WAL records sequentially
9//! 2. Track transaction states (Begin, Commit, Rollback)
10//! 3. For committed transactions, collect PageWrite records
11//! 4. Apply committed pages to the Pager in LSN order
12//! 5. Sync Pager to disk
13//! 6. Update checkpoint LSN in database header
14//! 7. Truncate WAL
15//!
16//! # References
17//!
18//! - Turso `core/storage/wal.rs:checkpoint()` - Checkpoint logic
19//! - SQLite WAL documentation
20
21use std::collections::{HashMap, HashSet};
22use std::io;
23use std::path::Path;
24
25use super::reader::WalReader;
26use super::record::WalRecord;
27use super::writer::WalWriter;
28use crate::storage::engine::{Page, Pager, PAGE_SIZE};
29
30/// Checkpoint mode
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CheckpointMode {
33    /// Passive: Only checkpoint if no active writers
34    Passive,
35    /// Full: Wait for active writers to finish, then checkpoint all
36    Full,
37    /// Restart: Like Full, but also truncates the WAL
38    Restart,
39    /// Truncate: Checkpoint all and truncate WAL
40    Truncate,
41}
42
43/// Checkpoint result statistics
44#[derive(Debug, Clone, Default)]
45pub struct CheckpointResult {
46    /// Number of transactions processed
47    pub transactions_processed: u64,
48    /// Highest transaction id observed while scanning the WAL.
49    pub max_transaction_id: u64,
50    /// Number of pages checkpointed
51    pub pages_checkpointed: u64,
52    /// Number of records processed
53    pub records_processed: u64,
54    /// Final LSN after checkpoint
55    pub checkpoint_lsn: u64,
56    /// Whether WAL was truncated
57    pub wal_truncated: bool,
58}
59
60/// Checkpoint error types
61#[derive(Debug)]
62pub enum CheckpointError {
63    /// I/O error
64    Io(io::Error),
65    /// Pager error
66    Pager(String),
67    /// WAL is corrupted
68    CorruptedWal(String),
69    /// No WAL file found
70    NoWal,
71}
72
73impl std::fmt::Display for CheckpointError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Io(e) => write!(f, "I/O error: {}", e),
77            Self::Pager(msg) => write!(f, "Pager error: {}", msg),
78            Self::CorruptedWal(msg) => write!(f, "Corrupted WAL: {}", msg),
79            Self::NoWal => write!(f, "No WAL file found"),
80        }
81    }
82}
83
84impl std::error::Error for CheckpointError {}
85
86impl From<io::Error> for CheckpointError {
87    fn from(e: io::Error) -> Self {
88        Self::Io(e)
89    }
90}
91
92/// Transaction state during checkpoint
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum TxState {
95    Active,
96    Committed,
97    Aborted,
98}
99
100/// Pending page write from a transaction
101#[derive(Debug)]
102struct PendingWrite {
103    tx_id: u64,
104    page_id: u32,
105    data: Vec<u8>,
106    lsn: u64,
107}
108
109fn result_observe_tx_id(max_transaction_id: &mut u64, tx_id: u64) {
110    *max_transaction_id = (*max_transaction_id).max(tx_id);
111}
112
113/// Checkpoint manager
114///
115/// Responsible for transferring committed transactions from the WAL to the main database file.
116pub struct Checkpointer {
117    /// Checkpoint mode
118    mode: CheckpointMode,
119}
120
121impl Checkpointer {
122    /// Create a new checkpointer with the given mode
123    pub fn new(mode: CheckpointMode) -> Self {
124        Self { mode }
125    }
126
127    /// Create a checkpointer with default mode (Full)
128    pub fn default_mode() -> Self {
129        Self::new(CheckpointMode::Full)
130    }
131
132    /// Perform a checkpoint
133    ///
134    /// Reads all records from the WAL and applies committed changes to the database.
135    ///
136    /// # Arguments
137    ///
138    /// * `pager` - The Pager to write committed pages to
139    /// * `wal_path` - Path to the WAL file
140    ///
141    /// # Returns
142    ///
143    /// Checkpoint statistics or error
144    pub fn checkpoint(
145        &self,
146        pager: &Pager,
147        wal_path: &Path,
148    ) -> Result<CheckpointResult, CheckpointError> {
149        // Open WAL for reading
150        let wal_reader = match WalReader::open(wal_path) {
151            Ok(r) => r,
152            Err(e) if e.kind() == io::ErrorKind::NotFound => {
153                // No WAL file - nothing to checkpoint
154                return Ok(CheckpointResult::default());
155            }
156            Err(e) => return Err(CheckpointError::Io(e)),
157        };
158
159        // Phase 1: Read and categorize all records
160        let mut tx_states: HashMap<u64, TxState> = HashMap::new();
161        let mut pending_writes: Vec<PendingWrite> = Vec::new();
162        let mut records_processed: u64 = 0;
163        let mut last_lsn: u64 = 0;
164        let mut max_transaction_id: u64 = 0;
165
166        for record_result in wal_reader.iter() {
167            let (lsn, record) = record_result.map_err(CheckpointError::Io)?;
168            records_processed += 1;
169            last_lsn = lsn;
170
171            match record {
172                WalRecord::PageOwnership { .. } => {
173                    return Err(CheckpointError::Io(io::Error::other(
174                        "page ownership journal requires recovery before checkpoint",
175                    )));
176                }
177                WalRecord::Begin { tx_id } => {
178                    result_observe_tx_id(&mut max_transaction_id, tx_id);
179                    tx_states.insert(tx_id, TxState::Active);
180                }
181                WalRecord::Commit { tx_id } => {
182                    result_observe_tx_id(&mut max_transaction_id, tx_id);
183                    tx_states.insert(tx_id, TxState::Committed);
184                }
185                WalRecord::Rollback { tx_id } => {
186                    result_observe_tx_id(&mut max_transaction_id, tx_id);
187                    tx_states.insert(tx_id, TxState::Aborted);
188                }
189                WalRecord::PageWrite {
190                    tx_id,
191                    page_id,
192                    data,
193                } => {
194                    result_observe_tx_id(&mut max_transaction_id, tx_id);
195                    pending_writes.push(PendingWrite {
196                        tx_id,
197                        page_id,
198                        data,
199                        lsn,
200                    });
201                }
202                WalRecord::Checkpoint {
203                    lsn: _checkpoint_lsn,
204                } => {
205                    // Checkpoint marker - we can skip records before this LSN
206                    // For now, we process everything
207                }
208                WalRecord::TxCommitBatch { tx_id, .. } => {
209                    result_observe_tx_id(&mut max_transaction_id, tx_id);
210                    // Store-level logical commit batches are replayed by
211                    // UnifiedStore, not by the pager page checkpoint path.
212                }
213                WalRecord::VectorInsert { .. } => {
214                    // Vector-turbo logical inserts are replayed by the
215                    // vector index layer, not by page checkpointing.
216                    // Issue #694 — named crash boundary for #673.
217                    crate::runtime::turbo_crash_inject::fire(
218                        crate::runtime::turbo_crash_inject::InjectionPoint::MidCheckpoint,
219                    );
220                }
221                WalRecord::ProbabilisticDelta { .. } => {
222                    // Probabilistic sidecar deltas are replayed by the
223                    // runtime after loading the latest full snapshot.
224                }
225                WalRecord::FullPageImage { tx_id, .. } => {
226                    result_observe_tx_id(&mut max_transaction_id, tx_id);
227                    // FPI records (gh-478) are consumed by the pager
228                    // recovery path before redo, not by checkpoint
229                    // accounting.
230                }
231            }
232        }
233
234        // Phase 2: Filter for committed transactions only
235        let committed_txs: HashSet<u64> = tx_states
236            .iter()
237            .filter(|(_, state)| **state == TxState::Committed)
238            .map(|(tx_id, _)| *tx_id)
239            .collect();
240
241        // Phase 3: Collect pages from committed transactions
242        // Keep only the latest write for each page (from committed txs)
243        let mut latest_writes: HashMap<u32, Vec<u8>> = HashMap::new();
244
245        for write in pending_writes {
246            if committed_txs.contains(&write.tx_id) {
247                // Always overwrite with later writes (they have higher LSN)
248                latest_writes.insert(write.page_id, write.data);
249            }
250        }
251
252        // Phase 4 (PREPARE): Mark checkpoint in progress in header
253        if !latest_writes.is_empty() {
254            pager
255                .set_checkpoint_in_progress(true, last_lsn)
256                .map_err(|e| CheckpointError::Pager(e.to_string()))?;
257        }
258
259        // Phase 5 (APPLY): Write committed pages to Pager
260        let mut pages_checkpointed: u64 = 0;
261
262        for (page_id, data) in &latest_writes {
263            // Reconstruct page from WAL data
264            if data.len() != PAGE_SIZE {
265                return Err(CheckpointError::CorruptedWal(format!(
266                    "Page {} has wrong size: {} (expected {})",
267                    page_id,
268                    data.len(),
269                    PAGE_SIZE
270                )));
271            }
272
273            let mut page_data = [0u8; PAGE_SIZE];
274            page_data.copy_from_slice(data);
275            let page = Page::from_bytes(page_data);
276
277            // Write to pager
278            pager
279                .write_page(*page_id, page)
280                .map_err(|e| CheckpointError::Pager(e.to_string()))?;
281
282            pages_checkpointed += 1;
283        }
284
285        // Phase 6: Sync Pager to disk
286        pager
287            .sync()
288            .map_err(|e| CheckpointError::Pager(e.to_string()))?;
289
290        // Phase 7 (COMPLETE): Clear in-progress flag and update checkpoint LSN
291        if !latest_writes.is_empty() {
292            pager
293                .complete_checkpoint(last_lsn)
294                .map_err(|e| CheckpointError::Pager(e.to_string()))?;
295        }
296
297        // Phase 8: Truncate WAL if requested
298        let wal_truncated = matches!(
299            self.mode,
300            CheckpointMode::Restart | CheckpointMode::Truncate
301        );
302
303        if wal_truncated {
304            let mut wal_writer = WalWriter::open(wal_path)?;
305            wal_writer.truncate()?;
306
307            // Write checkpoint marker with current LSN
308            let checkpoint_record = WalRecord::Checkpoint { lsn: last_lsn };
309            wal_writer.append(&checkpoint_record)?;
310            wal_writer.sync()?;
311        }
312
313        Ok(CheckpointResult {
314            transactions_processed: committed_txs.len() as u64,
315            max_transaction_id,
316            pages_checkpointed,
317            records_processed,
318            checkpoint_lsn: last_lsn,
319            wal_truncated,
320        })
321    }
322
323    /// Perform crash recovery
324    ///
325    /// Called on database open to apply any committed transactions from the WAL
326    /// that weren't checkpointed before the crash. If a checkpoint was interrupted
327    /// (checkpoint_in_progress flag set), re-applies all WAL records from scratch.
328    ///
329    /// # Arguments
330    ///
331    /// * `pager` - The Pager to recover into
332    /// * `wal_path` - Path to the WAL file
333    ///
334    /// # Returns
335    ///
336    /// Recovery statistics or error
337    pub fn recover(pager: &Pager, wal_path: &Path) -> Result<CheckpointResult, CheckpointError> {
338        // Check if a checkpoint was interrupted
339        if let Ok(header) = pager.header() {
340            if header.checkpoint_in_progress {
341                // Previous checkpoint was interrupted — re-apply everything from WAL
342                // This is safe because page writes are idempotent (last-write-wins)
343                let _ = pager.set_checkpoint_in_progress(false, 0);
344            }
345        }
346        let checkpointer = Self::new(CheckpointMode::Truncate);
347        checkpointer.checkpoint(pager, wal_path)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::storage::engine::PageType;
355    use std::fs;
356    use std::time::{SystemTime, UNIX_EPOCH};
357
358    fn temp_dir() -> std::path::PathBuf {
359        let timestamp = SystemTime::now()
360            .duration_since(UNIX_EPOCH)
361            .unwrap()
362            .as_nanos();
363        std::env::temp_dir().join(format!("reddb_checkpoint_test_{}", timestamp))
364    }
365
366    fn cleanup(dir: &Path) {
367        let _ = fs::remove_dir_all(dir);
368    }
369
370    fn temp_wal_path(dir: &Path, name: &str) -> std::path::PathBuf {
371        reddb_file::layout::wal_component_temp_path(dir, "checkpoint", name, std::process::id())
372    }
373
374    #[test]
375    fn test_checkpoint_empty_wal() {
376        let dir = temp_dir();
377        let _ = fs::create_dir_all(&dir);
378        let db_path = dir.join("test.db");
379        let wal_path = temp_wal_path(&dir, "empty");
380
381        // Create pager
382        let pager = Pager::open_default(&db_path).unwrap();
383
384        // No WAL file - should succeed with empty result
385        let checkpointer = Checkpointer::default_mode();
386        let result = checkpointer.checkpoint(&pager, &wal_path).unwrap();
387
388        assert_eq!(result.transactions_processed, 0);
389        assert_eq!(result.pages_checkpointed, 0);
390
391        cleanup(&dir);
392    }
393
394    #[test]
395    fn test_checkpoint_committed_transaction() {
396        let dir = temp_dir();
397        let _ = fs::create_dir_all(&dir);
398        let db_path = dir.join("test.db");
399        let wal_path = temp_wal_path(&dir, "committed");
400
401        // Create pager
402        let pager = Pager::open_default(&db_path).unwrap();
403
404        // Allocate a page to get its ID
405        let page = pager.allocate_page(PageType::BTreeLeaf).unwrap();
406        let page_id = page.page_id();
407
408        // Create WAL with a committed transaction
409        {
410            let mut wal_writer = WalWriter::open(&wal_path).unwrap();
411
412            // Begin transaction
413            wal_writer.append(&WalRecord::Begin { tx_id: 1 }).unwrap();
414
415            // Write a page
416            let mut page_data = [0u8; PAGE_SIZE];
417            page_data[0] = 0x42; // Mark with test byte
418            wal_writer
419                .append(&WalRecord::PageWrite {
420                    tx_id: 1,
421                    page_id,
422                    data: page_data.to_vec(),
423                })
424                .unwrap();
425
426            // Commit transaction
427            wal_writer.append(&WalRecord::Commit { tx_id: 1 }).unwrap();
428
429            wal_writer.sync().unwrap();
430        }
431
432        // Checkpoint
433        let checkpointer = Checkpointer::new(CheckpointMode::Full);
434        let result = checkpointer.checkpoint(&pager, &wal_path).unwrap();
435
436        assert_eq!(result.transactions_processed, 1);
437        assert_eq!(result.pages_checkpointed, 1);
438        assert_eq!(result.records_processed, 3);
439
440        // Verify page was written
441        let read_page = pager.read_page(page_id).unwrap();
442        assert_eq!(read_page.as_bytes()[0], 0x42);
443
444        cleanup(&dir);
445    }
446
447    #[test]
448    fn test_checkpoint_aborted_transaction() {
449        let dir = temp_dir();
450        let _ = fs::create_dir_all(&dir);
451        let db_path = dir.join("test.db");
452        let wal_path = temp_wal_path(&dir, "aborted");
453
454        // Create pager
455        let pager = Pager::open_default(&db_path).unwrap();
456
457        // Allocate a page to get its ID
458        let page = pager.allocate_page(PageType::BTreeLeaf).unwrap();
459        let page_id = page.page_id();
460
461        // Create WAL with an aborted transaction
462        {
463            let mut wal_writer = WalWriter::open(&wal_path).unwrap();
464
465            // Begin transaction
466            wal_writer.append(&WalRecord::Begin { tx_id: 1 }).unwrap();
467
468            // Write a page
469            let mut page_data = [0u8; PAGE_SIZE];
470            page_data[0] = 0x42;
471            wal_writer
472                .append(&WalRecord::PageWrite {
473                    tx_id: 1,
474                    page_id,
475                    data: page_data.to_vec(),
476                })
477                .unwrap();
478
479            // Rollback transaction
480            wal_writer
481                .append(&WalRecord::Rollback { tx_id: 1 })
482                .unwrap();
483
484            wal_writer.sync().unwrap();
485        }
486
487        // Checkpoint
488        let checkpointer = Checkpointer::new(CheckpointMode::Full);
489        let result = checkpointer.checkpoint(&pager, &wal_path).unwrap();
490
491        // Aborted transaction should not be checkpointed
492        assert_eq!(result.transactions_processed, 0);
493        assert_eq!(result.pages_checkpointed, 0);
494
495        // Verify page was NOT written (should still be zeros)
496        let read_page = pager.read_page(page_id).unwrap();
497        assert_ne!(read_page.as_bytes()[0], 0x42);
498
499        cleanup(&dir);
500    }
501
502    #[test]
503    fn test_checkpoint_mixed_transactions() {
504        let dir = temp_dir();
505        let _ = fs::create_dir_all(&dir);
506        let db_path = dir.join("test.db");
507        let wal_path = temp_wal_path(&dir, "truncate");
508
509        // Create pager
510        let pager = Pager::open_default(&db_path).unwrap();
511
512        // Allocate pages
513        let page1 = pager.allocate_page(PageType::BTreeLeaf).unwrap();
514        let page2 = pager.allocate_page(PageType::BTreeLeaf).unwrap();
515        let page1_id = page1.page_id();
516        let page2_id = page2.page_id();
517
518        // Create WAL with mixed transactions
519        {
520            let mut wal_writer = WalWriter::open(&wal_path).unwrap();
521
522            // Transaction 1: Committed
523            wal_writer.append(&WalRecord::Begin { tx_id: 1 }).unwrap();
524            let mut page_data1 = [0u8; PAGE_SIZE];
525            page_data1[0] = 0x11;
526            wal_writer
527                .append(&WalRecord::PageWrite {
528                    tx_id: 1,
529                    page_id: page1_id,
530                    data: page_data1.to_vec(),
531                })
532                .unwrap();
533            wal_writer.append(&WalRecord::Commit { tx_id: 1 }).unwrap();
534
535            // Transaction 2: Aborted
536            wal_writer.append(&WalRecord::Begin { tx_id: 2 }).unwrap();
537            let mut page_data2 = [0u8; PAGE_SIZE];
538            page_data2[0] = 0x22;
539            wal_writer
540                .append(&WalRecord::PageWrite {
541                    tx_id: 2,
542                    page_id: page2_id,
543                    data: page_data2.to_vec(),
544                })
545                .unwrap();
546            wal_writer
547                .append(&WalRecord::Rollback { tx_id: 2 })
548                .unwrap();
549
550            // Transaction 3: Committed
551            wal_writer.append(&WalRecord::Begin { tx_id: 3 }).unwrap();
552            let mut page_data3 = [0u8; PAGE_SIZE];
553            page_data3[0] = 0x33;
554            wal_writer
555                .append(&WalRecord::PageWrite {
556                    tx_id: 3,
557                    page_id: page2_id,
558                    data: page_data3.to_vec(),
559                })
560                .unwrap();
561            wal_writer.append(&WalRecord::Commit { tx_id: 3 }).unwrap();
562
563            wal_writer.sync().unwrap();
564        }
565
566        // Checkpoint
567        let checkpointer = Checkpointer::new(CheckpointMode::Full);
568        let result = checkpointer.checkpoint(&pager, &wal_path).unwrap();
569
570        // Only committed transactions (1 and 3) should be processed
571        assert_eq!(result.transactions_processed, 2);
572        assert_eq!(result.max_transaction_id, 3);
573        assert_eq!(result.pages_checkpointed, 2);
574
575        // Verify pages
576        let read_page1 = pager.read_page(page1_id).unwrap();
577        assert_eq!(read_page1.as_bytes()[0], 0x11);
578
579        let read_page2 = pager.read_page(page2_id).unwrap();
580        assert_eq!(read_page2.as_bytes()[0], 0x33); // From tx 3, not tx 2
581
582        cleanup(&dir);
583    }
584
585    #[test]
586    fn test_checkpoint_truncate() {
587        let dir = temp_dir();
588        let _ = fs::create_dir_all(&dir);
589        let db_path = dir.join("test.db");
590        let wal_path = temp_wal_path(&dir, "full-page-images");
591
592        // Create pager
593        let pager = Pager::open_default(&db_path).unwrap();
594
595        // Create WAL with a committed transaction
596        {
597            let mut wal_writer = WalWriter::open(&wal_path).unwrap();
598            wal_writer.append(&WalRecord::Begin { tx_id: 1 }).unwrap();
599            wal_writer.append(&WalRecord::Commit { tx_id: 1 }).unwrap();
600            wal_writer.sync().unwrap();
601        }
602
603        // Checkpoint with truncate
604        let checkpointer = Checkpointer::new(CheckpointMode::Truncate);
605        let result = checkpointer.checkpoint(&pager, &wal_path).unwrap();
606
607        assert!(result.wal_truncated);
608
609        let records: Vec<_> = WalReader::open(&wal_path)
610            .unwrap()
611            .iter()
612            .collect::<Result<Vec<_>, _>>()
613            .unwrap();
614        assert_eq!(records.len(), 1);
615        assert_eq!(
616            records[0].1,
617            WalRecord::Checkpoint {
618                lsn: result.checkpoint_lsn
619            }
620        );
621
622        cleanup(&dir);
623    }
624}