use std::error::Error;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use haematite::db::{DatabaseError, respond_to_inbound_writes};
use haematite::sync::ballot::Ballot;
use haematite::sync::membership::WriteMembership;
use haematite::sync::{ConsistencyError, DistributionEndpoint, ProposeWrite, SyncNodeId};
use haematite::{Database, DatabaseConfig};
type TestResult = Result<(), Box<dyn Error>>;
const NODE_A: &str = "node-a@127.0.0.1";
const NODE_B: &str = "node-b@127.0.0.1";
const NODE_C: &str = "node-c@127.0.0.1";
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const QUORUM_TIMEOUT: Duration = Duration::from_secs(5);
const FENCE_TIMEOUT: Duration = Duration::from_millis(400);
fn loopback() -> Result<SocketAddr, Box<dyn Error>> {
Ok("127.0.0.1:0".parse()?)
}
fn config_for(path: &Path) -> DatabaseConfig {
DatabaseConfig {
data_dir: path.to_path_buf(),
shard_count: 1,
distributed: None,
}
}
fn wait_until(timeout: Duration, mut predicate: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
loop {
if predicate() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
struct Node {
name: &'static str,
addr: SocketAddr,
db: Arc<Database>,
responder: Option<JoinHandle<()>>,
running: Arc<std::sync::atomic::AtomicBool>,
}
impl Node {
fn spawn(name: &'static str, dir: &Path) -> Result<Self, Box<dyn Error>> {
let endpoint = DistributionEndpoint::bind(name, loopback()?, 1, None)?;
let addr = endpoint.local_addr();
let db = Arc::new(
Database::create(config_for(dir.join("db").as_path()))?.with_distribution(endpoint),
);
let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
let responder_db = Arc::clone(&db);
let responder_running = Arc::clone(&running);
let responder = std::thread::spawn(move || {
while responder_running.load(std::sync::atomic::Ordering::Relaxed) {
drop(respond_to_inbound_writes(
&responder_db,
Duration::from_millis(100),
));
}
});
Ok(Self {
name,
addr,
db,
responder: Some(responder),
running,
})
}
}
impl Drop for Node {
fn drop(&mut self) {
self.running
.store(false, std::sync::atomic::Ordering::Relaxed);
if let Some(handle) = self.responder.take() {
drop(handle.join());
}
}
}
fn link(from: &Node, to: &Node) -> TestResult {
let endpoint = from
.db
.distribution()
.ok_or("dialing node has no endpoint")?;
endpoint.add_peer(to.name, to.addr);
endpoint.connect(to.name)?;
if !wait_until(HANDSHAKE_TIMEOUT, || endpoint.is_connected(to.name)) {
return Err(format!("{} never registered a link to {}", from.name, to.name).into());
}
Ok(())
}
fn link_both(a: &Node, b: &Node) -> TestResult {
link(a, b)?;
link(b, a)?;
Ok(())
}
fn membership(total_nodes: usize, send_targets: &[&str]) -> WriteMembership {
WriteMembership {
total_nodes,
send_targets: send_targets.iter().map(|n| SyncNodeId::from(*n)).collect(),
}
}
fn assert_post_heal_c_fenced(node_c: &Node, key: &[u8], value_c: &[u8]) -> TestResult {
let endpoint = node_c.db.distribution().ok_or("C has no endpoint")?;
let structured = endpoint.propose_write(
ProposeWrite {
key: key.to_vec(),
expected: None,
value: value_c.to_vec(),
ttl: None,
},
0, Ballot::bottom(),
&membership(3, &[NODE_A, NODE_B]),
QUORUM_TIMEOUT,
);
match structured {
Err(ConsistencyError::CasConflict {
required,
possible_accepts,
}) => {
assert_eq!(required, 2, "quorum over 3 is 2");
assert!(
possible_accepts < 2,
"CAS-mismatch rejects from A and B must drop possible accepts below quorum, got {possible_accepts}"
);
}
other => {
return Err(format!(
"post-heal C must be CAS-REJECTED -> CasConflict (NOT timeout, NOT commit, NOT epoch Fenced), got {other:?}"
)
.into());
}
}
let production = node_c.db.replicate_write(
key.to_vec(),
None,
value_c.to_vec(),
None,
&membership(3, &[NODE_A, NODE_B]),
QUORUM_TIMEOUT,
);
match production {
Err(DatabaseError::CasConflict {
required,
possible_accepts,
}) => assert!(
possible_accepts < required,
"production replicate_write must report C out-voted by CAS-mismatch rejects (a quorum of \
accepts no longer reachable), got required={required} possible_accepts={possible_accepts}"
),
other => {
return Err(format!(
"post-heal C replicate_write must fail with the typed CasConflict, got {other:?}"
)
.into());
}
}
Ok(())
}
#[test]
fn majority_commits_via_real_transport() -> TestResult {
let dir_a = tempfile::tempdir()?;
let dir_b = tempfile::tempdir()?;
let dir_c = tempfile::tempdir()?;
let node_a = Node::spawn(NODE_A, dir_a.path())?;
let node_b = Node::spawn(NODE_B, dir_b.path())?;
let node_c = Node::spawn(NODE_C, dir_c.path())?;
link_both(&node_a, &node_b)?;
link_both(&node_a, &node_c)?;
let key = b"majority-key".to_vec();
let value = b"majority-value".to_vec();
let outcome = node_a.db.replicate_write(
key.clone(),
None,
value.clone(),
None,
&membership(3, &[NODE_B, NODE_C]),
QUORUM_TIMEOUT,
)?;
assert_eq!(outcome.required, 2, "quorum over 3 nodes is 2");
assert!(
outcome.reached(),
"majority must reach quorum via real acks: {outcome:?}"
);
assert!(
outcome.acknowledged >= 2,
"local ack + >=1 real peer Applied ack: {outcome:?}"
);
assert_eq!(
node_a.db.get(&key)?,
Some(value.clone()),
"proposer A must durably hold its own committed value"
);
let stored_on_peer = wait_until(QUORUM_TIMEOUT, || {
matches!(node_b.db.get(&key), Ok(Some(ref v)) if v == &value)
|| matches!(node_c.db.get(&key), Ok(Some(ref v)) if v == &value)
});
assert!(
stored_on_peer,
"at least one peer (B or C) must durably hold the applied value"
);
Ok(())
}
#[test]
fn minority_is_fenced_without_reachable_peers() -> TestResult {
let dir_a = tempfile::tempdir()?;
let dir_b = tempfile::tempdir()?;
let dir_c = tempfile::tempdir()?;
let node_a = Node::spawn(NODE_A, dir_a.path())?;
let node_b = Node::spawn(NODE_B, dir_b.path())?;
let node_c = Node::spawn(NODE_C, dir_c.path())?;
link_both(&node_a, &node_b)?;
let key = b"minority-key".to_vec();
let value = b"from-isolated-C".to_vec();
let result = node_c.db.replicate_write(
key.clone(),
None,
value.clone(),
None,
&membership(3, &[]),
FENCE_TIMEOUT,
);
assert!(
matches!(result, Err(DatabaseError::ConsistencyError(_))),
"minority replicate_write must fail with a consistency error, got {result:?}"
);
let direct = node_c
.db
.distribution()
.ok_or("C has no endpoint")?
.propose_write(
ProposeWrite {
key: key.clone(),
expected: None,
value,
ttl: None,
},
0, Ballot::bottom(),
&membership(3, &[]),
FENCE_TIMEOUT,
);
match direct {
Err(ConsistencyError::QuorumTimeout {
required,
acknowledged,
..
}) => {
assert_eq!(required, 2, "quorum over 3 is 2");
assert_eq!(
acknowledged, 1,
"only C's own local ack — cannot self-quorum"
);
}
other => {
return Err(format!("minority must be fenced via QuorumTimeout, got {other:?}").into());
}
}
assert_eq!(
node_c.db.get(&key)?,
None,
"fenced minority write must not be durably present on C"
);
Ok(())
}
#[test]
fn heal_mid_write_exactly_one_side_acquires() -> TestResult {
let dir_a = tempfile::tempdir()?;
let dir_b = tempfile::tempdir()?;
let dir_c = tempfile::tempdir()?;
let node_a = Node::spawn(NODE_A, dir_a.path())?;
let node_b = Node::spawn(NODE_B, dir_b.path())?;
let node_c = Node::spawn(NODE_C, dir_c.path())?;
let key = b"contended-k".to_vec();
let value_ab = b"from-AB".to_vec();
let value_c = b"from-C".to_vec();
link_both(&node_a, &node_b)?;
let ab_outcome = node_a.db.replicate_write(
key.clone(),
None,
value_ab.clone(),
None,
&membership(3, &[NODE_B]),
QUORUM_TIMEOUT,
)?;
assert!(
ab_outcome.reached(),
"majority {{A,B}} must commit the create: {ab_outcome:?}"
);
assert_eq!(ab_outcome.required, 2);
assert!(
ab_outcome.acknowledged >= 2,
"A local + B Applied: {ab_outcome:?}"
);
assert_eq!(
node_a.db.get(&key)?,
Some(value_ab.clone()),
"A must durably hold its own committed k = \"from-AB\""
);
let b_has_winner = wait_until(
QUORUM_TIMEOUT,
|| matches!(node_b.db.get(&key), Ok(Some(ref v)) if v == &value_ab),
);
assert!(
b_has_winner,
"B must durably hold k = \"from-AB\" after the majority commit"
);
let c_fenced_pre_heal = node_c
.db
.distribution()
.ok_or("C has no endpoint")?
.propose_write(
ProposeWrite {
key: key.clone(),
expected: None,
value: value_c.clone(),
ttl: None,
},
0, Ballot::bottom(),
&membership(3, &[]),
FENCE_TIMEOUT,
);
assert!(
matches!(
c_fenced_pre_heal,
Err(ConsistencyError::QuorumTimeout { .. })
),
"isolated C must be fenced pre-heal (QuorumTimeout), got {c_fenced_pre_heal:?}"
);
link_both(&node_c, &node_a)?;
link_both(&node_c, &node_b)?;
assert_post_heal_c_fenced(&node_c, &key, &value_c)?;
assert_eq!(
node_a.db.get(&key)?,
Some(value_ab.clone()),
"A must STILL hold k = \"from-AB\" — C's rejected proposal applied nothing"
);
assert_eq!(
node_b.db.get(&key)?,
Some(value_ab),
"B must STILL hold k = \"from-AB\" — C's rejected proposal applied nothing"
);
assert_ne!(
node_a.db.get(&key)?,
Some(value_c.clone()),
"C's value must NOT have overwritten the winner on A"
);
assert_ne!(
node_b.db.get(&key)?,
Some(value_c.clone()),
"C's value must NOT have overwritten the winner on B"
);
assert_ne!(
node_c.db.get(&key)?,
Some(value_c),
"C must not have durably acquired its own contested value"
);
Ok(())
}