#![allow(
clippy::print_stdout,
clippy::doc_markdown,
clippy::doc_lazy_continuation,
clippy::uninlined_format_args
)]
use std::error::Error;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use haematite::db::respond_to_inbound_writes;
use haematite::sync::membership::WriteMembership;
use haematite::sync::{DistributionEndpoint, SyncNodeId};
use haematite::{Database, DatabaseConfig, DatabaseError};
type Result3<T> = Result<T, 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 OP_TIMEOUT: Duration = Duration::from_secs(5);
const SHARD: usize = 0;
const TS_WIDTH: usize = 8;
fn main() -> Result3<()> {
println!("== replicated_append: one atomic batch -> a quorum -> every node holds it ==\n");
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)?;
link_both(&node_b, &node_c)?;
println!("3-node cluster {{A, B, C}} up (quorum = 2 of 3)\n");
node_a
.db
.acquire_shard_and_serve(SHARD, &membership(3, &[NODE_B, NODE_C]), OP_TIMEOUT)?;
println!("A is the live owner of shard {SHARD}\n");
let stream = b"metrics:ingest";
let batch: Vec<Vec<u8>> = vec![
b"sample-1".to_vec(),
b"sample-2".to_vec(),
b"sample-3".to_vec(),
b"sample-4".to_vec(),
];
println!("-- A replicate_appends a 4-event batch to quorum {{A, B, C}} --");
for (i, e) in batch.iter().enumerate() {
println!(" event {i}: {}", String::from_utf8_lossy(e));
}
let next = node_a.db.replicate_append(
stream,
&batch,
0,
&membership(3, &[NODE_B, NODE_C]),
OP_TIMEOUT,
)?;
println!(" committed atomically at quorum; new next-seq = {next}\n");
println!("-- every node holds the full batch in order with next-seq 4 --");
for node in [&node_a, &node_b, &node_c] {
let payloads = read_payloads(node, stream)?;
let seq = node.db.read_stream_next_seq(stream)?;
println!(" {}: events = {:?}, next-seq = {seq:?}", node.name, as_strs(&payloads));
assert_eq!(payloads, batch, "{} must hold the full batch", node.name);
assert_eq!(seq, Some(4), "{} must hold next-seq 4", node.name);
}
println!();
println!("-- a re-send at the now-stale expected_seq 0 is rejected (no double-apply) --");
match node_a.db.replicate_append(
stream,
&[b"should-not-land".to_vec()],
0,
&membership(3, &[NODE_B, NODE_C]),
OP_TIMEOUT,
) {
Err(DatabaseError::SequenceConflict { expected, actual }) => {
println!(" rejected: SequenceConflict {{ expected: {expected}, actual: {actual} }}");
println!(" (the owner-local OCC pre-check proposed NOTHING — no partial replication)");
}
other => return Err(format!("expected a SequenceConflict, got {other:?}").into()),
}
for node in [&node_a, &node_b, &node_c] {
let payloads = read_payloads(node, stream)?;
assert_eq!(payloads, batch, "{} stream unchanged after the conflict", node.name);
}
println!(" all nodes still hold exactly the original 4 events\n");
println!("== done: replicate_append delivered one atomic batch to the whole quorum ==");
node_a.stop_responder();
node_b.stop_responder();
node_c.stop_responder();
Ok(())
}
struct Node {
db: Arc<Database>,
addr: SocketAddr,
name: &'static str,
responder: Option<JoinHandle<()>>,
running: Arc<AtomicBool>,
}
impl Node {
fn spawn(name: &'static str, dir: &Path) -> Result3<Self> {
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(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(Ordering::Relaxed) {
drop(respond_to_inbound_writes(
&responder_db,
Duration::from_millis(50),
));
}
});
Ok(Self {
db,
addr,
name,
responder: Some(responder),
running,
})
}
fn stop_responder(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for Node {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(handle) = self.responder.take() {
drop(handle.join());
}
}
}
fn loopback() -> Result3<SocketAddr> {
Ok("127.0.0.1:0".parse()?)
}
fn config_for(path: &Path) -> DatabaseConfig {
DatabaseConfig {
data_dir: path.to_path_buf(),
shard_count: 1,
sweep_interval: None,
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));
}
}
fn link(from: &Node, to: &Node) -> Result3<()> {
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) -> Result3<()> {
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 read_payloads(node: &Node, stream: &[u8]) -> Result3<Vec<Vec<u8>>> {
let raw = node.db.read_events(stream)?;
let mut out = Vec::with_capacity(raw.len());
for value in raw {
if value.len() < TS_WIDTH {
return Err(format!("event value shorter than timestamp header: {value:?}").into());
}
out.push(value[TS_WIDTH..].to_vec());
}
Ok(out)
}
fn as_strs(payloads: &[Vec<u8>]) -> Vec<String> {
payloads
.iter()
.map(|p| String::from_utf8_lossy(p).into_owned())
.collect()
}