Skip to main content

chronix_engine/wal/
mod.rs

1//! # Write-Ahead Log
2//!
3//! Write-Ahead Log (WAL) for the Chronix time-series database.
4//!
5//! The WAL provides durable, crash-safe persistence of write operations. Every
6//! write is first logged to the WAL before being applied to the memtable.
7//! On crash recovery, the WAL is replayed to reconstruct in-memory state.
8//!
9//! ## Record Format
10//!
11//! Each WAL record is:
12//! ```text
13//! [crc32c: u32][length: u32][sequence_no: u64][record_type: u8][payload_version: u8][payload: [u8]]
14//! ```
15//!
16//! The `record_type` discriminant enables forward-compatible payload
17//! handling.
18//!
19//! ## File Format
20//!
21//! WAL files start with a header:
22//! ```text
23//! [magic: b"CXWL"][version: u16]
24//! ```
25//!
26//! Files are named `wal_{sequence_start}.cxwl` and rotated when they exceed
27//! `max_file_size`.
28
29#![warn(missing_docs)]
30#![deny(unsafe_code)]
31
32mod reader;
33mod sink;
34mod writer;
35
36pub use reader::{replay_all, replay_range, WalReader, WalRecord};
37pub use sink::WalSink;
38pub use writer::WalWriter;
39
40/// WAL file magic bytes.
41pub const WAL_MAGIC: &[u8; 4] = b"CXWL";
42
43/// Current WAL format version.
44pub const WAL_VERSION: u16 = 2;
45
46/// WAL file header size: 4 (magic) + 2 (version) = 6 bytes.
47pub const WAL_HEADER_SIZE: usize = 6;
48
49/// WAL record header size:
50/// 4 (crc) + 4 (length) + 8 (sequence) + 1 (record_type) + 1 (payload_version) = 18 bytes.
51pub const WAL_RECORD_HEADER_SIZE: usize = 18;
52
53/// WAL record types — discriminant for forward-compatible payload handling.
54///
55/// New record types can be added without breaking existing readers as long
56/// as readers skip unknown types gracefully.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[repr(u8)]
59pub enum WalRecordType {
60    /// Single data write (point insert).
61    Data = 0,
62    /// Batch write (multiple points in one atomic record).
63    Batch = 1,
64    /// Schema change (measurement schema registration).
65    Schema = 2,
66    /// Tombstone (series deletion marker).
67    Tombstone = 3,
68}
69
70impl WalRecordType {
71    /// Convert from raw byte, returning `None` for unknown types.
72    #[must_use]
73    pub fn from_u8(val: u8) -> Option<Self> {
74        match val {
75            0 => Some(Self::Data),
76            1 => Some(Self::Batch),
77            2 => Some(Self::Schema),
78            3 => Some(Self::Tombstone),
79            _ => None,
80        }
81    }
82}
83
84/// Current payload serialisation version.
85///
86/// Bumped when the encoding of `Point` / batch payloads changes in a
87/// backwards-incompatible way.  Old readers that see a higher version
88/// can reject the record with a clear error instead of silently
89/// mis-parsing data.
90pub const WAL_PAYLOAD_VERSION: u8 = 1;
91
92/// Magic bytes for LZ4-compressed WAL payloads.
93pub(crate) const WAL_COMPRESS_MAGIC: &[u8; 4] = b"CXLZ";
94
95/// Compress a WAL payload with LZ4.
96///
97/// The compressed payload is prefixed with [`WAL_COMPRESS_MAGIC`] so the
98/// reader can auto-detect compressed vs. uncompressed records.
99pub(crate) fn compress_wal_payload(payload: &[u8]) -> Vec<u8> {
100    let compressed = lz4_flex::compress_prepend_size(payload);
101    let mut result = Vec::with_capacity(4 + compressed.len());
102    result.extend_from_slice(WAL_COMPRESS_MAGIC);
103    result.extend_from_slice(&compressed);
104    result
105}
106
107/// Decompress a WAL payload if it was LZ4-compressed.
108///
109/// Returns the original payload unchanged for non-compressed records
110/// (no `CXLZ` prefix).
111pub(crate) fn decompress_wal_payload(payload: Vec<u8>) -> Result<Vec<u8>, String> {
112    if payload.len() >= 4 && &payload[..4] == WAL_COMPRESS_MAGIC {
113        lz4_flex::decompress_size_prepended(&payload[4..])
114            .map_err(|e| format!("LZ4 decompression failed: {e}"))
115    } else {
116        Ok(payload)
117    }
118}
119
120/// Magic header for batch-framed payloads inside a WAL record.
121pub const BATCH_MAGIC: &[u8; 4] = b"CXBT";
122
123/// Encode multiple payloads into a single batch-framed payload.
124///
125/// Layout:
126/// ```text
127/// [CXBT: 4 bytes]          — batch magic
128/// [count: u32 le]          — number of sub-payloads
129/// for each sub-payload:
130///   [len: u32 le]          — sub-payload length
131///   [data: len bytes]      — sub-payload data
132/// ```
133///
134/// This format is written as a single WAL record so the CRC covers the
135/// entire batch — either all sub-payloads survive or none (crash atomicity).
136#[must_use]
137pub fn encode_batch_payload(payloads: &[&[u8]]) -> Vec<u8> {
138    let total: usize = 4 + 4 + payloads.iter().map(|p| 4 + p.len()).sum::<usize>();
139    let mut buf = Vec::with_capacity(total);
140    buf.extend_from_slice(BATCH_MAGIC);
141    buf.extend_from_slice(&(payloads.len() as u32).to_le_bytes());
142    for p in payloads {
143        buf.extend_from_slice(&(p.len() as u32).to_le_bytes());
144        buf.extend_from_slice(p);
145    }
146    buf
147}
148
149/// Decode a batch-framed payload back into individual sub-payloads.
150///
151/// Returns `None` if the payload does not start with the [`BATCH_MAGIC`]
152/// header (i.e. it is a plain single-record payload).
153#[must_use]
154pub fn decode_batch_payload(data: &[u8]) -> Option<Vec<Vec<u8>>> {
155    if data.len() < 8 || &data[..4] != BATCH_MAGIC {
156        return None;
157    }
158    let count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
159
160    // Sanity-check count against remaining data to prevent OOM
161    // on crafted payloads. Each sub-payload needs at least a 4-byte length prefix.
162    let remaining = data.len() - 8;
163    if count > remaining / 4 {
164        return None; // implausible count
165    }
166
167    let mut offset = 8;
168    let mut payloads = Vec::with_capacity(count);
169    for _ in 0..count {
170        if offset + 4 > data.len() {
171            return None; // truncated
172        }
173        let len = u32::from_le_bytes([
174            data[offset],
175            data[offset + 1],
176            data[offset + 2],
177            data[offset + 3],
178        ]) as usize;
179        offset += 4;
180        if offset + len > data.len() {
181            return None; // truncated
182        }
183        payloads.push(data[offset..offset + len].to_vec());
184        offset += len;
185    }
186    Some(payloads)
187}