Skip to main content

reddb_server/replication/
fence.rs

1//! Stale-term fencing for a returning ex-primary (issue #835, PRD #819, ADR 0030).
2//!
3//! After a failover the cluster serves a *new term*. A former primary that
4//! rejoins on its old, **stale** term must not be able to corrupt the new
5//! timeline — its term-stamped writes and its stream handshakes have to be
6//! refused until it re-syncs and adopts the new term. This module is the
7//! reusable term-comparison primitive both fencing boundaries share:
8//!
9//! * **Apply boundary** — a replica rejects a WAL/logical record whose term
10//!   is behind its current term. The live replica apply path routes through
11//!   [`TermFence::admit_record`] in
12//!   [`super::logical::LogicalChangeApplier::apply`].
13//! * **Handshake boundary** — when a node opens a replication stream it
14//!   declares the term it is streaming under. [`TermFence::admit_handshake`]
15//!   refuses a handshake whose declared term is behind the current term, so a
16//!   stale ex-primary cannot even establish the stream.
17//! * **Lease boundary** — a serverless writer lease is stamped with the term
18//!   it was taken under; a holder whose term is behind the current term fails
19//!   closed before mutating remote artifacts.
20//!
21//! The decision is deliberately the data-path twin of the election-side
22//! [`super::RefusalReason::StaleTerm`]:
23//!
24//! * `incoming == current` → **admit** at the live term;
25//! * `incoming  > current` → a newer timeline supersedes ours, so **adopt**
26//!   the new term (persisted durably) and then admit. This is how a replica
27//!   moves forward when the legitimate new primary streams to it;
28//! * `incoming  < current` → **fenced**: a superseded primary, refused.
29//!
30//! The current term is held behind a [`TermStore`] so adoption is durable —
31//! a replica that crashes after adopting term *N* comes back fencing stale
32//! term *N-1* records rather than briefly accepting them. Production wires the
33//! file-backed store alongside the node's other durable replication state;
34//! tests use the in-memory store.
35
36pub use reddb_file::FileTermStore;
37
38/// The boundary at which a term-stamped message is being admitted. Only
39/// affects diagnostics — the term rule is identical at both.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum FenceBoundary {
42    /// A WAL/logical record being applied on a replica.
43    Apply,
44    /// A replication stream handshake declaring the streamer's term.
45    Handshake,
46    /// A serverless writer lease attempting to mutate under its lease term.
47    Lease,
48}
49
50impl FenceBoundary {
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Apply => "apply",
54            Self::Handshake => "handshake",
55            Self::Lease => "lease",
56        }
57    }
58}
59
60/// The one shared stale-term predicate: only a strictly older incoming term is
61/// stale. Equal terms are retries; newer terms are adoption candidates.
62#[inline]
63pub fn term_is_stale(incoming_term: u64, current_term: u64) -> bool {
64    incoming_term < current_term
65}
66
67/// A replication-stream handshake as seen by the admitting replica.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct StreamHandshake {
70    pub peer_id: String,
71    pub term: u64,
72}
73
74impl StreamHandshake {
75    pub fn new(peer_id: impl Into<String>, term: u64) -> Self {
76        Self {
77            peer_id: peer_id.into(),
78            term,
79        }
80    }
81}
82
83/// Why the term fence refused a message: the incoming term is behind the
84/// current term, so the sender is a deposed primary on a superseded timeline.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct StaleTermFenced {
87    pub boundary: FenceBoundary,
88    pub incoming_term: u64,
89    pub current_term: u64,
90}
91
92impl std::fmt::Display for StaleTermFenced {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(
95            f,
96            "fenced stale-term {} message: incoming term {} is behind current term {}",
97            self.boundary.as_str(),
98            self.incoming_term,
99            self.current_term
100        )
101    }
102}
103
104impl std::error::Error for StaleTermFenced {}
105
106pub type StaleTermRejection = StaleTermFenced;
107
108/// The verdict of the term fence for one incoming term-stamped message.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum FenceVerdict {
111    /// `incoming == current`: admit at the live term.
112    Admit { term: u64 },
113    /// `incoming > current`: a newer timeline. The fence adopted `new_term`
114    /// (persisting it) and the message is admitted under it.
115    Adopt { new_term: u64 },
116    /// `incoming < current`: stale — refused.
117    Fenced(StaleTermFenced),
118}
119
120impl FenceVerdict {
121    /// Was the message let through (either at the live term or after adopting
122    /// a newer one)?
123    pub fn is_admitted(&self) -> bool {
124        matches!(self, Self::Admit { .. } | Self::Adopt { .. })
125    }
126
127    /// Was the message fenced as stale?
128    pub fn is_fenced(&self) -> bool {
129        matches!(self, Self::Fenced(_))
130    }
131}
132
133/// Error reading or persisting the durable current term.
134#[derive(Debug)]
135pub enum TermStoreError {
136    Io(std::io::Error),
137    InvalidFormat(String),
138}
139
140impl std::fmt::Display for TermStoreError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            Self::Io(err) => write!(f, "term store io error: {err}"),
144            Self::InvalidFormat(msg) => write!(f, "invalid term store format: {msg}"),
145        }
146    }
147}
148
149impl std::error::Error for TermStoreError {}
150
151impl From<reddb_file::RdbFileError> for TermStoreError {
152    fn from(value: reddb_file::RdbFileError) -> Self {
153        match value {
154            reddb_file::RdbFileError::Io(err) => Self::Io(err),
155            reddb_file::RdbFileError::InvalidOperation(msg) => Self::InvalidFormat(msg),
156            // The zone message names the zone and points at scrub/salvage
157            // (ADR 0074 §2/§4); keep it verbatim rather than flattening it.
158            err @ reddb_file::RdbFileError::ZoneUnrecoverable { .. } => {
159                Self::InvalidFormat(err.to_string())
160            }
161        }
162    }
163}
164
165/// Durable store for a node's current replication term. The default (when
166/// nothing was ever written) is
167/// [`DEFAULT_REPLICATION_TERM`](crate::replication::DEFAULT_REPLICATION_TERM),
168/// matching the term records carry before any failover.
169pub trait TermStore {
170    fn load(&self) -> Result<u64, TermStoreError>;
171    fn persist(&self, term: u64) -> Result<(), TermStoreError>;
172}
173
174/// In-memory term store for tests and ephemeral nodes.
175#[derive(Debug)]
176pub struct MemoryTermStore {
177    inner: std::sync::Mutex<u64>,
178}
179
180impl Default for MemoryTermStore {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl MemoryTermStore {
187    pub fn new() -> Self {
188        Self {
189            inner: std::sync::Mutex::new(crate::replication::DEFAULT_REPLICATION_TERM),
190        }
191    }
192
193    /// Seed an initial term — used by tests to simulate a node that already
194    /// adopted a term before a restart.
195    pub fn seeded(term: u64) -> Self {
196        Self {
197            inner: std::sync::Mutex::new(term),
198        }
199    }
200}
201
202impl TermStore for MemoryTermStore {
203    fn load(&self) -> Result<u64, TermStoreError> {
204        Ok(*self.inner.lock().expect("term store mutex"))
205    }
206
207    fn persist(&self, term: u64) -> Result<(), TermStoreError> {
208        *self.inner.lock().expect("term store mutex") = term;
209        Ok(())
210    }
211}
212
213impl TermStore for FileTermStore {
214    fn load(&self) -> Result<u64, TermStoreError> {
215        self.load_file().map_err(TermStoreError::from)
216    }
217
218    fn persist(&self, term: u64) -> Result<(), TermStoreError> {
219        self.persist_file(term).map_err(TermStoreError::from)
220    }
221}
222
223/// The stale-term fence. Wraps a durable [`TermStore`] and applies the term
224/// rule at the apply and handshake boundaries.
225pub struct TermFence<S: TermStore> {
226    store: S,
227}
228
229impl<S: TermStore> TermFence<S> {
230    pub fn new(store: S) -> Self {
231        Self { store }
232    }
233
234    /// The node's current (highest adopted) term.
235    pub fn current_term(&self) -> Result<u64, TermStoreError> {
236        self.store.load()
237    }
238
239    /// Classify `incoming_term` against the current term **without** mutating
240    /// anything. Pure read — useful for probing a decision before committing
241    /// to the adoption side-effect.
242    pub fn classify(
243        &self,
244        boundary: FenceBoundary,
245        incoming_term: u64,
246    ) -> Result<FenceVerdict, TermStoreError> {
247        let current = self.store.load()?;
248        Ok(if term_is_stale(incoming_term, current) {
249            FenceVerdict::Fenced(StaleTermFenced {
250                boundary,
251                incoming_term,
252                current_term: current,
253            })
254        } else if incoming_term > current {
255            FenceVerdict::Adopt {
256                new_term: incoming_term,
257            }
258        } else {
259            FenceVerdict::Admit { term: current }
260        })
261    }
262
263    /// Admit (or fence) a term-stamped **record** at the apply boundary. On a
264    /// newer term the fence adopts it durably before returning `Adopt`.
265    pub fn admit_record(&self, incoming_term: u64) -> Result<FenceVerdict, TermStoreError> {
266        self.admit(FenceBoundary::Apply, incoming_term)
267    }
268
269    /// Admit (or fence) a stream **handshake** declaring `incoming_term`. On a
270    /// newer term the fence adopts it durably before returning `Adopt`.
271    pub fn admit_handshake(&self, incoming_term: u64) -> Result<FenceVerdict, TermStoreError> {
272        self.admit(FenceBoundary::Handshake, incoming_term)
273    }
274
275    /// Admit (or fence) a stream handshake carrying peer metadata.
276    pub fn admit_stream_handshake(
277        &self,
278        handshake: &StreamHandshake,
279    ) -> Result<FenceVerdict, TermStoreError> {
280        self.admit_handshake(handshake.term)
281    }
282
283    /// Admit (or fence) a lease-backed write whose lease was taken under
284    /// `lease_term`.
285    pub fn admit_lease_write(&self, lease_term: u64) -> Result<FenceVerdict, TermStoreError> {
286        self.admit(FenceBoundary::Lease, lease_term)
287    }
288
289    fn admit(
290        &self,
291        boundary: FenceBoundary,
292        incoming_term: u64,
293    ) -> Result<FenceVerdict, TermStoreError> {
294        let verdict = self.classify(boundary, incoming_term)?;
295        if let FenceVerdict::Adopt { new_term } = verdict {
296            // Persist the adopted term before admitting so the advance is
297            // durable: a crash right after this cannot un-adopt the new term.
298            self.store.persist(new_term)?;
299        }
300        Ok(verdict)
301    }
302
303    /// Force the current term to `new_term`, persisting it. Used after a node
304    /// re-syncs under a known term (e.g. a deposed primary rejoining as a
305    /// replica) so it stops fencing the timeline it has now adopted. Never
306    /// moves the term backwards.
307    pub fn adopt(&self, new_term: u64) -> Result<(), TermStoreError> {
308        let current = self.store.load()?;
309        if new_term > current {
310            self.store.persist(new_term)?;
311        }
312        Ok(())
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn fence(term: u64) -> TermFence<MemoryTermStore> {
321        TermFence::new(MemoryTermStore::seeded(term))
322    }
323
324    // ---- Apply boundary ----
325
326    #[test]
327    fn apply_boundary_fences_stale_term() {
328        let f = fence(5);
329        let verdict = f.admit_record(4).unwrap();
330        assert_eq!(
331            verdict,
332            FenceVerdict::Fenced(StaleTermFenced {
333                boundary: FenceBoundary::Apply,
334                incoming_term: 4,
335                current_term: 5,
336            })
337        );
338        assert!(verdict.is_fenced());
339        // A fenced record must not move the current term.
340        assert_eq!(f.current_term().unwrap(), 5);
341    }
342
343    #[test]
344    fn apply_boundary_admits_current_term() {
345        let f = fence(5);
346        assert_eq!(f.admit_record(5).unwrap(), FenceVerdict::Admit { term: 5 });
347        assert_eq!(f.current_term().unwrap(), 5);
348    }
349
350    #[test]
351    fn apply_boundary_adopts_higher_term_durably() {
352        let f = fence(5);
353        assert_eq!(
354            f.admit_record(8).unwrap(),
355            FenceVerdict::Adopt { new_term: 8 }
356        );
357        // Adoption persisted: the old term is now fenced.
358        assert_eq!(f.current_term().unwrap(), 8);
359        assert!(f.admit_record(5).unwrap().is_fenced());
360    }
361
362    // ---- Handshake boundary ----
363
364    #[test]
365    fn handshake_boundary_fences_stale_term() {
366        let f = fence(7);
367        let verdict = f.admit_handshake(6).unwrap();
368        assert_eq!(
369            verdict,
370            FenceVerdict::Fenced(StaleTermFenced {
371                boundary: FenceBoundary::Handshake,
372                incoming_term: 6,
373                current_term: 7,
374            })
375        );
376        assert!(verdict.is_fenced());
377    }
378
379    #[test]
380    fn handshake_boundary_admits_current_and_adopts_higher() {
381        let f = fence(7);
382        assert_eq!(
383            f.admit_handshake(7).unwrap(),
384            FenceVerdict::Admit { term: 7 }
385        );
386        assert_eq!(
387            f.admit_handshake(9).unwrap(),
388            FenceVerdict::Adopt { new_term: 9 }
389        );
390        assert_eq!(f.current_term().unwrap(), 9);
391    }
392
393    // ---- End-to-end: returning ex-primary on a stale term ----
394
395    #[test]
396    fn returning_ex_primary_is_fenced_until_it_adopts_new_term() {
397        // The replica adopted the new term 6 (handshake from the new primary).
398        let f = fence(5);
399        assert!(matches!(
400            f.admit_handshake(6).unwrap(),
401            FenceVerdict::Adopt { new_term: 6 }
402        ));
403
404        // The ex-primary returns on its stale term 5: both its handshake and
405        // its records are fenced.
406        assert!(f.admit_handshake(5).unwrap().is_fenced());
407        assert!(f.admit_record(5).unwrap().is_fenced());
408
409        // Only after it re-syncs and adopts the new term can it participate —
410        // here it rejoins as a replica that has caught up to term 6.
411        f.adopt(6).unwrap();
412        assert!(f.admit_record(6).unwrap().is_admitted());
413    }
414
415    #[test]
416    fn classify_is_pure_and_does_not_adopt() {
417        let f = fence(3);
418        // Classifying a higher term reports Adopt but must NOT persist it.
419        assert_eq!(
420            f.classify(FenceBoundary::Apply, 9).unwrap(),
421            FenceVerdict::Adopt { new_term: 9 }
422        );
423        assert_eq!(f.current_term().unwrap(), 3, "classify must not mutate");
424    }
425
426    #[test]
427    fn adopt_never_moves_term_backwards() {
428        let f = fence(10);
429        f.adopt(4).unwrap();
430        assert_eq!(f.current_term().unwrap(), 10);
431        f.adopt(12).unwrap();
432        assert_eq!(f.current_term().unwrap(), 12);
433    }
434
435    // ---- File-backed durability ----
436
437    #[test]
438    fn file_term_store_round_trips_and_defaults() {
439        let path = std::env::temp_dir().join(format!(
440            "reddb-term-fence-{}-{}.json",
441            std::process::id(),
442            crate::utils::now_unix_nanos()
443        ));
444        let _ = std::fs::remove_file(&path);
445
446        // Missing file → default base term.
447        let store = FileTermStore::new(&path);
448        assert_eq!(
449            store.load().unwrap(),
450            crate::replication::DEFAULT_REPLICATION_TERM
451        );
452
453        // Adopt across a "restart": a fresh store at the same path still
454        // fences the old term.
455        {
456            let fence = TermFence::new(FileTermStore::new(&path));
457            assert!(matches!(
458                fence.admit_handshake(6).unwrap(),
459                FenceVerdict::Adopt { new_term: 6 }
460            ));
461        }
462        let reopened = TermFence::new(FileTermStore::new(&path));
463        assert_eq!(reopened.current_term().unwrap(), 6);
464        assert!(reopened.admit_record(5).unwrap().is_fenced());
465
466        let _ = std::fs::remove_file(&path);
467    }
468}