Skip to main content

reifydb_codec/log/
record.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use crc32fast::Hasher;
5use reifydb_value::{reifydb_assertions, value::datetime::DateTime};
6
7use crate::log::{LogIndex, LogVersion, RecordKind, Term};
8
9pub const HEADER_BYTES: usize = 48;
10
11pub const MIN_LENGTH: u32 = 44;
12
13pub const RESERVED: u32 = 0;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Record {
17	pub version: LogVersion,
18	pub index: LogIndex,
19	pub term: Term,
20	pub timestamp: DateTime,
21	pub kind: RecordKind,
22	pub payload: Vec<u8>,
23}
24
25impl Record {
26	pub fn new(
27		version: LogVersion,
28		index: LogIndex,
29		term: Term,
30		timestamp: DateTime,
31		kind: RecordKind,
32		payload: Vec<u8>,
33	) -> Self {
34		Self {
35			version,
36			index,
37			term,
38			timestamp,
39			kind,
40			payload,
41		}
42	}
43
44	pub fn encoded_len(&self) -> usize {
45		HEADER_BYTES + self.payload.len()
46	}
47
48	pub fn encode(&self) -> Vec<u8> {
49		let length = MIN_LENGTH as usize + self.payload.len();
50		reifydb_assertions! {
51			assert!(
52				length <= u32::MAX as usize,
53				"a payload of {} bytes overflows the four byte length field, which wraps silently and \
54				 frames the record at a length no scan can follow (payload limit={})",
55				self.payload.len(),
56				u32::MAX as usize - MIN_LENGTH as usize
57			);
58		}
59		let mut out = Vec::with_capacity(HEADER_BYTES + self.payload.len());
60		out.extend_from_slice(&(length as u32).to_le_bytes());
61		out.extend_from_slice(&0u32.to_le_bytes());
62		out.extend_from_slice(&self.version.as_u64().to_le_bytes());
63		out.extend_from_slice(&self.index.as_u64().to_le_bytes());
64		out.extend_from_slice(&self.term.as_u64().to_le_bytes());
65		out.extend_from_slice(&self.timestamp.to_bits().to_le_bytes());
66		out.extend_from_slice(&self.kind.as_u32().to_le_bytes());
67		out.extend_from_slice(&RESERVED.to_le_bytes());
68		out.extend_from_slice(&self.payload);
69		let checksum = checksum(&out[8..]);
70		out[4..8].copy_from_slice(&checksum.to_le_bytes());
71		out
72	}
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct Header {
77	pub length: u32,
78	pub checksum: u32,
79	pub version: LogVersion,
80	pub index: LogIndex,
81	pub term: Term,
82	pub timestamp: DateTime,
83	pub kind: RecordKind,
84	pub reserved: u32,
85}
86
87impl Header {
88	pub fn decode(buf: &[u8; HEADER_BYTES]) -> Self {
89		Self {
90			length: u32::from_le_bytes(buf[0..4].try_into().unwrap()),
91			checksum: u32::from_le_bytes(buf[4..8].try_into().unwrap()),
92			version: LogVersion::new(u64::from_le_bytes(buf[8..16].try_into().unwrap())),
93			index: LogIndex::new(u64::from_le_bytes(buf[16..24].try_into().unwrap())),
94			term: Term::new(u64::from_le_bytes(buf[24..32].try_into().unwrap())),
95			timestamp: DateTime::from_bits(u64::from_le_bytes(buf[32..40].try_into().unwrap())),
96			kind: RecordKind::new(u32::from_le_bytes(buf[40..44].try_into().unwrap())),
97			reserved: u32::from_le_bytes(buf[44..48].try_into().unwrap()),
98		}
99	}
100
101	pub fn is_end(&self) -> bool {
102		self.length == 0
103	}
104
105	pub fn payload_len(&self) -> Option<usize> {
106		if self.length < MIN_LENGTH {
107			return None;
108		}
109		Some((self.length - MIN_LENGTH) as usize)
110	}
111
112	pub fn verify(&self, payload: &[u8]) -> bool {
113		let mut hasher = Hasher::new();
114		hasher.update(&self.version.as_u64().to_le_bytes());
115		hasher.update(&self.index.as_u64().to_le_bytes());
116		hasher.update(&self.term.as_u64().to_le_bytes());
117		hasher.update(&self.timestamp.to_bits().to_le_bytes());
118		hasher.update(&self.kind.as_u32().to_le_bytes());
119		hasher.update(&self.reserved.to_le_bytes());
120		hasher.update(payload);
121		hasher.finalize() == self.checksum
122	}
123
124	pub fn into_record(self, payload: Vec<u8>) -> Record {
125		Record {
126			version: self.version,
127			index: self.index,
128			term: self.term,
129			timestamp: self.timestamp,
130			kind: self.kind,
131			payload,
132		}
133	}
134}
135
136fn checksum(bytes: &[u8]) -> u32 {
137	let mut hasher = Hasher::new();
138	hasher.update(bytes);
139	hasher.finalize()
140}
141
142#[cfg(test)]
143mod tests {
144	use super::*;
145
146	fn header_of(bytes: &[u8]) -> Header {
147		Header::decode(bytes[..HEADER_BYTES].try_into().unwrap())
148	}
149
150	fn record(version: u64, index: u64, term: u64, timestamp: u64, kind: u32, payload: Vec<u8>) -> Record {
151		Record::new(
152			LogVersion::new(version),
153			LogIndex::new(index),
154			Term::new(term),
155			DateTime::from_bits(timestamp),
156			RecordKind::new(kind),
157			payload,
158		)
159	}
160
161	#[test]
162	fn encode_lays_the_fields_out_at_the_documented_offsets() {
163		// The offsets are the on disk format; moving one silently makes every existing
164		// segment unreadable, so they are asserted numerically rather than via decode.
165		let bytes = record(
166			0x0102030405060708,
167			0x2122232425262728,
168			0x3132333435363738,
169			0x1112131415161718,
170			0x41424344,
171			vec![0xaa, 0xbb],
172		)
173		.encode();
174
175		assert_eq!(bytes.len(), HEADER_BYTES + 2);
176		assert_eq!(&bytes[0..4], &46u32.to_le_bytes());
177		assert_eq!(&bytes[8..16], &0x0102030405060708u64.to_le_bytes());
178		assert_eq!(&bytes[16..24], &0x2122232425262728u64.to_le_bytes());
179		assert_eq!(&bytes[24..32], &0x3132333435363738u64.to_le_bytes());
180		assert_eq!(&bytes[32..40], &0x1112131415161718u64.to_le_bytes());
181		assert_eq!(&bytes[40..44], &0x41424344u32.to_le_bytes());
182		assert_eq!(&bytes[44..48], &0u32.to_le_bytes());
183		assert_eq!(&bytes[48..50], &[0xaa, 0xbb]);
184	}
185
186	#[test]
187	fn length_counts_the_bytes_after_itself() {
188		// length must exclude its own four bytes; counting them would make every scan
189		// advance four bytes too far and land mid record.
190		let record = record(1, 2, 3, 4, 0, vec![0u8; 100]);
191		let bytes = record.encode();
192		let header = header_of(&bytes);
193
194		assert_eq!(header.length as usize, bytes.len() - 4);
195		assert_eq!(header.payload_len(), Some(100));
196	}
197
198	#[test]
199	fn a_roundtrip_returns_the_payload_byte_identical() {
200		let payload: Vec<u8> = (0..=255u8).collect();
201		let record = record(7, 9, 11, 13, 1, payload.clone());
202		let bytes = record.encode();
203		let header = header_of(&bytes);
204
205		assert!(header.verify(&bytes[HEADER_BYTES..]));
206		assert_eq!(header.into_record(bytes[HEADER_BYTES..].to_vec()), record);
207	}
208
209	#[test]
210	fn a_roundtrip_returns_the_index_term_and_kind() {
211		// The three raft fields have no other reader yet, so nothing but this test stops
212		// them being dropped on the floor between encode and decode.
213		let header = header_of(&record(500, 7, 3, 1234, 1, vec![0x11, 0x22]).encode());
214
215		assert_eq!(header.version, LogVersion::new(500));
216		assert_eq!(header.index, LogIndex::new(7));
217		assert_eq!(header.term, Term::new(3));
218		assert_eq!(header.timestamp, DateTime::from_bits(1234));
219		assert_eq!(header.kind, RecordKind::new(1));
220		assert_eq!(header.reserved, RESERVED);
221	}
222
223	#[test]
224	fn an_empty_payload_is_a_valid_record_and_not_a_terminator() {
225		// length zero terminates a scan, so a record carrying no payload must still
226		// report a non zero length or an empty append would truncate the segment.
227		let bytes = record(3, 4, 5, 6, 0, Vec::new()).encode();
228		let header = header_of(&bytes);
229
230		assert_eq!(header.length, MIN_LENGTH);
231		assert!(!header.is_end());
232		assert_eq!(header.payload_len(), Some(0));
233		assert!(header.verify(&[]));
234	}
235
236	#[test]
237	fn a_zeroed_header_reads_as_the_end_of_the_written_region() {
238		// Segments are preallocated with zeros, so the first unwritten byte must decode
239		// as a terminator rather than as a record of length zero.
240		let header = Header::decode(&[0u8; HEADER_BYTES]);
241
242		assert!(header.is_end());
243	}
244
245	#[test]
246	fn a_flipped_payload_bit_fails_verification() {
247		let mut bytes = record(1, 2, 3, 4, 0, vec![0x55; 64]).encode();
248		bytes[HEADER_BYTES + 30] ^= 0x01;
249		let header = header_of(&bytes);
250
251		assert!(!header.verify(&bytes[HEADER_BYTES..]));
252	}
253
254	#[test]
255	fn a_flipped_version_bit_fails_verification() {
256		// The checksum must cover the version, otherwise a torn header is mistaken for a
257		// valid record sitting at a plausible version.
258		let mut bytes = record(1, 2, 3, 4, 0, vec![0x55; 8]).encode();
259		bytes[8] ^= 0x01;
260		let header = header_of(&bytes);
261
262		assert!(!header.verify(&bytes[HEADER_BYTES..]));
263	}
264
265	#[test]
266	fn a_flipped_timestamp_bit_fails_verification() {
267		let mut bytes = record(1, 2, 3, 4, 0, vec![0x55; 8]).encode();
268		bytes[32] ^= 0x01;
269		let header = header_of(&bytes);
270
271		assert!(!header.verify(&bytes[HEADER_BYTES..]));
272	}
273
274	#[test]
275	fn a_flipped_bit_anywhere_past_the_length_fails_verification() {
276		// Everything from offset 8 on is inside the checksum, including the raft fields
277		// and the reserved padding; a gap there lets a torn header pass as a real record.
278		for byte in 8..HEADER_BYTES {
279			let mut bytes = record(1, 2, 3, 4, 1, vec![0x55; 8]).encode();
280			bytes[byte] ^= 0x01;
281			let header = header_of(&bytes);
282
283			assert!(!header.verify(&bytes[HEADER_BYTES..]), "byte {byte} is outside the checksum");
284		}
285	}
286
287	#[test]
288	fn a_length_below_the_minimum_has_no_payload_length() {
289		// Garbage that decodes to a short length must be rejected rather than wrapping
290		// around to a huge payload length in the subtraction below it.
291		for length in 1..MIN_LENGTH {
292			let mut buf = [0u8; HEADER_BYTES];
293			buf[0..4].copy_from_slice(&length.to_le_bytes());
294
295			assert_eq!(Header::decode(&buf).payload_len(), None, "length {length}");
296		}
297	}
298
299	#[test]
300	fn two_records_differing_only_in_payload_have_different_checksums() {
301		let a = header_of(&record(1, 2, 3, 4, 0, vec![0x00]).encode());
302		let b = header_of(&record(1, 2, 3, 4, 0, vec![0x01]).encode());
303
304		assert_ne!(a.checksum, b.checksum);
305	}
306
307	#[test]
308	fn two_records_differing_only_in_a_raft_field_have_different_checksums() {
309		// index, term and kind must each move the checksum, otherwise a record rewritten
310		// under a different leader verifies against the old header.
311		let base = header_of(&record(1, 2, 3, 4, 0, vec![0x00]).encode());
312
313		assert_ne!(base.checksum, header_of(&record(1, 9, 3, 4, 0, vec![0x00]).encode()).checksum);
314		assert_ne!(base.checksum, header_of(&record(1, 2, 9, 4, 0, vec![0x00]).encode()).checksum);
315		assert_ne!(base.checksum, header_of(&record(1, 2, 3, 4, 9, vec![0x00]).encode()).checksum);
316	}
317
318	#[test]
319	fn encoded_len_matches_what_encode_produces() {
320		// The appender reserves space from encoded_len before it encodes; a mismatch
321		// writes past the end of a preallocated segment.
322		for size in [0usize, 1, 511, 512, 513, 4096] {
323			let record = record(1, 2, 3, 4, 0, vec![0u8; size]);
324
325			assert_eq!(record.encoded_len(), record.encode().len());
326		}
327	}
328}