Skip to content

Commit 7097421

Browse files
committed
feat(cluster): add closed timestamp tracker and follower read gate
Introduce ClosedTimestampTracker for advancing the closed timestamp used by bounded-staleness reads, and FollowerReadGate with ReadLevel to decide whether a follower can serve a read at a given LSN without forwarding to the leader.
1 parent 9af5664 commit 7097421

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! Per-group closed-timestamp tracker.
2+
//!
3+
//! Every time a Raft group applies a committed entry, the applier
4+
//! records the wall-clock instant as that group's "closed timestamp".
5+
//! A follower whose closed timestamp for a group is within the
6+
//! caller's staleness bound can serve reads locally — no gateway hop
7+
//! to the leader.
8+
//!
9+
//! The tracker is intentionally simple: one `Instant` per group,
10+
//! updated monotonically. There is no HLC or cross-node coordination
11+
//! here — the closed timestamp is local to this node. Safety comes
12+
//! from the fact that a follower's applied index can only advance
13+
//! (Raft guarantees), so a read served at a given closed timestamp
14+
//! sees a consistent prefix of the log.
15+
16+
use std::collections::HashMap;
17+
use std::sync::RwLock;
18+
use std::time::{Duration, Instant};
19+
20+
/// Tracks the most recent apply instant per Raft group.
21+
pub struct ClosedTimestampTracker {
22+
groups: RwLock<HashMap<u64, Instant>>,
23+
}
24+
25+
impl ClosedTimestampTracker {
26+
pub fn new() -> Self {
27+
Self {
28+
groups: RwLock::new(HashMap::new()),
29+
}
30+
}
31+
32+
/// Record that `group_id` just applied one or more entries.
33+
/// Called by the raft-loop applier after each apply batch.
34+
pub fn mark_applied(&self, group_id: u64) {
35+
let mut g = self.groups.write().unwrap_or_else(|p| p.into_inner());
36+
g.insert(group_id, Instant::now());
37+
}
38+
39+
/// Record that `group_id` just applied, using a caller-supplied
40+
/// instant. Exposed for deterministic testing with paused time.
41+
pub fn mark_applied_at(&self, group_id: u64, at: Instant) {
42+
let mut g = self.groups.write().unwrap_or_else(|p| p.into_inner());
43+
g.insert(group_id, at);
44+
}
45+
46+
/// Check whether this node's replica of `group_id` has applied
47+
/// recently enough that a read with `max_staleness` can be
48+
/// served locally.
49+
///
50+
/// Returns `false` if the group has never applied on this node
51+
/// (no closed timestamp recorded).
52+
pub fn is_fresh_enough(&self, group_id: u64, max_staleness: Duration) -> bool {
53+
let g = self.groups.read().unwrap_or_else(|p| p.into_inner());
54+
match g.get(&group_id) {
55+
Some(last) => last.elapsed() <= max_staleness,
56+
None => false,
57+
}
58+
}
59+
60+
/// Return the age of the closed timestamp for a group, or `None`
61+
/// if the group has never applied on this node. Useful for
62+
/// observability (metrics, SHOW commands).
63+
pub fn staleness(&self, group_id: u64) -> Option<Duration> {
64+
let g = self.groups.read().unwrap_or_else(|p| p.into_inner());
65+
g.get(&group_id).map(|last| last.elapsed())
66+
}
67+
}
68+
69+
impl Default for ClosedTimestampTracker {
70+
fn default() -> Self {
71+
Self::new()
72+
}
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
79+
#[test]
80+
fn unknown_group_is_not_fresh() {
81+
let tracker = ClosedTimestampTracker::new();
82+
assert!(!tracker.is_fresh_enough(99, Duration::from_secs(10)));
83+
}
84+
85+
#[test]
86+
fn recently_applied_is_fresh() {
87+
let tracker = ClosedTimestampTracker::new();
88+
tracker.mark_applied(1);
89+
assert!(tracker.is_fresh_enough(1, Duration::from_secs(5)));
90+
}
91+
92+
#[test]
93+
fn stale_group_is_not_fresh() {
94+
let tracker = ClosedTimestampTracker::new();
95+
let old = Instant::now() - Duration::from_secs(30);
96+
tracker.mark_applied_at(1, old);
97+
assert!(!tracker.is_fresh_enough(1, Duration::from_secs(5)));
98+
}
99+
100+
#[test]
101+
fn staleness_returns_none_for_unknown() {
102+
let tracker = ClosedTimestampTracker::new();
103+
assert!(tracker.staleness(42).is_none());
104+
}
105+
106+
#[test]
107+
fn staleness_returns_age_for_known() {
108+
let tracker = ClosedTimestampTracker::new();
109+
tracker.mark_applied(1);
110+
let s = tracker.staleness(1).unwrap();
111+
assert!(s < Duration::from_millis(100));
112+
}
113+
114+
#[test]
115+
fn mark_applied_updates_monotonically() {
116+
let tracker = ClosedTimestampTracker::new();
117+
let old = Instant::now() - Duration::from_secs(10);
118+
tracker.mark_applied_at(1, old);
119+
assert!(!tracker.is_fresh_enough(1, Duration::from_secs(5)));
120+
tracker.mark_applied(1);
121+
assert!(tracker.is_fresh_enough(1, Duration::from_secs(5)));
122+
}
123+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! Follower-read decision gate.
2+
//!
3+
//! [`FollowerReadGate`] answers a single question: "given the
4+
//! session's `ReadConsistency` and the local node's role + closed
5+
//! timestamp for the target Raft group, can this read be served
6+
//! locally without forwarding to the leader?"
7+
//!
8+
//! ## Decision table
9+
//!
10+
//! | Consistency | Local role | Closed TS fresh? | Serve locally? |
11+
//! |-----------------------|-------------|------------------|----------------|
12+
//! | Strong | * | * | Only if leader |
13+
//! | BoundedStaleness(d) | Follower | ≤ d | Yes |
14+
//! | BoundedStaleness(d) | Follower | > d | No → forward |
15+
//! | BoundedStaleness(d) | Leader | * | Yes |
16+
//! | Eventual | * | * | Yes |
17+
//!
18+
//! The gate is stateless — it reads from shared handles to the
19+
//! closed-timestamp tracker and the raft-status provider.
20+
21+
use std::sync::Arc;
22+
use std::time::Duration;
23+
24+
use crate::closed_timestamp::ClosedTimestampTracker;
25+
26+
/// Consistency level for a single read — mirrors the `ReadConsistency`
27+
/// enum in the `nodedb` crate without coupling `nodedb-cluster` to it.
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub enum ReadLevel {
30+
Strong,
31+
BoundedStaleness(Duration),
32+
Eventual,
33+
}
34+
35+
/// Answers "can this read be served locally?"
36+
pub struct FollowerReadGate {
37+
closed_ts: Arc<ClosedTimestampTracker>,
38+
/// Type-erased function that returns true if this node is the
39+
/// leader for the given group. Injection seam — production wraps
40+
/// `MultiRaft::group_statuses`, tests supply a closure.
41+
is_leader_fn: Box<dyn Fn(u64) -> bool + Send + Sync>,
42+
}
43+
44+
impl FollowerReadGate {
45+
pub fn new(
46+
closed_ts: Arc<ClosedTimestampTracker>,
47+
is_leader_fn: Box<dyn Fn(u64) -> bool + Send + Sync>,
48+
) -> Self {
49+
Self {
50+
closed_ts,
51+
is_leader_fn,
52+
}
53+
}
54+
55+
/// Returns `true` if the read can be served from this node's
56+
/// local replica without forwarding to the leader.
57+
pub fn can_serve_locally(&self, group_id: u64, level: ReadLevel) -> bool {
58+
match level {
59+
ReadLevel::Strong => (self.is_leader_fn)(group_id),
60+
ReadLevel::Eventual => true,
61+
ReadLevel::BoundedStaleness(max) => {
62+
if (self.is_leader_fn)(group_id) {
63+
return true;
64+
}
65+
self.closed_ts.is_fresh_enough(group_id, max)
66+
}
67+
}
68+
}
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
use super::*;
74+
75+
fn gate(leader_groups: &'static [u64]) -> FollowerReadGate {
76+
FollowerReadGate::new(
77+
Arc::new(ClosedTimestampTracker::new()),
78+
Box::new(move |gid| leader_groups.contains(&gid)),
79+
)
80+
}
81+
82+
fn gate_with_tracker(
83+
leader_groups: &'static [u64],
84+
tracker: Arc<ClosedTimestampTracker>,
85+
) -> FollowerReadGate {
86+
FollowerReadGate::new(tracker, Box::new(move |gid| leader_groups.contains(&gid)))
87+
}
88+
89+
#[test]
90+
fn strong_requires_leader() {
91+
let g = gate(&[1]);
92+
assert!(g.can_serve_locally(1, ReadLevel::Strong));
93+
assert!(!g.can_serve_locally(2, ReadLevel::Strong));
94+
}
95+
96+
#[test]
97+
fn eventual_always_local() {
98+
let g = gate(&[]);
99+
assert!(g.can_serve_locally(99, ReadLevel::Eventual));
100+
}
101+
102+
#[test]
103+
fn bounded_staleness_leader_always_local() {
104+
let g = gate(&[1]);
105+
assert!(g.can_serve_locally(1, ReadLevel::BoundedStaleness(Duration::from_secs(5))));
106+
}
107+
108+
#[test]
109+
fn bounded_staleness_follower_fresh_enough() {
110+
let tracker = Arc::new(ClosedTimestampTracker::new());
111+
tracker.mark_applied(2);
112+
let g = gate_with_tracker(&[], tracker);
113+
assert!(g.can_serve_locally(2, ReadLevel::BoundedStaleness(Duration::from_secs(5))));
114+
}
115+
116+
#[test]
117+
fn bounded_staleness_follower_too_stale() {
118+
let tracker = Arc::new(ClosedTimestampTracker::new());
119+
let old = std::time::Instant::now() - Duration::from_secs(30);
120+
tracker.mark_applied_at(2, old);
121+
let g = gate_with_tracker(&[], tracker);
122+
assert!(!g.can_serve_locally(2, ReadLevel::BoundedStaleness(Duration::from_secs(5))));
123+
}
124+
125+
#[test]
126+
fn bounded_staleness_unknown_group_not_local() {
127+
let g = gate(&[]);
128+
assert!(!g.can_serve_locally(99, ReadLevel::BoundedStaleness(Duration::from_secs(5))));
129+
}
130+
}

nodedb-cluster/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod bootstrap;
22
pub mod catalog;
33
pub mod circuit_breaker;
4+
pub mod closed_timestamp;
45
pub mod cluster_info;
56
pub mod conf_change;
67
pub mod cross_shard_txn;
@@ -12,6 +13,7 @@ pub mod distributed_spatial;
1213
pub mod distributed_timeseries;
1314
pub mod distributed_vector;
1415
pub mod error;
16+
pub mod follower_read;
1517
pub mod forward;
1618
pub mod ghost;
1719
pub mod ghost_sweeper;
@@ -43,6 +45,7 @@ pub mod wire;
4345

4446
pub use bootstrap::{ClusterConfig, ClusterState, JoinRetryPolicy, start_cluster};
4547
pub use catalog::ClusterCatalog;
48+
pub use closed_timestamp::ClosedTimestampTracker;
4649
pub use cluster_info::{
4750
ClusterInfoSnapshot, ClusterObserver, GroupSnapshot, GroupStatusProvider, PeerSnapshot,
4851
};
@@ -52,6 +55,7 @@ pub use decommission::{
5255
DecommissionSafetyError, MetadataProposer, check_can_decommission, plan_full_decommission,
5356
};
5457
pub use error::{ClusterError, Result};
58+
pub use follower_read::{FollowerReadGate, ReadLevel};
5559
pub use forward::{NoopPlanExecutor, PlanExecutor};
5660
pub use ghost::{GhostStub, GhostTable};
5761
pub use health::{HealthConfig, HealthMonitor};

0 commit comments

Comments
 (0)