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}