chronix_engine/format.rs
1//! The on-disk format versions, and the one rule for checking them.
2//!
3//! Chronix writes four durable formats and every one of them used to carry
4//! its own version constant, its own comparison and its own error text:
5//!
6//! - the `.csx` segment header refused anything but an exact match, and said
7//! so through a dedicated `UnsupportedVersion` variant;
8//! - the WAL file header refused anything but an exact match, as an
9//! `InvalidHeader`;
10//! - the `.series` sidecar refused anything but an exact match and reported
11//! it as **`Corrupt`** — which points an operator at a failing disk when
12//! what actually happened is that they upgraded chronix;
13//! - and the catalog snapshot refused only a **newer** version, silently
14//! accepting an older one it has no code to interpret.
15//!
16//! That last one is an asymmetry nobody decided. The three others agree that
17//! a version is an identity, not an ordering: there is no partial reader for
18//! a superseded layout, because the layouts were superseded before anyone
19//! could have data under them.
20//!
21//! # The rule
22//!
23//! **A format version is checked for equality, in both directions, and a
24//! mismatch is its own error — never corruption.** The two are different
25//! facts with different remedies: corruption means the bytes are damaged and
26//! the answer is a restore; a version mismatch means the software and the
27//! directory disagree about the layout and the answer is to match them up.
28//!
29//! # Changing a format
30//!
31//! Bump the constant here **and** state the break in `CHANGELOG.md`. There is
32//! deliberately no migration: chronix reads exactly the generation it writes,
33//! and a directory from another generation is refused by name with the
34//! remedy in the message. That is a decision with an expiry date — it holds
35//! only while the project is willing to tell a consumer to recreate their
36//! data directory, which is a promise 1.0 will have to replace.
37//!
38//! **Bump it when the bytes change, not before.** The version exists to make
39//! a foreign directory *legible*: without a bump, an old directory has
40//! matching magic and a matching version, so the reader proceeds into a
41//! layout it does not understand and the failure arrives as a CRC error, a
42//! bounds check or a decode error — which is exactly the "report a version
43//! mismatch as corruption" confusion this module exists to remove. A bump
44//! with no layout change is the mirror mistake: it invalidates every
45//! directory in exchange for nothing. All four are still at **1** because
46//! nothing has changed the bytes yet; the format-changing work is batched so
47//! they move together, once.
48
49/// `.csx` segment files.
50pub const SEGMENT_FORMAT_VERSION: u16 = 1;
51
52/// Write-ahead log files.
53pub const WAL_FORMAT_VERSION: u16 = 1;
54
55/// The catalog manifest snapshot.
56pub const CATALOG_FORMAT_VERSION: u32 = 1;
57
58/// The per-segment `.series` sidecar.
59pub const SERIES_INDEX_FORMAT_VERSION: u8 = 1;
60
61/// What to tell somebody whose directory this build cannot read.
62///
63/// The message an operator meets before they have any other information, so
64/// it names the thing, both versions, and what to do — rather than a bare
65/// number, which is what three of the four used to print.
66#[must_use]
67pub fn version_mismatch(what: &str, found: u64, expected: u64) -> String {
68 format!(
69 "{what} is format version {found}, and this build of chronix reads \
70 version {expected}. Chronix has no on-disk migration: a data \
71 directory belongs to one format generation. Either run the version \
72 of chronix that wrote it, or start a new data directory and re-ingest \
73 — see the release notes for the version that changed it."
74 )
75}
76
77/// Check a format version, returning the message to report on a mismatch.
78///
79/// Equality in both directions: see the module documentation for why an
80/// older file is refused as firmly as a newer one.
81#[must_use]
82pub fn check(what: &str, found: u64, expected: u64) -> Option<String> {
83 (found != expected).then(|| version_mismatch(what, found, expected))
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn an_exact_match_passes() {
92 assert!(check("a segment", 2, 2).is_none());
93 }
94
95 #[test]
96 fn an_older_version_is_refused_as_firmly_as_a_newer_one() {
97 // The catalog used to accept an older snapshot, having no code to
98 // interpret one. Both directions are a mismatch.
99 assert!(check("a catalog", 1, 2).is_some());
100 assert!(check("a catalog", 3, 2).is_some());
101 }
102
103 #[test]
104 fn the_message_names_the_remedy_not_just_the_number() {
105 let m = check("the WAL", 1, 2).expect("mismatch");
106 assert!(m.contains("version 1"), "{m}");
107 assert!(m.contains("version 2"), "{m}");
108 assert!(
109 m.contains("re-ingest") || m.contains("new data directory"),
110 "a version mismatch is the first error an operator meets; it has \
111 to say what to do: {m}"
112 );
113 }
114
115 #[test]
116 fn every_durable_format_is_on_the_same_generation() {
117 // Keeping the four in step is not required by anything — it is a
118 // convention that makes "which generation is this directory?" one
119 // question instead of four, and it is why the format-changing work is
120 // batched into one break rather than trickled.
121 let g = u64::from(SEGMENT_FORMAT_VERSION);
122 assert_eq!(u64::from(WAL_FORMAT_VERSION), g);
123 assert_eq!(u64::from(CATALOG_FORMAT_VERSION), g);
124 assert_eq!(u64::from(SERIES_INDEX_FORMAT_VERSION), g);
125 }
126}
127
128#[cfg(test)]
129mod policy_tests {
130 /// Every durable reader must refuse a foreign version through a variant
131 /// that is *not* a corruption variant, and must build its message here.
132 ///
133 /// This is a source check rather than a behavioural one because the
134 /// alternative is writing four files at three versions each. The thing
135 /// it prevents is the one that was there: the `.series` sidecar reported
136 /// a version mismatch as `IndexError::Corrupt`, so an operator who had
137 /// upgraded chronix went looking for a failing disk.
138 #[test]
139 fn no_durable_reader_reports_a_version_mismatch_as_corruption() {
140 for (name, src) in [
141 ("wal/reader.rs", include_str!("wal/reader.rs")),
142 (
143 "index/series_index.rs",
144 include_str!("index/series_index.rs"),
145 ),
146 ("index/catalog.rs", include_str!("index/catalog.rs")),
147 ("segment/header.rs", include_str!("segment/header.rs")),
148 ] {
149 // Find the version check and read what it returns just after it.
150 let Some(at) = src.find("format::check").or_else(|| src.find("!= VERSION")) else {
151 panic!("{name}: no format-version check found — did the reader stop checking?");
152 };
153 // Only as far as the `return Err(..)` this check guards. A
154 // wider window ran into the *next* function's doc comment,
155 // which mentions `WalError::Corruption` — and a guard whose
156 // false positives are noise is a guard people learn to ignore.
157 let tail = &src[at..];
158 let end = tail
159 .find("return Err(")
160 .and_then(|i| tail[i..].find(';').map(|j| i + j + 1))
161 .unwrap_or(tail.len());
162 // Comments stripped: the window otherwise matched this very
163 // rule's own justification ("deliberately not `Corrupt`"), which
164 // is a guard tripping over prose about itself.
165 let window: String = tail[..end]
166 .lines()
167 .map(|l| l.split("//").next().unwrap_or(""))
168 .collect::<Vec<_>>()
169 .join("\n");
170 assert!(
171 !window.contains("Corrupt"),
172 "{name} reports a format-version mismatch as corruption. The \
173 bytes are intact and the reader is from another generation; \
174 saying `Corrupt` sends an operator to a restore when the fix \
175 is to match the versions up."
176 );
177 }
178 }
179}