reddb_server/replication/witness.rs
1//! Witness runtime profile (issue #836, PRD #819, ADR 0030).
2//!
3//! A **witness** is a node that runs *only* the control-plane supervisor —
4//! the vote path of the [election core](super::election) — and boots **no
5//! data plane** (no storage engine, no WAL, no replication streaming). It
6//! holds no data and can never be promoted to primary, but its vote counts
7//! toward the election quorum. This makes `2 data nodes + 1 witness` a valid
8//! HA shape (the Mongo "arbiter" idea), so an operator gets automatic
9//! failover without standing up a third *data* replica.
10//!
11//! ADR 0030 fixes the shape: "*The supervisor is therefore a module every
12//! node runs; a witness is a node that runs only that module.*" and
13//! "*Witness members require a build/runtime profile that excludes the data
14//! plane.*" This module is that profile.
15//!
16//! ## What a witness is, structurally
17//!
18//! * [`RuntimeProfile`] — the boot-time choice between a data-bearing node
19//! ([`RuntimeProfile::Data`], supervisor + data plane) and a witness
20//! ([`RuntimeProfile::Witness`], supervisor only). `boots_data_plane()` is
21//! the one bit the boot pipeline branches on.
22//! * [`WitnessSupervisor`] — a booted witness. It is exactly a durable
23//! [`Voter`](super::election::Voter) plus the node's shared
24//! [`NodeIdentity`](crate::cluster::NodeIdentity); there is, by
25//! construction, nothing else. The absence of a data-plane field *is* the
26//! guarantee — a witness cannot accidentally serve a read or accept a
27//! write because it holds no engine to do so.
28//!
29//! ## Shared identity, not a second namespace
30//!
31//! A witness authenticates with the **same per-node mTLS identity** a data
32//! member uses: [`NodeIdentity`](crate::cluster::NodeIdentity) is the
33//! validated X.509 subject of the node certificate, and the same type backs
34//! both [`ReplicationPeerIdentity`](crate::cluster::ReplicationPeerIdentity)
35//! and [`ClusterVoterIdentity`](crate::cluster::ClusterVoterIdentity). The
36//! witness's membership id is that identity's subject, so its votes land in
37//! the same identity namespace as every data member's acks — a witness is
38//! not a second-class peer with a parallel auth path.
39
40use std::path::PathBuf;
41
42use crate::cluster::NodeIdentity;
43
44use super::election::{
45 FileLastVoteStore, LastVoteError, LastVoteStore, Member, MemberKind, VoteDecision, VoteRequest,
46 Voter,
47};
48
49/// Which planes a node boots.
50///
51/// Every node runs the control-plane supervisor (the vote path). The profile
52/// decides whether the *data plane* — storage engine, WAL, replication
53/// streaming — is constructed alongside it.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum RuntimeProfile {
56 /// A data-bearing node: supervisor **and** data plane. Holds data,
57 /// streams WAL, and can be promoted to primary.
58 Data,
59 /// A witness: the supervisor / vote path **only**. Holds no data, boots
60 /// no data plane, and can never be promoted (ADR 0030).
61 Witness,
62}
63
64impl RuntimeProfile {
65 /// Does this profile boot the data plane (storage engine + WAL +
66 /// replication streaming)? Only [`RuntimeProfile::Data`] does — a witness
67 /// is supervisor-only.
68 pub fn boots_data_plane(self) -> bool {
69 matches!(self, RuntimeProfile::Data)
70 }
71
72 /// The supervisor module runs on *every* profile — that is the whole
73 /// point of the decoupled control plane (ADR 0030). A witness is the
74 /// degenerate node that runs nothing else.
75 pub fn boots_supervisor(self) -> bool {
76 true
77 }
78
79 /// The membership kind this profile presents to the election quorum.
80 pub fn member_kind(self) -> MemberKind {
81 match self {
82 RuntimeProfile::Data => MemberKind::Data,
83 RuntimeProfile::Witness => MemberKind::Witness,
84 }
85 }
86}
87
88/// A booted witness node: the control-plane supervisor with no data plane.
89///
90/// A witness is a [`Voter`] over a durable last-vote store plus the node's
91/// shared [`NodeIdentity`] — and nothing else. There is intentionally no
92/// engine, no WAL, and no replication handle on this struct: a witness
93/// *cannot* serve data because it holds none.
94///
95/// The store type is generic so production uses the durable
96/// [`FileLastVoteStore`] (ADR 0030: "the supervisor needs durable per-node
97/// vote state to prevent double-voting across restarts") while tests use an
98/// in-memory store.
99pub struct WitnessSupervisor<S: LastVoteStore> {
100 identity: NodeIdentity,
101 voter: Voter<S>,
102}
103
104impl<S: LastVoteStore> WitnessSupervisor<S> {
105 /// Boot a witness supervisor over `store`, identified by the shared
106 /// per-node `identity`. The voter id is the identity's certificate
107 /// subject, so the witness votes under the same identity a data member
108 /// would replicate under.
109 pub fn new(identity: NodeIdentity, store: S) -> Self {
110 let voter = Voter::new(identity.as_str(), store);
111 Self { identity, voter }
112 }
113
114 /// A witness always runs the witness profile.
115 pub fn profile(&self) -> RuntimeProfile {
116 RuntimeProfile::Witness
117 }
118
119 /// A witness never boots a data plane — invariant by construction, stated
120 /// here so callers (and the boot pipeline) can assert it without reaching
121 /// into the profile.
122 pub fn boots_data_plane(&self) -> bool {
123 false
124 }
125
126 /// The shared per-node identity this witness authenticates with — the
127 /// same [`NodeIdentity`](crate::cluster::NodeIdentity) type a data member
128 /// presents over mTLS.
129 pub fn identity(&self) -> &NodeIdentity {
130 &self.identity
131 }
132
133 /// This witness's entry in the supervisor's membership view: a vote-only
134 /// [`MemberKind::Witness`], always [`VotingState::Voting`](super::election::VotingState::Voting).
135 /// It counts toward quorum but is never electable.
136 pub fn member(&self) -> Member {
137 Member::witness(self.identity.as_str())
138 }
139
140 /// Consider a candidate's vote request against the current commit
141 /// watermark — the only control-plane action a witness performs. The
142 /// watermark rule and the durable double-vote guard live in the
143 /// [`Voter`], so a witness applies the exact same safety rule a data
144 /// voter does.
145 pub fn consider_vote(
146 &self,
147 req: &VoteRequest,
148 commit_watermark: u64,
149 ) -> Result<VoteDecision, LastVoteError> {
150 self.voter.consider(req, commit_watermark)
151 }
152
153 /// The highest term this witness has durably recorded.
154 pub fn current_term(&self) -> Result<u64, LastVoteError> {
155 self.voter.current_term()
156 }
157}
158
159impl WitnessSupervisor<FileLastVoteStore> {
160 /// Boot a witness with a durable, on-disk last-vote store at
161 /// `last_vote_path` — the production constructor. Survives a restart so a
162 /// witness that crashes mid-term never double-votes (ADR 0030).
163 pub fn with_durable_store(identity: NodeIdentity, last_vote_path: impl Into<PathBuf>) -> Self {
164 Self::new(identity, FileLastVoteStore::new(last_vote_path))
165 }
166}
167
168#[cfg(test)]
169mod tests;