use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReplicaStatus {
Joining,
Active,
Suspected,
Removed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Replica {
pub replica_id: String,
pub node_id: String,
pub status: ReplicaStatus,
pub joined_at_ms: u64,
pub last_seen_at_ms: Option<u64>,
pub required_for_reclamation: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReplicationCluster {
pub cluster_id: String,
pub created_at_ms: u64,
pub replicas: BTreeMap<String, Replica>,
}
#[derive(Debug)]
pub enum MembershipError {
NoCluster,
ClusterExists(String),
UnknownReplica(String),
ReplicaExists(String),
NotJoining(String),
Io(String),
}
impl std::fmt::Display for MembershipError {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCluster => write!(out, "no cluster has been created"),
Self::ClusterExists(id) => write!(out, "cluster {id} already exists"),
Self::UnknownReplica(id) => write!(out, "unknown replica {id}"),
Self::ReplicaExists(id) => write!(out, "replica {id} already exists"),
Self::NotJoining(id) => write!(out, "replica {id} is not JOINING"),
Self::Io(message) => write!(out, "membership store: {message}"),
}
}
}
pub struct MembershipStore {
path: PathBuf,
cluster: Option<ReplicationCluster>,
}
impl MembershipStore {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, MembershipError> {
let path = path.as_ref().to_path_buf();
let cluster = if path.exists() {
let bytes = std::fs::read(&path).map_err(|e| MembershipError::Io(e.to_string()))?;
Some(serde_json::from_slice(&bytes).map_err(|e| MembershipError::Io(e.to_string()))?)
} else {
None
};
Ok(Self { path, cluster })
}
fn persist(&self) -> Result<(), MembershipError> {
let Some(cluster) = &self.cluster else {
return Ok(());
};
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| MembershipError::Io(e.to_string()))?;
}
let temp = self.path.with_extension("tmp");
let bytes =
serde_json::to_vec_pretty(cluster).map_err(|e| MembershipError::Io(e.to_string()))?;
{
use std::io::Write;
let mut file =
std::fs::File::create(&temp).map_err(|e| MembershipError::Io(e.to_string()))?;
file.write_all(&bytes)
.map_err(|e| MembershipError::Io(e.to_string()))?;
file.sync_all()
.map_err(|e| MembershipError::Io(e.to_string()))?;
}
std::fs::rename(&temp, &self.path).map_err(|e| MembershipError::Io(e.to_string()))?;
if let Some(parent) = self.path.parent() {
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
Ok(())
}
pub fn cluster(&self) -> Option<&ReplicationCluster> {
self.cluster.as_ref()
}
pub fn create_cluster(&mut self, cluster_id: &str) -> Result<(), MembershipError> {
if let Some(existing) = &self.cluster {
return Err(MembershipError::ClusterExists(existing.cluster_id.clone()));
}
self.cluster = Some(ReplicationCluster {
cluster_id: cluster_id.to_string(),
created_at_ms: now_ms(),
replicas: BTreeMap::new(),
});
self.persist()
}
fn cluster_mut(&mut self) -> Result<&mut ReplicationCluster, MembershipError> {
self.cluster.as_mut().ok_or(MembershipError::NoCluster)
}
pub fn join_replica(&mut self, replica_id: &str, node_id: &str) -> Result<(), MembershipError> {
let cluster = self.cluster_mut()?;
if cluster.replicas.contains_key(replica_id) {
return Err(MembershipError::ReplicaExists(replica_id.to_string()));
}
cluster.replicas.insert(
replica_id.to_string(),
Replica {
replica_id: replica_id.to_string(),
node_id: node_id.to_string(),
status: ReplicaStatus::Joining,
joined_at_ms: now_ms(),
last_seen_at_ms: None,
required_for_reclamation: false,
},
);
self.persist()
}
pub fn approve_replica(&mut self, replica_id: &str) -> Result<(), MembershipError> {
let cluster = self.cluster_mut()?;
let replica = cluster
.replicas
.get_mut(replica_id)
.ok_or_else(|| MembershipError::UnknownReplica(replica_id.to_string()))?;
if replica.status != ReplicaStatus::Joining {
return Err(MembershipError::NotJoining(replica_id.to_string()));
}
replica.status = ReplicaStatus::Active;
replica.required_for_reclamation = true;
self.persist()
}
pub fn suspect_replica(&mut self, replica_id: &str) -> Result<(), MembershipError> {
let cluster = self.cluster_mut()?;
let replica = cluster
.replicas
.get_mut(replica_id)
.ok_or_else(|| MembershipError::UnknownReplica(replica_id.to_string()))?;
if replica.status == ReplicaStatus::Removed {
return Ok(());
}
replica.status = ReplicaStatus::Suspected;
self.persist()
}
pub fn mark_seen(&mut self, replica_id: &str) -> Result<(), MembershipError> {
let cluster = self.cluster_mut()?;
let replica = cluster
.replicas
.get_mut(replica_id)
.ok_or_else(|| MembershipError::UnknownReplica(replica_id.to_string()))?;
replica.last_seen_at_ms = Some(now_ms());
if replica.status == ReplicaStatus::Suspected {
replica.status = ReplicaStatus::Active;
}
self.persist()
}
pub fn remove_replica(&mut self, replica_id: &str) -> Result<(), MembershipError> {
let cluster = self.cluster_mut()?;
let replica = cluster
.replicas
.get_mut(replica_id)
.ok_or_else(|| MembershipError::UnknownReplica(replica_id.to_string()))?;
replica.status = ReplicaStatus::Removed;
replica.required_for_reclamation = false;
self.persist()
}
pub fn get_members(&self) -> Vec<Replica> {
self.cluster
.as_ref()
.map(|cluster| {
cluster
.replicas
.values()
.filter(|replica| replica.status != ReplicaStatus::Removed)
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn all_replicas(&self) -> Vec<Replica> {
self.cluster
.as_ref()
.map(|cluster| cluster.replicas.values().cloned().collect())
.unwrap_or_default()
}
pub fn get_required_replicas(&self) -> Vec<Replica> {
self.cluster
.as_ref()
.map(|cluster| {
cluster
.replicas
.values()
.filter(|replica| {
replica.required_for_reclamation && replica.status != ReplicaStatus::Removed
})
.cloned()
.collect()
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store(dir: &tempfile::TempDir) -> MembershipStore {
MembershipStore::open(dir.path().join("membership.json")).expect("open")
}
fn ids(replicas: &[Replica]) -> Vec<String> {
let mut out: Vec<String> = replicas.iter().map(|r| r.replica_id.clone()).collect();
out.sort();
out
}
#[test]
fn membership_survives_restart_and_only_removal_changes_the_reclamation_set() {
let dir = tempfile::tempdir().unwrap();
{
let mut cluster = store(&dir);
cluster.create_cluster("c1").expect("create");
for (replica, node) in [("ra", "A"), ("rb", "B"), ("rc", "C")] {
cluster.join_replica(replica, node).expect("join");
cluster.approve_replica(replica).expect("approve");
}
assert_eq!(ids(&cluster.get_required_replicas()), ["ra", "rb", "rc"]);
}
let mut restarted = store(&dir);
assert_eq!(
ids(&restarted.get_members()),
["ra", "rb", "rc"],
"membership is durable, not reconstructed from how the process was launched"
);
assert_eq!(restarted.cluster().expect("cluster").cluster_id, "c1");
restarted.suspect_replica("rc").expect("suspect");
let suspected = restarted
.get_members()
.into_iter()
.find(|r| r.replica_id == "rc")
.expect("rc");
assert_eq!(suspected.status, ReplicaStatus::Suspected);
assert!(suspected.required_for_reclamation);
assert_eq!(
ids(&restarted.get_required_replicas()),
["ra", "rb", "rc"],
"an offline replica is still owed its history: being unreachable is not consent"
);
restarted.remove_replica("rc").expect("remove");
assert_eq!(
ids(&restarted.get_required_replicas()),
["ra", "rb"],
"removal is the only thing that takes a replica out of the reclamation set"
);
assert_eq!(
ids(&restarted.get_members()),
["ra", "rb"],
"and out of the membership"
);
let after_removal = store(&dir);
assert_eq!(ids(&after_removal.get_required_replicas()), ["ra", "rb"]);
assert_eq!(
after_removal.all_replicas().len(),
3,
"the removed replica is retained for audit, not erased"
);
}
#[test]
fn membership_a_stale_last_seen_never_changes_what_is_required() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
cluster.join_replica("rc", "C").unwrap();
cluster.approve_replica("rc").unwrap();
cluster.suspect_replica("rc").unwrap();
let ancient = cluster.all_replicas().into_iter().next().unwrap();
assert_eq!(ancient.last_seen_at_ms, None, "never seen at all");
assert!(
ancient.required_for_reclamation,
"a replica that has never been seen is still required: silence is not removal"
);
assert_eq!(ids(&cluster.get_required_replicas()), ["rc"]);
}
#[test]
fn membership_suspicion_is_reversible_and_removal_is_not() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
cluster.join_replica("rc", "C").unwrap();
cluster.approve_replica("rc").unwrap();
cluster.suspect_replica("rc").unwrap();
cluster.mark_seen("rc").unwrap();
let seen = cluster.get_members().into_iter().next().unwrap();
assert_eq!(
seen.status,
ReplicaStatus::Active,
"contact reinstates a suspected replica"
);
assert!(seen.last_seen_at_ms.is_some());
cluster.remove_replica("rc").unwrap();
cluster.mark_seen("rc").unwrap();
let after = cluster.all_replicas().into_iter().next().unwrap();
assert_eq!(
after.status,
ReplicaStatus::Removed,
"contact does NOT resurrect a removed replica: removal is a decision, not an observation"
);
assert!(!after.required_for_reclamation);
assert!(cluster.get_required_replicas().is_empty());
}
#[test]
fn membership_a_joining_replica_is_a_member_but_not_yet_required() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
cluster.join_replica("rd", "D").unwrap();
assert_eq!(ids(&cluster.get_members()), ["rd"]);
assert!(cluster.get_required_replicas().is_empty());
cluster.approve_replica("rd").unwrap();
assert_eq!(ids(&cluster.get_required_replicas()), ["rd"]);
}
#[test]
fn membership_a_removed_replica_rejoins_by_joining_not_by_appearing() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
cluster.join_replica("rc", "C").unwrap();
cluster.approve_replica("rc").unwrap();
cluster.remove_replica("rc").unwrap();
assert!(matches!(
cluster.join_replica("rc", "C"),
Err(MembershipError::ReplicaExists(_))
));
assert!(cluster.get_required_replicas().is_empty());
}
#[test]
fn membership_is_empty_until_someone_creates_it() {
let dir = tempfile::tempdir().unwrap();
let cluster = store(&dir);
assert!(cluster.cluster().is_none());
assert!(cluster.get_members().is_empty());
assert!(cluster.get_required_replicas().is_empty());
}
#[test]
fn membership_operations_need_a_cluster() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
assert!(matches!(
cluster.join_replica("ra", "A"),
Err(MembershipError::NoCluster)
));
cluster.create_cluster("c1").unwrap();
assert!(matches!(
cluster.create_cluster("c2"),
Err(MembershipError::ClusterExists(_))
));
}
#[test]
fn membership_approval_requires_joining_first() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
assert!(matches!(
cluster.approve_replica("ghost"),
Err(MembershipError::UnknownReplica(_))
));
cluster.join_replica("ra", "A").unwrap();
cluster.approve_replica("ra").unwrap();
assert!(
matches!(
cluster.approve_replica("ra"),
Err(MembershipError::NotJoining(_))
),
"approving twice is not a silent no-op"
);
}
#[test]
fn membership_is_not_the_authorization_membership_of_the_same_name() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
cluster.join_replica("ra", "A").unwrap();
cluster.approve_replica("ra").unwrap();
let mut application_state = crate::state_hash::CanonicalState::new();
application_state.set_record(
"memberships".to_string(),
"user-1:org-1".to_string(),
serde_json::json!({ "user_id": "user-1", "organization_id": "org-1", "role": "admin" }),
);
assert_eq!(application_state.records.len(), 1);
assert_eq!(
ids(&cluster.get_required_replicas()),
["ra"],
"the reclamation set is what was joined as a replica, and nothing else"
);
let reopened = store(&dir);
assert_eq!(ids(&reopened.get_required_replicas()), ["ra"]);
assert!(
reopened
.all_replicas()
.iter()
.all(|replica| replica.node_id == "A"),
"no authorization principal became a replica"
);
}
#[test]
fn membership_every_transition_is_durable_immediately() {
let dir = tempfile::tempdir().unwrap();
let mut cluster = store(&dir);
cluster.create_cluster("c1").unwrap();
assert_eq!(store(&dir).cluster().expect("cluster").cluster_id, "c1");
cluster.join_replica("ra", "A").unwrap();
assert_eq!(ids(&store(&dir).get_members()), ["ra"]);
cluster.approve_replica("ra").unwrap();
assert_eq!(ids(&store(&dir).get_required_replicas()), ["ra"]);
cluster.suspect_replica("ra").unwrap();
assert_eq!(
ids(&store(&dir).get_required_replicas()),
["ra"],
"suspicion is durable and still does not change what is required"
);
cluster.remove_replica("ra").unwrap();
assert!(store(&dir).get_required_replicas().is_empty());
}
#[test]
fn membership_concurrent_transitions_do_not_lose_updates() {
use std::sync::{Arc, Mutex};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("membership.json");
let shared = Arc::new(Mutex::new(MembershipStore::open(&path).unwrap()));
shared.lock().unwrap().create_cluster("c1").unwrap();
let mut threads = Vec::new();
for index in 0..8 {
let shared = Arc::clone(&shared);
threads.push(std::thread::spawn(move || {
let replica = format!("r{index}");
let mut guard = shared.lock().unwrap();
guard.join_replica(&replica, &format!("N{index}")).unwrap();
guard.approve_replica(&replica).unwrap();
}));
}
for thread in threads {
thread.join().unwrap();
}
assert_eq!(shared.lock().unwrap().get_required_replicas().len(), 8);
let reloaded = MembershipStore::open(&path).unwrap();
assert_eq!(
reloaded.get_required_replicas().len(),
8,
"the durable record holds every transition, not the last writer's view"
);
}
#[test]
fn membership_a_torn_write_cannot_be_observed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("membership.json");
let mut cluster = MembershipStore::open(&path).unwrap();
cluster.create_cluster("c1").unwrap();
for index in 0..20 {
cluster
.join_replica(&format!("r{index}"), &format!("N{index}"))
.unwrap();
let observed = MembershipStore::open(&path).unwrap();
assert_eq!(
observed.get_members().len(),
index + 1,
"every observation is of a complete cluster"
);
}
assert!(
!path.with_extension("tmp").exists(),
"no temp file is left at rest"
);
}
}