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 DISC_DELETE: u8 = 0x01;
30
31#[derive(Debug)]
33pub enum CodecError {
34 Truncated,
36 UnknownVersion(u8),
38 UnknownDiscriminant(u8),
40 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
70pub fn encode(entry: &WalEntry) -> Result<Vec<u8>, CodecError> {
78 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
98pub 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#[allow(clippy::indexing_slicing)] pub 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 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 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 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 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}