chronix_core/
wal_codec.rs1use crate::types::{Point, WalEntry};
22use std::fmt;
23
24const WAL_BINARY_V1: u8 = 0x01;
26
27const DISC_WRITE: u8 = 0x00;
29const _RETIRED_DISC_DELETE: u8 = 0x01;
34
35#[derive(Debug)]
37pub enum CodecError {
38 Truncated,
40 UnknownVersion(u8),
42 UnknownDiscriminant(u8),
44 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
74pub fn encode(entry: &WalEntry) -> Result<Vec<u8>, CodecError> {
82 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
97pub 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#[allow(clippy::indexing_slicing)] pub 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 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 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 #[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 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}