Skip to main content

reifydb_codec/log/
meta.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use crc32fast::Hasher;
5use reifydb_value::{byte_size::ByteSize, value::duration::Duration};
6
7pub const META_BYTES: usize = 48;
8
9pub const MAGIC: u32 = u32::from_le_bytes(*b"RMET");
10
11pub const FORMAT_VERSION: u32 = 2;
12
13pub const DEFAULT_PARTITIONS: u32 = 4;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Meta {
17	pub version: u32,
18	pub partitions: u32,
19	pub segment_bytes: ByteSize,
20	pub index_interval: ByteSize,
21	pub segment_age: Duration,
22}
23
24impl Meta {
25	pub fn encode(&self) -> [u8; META_BYTES] {
26		let mut out = [0u8; META_BYTES];
27		out[0..4].copy_from_slice(&MAGIC.to_le_bytes());
28		out[8..12].copy_from_slice(&self.version.to_le_bytes());
29		out[12..16].copy_from_slice(&self.partitions.to_le_bytes());
30		out[16..24].copy_from_slice(&self.segment_bytes.as_bytes().to_le_bytes());
31		out[24..32].copy_from_slice(&self.index_interval.as_bytes().to_le_bytes());
32		out[32..36].copy_from_slice(&self.segment_age.get_months().to_le_bytes());
33		out[36..40].copy_from_slice(&self.segment_age.get_days().to_le_bytes());
34		out[40..48].copy_from_slice(&self.segment_age.get_nanos().to_le_bytes());
35		let checksum = checksum(&out[8..]);
36		out[4..8].copy_from_slice(&checksum.to_le_bytes());
37		out
38	}
39
40	pub fn decode(buf: &[u8; META_BYTES]) -> Option<Self> {
41		if u32::from_le_bytes(buf[0..4].try_into().unwrap()) != MAGIC {
42			return None;
43		}
44		if u32::from_le_bytes(buf[4..8].try_into().unwrap()) != checksum(&buf[8..]) {
45			return None;
46		}
47		let segment_age = Duration::new(
48			i32::from_le_bytes(buf[32..36].try_into().unwrap()),
49			i32::from_le_bytes(buf[36..40].try_into().unwrap()),
50			i64::from_le_bytes(buf[40..48].try_into().unwrap()),
51		)
52		.ok()?;
53		Some(Self {
54			version: u32::from_le_bytes(buf[8..12].try_into().unwrap()),
55			partitions: u32::from_le_bytes(buf[12..16].try_into().unwrap()),
56			segment_bytes: ByteSize::from_bytes(u64::from_le_bytes(buf[16..24].try_into().unwrap())),
57			index_interval: ByteSize::from_bytes(u64::from_le_bytes(buf[24..32].try_into().unwrap())),
58			segment_age,
59		})
60	}
61}
62
63fn checksum(bytes: &[u8]) -> u32 {
64	let mut hasher = Hasher::new();
65	hasher.update(bytes);
66	hasher.finalize()
67}
68
69#[cfg(test)]
70mod tests {
71	use super::*;
72
73	fn meta() -> Meta {
74		Meta {
75			version: FORMAT_VERSION,
76			partitions: DEFAULT_PARTITIONS,
77			segment_bytes: ByteSize::from_mib(256),
78			index_interval: ByteSize::from_kib(4),
79			segment_age: Duration::from_seconds_const(60),
80		}
81	}
82
83	#[test]
84	fn meta_lays_its_fields_out_at_the_documented_offsets() {
85		// The offsets are the on disk format. Meta is the one file that cannot be rebuilt from
86		// anything else, so moving a field makes every existing log unopenable.
87		let raw = Meta {
88			version: 0x01020304,
89			partitions: 0x11121314,
90			segment_bytes: ByteSize::from_bytes(0x2122232425262728),
91			index_interval: ByteSize::from_bytes(0x3132333435363738),
92			segment_age: Duration::new(0x41424344, 0x51525354, 0x1122334455).unwrap(),
93		}
94		.encode();
95
96		assert_eq!(raw.len(), META_BYTES);
97		assert_eq!(&raw[0..4], &MAGIC.to_le_bytes());
98		assert_eq!(&raw[8..12], &0x01020304u32.to_le_bytes());
99		assert_eq!(&raw[12..16], &0x11121314u32.to_le_bytes());
100		assert_eq!(&raw[16..24], &0x2122232425262728u64.to_le_bytes());
101		assert_eq!(&raw[24..32], &0x3132333435363738u64.to_le_bytes());
102		assert_eq!(&raw[32..36], &0x41424344i32.to_le_bytes());
103		assert_eq!(&raw[36..40], &0x51525354i32.to_le_bytes());
104		assert_eq!(&raw[40..48], &0x1122334455i64.to_le_bytes());
105	}
106
107	#[test]
108	fn a_meta_round_trips_through_its_bytes() {
109		assert_eq!(Meta::decode(&meta().encode()), Some(meta()));
110	}
111
112	#[test]
113	fn a_duration_with_a_months_component_survives_the_round_trip() {
114		// Decision 205: Duration is a months, days and nanos triple, so encoding it as a single
115		// nanosecond count would drop the months and silently shorten the age bound.
116		let with_months = Meta {
117			segment_age: Duration::new(3, 2, 1).unwrap(),
118			..meta()
119		};
120
121		let back = Meta::decode(&with_months.encode()).unwrap();
122
123		assert_eq!(back.segment_age.get_months(), 3);
124		assert_eq!(back.segment_age.get_days(), 2);
125		assert_eq!(back.segment_age.get_nanos(), 1);
126	}
127
128	#[test]
129	fn a_foreign_file_is_refused_by_its_magic() {
130		let mut raw = meta().encode();
131		raw[0..4].copy_from_slice(&u32::from_le_bytes(*b"RIDX").to_le_bytes());
132
133		assert_eq!(Meta::decode(&raw), None);
134	}
135
136	#[test]
137	fn a_flipped_bit_in_the_partition_count_is_caught_by_the_checksum() {
138		// The partition count decides where every table's records live, so a bit flip here
139		// routes reads at a directory that was never written rather than failing to open.
140		let mut raw = meta().encode();
141		raw[12] ^= 0x01;
142
143		assert_eq!(Meta::decode(&raw), None);
144	}
145
146	#[test]
147	fn a_flipped_bit_in_the_segment_size_is_caught_by_the_checksum() {
148		let mut raw = meta().encode();
149		raw[16] ^= 0x01;
150
151		assert_eq!(Meta::decode(&raw), None);
152	}
153
154	#[test]
155	fn an_unconstructible_duration_is_refused_rather_than_normalised() {
156		// Days and nanos must share a sign; a torn write that leaves them opposed must not
157		// decode into some other duration that then drives a roll boundary nothing chose.
158		let mut raw = meta().encode();
159		raw[36..40].copy_from_slice(&1i32.to_le_bytes());
160		raw[40..48].copy_from_slice(&(-1i64).to_le_bytes());
161		let fixed = checksum(&raw[8..]);
162		raw[4..8].copy_from_slice(&fixed.to_le_bytes());
163
164		assert_eq!(Meta::decode(&raw), None);
165	}
166}