Skip to content

Commit a81cb7e

Browse files
committed
feat(cluster): add cluster epoch fence token
Introduce a monotonic cluster epoch that the Raft leader bumps on every term transition. The epoch is stamped on all outbound RPCs via the v3 frame header and checked by followers, so stale messages from old leaders are rejected before they touch any state machine. Key changes: - New ClusterEpoch type (cluster_epoch.rs) with load/bump/check helpers - Catalog persistence via save_cluster_epoch / load_cluster_epoch - Epoch incremented in the Raft tick loop on leader election - Outbound header stamped in loop_core; inbound header validated in the RPC codec before dispatching to the applier - Topology NodeInfo now carries the epoch for membership-change fencing - CONF_CHANGE_PREFIX discriminant corrected from 0xFF to 0xC1 to avoid collision with MessagePack's fixarray leading byte
1 parent e10b0dd commit a81cb7e

9 files changed

Lines changed: 463 additions & 41 deletions

File tree

nodedb-cluster/src/catalog/core.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use crate::error::Result;
1313

1414
use super::migration::migrate_if_needed;
1515
use super::schema::{
16-
CATALOG_FORMAT_VERSION, GHOST_TABLE, KEY_CA_CERT, KEY_CLUSTER_ID, KEY_FORMAT_VERSION,
17-
METADATA_TABLE, MIGRATION_STATE_TABLE, ROUTING_TABLE, TOPOLOGY_TABLE, catalog_err,
16+
CATALOG_FORMAT_VERSION, GHOST_TABLE, KEY_CA_CERT, KEY_CLUSTER_EPOCH, KEY_CLUSTER_ID,
17+
KEY_FORMAT_VERSION, METADATA_TABLE, MIGRATION_STATE_TABLE, ROUTING_TABLE, TOPOLOGY_TABLE,
18+
catalog_err,
1819
};
1920

2021
/// Persistent cluster catalog backed by redb.
@@ -107,6 +108,41 @@ impl ClusterCatalog {
107108
self.load_cluster_id().map(|id| id.is_some())
108109
}
109110

111+
/// Persist the cluster epoch (the leader-bumped monotonic fence
112+
/// token stamped on every Raft RPC). Overwrites any prior value.
113+
pub fn save_cluster_epoch(&self, epoch: u64) -> Result<()> {
114+
let bytes = epoch.to_le_bytes();
115+
let txn = self.db.begin_write().map_err(catalog_err)?;
116+
{
117+
let mut table = txn.open_table(METADATA_TABLE).map_err(catalog_err)?;
118+
table
119+
.insert(KEY_CLUSTER_EPOCH, bytes.as_slice())
120+
.map_err(catalog_err)?;
121+
}
122+
txn.commit().map_err(catalog_err)?;
123+
Ok(())
124+
}
125+
126+
/// Load the persisted cluster epoch. Returns `None` on a catalog
127+
/// that has never written one (callers treat that as 0).
128+
pub fn load_cluster_epoch(&self) -> Result<Option<u64>> {
129+
let txn = self.db.begin_read().map_err(catalog_err)?;
130+
let table = txn.open_table(METADATA_TABLE).map_err(catalog_err)?;
131+
match table.get(KEY_CLUSTER_EPOCH).map_err(catalog_err)? {
132+
Some(guard) => {
133+
let bytes = guard.value();
134+
if bytes.len() == 8 {
135+
let mut arr = [0u8; 8];
136+
arr.copy_from_slice(bytes);
137+
Ok(Some(u64::from_le_bytes(arr)))
138+
} else {
139+
Ok(None)
140+
}
141+
}
142+
None => Ok(None),
143+
}
144+
}
145+
110146
// ── TLS Certificates ────────────────────────────────────────────
111147

112148
/// Store the cluster CA certificate (DER-encoded).

