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 a_decimal_field_round_trips_through_the_wal() {
167        // postcard carries an `i128` as a varint and the scale as a byte, so
168        // the WAL record holds the digits themselves. If this went through
169        // an `f64` — as it would if the field were serialised as a number —
170        // 0.30000000000000004 would come back as 0.3 and nobody would see it.
171        let key = SeriesKey::new("meter", BTreeMap::new()).unwrap();
172        let fields = BTreeMap::from([
173            (
174                "z1nb".to_string(),
175                FieldValue::Decimal("1234.5678".parse().unwrap()),
176            ),
177            (
178                "tiny".to_string(),
179                FieldValue::Decimal("0.30000000000000004".parse().unwrap()),
180            ),
181            (
182                "widest".to_string(),
183                FieldValue::Decimal("99999999999999999999999999999999999999".parse().unwrap()),
184            ),
185        ]);
186        let entry = WalEntry::Write {
187            point: Point::new(key, fields, 1).unwrap(),
188        };
189        let decoded = decode(&encode(&entry).unwrap()).unwrap();
190        assert_eq!(entry, decoded);
191
192        let WalEntry::Write { point } = decoded else {
193            panic!("a write record");
194        };
195        // Equality alone would pass if both sides had lost the same digits.
196        match point.field("z1nb") {
197            Some(FieldValue::Decimal(d)) => {
198                assert_eq!(d.to_string(), "1234.5678");
199                assert_eq!(d.scale(), 4, "the scale travels with the value");
200            }
201            other => panic!("expected a decimal, got {other:?}"),
202        }
203        match point.field("tiny") {
204            Some(FieldValue::Decimal(d)) => assert_eq!(d.to_string(), "0.30000000000000004"),
205            other => panic!("expected a decimal, got {other:?}"),
206        }
207    }
208
209    #[test]
210    fn write_roundtrip() {
211        let entry = WalEntry::Write {
212            point: sample_point(),
213        };
214        let encoded = encode(&entry).unwrap();
215        assert_eq!(encoded[0], WAL_BINARY_V1);
216        assert_eq!(encoded[1], DISC_WRITE);
217        let decoded = decode(&encoded).unwrap();
218        assert_eq!(entry, decoded);
219    }
220
221    #[test]
222    fn delete_roundtrip_preserves_the_time_range() {
223        // The range is the point: the previous encoding stored the *request*
224        // and rebuilt unranged tombstones on replay, so a one-hour delete
225        // became a whole-series delete after a restart.
226        let entry = WalEntry::Delete {
227            tombstones: vec![
228                Tombstone::ranged("cpu\0host=srv-1", 100, 200),
229                Tombstone::ranged("cpu\0host=srv-2", i64::MIN, 5_000).with_segments([1, 2, 3]),
230            ],
231        };
232        let encoded = encode(&entry).unwrap();
233        assert_eq!(encoded[1], DISC_DELETE);
234        let decoded = decode(&encoded).unwrap();
235        assert_eq!(entry, decoded);
236    }
237
238    #[test]
239    fn decode_json_rejected_with_unknown_version() {
240        // JSON starts with `{` (0x7B) — must be rejected, not silently decoded.
241        let json = b"{\"Write\":{}}";
242        let err = decode(json).unwrap_err();
243        assert!(
244            matches!(err, CodecError::UnknownVersion(0x7B)),
245            "expected UnknownVersion(0x7B), got {err:?}"
246        );
247    }
248
249    #[test]
250    fn decode_empty_payload_returns_error() {
251        assert!(decode(&[]).is_err());
252    }
253
254    #[test]
255    fn decode_truncated_binary_returns_error() {
256        assert!(decode(&[WAL_BINARY_V1]).is_err());
257    }
258
259    #[test]
260    fn decode_unknown_discriminant_returns_error() {
261        assert!(decode(&[WAL_BINARY_V1, 0xFF, 0x00]).is_err());
262    }
263}