Skip to main content

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;