nodedb-cluster/src/catalog/schema.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ pub(super) const MIGRATION_STATE_TABLE: TableDefinition<&str, &[u8]> =
3434
pub(super) const KEY_TOPOLOGY: &str = "topology";
3535
pub(super) const KEY_ROUTING: &str = "routing";
3636
pub(super) const KEY_CLUSTER_ID: &str = "cluster_id";
37+
/// Cluster epoch (u64 LE) — monotonic, leader-bumped, stamped on every
38+
/// Raft RPC. Persisted so the local high-water mark survives restart.
39+
pub(super) const KEY_CLUSTER_EPOCH: &str = "cluster_epoch";
3740
pub(super) const KEY_CA_CERT: &str = "ca_cert";
3841
/// Metadata key holding the catalog format version (u32 LE).
3942
///
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//! Cluster generation/epoch — a monotonic, leader-bumped fence token
2+
//! stamped on every Raft RPC frame.
3+
//!
4+
//! # Purpose
5+
//!
6+
//! `cluster_epoch` is a process-wide `u64` that advances on every
7+
//! cluster-membership-shifting event the metadata-group leader observes
8+
//! (currently: becoming leader of the metadata group). Stamping it on every
9+
//! outbound RPC and observing it on every inbound RPC lets every peer keep
10+
//! a cluster-wide high-water mark and reject (or quarantine) frames from
11+
//! peers stuck on a strictly older epoch — i.e. peers that missed a topology
12+
//! transition and may be acting on stale state.
13+
//!
14+
//! # Mechanics
15+
//!
16+
//! * A process-global `AtomicU64`, [`LOCAL_CLUSTER_EPOCH`], holds the local
17+
//! high-water mark. On startup it is loaded from the cluster catalog
18+
//! (see [`ClusterCatalog::load_cluster_epoch`]).
19+
//! * [`current_local_cluster_epoch`] reads it for the encoder.
20+
//! * [`observe_peer_cluster_epoch`] is called by the decoder for every
21+
//! inbound frame; it bumps the local mark via `fetch_max` (monotonic).
22+
//! * [`bump_local_cluster_epoch`] is called by the metadata-group leader
23+
//! when leadership transitions to it; it advances the local mark and
24+
//! persists the new value.
25+
//!
26+
//! Persistence is best-effort: a bump is committed in-memory atomically;
27+
//! if the catalog write fails, the in-memory value is still advanced and
28+
//! the failure is logged. (After a crash, the persisted value is a lower
29+
//! bound — the new leader will re-bump beyond it.)
30+
//!
31+
//! # Why a global atomic
32+
//!
33+
//! Every encode/decode call site needs the current epoch. Threading it
34+
//! through the existing 19 encode/decode functions and their callers
35+
//! would touch hundreds of sites for what is, semantically, a single
36+
//! per-process value. An `AtomicU64` is the right shape for this kind
37+
//! of read-mostly, monotonic counter.
38+
39+
use std::sync::Arc;
40+
use std::sync::atomic::{AtomicU64, Ordering};
41+
42+
use crate::catalog::ClusterCatalog;
43+
use crate::error::Result;
44+
45+
/// Process-global cluster epoch high-water mark.
46+
///
47+
/// Initialized to 0 (genesis). Loaded from catalog at startup via
48+
/// [`init_local_cluster_epoch_from_catalog`].
49+
static LOCAL_CLUSTER_EPOCH: AtomicU64 = AtomicU64::new(0);
50+
51+
/// Read the current local cluster epoch (the value an outbound RPC
52+
/// should stamp). Cheap; lock-free.
53+
pub fn current_local_cluster_epoch() -> u64 {
54+
LOCAL_CLUSTER_EPOCH.load(Ordering::Acquire)
55+
}
56+
57+
/// Set the local epoch directly. Only intended for use by
58+
/// [`init_local_cluster_epoch_from_catalog`] at startup and by tests.
59+
/// Production code paths use [`observe_peer_cluster_epoch`] (monotonic
60+
/// max) or [`bump_local_cluster_epoch`] (leader-side increment).
61+
pub fn set_local_cluster_epoch(value: u64) {
62+
LOCAL_CLUSTER_EPOCH.store(value, Ordering::Release);
63+
}
64+
65+
/// Observe an epoch carried by an inbound RPC. Advances the local mark
66+
/// via `fetch_max` (so concurrent observations are safe and monotonic).
67+
///
68+
/// Returns the new local high-water mark.
69+
pub fn observe_peer_cluster_epoch(peer_epoch: u64) -> u64 {
70+
let prev = LOCAL_CLUSTER_EPOCH.fetch_max(peer_epoch, Ordering::AcqRel);
71+
prev.max(peer_epoch)
72+
}
73+
74+
/// Increment the local epoch by 1 and persist the new value to the
75+
/// cluster catalog. Called by the metadata-group leader on a leadership
76+
/// transition.
77+
///
78+
/// Returns the new epoch. The persistence failure path advances the
79+
/// in-memory value anyway (so RPCs immediately reflect the bump) and
80+
/// returns the persistence error to the caller.
81+
pub fn bump_local_cluster_epoch(catalog: &ClusterCatalog) -> Result<u64> {
82+
let new_epoch = LOCAL_CLUSTER_EPOCH.fetch_add(1, Ordering::AcqRel) + 1;
83+
catalog.save_cluster_epoch(new_epoch)?;
84+
Ok(new_epoch)
85+
}
86+
87+
/// Initialize the local epoch from the cluster catalog at process
88+
/// startup. Idempotent — safe to call multiple times during boot.
89+
pub fn init_local_cluster_epoch_from_catalog(catalog: &Arc<ClusterCatalog>) -> Result<u64> {
90+
let persisted = catalog.load_cluster_epoch()?.unwrap_or(0);
91+
// fetch_max so we don't regress past anything already observed
92+
// earlier in startup (e.g. an inbound frame on the join path).
93+
let prev = LOCAL_CLUSTER_EPOCH.fetch_max(persisted, Ordering::AcqRel);
94+
Ok(prev.max(persisted))
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
use std::sync::Mutex;
101+
102+
/// All tests in this module mutate the same global atomic, so they
103+
/// must be serialised. The lock is taken at the top of each test.
104+
static TEST_LOCK: Mutex<()> = Mutex::new(());
105+
106+
fn reset() -> std::sync::MutexGuard<'static, ()> {
107+
let g = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
108+
LOCAL_CLUSTER_EPOCH.store(0, Ordering::Release);
109+
g
110+
}
111+
112+
#[test]
113+
fn observe_is_monotonic_max() {
114+
let _g = reset();
115+
assert_eq!(observe_peer_cluster_epoch(5), 5);
116+
assert_eq!(current_local_cluster_epoch(), 5);
117+
// Older peer epoch must not regress the local mark.
118+
assert_eq!(observe_peer_cluster_epoch(3), 5);
119+
assert_eq!(current_local_cluster_epoch(), 5);
120+
// Newer advances.
121+
assert_eq!(observe_peer_cluster_epoch(7), 7);
122+
assert_eq!(current_local_cluster_epoch(), 7);
123+
}
124+
125+
#[test]
126+
fn set_overrides_for_init() {
127+
let _g = reset();
128+
set_local_cluster_epoch(42);
129+
assert_eq!(current_local_cluster_epoch(), 42);
130+
}
131+
132+
#[test]
133+
fn observe_zero_is_noop() {
134+
let _g = reset();
135+
set_local_cluster_epoch(9);
136+
assert_eq!(observe_peer_cluster_epoch(0), 9);
137+
assert_eq!(current_local_cluster_epoch(), 9);
138+
}
139+
140+
#[test]
141+
fn bump_increments_and_persists() {
142+
let _g = reset();
143+
let dir = tempfile::tempdir().unwrap();
144+
let catalog = ClusterCatalog::open(&dir.path().join("cluster.redb")).unwrap();
145+
set_local_cluster_epoch(10);
146+
let new_epoch = bump_local_cluster_epoch(&catalog).unwrap();
147+
assert_eq!(new_epoch, 11);
148+
assert_eq!(current_local_cluster_epoch(), 11);
149+
assert_eq!(catalog.load_cluster_epoch().unwrap(), Some(11));
150+
}
151+
152+
#[test]
153+
fn init_from_catalog_loads_persisted_value() {
154+
let _g = reset();
155+
let dir = tempfile::tempdir().unwrap();
156+
let catalog = Arc::new(ClusterCatalog::open(&dir.path().join("cluster.redb")).unwrap());
157+
catalog.save_cluster_epoch(123).unwrap();
158+
let v = init_local_cluster_epoch_from_catalog(&catalog).unwrap();
159+
assert_eq!(v, 123);
160+
assert_eq!(current_local_cluster_epoch(), 123);
161+
}
162+
163+
#[test]
164+
fn init_with_no_persisted_value_starts_at_zero() {
165+
let _g = reset();
166+
let dir = tempfile::tempdir().unwrap();
167+
let catalog = Arc::new(ClusterCatalog::open(&dir.path().join("cluster.redb")).unwrap());
168+
let v = init_local_cluster_epoch_from_catalog(&catalog).unwrap();
169+
assert_eq!(v, 0);
170+
assert_eq!(current_local_cluster_epoch(), 0);
171+
}
172+
}

