Skip to main content

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}