Skip to main content

weir_core/
durability.rs

1//! The [`Durability`] tier — the per-record durability guarantee a producer
2//! requests in the frame header.
3
4/// Durability tier requested by the producer for a given record.
5///
6/// There are two tiers because there are two behaviours. `Durable` writes and
7/// `fdatasync`s the record — as part of one batch-boundary group fsync —
8/// *before* the ACK, so an ack means the bytes are on stable storage and will
9/// replay after a crash. `Buffered` trades that for latency: it acks after the
10/// in-memory write, before any fsync, so a `Buffered` ack survives a process
11/// crash but not power loss.
12///
13/// # Wire values
14///
15/// `0x01` and `0x03` are fixed and must not change without a `WIRE_VERSION`
16/// bump. **`0x02` is retired and permanently reserved**:
17/// it was `Batched`, which fsynced once per batch while `Sync` fsynced once per
18/// record. Both moved to the batch-boundary group fsync, at which point the two
19/// tiers became the same thing. `try_from` still accepts `0x02` so producers
20/// built against weir 1.x keep working; the encoder only ever emits `0x01`.
21/// Reusing `0x02` for a future tier would turn every 1.x client into a silent
22/// mis-tier, so it stays reserved.
23#[repr(u8)]
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Durability {
26    /// Durable before ACK: written and `fdatasync`ed via the batch-boundary
27    /// group fsync before the ACK is sent.
28    ///
29    /// # Evidence under power loss
30    ///
31    /// **5,311 simulated power-loss episodes over 24 hours, 42,814,591
32    /// acknowledged records, zero lost**
33    /// (`docs/benchmarks/chaos-phase2/2026-08-28-first-power-loss-measurement.md`).
34    /// Zero I1 violations, zero I2 leaks, zero ledger conflicts, and the WAB
35    /// was completely empty at shutdown — nothing quarantined, nothing
36    /// unconfirmed, nothing dead-lettered.
37    ///
38    /// The fault is a `dm-flakey drop_writes` device that discards writes
39    /// while reporting fsync success, applied after the daemon is killed so
40    /// the loss window contains only data that was dirty at the instant of
41    /// death — the definition of power loss.
42    ///
43    /// Each episode carried an independent check that the fault actually
44    /// fired: a known block written before the fault, overwritten while it
45    /// was engaged, and read back after the remount. **It was destroyed in
46    /// all 5,311.** This matters more here than for
47    /// [`Buffered`](Self::Buffered): a tier that loses nothing cannot use
48    /// "something went missing" as proof the fault was real, so without that
49    /// check the result would be unfalsifiable.
50    ///
51    /// Zero violations across 5,311 episodes bounds the per-episode violation
52    /// rate at roughly 0.06% with 95% confidence. It is not proof of
53    /// correctness — no finite run is — but it is the strongest evidence
54    /// behind weir's central claim.
55    Durable = 0x01,
56    /// Memory write only. ACK is sent after the record enters the in-memory
57    /// queue — survives a process crash, but not power loss.
58    ///
59    /// # How much is at risk
60    ///
61    /// Measured, not estimated: 706 real power-loss episodes on ext4 over
62    /// dm-flakey, 604,485,602 acknowledged records
63    /// (`docs/benchmarks/chaos-phase2/2026-08-28-first-power-loss-measurement.md`).
64    ///
65    /// Loss is **uniformly distributed between zero and one writeback
66    /// interval** — a power cut lands at a random point in a periodic
67    /// writeback cycle, so what has not yet reached the platter is uniform
68    /// between "just flushed" and "about to flush". The ceiling, not the mean,
69    /// is what characterises the tier.
70    ///
71    /// On that hardware, at ~72,000 records/s, the ceiling was **1.76 seconds
72    /// of acknowledged writes** (126,782 records) with a median of 0.89 s.
73    ///
74    /// What generalises is the *shape*: the bound is one writeback interval of
75    /// whatever your throughput is, and it does not grow with uptime, queue
76    /// depth, or how long the process has been running. The specific figures
77    /// are a property of that filesystem, its mount options and that record
78    /// rate — measure your own if the number matters to you.
79    ///
80    /// # WAB compression raises this materially
81    ///
82    /// The figures above are for the default uncompressed WAB. A 40-episode
83    /// controlled comparison — identical in every parameter except
84    /// `wab_compression` — found that enabling zstd makes the loss **bimodal**
85    /// rather than uniform, and roughly triples the ceiling: 449,876 records
86    /// (~5.7 s) against 124,897 (~1.8 s). The two modes separate cleanly, with
87    /// no episode landing between 127,711 and 307,263, and the lower mode
88    /// reproduces the uncompressed distribution almost exactly. Compression
89    /// packs far more records behind each unflushed byte, so a power cut takes
90    /// proportionally more of them.
91    ///
92    /// **Correctness is unaffected** — zero I1 and I2 violations, and recovery
93    /// quarantined torn compressed segments at the same rate as uncompressed
94    /// ones. This is a change in how much the tier can lose, not in whether it
95    /// keeps its contract.
96    ///
97    /// Treat the multiplier as indicative, not as a number to plan with: the
98    /// chaos load generator's payload is far more compressible than real data,
99    /// so the mechanism is demonstrated but its size on a realistic workload is
100    /// unmeasured.
101    ///
102    /// [`Durable`](Self::Durable) is not exposed this way by construction: it
103    /// `fdatasync`s at the batch boundary before acking. Note the run above
104    /// was single-tier (`tier="U"`), so it measured Buffered only and is not
105    /// itself evidence about Durable.
106    Buffered = 0x03,
107}
108
109// These deprecated consts are named `Sync` and `Batched` on purpose: they are
110// aliases for the old variant names, and being spelled exactly like those
111// variants is their entire reason to exist — a 1.x caller's `Durability::Sync`
112// / `Durability::Batched` must keep compiling unchanged through the
113// deprecation path. Renaming them to satisfy `non_upper_case_globals` would
114// silently break that path, so the lint is suppressed here instead.
115#[allow(non_upper_case_globals)]
116impl Durability {
117    /// Former name of [`Durability::Durable`].
118    #[deprecated(since = "2.0.0", note = "renamed to `Durable`")]
119    pub const Sync: Self = Self::Durable;
120    /// Former tier that was identical to `Sync` in behaviour. See the type docs.
121    #[deprecated(
122        since = "2.0.0",
123        note = "`Batched` was always identical to `Sync`; use `Durable`"
124    )]
125    pub const Batched: Self = Self::Durable;
126}
127
128impl From<Durability> for u8 {
129    /// The canonical wire byte for this tier. Never returns the retired `0x02`.
130    fn from(d: Durability) -> u8 {
131        d as u8
132    }
133}
134
135impl std::fmt::Display for Durability {
136    /// Also the value of the `tier` label on
137    /// `weir_records_{accepted,ack,nack}_total`.
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        let s = match self {
140            Durability::Durable => "durable",
141            Durability::Buffered => "buffered",
142        };
143        write!(f, "{s}")
144    }
145}
146
147/// Error returned when a `u8` does not map to a known `Durability` variant.
148/// Preserves the raw byte for logging by the caller.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct UnknownDurability(pub u8);
151
152impl std::fmt::Display for UnknownDurability {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        write!(f, "unknown durability byte: {:#04x}", self.0)
155    }
156}
157
158impl std::error::Error for UnknownDurability {}
159
160impl TryFrom<u8> for Durability {
161    type Error = UnknownDurability;
162
163    /// Permissive by design: `0x02` (retired `Batched`) decodes to
164    /// [`Durability::Durable`] so weir 1.x producers keep working.
165    fn try_from(value: u8) -> Result<Self, Self::Error> {
166        match value {
167            0x01 | 0x02 => Ok(Durability::Durable),
168            0x03 => Ok(Durability::Buffered),
169            v => Err(UnknownDurability(v)),
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn wire_values_are_frozen() {
180        assert_eq!(Durability::Durable as u8, 0x01);
181        assert_eq!(Durability::Buffered as u8, 0x03);
182    }
183
184    #[test]
185    fn legacy_batched_byte_still_decodes_to_durable() {
186        // weir 1.3.1 is live and 0x02 is a published contract. A 1.x client
187        // pushing Batched must keep working against a 2.0 daemon.
188        assert_eq!(Durability::try_from(0x02).unwrap(), Durability::Durable);
189    }
190
191    #[test]
192    fn encoder_never_emits_the_retired_byte() {
193        // Decode is permissive, encode is canonical.
194        assert_eq!(u8::from(Durability::Durable), 0x01);
195        assert_ne!(u8::from(Durability::Durable), 0x02);
196    }
197
198    #[test]
199    fn unknown_bytes_are_still_rejected() {
200        assert!(Durability::try_from(0x00).is_err());
201        assert!(Durability::try_from(0x04).is_err());
202        assert_eq!(
203            Durability::try_from(0x7f).unwrap_err(),
204            UnknownDurability(0x7f)
205        );
206    }
207
208    #[test]
209    fn deprecated_aliases_resolve_to_durable() {
210        #![allow(deprecated)]
211        assert_eq!(Durability::Sync, Durability::Durable);
212        assert_eq!(Durability::Batched, Durability::Durable);
213    }
214
215    #[test]
216    fn display_matches_the_metric_label() {
217        assert_eq!(Durability::Durable.to_string(), "durable");
218        assert_eq!(Durability::Buffered.to_string(), "buffered");
219    }
220}