use std::collections::{BTreeMap, BTreeSet};
use mongreldb_types::ids::{NodeId, TableId, TabletId};
use serde::{Deserialize, Serialize};
use crate::node::{Locality, NodeDescriptor, NodeState};
use crate::tablet::{ReplicaDescriptor, ReplicaRole, TabletDescriptor, TabletState};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalityConstraint {
pub key: String,
pub value: String,
pub required: bool,
}
impl LocalityConstraint {
pub fn required(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
required: true,
}
}
pub fn preferred(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
required: false,
}
}
pub fn satisfied_by(&self, locality: &Locality) -> bool {
locality.get(&self.key) == Some(self.value.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlacementPolicy {
pub replicas: u8,
pub voter_constraints: Vec<LocalityConstraint>,
pub leader_preferences: Vec<LocalityConstraint>,
pub prohibited_nodes: Vec<NodeId>,
}
impl Default for PlacementPolicy {
fn default() -> Self {
Self {
replicas: 3,
voter_constraints: Vec::new(),
leader_preferences: Vec::new(),
prohibited_nodes: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PlacementError {
#[error("placement policy must request at least one replica")]
ZeroReplicas,
#[error("locality constraint has an empty key or value")]
EmptyConstraint,
#[error("placement policy is infeasible: {0}")]
Infeasible(String),
#[error("voter change on an empty group is meaningless")]
EmptyGroup,
#[error(
"{change} refused: {remaining} healthy voter(s) would remain below the quorum \
{quorum} of the current {current_voters}-voter configuration"
)]
QuorumViolation {
current_voters: u32,
remaining: u32,
quorum: u32,
change: &'static str,
},
}
pub fn quorum_size(voters: u32) -> u32 {
voters / 2 + 1
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VoterChange {
AddVoter,
AddLearner,
PromoteLearner,
DemoteVoter,
RemoveVoter,
}
impl VoterChange {
pub fn name(self) -> &'static str {
match self {
Self::AddVoter => "add voter",
Self::AddLearner => "add learner",
Self::PromoteLearner => "promote learner",
Self::DemoteVoter => "demote voter",
Self::RemoveVoter => "remove voter",
}
}
}
pub fn check_move_safety(current_voters: u32, change: VoterChange) -> Result<(), PlacementError> {
check_move_safety_healthy(current_voters, current_voters, change)
}
pub fn check_move_safety_healthy(
configured_voters: u32,
healthy_voters: u32,
change: VoterChange,
) -> Result<(), PlacementError> {
if configured_voters == 0 {
return Err(PlacementError::EmptyGroup);
}
let healthy_voters = healthy_voters.min(configured_voters);
match change {
VoterChange::AddVoter | VoterChange::AddLearner | VoterChange::PromoteLearner => Ok(()),
VoterChange::DemoteVoter | VoterChange::RemoveVoter => {
let remaining_configured = configured_voters - 1;
let remaining_healthy = healthy_voters.saturating_sub(1);
let quorum = quorum_size(configured_voters);
if remaining_configured < quorum || remaining_healthy < quorum {
return Err(PlacementError::QuorumViolation {
current_voters: healthy_voters,
remaining: remaining_healthy.min(remaining_configured),
quorum,
change: change.name(),
});
}
Ok(())
}
}
}
pub fn validate_policy(
policy: &PlacementPolicy,
nodes: &[NodeDescriptor],
) -> Result<(), PlacementError> {
if policy.replicas == 0 {
return Err(PlacementError::ZeroReplicas);
}
for constraint in policy
.voter_constraints
.iter()
.chain(&policy.leader_preferences)
{
if constraint.key.is_empty() || constraint.value.is_empty() {
return Err(PlacementError::EmptyConstraint);
}
}
let prohibited: BTreeSet<NodeId> = policy.prohibited_nodes.iter().copied().collect();
let eligible = nodes
.iter()
.filter(|node| node.state == NodeState::Up)
.filter(|node| !prohibited.contains(&node.node_id))
.filter(|node| {
policy
.voter_constraints
.iter()
.filter(|constraint| constraint.required)
.all(|constraint| constraint.satisfied_by(&node.locality))
})
.count();
if eligible < usize::from(policy.replicas) {
return Err(PlacementError::Infeasible(format!(
"policy requests {} replica(s) but only {eligible} eligible node(s) are up, \
not prohibited, and satisfy the required voter constraints",
policy.replicas
)));
}
Ok(())
}
fn zone_of(node: &NodeDescriptor) -> String {
if let Some(zone) = node.locality.get("zone") {
format!("zone:{zone}")
} else if let Some(region) = node.locality.get("region") {
format!("region:{region}")
} else {
zone_of_id(node.node_id)
}
}
fn zone_of_id(node_id: NodeId) -> String {
format!("node:{}", node_id.to_hex())
}
fn seed_zone_counts(
existing: &[ReplicaDescriptor],
nodes: &[NodeDescriptor],
) -> BTreeMap<String, usize> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for replica in existing {
let zone = nodes
.iter()
.find(|node| node.node_id == replica.node_id)
.map_or_else(|| zone_of_id(replica.node_id), zone_of);
*counts.entry(zone).or_insert(0) += 1;
}
counts
}
pub fn choose_replicas(
policy: &PlacementPolicy,
nodes: &[NodeDescriptor],
existing: &[ReplicaDescriptor],
) -> Vec<NodeId> {
let existing_voters = existing
.iter()
.filter(|replica| replica.role == ReplicaRole::Voter)
.count();
let needed = usize::from(policy.replicas).saturating_sub(existing_voters);
if needed == 0 {
return Vec::new();
}
let excluded: BTreeSet<NodeId> = existing.iter().map(|replica| replica.node_id).collect();
let mut zone_counts = seed_zone_counts(existing, nodes);
choose_targets(policy, nodes, &excluded, &mut zone_counts, needed, None)
}
fn choose_targets(
policy: &PlacementPolicy,
nodes: &[NodeDescriptor],
excluded: &BTreeSet<NodeId>,
zone_counts: &mut BTreeMap<String, usize>,
count: usize,
load_scores: Option<&BTreeMap<NodeId, u64>>,
) -> Vec<NodeId> {
let prohibited: BTreeSet<NodeId> = policy.prohibited_nodes.iter().copied().collect();
let mut chosen: Vec<NodeId> = Vec::with_capacity(count);
for _ in 0..count {
let candidate = nodes
.iter()
.filter(|node| node.state == NodeState::Up)
.filter(|node| !prohibited.contains(&node.node_id))
.filter(|node| !excluded.contains(&node.node_id))
.filter(|node| !chosen.contains(&node.node_id))
.filter(|node| {
policy
.voter_constraints
.iter()
.filter(|constraint| constraint.required)
.all(|constraint| constraint.satisfied_by(&node.locality))
})
.filter(|node| load_scores.is_none_or(|scores| scores.contains_key(&node.node_id)))
.min_by_key(|node| {
(
policy
.voter_constraints
.iter()
.filter(|constraint| {
!constraint.required && !constraint.satisfied_by(&node.locality)
})
.count(),
zone_counts.get(&zone_of(node)).copied().unwrap_or(0),
load_scores
.and_then(|scores| scores.get(&node.node_id).copied())
.unwrap_or(0),
node.node_id,
)
});
let Some(node) = candidate else { break };
*zone_counts.entry(zone_of(node)).or_insert(0) += 1;
chosen.push(node.node_id);
}
chosen
}
pub fn choose_leader(
policy: &PlacementPolicy,
voters: &[NodeId],
nodes: &[NodeDescriptor],
) -> Option<NodeId> {
voters
.iter()
.filter_map(|voter| nodes.iter().find(|node| node.node_id == *voter))
.filter(|node| node.state == NodeState::Up)
.filter(|node| {
policy
.leader_preferences
.iter()
.filter(|preference| preference.required)
.all(|preference| preference.satisfied_by(&node.locality))
})
.min_by_key(|node| {
(
policy
.leader_preferences
.iter()
.filter(|preference| {
!preference.required && !preference.satisfied_by(&node.locality)
})
.count(),
node.node_id,
)
})
.map(|node| node.node_id)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeLoad {
pub node_id: NodeId,
pub disk_used_bytes: u64,
pub write_ops: u64,
pub read_ops: u64,
pub cpu_millis: u64,
pub memory_used_bytes: u64,
pub replica_count: u64,
pub leader_count: u64,
pub ai_index_memory_bytes: u64,
}
impl Default for NodeLoad {
fn default() -> Self {
Self::for_node(NodeId::ZERO)
}
}
impl NodeLoad {
pub fn for_node(node_id: NodeId) -> Self {
Self {
node_id,
disk_used_bytes: 0,
write_ops: 0,
read_ops: 0,
cpu_millis: 0,
memory_used_bytes: 0,
replica_count: 0,
leader_count: 0,
ai_index_memory_bytes: 0,
}
}
pub fn score(&self, maxima: &NodeLoad) -> u64 {
per_mille(self.disk_used_bytes, maxima.disk_used_bytes)
+ per_mille(self.write_ops, maxima.write_ops)
+ per_mille(self.read_ops, maxima.read_ops)
+ per_mille(self.cpu_millis, maxima.cpu_millis)
+ per_mille(self.memory_used_bytes, maxima.memory_used_bytes)
+ per_mille(self.replica_count, maxima.replica_count)
+ per_mille(self.leader_count, maxima.leader_count)
+ per_mille(self.ai_index_memory_bytes, maxima.ai_index_memory_bytes)
}
}
fn per_mille(value: u64, maximum: u64) -> u64 {
value.saturating_mul(1000) / maximum.max(1)
}
fn load_maxima(loads: &[NodeLoad]) -> NodeLoad {
let mut maxima = NodeLoad::default();
for load in loads {
maxima.disk_used_bytes = maxima.disk_used_bytes.max(load.disk_used_bytes);
maxima.write_ops = maxima.write_ops.max(load.write_ops);
maxima.read_ops = maxima.read_ops.max(load.read_ops);
maxima.cpu_millis = maxima.cpu_millis.max(load.cpu_millis);
maxima.memory_used_bytes = maxima.memory_used_bytes.max(load.memory_used_bytes);
maxima.replica_count = maxima.replica_count.max(load.replica_count);
maxima.leader_count = maxima.leader_count.max(load.leader_count);
maxima.ai_index_memory_bytes = maxima.ai_index_memory_bytes.max(load.ai_index_memory_bytes);
}
maxima
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RebalanceConfig {
pub max_concurrent_moves: usize,
pub hot_threshold_per_mille: u64,
}
impl Default for RebalanceConfig {
fn default() -> Self {
Self {
max_concurrent_moves: 1,
hot_threshold_per_mille: 1250,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum MovementStep {
AddLearner {
tablet_id: TabletId,
node_id: NodeId,
},
CatchUp {
tablet_id: TabletId,
node_id: NodeId,
},
PromoteLearner {
tablet_id: TabletId,
node_id: NodeId,
},
TransferLeadership {
tablet_id: TabletId,
from: NodeId,
to: NodeId,
},
RemoveReplica {
tablet_id: TabletId,
node_id: NodeId,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplicaMove {
pub tablet_id: TabletId,
pub from: NodeId,
pub to: NodeId,
pub steps: Vec<MovementStep>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebalancePlan {
pub moves: Vec<ReplicaMove>,
}
impl RebalancePlan {
pub fn is_empty(&self) -> bool {
self.moves.is_empty()
}
}
pub fn plan_rebalance(
tablets: &[TabletDescriptor],
nodes: &[NodeDescriptor],
loads: &[NodeLoad],
policy_for: &dyn Fn(TableId) -> PlacementPolicy,
config: &RebalanceConfig,
) -> RebalancePlan {
let mut plan = RebalancePlan::default();
if config.max_concurrent_moves == 0 {
return plan;
}
let up_ids: BTreeSet<NodeId> = nodes
.iter()
.filter(|n| n.state == NodeState::Up)
.map(|n| n.node_id)
.collect();
let up_loads: Vec<NodeLoad> = loads
.iter()
.filter(|l| up_ids.contains(&l.node_id))
.copied()
.collect();
let maxima = load_maxima(&up_loads);
let mut planned_loads: BTreeMap<NodeId, NodeLoad> =
up_loads.iter().map(|load| (load.node_id, *load)).collect();
let current_scores = |planned_loads: &BTreeMap<NodeId, NodeLoad>| -> BTreeMap<NodeId, u64> {
nodes
.iter()
.filter(|node| node.state == NodeState::Up)
.filter_map(|node| {
planned_loads
.get(&node.node_id)
.map(|load| (node.node_id, load.score(&maxima)))
})
.collect()
};
let scores = current_scores(&planned_loads);
if scores.len() < 2 {
return plan;
}
let mean = scores.values().sum::<u64>() / scores.len() as u64;
let mut hot: Vec<(NodeId, u64)> = scores
.iter()
.filter(|(_, score)| {
score.saturating_mul(1000) > mean.saturating_mul(config.hot_threshold_per_mille)
})
.map(|(node_id, score)| (*node_id, *score))
.collect();
hot.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
let mut in_flight: BTreeSet<TabletId> = BTreeSet::new();
'sources: for (source, _) in hot {
let mut candidates: Vec<&TabletDescriptor> = tablets
.iter()
.filter(|tablet| tablet.state == TabletState::Active)
.filter(|tablet| !in_flight.contains(&tablet.tablet_id))
.filter(|tablet| tablet.voters().any(|replica| replica.node_id == source))
.collect();
candidates.sort_by_key(|tablet| tablet.tablet_id);
for tablet in candidates {
if plan.moves.len() >= config.max_concurrent_moves {
break 'sources;
}
let voters = tablet.voter_count() as u32;
if check_move_safety(voters + 1, VoterChange::RemoveVoter).is_err() {
continue;
}
let policy = policy_for(tablet.table_id);
let excluded: BTreeSet<NodeId> = tablet
.replicas
.iter()
.map(|replica| replica.node_id)
.collect();
let mut zone_counts = seed_zone_counts(&tablet.replicas, nodes);
let target = {
let scores = current_scores(&planned_loads);
choose_targets(
&policy,
nodes,
&excluded,
&mut zone_counts,
1,
Some(&scores),
)
};
let Some(target) = target.into_iter().next() else {
continue;
};
let mut steps = vec![
MovementStep::AddLearner {
tablet_id: tablet.tablet_id,
node_id: target,
},
MovementStep::CatchUp {
tablet_id: tablet.tablet_id,
node_id: target,
},
MovementStep::PromoteLearner {
tablet_id: tablet.tablet_id,
node_id: target,
},
];
let transfers_leadership = tablet.leader_hint == Some(source);
if transfers_leadership {
steps.push(MovementStep::TransferLeadership {
tablet_id: tablet.tablet_id,
from: source,
to: target,
});
}
steps.push(MovementStep::RemoveReplica {
tablet_id: tablet.tablet_id,
node_id: source,
});
plan.moves.push(ReplicaMove {
tablet_id: tablet.tablet_id,
from: source,
to: target,
steps,
});
in_flight.insert(tablet.tablet_id);
if let Some(load) = planned_loads.get_mut(&source) {
load.replica_count = load.replica_count.saturating_sub(1);
if transfers_leadership {
load.leader_count = load.leader_count.saturating_sub(1);
}
}
if let Some(load) = planned_loads.get_mut(&target) {
load.replica_count = load.replica_count.saturating_add(1);
if transfers_leadership {
load.leader_count = load.leader_count.saturating_add(1);
}
}
}
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::{BuildVersion, NodeCapacity, VersionInfo};
use crate::tablet::{PartitionBounds, TabletState};
use mongreldb_types::ids::RaftGroupId;
fn node_id(byte: u8) -> NodeId {
NodeId::from_bytes([byte; 16])
}
fn tablet_id(byte: u8) -> TabletId {
TabletId::from_bytes([byte; 16])
}
fn node(byte: u8, locality: &str) -> NodeDescriptor {
NodeDescriptor {
node_id: node_id(byte),
rpc_address: format!("127.0.0.1:{}", 8000 + u16::from(byte)),
locality: locality.parse().unwrap(),
capacity: NodeCapacity::default(),
state: NodeState::Up,
version: BuildVersion::current(),
version_info: VersionInfo::current(),
}
}
fn zoned_nodes() -> Vec<NodeDescriptor> {
vec![
node(1, "region=r1,zone=a"),
node(2, "region=r1,zone=a"),
node(3, "region=r1,zone=b"),
node(4, "region=r1,zone=b"),
node(5, "region=r2,zone=c"),
node(6, "region=r2,zone=c"),
]
}
fn voter(node: NodeId, raft_node_id: u64) -> ReplicaDescriptor {
ReplicaDescriptor {
node_id: node,
role: ReplicaRole::Voter,
raft_node_id,
}
}
#[test]
fn choose_replicas_is_deterministic_regardless_of_input_order() {
let nodes = zoned_nodes();
let policy = PlacementPolicy::default();
let first = choose_replicas(&policy, &nodes, &[]);
let second = choose_replicas(&policy, &nodes, &[]);
assert_eq!(first, second);
let mut reversed = nodes.clone();
reversed.reverse();
assert_eq!(first, choose_replicas(&policy, &reversed, &[]));
assert_eq!(first.len(), 3);
let unique: BTreeSet<_> = first.iter().collect();
assert_eq!(unique.len(), 3);
}
#[test]
fn choose_replicas_spreads_failure_domains_zone_then_node() {
let nodes = zoned_nodes();
let policy = PlacementPolicy::default();
let chosen = choose_replicas(&policy, &nodes, &[]);
let zones: BTreeSet<String> = chosen
.iter()
.map(|id| zone_of(nodes.iter().find(|n| &n.node_id == id).unwrap()))
.collect();
assert_eq!(zones.len(), 3);
let mut four = policy.clone();
four.replicas = 4;
let chosen = choose_replicas(&four, &nodes, &[]);
assert_eq!(chosen.len(), 4);
let mut zone_counts: BTreeMap<String, usize> = BTreeMap::new();
for id in &chosen {
let zone = zone_of(nodes.iter().find(|n| &n.node_id == id).unwrap());
*zone_counts.entry(zone).or_insert(0) += 1;
}
assert!(zone_counts.values().all(|count| *count <= 2));
}
#[test]
fn choose_replicas_never_picks_prohibited_nodes() {
let nodes = zoned_nodes();
let policy = PlacementPolicy {
prohibited_nodes: vec![node_id(1), node_id(3)],
..PlacementPolicy::default()
};
let chosen = choose_replicas(&policy, &nodes, &[]);
assert_eq!(chosen.len(), 3);
assert!(!chosen.contains(&node_id(1)));
assert!(!chosen.contains(&node_id(3)));
}
#[test]
fn choose_replicas_honors_required_and_preferred_locality() {
let nodes = zoned_nodes();
let mut policy = PlacementPolicy {
replicas: 2,
voter_constraints: vec![LocalityConstraint::required("zone", "b")],
..PlacementPolicy::default()
};
let chosen = choose_replicas(&policy, &nodes, &[]);
assert_eq!(chosen.len(), 2);
assert!(chosen
.iter()
.all(|id| matches!(*id, n if n == node_id(3) || n == node_id(4))));
policy.voter_constraints = vec![LocalityConstraint::preferred("zone", "a")];
policy.replicas = 3;
let chosen = choose_replicas(&policy, &nodes, &[]);
assert_eq!(chosen.len(), 3);
assert!(chosen[..2]
.iter()
.all(|id| matches!(*id, n if n == node_id(1) || n == node_id(2))));
}
#[test]
fn choose_replicas_complements_existing_replicas() {
let nodes = zoned_nodes();
let policy = PlacementPolicy::default();
let existing = vec![voter(node_id(1), 1), voter(node_id(3), 2)];
let chosen = choose_replicas(&policy, &nodes, &existing);
assert_eq!(chosen.len(), 1);
assert!(!existing.iter().any(|replica| replica.node_id == chosen[0]));
assert!(matches!(chosen[0], n if n == node_id(5) || n == node_id(6)));
let full = vec![
voter(node_id(1), 1),
voter(node_id(3), 2),
voter(node_id(5), 3),
];
assert!(choose_replicas(&policy, &nodes, &full).is_empty());
}
#[test]
fn validate_policy_checks_quorum_feasibility() {
let nodes = zoned_nodes();
assert!(validate_policy(&PlacementPolicy::default(), &nodes).is_ok());
let zero = PlacementPolicy {
replicas: 0,
..PlacementPolicy::default()
};
assert_eq!(
validate_policy(&zero, &nodes),
Err(PlacementError::ZeroReplicas)
);
let too_many = PlacementPolicy {
replicas: 7,
..PlacementPolicy::default()
};
assert!(matches!(
validate_policy(&too_many, &nodes),
Err(PlacementError::Infeasible(_))
));
let constrained = PlacementPolicy {
replicas: 3,
voter_constraints: vec![LocalityConstraint::required("zone", "a")],
..PlacementPolicy::default()
};
assert!(matches!(
validate_policy(&constrained, &nodes),
Err(PlacementError::Infeasible(_))
));
let prohibited = PlacementPolicy {
replicas: 3,
prohibited_nodes: (1..=4).map(node_id).collect(),
..PlacementPolicy::default()
};
assert!(matches!(
validate_policy(&prohibited, &nodes),
Err(PlacementError::Infeasible(_))
));
let empty = PlacementPolicy {
replicas: 2,
voter_constraints: vec![LocalityConstraint::required("", "a")],
..PlacementPolicy::default()
};
assert_eq!(
validate_policy(&empty, &nodes),
Err(PlacementError::EmptyConstraint)
);
}
#[test]
fn move_safety_never_drops_healthy_voters_below_quorum() {
for voters in 1..=5 {
for change in [
VoterChange::AddVoter,
VoterChange::AddLearner,
VoterChange::PromoteLearner,
] {
assert!(check_move_safety(voters, change).is_ok());
}
}
assert!(check_move_safety(5, VoterChange::RemoveVoter).is_ok());
assert!(check_move_safety(4, VoterChange::RemoveVoter).is_ok());
assert!(check_move_safety(3, VoterChange::RemoveVoter).is_ok());
assert!(check_move_safety(3, VoterChange::DemoteVoter).is_ok());
let error = check_move_safety(2, VoterChange::RemoveVoter).unwrap_err();
assert_eq!(
error,
PlacementError::QuorumViolation {
current_voters: 2,
remaining: 1,
quorum: 2,
change: "remove voter",
}
);
assert!(check_move_safety(1, VoterChange::RemoveVoter).is_err());
assert!(check_move_safety(1, VoterChange::DemoteVoter).is_err());
assert_eq!(
check_move_safety(0, VoterChange::AddVoter),
Err(PlacementError::EmptyGroup)
);
}
#[test]
fn choose_leader_follows_leader_preferences() {
let nodes = zoned_nodes();
let voters = vec![node_id(1), node_id(3), node_id(5)];
let policy = PlacementPolicy::default();
assert_eq!(choose_leader(&policy, &voters, &nodes), Some(node_id(1)));
let preferred = PlacementPolicy {
leader_preferences: vec![LocalityConstraint::preferred("zone", "c")],
..PlacementPolicy::default()
};
assert_eq!(choose_leader(&preferred, &voters, &nodes), Some(node_id(5)));
let required = PlacementPolicy {
leader_preferences: vec![LocalityConstraint::required("zone", "b")],
..PlacementPolicy::default()
};
assert_eq!(choose_leader(&required, &voters, &nodes), Some(node_id(3)));
let impossible = PlacementPolicy {
leader_preferences: vec![LocalityConstraint::required("zone", "z")],
..PlacementPolicy::default()
};
assert_eq!(choose_leader(&impossible, &voters, &nodes), None);
}
fn tablet(byte: u8, voters: &[u8], leader: Option<u8>) -> TabletDescriptor {
TabletDescriptor {
tablet_id: tablet_id(byte),
table_id: TableId::new(7),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: RaftGroupId::from_bytes([byte.wrapping_add(64); 16]),
partition: PartitionBounds::unbounded(),
replicas: voters
.iter()
.enumerate()
.map(|(index, node)| voter(node_id(*node), index as u64 + 1))
.collect(),
leader_hint: leader.map(node_id),
generation: 1,
state: TabletState::Active,
}
}
fn load(byte: u8, disk: u64, replicas: u64, leaders: u64) -> NodeLoad {
NodeLoad {
node_id: node_id(byte),
disk_used_bytes: disk,
write_ops: 0,
read_ops: 0,
cpu_millis: 0,
memory_used_bytes: 0,
replica_count: replicas,
leader_count: leaders,
ai_index_memory_bytes: 0,
}
}
fn rebalance_fixture() -> (Vec<TabletDescriptor>, Vec<NodeDescriptor>, Vec<NodeLoad>) {
let nodes = vec![node(1, ""), node(2, ""), node(3, ""), node(4, "")];
let tablets = vec![
tablet(1, &[1, 2, 3], Some(1)),
tablet(2, &[1, 2, 3], Some(2)),
tablet(3, &[1], Some(1)),
];
let loads = vec![
load(1, 1_000, 6, 2),
load(2, 10, 1, 1),
load(3, 10, 1, 0),
load(4, 10, 0, 0),
];
(tablets, nodes, loads)
}
fn default_policy(_: TableId) -> PlacementPolicy {
PlacementPolicy::default()
}
#[test]
fn rebalancer_moves_replicas_off_a_hot_node_without_dropping_quorum() {
let (tablets, nodes, loads) = rebalance_fixture();
let config = RebalanceConfig {
max_concurrent_moves: 4,
..RebalanceConfig::default()
};
let plan = plan_rebalance(&tablets, &nodes, &loads, &default_policy, &config);
assert_eq!(plan.moves.len(), 2);
for movement in &plan.moves {
assert_eq!(movement.from, node_id(1));
assert_eq!(movement.to, node_id(4));
assert_eq!(
movement.steps.first(),
Some(&MovementStep::AddLearner {
tablet_id: movement.tablet_id,
node_id: node_id(4),
})
);
assert_eq!(
movement.steps.last(),
Some(&MovementStep::RemoveReplica {
tablet_id: movement.tablet_id,
node_id: node_id(1),
})
);
}
for movement in &plan.moves {
let tablet = tablets
.iter()
.find(|tablet| tablet.tablet_id == movement.tablet_id)
.unwrap();
let mut voters: BTreeSet<NodeId> =
tablet.voters().map(|replica| replica.node_id).collect();
let mut learners: BTreeSet<NodeId> = BTreeSet::new();
for step in &movement.steps {
match step {
MovementStep::AddLearner { node_id, .. } => {
assert!(learners.insert(*node_id));
}
MovementStep::CatchUp { .. } => {}
MovementStep::PromoteLearner { node_id, .. } => {
assert!(learners.remove(node_id));
assert!(voters.insert(*node_id));
}
MovementStep::TransferLeadership { .. } => {}
MovementStep::RemoveReplica { node_id, .. } => {
assert!(
check_move_safety(voters.len() as u32, VoterChange::RemoveVoter)
.is_ok()
);
assert!(voters.remove(node_id));
}
}
assert!(voters.len() as u32 >= quorum_size(voters.len() as u32));
}
assert_eq!(voters.len(), 3);
}
let led = plan
.moves
.iter()
.find(|movement| movement.tablet_id == tablet_id(1))
.unwrap();
assert!(led.steps.contains(&MovementStep::TransferLeadership {
tablet_id: tablet_id(1),
from: node_id(1),
to: node_id(4),
}));
let unled = plan
.moves
.iter()
.find(|movement| movement.tablet_id == tablet_id(2))
.unwrap();
assert!(!unled
.steps
.iter()
.any(|step| matches!(step, MovementStep::TransferLeadership { .. })));
}
#[test]
fn rebalancer_is_deterministic_and_bounded_by_max_concurrent_moves() {
let (tablets, nodes, loads) = rebalance_fixture();
let config = RebalanceConfig {
max_concurrent_moves: 1,
..RebalanceConfig::default()
};
let first = plan_rebalance(&tablets, &nodes, &loads, &default_policy, &config);
let second = plan_rebalance(&tablets, &nodes, &loads, &default_policy, &config);
assert_eq!(first, second);
assert_eq!(first.moves.len(), 1);
assert_eq!(first.moves[0].from, node_id(1));
let disabled = RebalanceConfig {
max_concurrent_moves: 0,
..RebalanceConfig::default()
};
assert!(plan_rebalance(&tablets, &nodes, &loads, &default_policy, &disabled).is_empty());
}
#[test]
fn rebalancer_leaves_a_balanced_cluster_alone() {
let nodes = vec![node(1, ""), node(2, ""), node(3, "")];
let tablets = vec![tablet(1, &[1, 2, 3], Some(1))];
let loads = vec![load(1, 10, 1, 0), load(2, 10, 1, 0), load(3, 10, 1, 0)];
let plan = plan_rebalance(
&tablets,
&nodes,
&loads,
&default_policy,
&RebalanceConfig::default(),
);
assert!(plan.is_empty());
}
#[test]
fn rebalancer_never_targets_unreported_or_prohibited_nodes() {
let (tablets, nodes, loads) = rebalance_fixture();
let config = RebalanceConfig {
max_concurrent_moves: 4,
..RebalanceConfig::default()
};
let prohibiting = |_: TableId| PlacementPolicy {
prohibited_nodes: vec![node_id(4)],
..PlacementPolicy::default()
};
assert!(plan_rebalance(&tablets, &nodes, &loads, &prohibiting, &config).is_empty());
let loads_without_four: Vec<NodeLoad> = loads
.into_iter()
.filter(|load| load.node_id != node_id(4))
.collect();
assert!(plan_rebalance(
&tablets,
&nodes,
&loads_without_four,
&default_policy,
&config
)
.is_empty());
}
#[test]
fn placement_records_round_trip_serde() {
let policy = PlacementPolicy {
replicas: 5,
voter_constraints: vec![LocalityConstraint::required("zone", "a")],
leader_preferences: vec![LocalityConstraint::preferred("region", "r1")],
prohibited_nodes: vec![node_id(9)],
};
let json = serde_json::to_vec(&policy).unwrap();
assert_eq!(
serde_json::from_slice::<PlacementPolicy>(&json).unwrap(),
policy
);
let plan = RebalancePlan {
moves: vec![ReplicaMove {
tablet_id: tablet_id(1),
from: node_id(1),
to: node_id(4),
steps: vec![
MovementStep::AddLearner {
tablet_id: tablet_id(1),
node_id: node_id(4),
},
MovementStep::CatchUp {
tablet_id: tablet_id(1),
node_id: node_id(4),
},
MovementStep::PromoteLearner {
tablet_id: tablet_id(1),
node_id: node_id(4),
},
MovementStep::RemoveReplica {
tablet_id: tablet_id(1),
node_id: node_id(1),
},
],
}],
};
let json = serde_json::to_vec(&plan).unwrap();
assert_eq!(
serde_json::from_slice::<RebalancePlan>(&json).unwrap(),
plan
);
}
}