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 — 1, the first that ships.
44///
45/// Checked for equality by the reader. The number counts released formats,
46/// not the times the layout changed before anyone could see one.
47pub const WAL_VERSION: u16 = 1;
48
49/// WAL file header size: 4 (magic) + 2 (version) = 6 bytes.
50pub const WAL_HEADER_SIZE: usize = 6;
51
52/// WAL record header size:
53/// 4 (crc) + 4 (length) + 8 (sequence) + 1 (record_type) + 1 (payload_version) = 18 bytes.
54pub const WAL_RECORD_HEADER_SIZE: usize = 18;
55
56/// WAL record types — discriminant for forward-compatible payload handling.
57///
58/// New record types can be added without breaking existing readers as long
59/// as readers skip unknown types gracefully.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[repr(u8)]
62pub enum WalRecordType {
63 /// Single data write (point insert).
64 Data = 0,
65 /// Batch write (multiple points in one atomic record).
66 Batch = 1,
67 /// Schema change (measurement schema registration).
68 Schema = 2,
69 /// Tombstone (series deletion marker).
70 Tombstone = 3,
71}
72
73impl WalRecordType {
74 /// Convert from raw byte, returning `None` for unknown types.
75 #[must_use]
76 pub fn from_u8(val: u8) -> Option<Self> {
77 match val {
78 0 => Some(Self::Data),
79 1 => Some(Self::Batch),
80 2 => Some(Self::Schema),
81 3 => Some(Self::Tombstone),
82 _ => None,
83 }
84 }
85}
86
87/// Current payload serialisation version.
88///
89/// Bumped when the encoding of `Point` / batch payloads changes in a
90/// backwards-incompatible way. Old readers that see a higher version
91/// can reject the record with a clear error instead of silently
92/// mis-parsing data.
93pub const WAL_PAYLOAD_VERSION: u8 = 1;
94
95/// Magic bytes for LZ4-compressed WAL payloads.
96pub(crate) const WAL_COMPRESS_MAGIC: &[u8; 4] = b"CXLZ";
97
98/// Compress a WAL payload with LZ4.
99///
100/// The compressed payload is prefixed with [`WAL_COMPRESS_MAGIC`] so the
101/// reader can auto-detect compressed vs. uncompressed records.
102pub(crate) fn compress_wal_payload(payload: &[u8]) -> Vec<u8> {
103 let compressed = lz4_flex::compress_prepend_size(payload);
104 let mut result = Vec::with_capacity(4 + compressed.len());
105 result.extend_from_slice(WAL_COMPRESS_MAGIC);
106 result.extend_from_slice(&compressed);
107 result
108}
109
110/// Decompress a WAL payload if it was LZ4-compressed.
111///
112/// Returns the original payload unchanged for non-compressed records
113/// (no `CXLZ` prefix).
114pub(crate) fn decompress_wal_payload(payload: Vec<u8>) -> Result<Vec<u8>, String> {
115 if payload.len() >= 4 && &payload[..4] == WAL_COMPRESS_MAGIC {
116 lz4_flex::decompress_size_prepended(&payload[4..])
117 .map_err(|e| format!("LZ4 decompression failed: {e}"))
118 } else {
119 Ok(payload)
120 }
121}
122
123/// Magic header for batch-framed payloads inside a WAL record.
124pub const BATCH_MAGIC: &[u8; 4] = b"CXBT";
125
126/// Encode multiple payloads into a single batch-framed payload.
127///
128/// Layout:
129/// ```text
130/// [CXBT: 4 bytes] — batch magic
131/// [count: u32 le] — number of sub-payloads
132/// for each sub-payload:
133/// [len: u32 le] — sub-payload length
134/// [data: len bytes] — sub-payload data
135/// ```
136///
137/// This format is written as a single WAL record so the CRC covers the
138/// entire batch — either all sub-payloads survive or none (crash atomicity).
139#[must_use]
140pub fn encode_batch_payload(payloads: &[&[u8]]) -> Vec<u8> {
141 let total: usize = 4 + 4 + payloads.iter().map(|p| 4 + p.len()).sum::<usize>();
142 let mut buf = Vec::with_capacity(total);
143 buf.extend_from_slice(BATCH_MAGIC);
144 buf.extend_from_slice(&(payloads.len() as u32).to_le_bytes());
145 for p in payloads {
146 buf.extend_from_slice(&(p.len() as u32).to_le_bytes());
147 buf.extend_from_slice(p);
148 }
149 buf
150}
151
152/// Decode a batch-framed payload back into individual sub-payloads.
153///
154/// Returns `None` if the payload does not start with the [`BATCH_MAGIC`]
155/// header (i.e. it is a plain single-record payload).
156#[must_use]
157pub fn decode_batch_payload(data: &[u8]) -> Option<Vec<Vec<u8>>> {
158 if data.len() < 8 || &data[..4] != BATCH_MAGIC {
159 return None;
160 }
161 let count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
162
163 // Sanity-check count against remaining data to prevent OOM
164 // on crafted payloads. Each sub-payload needs at least a 4-byte length prefix.
165 let remaining = data.len() - 8;
166 if count > remaining / 4 {
167 return None; // implausible count
168 }
169
170 let mut offset = 8;
171 let mut payloads = Vec::with_capacity(count);
172 for _ in 0..count {
173 if offset + 4 > data.len() {
174 return None; // truncated
175 }
176 let len = u32::from_le_bytes([
177 data[offset],
178 data[offset + 1],
179 data[offset + 2],
180 data[offset + 3],
181 ]) as usize;
182 offset += 4;
183 if offset + len > data.len() {
184 return None; // truncated
185 }
186 payloads.push(data[offset..offset + len].to_vec());
187 offset += len;
188 }
189 Some(payloads)
190}