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` → retired (was `Delete`; tombstones are catalog state)
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;
29/// Retired. A `Delete` record carried the tombstones a delete resolved to,
30/// and nothing depended on it: the catalog manifest is the durable record and
31/// is fsynced first. The number is not reused, so an old log decodes as an
32/// unknown discriminant rather than as something else.
33const _RETIRED_DISC_DELETE: u8 = 0x01;
34
35/// Errors produced by the WAL codec.
36#[derive(Debug)]
37pub enum CodecError {
38    /// The payload is empty or too short to contain a valid record.
39    Truncated,
40    /// The version prefix is not recognised (e.g. legacy JSON or corrupt data).
41    UnknownVersion(u8),
42    /// The binary discriminant byte is not recognised.
43    UnknownDiscriminant(u8),
44    /// `postcard` serialisation/deserialisation failed.
45    Postcard(postcard::Error),
46}
47
48impl fmt::Display for CodecError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Truncated => write!(f, "WAL record truncated"),
52            Self::UnknownVersion(v) => write!(f, "unknown WAL version prefix: 0x{v:02x}"),
53            Self::UnknownDiscriminant(d) => write!(f, "unknown WAL binary discriminant: 0x{d:02x}"),
54            Self::Postcard(e) => write!(f, "WAL postcard error: {e}"),
55        }
56    }
57}
58
59impl std::error::Error for CodecError {
60    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
61        match self {
62            Self::Postcard(e) => Some(e),
63            _ => None,
64        }
65    }
66}
67
68impl From<postcard::Error> for CodecError {
69    fn from(e: postcard::Error) -> Self {
70        Self::Postcard(e)
71    }
72}
73
74/// Encode a [`WalEntry`] into the compact binary v1 format.
75///
76/// The returned `Vec<u8>` can be passed directly to the WAL writer.
77/// The encoding is ~5–10× smaller and faster than the previous JSON format.
78/// # Errors
79///
80/// Returns [`CodecError::Postcard`] when postcard serialization fails.
81pub fn encode(entry: &WalEntry) -> Result<Vec<u8>, CodecError> {
82    // Pre-allocate a reasonable buffer (version byte + discriminant + payload).
83    let mut buf = Vec::with_capacity(256);
84    buf.push(WAL_BINARY_V1);
85
86    match entry {
87        WalEntry::Write { point } => {
88            buf.push(DISC_WRITE);
89            let data = postcard::to_stdvec(point)?;
90            buf.extend_from_slice(&data);
91        }
92    }
93
94    Ok(buf)
95}
96
97/// Encode a single write-point directly without constructing a [`WalEntry`].
98///
99/// This avoids the `Point::clone()` that would otherwise be needed to build
100/// a `WalEntry::Write { point }` variant.  On the hot write path — especially
101/// batch inserts — this eliminates one `String` + `Vec` heap allocation per
102/// point.
103/// # Errors
104///
105/// Returns [`CodecError::Postcard`] when postcard serialization fails.
106pub fn encode_write_point(point: &Point) -> Result<Vec<u8>, CodecError> {
107    let mut buf = Vec::with_capacity(256);
108    buf.push(WAL_BINARY_V1);
109    buf.push(DISC_WRITE);
110    let data = postcard::to_stdvec(point)?;
111    buf.extend_from_slice(&data);
112    Ok(buf)
113}
114
115/// Decode a WAL payload into a [`WalEntry`].
116///
117/// Only binary v1 records (starting with `0x01`) are accepted.
118/// Legacy JSON records are rejected with [`CodecError::UnknownVersion`].
119/// # Errors
120///
121/// Returns [`CodecError::Truncated`] on short input,
122/// [`CodecError::UnknownVersion`] / [`CodecError::UnknownDiscriminant`] on
123/// unrecognised bytes, and a deserialization error on corrupt payloads.
124#[allow(clippy::indexing_slicing)] // all accesses guarded by explicit length checks above them
125pub fn decode(data: &[u8]) -> Result<WalEntry, CodecError> {
126    if data.is_empty() {
127        return Err(CodecError::Truncated);
128    }
129
130    if data[0] != WAL_BINARY_V1 {
131        return Err(CodecError::UnknownVersion(data[0]));
132    }
133
134    if data.len() < 2 {
135        return Err(CodecError::Truncated);
136    }
137
138    let payload = &data[2..];
139    match data[1] {
140        DISC_WRITE => {
141            let point: Point = postcard::from_bytes(payload)?;
142            Ok(WalEntry::Write { point })
143        }
144        d => Err(CodecError::UnknownDiscriminant(d)),
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::types::{FieldValue, SeriesKey};
152    use std::collections::BTreeMap;
153
154    fn sample_point() -> Point {
155        let key = SeriesKey::new("cpu", BTreeMap::from([("host".into(), "srv-1".into())])).unwrap();
156        let fields = BTreeMap::from([("value".to_string(), FieldValue::F64(42.5))]);
157        Point::new(key, fields, 1_000_000_000).unwrap()
158    }
159
160    #[test]
161    fn a_decimal_field_round_trips_through_the_wal() {
162        // postcard carries an `i128` as a varint and the scale as a byte, so
163        // the WAL record holds the digits themselves. If this went through
164        // an `f64` — as it would if the field were serialised as a number —
165        // 0.30000000000000004 would come back as 0.3 and nobody would see it.
166        let key = SeriesKey::new("meter", BTreeMap::new()).unwrap();
167        let fields = BTreeMap::from([
168            (
169                "z1nb".to_string(),
170                FieldValue::Decimal("1234.5678".parse().unwrap()),
171            ),
172            (
173                "tiny".to_string(),
174                FieldValue::Decimal("0.30000000000000004".parse().unwrap()),
175            ),
176            (
177                "widest".to_string(),
178                FieldValue::Decimal("99999999999999999999999999999999999999".parse().unwrap()),
179            ),
180        ]);
181        let entry = WalEntry::Write {
182            point: Point::new(key, fields, 1).unwrap(),
183        };
184        let decoded = decode(&encode(&entry).unwrap()).unwrap();
185        assert_eq!(entry, decoded);
186
187        let WalEntry::Write { point } = decoded;
188        // Equality alone would pass if both sides had lost the same digits.
189        match point.field("z1nb") {
190            Some(FieldValue::Decimal(d)) => {
191                assert_eq!(d.to_string(), "1234.5678");
192                assert_eq!(d.scale(), 4, "the scale travels with the value");
193            }
194            other => panic!("expected a decimal, got {other:?}"),
195        }
196        match point.field("tiny") {
197            Some(FieldValue::Decimal(d)) => assert_eq!(d.to_string(), "0.30000000000000004"),
198            other => panic!("expected a decimal, got {other:?}"),
199        }
200    }
201
202    #[test]
203    fn write_roundtrip() {
204        let entry = WalEntry::Write {
205            point: sample_point(),
206        };
207        let encoded = encode(&entry).unwrap();
208        assert_eq!(encoded[0], WAL_BINARY_V1);
209        assert_eq!(encoded[1], DISC_WRITE);
210        let decoded = decode(&encoded).unwrap();
211        assert_eq!(entry, decoded);
212    }
213
214    /// The retired `Delete` discriminant decodes as unknown, rather than as
215    /// something else.
216    ///
217    /// A number that is reused is how an old log becomes a wrong answer
218    /// instead of an error.
219    #[test]
220    fn the_retired_delete_discriminant_is_not_reused() {
221        let old = [WAL_BINARY_V1, 0x01, 0x00];
222        assert!(
223            matches!(decode(&old), Err(CodecError::UnknownDiscriminant(0x01))),
224            "a retired record must fail loudly"
225        );
226    }
227
228    #[test]
229    fn decode_json_rejected_with_unknown_version() {
230        // JSON starts with `{` (0x7B) — must be rejected, not silently decoded.
231        let json = b"{\"Write\":{}}";
232        let err = decode(json).unwrap_err();
233        assert!(
234            matches!(err, CodecError::UnknownVersion(0x7B)),
235            "expected UnknownVersion(0x7B), got {err:?}"
236        );
237    }
238
239    #[test]
240    fn decode_empty_payload_returns_error() {
241        assert!(decode(&[]).is_err());
242    }
243
244    #[test]
245    fn decode_truncated_binary_returns_error() {
246        assert!(decode(&[WAL_BINARY_V1]).is_err());
247    }
248
249    #[test]
250    fn decode_unknown_discriminant_returns_error() {
251        assert!(decode(&[WAL_BINARY_V1, 0xFF, 0x00]).is_err());
252    }
253}