use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::replica::LogEntry;
use super::types::{ClusterId, HardState, LogPosition, NodeId, Term};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveredState {
pub cluster_id: Option<ClusterId>,
pub hard: Option<HardState>,
pub log: Option<Vec<LogEntry>>,
pub active: u32,
pub commit_marker: u64,
pub integrity: Integrity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Integrity {
pub hard_state_verified: bool,
pub log_verified: bool,
}
impl RecoveredState {
pub fn blank() -> Self {
Self {
cluster_id: None,
hard: None,
log: None,
active: 0,
commit_marker: 0,
integrity: Integrity {
hard_state_verified: true,
log_verified: true,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuarantineReason {
TornHardState,
MissingHardState,
ClusterIdMismatch,
LogCorruption,
CommitBeyondLog,
UnsupportedCapability,
}
impl QuarantineReason {
pub fn is_corruption_evidence(&self) -> bool {
matches!(
self,
QuarantineReason::TornHardState | QuarantineReason::LogCorruption
)
}
}
#[derive(Debug)]
pub enum BootDecision {
Healthy { hard: HardState, log: Vec<LogEntry> },
Quarantine {
reasons: Vec<QuarantineReason>,
term_hint: Option<Term>,
},
}
pub fn inspect(
expected_cluster: ClusterId,
supported: u32,
recovered: &RecoveredState,
) -> BootDecision {
if recovered.cluster_id.is_none()
&& recovered.hard.is_none()
&& recovered.log.is_none()
&& recovered.commit_marker == 0
{
return BootDecision::Healthy {
hard: HardState::default(),
log: Vec::new(),
};
}
let mut reasons = Vec::new();
match recovered.cluster_id {
Some(id) if id != expected_cluster => reasons.push(QuarantineReason::ClusterIdMismatch),
None => {
reasons.push(QuarantineReason::ClusterIdMismatch);
}
Some(_) => {}
}
match (&recovered.hard, recovered.integrity.hard_state_verified) {
(Some(_), true) => {}
(Some(_), false) => reasons.push(QuarantineReason::TornHardState),
(None, _) => reasons.push(QuarantineReason::MissingHardState),
}
if !recovered.integrity.log_verified {
reasons.push(QuarantineReason::LogCorruption);
}
let log_len = recovered.log.as_ref().map_or(0, |l| l.len() as u64);
if recovered.commit_marker > log_len {
reasons.push(QuarantineReason::CommitBeyondLog);
}
let suffix_bits: u32 = recovered
.log
.iter()
.flatten()
.filter_map(|e| e.activate)
.fold(0, |acc, b| acc | b);
if supported & (recovered.active | suffix_bits) != (recovered.active | suffix_bits) {
reasons.push(QuarantineReason::UnsupportedCapability);
}
if reasons.is_empty() {
BootDecision::Healthy {
hard: recovered.hard.expect("verified above"),
log: recovered.log.clone().unwrap_or_default(),
}
} else {
BootDecision::Quarantine {
reasons,
term_hint: recovered.hard.map(|h| h.current_term),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RejoinMessage {
Request { node: NodeId },
Grant {
cluster_id: ClusterId,
term: Term,
base: LogPosition,
log: Vec<LogEntry>,
claims: BTreeMap<u64, u64>,
active: u32,
commit: u64,
verified: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootstrapEffect {
PreserveOldState,
Alarm { reasons: Vec<QuarantineReason> },
Send { to: NodeId, msg: RejoinMessage },
AdoptSnapshot {
cluster_id: ClusterId,
hard: HardState,
base: LogPosition,
log: Vec<LogEntry>,
claims: BTreeMap<u64, u64>,
active: u32,
},
}
pub struct QuarantinedNode {
id: NodeId,
cluster: ClusterId,
reasons: Vec<QuarantineReason>,
term_hint: Option<Term>,
preserved: bool,
alarmed: bool,
}
impl QuarantinedNode {
pub fn new(
id: NodeId,
cluster: ClusterId,
reasons: Vec<QuarantineReason>,
term_hint: Option<Term>,
) -> Self {
debug_assert!(!reasons.is_empty(), "quarantine requires a reason");
Self {
id,
cluster,
reasons,
term_hint,
preserved: false,
alarmed: false,
}
}
pub fn reasons(&self) -> &[QuarantineReason] {
&self.reasons
}
pub fn stale_reads_allowed(&self) -> bool {
!self
.reasons
.iter()
.any(|r| matches!(r, QuarantineReason::LogCorruption))
}
pub fn tick_rejoin(&mut self, leader_hint: NodeId) -> Vec<BootstrapEffect> {
let mut out = Vec::new();
if !self.preserved {
self.preserved = true;
out.push(BootstrapEffect::PreserveOldState);
}
if !self.alarmed && self.reasons.iter().any(|r| r.is_corruption_evidence()) {
self.alarmed = true;
out.push(BootstrapEffect::Alarm {
reasons: self.reasons.clone(),
});
}
out.push(BootstrapEffect::Send {
to: leader_hint,
msg: RejoinMessage::Request { node: self.id },
});
out
}
pub fn on_grant(&mut self, from: NodeId, grant: RejoinMessage) -> Vec<BootstrapEffect> {
let RejoinMessage::Grant {
cluster_id,
term,
base,
log,
claims,
active,
commit: _,
verified,
} = grant
else {
return Vec::new();
};
if cluster_id != self.cluster {
return Vec::new();
}
if self.reasons.iter().any(|r| r.is_corruption_evidence()) && !verified {
return Vec::new();
}
if let Some(hint) = self.term_hint {
if term < hint {
return Vec::new();
}
}
vec![BootstrapEffect::AdoptSnapshot {
cluster_id,
hard: HardState {
current_term: term,
voted_for: Some(from),
},
base,
log,
claims,
active,
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
const CLUSTER: ClusterId = ClusterId(7);
fn coherent() -> RecoveredState {
RecoveredState {
cluster_id: Some(CLUSTER),
hard: Some(HardState {
current_term: Term(3),
voted_for: Some(NodeId(2)),
}),
log: Some(vec![LogEntry::unkeyed(
Term(3),
super::super::replica::Payload::Test(42),
)]),
active: 0,
commit_marker: 1,
integrity: Integrity {
hard_state_verified: true,
log_verified: true,
},
}
}
#[test]
fn blank_disk_boots_healthy_fresh() {
match inspect(CLUSTER, u32::MAX, &RecoveredState::blank()) {
BootDecision::Healthy { hard, log } => {
assert_eq!(hard, HardState::default());
assert!(log.is_empty());
}
BootDecision::Quarantine { reasons, .. } => {
panic!("fresh node quarantined: {reasons:?}")
}
}
}
#[test]
fn coherent_state_boots_healthy_with_exact_state() {
match inspect(CLUSTER, u32::MAX, &coherent()) {
BootDecision::Healthy { hard, log } => {
assert_eq!(hard.voted_for, Some(NodeId(2)));
assert_eq!(log.len(), 1);
}
BootDecision::Quarantine { reasons, .. } => panic!("quarantined: {reasons:?}"),
}
}
#[test]
fn torn_hard_state_quarantines() {
let mut s = coherent();
s.integrity.hard_state_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!("torn state booted healthy");
};
assert!(reasons.contains(&QuarantineReason::TornHardState));
}
#[test]
fn alien_cluster_quarantines() {
let mut s = coherent();
s.cluster_id = Some(ClusterId(99));
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!("alien state booted healthy");
};
assert!(reasons.contains(&QuarantineReason::ClusterIdMismatch));
}
#[test]
fn commit_marker_beyond_log_quarantines() {
let mut s = coherent();
s.commit_marker = 5; let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!("frontier-beyond-data booted healthy");
};
assert!(reasons.contains(&QuarantineReason::CommitBeyondLog));
}
#[test]
fn log_corruption_is_corruption_evidence_and_blocks_stale_reads() {
let mut s = coherent();
s.integrity.log_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!("corrupt log booted healthy");
};
let node = QuarantinedNode::new(NodeId(3), CLUSTER, reasons, None);
assert!(!node.stale_reads_allowed());
}
#[test]
fn torn_metadata_still_allows_labeled_stale_reads() {
let mut s = coherent();
s.integrity.hard_state_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!()
};
let node = QuarantinedNode::new(NodeId(3), CLUSTER, reasons, None);
assert!(node.stale_reads_allowed());
}
#[test]
fn corruption_requires_verified_grant() {
let mut s = coherent();
s.integrity.log_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!()
};
let mut node = QuarantinedNode::new(NodeId(3), CLUSTER, reasons, None);
let unverified = RejoinMessage::Grant {
cluster_id: CLUSTER,
term: Term(4),
base: LogPosition::ZERO,
log: Vec::new(),
claims: BTreeMap::new(),
active: 0,
commit: 0,
verified: false,
};
assert!(
node.on_grant(NodeId(1), unverified).is_empty(),
"unverified grant accepted"
);
let verified = RejoinMessage::Grant {
cluster_id: CLUSTER,
term: Term(4),
base: LogPosition::ZERO,
log: Vec::new(),
claims: BTreeMap::new(),
active: 0,
commit: 0,
verified: true,
};
assert!(
!node.on_grant(NodeId(1), verified).is_empty(),
"verified grant refused"
);
}
#[test]
fn wrong_cluster_grant_is_never_adopted() {
let mut s = coherent();
s.integrity.hard_state_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!()
};
let mut node = QuarantinedNode::new(NodeId(3), CLUSTER, reasons, None);
let alien = RejoinMessage::Grant {
cluster_id: ClusterId(99),
term: Term(4),
base: LogPosition::ZERO,
log: Vec::new(),
claims: BTreeMap::new(),
active: 0,
commit: 0,
verified: true,
};
assert!(node.on_grant(NodeId(1), alien).is_empty());
}
#[test]
fn preserve_happens_before_first_rejoin_request_and_alarm_fires_once() {
let mut s = coherent();
s.integrity.log_verified = false;
let BootDecision::Quarantine { reasons, .. } = inspect(CLUSTER, u32::MAX, &s) else {
panic!()
};
let mut node = QuarantinedNode::new(NodeId(3), CLUSTER, reasons, None);
let first = node.tick_rejoin(NodeId(1));
assert!(matches!(first[0], BootstrapEffect::PreserveOldState));
assert!(matches!(first[1], BootstrapEffect::Alarm { .. }));
assert!(matches!(first[2], BootstrapEffect::Send { .. }));
let second = node.tick_rejoin(NodeId(1));
assert_eq!(second.len(), 1, "preserve/alarm must fire once: {second:?}");
assert!(matches!(second[0], BootstrapEffect::Send { .. }));
}
}