use std::collections::HashSet;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Master,
Worker,
Bootstrap,
Observer,
}
impl Role {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Master => "master",
Self::Worker => "worker",
Self::Bootstrap => "bootstrap",
Self::Observer => "observer",
}
}
#[must_use]
pub fn is_voting(self) -> bool {
matches!(self, Self::Master | Self::Bootstrap)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(pub String);
impl NodeId {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeState {
Joining,
Standby,
Active(Role),
Demoting,
Departing,
Failed,
}
impl NodeState {
#[must_use]
pub fn is_serving(self) -> bool {
matches!(self, Self::Active(_))
}
#[must_use]
pub fn role(self) -> Option<Role> {
match self {
Self::Active(r) => Some(r),
_ => None,
}
}
#[must_use]
pub fn is_eligible(self) -> bool {
!matches!(self, Self::Failed | Self::Departing)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleAssignment {
pub assignments: Vec<(NodeId, NodeState)>,
}
impl RoleAssignment {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, node: NodeId, state: NodeState) {
for entry in &mut self.assignments {
if entry.0 == node {
entry.1 = state;
return;
}
}
self.assignments.push((node, state));
}
#[must_use]
pub fn get(&self, node: &NodeId) -> Option<NodeState> {
self.assignments.iter().find(|(n, _)| n == node).map(|(_, s)| *s)
}
#[must_use]
pub fn nodes_with_role(&self, role: Role) -> Vec<&NodeId> {
self.assignments
.iter()
.filter(|(_, s)| s.role() == Some(role))
.map(|(n, _)| n)
.collect()
}
#[must_use]
pub fn eligible(&self) -> Vec<&NodeId> {
self.assignments
.iter()
.filter(|(_, s)| s.is_eligible())
.map(|(n, _)| n)
.collect()
}
#[must_use]
pub fn voting_count(&self) -> usize {
self.assignments
.iter()
.filter(|(_, s)| s.role().map(Role::is_voting).unwrap_or(false))
.count()
}
#[must_use]
pub fn has_majority(&self) -> bool {
let voting = self.voting_count();
let eligible_voting: usize = self
.assignments
.iter()
.filter(|(_, s)| s.is_eligible() && s.role().map(Role::is_voting).unwrap_or(false))
.count();
voting > 0 && voting * 2 > eligible_voting.saturating_sub(voting).max(0)
|| (voting > 0 && eligible_voting <= 1)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Transition {
Admit(NodeId),
Promote(NodeId, Role),
Demote(NodeId),
Reassign(NodeId, Role),
Evict(NodeId),
}
#[derive(Debug, thiserror::Error, Clone)]
pub enum TopologyError {
#[error("not enough eligible nodes: need {needed}, have {have}")]
InsufficientNodes { needed: usize, have: usize },
#[error("invariant violated: {0}")]
InvariantViolated(String),
#[error("strategy cannot satisfy desired shape with available nodes")]
UnsatisfiableShape,
}
pub trait TopologyStrategy: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn min_nodes(&self) -> usize;
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError>;
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition>;
fn validate(&self, assignment: &RoleAssignment) -> Result<(), TopologyError>;
}
#[derive(Debug, Default, Clone)]
pub struct Solo;
impl TopologyStrategy for Solo {
fn name(&self) -> &'static str {
"solo"
}
fn min_nodes(&self) -> usize {
1
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
if eligible.is_empty() {
return Err(TopologyError::InsufficientNodes {
needed: 1,
have: 0,
});
}
let mut a = RoleAssignment::new();
a.set(eligible[0].clone(), NodeState::Active(Role::Master));
for id in &eligible[1..] {
a.set(id.clone(), NodeState::Standby);
}
Ok(a)
}
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
let lost_set: HashSet<&NodeId> = lost.iter().collect();
let master_lost = current
.nodes_with_role(Role::Master)
.into_iter()
.any(|n| lost_set.contains(n));
let mut tx = Vec::new();
if master_lost {
let candidate = current
.assignments
.iter()
.find(|(id, state)| {
!lost_set.contains(id)
&& matches!(state, NodeState::Standby | NodeState::Active(Role::Worker))
})
.map(|(id, _)| id.clone());
if let Some(id) = candidate {
tx.push(Transition::Promote(id, Role::Master));
}
}
for id in lost {
tx.push(Transition::Evict(id.clone()));
}
tx
}
fn validate(&self, assignment: &RoleAssignment) -> Result<(), TopologyError> {
let masters = assignment.nodes_with_role(Role::Master).len();
if masters != 1 {
return Err(TopologyError::InvariantViolated(format!(
"Solo expects exactly 1 master; have {masters}"
)));
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct Pair;
impl TopologyStrategy for Pair {
fn name(&self) -> &'static str {
"pair"
}
fn min_nodes(&self) -> usize {
2
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
if eligible.len() < 2 {
return Err(TopologyError::InsufficientNodes {
needed: 2,
have: eligible.len(),
});
}
let mut a = RoleAssignment::new();
a.set(eligible[0].clone(), NodeState::Active(Role::Master));
a.set(eligible[1].clone(), NodeState::Active(Role::Master));
for id in &eligible[2..] {
a.set(id.clone(), NodeState::Active(Role::Worker));
}
Ok(a)
}
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
let lost_set: HashSet<&NodeId> = lost.iter().collect();
let mut tx = Vec::new();
let masters_lost = current
.nodes_with_role(Role::Master)
.into_iter()
.filter(|n| lost_set.contains(n))
.count();
if masters_lost == 0 {
return tx;
}
let workers: Vec<NodeId> = current
.nodes_with_role(Role::Worker)
.into_iter()
.filter(|n| !lost_set.contains(n))
.cloned()
.collect();
for w in workers.into_iter().take(masters_lost) {
tx.push(Transition::Reassign(w, Role::Master));
}
for id in lost {
tx.push(Transition::Evict(id.clone()));
}
tx
}
fn validate(&self, a: &RoleAssignment) -> Result<(), TopologyError> {
let m = a.nodes_with_role(Role::Master).len();
if m != 2 {
return Err(TopologyError::InvariantViolated(format!(
"Pair expects 2 masters; have {m}"
)));
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct Quorum3M;
impl TopologyStrategy for Quorum3M {
fn name(&self) -> &'static str {
"quorum_3m"
}
fn min_nodes(&self) -> usize {
3
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
if eligible.len() < 3 {
return Err(TopologyError::InsufficientNodes {
needed: 3,
have: eligible.len(),
});
}
let mut a = RoleAssignment::new();
for n in &eligible[..3] {
a.set(n.clone(), NodeState::Active(Role::Master));
}
for n in &eligible[3..] {
a.set(n.clone(), NodeState::Active(Role::Worker));
}
Ok(a)
}
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
let lost_set: HashSet<&NodeId> = lost.iter().collect();
let masters_lost: Vec<NodeId> = current
.nodes_with_role(Role::Master)
.into_iter()
.filter(|n| lost_set.contains(n))
.cloned()
.collect();
if masters_lost.is_empty() {
return Vec::new();
}
let need = masters_lost.len();
let mut prioritised: Vec<(u8, NodeId)> = current
.assignments
.iter()
.filter(|(id, state)| {
!lost_set.contains(id)
&& state.is_eligible()
&& !matches!(state, NodeState::Active(Role::Master))
})
.map(|(id, state)| {
let priority = match state {
NodeState::Active(Role::Worker) => 0u8,
NodeState::Standby => 1,
_ => 2,
};
(priority, id.clone())
})
.collect();
prioritised.sort_by_key(|(p, _)| *p);
let mut tx = Vec::new();
for (_, id) in prioritised.into_iter().take(need) {
tx.push(Transition::Promote(id, Role::Master));
}
for n in lost {
tx.push(Transition::Evict(n.clone()));
}
tx
}
fn validate(&self, a: &RoleAssignment) -> Result<(), TopologyError> {
let m = a.nodes_with_role(Role::Master).len();
if m != 3 {
return Err(TopologyError::InvariantViolated(format!(
"Quorum3M expects 3 masters; have {m}"
)));
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct Cluster3MNW;
impl TopologyStrategy for Cluster3MNW {
fn name(&self) -> &'static str {
"cluster_3m_nw"
}
fn min_nodes(&self) -> usize {
3
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
Quorum3M.assign(eligible)
}
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
Quorum3M.react_to_loss(current, lost)
}
fn validate(&self, a: &RoleAssignment) -> Result<(), TopologyError> {
let m = a.nodes_with_role(Role::Master).len();
if m != 3 {
return Err(TopologyError::InvariantViolated(format!(
"Cluster3MNW expects 3 masters; have {m}"
)));
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct MeshAllPeers;
impl TopologyStrategy for MeshAllPeers {
fn name(&self) -> &'static str {
"mesh_all_peers"
}
fn min_nodes(&self) -> usize {
1
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
if eligible.is_empty() {
return Err(TopologyError::InsufficientNodes {
needed: 1,
have: 0,
});
}
let mut a = RoleAssignment::new();
for n in eligible {
a.set(n.clone(), NodeState::Active(Role::Master));
}
Ok(a)
}
fn react_to_loss(
&self,
_current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
lost.iter().map(|n| Transition::Evict(n.clone())).collect()
}
fn validate(&self, _a: &RoleAssignment) -> Result<(), TopologyError> {
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct Phalanx;
impl Phalanx {
#[must_use]
pub fn target_masters(eligible: usize) -> usize {
if eligible == 0 {
return 0;
}
((eligible * 2) + 4) / 5
}
}
impl TopologyStrategy for Phalanx {
fn name(&self) -> &'static str {
"phalanx"
}
fn min_nodes(&self) -> usize {
1
}
fn assign(&self, eligible: &[NodeId]) -> Result<RoleAssignment, TopologyError> {
if eligible.is_empty() {
return Err(TopologyError::InsufficientNodes {
needed: 1,
have: 0,
});
}
let m = Self::target_masters(eligible.len()).max(1);
let mut a = RoleAssignment::new();
for n in &eligible[..m.min(eligible.len())] {
a.set(n.clone(), NodeState::Active(Role::Master));
}
for n in &eligible[m.min(eligible.len())..] {
a.set(n.clone(), NodeState::Active(Role::Worker));
}
Ok(a)
}
fn react_to_loss(
&self,
current: &RoleAssignment,
lost: &[NodeId],
) -> Vec<Transition> {
let lost_set: HashSet<&NodeId> = lost.iter().collect();
let surviving: Vec<NodeId> = current
.eligible()
.into_iter()
.filter(|n| !lost_set.contains(n))
.cloned()
.collect();
if surviving.is_empty() {
return Vec::new();
}
let target_m = Self::target_masters(surviving.len()).max(1);
let current_masters: HashSet<NodeId> = current
.nodes_with_role(Role::Master)
.into_iter()
.filter(|n| !lost_set.contains(n))
.cloned()
.collect();
let mut tx = Vec::new();
if current_masters.len() < target_m {
let need = target_m - current_masters.len();
let workers: Vec<NodeId> = current
.nodes_with_role(Role::Worker)
.into_iter()
.filter(|n| !lost_set.contains(n))
.cloned()
.collect();
for w in workers.into_iter().take(need) {
tx.push(Transition::Reassign(w, Role::Master));
}
} else if current_masters.len() > target_m {
let demote_count = current_masters.len() - target_m;
for m in current_masters.into_iter().take(demote_count) {
tx.push(Transition::Reassign(m, Role::Worker));
}
}
for n in lost {
tx.push(Transition::Evict(n.clone()));
}
tx
}
fn validate(&self, _a: &RoleAssignment) -> Result<(), TopologyError> {
Ok(())
}
}
pub struct TopologyReactor {
strategy: Box<dyn TopologyStrategy>,
current: std::sync::Mutex<RoleAssignment>,
}
impl std::fmt::Debug for TopologyReactor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TopologyReactor")
.field("strategy", &self.strategy.name())
.field("current", &self.current.lock().unwrap())
.finish()
}
}
impl TopologyReactor {
#[must_use]
pub fn new(strategy: Box<dyn TopologyStrategy>) -> Self {
Self {
strategy,
current: std::sync::Mutex::new(RoleAssignment::new()),
}
}
#[must_use]
pub fn with_initial(
strategy: Box<dyn TopologyStrategy>,
initial: RoleAssignment,
) -> Self {
Self {
strategy,
current: std::sync::Mutex::new(initial),
}
}
#[must_use]
pub fn strategy_name(&self) -> &'static str {
self.strategy.name()
}
#[must_use]
pub fn current(&self) -> RoleAssignment {
self.current.lock().unwrap().clone()
}
pub fn observe_membership(
&self,
eligible_now: &[NodeId],
failed_now: &[NodeId],
) -> Vec<Transition> {
let current = self.current.lock().unwrap().clone();
let mut tx = Vec::new();
for id in eligible_now {
if current.get(id).is_none() {
tx.push(Transition::Admit(id.clone()));
}
}
let no_active_masters = current.nodes_with_role(Role::Master).is_empty();
if no_active_masters && eligible_now.len() >= self.strategy.min_nodes() {
if let Ok(target) = self.strategy.assign(eligible_now) {
for (id, state) in &target.assignments {
if let Some(role) = state.role() {
if current.get(id).and_then(NodeState::role) != Some(role) {
tx.push(Transition::Promote(id.clone(), role));
}
}
}
}
}
if !failed_now.is_empty() {
let mut effective = current.clone();
for id in eligible_now {
if effective.get(id).is_none() {
effective.set(id.clone(), NodeState::Standby);
}
}
let strategy_tx = self.strategy.react_to_loss(&effective, failed_now);
let mut emitted_evicts: std::collections::HashSet<NodeId> =
std::collections::HashSet::new();
for t in &strategy_tx {
if let Transition::Evict(id) = t {
emitted_evicts.insert(id.clone());
}
}
tx.extend(strategy_tx);
for id in failed_now {
if !emitted_evicts.contains(id) {
tx.push(Transition::Evict(id.clone()));
}
}
}
tx
}
pub fn apply_transition(&self, t: &Transition) {
let mut current = self.current.lock().unwrap();
match t.clone() {
Transition::Admit(id) => current.set(id, NodeState::Standby),
Transition::Promote(id, r) => current.set(id, NodeState::Active(r)),
Transition::Demote(id) => current.set(id, NodeState::Demoting),
Transition::Reassign(id, r) => current.set(id, NodeState::Active(r)),
Transition::Evict(id) => current.set(id, NodeState::Failed),
}
}
pub fn apply_transitions(&self, transitions: &[Transition]) {
let mut current = self.current.lock().unwrap();
for t in transitions {
match t.clone() {
Transition::Admit(id) => current.set(id, NodeState::Standby),
Transition::Promote(id, r) => current.set(id, NodeState::Active(r)),
Transition::Demote(id) => current.set(id, NodeState::Demoting),
Transition::Reassign(id, r) => current.set(id, NodeState::Active(r)),
Transition::Evict(id) => current.set(id, NodeState::Failed),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: usize) -> Vec<NodeId> {
(0..n).map(|i| NodeId::new(format!("node-{i}"))).collect()
}
#[test]
fn role_is_voting() {
assert!(Role::Master.is_voting());
assert!(Role::Bootstrap.is_voting());
assert!(!Role::Worker.is_voting());
assert!(!Role::Observer.is_voting());
}
#[test]
fn node_state_eligibility() {
assert!(NodeState::Joining.is_eligible());
assert!(NodeState::Standby.is_eligible());
assert!(NodeState::Active(Role::Master).is_eligible());
assert!(NodeState::Demoting.is_eligible());
assert!(!NodeState::Departing.is_eligible());
assert!(!NodeState::Failed.is_eligible());
}
#[test]
fn solo_assigns_first_node_as_master() {
let s = Solo;
let a = s.assign(&ids(1)).unwrap();
assert_eq!(
a.get(&NodeId::new("node-0")),
Some(NodeState::Active(Role::Master))
);
s.validate(&a).unwrap();
}
#[test]
fn solo_requires_at_least_one_node() {
let s = Solo;
let err = s.assign(&[]).unwrap_err();
assert!(matches!(err, TopologyError::InsufficientNodes { .. }));
}
#[test]
fn pair_assigns_two_masters() {
let s = Pair;
let a = s.assign(&ids(2)).unwrap();
assert_eq!(a.nodes_with_role(Role::Master).len(), 2);
s.validate(&a).unwrap();
}
#[test]
fn pair_promotes_worker_on_master_loss() {
let s = Pair;
let mut current = s.assign(&ids(3)).unwrap();
assert_eq!(
current.get(&NodeId::new("node-2")),
Some(NodeState::Active(Role::Worker))
);
let lost = vec![NodeId::new("node-0")];
let tx = s.react_to_loss(¤t, &lost);
assert!(tx.contains(&Transition::Reassign(
NodeId::new("node-2"),
Role::Master
)));
assert!(tx.contains(&Transition::Evict(NodeId::new("node-0"))));
for t in tx {
match t {
Transition::Reassign(id, r) => current.set(id, NodeState::Active(r)),
Transition::Evict(id) => current.set(id, NodeState::Failed),
_ => {}
}
}
let voting = current.voting_count();
assert_eq!(voting, 2, "still 2 voting masters after loss");
}
#[test]
fn quorum_3m_creates_3_masters_n_workers() {
let s = Quorum3M;
let a = s.assign(&ids(7)).unwrap();
assert_eq!(a.nodes_with_role(Role::Master).len(), 3);
assert_eq!(a.nodes_with_role(Role::Worker).len(), 4);
s.validate(&a).unwrap();
}
#[test]
fn quorum_3m_promotes_worker_on_master_loss() {
let s = Quorum3M;
let mut current = s.assign(&ids(5)).unwrap();
let lost = vec![NodeId::new("node-1")];
let tx = s.react_to_loss(¤t, &lost);
let promotes: Vec<&Transition> = tx
.iter()
.filter(|t| matches!(t, Transition::Promote(_, Role::Master)))
.collect();
assert_eq!(promotes.len(), 1);
for t in tx {
match t {
Transition::Promote(id, r) | Transition::Reassign(id, r) => {
current.set(id, NodeState::Active(r))
}
Transition::Evict(id) => current.set(id, NodeState::Failed),
_ => {}
}
}
assert_eq!(current.voting_count(), 3);
}
#[test]
fn quorum_3m_with_workers_handles_double_master_loss() {
let s = Quorum3M;
let mut current = s.assign(&ids(7)).unwrap();
let lost = vec![NodeId::new("node-0"), NodeId::new("node-1")];
let tx = s.react_to_loss(¤t, &lost);
let promotes: Vec<&Transition> = tx
.iter()
.filter(|t| matches!(t, Transition::Promote(_, Role::Master)))
.collect();
assert_eq!(promotes.len(), 2);
for t in tx {
match t {
Transition::Promote(id, r) | Transition::Reassign(id, r) => {
current.set(id, NodeState::Active(r))
}
Transition::Evict(id) => current.set(id, NodeState::Failed),
_ => {}
}
}
assert_eq!(current.voting_count(), 3);
}
#[test]
fn mesh_assigns_every_node_as_master() {
let s = MeshAllPeers;
let a = s.assign(&ids(5)).unwrap();
assert_eq!(a.nodes_with_role(Role::Master).len(), 5);
assert_eq!(a.nodes_with_role(Role::Worker).len(), 0);
}
#[test]
fn phalanx_target_masters_scales() {
assert_eq!(Phalanx::target_masters(1), 1);
assert_eq!(Phalanx::target_masters(5), 2);
assert_eq!(Phalanx::target_masters(10), 4);
assert_eq!(Phalanx::target_masters(25), 10);
assert_eq!(Phalanx::target_masters(0), 0);
}
#[test]
fn phalanx_assign_matches_target() {
let s = Phalanx;
let a = s.assign(&ids(10)).unwrap();
assert_eq!(a.nodes_with_role(Role::Master).len(), 4);
assert_eq!(a.nodes_with_role(Role::Worker).len(), 6);
}
#[test]
fn phalanx_reacts_to_loss_with_correct_target() {
let s = Phalanx;
let mut current = s.assign(&ids(10)).unwrap();
let lost: Vec<NodeId> = (0..5).map(|i| NodeId::new(format!("node-{i}"))).collect();
let tx = s.react_to_loss(¤t, &lost);
for t in &tx {
match t.clone() {
Transition::Reassign(id, r) => current.set(id, NodeState::Active(r)),
Transition::Evict(id) => current.set(id, NodeState::Failed),
_ => {}
}
}
let surviving_eligible = current.eligible().len();
assert_eq!(surviving_eligible, 5);
let masters_now = current.nodes_with_role(Role::Master).len();
assert_eq!(
masters_now,
Phalanx::target_masters(5),
"phalanx should rebalance to 2 masters for 5-node cluster"
);
}
#[test]
fn assignment_serde_round_trip() {
let mut a = RoleAssignment::new();
a.set(NodeId::new("n1"), NodeState::Active(Role::Master));
a.set(NodeId::new("n2"), NodeState::Active(Role::Worker));
let json = serde_json::to_string(&a).unwrap();
let back: RoleAssignment = serde_json::from_str(&json).unwrap();
assert_eq!(back, a);
}
#[test]
fn transition_serde_round_trip() {
let cases = vec![
Transition::Admit(NodeId::new("a")),
Transition::Promote(NodeId::new("b"), Role::Master),
Transition::Demote(NodeId::new("c")),
Transition::Reassign(NodeId::new("d"), Role::Worker),
Transition::Evict(NodeId::new("e")),
];
for t in cases {
let json = serde_json::to_string(&t).unwrap();
let back: Transition = serde_json::from_str(&json).unwrap();
assert_eq!(back, t);
}
}
#[test]
fn strategies_produce_at_least_one_voter() {
for s in strategies() {
for n in 1..=12 {
if n < s.min_nodes() {
continue;
}
let a = s.assign(&ids(n)).expect("assignable");
assert!(
a.voting_count() >= 1,
"strategy {} produced 0 voters for {n} nodes",
s.name()
);
}
}
}
fn strategies() -> Vec<Box<dyn TopologyStrategy>> {
vec![
Box::new(Solo),
Box::new(Pair),
Box::new(Quorum3M),
Box::new(Cluster3MNW),
Box::new(MeshAllPeers),
Box::new(Phalanx),
]
}
}