use std::io::Read;
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use base64::Engine;
use counter_server::{
Counter, CounterEvent, GET_VALUE_REQUEST, KIND_RESP_ACK, KIND_RESP_VALUE, RequestDecoder,
ResponseEncoder, increment_request,
};
use melin_client::{Connection, SigningKey};
use melin_server_runtime::StartupEvents;
use melin_server_runtime::ack_policy::AckPolicy;
use melin_server_runtime::layout::PipelineCores;
use melin_server_runtime::server::ServerConfig;
use melin_test_node::Addrs;
use melin_test_node::probe::{admin_until_ok, http_metrics, wait_for_gauge};
use melin_transport_core::test_ports::free_addr;
use melin_wire_protocol::control_codec::TAG_CHALLENGE;
use serial_test::serial;
const PORT_BASE: u16 = 10_000;
fn startup_events(i: usize) -> StartupEvents<CounterEvent> {
let weight = i as u64 + 1;
StartupEvents {
genesis: vec![CounterEvent::Increment {
amount: weight * GENESIS,
}],
on_primary: vec![CounterEvent::Increment {
amount: weight * ON_PRIMARY,
}],
}
}
const GENESIS: u64 = 1_000_000;
const ON_PRIMARY: u64 = 1_000;
fn connect_authenticated(addr: SocketAddr, key: &SigningKey) -> Connection {
let mut conn = Connection::connect_by(addr, key, Instant::now() + Duration::from_secs(30))
.unwrap_or_else(|e| panic!("timed out waiting for a serving node at {addr}: {e}"));
conn.set_read_timeout(Duration::from_secs(30))
.expect("set timeout");
conn
}
fn serves_clients(addr: SocketAddr, patience: Duration) -> bool {
let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(300)) else {
return false;
};
if stream.set_read_timeout(Some(patience)).is_err() {
return false;
}
let mut head = [0u8; 5];
stream.read_exact(&mut head).is_ok() && head[4] == TAG_CHALLENGE
}
struct NodeSetup {
key: SigningKey,
addrs: Addrs,
raft_addr: SocketAddr,
health_addr: SocketAddr,
admin_addr: SocketAddr,
}
fn cluster_summary(nodes: &[NodeSetup]) -> String {
nodes
.iter()
.enumerate()
.map(|(i, n)| {
let gauges = match http_metrics(n.health_addr) {
None => "health endpoint unreachable".to_owned(),
Some(body) => body
.lines()
.filter(|l| {
l.starts_with("melin_raft_") || l.starts_with("melin_replicas_connected")
})
.collect::<Vec<_>>()
.join(" "),
};
format!("node {}: {gauges}", i + 1)
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
#[serial]
fn acked_events_survive_primary_death_under_ram_policy() {
melin_test_node::logs::capture_if_requested();
let tmp = tempfile::tempdir().expect("tempdir");
let nodes: Vec<NodeSetup> = (0..3)
.map(|i| NodeSetup {
key: SigningKey::from_bytes(&[0x61 + i as u8; 32]),
addrs: melin_test_node::addrs(i, || free_addr(PORT_BASE)),
raft_addr: free_addr(PORT_BASE),
health_addr: free_addr(PORT_BASE),
admin_addr: free_addr(PORT_BASE),
})
.collect();
let client_key = SigningKey::from_bytes(&[0x11; 32]);
let b64 = |k: &SigningKey| {
base64::engine::general_purpose::STANDARD.encode(k.verifying_key().to_bytes())
};
let auth_path = tmp.path().join("authorized_keys");
let mut table: String = nodes
.iter()
.enumerate()
.map(|(i, n)| format!("replication {} node-{}\n", b64(&n.key), i + 1))
.collect();
table.push_str(&format!("operator {} client\n", b64(&client_key)));
std::fs::write(&auth_path, table).unwrap();
let peers: Vec<String> = nodes
.iter()
.enumerate()
.map(|(i, n)| format!("{}@{}#{}", i + 1, n.raft_addr, b64(&n.key)))
.collect();
let make_config = |i: usize| -> ServerConfig {
let key_path = tmp.path().join(format!("key-{i}"));
std::fs::write(&key_path, nodes[i].key.to_bytes()).unwrap();
ServerConfig {
journal: tmp.path().join(format!("node-{i}.journal")),
authorized_keys: auth_path.clone(),
ack_policy: AckPolicy::Ram,
cores: PipelineCores::unpinned(),
tick_interval_ms: 0,
snapshot_interval_ms: 0,
health_bind: Some(nodes[i].health_addr),
admin_bind: Some(nodes[i].admin_addr),
replication_key: Some(key_path),
raft_bind: Some(nodes[i].raft_addr),
raft_node_id: Some(i as u64 + 1),
raft_peer: peers.clone(),
raft_dir: Some(tmp.path().join(format!("node-{i}.raft"))),
raft_auto_promote: true,
..melin_test_node::config()
}
};
let replication_addr = nodes[0].addrs.replication();
let start = |i: usize, config: ServerConfig| {
melin_test_node::start_at::<Counter>(
&nodes[i].addrs,
config,
startup_events(i),
(),
RequestDecoder,
ResponseEncoder,
)
};
let primary = {
let mut config = make_config(0);
config.replication_bind = Some(replication_addr);
start(0, config)
};
let replicas: Vec<_> = (1..3)
.map(|i| {
let mut config = make_config(i);
config.replica_of = Some(replication_addr);
start(i, config)
})
.collect();
let deadline = Instant::now() + Duration::from_secs(60);
let leader_at_formation = loop {
if primary.is_finished() {
panic!("primary exited early: {:?}", primary.join());
}
let connected = http_metrics(nodes[0].health_addr)
.is_some_and(|m| m.contains("melin_replicas_connected 2\n"));
let leader = nodes.iter().position(|n| {
http_metrics(n.health_addr).is_some_and(|m| m.contains("melin_raft_is_leader 1\n"))
});
if connected && leader.is_some() {
break leader;
}
assert!(
Instant::now() < deadline,
"cluster never formed: replicas_connected_2={connected} leader={leader:?}\n{}",
cluster_summary(&nodes)
);
std::thread::sleep(Duration::from_millis(200));
};
let acked_total = GENESIS + ON_PRIMARY + 7;
{
let mut conn = connect_authenticated(primary.addr(), &client_key);
let mut expected_total = GENESIS + ON_PRIMARY;
for amount in [1u64, 2, 4] {
expected_total += amount;
let responses = conn
.request(&increment_request(amount))
.expect("the increment is answered");
assert_eq!(responses.len(), 1);
assert_eq!(responses[0][0], KIND_RESP_ACK, "increment must be acked");
let value = u64::from_le_bytes(responses[0][1..9].try_into().unwrap());
assert_eq!(value, expected_total, "ack carries the running total");
}
}
#[cfg(feature = "dpdk")]
primary.cut_off();
primary.stop();
let deadline = Instant::now() + Duration::from_secs(60);
let winner = loop {
assert!(
Instant::now() < deadline,
"no replica promoted within the deadline\n{}",
cluster_summary(&nodes)
);
let serving: Vec<usize> = (1..3)
.filter(|&i| serves_clients(nodes[i].addrs.client(), Duration::from_millis(500)))
.collect();
match serving.len() {
0 => std::thread::sleep(Duration::from_millis(200)),
1 => break serving[0],
_ => panic!("both replicas promoted — split brain: {serving:?}"),
}
};
let loser = if winner == 1 { 2 } else { 1 };
assert!(
!serves_clients(nodes[loser].addrs.client(), Duration::from_secs(2)),
"the losing replica must not serve clients"
);
wait_for_gauge(
nodes[winner].health_addr,
"melin_ack_policy_journaled",
AckPolicy::Ram.as_u8().into(),
);
admin_until_ok(nodes[winner].admin_addr, &client_key, "ACK-POLICY disk");
wait_for_gauge(
nodes[winner].health_addr,
"melin_ack_policy_in_force",
AckPolicy::Disk.as_u8().into(),
);
{
let mut conn = connect_authenticated(nodes[winner].addrs.client(), &client_key);
let responses = conn
.request(&GET_VALUE_REQUEST)
.expect("the query is answered");
assert_eq!(responses.len(), 1);
assert_eq!(responses[0][0], KIND_RESP_VALUE);
let value = u64::from_le_bytes(responses[0][1..9].try_into().unwrap());
let seqs: Vec<String> = (1..3)
.map(|i| {
let m = http_metrics(nodes[i].health_addr).unwrap_or_default();
let line = m
.lines()
.filter(|l| {
l.starts_with("melin_journal_sequence")
|| l.starts_with("melin_events_processed")
})
.collect::<Vec<_>>()
.join(" ");
format!("node {}: {line}", i + 1)
})
.collect();
assert_eq!(
value,
acked_total + (winner as u64 + 1) * ON_PRIMARY,
"the promoted node must hold every event acked under `ram`, plus its own \
on_primary events (winner=node {}, leader at formation=node {:?}, {})",
winner + 1,
leader_at_formation.map(|i| i + 1),
seqs.join("; ")
);
}
for replica in replicas {
replica.stop();
}
}