nodedb-cluster/src/conf_change.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
//!
77
//! Uses single-server changes (one peer at a time) for simplicity and safety.
88
9-
/// Prefix byte in log entry data that marks it as a configuration change.
10-
/// Regular application data never starts with this byte (MessagePack and
11-
/// rkyv both use different leading bytes).
12-
pub const CONF_CHANGE_PREFIX: u8 = 0xFF;
9+
/// Discriminator byte at offset 0 of a Raft log entry that marks it as a
10+
/// configuration change. Layout: `[kind:1][msgpack(ConfChange)]`.
11+
///
12+
/// `0xC1` is the only byte the MessagePack spec lists as "never used" — no
13+
/// valid msgpack payload can start with it. All current app-data proposals
14+
/// are msgpack-encoded (MetadataEntry, distributed-applier batches, auth
15+
/// transitions), so the discriminator is unambiguous. (`0xFF` was incorrect
16+
/// here: it is msgpack negative fixint -1 and collides with valid scalars.)
17+
pub const CONF_CHANGE_PREFIX: u8 = 0xC1;
1318

1419
/// Type of configuration change.
1520
#[derive(
@@ -113,6 +118,27 @@ mod tests {
113118
assert!(ConfChange::from_entry_data(b"hello").is_none());
114119
}
115120

