use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::app::election::{LeaderElection, OldestNode};
use crate::app::node_info::ClusterView;
use crate::cluster::config::NodeClass;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SingletonAnchor {
Leader,
Class(NodeClass),
Node(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SingletonGeneration {
pub term: u64,
pub seq: u64,
}
impl SingletonGeneration {
pub fn packed(self) -> u64 {
(self.term << 32) | (self.seq & 0xFFFF_FFFF)
}
pub fn from_packed(packed: u64) -> Self {
Self {
term: packed >> 32,
seq: packed & 0xFFFF_FFFF,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SingletonSpec {
pub label: String,
pub actor_type_name: String,
pub initial_state: Vec<u8>,
pub anchor: SingletonAnchor,
pub drain_timeout: Option<Duration>,
}
impl SingletonSpec {
pub fn new(
label: impl Into<String>,
actor_type_name: impl Into<String>,
anchor: SingletonAnchor,
) -> Self {
Self {
label: label.into(),
actor_type_name: actor_type_name.into(),
initial_state: Vec::new(),
anchor,
drain_timeout: None,
}
}
pub fn with_state(mut self, state: Vec<u8>) -> Self {
self.initial_state = state;
self
}
pub fn with_drain_timeout(mut self, timeout: Duration) -> Self {
self.drain_timeout = Some(timeout);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SingletonPhase {
Active,
Draining,
Starting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingletonOwnership {
pub label: String,
pub owner_node_id: Option<String>,
pub generation: SingletonGeneration,
pub phase: SingletonPhase,
}
pub fn resolve_owner(
anchor: &SingletonAnchor,
view: &ClusterView,
election: &dyn LeaderElection,
) -> Option<String> {
match anchor {
SingletonAnchor::Leader => election.elect(view),
SingletonAnchor::Class(class) => OldestNode::with_class(class.clone()).elect(view),
SingletonAnchor::Node(node_id) => view
.alive_nodes()
.find(|n| &n.node_id() == node_id)
.map(|n| n.node_id()),
}
}
#[async_trait::async_trait]
pub trait GenerationSource: Send + Sync {
async fn claim_term(
&self,
label: &str,
owner_node_id: &str,
) -> Result<SingletonGeneration, String>;
async fn claim_seq(&self, label: &str) -> Result<SingletonGeneration, String>;
async fn current(&self, label: &str) -> Result<Option<SingletonOwnership>, String>;
}
#[derive(Debug, Default)]
pub struct CoordinatorGenerationSource {
state: Mutex<HashMap<String, SingletonOwnership>>,
}
impl CoordinatorGenerationSource {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait::async_trait]
impl GenerationSource for CoordinatorGenerationSource {
async fn claim_term(
&self,
label: &str,
owner_node_id: &str,
) -> Result<SingletonGeneration, String> {
let mut state = self.state.lock().expect("generation source mutex poisoned");
let term = state.get(label).map(|o| o.generation.term + 1).unwrap_or(1);
let generation = SingletonGeneration { term, seq: 0 };
state.insert(
label.to_string(),
SingletonOwnership {
label: label.to_string(),
owner_node_id: Some(owner_node_id.to_string()),
generation,
phase: SingletonPhase::Active,
},
);
Ok(generation)
}
async fn claim_seq(&self, label: &str) -> Result<SingletonGeneration, String> {
let mut state = self.state.lock().expect("generation source mutex poisoned");
match state.get_mut(label) {
Some(ownership) => {
ownership.generation.seq += 1;
Ok(ownership.generation)
}
None => Err(format!(
"claim_seq before claim_term for singleton '{label}'"
)),
}
}
async fn current(&self, label: &str) -> Result<Option<SingletonOwnership>, String> {
Ok(self
.state
.lock()
.expect("generation source mutex poisoned")
.get(label)
.cloned())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::node_info::NodeInfo;
use crate::cluster::config::NodeIdentity;
fn make_node(name: &str, incarnation: u64, class: NodeClass) -> NodeInfo {
NodeInfo::new(
NodeIdentity {
name: name.into(),
host: "127.0.0.1".into(),
port: 7100,
incarnation,
},
class,
HashMap::new(),
)
}
fn view_of(nodes: Vec<NodeInfo>) -> ClusterView {
let mut view = ClusterView::new();
for n in nodes {
view.upsert_node(n);
}
view
}
#[test]
fn test_packed_preserves_lexicographic_order_across_u32_boundary() {
let lo = SingletonGeneration {
term: 0,
seq: u32::MAX as u64,
};
let hi = SingletonGeneration { term: 1, seq: 0 };
assert!(hi > lo);
assert!(hi.packed() > lo.packed());
assert_eq!(hi.packed(), 1u64 << 32);
assert_eq!(lo.packed(), u32::MAX as u64);
}
#[test]
fn test_packed_roundtrip() {
let g = SingletonGeneration { term: 7, seq: 42 };
assert_eq!(SingletonGeneration::from_packed(g.packed()), g);
}
#[test]
fn test_ord_is_term_major() {
let a = SingletonGeneration { term: 2, seq: 0 };
let b = SingletonGeneration {
term: 2,
seq: 1_000_000,
};
let c = SingletonGeneration { term: 3, seq: 0 };
assert!(a < b); assert!(b < c); assert!(a < c);
}
#[test]
fn test_resolve_owner_leader_matches_election() {
let view = view_of(vec![
make_node("gamma", 300, NodeClass::Worker),
make_node("alpha", 100, NodeClass::Worker),
make_node("beta", 200, NodeClass::Worker),
]);
let election = OldestNode::any();
let owner = resolve_owner(&SingletonAnchor::Leader, &view, &election).unwrap();
assert_eq!(owner, election.elect(&view).unwrap());
assert!(owner.contains("alpha"), "oldest should win, got {owner}");
}
#[test]
fn test_resolve_owner_class_picks_oldest_of_class() {
let view = view_of(vec![
make_node("alpha", 100, NodeClass::Worker),
make_node("beta", 200, NodeClass::Coordinator),
make_node("delta", 300, NodeClass::Coordinator),
]);
let election = OldestNode::any();
let owner = resolve_owner(
&SingletonAnchor::Class(NodeClass::Coordinator),
&view,
&election,
)
.unwrap();
assert!(
owner.contains("beta"),
"oldest Coordinator should win, got {owner}"
);
}
#[test]
fn test_resolve_owner_node_pins_exactly_and_requires_alive() {
let target = make_node("beta", 200, NodeClass::Worker);
let target_id = target.node_id();
let view = view_of(vec![make_node("alpha", 100, NodeClass::Worker), target]);
let election = OldestNode::any();
let owner =
resolve_owner(&SingletonAnchor::Node(target_id.clone()), &view, &election).unwrap();
assert_eq!(owner, target_id);
assert!(
resolve_owner(
&SingletonAnchor::Node("ghost@127.0.0.1:9999#0".into()),
&view,
&election,
)
.is_none()
);
}
#[test]
fn test_resolve_owner_node_pin_rejects_dead_node() {
let target = make_node("beta", 200, NodeClass::Worker);
let target_id = target.node_id();
let mut view = view_of(vec![make_node("alpha", 100, NodeClass::Worker), target]);
view.mark_failed(&target_id);
let election = OldestNode::any();
assert!(resolve_owner(&SingletonAnchor::Node(target_id), &view, &election).is_none());
}
#[tokio::test]
async fn test_ram_source_claim_term_increments_and_resets_seq() {
let src = CoordinatorGenerationSource::new();
let g1 = src.claim_term("catalog", "node-a").await.unwrap();
assert_eq!(g1, SingletonGeneration { term: 1, seq: 0 });
let g1b = src.claim_seq("catalog").await.unwrap();
assert_eq!(g1b, SingletonGeneration { term: 1, seq: 1 });
let g2 = src.claim_term("catalog", "node-b").await.unwrap();
assert_eq!(g2, SingletonGeneration { term: 2, seq: 0 });
assert!(g2 > g1b);
let cur = src.current("catalog").await.unwrap().unwrap();
assert_eq!(cur.owner_node_id.as_deref(), Some("node-b"));
assert_eq!(cur.generation, g2);
assert_eq!(cur.phase, SingletonPhase::Active);
}
#[tokio::test]
async fn test_ram_source_claim_seq_before_term_errors() {
let src = CoordinatorGenerationSource::new();
assert!(src.claim_seq("never-claimed").await.is_err());
}
#[tokio::test]
async fn test_ram_source_current_unknown_is_none() {
let src = CoordinatorGenerationSource::new();
assert!(src.current("nope").await.unwrap().is_none());
}
}