Skip to main content

chronix_core/
wal_codec.rs

1//! Compact binary codec for WAL entries.
2//!
3//! All records use the binary v1 format (version-prefixed + `postcard`
4//! serialisation of the variant fields). Legacy JSON is **not** supported.
5//!
6//! ## Wire format (v1)
7//!
8//! ```text
9//! [1 byte  version = 0x01]
10//! [1 byte  variant discriminant]
11//! [N bytes postcard-encoded variant fields]
12//! ```
13//!
14//! Discriminants:
15//! - `0x00` → `WalEntry::Write { point }`
16//! - `0x01` → `WalEntry::Delete { tombstones }`
17//!
18//! Schema changes are not WAL entries: the catalog manifest is their
19//! durable record, and it is written before the data that needs them.
20
21use crate::types::{Point, WalEntry};
22use std::fmt;
23
24/// Binary version prefix.
25const WAL_BINARY_V1: u8 = 0x01;
26
27// Variant discriminants.
28const DISC_WRITE: u8 = 0x00;
29const DISC_DELETE: u8 = 0x01;
30
31/// Errors produced by the WAL codec.
32#[derive(Debug)]
33pub enum CodecError {
34    /// The payload is empty or too short to contain a valid record.
35    Truncated,
36    /// The version prefix is not recognised (e.g. legacy JSON or corrupt data).
37    UnknownVersion(u8),
38    /// The binary discriminant byte is not recognised.
39    UnknownDiscriminant(u8),
40    /// `postcard` serialisation/deserialisation failed.
41    Postcard(postcard::Error),
42}
43
44impl fmt::Display for CodecError {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Truncated => write!(f, "WAL record truncated"),
48            Self::UnknownVersion(v) => write!(f, "unknown WAL version prefix: 0x{v:02x}"),
49            Self::UnknownDiscriminant(d) => write!(f, "unknown WAL binary discriminant: 0x{d:02x}"),
50            Self::Postcard(e) => write!(f, "WAL postcard error: {e}"),
51        }
52    }
53}
54
55impl std::error::Error for CodecError {
56    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
57        match self {
58            Self::Postcard(e) => Some(e),
59            _ => None,
60        }
61    }
62}
63
64impl From<postcard::Error> for CodecError {
65    fn from(e: postcard::Error) -> Self {
66        Self::Postcard(e)
67    }
68}
69
70/// Encode a [`WalEntry`] into the compact binary v1 format.
71///
72/// The returned `Vec<u8>` can be passed directly to the WAL writer.
73/// The encoding is ~5–10× smaller and faster than the previous JSON format.
74/// # Errors
75///
76/// Returns [`CodecError::Postcard`] when postcard serialization fails.
77pub fn encode(entry: &WalEntry) -> Result<Vec<u8>, CodecError> {
78    // Pre-allocate a reasonable buffer (version byte + discriminant + payload).
79    let mut buf = Vec::with_capacity(256);
80    buf.push(WAL_BINARY_V1);
81
82    match entry {
83        WalEntry::Write { point } => {
84            buf.push(DISC_WRITE);
85            let data = postcard::to_stdvec(point)?;
86            buf.extend_from_slice(&data);
87        }
88        WalEntry::Delete { tombstones } => {
89            buf.push(DISC_DELETE);
90            let data = postcard::to_stdvec(tombstones)?;
91            buf.extend_from_slice(&data);
92        }
93    }
94
95    Ok(buf)
96}
97
98/// Encode a single write-point directly without constructing a [`WalEntry`].
99///
100/// This avoids the `Point::clone()` that would otherwise be needed to build
101/// a `WalEntry::Write { point }` variant.  On the hot write path — especially
102/// batch inserts — this eliminates one `String` + `Vec` heap allocation per
103/// point.
104/// # Errors
105///
106/// Returns [`CodecError::Postcard`] when postcard serialization fails.
107pub fn encode_write_point(point: &Point) -> Result<Vec<u8>, CodecError> {
108    let mut buf = Vec::with_capacity(256);
109    buf.push(WAL_BINARY_V1);
110    buf.push(DISC_WRITE);
111    let data = postcard::to_stdvec(point)?;
112    buf.extend_from_slice(&data);
113    Ok(buf)
114}
115
116/// Decode a WAL payload into a [`WalEntry`].
117///
118/// Only binary v1 records (starting with `0x01`) are accepted.
119/// Legacy JSON records are rejected with [`CodecError::UnknownVersion`].
120/// # Errors
121///
122/// Returns [`CodecError::Truncated`] on short input,
123/// [`CodecError::UnknownVersion`] / [`CodecError::UnknownDiscriminant`] on
124/// unrecognised bytes, and a deserialization error on corrupt payloads.
125#[allow(clippy::indexing_slicing)] // all accesses guarded by explicit length checks above them
126pub fn decode(data: &[u8]) -> Result<WalEntry, CodecError> {
127    if data.is_empty() {
128        return Err(CodecError::Truncated);
129    }
130
131    if data[0] != WAL_BINARY_V1 {
132        return Err(CodecError::UnknownVersion(data[0]));
133    }
134
135    if data.len() < 2 {
136        return Err(CodecError::Truncated);
137    }
138
139    let payload = &data[2..];
140    match data[1] {
141        DISC_WRITE => {
142            let point: Point = postcard::from_bytes(payload)?;
143            Ok(WalEntry::Write { point })
144        }
145        DISC_DELETE => {
146            let tombstones: Vec<crate::types::Tombstone> = postcard::from_bytes(payload)?;
147            Ok(WalEntry::Delete { tombstones })
148        }
149        d => Err(CodecError::UnknownDiscriminant(d)),
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::types::{FieldValue, SeriesKey, Tombstone};
157    use std::collections::BTreeMap;
158
159    fn sample_point() -> Point {
160        let key = SeriesKey::new("cpu", BTreeMap::from([("host".into(), "srv-1".into())])).unwrap();
161        let fields = BTreeMap::from([("value".to_string(), FieldValue::F64(42.5))]);
162        Point::new(key, fields, 1_000_000_000).unwrap()
163    }
164
165    #[test]
166    fn write_roundtrip() {
167        let entry = WalEntry::Write {
168            point: sample_point(),
169        };
170        let encoded = encode(&entry).unwrap();
171        assert_eq!(encoded[0], WAL_BINARY_V1);
172        assert_eq!(encoded[1], DISC_WRITE);
173        let decoded = decode(&encoded).unwrap();
174        assert_eq!(entry, decoded);
175    }
176
177    #[test]
178    fn delete_roundtrip_preserves_the_time_range() {
179        // The range is the point: the previous encoding stored the *request*
180        // and rebuilt unranged tombstones on replay, so a one-hour delete
181        // became a whole-series delete after a restart.
182        let entry = WalEntry::Delete {
183            tombstones: vec![
184                Tombstone::ranged("cpu\0host=srv-1", 100, 200),
185                Tombstone::ranged("cpu\0host=srv-2", i64::MIN, 5_000).with_segments([1, 2, 3]),
186            ],
187        };
188        let encoded = encode(&entry).unwrap();
189        assert_eq!(encoded[1], DISC_DELETE);
190        let decoded = decode(&encoded).unwrap();
191        assert_eq!(entry, decoded);
192    }
193
194    #[test]
195    fn decode_json_rejected_with_unknown_version() {
196        // JSON starts with `{` (0x7B) — must be rejected, not silently decoded.
197        let json = b"{\"Write\":{}}";
198        let err = decode(json).unwrap_err();
199        assert!(
200            matches!(err, CodecError::UnknownVersion(0x7B)),
201            "expected UnknownVersion(0x7B), got {err:?}"
202        );
203    }
204
205    #[test]
206    fn decode_empty_payload_returns_error() {
207        assert!(decode(&[]).is_err());
208    }
209
210    #[test]
211    fn decode_truncated_binary_returns_error() {
212        assert!(decode(&[WAL_BINARY_V1]).is_err());
213    }
214
215    #[test]
216    fn decode_unknown_discriminant_returns_error() {
217        assert!(decode(&[WAL_BINARY_V1, 0xFF, 0x00]).is_err());
218    }
219}