Skip to main content

melin_server_runtime/
durability_policy.rs

1//! Operator-facing durability mode.
2//!
3//! The generic policy types — [`Level`], [`Clause`], [`Policy`],
4//! [`CursorView`], [`EvalStatus`], [`PolicyError`], [`MAX_CLUSTER_SIZE`]
5//! — live in `melin_transport_core::durability_policy`. They're
6//! re-exported here so existing call sites (`crate::durability_policy::*`)
7//! keep working, and the response stage's ack gate is built on them.
8//!
9//! What this module owns is the *operator surface*: a small enum that
10//! exposes four named modes (`local`, `replicated`, `hybrid`,
11//! `durably-replicated`) via `--durability-mode`, plus the mapping from
12//! each mode to the underlying clause list. The set of modes is
13//! exchange-server policy, not a transport-core concern, so it lives
14//! here.
15
16use std::fmt;
17
18pub use melin_transport_core::durability_policy::{
19    Blocker, Clause, CursorView, EvalStatus, Level, MAX_CLUSTER_SIZE, Policy, PolicyError,
20};
21
22/// Operator-facing durability mode. Each variant maps to one of four
23/// named policies that compose the underlying [`Clause`] list directly
24/// in code, replacing the legacy `--durability-policy <STRING>` DSL.
25/// See `docs/replication.md` for the mode menu in operational terms.
26///
27/// Sentinel for "no primary acking mode observed yet" in the
28/// replica-side gauge the replication stream feeds (see
29/// `ReplicaControlPlane::primary_acking_mode`). Deliberately outside
30/// the `as_u8` range so `from_u8` maps it to `None`; `u8::MAX` leaves
31/// the low values free for future modes.
32pub const ACKING_MODE_UNKNOWN: u8 = u8::MAX;
33
34/// `clap::ValueEnum` derives `--durability-mode <local|replicated|hybrid|durably-replicated>`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
36pub enum DurabilityMode {
37    /// `persisted>=1`. Single-node durability — the primary's
38    /// PLP-backed NVMe write is the only confirmation needed.
39    /// Required when running with `--standalone`; appropriate for
40    /// dev/staging deployments without a replica.
41    Local,
42
43    /// `in_memory>=2`. RAM-quorum durability — a second node holds the
44    /// event in memory before the client ack; every disk write trails
45    /// asynchronously off the ack path (the journal still fsyncs every
46    /// batch, it just no longer gates responses). Survives any single
47    /// node failure via failover; loses only the un-fsynced tail on a
48    /// simultaneous whole-cluster power loss. For deployments where
49    /// fsync is slow (cloud block storage) or where that bounded RPO
50    /// is an acceptable trade for the lowest ack latency. Fails closed
51    /// when no replica is connected.
52    Replicated,
53
54    /// `persisted>=1 && in_memory>=2`. One durable copy on the
55    /// primary's disk plus an in-memory ack from a second node.
56    /// Single-failure-safe with a brief RAM-only window (~80 µs on
57    /// PLP-backed NVMe) for the secondary copy. The default — typical
58    /// live trading deployments. Saves ~50–80 µs per fill vs
59    /// [`DurablyReplicated`](Self::DurablyReplicated). Fails closed
60    /// when no replica is connected.
61    Hybrid,
62
63    /// `persisted>=2`. Two durable copies before client ack. Zero
64    /// RAM-only window; the gate stalls if no replica is currently
65    /// connected. Compliance-driven venues.
66    DurablyReplicated,
67}
68
69impl DurabilityMode {
70    /// Build the underlying [`Policy`] for this mode. Every variant's
71    /// clause list is hand-constructed from in-range counts, so
72    /// [`Policy::new`] cannot fail — any regression would surface in
73    /// the unit tests below.
74    pub fn to_policy(self) -> Policy {
75        let clauses = match self {
76            DurabilityMode::Local => vec![Clause {
77                count: 1,
78                level: Level::Persisted,
79            }],
80            DurabilityMode::Replicated => vec![Clause {
81                count: 2,
82                level: Level::InMemory,
83            }],
84            DurabilityMode::Hybrid => vec![
85                Clause {
86                    count: 1,
87                    level: Level::Persisted,
88                },
89                Clause {
90                    count: 2,
91                    level: Level::InMemory,
92                },
93            ],
94            DurabilityMode::DurablyReplicated => vec![Clause {
95                count: 2,
96                level: Level::Persisted,
97            }],
98        };
99        Policy::new(clauses)
100            .expect("DurabilityMode::to_policy: hand-constructed clauses must validate")
101    }
102
103    /// CLI / log-friendly name. Matches the `clap::ValueEnum`
104    /// kebab-cased spelling.
105    pub fn as_str(self) -> &'static str {
106        match self {
107            DurabilityMode::Local => "local",
108            DurabilityMode::Replicated => "replicated",
109            DurabilityMode::Hybrid => "hybrid",
110            DurabilityMode::DurablyReplicated => "durably-replicated",
111        }
112    }
113
114    /// Parse the admin-channel / CLI wire spelling. Accepts the same
115    /// kebab-cased strings [`as_str`](Self::as_str) emits so operators
116    /// only have to learn one vocabulary across `--durability-mode`
117    /// and the admin `DURABILITY` command.
118    pub fn parse(s: &str) -> Option<Self> {
119        match s {
120            "local" => Some(DurabilityMode::Local),
121            "replicated" => Some(DurabilityMode::Replicated),
122            "hybrid" => Some(DurabilityMode::Hybrid),
123            "durably-replicated" => Some(DurabilityMode::DurablyReplicated),
124            _ => None,
125        }
126    }
127
128    /// Stable u8 discriminant. The response stage publishes the
129    /// operator-selected mode through an [`AtomicU8`] so it can detect
130    /// a runtime swap (via the admin `DURABILITY` command) with a
131    /// relaxed load on every gate iteration — cheaper than crossing a
132    /// `Mutex` or carrying a refcounted `Arc<Policy>` snapshot.
133    ///
134    /// **These values are a wire format**, not just an in-process ABI:
135    /// the primary stamps this byte onto every `StreamStart` and
136    /// `Heartbeat`, and the replica decodes it with [`from_u8`] to
137    /// judge auto-promotion (see `raft_promotion`). So they must stay
138    /// stable across releases in two directions — the round-trip
139    /// `from_u8(as_u8(x)) == Some(x)` has to hold, *and* a value once
140    /// released can never be reassigned to a different mode, or a peer
141    /// on an older build would silently read the new mode as the old
142    /// one. Add new modes on the next free byte; never renumber.
143    ///
144    /// A peer that does not know a byte gets `None` and refuses to
145    /// auto-promote on it — fail-closed, and the reason replicas must
146    /// be upgraded before a primary starts advertising a new mode.
147    pub fn as_u8(self) -> u8 {
148        match self {
149            DurabilityMode::Local => 0,
150            DurabilityMode::Hybrid => 1,
151            DurabilityMode::DurablyReplicated => 2,
152            // 3, not a re-sort: these bytes are on the wire (see doc
153            // comment), so a later variant takes the next free one
154            // regardless of where it sits in the operator-facing menu.
155            DurabilityMode::Replicated => 3,
156        }
157    }
158
159    /// Inverse of [`as_u8`]. Returns `None` for an unknown byte —
160    /// callers initialise the atomic from a valid mode and the admin
161    /// path only writes `as_u8(parse(s)?)`, so an unknown byte
162    /// indicates memory corruption or a programmer bug. The response
163    /// stage logs and retains the prior mode in that case rather than
164    /// silently falling back.
165    pub fn from_u8(b: u8) -> Option<Self> {
166        match b {
167            0 => Some(DurabilityMode::Local),
168            1 => Some(DurabilityMode::Hybrid),
169            2 => Some(DurabilityMode::DurablyReplicated),
170            3 => Some(DurabilityMode::Replicated),
171            _ => None,
172        }
173    }
174}
175
176impl fmt::Display for DurabilityMode {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(self.as_str())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn durability_mode_u8_round_trip() {
188        for m in [
189            DurabilityMode::Local,
190            DurabilityMode::Hybrid,
191            DurabilityMode::DurablyReplicated,
192            DurabilityMode::Replicated,
193        ] {
194            assert_eq!(DurabilityMode::from_u8(m.as_u8()), Some(m));
195        }
196        // Unknown bytes surface as None — the response stage relies on
197        // this to detect a corrupted atomic and retain the prior mode.
198        for b in [4, 5, 255] {
199            assert_eq!(DurabilityMode::from_u8(b), None);
200        }
201    }
202
203    #[test]
204    fn durability_mode_parse_matches_as_str() {
205        for m in [
206            DurabilityMode::Local,
207            DurabilityMode::Hybrid,
208            DurabilityMode::DurablyReplicated,
209            DurabilityMode::Replicated,
210        ] {
211            assert_eq!(DurabilityMode::parse(m.as_str()), Some(m));
212        }
213        for bad in ["", "LOCAL", "hyb", "fast", "durably_replicated", "repl"] {
214            assert_eq!(DurabilityMode::parse(bad), None, "{bad:?} should not parse");
215        }
216    }
217
218    #[test]
219    fn mode_local_builds_persisted_ge_1() {
220        let p = DurabilityMode::Local.to_policy();
221        assert_eq!(p.clauses().len(), 1);
222        let c = p.clauses()[0];
223        assert_eq!(c.level, Level::Persisted);
224        assert_eq!(c.count, 1);
225    }
226
227    #[test]
228    fn mode_hybrid_builds_persisted_ge_1_and_in_memory_ge_2() {
229        let p = DurabilityMode::Hybrid.to_policy();
230        assert_eq!(p.clauses().len(), 2);
231        let persisted = p
232            .clauses()
233            .iter()
234            .find(|c| c.level == Level::Persisted)
235            .expect("persisted clause");
236        assert_eq!(persisted.count, 1);
237        let in_mem = p
238            .clauses()
239            .iter()
240            .find(|c| c.level == Level::InMemory)
241            .expect("in_memory clause");
242        assert_eq!(in_mem.count, 2);
243    }
244
245    #[test]
246    fn mode_replicated_builds_in_memory_ge_2() {
247        let p = DurabilityMode::Replicated.to_policy();
248        assert_eq!(p.clauses().len(), 1);
249        let c = p.clauses()[0];
250        assert_eq!(c.level, Level::InMemory);
251        assert_eq!(c.count, 2);
252    }
253
254    #[test]
255    fn mode_replicated_fails_closed_on_single_node() {
256        // Same fail-closed semantic as hybrid: `in_memory>=2` cannot be
257        // satisfied by the primary alone, so the gate must stall rather
258        // than silently weaken to local durability.
259        let p = DurabilityMode::Replicated.to_policy();
260        let nodes = [[100u64, 100u64]];
261        let v = CursorView::new(&nodes);
262        assert_eq!(p.evaluate(&v), 0, "replicated stalls on single-node view");
263    }
264
265    #[test]
266    fn mode_replicated_ignores_every_persisted_cursor() {
267        // The mode's whole point: no disk gates the ack. Primary at
268        // in_memory=100 with fsync trailing at 10; replica at
269        // in_memory=80 with nothing persisted at all.
270        // in_memory>=2: 2nd largest in_memory = 80. Gate = 80 — the
271        // trailing fsyncs on both nodes must not bind.
272        let p = DurabilityMode::Replicated.to_policy();
273        let nodes = [[100u64, 10u64], [80u64, 0u64]];
274        let v = CursorView::new(&nodes);
275        assert_eq!(p.evaluate(&v), 80);
276    }
277
278    #[test]
279    fn mode_durably_replicated_builds_persisted_ge_2() {
280        let p = DurabilityMode::DurablyReplicated.to_policy();
281        assert_eq!(p.clauses().len(), 1);
282        let c = p.clauses()[0];
283        assert_eq!(c.level, Level::Persisted);
284        assert_eq!(c.count, 2);
285    }
286
287    #[test]
288    fn no_mode_gates_on_the_primary_in_memory_sentinel() {
289        // The response stage models the primary's in-memory cursor as
290        // `u64::MAX` (events it gates are trivially in-memory on the
291        // primary). A mode whose policy contained `in_memory>=1` would
292        // therefore ack instantly and unconditionally — and would also
293        // break the transport-core evaluation's u64::MAX-sentinel
294        // reasoning. Pin the invariant: every clause of every mode is
295        // either persisted-level or requires a second node.
296        for m in [
297            DurabilityMode::Local,
298            DurabilityMode::Replicated,
299            DurabilityMode::Hybrid,
300            DurabilityMode::DurablyReplicated,
301        ] {
302            for c in m.to_policy().clauses() {
303                assert!(
304                    c.level == Level::Persisted || c.count >= 2,
305                    "mode {m}: clause `{c}` would be satisfied by the primary's \
306                     in-memory sentinel alone"
307                );
308            }
309        }
310    }
311
312    #[test]
313    fn mode_hybrid_fails_closed_on_single_node() {
314        // The gate must NOT advance when only the primary is present
315        // — in_memory>=2 can't be satisfied. This is the fail-closed
316        // semantic the design call rests on; the dev-evaluator
317        // footgun is caught upstream by the `--standalone` validation
318        // in `server.rs`.
319        let p = DurabilityMode::Hybrid.to_policy();
320        let nodes = [[100u64, 100u64]];
321        let v = CursorView::new(&nodes);
322        assert_eq!(p.evaluate(&v), 0, "hybrid stalls on single-node view");
323    }
324
325    #[test]
326    fn mode_hybrid_advances_with_two_nodes_acking_in_memory() {
327        // Primary at persisted=100, in_memory=100; replica at
328        // in_memory=80, persisted=0. Both clauses' nth-largest must
329        // cross to advance.
330        // persisted>=1: 1st largest persisted = 100.
331        // in_memory>=2: 2nd largest in_memory = 80.
332        // Gate = min(100, 80) = 80.
333        let p = DurabilityMode::Hybrid.to_policy();
334        let nodes = [[100u64, 100u64], [80u64, 0u64]];
335        let v = CursorView::new(&nodes);
336        assert_eq!(p.evaluate(&v), 80);
337    }
338}