Skip to main content

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}