#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MemberRole {
Learner,
Voter,
}
#[derive(Clone, Copy, Debug)]
pub struct PodState {
pub ordinal: u32,
pub ready: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct Member {
pub ordinal: u32,
pub role: MemberRole,
pub caught_up: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MembershipAction {
AddLearner { ordinal: u32 },
PromoteToVoter { ordinal: u32 },
Remove { ordinal: u32 },
}
fn majority(voters: usize) -> usize {
voters / 2 + 1
}
fn total_voters(members: &[Member]) -> usize {
members
.iter()
.filter(|m| m.role == MemberRole::Voter)
.count()
}
fn ready_voters(members: &[Member], pods: &[PodState]) -> usize {
members
.iter()
.filter(|m| m.role == MemberRole::Voter)
.filter(|m| pods.iter().any(|p| p.ordinal == m.ordinal && p.ready))
.count()
}
pub fn has_quorum(members: &[Member], pods: &[PodState]) -> bool {
let voters = total_voters(members);
voters > 0 && ready_voters(members, pods) >= majority(voters)
}
pub fn has_roll_margin(members: &[Member], pods: &[PodState]) -> bool {
let voters = total_voters(members);
voters > 1 && ready_voters(members, pods) > majority(voters)
}
pub fn plan_next(desired: u32, pods: &[PodState], members: &[Member]) -> Option<MembershipAction> {
if !has_quorum(members, pods) {
return None;
}
let mut out_of_range: Vec<&Member> = members.iter().filter(|m| m.ordinal >= desired).collect();
out_of_range.sort_by_key(|m| std::cmp::Reverse(m.ordinal));
for m in out_of_range {
if m.role == MemberRole::Voter && total_voters(members) <= 1 {
continue; }
return Some(MembershipAction::Remove { ordinal: m.ordinal });
}
if let Some(m) = members
.iter()
.filter(|m| m.ordinal < desired && m.role == MemberRole::Learner && m.caught_up)
.min_by_key(|m| m.ordinal)
{
return Some(MembershipAction::PromoteToVoter { ordinal: m.ordinal });
}
let is_member = |o: u32| members.iter().any(|m| m.ordinal == o);
if let Some(p) = pods
.iter()
.filter(|p| p.ordinal < desired && p.ready && !is_member(p.ordinal))
.min_by_key(|p| p.ordinal)
{
return Some(MembershipAction::AddLearner { ordinal: p.ordinal });
}
None
}
#[derive(Clone, Debug)]
pub struct ApiMember {
pub node_id: u64,
pub voter: bool,
pub caught_up: bool,
pub leader: bool,
pub addr: Option<String>,
}
pub fn ordinal_from_addr(addr: &str) -> Option<u32> {
let after_scheme = addr.split("//").last()?;
let host = after_scheme.split(['.', ':', '/']).next()?; let (name, ordinal) = host.rsplit_once('-')?;
if name.is_empty() {
return None;
}
ordinal.parse::<u32>().ok()
}
pub fn members_from_api(api: &[ApiMember]) -> (Vec<Member>, std::collections::BTreeMap<u32, u64>) {
let mut members = Vec::new();
let mut map = std::collections::BTreeMap::new();
for m in api {
let Some(ordinal) = m.addr.as_deref().and_then(ordinal_from_addr) else {
continue;
};
map.insert(ordinal, m.node_id);
members.push(Member {
ordinal,
role: if m.voter {
MemberRole::Voter
} else {
MemberRole::Learner
},
caught_up: m.caught_up,
});
}
(members, map)
}
pub fn action_node_id(
action: &MembershipAction,
ordinal_to_node: &std::collections::BTreeMap<u32, u64>,
) -> Option<u64> {
match action {
MembershipAction::PromoteToVoter { ordinal } | MembershipAction::Remove { ordinal } => {
ordinal_to_node.get(ordinal).copied()
}
MembershipAction::AddLearner { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::MemberRole::{Learner, Voter};
use super::*;
fn pod(ordinal: u32, ready: bool) -> PodState {
PodState { ordinal, ready }
}
fn member(ordinal: u32, role: MemberRole, caught_up: bool) -> Member {
Member {
ordinal,
role,
caught_up,
}
}
fn voters(n: u32) -> (Vec<PodState>, Vec<Member>) {
(
(0..n).map(|o| pod(o, true)).collect(),
(0..n).map(|o| member(o, Voter, true)).collect(),
)
}
#[test]
fn ordinal_parses_from_a_statefulset_pod_address() {
assert_eq!(
ordinal_from_addr("https://boatramp-cluster-2.boatramp-cluster.ns.svc:7000"),
Some(2)
);
assert_eq!(ordinal_from_addr("http://my-cluster-0.svc:8080"), Some(0));
assert_eq!(ordinal_from_addr("https://10.0.0.5:7000"), None);
}
#[test]
fn api_members_map_to_ordinals_and_resolve_action_node_ids() {
let api = vec![
ApiMember {
node_id: 0xAA,
voter: true,
caught_up: true,
leader: true,
addr: Some("https://sts-0.svc:7000".into()),
},
ApiMember {
node_id: 0xBB,
voter: false,
caught_up: true,
leader: false,
addr: Some("https://sts-1.svc:7000".into()),
},
ApiMember {
node_id: 0xCC,
voter: false,
caught_up: false,
leader: false,
addr: Some("https://10.0.0.9:7000".into()),
},
];
let (members, map) = members_from_api(&api);
assert_eq!(members.len(), 2);
assert_eq!(map.get(&0), Some(&0xAA));
assert_eq!(map.get(&1), Some(&0xBB));
assert!(!map.values().any(|&n| n == 0xCC));
assert_eq!(
action_node_id(&MembershipAction::PromoteToVoter { ordinal: 1 }, &map),
Some(0xBB)
);
assert_eq!(
action_node_id(&MembershipAction::Remove { ordinal: 0 }, &map),
Some(0xAA)
);
assert_eq!(
action_node_id(&MembershipAction::AddLearner { ordinal: 2 }, &map),
None
);
}
#[test]
fn roll_margin_needs_a_spare_voter() {
let (pods, members) = voters(3);
assert!(has_roll_margin(&members, &pods));
let mut pods2 = pods.clone();
pods2[0].ready = false;
assert!(!has_roll_margin(&members, &pods2));
let (p1, m1) = voters(1);
assert!(!has_roll_margin(&m1, &p1));
let (p5, m5) = voters(5);
assert!(has_roll_margin(&m5, &p5));
}
#[test]
fn majority_is_more_than_half() {
assert_eq!(majority(1), 1);
assert_eq!(majority(2), 2);
assert_eq!(majority(3), 2);
assert_eq!(majority(5), 3);
}
#[test]
fn quorum_needs_a_ready_majority_of_voters() {
let (pods, mem) = voters(3);
assert!(has_quorum(&mem, &pods));
let one_ready = vec![pod(0, true), pod(1, false), pod(2, false)];
assert!(!has_quorum(&mem, &one_ready));
let two_ready = vec![pod(0, true), pod(1, true), pod(2, false)];
assert!(has_quorum(&mem, &two_ready));
assert!(!has_quorum(&[], &[pod(0, true)]));
}
#[test]
fn waits_for_bootstrap_when_no_members_exist() {
let pods = vec![pod(0, true), pod(1, true), pod(2, true)];
assert_eq!(plan_next(3, &pods, &[]), None);
}
#[test]
fn scales_up_by_adding_then_promoting_one_at_a_time() {
let pods = vec![pod(0, true), pod(1, true), pod(2, true)];
let mem = vec![member(0, Voter, true)];
assert_eq!(
plan_next(3, &pods, &mem),
Some(MembershipAction::AddLearner { ordinal: 1 })
);
let mem = vec![member(0, Voter, true), member(1, Learner, false)];
assert_eq!(
plan_next(3, &pods, &mem),
Some(MembershipAction::AddLearner { ordinal: 2 })
);
let mem = vec![
member(0, Voter, true),
member(1, Learner, true),
member(2, Learner, false),
];
assert_eq!(
plan_next(3, &pods, &mem),
Some(MembershipAction::PromoteToVoter { ordinal: 1 })
);
}
#[test]
fn converged_cluster_plans_nothing() {
let (pods, mem) = voters(3);
assert_eq!(plan_next(3, &pods, &mem), None);
}
#[test]
fn scales_down_by_removing_out_of_range_highest_first() {
let (pods, mem) = voters(5);
assert_eq!(
plan_next(3, &pods, &mem),
Some(MembershipAction::Remove { ordinal: 4 })
);
let mem: Vec<_> = mem.into_iter().filter(|m| m.ordinal != 4).collect();
assert_eq!(
plan_next(3, &pods, &mem),
Some(MembershipAction::Remove { ordinal: 3 })
);
}
#[test]
fn never_acts_without_quorum() {
let pods = vec![pod(0, true), pod(1, false), pod(2, false)];
let mem: Vec<_> = (0..3).map(|o| member(o, Voter, true)).collect();
assert_eq!(
plan_next(1, &pods, &mem),
None,
"must not remove without quorum"
);
assert_eq!(
plan_next(5, &pods, &mem),
None,
"must not add without quorum"
);
}
#[test]
fn never_removes_the_last_voter() {
let pods = vec![pod(0, true)];
let mem = vec![member(0, Voter, true)];
assert_eq!(plan_next(0, &pods, &mem), None);
}
#[test]
fn removes_an_out_of_range_learner() {
let pods = vec![pod(0, true), pod(1, true), pod(2, true)];
let mem = vec![
member(0, Voter, true),
member(1, Voter, true),
member(2, Learner, false),
];
assert_eq!(
plan_next(2, &pods, &mem),
Some(MembershipAction::Remove { ordinal: 2 })
);
}
#[test]
fn planned_actions_always_respect_the_invariants() {
for desired in 0u32..6 {
for n in 1u32..6 {
for down in 0u32..n {
let pods: Vec<PodState> = (0..n).map(|o| pod(o, o >= down)).collect();
let mem: Vec<Member> = (0..n).map(|o| member(o, Voter, true)).collect();
let quorum = has_quorum(&mem, &pods);
if let Some(action) = plan_next(desired, &pods, &mem) {
assert!(quorum, "acted without quorum: {action:?}");
match action {
MembershipAction::Remove { ordinal } => {
assert!(ordinal >= desired, "removed in-range {ordinal}");
assert!(total_voters(&mem) > 1, "removed the last voter");
}
MembershipAction::PromoteToVoter { ordinal }
| MembershipAction::AddLearner { ordinal } => {
assert!(ordinal < desired, "acted out-of-range {ordinal}");
}
}
}
}
}
}
}
}