use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AuthorityRole {
Authority,
Replica,
Candidate,
Fenced,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct FencingToken(pub u64);
impl FencingToken {
pub fn initial() -> Self {
Self(1)
}
pub fn next(&self) -> Self {
Self(self.0.saturating_add(1))
}
pub fn supersedes(&self, other: &FencingToken) -> bool {
self.0 > other.0
}
}
impl std::fmt::Display for FencingToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FT:{}", self.0)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorityRecord {
pub node_id: String,
pub fencing_token: FencingToken,
pub elected_at_ms: u64,
pub previous_authority: Option<String>,
pub acknowledgements: HashSet<String>,
}
impl AuthorityRecord {
pub fn new(node_id: String, fencing_token: FencingToken, previous: Option<String>) -> Self {
Self {
node_id,
fencing_token,
elected_at_ms: now_ms(),
previous_authority: previous,
acknowledgements: HashSet::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HeartbeatState {
pub last_seen: BTreeMap<String, u64>,
pub interval_ms: u64,
pub failure_threshold: u32,
}
impl HeartbeatState {
pub fn new(interval_ms: u64, failure_threshold: u32) -> Self {
Self {
last_seen: BTreeMap::new(),
interval_ms,
failure_threshold,
}
}
pub fn record_heartbeat(&mut self, node_id: &str) {
self.last_seen.insert(node_id.to_string(), now_ms());
}
pub fn is_failed(&self, node_id: &str) -> bool {
let Some(last) = self.last_seen.get(node_id) else {
return false;
};
let deadline = self.interval_ms * self.failure_threshold as u64;
now_ms().saturating_sub(*last) > deadline
}
pub fn failed_nodes(&self) -> Vec<String> {
self.last_seen
.keys()
.filter(|node_id| self.is_failed(node_id))
.cloned()
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ElectionPriority {
pub operations_applied: u64,
pub static_priority: u32,
pub node_id: String,
}
impl ElectionPriority {
pub fn new(operations_applied: u64, static_priority: u32, node_id: String) -> Self {
Self {
operations_applied,
static_priority,
node_id,
}
}
}
#[derive(Clone, Debug)]
pub enum ClaimResult {
Granted { token: FencingToken },
Rejected {
current_authority: String,
current_token: FencingToken,
},
Fenced { token: FencingToken },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorityState {
pub cluster_id: String,
pub current_authority: Option<AuthorityRecord>,
pub local_role: AuthorityRole,
pub local_node_id: String,
pub highest_seen_token: FencingToken,
pub local_priority: u32,
}
impl AuthorityState {
pub fn new(cluster_id: String, local_node_id: String, local_priority: u32) -> Self {
Self {
cluster_id,
current_authority: None,
local_role: AuthorityRole::Replica,
local_node_id,
highest_seen_token: FencingToken::initial(),
local_priority,
}
}
pub fn is_authority(&self) -> bool {
self.local_role == AuthorityRole::Authority
}
pub fn is_fenced(&self) -> bool {
self.local_role == AuthorityRole::Fenced
}
pub fn authority_node(&self) -> Option<&str> {
self.current_authority.as_ref().map(|r| r.node_id.as_str())
}
pub fn current_token(&self) -> FencingToken {
self.current_authority
.as_ref()
.map(|r| r.fencing_token)
.unwrap_or(FencingToken::initial())
}
}
pub struct AuthorityStore {
path: PathBuf,
state: AuthorityState,
heartbeats: HeartbeatState,
}
#[derive(Debug)]
pub enum AuthorityError {
NoCluster,
AlreadyAuthority(String),
NotAuthority,
Fenced(FencingToken),
StaleToken {
provided: FencingToken,
current: FencingToken,
},
QuorumUnavailable {
live: usize,
required: usize,
},
Io(String),
}
impl std::fmt::Display for AuthorityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCluster => write!(f, "no cluster has been created"),
Self::AlreadyAuthority(id) => write!(f, "node {id} is already authority"),
Self::NotAuthority => write!(f, "this node is not the authority"),
Self::Fenced(token) => write!(f, "this node is fenced at {token}"),
Self::StaleToken { provided, current } => {
write!(f, "stale token {provided}, current is {current}")
}
Self::QuorumUnavailable { live, required } => {
write!(
f,
"authority quorum unavailable: {live} live, {required} required"
)
}
Self::Io(msg) => write!(f, "authority store: {msg}"),
}
}
}
impl AuthorityStore {
pub fn open<P: AsRef<Path>>(
path: P,
cluster_id: &str,
local_node_id: &str,
local_priority: u32,
) -> Result<Self, AuthorityError> {
Self::open_with_failure_detector(path, cluster_id, local_node_id, local_priority, 1_000, 5)
}
pub fn open_with_failure_detector<P: AsRef<Path>>(
path: P,
cluster_id: &str,
local_node_id: &str,
local_priority: u32,
heartbeat_interval_ms: u64,
failure_threshold: u32,
) -> Result<Self, AuthorityError> {
let path = path.as_ref().to_path_buf();
let state = if path.exists() {
let bytes = std::fs::read(&path).map_err(|e| AuthorityError::Io(e.to_string()))?;
serde_json::from_slice(&bytes).map_err(|e| AuthorityError::Io(e.to_string()))?
} else {
AuthorityState::new(
cluster_id.to_string(),
local_node_id.to_string(),
local_priority,
)
};
Ok(Self {
path,
state,
heartbeats: HeartbeatState::new(heartbeat_interval_ms, failure_threshold),
})
}
fn persist(&self) -> Result<(), AuthorityError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AuthorityError::Io(e.to_string()))?;
}
let temp = self.path.with_extension("tmp");
let bytes = serde_json::to_vec_pretty(&self.state)
.map_err(|e| AuthorityError::Io(e.to_string()))?;
{
use std::io::Write;
let mut file =
std::fs::File::create(&temp).map_err(|e| AuthorityError::Io(e.to_string()))?;
file.write_all(&bytes)
.map_err(|e| AuthorityError::Io(e.to_string()))?;
file.sync_all()
.map_err(|e| AuthorityError::Io(e.to_string()))?;
}
std::fs::rename(&temp, &self.path).map_err(|e| AuthorityError::Io(e.to_string()))?;
if let Some(parent) = self.path.parent() {
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
Ok(())
}
pub fn state(&self) -> &AuthorityState {
&self.state
}
pub fn heartbeat(&mut self, node_id: &str) {
self.heartbeats.record_heartbeat(node_id);
}
pub fn authority_failed(&self) -> bool {
if let Some(ref authority) = self.state.current_authority {
if authority.node_id == self.state.local_node_id {
return false; }
return self.heartbeats.is_failed(&authority.node_id);
}
false
}
pub fn failed_nodes(&self) -> Vec<String> {
self.heartbeats.failed_nodes()
}
pub fn claim_authority(&mut self) -> Result<ClaimResult, AuthorityError> {
if self.state.is_fenced() {
return Ok(ClaimResult::Fenced {
token: self.state.highest_seen_token,
});
}
if let Some(ref current) = self.state.current_authority {
if current.node_id != self.state.local_node_id
&& !self.heartbeats.is_failed(¤t.node_id)
{
return Ok(ClaimResult::Rejected {
current_authority: current.node_id.clone(),
current_token: current.fencing_token,
});
}
}
let new_token = self.state.highest_seen_token.next();
let previous = self
.state
.current_authority
.as_ref()
.map(|r| r.node_id.clone());
let mut record =
AuthorityRecord::new(self.state.local_node_id.clone(), new_token, previous);
record
.acknowledgements
.insert(self.state.local_node_id.clone());
self.state.current_authority = Some(record);
self.state.local_role = AuthorityRole::Authority;
self.state.highest_seen_token = new_token;
self.persist()?;
Ok(ClaimResult::Granted { token: new_token })
}
pub fn accept_authority(
&mut self,
authority_node: &str,
token: FencingToken,
) -> Result<(), AuthorityError> {
if !token.supersedes(&self.state.highest_seen_token)
&& token != self.state.highest_seen_token
{
return Err(AuthorityError::StaleToken {
provided: token,
current: self.state.highest_seen_token,
});
}
self.state.highest_seen_token = token;
if self.state.local_role == AuthorityRole::Authority
&& authority_node != self.state.local_node_id
{
self.state.local_role = AuthorityRole::Fenced;
} else if authority_node != self.state.local_node_id {
self.state.local_role = AuthorityRole::Replica;
}
self.state.current_authority = Some(AuthorityRecord::new(
authority_node.to_string(),
token,
self.state
.current_authority
.as_ref()
.map(|r| r.node_id.clone()),
));
if authority_node != self.state.local_node_id {
self.heartbeats.record_heartbeat(authority_node);
}
self.persist()
}
pub fn acknowledge_authority(&mut self, from_node: &str) -> Result<(), AuthorityError> {
if let Some(ref mut authority) = self.state.current_authority {
authority.acknowledgements.insert(from_node.to_string());
self.persist()?;
}
Ok(())
}
pub fn validate_write(&self, token: Option<FencingToken>) -> Result<(), AuthorityError> {
if self.state.is_fenced() {
return Err(AuthorityError::Fenced(self.state.highest_seen_token));
}
if !self.state.is_authority() {
return Err(AuthorityError::NotAuthority);
}
if let Some(provided) = token {
if provided != self.state.current_token() {
return Err(AuthorityError::StaleToken {
provided,
current: self.state.current_token(),
});
}
}
Ok(())
}
pub fn validate_quorum_write(
&self,
token: FencingToken,
required_quorum: usize,
) -> Result<(), AuthorityError> {
self.validate_write(Some(token))?;
let live = 1 + self
.heartbeats
.last_seen
.keys()
.filter(|node_id| !self.heartbeats.is_failed(node_id))
.count();
if live < required_quorum {
return Err(AuthorityError::QuorumUnavailable {
live,
required: required_quorum,
});
}
Ok(())
}
pub fn step_down(&mut self) -> Result<(), AuthorityError> {
if self.state.local_role == AuthorityRole::Authority {
self.state.local_role = AuthorityRole::Replica;
self.persist()?;
}
Ok(())
}
pub fn recover_from_fenced(&mut self) -> Result<(), AuthorityError> {
if self.state.local_role == AuthorityRole::Fenced {
self.state.local_role = AuthorityRole::Replica;
self.persist()?;
}
Ok(())
}
pub fn election_priority(&self, operations_applied: u64) -> ElectionPriority {
ElectionPriority::new(
operations_applied,
self.state.local_priority,
self.state.local_node_id.clone(),
)
}
pub fn elect_authority(candidates: &[ElectionPriority]) -> Option<&ElectionPriority> {
candidates.iter().max()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorityAnnouncement {
pub authority_node: String,
pub fencing_token: FencingToken,
pub elected_at_ms: u64,
pub cluster_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Heartbeat {
pub from_node: String,
pub is_authority: bool,
pub fencing_token: FencingToken,
pub operations_applied: u64,
pub timestamp_ms: u64,
}
impl Heartbeat {
pub fn new(
from_node: String,
is_authority: bool,
fencing_token: FencingToken,
operations_applied: u64,
) -> Self {
Self {
from_node,
is_authority,
fencing_token,
operations_applied,
timestamp_ms: now_ms(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ElectionRequest {
pub candidate_node: String,
pub priority: ElectionPriority,
pub proposed_token: FencingToken,
pub timestamp_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ElectionResponse {
pub from_node: String,
pub vote_granted: bool,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fencing_token_ordering() {
let t1 = FencingToken::initial();
let t2 = t1.next();
let t3 = t2.next();
assert!(t2.supersedes(&t1));
assert!(t3.supersedes(&t2));
assert!(t3.supersedes(&t1));
assert!(!t1.supersedes(&t2));
}
#[test]
fn authority_claim_and_fencing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("authority.json");
let mut store_a =
AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store A");
let result = store_a.claim_authority().expect("claim");
match result {
ClaimResult::Granted { token } => {
assert_eq!(token, FencingToken(2)); }
_ => panic!("expected Granted"),
}
assert!(store_a.state().is_authority());
let path_b = dir.path().join("authority_b.json");
let mut store_b =
AuthorityStore::open(&path_b, "test-cluster", "B", 5).expect("open store B");
store_b
.accept_authority("A", FencingToken(2))
.expect("accept");
assert!(!store_b.state().is_authority());
assert_eq!(store_b.state().authority_node(), Some("A"));
let result_b = store_b.claim_authority().expect("claim B");
match result_b {
ClaimResult::Rejected {
current_authority,
current_token,
} => {
assert_eq!(current_authority, "A");
assert_eq!(current_token, FencingToken(2));
}
_ => panic!("expected Rejected"),
}
}
#[test]
fn failover_when_authority_dies() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("authority_a.json");
let mut store_a =
AuthorityStore::open(&path_a, "test-cluster", "A", 10).expect("open store A");
store_a.claim_authority().expect("claim A");
let path_b = dir.path().join("authority_b.json");
let mut store_b =
AuthorityStore::open(&path_b, "test-cluster", "B", 5).expect("open store B");
store_b
.accept_authority("A", FencingToken(2))
.expect("accept A");
store_b.heartbeats.last_seen.insert("A".to_string(), 0);
assert!(store_b.authority_failed());
let result_b = store_b.claim_authority().expect("claim B after failover");
match result_b {
ClaimResult::Granted { token } => {
assert_eq!(token, FencingToken(3)); }
_ => panic!("expected Granted after failover"),
}
}
#[test]
fn stale_authority_is_fenced() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("authority_a.json");
let mut store_a =
AuthorityStore::open(&path_a, "test-cluster", "A", 10).expect("open store A");
store_a.claim_authority().expect("claim A");
assert!(store_a.state().is_authority());
store_a
.accept_authority("B", FencingToken(3))
.expect("accept B");
assert!(store_a.state().is_fenced());
assert!(!store_a.state().is_authority());
let result = store_a.validate_write(None);
assert!(matches!(result, Err(AuthorityError::Fenced(_))));
}
#[test]
fn election_priority_ordering() {
let p1 = ElectionPriority::new(100, 5, "A".to_string());
let p2 = ElectionPriority::new(100, 10, "B".to_string());
let p3 = ElectionPriority::new(150, 5, "C".to_string());
assert!(p3 > p1);
assert!(p3 > p2);
assert!(p2 > p1);
let candidates = vec![p1.clone(), p2.clone(), p3.clone()];
let winner = AuthorityStore::elect_authority(&candidates);
assert_eq!(winner.map(|w| &w.node_id), Some(&"C".to_string()));
}
#[test]
fn heartbeat_failure_detection() {
let mut heartbeats = HeartbeatState::new(1000, 3);
heartbeats.record_heartbeat("A");
assert!(!heartbeats.is_failed("A"));
heartbeats.last_seen.insert("A".to_string(), 0);
assert!(heartbeats.is_failed("A"));
}
#[test]
fn partitioned_authority_loses_write_quorum() {
let dir = tempfile::tempdir().unwrap();
let mut store = AuthorityStore::open_with_failure_detector(
dir.path().join("authority.json"),
"test-cluster",
"A",
10,
1,
1,
)
.expect("open authority store");
let token = match store.claim_authority().expect("claim") {
ClaimResult::Granted { token } => token,
_ => panic!("authority claim was not granted"),
};
store.heartbeat("B");
assert!(store.validate_quorum_write(token, 2).is_ok());
store.heartbeats.last_seen.insert("B".to_string(), 0);
assert!(matches!(
store.validate_quorum_write(token, 2),
Err(AuthorityError::QuorumUnavailable {
live: 1,
required: 2
})
));
}
#[test]
fn recovery_from_fenced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("authority.json");
let mut store = AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
store.claim_authority().expect("claim");
store
.accept_authority("B", FencingToken(3))
.expect("accept B");
assert!(store.state().is_fenced());
store.recover_from_fenced().expect("recover");
assert!(!store.state().is_fenced());
assert_eq!(store.state().local_role, AuthorityRole::Replica);
}
}