Skip to main content

akar_storage/
wal.rs

1//! Write-Ahead Log for crash recovery.
2//!
3//! Logs all write operations (column writes, table inserts, etc.) before
4//! they are applied to the main storage. During checkpoint, the WAL is
5//! flushed to disk and the storage pages are synchronized.
6
7use std::fmt;
8use std::io::{Read, Write};
9use std::path::PathBuf;
10
11/// WAL file magic bytes — identifies a v2 WAL with per-record CRC32 checksums.
12const WAL_MAGIC: &[u8; 4] = b"AKAR";
13/// WAL file version — bumped when the on-disk format changes.
14const WAL_VERSION: u16 = 2;
15
16/// A record in the WAL.
17#[derive(Debug, Clone, PartialEq)]
18pub enum WALRecord {
19    Insert {
20        table_id: u64,
21
22        data: Vec<u8>,
23    },
24    Delete {
25        table_id: u64,
26        row_id: u64,
27    },
28    Update {
29        table_id: u64,
30        row_id: u64,
31        column: u32,
32        data: Vec<u8>,
33    },
34    /// Log an FSM page update (allocation or deallocation)
35    UpdateFsm {
36        page_idx: u64,
37        is_free: bool,
38    },
39    /// Log a write to a column page: (table_id, col_id, page_id, serialized_data).
40    ColumnWrite {
41        table_id: u64,
42        col_id: u32,
43        page_id: u64,
44        data: Vec<u8>,
45    },
46    /// Bulk-copied serialized WAL data from a transaction's LocalWAL.
47    /// The raw bytes are flushed directly to disk during checkpoint.
48    LocalWALData {
49        data: Vec<u8>,
50    },
51    Commit {
52        transaction_id: u64,
53    },
54    Rollback {
55        transaction_id: u64,
56    },
57    Checkpoint,
58    // ── DDL record types (extended for Ladybug-style WAL) ──
59    /// Create a node or rel table.
60    CreateTable {
61        table_id: u64,
62    },
63    /// Drop a table.
64    DropTable {
65        table_id: u64,
66    },
67    /// Alter a table (add/drop/rename column).
68    AlterTable {
69        table_id: u64,
70    },
71    /// Create an index on a table.
72    CreateIndex {
73        table_id: u64,
74    },
75    /// Drop an index on a table.
76    DropIndex {
77        table_id: u64,
78    },
79    /// Create a sequence.
80    CreateSequence {
81        table_id: u64,
82    },
83}
84
85/// Shared sink that SQL write-path operators push typed records into during
86/// execution (P60.2). The connection layer drains it into the transaction's
87/// `LocalWAL` after the query; commit bulk-copies the buffer into the global
88/// WAL. `None` disables logging (e.g. read-only or non-durable contexts).
89pub type WalSink = std::sync::Arc<std::sync::Mutex<Vec<WALRecord>>>;
90
91/// Push an [`WALRecord::Insert`] for a node-table row onto the sink (no-op when
92/// `sink` is `None`). `row` is serialized with the tagged binary format that
93/// recovery deserializes via `deserialize_values_from_bytes` (P60.2).
94pub fn log_insert_record(sink: &Option<WalSink>, table_id: u64, row: &[akar_common::types::Value]) {
95    if let Some(sink) = sink
96        && let Ok(mut buf) = sink.lock()
97    {
98        buf.push(WALRecord::Insert {
99            table_id,
100            data: crate::serialize_values_to_bytes(row),
101        });
102    }
103}
104
105/// Push an [`WALRecord::Insert`] for a rel-table edge onto the sink. The
106/// payload carries `[src, dst, props…]` so recovery can rebuild the adjacency
107/// entry (`RelTable::insert_rel`) — node offsets are stored as `UInt64`
108/// (P60.2).
109pub fn log_rel_insert_record(
110    sink: &Option<WalSink>,
111    table_id: u64,
112    src: u64,
113    dst: u64,
114    props: &[akar_common::types::Value],
115) {
116    use akar_common::types::Value;
117    let mut row = Vec::with_capacity(props.len() + 2);
118    row.push(Value::UInt64(src));
119    row.push(Value::UInt64(dst));
120    row.extend_from_slice(props);
121    log_insert_record(sink, table_id, &row);
122}
123
124/// Push an [`WALRecord::Delete`] onto the sink (no-op when `sink` is `None`).
125pub fn log_delete_record(sink: &Option<WalSink>, table_id: u64, row_id: u64) {
126    if let Some(sink) = sink
127        && let Ok(mut buf) = sink.lock()
128    {
129        buf.push(WALRecord::Delete { table_id, row_id });
130    }
131}
132
133/// Push an [`WALRecord::Update`] for a single cell onto the sink (no-op when
134/// `sink` is `None`).
135pub fn log_update_record(
136    sink: &Option<WalSink>,
137    table_id: u64,
138    row_id: u64,
139    column: u32,
140    value: &akar_common::types::Value,
141) {
142    if let Some(sink) = sink
143        && let Ok(mut buf) = sink.lock()
144    {
145        buf.push(WALRecord::Update {
146            table_id,
147            row_id,
148            column,
149            data: crate::serialize_values_to_bytes(std::slice::from_ref(value)),
150        });
151    }
152}
153
154/// Decode a bulk-copied `LocalWALData` payload back into individual records.
155///
156/// The buffer uses the same tag format as the WAL file itself (written by
157/// `LocalWAL::write_record`); decoding lets recovery replay SQL-path DML that
158/// arrives inside a single blob record (P60.2). Trailing garbage stops the
159/// decode with an error; callers decide whether to skip or fail.
160pub fn decode_wal_buffer(buffer: &[u8]) -> std::io::Result<Vec<WALRecord>> {
161    use akar_common::serialization::Deserialize;
162    let mut cursor = std::io::Cursor::new(buffer);
163    let mut records = Vec::new();
164    while (cursor.position() as usize) < buffer.len() {
165        let mut tag_buf = [0u8; 1];
166        if cursor.read_exact(&mut tag_buf).is_err() {
167            break;
168        }
169        let record = match tag_buf[0] {
170            b'I' => {
171                let table_id = u64::deserialize(&mut cursor)?;
172                let data_len = u32::deserialize(&mut cursor)? as usize;
173                let mut data = vec![0u8; data_len];
174                cursor.read_exact(&mut data)?;
175                WALRecord::Insert { table_id, data }
176            }
177            b'D' => {
178                let table_id = u64::deserialize(&mut cursor)?;
179                let row_id = u64::deserialize(&mut cursor)?;
180                WALRecord::Delete { table_id, row_id }
181            }
182            b'U' => {
183                let table_id = u64::deserialize(&mut cursor)?;
184                let row_id = u64::deserialize(&mut cursor)?;
185                let column = u32::deserialize(&mut cursor)?;
186                let data_len = u32::deserialize(&mut cursor)? as usize;
187                let mut data = vec![0u8; data_len];
188                cursor.read_exact(&mut data)?;
189                WALRecord::Update {
190                    table_id,
191                    row_id,
192                    column,
193                    data,
194                }
195            }
196            b'C' => WALRecord::Commit {
197                transaction_id: u64::deserialize(&mut cursor)?,
198            },
199            // Unknown/nested-blob tags cannot be produced by the current
200            // LocalWAL writer. Stop decoding (the stream is misaligned after
201            // such a byte) but keep what decoded so far — recovery prefers
202            // partial data over failing the whole replay.
203            _ => {
204                tracing::warn!(
205                    "LocalWAL blob: unexpected tag byte 0x{:02x} at offset {}; keeping {} decoded record(s)",
206                    tag_buf[0],
207                    cursor.position(),
208                    records.len()
209                );
210                break;
211            }
212        };
213        records.push(record);
214    }
215    Ok(records)
216}
217
218impl fmt::Display for WALRecord {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        match self {
221            WALRecord::Insert { table_id, data } => {
222                write!(f, "INSERT table={} data_len={}", table_id, data.len())
223            }
224            WALRecord::Delete { table_id, row_id } => {
225                write!(f, "DELETE table={} row={}", table_id, row_id)
226            }
227            WALRecord::Update {
228                table_id,
229                row_id,
230                column,
231                data,
232            } => {
233                write!(
234                    f,
235                    "UPDATE table={} row={} col={} data_len={}",
236                    table_id,
237                    row_id,
238                    column,
239                    data.len()
240                )
241            }
242            WALRecord::UpdateFsm { page_idx, is_free } => {
243                write!(f, "FSM page={} {}", page_idx, if *is_free { "FREE" } else { "ALLOC" })
244            }
245            WALRecord::ColumnWrite {
246                table_id,
247                col_id,
248                page_id,
249                data,
250            } => {
251                write!(
252                    f,
253                    "COLUMN_WRITE table={} col={} page={} data_len={}",
254                    table_id,
255                    col_id,
256                    page_id,
257                    data.len()
258                )
259            }
260            WALRecord::LocalWALData { data } => {
261                write!(f, "LOCAL_WAL data_len={}", data.len())
262            }
263            WALRecord::Commit { transaction_id } => {
264                write!(f, "COMMIT txn={}", transaction_id)
265            }
266            WALRecord::Rollback { transaction_id } => {
267                write!(f, "ROLLBACK txn={}", transaction_id)
268            }
269            WALRecord::Checkpoint => write!(f, "CHECKPOINT"),
270            WALRecord::CreateTable { table_id } => {
271                write!(f, "CREATE_TABLE id={}", table_id)
272            }
273            WALRecord::DropTable { table_id } => {
274                write!(f, "DROP_TABLE id={}", table_id)
275            }
276            WALRecord::AlterTable { table_id } => {
277                write!(f, "ALTER_TABLE id={}", table_id)
278            }
279            WALRecord::CreateIndex { table_id } => {
280                write!(f, "CREATE_INDEX table_id={}", table_id)
281            }
282            WALRecord::DropIndex { table_id } => {
283                write!(f, "DROP_INDEX table_id={}", table_id)
284            }
285            WALRecord::CreateSequence { table_id } => {
286                write!(f, "CREATE_SEQUENCE id={}", table_id)
287            }
288        }
289    }
290}
291
292/// Write-Ahead Log for durability.
293///
294/// Uses an append-only on-disk format: each `flush_to_disk()` call serializes
295/// only the **new** records since the last flush and appends them to the file.
296/// The full file is only rewritten during `clear()` (checkpoint).
297pub struct WAL {
298    path: PathBuf,
299    records: Vec<WALRecord>,
300    /// Number of records that have been flushed to disk.
301    flushed_count: usize,
302    /// Whether the next flush should write the file header (true after `clear()`).
303    needs_header: bool,
304    total_size: usize,
305    is_dirty: bool,
306}
307
308impl WAL {
309    pub fn new(path: PathBuf) -> Self {
310        Self {
311            path,
312            records: Vec::new(),
313            flushed_count: 0,
314            needs_header: true,
315            total_size: 0,
316            is_dirty: false,
317        }
318    }
319
320    pub fn path(&self) -> &PathBuf {
321        &self.path
322    }
323
324    pub fn append(&mut self, record: WALRecord) {
325        let size = match &record {
326            WALRecord::Insert { data, .. } => data.len(),
327            WALRecord::Update { data, .. } => data.len(),
328            WALRecord::UpdateFsm { .. } => 8 + 1, // u64 + bool
329            WALRecord::ColumnWrite { data, .. } => data.len(),
330            WALRecord::LocalWALData { data } => data.len(),
331            // DDL variants: each has a u64 table_id
332            WALRecord::CreateTable { .. }
333            | WALRecord::DropTable { .. }
334            | WALRecord::AlterTable { .. }
335            | WALRecord::CreateIndex { .. }
336            | WALRecord::DropIndex { .. }
337            | WALRecord::CreateSequence { .. } => 8,
338            _ => 8,
339        };
340        self.total_size += size;
341        self.is_dirty = true;
342        self.records.push(record);
343    }
344
345    /// Log a column page write before it is applied to the BufferManager.
346    pub fn log_column_write(&mut self, table_id: u64, col_id: u32, page_id: u64, data: &[u8]) {
347        self.append(WALRecord::ColumnWrite {
348            table_id,
349            col_id,
350            page_id,
351            data: data.to_vec(),
352        });
353    }
354
355    /// Bulk-copy a `LocalWAL`'s serialized buffer into this global WAL.
356    ///
357    /// Called during commit path: the transaction's `LocalWAL` has already
358    /// been serialized to a byte buffer; this method appends the raw bytes
359    /// directly to the in-memory record list.
360    ///
361    /// The caller (`StorageManager::commit_transaction()`) is responsible
362    /// for holding the `Arc<Mutex<WAL>>` lock to serialize concurrent calls.
363    pub fn write_raw_buffer(&mut self, local_wal_buffer: &[u8]) {
364        if local_wal_buffer.is_empty() {
365            return;
366        }
367        self.total_size += local_wal_buffer.len();
368        self.is_dirty = true;
369        self.records.push(WALRecord::LocalWALData {
370            data: local_wal_buffer.to_vec(),
371        });
372    }
373
374    pub fn records(&self) -> &[WALRecord] {
375        &self.records
376    }
377    /// Clear all in-memory records and truncate the WAL file on disk.
378    ///
379    /// Called during checkpoint after dirty pages have been flushed — at this
380    /// point the WAL data is durable in the main DB files and can be discarded.
381    pub fn clear(&mut self) -> std::io::Result<()> {
382        self.records.clear();
383        self.flushed_count = 0;
384        self.total_size = 0;
385        self.is_dirty = false;
386        self.needs_header = true;
387        // Truncate the WAL file to empty so the next flush starts fresh.
388        std::fs::OpenOptions::new()
389            .create(true)
390            .write(true)
391            .truncate(true)
392            .open(&self.path)?
393            .sync_data()
394    }
395    pub fn len(&self) -> usize {
396        self.records.len()
397    }
398    pub fn is_empty(&self) -> bool {
399        self.records.is_empty()
400    }
401    pub fn total_size(&self) -> usize {
402        self.total_size
403    }
404    pub fn is_dirty(&self) -> bool {
405        self.is_dirty
406    }
407
408    /// Replay the WAL to recover state after a crash.
409    pub fn replay<F>(&self, mut apply: F) -> std::io::Result<()>
410    where
411        F: FnMut(&WALRecord) -> std::io::Result<()>,
412    {
413        for record in &self.records {
414            apply(record)?;
415        }
416        Ok(())
417    }
418
419    /// Append-only flush: serialize only records not yet on disk.
420    ///
421    /// Instead of rewriting the entire WAL file (O(n) per flush → O(n²) total),
422    /// this method serializes only the **new** records since the last flush and
423    /// appends them to the existing file. The header is written once on the
424    /// first flush after a `clear()` (checkpoint).
425    ///
426    /// Crash safety: CRC32 per record detects partial appends. A partial final
427    /// record is silently skipped on recovery.
428    ///
429    /// ## On-disk format (v2)
430    ///
431    /// ```text
432    /// ┌──────────────────────────────────────────────┐
433    /// │ Header: "AKAR" (4 bytes) + version (u16 LE) │
434    /// ├──────────────────────────────────────────────┤
435    /// │ Per record:                                  │
436    /// │   CRC32 (u32 LE) of [tag .. payload]        │
437    /// │   tag (1 byte)                               │
438    /// │   payload (variable)                         │
439    /// └──────────────────────────────────────────────┘
440    /// ```
441    pub fn flush_to_disk(&mut self) -> std::io::Result<()> {
442        if self.flushed_count >= self.records.len() {
443            return Ok(()); // Nothing new to write
444        }
445
446        let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
447
448        // Write header on fresh WAL (after clear() or first ever write).
449        if self.needs_header {
450            file.write_all(WAL_MAGIC)?;
451            file.write_all(&WAL_VERSION.to_le_bytes())?;
452            self.needs_header = false;
453        }
454
455        // Append only records not yet on disk.
456        let new_records = &self.records[self.flushed_count..];
457        Self::append_records_to_file(&mut file, new_records)?;
458
459        self.flushed_count = self.records.len();
460        file.sync_data()
461    }
462
463    /// Serialize one WAL record + CRC32 and write it to the file.
464    fn append_records_to_file(file: &mut std::fs::File, records: &[WALRecord]) -> std::io::Result<()> {
465        use akar_common::serialization::Serialize;
466        let mut crc_buf = [0u8; 4];
467        for record in records {
468            let mut payload = Vec::new();
469            match record {
470                WALRecord::Insert { table_id, data } => {
471                    payload.write_all(b"I")?;
472                    table_id.serialize(&mut payload)?;
473                    (data.len() as u32).serialize(&mut payload)?;
474                    payload.write_all(data)?;
475                }
476                WALRecord::Delete { table_id, row_id } => {
477                    payload.write_all(b"D")?;
478                    table_id.serialize(&mut payload)?;
479                    row_id.serialize(&mut payload)?;
480                }
481                WALRecord::Update {
482                    table_id,
483                    row_id,
484                    column,
485                    data,
486                } => {
487                    payload.write_all(b"U")?;
488                    table_id.serialize(&mut payload)?;
489                    row_id.serialize(&mut payload)?;
490                    column.serialize(&mut payload)?;
491                    (data.len() as u32).serialize(&mut payload)?;
492                    payload.write_all(data)?;
493                }
494                WALRecord::UpdateFsm { page_idx, is_free } => {
495                    payload.write_all(b"F")?;
496                    page_idx.serialize(&mut payload)?;
497                    let is_free_u8: u8 = if *is_free { 1 } else { 0 };
498                    is_free_u8.serialize(&mut payload)?;
499                }
500                WALRecord::ColumnWrite {
501                    table_id,
502                    col_id,
503                    page_id,
504                    data,
505                } => {
506                    payload.write_all(b"W")?;
507                    table_id.serialize(&mut payload)?;
508                    col_id.serialize(&mut payload)?;
509                    page_id.serialize(&mut payload)?;
510                    (data.len() as u32).serialize(&mut payload)?;
511                    payload.write_all(data)?;
512                }
513                WALRecord::LocalWALData { data } => {
514                    payload.write_all(b"L")?;
515                    (data.len() as u32).serialize(&mut payload)?;
516                    payload.write_all(data)?;
517                }
518                WALRecord::Commit { transaction_id } => {
519                    payload.write_all(b"C")?;
520                    transaction_id.serialize(&mut payload)?;
521                }
522                WALRecord::Rollback { transaction_id } => {
523                    payload.write_all(b"R")?;
524                    transaction_id.serialize(&mut payload)?;
525                }
526                WALRecord::Checkpoint => {
527                    payload.write_all(b"K")?;
528                }
529                // ── DDL record types ──
530                WALRecord::CreateTable { table_id } => {
531                    payload.write_all(b"T")?;
532                    table_id.serialize(&mut payload)?;
533                }
534                WALRecord::DropTable { table_id } => {
535                    payload.write_all(b"A")?;
536                    table_id.serialize(&mut payload)?;
537                }
538                WALRecord::AlterTable { table_id } => {
539                    payload.write_all(b"M")?;
540                    table_id.serialize(&mut payload)?;
541                }
542                WALRecord::CreateIndex { table_id } => {
543                    payload.write_all(b"N")?;
544                    table_id.serialize(&mut payload)?;
545                }
546                WALRecord::DropIndex { table_id } => {
547                    payload.write_all(b"X")?;
548                    table_id.serialize(&mut payload)?;
549                }
550                WALRecord::CreateSequence { table_id } => {
551                    payload.write_all(b"Q")?;
552                    table_id.serialize(&mut payload)?;
553                }
554            }
555            let checksum = crc32fast::hash(&payload);
556            crc_buf.copy_from_slice(&checksum.to_le_bytes());
557            file.write_all(&crc_buf)?;
558            file.write_all(&payload)?;
559        }
560        Ok(())
561    }
562
563    /// Load WAL records from disk.
564    ///
565    /// Supports both v1 (no checksums) and v2 (CRC32 per record) formats.
566    /// Records with invalid checksums are silently skipped with a warning.
567    pub fn load_from_disk(&mut self) -> std::io::Result<()> {
568        use std::io::{BufReader, Read};
569
570        if !self.path.exists() {
571            return Ok(()); // Nothing to recover
572        }
573
574        let file = std::fs::File::open(&self.path)?;
575        let mut reader = BufReader::new(file);
576
577        // Read all data into a buffer for easier parsing
578        let mut buffer = Vec::new();
579        reader.read_to_end(&mut buffer)?;
580
581        if buffer.is_empty() {
582            return Ok(());
583        }
584
585        // Detect format: v2 has "AKAR" magic header
586        let (is_v2, cursor_pos) = if buffer.len() >= 6 && &buffer[..4] == WAL_MAGIC {
587            let version = u16::from_le_bytes([buffer[4], buffer[5]]);
588            if version >= 2 { (true, 6usize) } else { (false, 0usize) }
589        } else {
590            (false, 0usize)
591        };
592
593        let mut cursor = std::io::Cursor::new(&buffer[cursor_pos..]);
594        let mut skipped = 0u32;
595
596        if is_v2 {
597            // v2: each record is CRC32 (4 bytes) + tag + payload
598            while cursor.position() < (buffer.len() - cursor_pos) as u64 {
599                // Read CRC32
600                let mut crc_bytes = [0u8; 4];
601                if cursor.read_exact(&mut crc_bytes).is_err() {
602                    break;
603                }
604                let expected_crc = u32::from_le_bytes(crc_bytes);
605
606                // Read the rest of the record into a temp buffer to compute CRC
607                let record_start = cursor.position() as usize;
608                let record_data = &buffer[(cursor_pos + record_start)..];
609                if record_data.is_empty() {
610                    break;
611                }
612
613                // We need to know how long this record is to compute CRC.
614                // Read tag first to determine length.
615                let tag = record_data[0];
616                let payload_len = match tag {
617                    b'I' => {
618                        // table_id(u64) + data_len(u32) + data
619                        if record_data.len() < 13 {
620                            skipped += 1;
621                            break;
622                        }
623                        let data_len = u32::from_le_bytes(record_data[9..13].try_into().unwrap()) as usize;
624                        1 + 8 + 4 + data_len // tag + u64 + u32 + data
625                    }
626                    b'D' => 1 + 8 + 8, // tag + table_id + row_id
627                    b'U' => {
628                        // Layout: tag(1) + table_id(8) + row_id(8) + column(4) + data_len(4) + data
629                        if record_data.len() < 25 {
630                            skipped += 1;
631                            break;
632                        }
633                        let data_len = u32::from_le_bytes(record_data[21..25].try_into().unwrap()) as usize;
634                        1 + 8 + 8 + 4 + 4 + data_len
635                    }
636                    b'F' => 1 + 8 + 1, // tag + page_idx + is_free
637                    b'W' => {
638                        // Layout: tag(1) + table_id(8) + col_id(4) + page_id(8) + data_len(4) + data
639                        if record_data.len() < 25 {
640                            skipped += 1;
641                            break;
642                        }
643                        let data_len = u32::from_le_bytes(record_data[21..25].try_into().unwrap()) as usize;
644                        1 + 8 + 4 + 8 + 4 + data_len
645                    }
646                    b'L' => {
647                        if record_data.len() < 5 {
648                            skipped += 1;
649                            break;
650                        }
651                        let data_len = u32::from_le_bytes(record_data[1..5].try_into().unwrap()) as usize;
652                        1 + 4 + data_len
653                    }
654                    b'C' => 1 + 8, // tag + transaction_id
655                    b'R' => 1 + 8,
656                    b'K' => 1, // checkpoint — tag only
657                    // DDL types: tag + u64
658                    b'T' | b'A' | b'M' | b'N' | b'X' | b'Q' => 1 + 8,
659                    _ => {
660                        // Unknown tag — skip rest of file
661                        skipped += 1;
662                        break;
663                    }
664                };
665
666                if payload_len > record_data.len() {
667                    skipped += 1;
668                    break;
669                }
670
671                // Compute CRC over tag+payload
672                let computed_crc = crc32fast::hash(&record_data[..payload_len]);
673
674                if computed_crc != expected_crc {
675                    // Checksum mismatch — skip this record
676                    skipped += 1;
677                    cursor.set_position(cursor.position() + payload_len as u64);
678                    continue;
679                }
680
681                // CRC valid — parse the record
682                cursor.set_position(cursor.position() + payload_len as u64);
683                let mut inner = std::io::Cursor::new(&record_data[..payload_len]);
684                self.parse_record(&mut inner)?;
685            }
686        } else {
687            // v1: no checksums, original format
688            self.parse_v1_records(&mut cursor)?;
689        }
690
691        if skipped > 0 {
692            tracing::warn!(
693                "WAL: skipped {} corrupted record(s) during recovery (v{})",
694                skipped,
695                if is_v2 { 2 } else { 1 }
696            );
697        }
698
699        // All loaded records are already on disk — nothing to re-flush.
700        self.flushed_count = self.records.len();
701        self.needs_header = false;
702        self.total_size = buffer.len();
703        self.is_dirty = !self.records.is_empty();
704        Ok(())
705    }
706
707    /// Parse a single WAL record from the cursor (v1 format — no checksums).
708    fn parse_record(&mut self, cursor: &mut std::io::Cursor<&[u8]>) -> std::io::Result<()> {
709        use akar_common::serialization::Deserialize;
710        let mut tag_buf = [0u8; 1];
711        if cursor.read_exact(&mut tag_buf).is_err() {
712            return Ok(());
713        }
714        let tag = tag_buf[0];
715        match tag {
716            b'I' => {
717                let table_id = u64::deserialize(cursor)?;
718                let data_len = u32::deserialize(cursor)? as usize;
719                let mut data = vec![0u8; data_len];
720                cursor.read_exact(&mut data)?;
721                self.records.push(WALRecord::Insert { table_id, data });
722            }
723            b'D' => {
724                let table_id = u64::deserialize(cursor)?;
725                let row_id = u64::deserialize(cursor)?;
726                self.records.push(WALRecord::Delete { table_id, row_id });
727            }
728            b'U' => {
729                let table_id = u64::deserialize(cursor)?;
730                let row_id = u64::deserialize(cursor)?;
731                let column = u32::deserialize(cursor)?;
732                let data_len = u32::deserialize(cursor)? as usize;
733                let mut data = vec![0u8; data_len];
734                cursor.read_exact(&mut data)?;
735                self.records.push(WALRecord::Update {
736                    table_id,
737                    row_id,
738                    column,
739                    data,
740                });
741            }
742            b'F' => {
743                let page_idx = u64::deserialize(cursor)?;
744                let is_free_u8 = u8::deserialize(cursor)?;
745                self.records.push(WALRecord::UpdateFsm {
746                    page_idx,
747                    is_free: is_free_u8 != 0,
748                });
749            }
750            b'W' => {
751                let table_id = u64::deserialize(cursor)?;
752                let col_id = u32::deserialize(cursor)?;
753                let page_id = u64::deserialize(cursor)?;
754                let data_len = u32::deserialize(cursor)? as usize;
755                let mut data = vec![0u8; data_len];
756                cursor.read_exact(&mut data)?;
757                self.records.push(WALRecord::ColumnWrite {
758                    table_id,
759                    col_id,
760                    page_id,
761                    data,
762                });
763            }
764            b'C' => {
765                let transaction_id = u64::deserialize(cursor)?;
766                self.records.push(WALRecord::Commit { transaction_id });
767            }
768            b'R' => {
769                let transaction_id = u64::deserialize(cursor)?;
770                self.records.push(WALRecord::Rollback { transaction_id });
771            }
772            b'L' => {
773                let data_len = u32::deserialize(cursor)? as usize;
774                let mut data = vec![0u8; data_len];
775                cursor.read_exact(&mut data)?;
776                self.records.push(WALRecord::LocalWALData { data });
777            }
778            b'K' => {
779                self.records.push(WALRecord::Checkpoint);
780            }
781            // ── DDL record types ──
782            b'T' => {
783                let table_id = u64::deserialize(cursor)?;
784                self.records.push(WALRecord::CreateTable { table_id });
785            }
786            b'A' => {
787                let table_id = u64::deserialize(cursor)?;
788                self.records.push(WALRecord::DropTable { table_id });
789            }
790            b'M' => {
791                let table_id = u64::deserialize(cursor)?;
792                self.records.push(WALRecord::AlterTable { table_id });
793            }
794            b'N' => {
795                let table_id = u64::deserialize(cursor)?;
796                self.records.push(WALRecord::CreateIndex { table_id });
797            }
798            b'X' => {
799                let table_id = u64::deserialize(cursor)?;
800                self.records.push(WALRecord::DropIndex { table_id });
801            }
802            b'Q' => {
803                let table_id = u64::deserialize(cursor)?;
804                self.records.push(WALRecord::CreateSequence { table_id });
805            }
806            _ => {
807                return Err(std::io::Error::new(
808                    std::io::ErrorKind::InvalidData,
809                    format!("WAL: unknown record tag byte: 0x{:02x}", tag),
810                ));
811            }
812        }
813        Ok(())
814    }
815
816    /// Parse v1 records (legacy format without checksums).
817    fn parse_v1_records(&mut self, cursor: &mut std::io::Cursor<&[u8]>) -> std::io::Result<()> {
818        while cursor.position() < cursor.get_ref().len() as u64 {
819            self.parse_record(cursor)?;
820        }
821        Ok(())
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn test_decode_wal_buffer_roundtrip() {
831        use crate::local_wal::LocalWAL;
832        use akar_common::types::Value;
833
834        // Serialize typed records through the LocalWAL (exactly what commit
835        // bulk-copies), then decode them back (what recovery does, P60.2).
836        let mut lwal = LocalWAL::new();
837        lwal.log_insert(
838            7,
839            crate::serialize_values_to_bytes(&[Value::Int64(42), Value::String("hi".into())]),
840        );
841        lwal.log_delete(7, 3);
842        lwal.log_update(7, 5, 1, crate::serialize_values_to_bytes(&[Value::Double(2.5)]));
843
844        let decoded = decode_wal_buffer(lwal.buffer()).unwrap();
845        assert_eq!(decoded.len(), 3);
846        match &decoded[0] {
847            WALRecord::Insert { table_id, data } => {
848                assert_eq!(*table_id, 7);
849                let values = crate::deserialize_values_from_bytes(data, 2);
850                assert_eq!(values[0], Value::Int64(42));
851                assert_eq!(values[1], Value::String("hi".into()));
852            }
853            other => panic!("expected Insert, got {other:?}"),
854        }
855        assert_eq!(decoded[1], WALRecord::Delete { table_id: 7, row_id: 3 });
856        match &decoded[2] {
857            WALRecord::Update {
858                table_id,
859                row_id,
860                column,
861                data,
862            } => {
863                assert_eq!(*table_id, 7);
864                assert_eq!(*row_id, 5);
865                assert_eq!(*column, 1);
866                assert_eq!(crate::deserialize_values_from_bytes(data, 1), vec![Value::Double(2.5)]);
867            }
868            other => panic!("expected Update, got {other:?}"),
869        }
870    }
871
872    #[test]
873    fn test_decode_wal_buffer_empty_and_unknown_tag() {
874        // Empty buffer decodes to no records.
875        assert!(decode_wal_buffer(&[]).unwrap().is_empty());
876
877        // An unknown tag stops decoding but keeps prior records instead of
878        // failing the whole replay.
879        let mut buf = Vec::new();
880        let mut lwal = crate::local_wal::LocalWAL::new();
881        lwal.log_insert(1, vec![9]);
882        buf.extend_from_slice(lwal.buffer());
883        buf.push(b'Z'); // unknown tag
884        buf.push(0xFF); // garbage that must not be parsed as a record
885
886        let decoded = decode_wal_buffer(&buf).unwrap();
887        assert_eq!(decoded.len(), 1);
888        assert_eq!(
889            decoded[0],
890            WALRecord::Insert {
891                table_id: 1,
892                data: vec![9]
893            }
894        );
895    }
896
897    #[test]
898    fn test_serialize_values_to_bytes_roundtrip() {
899        use akar_common::types::Value;
900        let row = vec![
901            Value::Null,
902            Value::Bool(true),
903            Value::Int64(-17),
904            Value::Double(1.25),
905            Value::String("akar".into()),
906        ];
907        let bytes = crate::serialize_values_to_bytes(&row);
908        assert_eq!(
909            crate::deserialize_values_from_bytes(&bytes, row.len()),
910            row,
911            "serialize_values_to_bytes must roundtrip through the recovery deserializer"
912        );
913    }
914
915    #[test]
916    fn test_log_helpers_noop_without_sink() {
917        use akar_common::types::Value;
918        let none: Option<WalSink> = None;
919        // None sink must not panic and must not record anything.
920        log_insert_record(&none, 1, &[Value::Int64(1)]);
921        log_delete_record(&none, 1, 0);
922        log_update_record(&none, 1, 0, 0, &Value::Null);
923        log_rel_insert_record(&none, 1, 0, 1, &[]);
924    }
925
926    #[test]
927    fn test_wal_append_clear() {
928        let dir = tempfile::tempdir().unwrap();
929        let mut wal = WAL::new(dir.path().join("wal.log"));
930        assert!(wal.is_empty());
931        wal.append(WALRecord::Insert {
932            table_id: 1,
933            data: vec![1, 2, 3],
934        });
935        assert_eq!(wal.len(), 1);
936        assert!(wal.is_dirty());
937        wal.append(WALRecord::Commit { transaction_id: 42 });
938        assert_eq!(wal.len(), 2);
939        wal.clear().unwrap();
940        assert!(wal.is_empty());
941        assert!(!wal.is_dirty());
942    }
943
944    #[test]
945    fn test_wal_replay() {
946        let dir = tempfile::tempdir().unwrap();
947        let mut wal = WAL::new(dir.path().join("wal.log"));
948        wal.append(WALRecord::Insert {
949            table_id: 1,
950            data: vec![10, 20],
951        });
952        wal.append(WALRecord::Commit { transaction_id: 1 });
953        let mut count = 0;
954        wal.replay(|record| {
955            count += 1;
956            if let WALRecord::Insert { table_id, data } = record {
957                assert_eq!(*table_id, 1);
958                assert_eq!(data, &[10, 20]);
959            }
960            Ok(())
961        })
962        .unwrap();
963        assert_eq!(count, 2);
964    }
965
966    #[test]
967    fn test_wal_flush_to_disk() {
968        let dir = tempfile::tempdir().unwrap();
969        let wal_path = dir.path().join("wal.log");
970        let mut wal = WAL::new(wal_path.clone());
971        wal.append(WALRecord::Insert {
972            table_id: 1,
973            data: vec![1, 2, 3],
974        });
975        wal.flush_to_disk().unwrap();
976        assert!(wal_path.exists());
977        assert!(std::fs::metadata(&wal_path).unwrap().len() > 0);
978    }
979
980    #[test]
981    fn test_wal_checksum_roundtrip() {
982        let dir = tempfile::tempdir().unwrap();
983        let wal_path = dir.path().join("wal.log");
984        let mut wal = WAL::new(wal_path.clone());
985        wal.append(WALRecord::Insert {
986            table_id: 42,
987            data: vec![10, 20, 30, 40, 50],
988        });
989        wal.append(WALRecord::Delete {
990            table_id: 7,
991            row_id: 99,
992        });
993        wal.append(WALRecord::Commit { transaction_id: 1 });
994        wal.flush_to_disk().unwrap();
995
996        // Verify the file starts with AKAR magic
997        let bytes = std::fs::read(&wal_path).unwrap();
998        assert_eq!(&bytes[..4], b"AKAR");
999        assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 2);
1000
1001        // Reload and verify all records recovered
1002        let mut wal2 = WAL::new(wal_path);
1003        wal2.load_from_disk().unwrap();
1004        assert_eq!(wal2.len(), 3);
1005        assert!(matches!(
1006            &wal2.records()[0],
1007            WALRecord::Insert { table_id: 42, data } if data == &vec![10, 20, 30, 40, 50]
1008        ));
1009        assert!(matches!(
1010            &wal2.records()[1],
1011            WALRecord::Delete {
1012                table_id: 7,
1013                row_id: 99
1014            }
1015        ));
1016        assert!(matches!(&wal2.records()[2], WALRecord::Commit { transaction_id: 1 }));
1017    }
1018
1019    #[test]
1020    fn test_wal_corrupted_record_skipped() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let wal_path = dir.path().join("wal.log");
1023
1024        // Write two valid records
1025        let mut wal = WAL::new(wal_path.clone());
1026        wal.append(WALRecord::Insert {
1027            table_id: 1,
1028            data: vec![100, 200],
1029        });
1030        wal.append(WALRecord::Commit { transaction_id: 5 });
1031        wal.flush_to_disk().unwrap();
1032
1033        // Corrupt one byte in the file (flip a byte in the first record's payload)
1034        let mut bytes = std::fs::read(&wal_path).unwrap();
1035        // Header is 6 bytes, first CRC is 4 bytes, then tag (1 byte), then table_id bytes
1036        // Corrupt the data payload (after table_id + data_len)
1037        let corrupt_offset = 6 + 4 + 1 + 8 + 4 + 1; // header + crc + tag + table_id + data_len + first data byte
1038        if corrupt_offset < bytes.len() {
1039            bytes[corrupt_offset] ^= 0xFF;
1040        }
1041        std::fs::write(&wal_path, &bytes).unwrap();
1042
1043        // Reload — corrupted record should be skipped, second should survive
1044        let mut wal2 = WAL::new(wal_path);
1045        let _result = wal2.load_from_disk();
1046        // The corrupted Insert is skipped, Commit may or may not survive
1047        // depending on whether the corruption affected its CRC range
1048        // At minimum, no panic and the WAL loads
1049    }
1050
1051    #[test]
1052    fn test_wal_v1_backward_compat() {
1053        // Simulate a v1 WAL file (no header, no checksums)
1054        use akar_common::serialization::Serialize;
1055        let dir = tempfile::tempdir().unwrap();
1056        let wal_path = dir.path().join("wal.log");
1057        let mut file = std::fs::File::create(&wal_path).unwrap();
1058
1059        // Write a v1 Insert record: tag "I" + table_id(u64) + data_len(u32) + data
1060        file.write_all(b"I").unwrap();
1061        42u64.serialize(&mut file).unwrap();
1062        (3u32).serialize(&mut file).unwrap();
1063        file.write_all(&[1, 2, 3]).unwrap();
1064        // Write a v1 Commit record
1065        file.write_all(b"C").unwrap();
1066        1u64.serialize(&mut file).unwrap();
1067        drop(file);
1068
1069        // Load — should parse as v1 (no magic header)
1070        let mut wal = WAL::new(wal_path);
1071        wal.load_from_disk().unwrap();
1072        assert_eq!(wal.len(), 2);
1073        assert!(matches!(&wal.records()[0], WALRecord::Insert { table_id: 42, .. }));
1074        assert!(matches!(&wal.records()[1], WALRecord::Commit { transaction_id: 1 }));
1075    }
1076
1077    #[test]
1078    fn test_wal_all_record_types_checksum() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let wal_path = dir.path().join("wal.log");
1081        let mut wal = WAL::new(wal_path.clone());
1082
1083        wal.append(WALRecord::Insert {
1084            table_id: 1,
1085            data: vec![1],
1086        });
1087        wal.append(WALRecord::Delete { table_id: 2, row_id: 3 });
1088        wal.append(WALRecord::Update {
1089            table_id: 4,
1090            row_id: 5,
1091            column: 6,
1092            data: vec![7, 8],
1093        });
1094        wal.append(WALRecord::UpdateFsm {
1095            page_idx: 100,
1096            is_free: true,
1097        });
1098        wal.append(WALRecord::ColumnWrite {
1099            table_id: 10,
1100            col_id: 11,
1101            page_id: 12,
1102            data: vec![99],
1103        });
1104        wal.append(WALRecord::LocalWALData { data: vec![10, 11, 12] });
1105        wal.append(WALRecord::Commit { transaction_id: 50 });
1106        wal.append(WALRecord::Rollback { transaction_id: 51 });
1107        wal.append(WALRecord::Checkpoint);
1108        wal.append(WALRecord::CreateTable { table_id: 20 });
1109        wal.append(WALRecord::DropTable { table_id: 21 });
1110        wal.append(WALRecord::AlterTable { table_id: 22 });
1111        wal.append(WALRecord::CreateIndex { table_id: 23 });
1112        wal.append(WALRecord::DropIndex { table_id: 24 });
1113        wal.append(WALRecord::CreateSequence { table_id: 25 });
1114
1115        wal.flush_to_disk().unwrap();
1116
1117        let mut wal2 = WAL::new(wal_path);
1118        wal2.load_from_disk().unwrap();
1119        assert_eq!(wal2.len(), 15);
1120    }
1121}