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