melin_server_runtime/ack_policy.rs
1//! Operator-facing ack policy.
2//!
3//! The generic policy types — `Level`, `Clause`, `Policy`, `CursorView`,
4//! `EvalStatus`, `MAX_CLUSTER_SIZE` — live in
5//! `melin_transport_core::ack_policy`. They're imported here for the
6//! crate, so call sites (`crate::ack_policy::*`) reach both the enum and
7//! the machinery it compiles to, and the response stage's ack gate is
8//! built on them.
9//!
10//! What this module owns is the *operator surface*: a small enum that
11//! exposes four named policies (`disk`, `ram`, `disk+ram`, `two-disks`)
12//! via `--ack-policy`, plus the mapping from each policy to the
13//! underlying clause list. The set of policies is server policy, not a
14//! transport-core concern, so it lives here.
15//!
16//! Every policy counts *copies*, never *nodes*: `disk` is satisfied by
17//! whichever node fsyncs first, which is usually the primary but not
18//! always (a disk spike on the primary lets the replica's fsync win).
19//! The names deliberately say what must hold the event at ack time and
20//! nothing about which node does it.
21
22use std::fmt;
23
24pub(crate) use melin_transport_core::ack_policy::{
25 Blocker, Clause, CursorView, EvalStatus, Level, MAX_CLUSTER_SIZE, Policy,
26};
27
28/// Operator-facing ack policy: which copies of an event must exist
29/// before its response is released. Each variant maps to a named
30/// [`Clause`] list composed directly in code. See `docs/replication.md`
31/// for the menu in operational terms.
32///
33/// `clap::ValueEnum` derives `--ack-policy <disk|ram|disk+ram|two-disks>`;
34/// the `disk+ram` spelling is set explicitly because the derive would
35/// kebab-case the variant to `disk-and-ram`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
37pub enum AckPolicy {
38 /// `persisted>=1`. One fsynced copy, on whichever node's disk
39 /// confirms first. Required when running with `--standalone`;
40 /// appropriate for dev/staging deployments without a replica.
41 Disk,
42
43 /// `in_memory>=2`. Two nodes hold the event in memory before the
44 /// client ack; every disk write trails asynchronously off the ack
45 /// path (the journal still fsyncs every batch, it just no longer
46 /// gates responses). Survives any single node failure via
47 /// failover; loses only the un-fsynced tail on a simultaneous
48 /// whole-cluster power loss. For deployments where fsync is slow
49 /// (cloud block storage) or where that bounded RPO is an acceptable
50 /// trade for the lowest ack latency. Fails closed when no replica
51 /// is connected.
52 Ram,
53
54 /// `persisted>=1 && in_memory>=2`. One fsynced copy plus a second
55 /// copy in another node's memory. Single-failure-safe with a brief
56 /// RAM-only window (~80 µs on PLP-backed NVMe) for the second copy.
57 /// The default — typical live deployments. Faster than
58 /// [`TwoDisks`](Self::TwoDisks): an acknowledgement waits for the
59 /// second node to receive the event, not to fsync it. Fails closed
60 /// when no replica is connected.
61 #[value(name = "disk+ram")]
62 DiskAndRam,
63
64 /// `persisted>=2`. Two fsynced copies before the client ack. Zero
65 /// RAM-only window; the gate stalls if no replica is currently
66 /// connected. Compliance-driven deployments.
67 TwoDisks,
68}
69
70impl AckPolicy {
71 /// Build the underlying [`Policy`]. Every variant's clause list is
72 /// hand-constructed from in-range counts, so [`Policy::new`] cannot
73 /// fail — any regression would surface in the unit tests below.
74 pub(crate) fn to_policy(self) -> Policy {
75 let clauses = match self {
76 AckPolicy::Disk => vec![Clause {
77 count: 1,
78 level: Level::Persisted,
79 }],
80 AckPolicy::Ram => vec![Clause {
81 count: 2,
82 level: Level::InMemory,
83 }],
84 AckPolicy::DiskAndRam => vec![
85 Clause {
86 count: 1,
87 level: Level::Persisted,
88 },
89 Clause {
90 count: 2,
91 level: Level::InMemory,
92 },
93 ],
94 AckPolicy::TwoDisks => vec![Clause {
95 count: 2,
96 level: Level::Persisted,
97 }],
98 };
99 Policy::new(clauses).expect("AckPolicy::to_policy: hand-constructed clauses must validate")
100 }
101
102 /// Whether the policy can only be met with a replica: some clause
103 /// counts more than one copy. Derived from [`Self::to_policy`] so it
104 /// cannot drift from the clauses. Builds the policy, so it is for cold
105 /// paths (journaling a change), not per slot.
106 pub(crate) fn needs_replica(self) -> bool {
107 self.to_policy()
108 .clauses()
109 .iter()
110 .any(|clause| clause.count > 1)
111 }
112
113 /// CLI / log-friendly name. Matches the `clap::ValueEnum` spelling.
114 pub fn as_str(self) -> &'static str {
115 match self {
116 AckPolicy::Disk => "disk",
117 AckPolicy::Ram => "ram",
118 AckPolicy::DiskAndRam => "disk+ram",
119 AckPolicy::TwoDisks => "two-disks",
120 }
121 }
122
123 /// Parse the admin-channel / CLI wire spelling. Accepts exactly the
124 /// strings [`as_str`](Self::as_str) emits so operators only have to
125 /// learn one vocabulary across `--ack-policy` and the admin
126 /// `ACK-POLICY` command.
127 pub fn parse(s: &str) -> Option<Self> {
128 match s {
129 "disk" => Some(AckPolicy::Disk),
130 "ram" => Some(AckPolicy::Ram),
131 "disk+ram" => Some(AckPolicy::DiskAndRam),
132 "two-disks" => Some(AckPolicy::TwoDisks),
133 _ => None,
134 }
135 }
136
137 /// Parse the pre-0.15 "durability mode" names, for the deprecated
138 /// `DURABILITY` admin alias. Accepts the current spellings too so
139 /// the alias is a strict superset of [`parse`](Self::parse).
140 /// Removed with the alias in the next minor release.
141 pub fn parse_legacy(s: &str) -> Option<Self> {
142 match s {
143 "local" => Some(AckPolicy::Disk),
144 "replicated" => Some(AckPolicy::Ram),
145 "hybrid" => Some(AckPolicy::DiskAndRam),
146 "durably-replicated" => Some(AckPolicy::TwoDisks),
147 _ => Self::parse(s),
148 }
149 }
150
151 /// Stable u8 discriminant: the byte a journaled `AckPolicyChange`
152 /// carries (`to_byte`), and the one the health gauges
153 /// export.
154 ///
155 /// **These values are a wire format**, not just an in-process ABI:
156 /// they are in every journal holding a policy change, streamed to
157 /// every replica with the entries, and a replica decodes the one its
158 /// lineage holds with `from_byte` to judge
159 /// auto-promotion (see `raft_promotion`). So they must stay
160 /// stable across releases in two directions — the round-trip
161 /// `from_u8(as_u8(x)) == Some(x)` has to hold, *and* a value once
162 /// released can never be reassigned to a different policy, or a
163 /// peer on an older build would silently read the new policy as
164 /// the old one. Add new policies on the next free byte; never
165 /// renumber. (The bytes predate the current names: they were
166 /// assigned to `local`/`hybrid`/`durably-replicated`/`replicated`
167 /// and kept through the rename.)
168 ///
169 /// A peer that does not know a byte gets `None` and refuses to
170 /// auto-promote on it — fail-closed, and the reason replicas must
171 /// be upgraded before a primary starts journaling a new policy.
172 pub fn as_u8(self) -> u8 {
173 match self {
174 AckPolicy::Disk => 0,
175 AckPolicy::DiskAndRam => 1,
176 AckPolicy::TwoDisks => 2,
177 // 3, not a re-sort: these bytes are on the wire (see doc
178 // comment), so a later variant takes the next free one
179 // regardless of where it sits in the operator-facing menu.
180 AckPolicy::Ram => 3,
181 }
182 }
183
184 /// Inverse of [`as_u8`](Self::as_u8). Returns `None` for an unknown
185 /// byte — one a newer build wrote, or a bug. A primary refuses to
186 /// start on a lineage holding one; the response stage, meeting one in
187 /// a change it journaled itself, logs and retains the prior policy
188 /// rather than silently falling back.
189 pub fn from_u8(b: u8) -> Option<Self> {
190 match b {
191 0 => Some(AckPolicy::Disk),
192 1 => Some(AckPolicy::DiskAndRam),
193 2 => Some(AckPolicy::TwoDisks),
194 3 => Some(AckPolicy::Ram),
195 _ => None,
196 }
197 }
198
199 /// The policy a journaled `AckPolicyChange` names: the journal carries
200 /// the [`as_u8`](Self::as_u8) byte opaquely, and this is where it is
201 /// validated. `None` for a byte this build does not know.
202 pub(crate) fn from_byte(byte: melin_journal::AckPolicyByte) -> Option<Self> {
203 Self::from_u8(byte.0)
204 }
205
206 /// The journal's opaque form of this policy.
207 pub(crate) fn to_byte(self) -> melin_journal::AckPolicyByte {
208 melin_journal::AckPolicyByte(self.as_u8())
209 }
210}
211
212impl fmt::Display for AckPolicy {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 f.write_str(self.as_str())
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use clap::ValueEnum;
222
223 const ALL: [AckPolicy; 4] = [
224 AckPolicy::Disk,
225 AckPolicy::DiskAndRam,
226 AckPolicy::TwoDisks,
227 AckPolicy::Ram,
228 ];
229
230 #[test]
231 fn u8_round_trip() {
232 for p in ALL {
233 assert_eq!(AckPolicy::from_u8(p.as_u8()), Some(p));
234 }
235 // Unknown bytes surface as None — the response stage relies on
236 // this to retain the prior policy on a change it cannot read.
237 for b in [4, 5, 255] {
238 assert_eq!(AckPolicy::from_u8(b), None);
239 }
240 }
241
242 /// Only `disk` is met by one node alone: it is the one policy whose
243 /// journaling never restores the halt.
244 #[test]
245 fn only_disk_needs_no_replica() {
246 for p in ALL {
247 assert_eq!(p.needs_replica(), p != AckPolicy::Disk, "{p}");
248 }
249 }
250
251 #[test]
252 fn wire_bytes_are_pinned() {
253 // On the wire since before the rename; see `as_u8`.
254 assert_eq!(AckPolicy::Disk.as_u8(), 0);
255 assert_eq!(AckPolicy::DiskAndRam.as_u8(), 1);
256 assert_eq!(AckPolicy::TwoDisks.as_u8(), 2);
257 assert_eq!(AckPolicy::Ram.as_u8(), 3);
258 }
259
260 #[test]
261 fn parse_matches_as_str() {
262 for p in ALL {
263 assert_eq!(AckPolicy::parse(p.as_str()), Some(p));
264 }
265 for bad in [
266 "",
267 "DISK",
268 "dis",
269 "disk-ram",
270 "disk-and-ram",
271 "two-disk",
272 "disks",
273 "local",
274 "hybrid",
275 "replicated",
276 "durably-replicated",
277 ] {
278 assert_eq!(AckPolicy::parse(bad), None, "{bad:?} should not parse");
279 }
280 }
281
282 #[test]
283 fn parse_legacy_maps_old_names_and_accepts_new_ones() {
284 assert_eq!(AckPolicy::parse_legacy("local"), Some(AckPolicy::Disk));
285 assert_eq!(AckPolicy::parse_legacy("replicated"), Some(AckPolicy::Ram));
286 assert_eq!(
287 AckPolicy::parse_legacy("hybrid"),
288 Some(AckPolicy::DiskAndRam)
289 );
290 assert_eq!(
291 AckPolicy::parse_legacy("durably-replicated"),
292 Some(AckPolicy::TwoDisks)
293 );
294 for p in ALL {
295 assert_eq!(AckPolicy::parse_legacy(p.as_str()), Some(p));
296 }
297 assert_eq!(AckPolicy::parse_legacy("fast"), None);
298 }
299
300 #[test]
301 fn clap_spelling_matches_as_str() {
302 // The admin channel parses `as_str` and the CLI parses the clap
303 // value name; an operator must be able to use the same word in
304 // both places.
305 for p in ALL {
306 let clap_name = p.to_possible_value().expect("every variant is a CLI value");
307 assert_eq!(clap_name.get_name(), p.as_str());
308 }
309 }
310
311 #[test]
312 fn disk_builds_persisted_ge_1() {
313 let p = AckPolicy::Disk.to_policy();
314 assert_eq!(p.clauses().len(), 1);
315 let c = p.clauses()[0];
316 assert_eq!(c.level, Level::Persisted);
317 assert_eq!(c.count, 1);
318 }
319
320 #[test]
321 fn disk_and_ram_builds_persisted_ge_1_and_in_memory_ge_2() {
322 let p = AckPolicy::DiskAndRam.to_policy();
323 assert_eq!(p.clauses().len(), 2);
324 let persisted = p
325 .clauses()
326 .iter()
327 .find(|c| c.level == Level::Persisted)
328 .expect("persisted clause");
329 assert_eq!(persisted.count, 1);
330 let in_mem = p
331 .clauses()
332 .iter()
333 .find(|c| c.level == Level::InMemory)
334 .expect("in_memory clause");
335 assert_eq!(in_mem.count, 2);
336 }
337
338 #[test]
339 fn ram_builds_in_memory_ge_2() {
340 let p = AckPolicy::Ram.to_policy();
341 assert_eq!(p.clauses().len(), 1);
342 let c = p.clauses()[0];
343 assert_eq!(c.level, Level::InMemory);
344 assert_eq!(c.count, 2);
345 }
346
347 #[test]
348 fn ram_fails_closed_on_single_node() {
349 // Same fail-closed semantic as disk+ram: `in_memory>=2` cannot
350 // be satisfied by one node, so the gate must stall rather than
351 // silently weaken to a single copy.
352 let p = AckPolicy::Ram.to_policy();
353 let nodes = [[100u64, 100u64]];
354 let v = CursorView::new(&nodes);
355 assert_eq!(p.evaluate(&v), 0, "ram stalls on single-node view");
356 }
357
358 #[test]
359 fn ram_ignores_every_persisted_cursor() {
360 // The policy's whole point: no disk gates the ack. Primary at
361 // in_memory=100 with fsync trailing at 10; replica at
362 // in_memory=80 with nothing persisted at all.
363 // in_memory>=2: 2nd largest in_memory = 80. Gate = 80 — the
364 // trailing fsyncs on both nodes must not bind.
365 let p = AckPolicy::Ram.to_policy();
366 let nodes = [[100u64, 10u64], [80u64, 0u64]];
367 let v = CursorView::new(&nodes);
368 assert_eq!(p.evaluate(&v), 80);
369 }
370
371 #[test]
372 fn two_disks_builds_persisted_ge_2() {
373 let p = AckPolicy::TwoDisks.to_policy();
374 assert_eq!(p.clauses().len(), 1);
375 let c = p.clauses()[0];
376 assert_eq!(c.level, Level::Persisted);
377 assert_eq!(c.count, 2);
378 }
379
380 #[test]
381 fn no_policy_gates_on_the_primary_in_memory_sentinel() {
382 // The response stage models the primary's in-memory cursor as
383 // `u64::MAX` (events it gates are trivially in-memory on the
384 // primary). A policy containing `in_memory>=1` would therefore
385 // ack instantly and unconditionally — and would also break the
386 // transport-core evaluation's u64::MAX-sentinel reasoning. Pin
387 // the invariant: every clause of every policy is either
388 // persisted-level or requires a second node.
389 for p in ALL {
390 for c in p.to_policy().clauses() {
391 assert!(
392 c.level == Level::Persisted || c.count >= 2,
393 "policy {p}: clause `{c}` would be satisfied by the primary's \
394 in-memory sentinel alone"
395 );
396 }
397 }
398 }
399
400 #[test]
401 fn disk_and_ram_fails_closed_on_single_node() {
402 // The gate must NOT advance when only the primary is present
403 // — in_memory>=2 can't be satisfied. This is the fail-closed
404 // semantic the design call rests on; the dev-evaluator
405 // footgun is caught upstream by the `--standalone` validation
406 // in `server.rs`.
407 let p = AckPolicy::DiskAndRam.to_policy();
408 let nodes = [[100u64, 100u64]];
409 let v = CursorView::new(&nodes);
410 assert_eq!(p.evaluate(&v), 0, "disk+ram stalls on single-node view");
411 }
412
413 #[test]
414 fn disk_and_ram_advances_with_two_nodes_acking_in_memory() {
415 // Primary at persisted=100, in_memory=100; replica at
416 // in_memory=80, persisted=0. Both clauses' nth-largest must
417 // cross to advance.
418 // persisted>=1: 1st largest persisted = 100.
419 // in_memory>=2: 2nd largest in_memory = 80.
420 // Gate = min(100, 80) = 80.
421 let p = AckPolicy::DiskAndRam.to_policy();
422 let nodes = [[100u64, 100u64], [80u64, 0u64]];
423 let v = CursorView::new(&nodes);
424 assert_eq!(p.evaluate(&v), 80);
425 }
426
427 #[test]
428 fn disk_is_satisfied_by_whichever_node_fsyncs_first() {
429 // `disk` counts copies, not nodes: the primary's fsync is
430 // stalled at 10 (disk spike) while the replica has persisted
431 // through 90. persisted>=1 takes the largest persisted cursor,
432 // so the replica's disk opens the gate.
433 let p = AckPolicy::Disk.to_policy();
434 let nodes = [[100u64, 10u64], [90u64, 90u64]];
435 let v = CursorView::new(&nodes);
436 assert_eq!(p.evaluate(&v), 90);
437 }
438}