121+
#[test]
122+
fn prefix_is_msgpack_never_used_byte() {
123+
// 0xC1 is the only byte the MessagePack spec marks "never used".
124+
// If anyone changes this constant, they must re-prove non-collision
125+
// with every app-data proposal path.
126+
assert_eq!(CONF_CHANGE_PREFIX, 0xC1);
127+
}
128+
129+
#[test]
130+
fn prefix_does_not_collide_with_msgpack_metadata_entry() {
131+
// App data on the metadata group is msgpack(MetadataEntry), which
132+
// is a struct — encodes as a fixmap/fixarray (0x80..=0x9f). It
133+
// must never start with the conf-change prefix.
134+
let cc = ConfChange {
135+
change_type: ConfChangeType::AddNode,
136+
node_id: 1,
137+
};
138+
let msgpack_struct = zerompk::to_msgpack_vec(&cc).unwrap();
139+
assert_ne!(msgpack_struct.first(), Some(&CONF_CHANGE_PREFIX));
140+
}
141+
116142
#[test]
117143
fn all_change_types() {
118144
for ct in [

nodedb-cluster/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod bootstrap_listener;
66
pub mod catalog;
77
pub mod circuit_breaker;
88
pub mod closed_timestamp;
9+
pub mod cluster_epoch;
910
pub mod cluster_info;
1011
pub mod conf_change;
1112
pub mod cross_shard_txn;
@@ -57,6 +58,10 @@ pub use bootstrap::{
5758
ClusterConfig, ClusterState, JoinRetryPolicy, start_cluster, start_cluster_subsystems,
5859
};
5960
pub use catalog::ClusterCatalog;
61+
pub use cluster_epoch::{
62+
bump_local_cluster_epoch, current_local_cluster_epoch, init_local_cluster_epoch_from_catalog,
63+
observe_peer_cluster_epoch, set_local_cluster_epoch,
64+
};
6065
pub use circuit_breaker::BreakerSnapshot;
6166
pub use closed_timestamp::ClosedTimestampTracker;
6267
pub use cluster_info::{

nodedb-cluster/src/raft_loop/loop_core.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ pub struct RaftLoop<A: CommitApplier, P: PlanExecutor = NoopPlanExecutor> {
116116
/// renewals, and consistent reads can wait on the apply
117117
/// watermark of the *specific* group whose proposal they made.
118118
pub(super) group_watchers: Arc<GroupAppliedWatchers>,
119+
/// Tracks whether this node was the metadata-group leader on the
120+
/// previous tick. Used to detect false→true edges so the cluster
121+
/// epoch (see [`crate::cluster_epoch`]) can be bumped exactly once
122+
/// per leadership acquisition. `AtomicBool` because [`super::tick::do_tick`]
123+
/// runs against `&self`.
124+
pub(super) prev_metadata_leader: std::sync::atomic::AtomicBool,
119125
}
120126

121127
impl<A: CommitApplier> RaftLoop<A> {
@@ -143,6 +149,7 @@ impl<A: CommitApplier> RaftLoop<A> {
143149
ready_watch,
144150
loop_metrics: LoopMetrics::new("raft_tick_loop"),
145151
group_watchers: Arc::new(GroupAppliedWatchers::new()),
152+
prev_metadata_leader: std::sync::atomic::AtomicBool::new(false),
146153
}
147154
}
148155
}
@@ -165,6 +172,7 @@ impl<A: CommitApplier, P: PlanExecutor> RaftLoop<A, P> {
165172
ready_watch: self.ready_watch,
166173
loop_metrics: self.loop_metrics,
167174
group_watchers: self.group_watchers,
175+
prev_metadata_leader: self.prev_metadata_leader,
168176
}
169177
}
170178

@@ -247,6 +255,15 @@ impl<A: CommitApplier, P: PlanExecutor> RaftLoop<A, P> {
247255
/// Attach a cluster catalog — used by the join flow to persist the
248256
/// updated topology + routing after a conf-change commits.
249257
pub fn with_catalog(mut self, catalog: Arc<ClusterCatalog>) -> Self {
258+
// Seed the local cluster-epoch high-water mark from the catalog
259+
// on attach, so the value emitted in the very first outbound
260+
// RPC reflects whatever the previous incarnation persisted. A
261+
// failure to load is treated as a fresh catalog (epoch 0); a
262+
// genuine catalog read error would have surfaced from earlier
263+
// catalog operations on this same handle.
264+
if let Err(e) = crate::cluster_epoch::init_local_cluster_epoch_from_catalog(&catalog) {
265+
tracing::warn!(error = %e, "failed to load persisted cluster_epoch; defaulting to 0");
266+
}
250267
self.catalog = Some(catalog);
251268
self
252269
}

nodedb-cluster/src/raft_loop/tick.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,43 @@ impl<A: CommitApplier, P: PlanExecutor> RaftLoop<A, P> {
216216
{
217217
let _ = self.ready_watch.send(true);
218218
}
219+
220+
// Detect false→true transitions on metadata-group
221+
// leadership and bump the cluster epoch exactly once
222+
// per acquisition. The fence token rides on every
223+
// outbound RPC after this point (see
224+
// `cluster_epoch.rs`).
225+
if group_id == crate::metadata_group::METADATA_GROUP_ID {
226+
let is_leader = self
227+
.multi_raft
228+
.lock()
229+
.unwrap_or_else(|p| p.into_inner())
230+
.group_role_is_leader(group_id);
231+
let was_leader = self
232+
.prev_metadata_leader
233+
.swap(is_leader, std::sync::atomic::Ordering::AcqRel);
234+
if is_leader && !was_leader {
235+
if let Some(catalog) = self.catalog.as_ref() {
236+
match crate::cluster_epoch::bump_local_cluster_epoch(catalog) {
237+
Ok(new_epoch) => tracing::info!(
238+
node = self.node_id,
239+
new_epoch,
240+
"bumped cluster epoch on metadata-group leadership acquisition"
241+
),
242+
Err(e) => tracing::warn!(
243+
node = self.node_id,
244+
error = %e,
245+
"failed to persist bumped cluster epoch (in-memory value advanced anyway)"
246+
),
247+
}
248+
} else {
249+
// No catalog → in-memory only (test path).
250+
let _ = crate::cluster_epoch::observe_peer_cluster_epoch(
251+
crate::cluster_epoch::current_local_cluster_epoch() + 1,
252+
);
253+
}
254+
}
255+
}
219256
}
220257

221258
// Phase 5: install-snapshot dispatch for lagging peers.

0 commit comments

Comments
 (0)