#![cfg(not(feature = "skip-order-exec"))]
use ed25519_dalek::Signer;
use serial_test::serial;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use ed25519_dalek::SigningKey;
use melin_client::Client;
use melin_protocol::message::Request;
use melin_protocol::types::{
AccountId, Order, OrderId, OrderType, Price, Quantity, Side, Symbol, TimeInForce,
};
fn server_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_melin-server"))
}
fn connect_with_timeout(addr: SocketAddr, key: &SigningKey) -> Client {
let client = Client::connect(addr, key).expect("client connect");
client
.set_read_timeout(Some(Duration::from_secs(60)))
.expect("set read timeout");
client
}
fn free_port() -> u16 {
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::fd::AsRawFd;
const PORT_FILE: &str = "/tmp/melin_test_port_alloc";
const PORT_FLOOR: u16 = 20_000;
const PORT_CEILING: u16 = 32_000;
let mut f = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(PORT_FILE)
.expect("open port allocator file");
let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) };
assert!(rc == 0, "flock failed: {}", std::io::Error::last_os_error());
let mut s = String::new();
let _ = f.read_to_string(&mut s);
let next: u16 = s.trim().parse().unwrap_or(PORT_FLOOR);
let port = if next >= PORT_CEILING {
PORT_FLOOR
} else {
next
};
let after = port + 1;
f.seek(SeekFrom::Start(0)).expect("seek port file");
f.set_len(0).expect("truncate port file");
write!(f, "{after}").expect("write port file");
let _ = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) };
port
}
fn write_auth_keys_multi(
dir: &Path,
keys: &[&SigningKey],
operator_key: &SigningKey,
repl_key: &SigningKey,
) -> (PathBuf, PathBuf) {
let path = dir.join("authorized_keys");
let mut content = String::new();
for (i, key) in keys.iter().enumerate() {
let pub_key_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key.verifying_key().to_bytes(),
);
content.push_str(&format!("trader {pub_key_b64} test-key-{i}\n"));
}
let ops_pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
operator_key.verifying_key().to_bytes(),
);
content.push_str(&format!("operator {ops_pub_b64} ops\n"));
let repl_pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
repl_key.verifying_key().to_bytes(),
);
content.push_str(&format!("replication {repl_pub_b64} replication\n"));
std::fs::write(&path, content).expect("write authorized_keys");
let repl_key_path = dir.join("replication.key");
std::fs::write(&repl_key_path, repl_key.to_bytes()).expect("write replication key");
(path, repl_key_path)
}
fn wait_healthy(addr: SocketAddr, timeout: Duration) -> (u64, u64, u64, bool) {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
panic!("health endpoint {addr} did not respond within {timeout:?}");
}
if let Ok(status) = query_health(addr) {
return status;
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn wait_ready(addr: SocketAddr, timeout: Duration) {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
panic!("server {addr} did not become ready within {timeout:?}");
}
if let Ok((_, _, _, true)) = query_health(addr) {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_halted(addr: SocketAddr, timeout: Duration) {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
panic!("server {addr} did not halt within {timeout:?}");
}
if let Ok((_, _, _, false)) = query_health(addr) {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_primary_repl_ready(health_addr: SocketAddr, timeout: Duration) {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
panic!("primary {health_addr} never became ready for replica connections");
}
if query_health(health_addr).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn fetch_replica_cursors(addr: SocketAddr) -> Option<[(u64, u64); 2]> {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1)).ok()?;
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
stream.write_all(b"GET /metrics HTTP/1.1\r\n\r\n").ok()?;
let mut body = Vec::new();
stream.read_to_end(&mut body).ok()?;
let text = std::str::from_utf8(&body).ok()?;
let mut acked = [0u64; 2];
let mut in_mem = [0u64; 2];
for line in text.lines() {
for slot in 0..2usize {
let acked_prefix = format!("melin_replica_acked_sequence{{slot=\"{slot}\"}} ");
let in_mem_prefix = format!("melin_replica_in_memory_sequence{{slot=\"{slot}\"}} ");
if let Some(rest) = line.strip_prefix(&acked_prefix) {
acked[slot] = rest.trim().parse().ok()?;
} else if let Some(rest) = line.strip_prefix(&in_mem_prefix) {
in_mem[slot] = rest.trim().parse().ok()?;
}
}
}
Some([(in_mem[0], acked[0]), (in_mem[1], acked[1])])
}
fn fetch_policy_degraded(addr: SocketAddr) -> Option<u32> {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1)).ok()?;
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
stream.write_all(b"GET /metrics HTTP/1.1\r\n\r\n").ok()?;
let mut body = Vec::new();
stream.read_to_end(&mut body).ok()?;
let text = std::str::from_utf8(&body).ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("melin_durability_policy_degraded ") {
return rest.trim().parse().ok();
}
}
None
}
fn wait_for_policy_degraded(addr: SocketAddr, expected: u32, timeout: Duration) {
let start = Instant::now();
loop {
if let Some(v) = fetch_policy_degraded(addr)
&& v == expected
{
return;
}
if start.elapsed() >= timeout {
let last = fetch_policy_degraded(addr);
panic!("timed out waiting for policy_degraded={expected}; last observed = {last:?}");
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn fetch_metric_u64(addr: SocketAddr, line_prefix: &str) -> Option<u64> {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1)).ok()?;
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
stream.write_all(b"GET /metrics HTTP/1.1\r\n\r\n").ok()?;
let mut body = Vec::new();
stream.read_to_end(&mut body).ok()?;
let text = std::str::from_utf8(&body).ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix(line_prefix) {
return rest.trim().parse().ok();
}
}
None
}
fn wait_metric(
addr: SocketAddr,
line_prefix: &str,
timeout: Duration,
what: &str,
pred: impl Fn(u64) -> bool,
) {
let start = Instant::now();
loop {
if let Some(v) = fetch_metric_u64(addr, line_prefix)
&& pred(v)
{
return;
}
if start.elapsed() >= timeout {
let last = fetch_metric_u64(addr, line_prefix);
panic!("timed out waiting for {what}; last `{line_prefix}` = {last:?}");
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn query_health(addr: SocketAddr) -> Result<(u64, u64, u64, bool), Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut buf = [0u8; 256];
let n = stream.read(&mut buf)?;
let line = std::str::from_utf8(&buf[..n])?.trim().to_string();
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 || parts[0] != "OK" {
return Err(format!("unexpected health response: {line}").into());
}
Ok((
parts[1].parse()?,
parts[2].parse()?,
parts[3].parse()?,
parts[4] == "trading",
))
}
fn wait_for_replacement_catchup(primary_health: SocketAddr) {
let start = Instant::now();
let mut saw_nonzero = false;
loop {
if let Ok((_, _, lag, _)) = query_health(primary_health) {
if lag > 0 {
saw_nonzero = true;
} else if saw_nonzero {
return;
}
}
if start.elapsed() > Duration::from_secs(30) {
panic!("replacement catch-up timeout (saw_nonzero={saw_nonzero})");
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn admin_command(addr: SocketAddr, operator_key: &SigningKey, command: &str) -> String {
use melin_protocol::codec;
use melin_protocol::message::{Request, ResponseKind};
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))
.expect("connect to admin endpoint");
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set read timeout");
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).expect("read challenge len");
let frame_len = u32::from_le_bytes(len_buf) as usize;
let mut frame_buf = vec![0u8; frame_len];
stream
.read_exact(&mut frame_buf)
.expect("read challenge payload");
let nonce = match codec::decode_response(&frame_buf).expect("decode challenge") {
ResponseKind::Challenge { nonce } => nonce,
other => panic!("expected Challenge, got {other:?}"),
};
let signature = operator_key.sign(&nonce);
let request = Request::ChallengeResponse {
signature: signature.to_bytes(),
public_key: operator_key.verifying_key().to_bytes(),
};
let mut encode_buf = [0u8; 256];
let written = codec::encode_request(&request, 0, &mut encode_buf).expect("encode");
stream
.write_all(&encode_buf[..written])
.expect("send ChallengeResponse");
stream.flush().expect("flush");
stream
.read_exact(&mut len_buf)
.expect("read auth result len");
let result_len = u32::from_le_bytes(len_buf) as usize;
let mut result_buf = vec![0u8; result_len];
stream
.read_exact(&mut result_buf)
.expect("read auth result payload");
match codec::decode_response(&result_buf).expect("decode auth result") {
ResponseKind::ServerReady => {}
ResponseKind::AuthFailed => panic!("admin auth failed"),
other => panic!("unexpected auth response: {other:?}"),
}
stream
.write_all(format!("{command}\n").as_bytes())
.expect("send admin command");
let mut reader = BufReader::new(&stream);
let mut response = String::new();
reader
.read_line(&mut response)
.expect("read admin response");
response.trim().to_string()
}
fn promote(addr: SocketAddr, operator_key: &SigningKey) {
let response = admin_command(addr, operator_key, "PROMOTE");
assert!(response == "OK", "promotion failed: {response}");
}
fn set_durability_mode(addr: SocketAddr, operator_key: &SigningKey, mode: &str) {
let cmd = format!("DURABILITY {mode}");
let response = admin_command(addr, operator_key, &cmd);
assert!(
response == "OK",
"set durability {mode} on {addr} failed: {response}"
);
}
struct ServerProcess {
child: Child,
client_addr: SocketAddr,
health_addr: SocketAddr,
}
impl Drop for ServerProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn spawn_primary_with_extra(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
client_port: u16,
health_port: u16,
replication_port: u16,
extra_args: &[&str],
) -> ServerProcess {
spawn_primary_with_extra_env(
bin,
tmp_dir,
keys_path,
client_port,
health_port,
replication_port,
extra_args,
&[],
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_primary_with_extra_env(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
client_port: u16,
health_port: u16,
replication_port: u16,
extra_args: &[&str],
extra_env: &[(&str, &str)],
) -> ServerProcess {
let journal = tmp_dir.join("primary.journal");
let mut args: Vec<String> = vec![
"--bind".into(),
format!("127.0.0.1:{client_port}"),
"--health-bind".into(),
format!("127.0.0.1:{health_port}"),
"--replication-bind".into(),
format!("127.0.0.1:{replication_port}"),
"--journal".into(),
journal.to_str().expect("valid path").into(),
"--authorized-keys".into(),
keys_path.to_str().expect("valid path").into(),
"--accounts".into(),
"10".into(),
"--instruments".into(),
"2".into(),
"--connection-timeout-secs".into(),
"0".into(),
"--yield-idle".into(),
"--cores".into(),
"0,0,0,0,0,0,0,0,0".into(),
];
for a in extra_args {
args.push((*a).into());
}
let mut command = Command::new(bin);
command
.args(&args)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4");
for (k, v) in extra_env {
command.env(k, v);
}
let child = command.spawn().expect("spawn primary server");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{client_port}").parse().unwrap(),
health_addr: format!("127.0.0.1:{health_port}").parse().unwrap(),
}
}
fn spawn_replica(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
repl_key_path: &Path,
primary_repl_port: u16,
client_port: u16,
health_port: u16,
admin_port: u16,
) -> ServerProcess {
spawn_replica_named(
bin,
tmp_dir,
keys_path,
repl_key_path,
primary_repl_port,
client_port,
health_port,
admin_port,
"replica",
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_replica_named(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
repl_key_path: &Path,
primary_repl_port: u16,
client_port: u16,
health_port: u16,
admin_port: u16,
name: &str,
) -> ServerProcess {
spawn_replica_named_with_extra(
bin,
tmp_dir,
keys_path,
repl_key_path,
primary_repl_port,
client_port,
health_port,
admin_port,
name,
&[],
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_replica_named_with_extra(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
repl_key_path: &Path,
primary_repl_port: u16,
client_port: u16,
health_port: u16,
admin_port: u16,
name: &str,
extra_args: &[&str],
) -> ServerProcess {
spawn_replica_named_with_extra_env(
bin,
tmp_dir,
keys_path,
repl_key_path,
primary_repl_port,
client_port,
health_port,
admin_port,
name,
extra_args,
&[],
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_replica_named_with_extra_env(
bin: &Path,
tmp_dir: &Path,
keys_path: &Path,
repl_key_path: &Path,
primary_repl_port: u16,
client_port: u16,
health_port: u16,
admin_port: u16,
name: &str,
extra_args: &[&str],
extra_env: &[(&str, &str)],
) -> ServerProcess {
let journal = tmp_dir.join(format!("{name}.journal"));
let mut args: Vec<String> = vec![
"--bind".into(),
format!("127.0.0.1:{client_port}"),
"--health-bind".into(),
format!("127.0.0.1:{health_port}"),
"--replica-of".into(),
format!("127.0.0.1:{primary_repl_port}"),
"--replication-key".into(),
repl_key_path.to_str().expect("valid path").into(),
"--admin-bind".into(),
format!("127.0.0.1:{admin_port}"),
"--journal".into(),
journal.to_str().expect("valid path").into(),
"--authorized-keys".into(),
keys_path.to_str().expect("valid path").into(),
"--connection-timeout-secs".into(),
"0".into(),
"--yield-idle".into(),
"--cores".into(),
"0,0,0,0,0,0,0,0,0".into(),
];
for a in extra_args {
args.push((*a).into());
}
let mut command = Command::new(bin);
command
.args(&args)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4");
for (k, v) in extra_env {
command.env(k, v);
}
let child = command.spawn().expect("spawn replica server");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{client_port}").parse().unwrap(),
health_addr: format!("127.0.0.1:{health_port}").parse().unwrap(),
}
}
fn qty(n: u64) -> Quantity {
Quantity(std::num::NonZeroU64::new(n).unwrap())
}
fn price(n: u64) -> Price {
Price(std::num::NonZeroU64::new(n).unwrap())
}
struct TestCluster {
primary: ServerProcess,
replica: ServerProcess,
admin_port: u16,
primary_repl_port: u16,
key: SigningKey,
key2: SigningKey,
operator_key: SigningKey,
bin: PathBuf,
keys_path: PathBuf,
repl_key_path: PathBuf,
_tmp: tempfile::TempDir,
}
impl TestCluster {
fn start() -> Self {
Self::start_with_extra_args(&[])
}
fn start_with_extra_args(extra_args: &[&str]) -> Self {
let bin = server_bin();
assert!(
bin.exists(),
"melin-server binary not found at {bin:?}. Run `cargo build --release` first."
);
let tmp = tempfile::tempdir().expect("create temp dir");
let key = SigningKey::from_bytes(&[0xFA; 32]);
let key2 = SigningKey::from_bytes(&[0xFB; 32]);
let operator_key = SigningKey::from_bytes(&[0xFD; 32]);
let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
let (keys_path, repl_key_path) =
write_auth_keys_multi(tmp.path(), &[&key, &key2], &operator_key, &repl_key);
let primary_client_port = free_port();
let primary_health_port = free_port();
let primary_repl_port = free_port();
let replica_client_port = free_port();
let replica_health_port = free_port();
let replica_admin_port = free_port();
let primary = spawn_primary_with_extra(
&bin,
tmp.path(),
&keys_path,
primary_client_port,
primary_health_port,
primary_repl_port,
extra_args,
);
wait_for_primary_repl_ready(primary.health_addr, Duration::from_secs(10));
let replica = spawn_replica_named_with_extra(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
primary_repl_port,
replica_client_port,
replica_health_port,
replica_admin_port,
"replica",
extra_args,
);
wait_healthy(primary.health_addr, Duration::from_secs(30));
Self {
primary,
replica,
admin_port: replica_admin_port,
primary_repl_port,
key,
key2,
operator_key,
bin,
keys_path,
repl_key_path,
_tmp: tmp,
}
}
fn connect_primary(&self) -> Client {
connect_with_timeout(self.primary.client_addr, &self.key)
}
fn wait_replicated(&self) {
let start = Instant::now();
loop {
if let Ok((_, _, 0, _)) = query_health(self.primary.health_addr) {
return;
}
if start.elapsed() > Duration::from_secs(10) {
panic!("replication lag did not reach 0 within 10s");
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn kill_and_promote(&mut self) -> Client {
unsafe {
libc::kill(self.primary.child.id() as i32, libc::SIGKILL);
}
let _ = self.primary.child.wait();
let promote_addr: SocketAddr = format!("127.0.0.1:{}", self.admin_port).parse().unwrap();
promote(promote_addr, &self.operator_key);
set_durability_mode(promote_addr, &self.operator_key, "local");
wait_ready(self.replica.health_addr, Duration::from_secs(30));
connect_with_timeout(self.replica.client_addr, &self.key2)
}
}
fn submit_order(
client: &mut Client,
id: u64,
account: u32,
symbol: u32,
side: Side,
price_val: u64,
qty_val: u64,
) -> Vec<melin_protocol::message::ResponseKind> {
client
.send_request(&Request::SubmitOrder {
symbol: Symbol(symbol),
order: Order {
id: OrderId(id),
account: AccountId(account),
side,
order_type: OrderType::Limit {
price: price(price_val),
post_only: false,
},
time_in_force: TimeInForce::GTC,
quantity: qty(qty_val),
stp: melin_protocol::types::SelfTradeProtection::Allow,
expiry_ns: 0,
},
})
.expect("submit order")
}
fn has_report(
responses: &[melin_protocol::message::ResponseKind],
pred: fn(&melin_protocol::types::ExecutionReport) -> bool,
) -> bool {
responses.iter().any(|r| {
if let melin_protocol::message::ResponseKind::Report(report) = r {
pred(report)
} else {
false
}
})
}
#[test]
#[serial]
fn kill_primary_promote_replica_no_data_loss() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=50u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
let mut client2 = cluster.kill_and_promote();
let r = submit_order(&mut client2, 51, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed, got: {r:?}"
);
let r = submit_order(&mut client2, 52, 1, 1, Side::Sell, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Fill, got: {r:?}"
);
}
#[test]
#[serial]
fn kill_during_active_fills() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=20u64 {
let r = submit_order(&mut client, i, 2, 1, Side::Sell, 100 + i, 10);
assert!(!r.is_empty());
}
for i in 21..=40u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 200, 5);
assert!(!r.is_empty());
}
cluster.wait_replicated();
let mut client2 = cluster.kill_and_promote();
let r = submit_order(&mut client2, 41, 2, 1, Side::Sell, 300, 1);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed after fill-heavy workload, got: {r:?}"
);
let r = submit_order(&mut client2, 42, 1, 1, Side::Buy, 300, 1);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Fill after fill-heavy workload, got: {r:?}"
);
}
#[test]
#[serial]
fn kill_without_waiting_for_replication() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
let mut last_acked_id = 0u64;
for i in 1..=30u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
if !r.is_empty() {
last_acked_id = i;
}
}
assert!(last_acked_id > 0, "no orders were acked");
drop(client);
let mut client2 = cluster.kill_and_promote();
let r = submit_order(&mut client2, last_acked_id + 1, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed with id={}, got: {r:?}",
last_acked_id + 1
);
let r = submit_order(&mut client2, last_acked_id, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId for id={last_acked_id}, got: {r:?}"
);
}
#[test]
#[serial]
fn recovered_primary_durability_gate_holds() {
const PREFILL: u64 = 50;
const BURST: u64 = 10;
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=PREFILL {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "phase-1 order {i} no response");
}
cluster.wait_replicated();
drop(client);
unsafe {
libc::kill(cluster.primary.child.id() as i32, libc::SIGKILL);
libc::kill(cluster.replica.child.id() as i32, libc::SIGKILL);
}
let _ = cluster.primary.child.wait();
let _ = cluster.replica.child.wait();
let _ = std::fs::remove_file(cluster._tmp.path().join("replica.journal"));
let _ = std::fs::remove_file(cluster._tmp.path().join("replica.snapshot"));
let restarted_primary = spawn_primary_with_extra(
&cluster.bin,
cluster._tmp.path(),
&cluster.keys_path,
cluster.primary.client_addr.port(),
cluster.primary.health_addr.port(),
cluster.primary_repl_port,
&[],
);
cluster.primary = restarted_primary;
wait_for_primary_repl_ready(cluster.primary.health_addr, Duration::from_secs(10));
let restarted_replica = spawn_replica_named_with_extra(
&cluster.bin,
cluster._tmp.path(),
&cluster.keys_path,
&cluster.repl_key_path,
cluster.primary_repl_port,
cluster.replica.client_addr.port(),
cluster.replica.health_addr.port(),
cluster.admin_port,
"replica",
&[],
);
cluster.replica = restarted_replica;
wait_healthy(cluster.primary.health_addr, Duration::from_secs(30));
cluster.wait_replicated();
let mut client = cluster.connect_primary();
let mut acked: Vec<u64> = Vec::with_capacity(BURST as usize);
for i in 0..BURST {
let id = PREFILL + 1 + i;
let r = submit_order(&mut client, id, 1, 1, Side::Buy, 100, 10);
if has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Placed { .. })
}) {
acked.push(id);
}
}
assert!(!acked.is_empty(), "no burst orders were placed");
drop(client);
let mut client2 = cluster.kill_and_promote();
let mut missing: Vec<u64> = Vec::new();
for id in &acked {
let r = submit_order(&mut client2, *id, 1, 1, Side::Buy, 100, 10);
if !has_report(&r, |rep| {
matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)
}) {
missing.push(*id);
}
}
assert!(
missing.is_empty(),
"hybrid gate broken on recovered primary: {} of {} acked burst orders \
were not on the promoted replica (missing ids: {:?}). \
Acked: {:?}",
missing.len(),
acked.len(),
missing,
acked,
);
}
#[test]
#[serial]
fn replica_reconnects_after_primary_restart_without_journal_wipe() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "phase-1 order {i} no response");
}
cluster.wait_replicated();
drop(client);
unsafe {
libc::kill(cluster.primary.child.id() as i32, libc::SIGKILL);
}
let _ = cluster.primary.child.wait();
let restarted_primary = spawn_primary_with_extra(
&cluster.bin,
cluster._tmp.path(),
&cluster.keys_path,
cluster.primary.client_addr.port(),
cluster.primary.health_addr.port(),
cluster.primary_repl_port,
&[],
);
cluster.primary = restarted_primary;
wait_for_primary_repl_ready(cluster.primary.health_addr, Duration::from_secs(10));
wait_healthy(cluster.primary.health_addr, Duration::from_secs(30));
cluster.wait_replicated();
let mut client = cluster.connect_primary();
client
.synchronize_request_seq()
.expect("synchronize_request_seq with recovered primary");
for i in 11..=20u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"phase-2 order {i}: expected Placed, got {r:?}"
);
}
drop(client);
cluster.wait_replicated();
}
#[test]
#[serial]
fn crashed_primary_recovers_from_journal() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
submit_order(&mut client, i, 2, 1, Side::Sell, 100 + i, 5);
}
for i in 11..=20u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 200, 3);
}
cluster.wait_replicated();
unsafe {
libc::kill(cluster.primary.child.id() as i32, libc::SIGKILL);
}
let _ = cluster.primary.child.wait();
let primary_journal = cluster._tmp.path().join("primary.journal");
assert!(primary_journal.exists(), "primary journal must exist");
let recovered_client_port = free_port();
let recovered_health_port = free_port();
let recovered = {
let child = Command::new(&cluster.bin)
.args([
"--bind",
&format!("127.0.0.1:{recovered_client_port}"),
"--health-bind",
&format!("127.0.0.1:{recovered_health_port}"),
"--standalone",
"--durability-mode",
"local",
"--journal",
primary_journal.to_str().expect("valid path"),
"--authorized-keys",
cluster.keys_path.to_str().expect("valid path"),
"--accounts",
"10",
"--instruments",
"2",
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn recovered primary");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{recovered_client_port}")
.parse()
.unwrap(),
health_addr: format!("127.0.0.1:{recovered_health_port}")
.parse()
.unwrap(),
}
};
wait_ready(recovered.health_addr, Duration::from_secs(30));
let mut client3 = connect_with_timeout(recovered.client_addr, &cluster.key2);
let r = submit_order(&mut client3, 21, 1, 1, Side::Buy, 300, 1);
let accepted = has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Placed { .. })
}) || has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Fill { .. })
});
assert!(
accepted,
"expected Placed or Fill on recovered primary, got: {r:?}"
);
let r = submit_order(&mut client3, 10, 2, 1, Side::Sell, 100, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId on recovered primary, got: {r:?}"
);
}
#[test]
#[serial]
fn journals_contiguous_across_replication() {
use melin_journal::JournalReader;
let cluster = TestCluster::start();
let mut client = cluster.connect_primary();
const ORDERS: u64 = 250;
for i in 1..=ORDERS {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
let r = submit_order(&mut client, i, 1, 1, side, 100, 1);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
drop(client);
std::thread::sleep(Duration::from_millis(250));
let primary_journal = cluster._tmp.path().join("primary.journal");
let replica_journal = cluster._tmp.path().join("replica.journal");
let walk = |label: &str, path: &Path| -> u64 {
let mut reader = JournalReader::<melin_trading::trading_event::TradingEvent>::open(path)
.unwrap_or_else(|e| panic!("{label}: open {}: {e}", path.display()));
let mut count = 0u64;
loop {
match reader.next_entry() {
Ok(Some(_)) => count += 1,
Ok(None) => break,
Err(e) => panic!(
"{label}: read error after {count} user entries \
(last_sequence = {:?}): {e}",
reader.last_sequence()
),
}
}
count
};
let primary_count = walk("primary", &primary_journal);
let replica_count = walk("replica", &replica_journal);
assert!(
primary_count >= ORDERS,
"primary journal recovered {primary_count} entries, expected >= {ORDERS}"
);
assert!(
replica_count >= ORDERS,
"replica journal recovered {replica_count} entries, expected >= {ORDERS}"
);
}
#[test]
#[serial]
fn same_key_request_seq_hwm_survives_failover() {
let mut cluster = TestCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
}
cluster.wait_replicated();
drop(client);
let promote_addr: SocketAddr = format!("127.0.0.1:{}", cluster.admin_port).parse().unwrap();
unsafe {
libc::kill(cluster.primary.child.id() as i32, libc::SIGKILL);
}
let _ = cluster.primary.child.wait();
promote(promote_addr, &cluster.operator_key);
set_durability_mode(promote_addr, &cluster.operator_key, "local");
wait_ready(cluster.replica.health_addr, Duration::from_secs(30));
let mut client_retry = connect_with_timeout(cluster.replica.client_addr, &cluster.key);
let hwm = client_retry
.synchronize_request_seq()
.expect("query request_seq HWM on promoted replica");
assert_eq!(
hwm, 10,
"promoted replica lost per-key request_seq HWM: got {hwm}, expected 10"
);
}
struct DualCluster {
primary: ServerProcess,
primary_repl_port: u16,
replica1: ServerProcess,
replica2: ServerProcess,
replica1_admin_port: u16,
replica2_admin_port: u16,
key: SigningKey,
key2: SigningKey,
operator_key: SigningKey,
repl_key_path: PathBuf,
_tmp: tempfile::TempDir,
}
impl DualCluster {
fn start() -> Self {
Self::start_with_args(&[], &[])
}
fn start_with_primary_args(primary_extra_args: &[&str]) -> Self {
Self::start_with_args(primary_extra_args, &[])
}
fn start_with_args(primary_extra_args: &[&str], replica_extra_args: &[&str]) -> Self {
let bin = server_bin();
assert!(bin.exists(), "melin-server binary not found");
let tmp = tempfile::tempdir().expect("create temp dir");
let key = SigningKey::from_bytes(&[0xFA; 32]);
let key2 = SigningKey::from_bytes(&[0xFB; 32]);
let operator_key = SigningKey::from_bytes(&[0xFD; 32]);
let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
let (keys_path, repl_key_path) =
write_auth_keys_multi(tmp.path(), &[&key, &key2], &operator_key, &repl_key);
let primary_client_port = free_port();
let primary_health_port = free_port();
let primary_repl_port = free_port();
let r1_client = free_port();
let r1_health = free_port();
let r1_promote = free_port();
let r2_client = free_port();
let r2_health = free_port();
let r2_promote = free_port();
let primary = spawn_primary_with_extra(
&bin,
tmp.path(),
&keys_path,
primary_client_port,
primary_health_port,
primary_repl_port,
primary_extra_args,
);
wait_for_primary_repl_ready(primary.health_addr, Duration::from_secs(10));
let replica1 = spawn_replica_named_with_extra(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
primary_repl_port,
r1_client,
r1_health,
r1_promote,
"replica1",
replica_extra_args,
);
let replica2 = spawn_replica_named_with_extra(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
primary_repl_port,
r2_client,
r2_health,
r2_promote,
"replica2",
replica_extra_args,
);
wait_healthy(primary.health_addr, Duration::from_secs(30));
Self {
primary,
primary_repl_port,
replica1,
replica2,
replica1_admin_port: r1_promote,
replica2_admin_port: r2_promote,
key,
key2,
operator_key,
repl_key_path,
_tmp: tmp,
}
}
fn connect_primary(&self) -> Client {
connect_with_timeout(self.primary.client_addr, &self.key)
}
fn wait_replicated(&self) {
let start = Instant::now();
loop {
if let Ok((_, _, 0, _)) = query_health(self.primary.health_addr) {
return;
}
if start.elapsed() > Duration::from_secs(10) {
panic!("replication lag did not reach 0 within 10s");
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn kill_primary(&mut self) {
unsafe {
libc::kill(self.primary.child.id() as i32, libc::SIGKILL);
}
let _ = self.primary.child.wait();
}
fn kill_replica1(&mut self) {
unsafe {
libc::kill(self.replica1.child.id() as i32, libc::SIGKILL);
}
let _ = self.replica1.child.wait();
}
fn kill_replica2(&mut self) {
unsafe {
libc::kill(self.replica2.child.id() as i32, libc::SIGKILL);
}
let _ = self.replica2.child.wait();
}
fn promote_replica1(&self) -> Client {
let addr: SocketAddr = format!("127.0.0.1:{}", self.replica1_admin_port)
.parse()
.unwrap();
promote(addr, &self.operator_key);
set_durability_mode(addr, &self.operator_key, "local");
wait_ready(self.replica1.health_addr, Duration::from_secs(30));
connect_with_timeout(self.replica1.client_addr, &self.key2)
}
fn promote_replica2(&self) -> Client {
let addr: SocketAddr = format!("127.0.0.1:{}", self.replica2_admin_port)
.parse()
.unwrap();
promote(addr, &self.operator_key);
set_durability_mode(addr, &self.operator_key, "local");
wait_ready(self.replica2.health_addr, Duration::from_secs(30));
connect_with_timeout(self.replica2.client_addr, &self.key2)
}
fn primary_trading(&self) -> bool {
query_health(self.primary.health_addr)
.map(|(_, _, _, t)| t)
.unwrap_or(false)
}
}
#[test]
#[serial]
fn sec04_rate_limit_replicates_to_replica() {
let cluster = TestCluster::start_with_extra_args(&[
"--max-orders-per-second",
"1", "--max-orders-burst",
"2", ]);
let mut client = cluster.connect_primary();
for i in 1..=2u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 1);
assert!(
!r.is_empty(),
"order {i}: response gate dropped reply — replication issue?",
);
assert!(
!has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::ExceedsOrderRate,
..
}
)),
"order {i} within burst should NOT rate-reject, got: {r:?}",
);
}
let r = submit_order(&mut client, 3, 1, 1, Side::Buy, 100, 1);
assert!(!r.is_empty(), "rate-limited response gate timed out");
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::ExceedsOrderRate,
..
}
)),
"expected ExceedsOrderRate cross-receiver, got: {r:?}",
);
cluster.wait_replicated();
}
#[test]
#[serial]
fn dual_replication_survives_one_replica_failure() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=20u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
cluster.kill_replica1();
wait_ready(cluster.primary.health_addr, Duration::from_secs(5));
for i in 21..=40u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(
!r.is_empty(),
"order {i}: no response after replica 1 death"
);
}
cluster.wait_replicated();
drop(client);
cluster.kill_primary();
let mut client2 = cluster.promote_replica2();
let r = submit_order(&mut client2, 41, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed, got: {r:?}"
);
}
#[test]
#[serial]
fn dual_replication_halts_when_both_disconnect() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
}
cluster.wait_replicated();
cluster.kill_replica1();
cluster.kill_replica2();
wait_halted(cluster.primary.health_addr, Duration::from_secs(5));
assert!(
!cluster.primary_trading(),
"should be halted with no replicas"
);
let r = submit_order(&mut client, 11, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::ReplicaDisconnected,
..
}
)),
"expected ReplicaDisconnected, got: {r:?}"
);
}
#[test]
#[serial]
fn dual_replication_promote_replica1_after_replica2_dies() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=15u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
}
cluster.wait_replicated();
cluster.kill_replica2();
wait_ready(cluster.primary.health_addr, Duration::from_secs(5));
for i in 16..=30u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty());
}
cluster.wait_replicated();
drop(client);
cluster.kill_primary();
let mut client2 = cluster.promote_replica1();
let r = submit_order(&mut client2, 31, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed on promoted replica 1, got: {r:?}"
);
}
#[test]
#[serial]
fn dual_replication_with_fills_then_failover() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
submit_order(&mut client, i, 2, 1, Side::Sell, 100 + i, 5);
}
for i in 11..=20u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 200, 3);
}
cluster.wait_replicated();
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(500));
for i in 21..=25u64 {
submit_order(&mut client, i, 2, 1, Side::Sell, 300, 2);
}
for i in 26..=30u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 300, 2);
}
cluster.wait_replicated();
drop(client);
cluster.kill_primary();
let mut client2 = cluster.promote_replica2();
let r = submit_order(&mut client2, 31, 2, 1, Side::Sell, 500, 1);
let accepted = has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Placed { .. })
}) || has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Fill { .. })
});
assert!(accepted, "expected Placed or Fill, got: {r:?}");
let r = submit_order(&mut client2, 32, 1, 1, Side::Buy, 500, 1);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Fill on promoted replica, got: {r:?}"
);
}
#[test]
#[serial]
fn replacement_replica_catches_up_from_journal() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=20u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
let replica1_journal = cluster._tmp.path().join("replica1.journal");
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(500));
for i in 21..=40u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
let replacement_journal = cluster._tmp.path().join("replacement.journal");
std::fs::copy(&replica1_journal, &replacement_journal).expect("copy replica journal");
assert!(
replacement_journal.exists(),
"replacement journal must exist after copy"
);
let copy_len = std::fs::metadata(&replacement_journal)
.expect("replacement journal metadata")
.len();
assert!(copy_len > 100, "replacement journal too small: {copy_len}");
let r3_client = free_port();
let r3_health = free_port();
let r3_promote = free_port();
let bin = server_bin();
let _replacement = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{r3_client}"),
"--health-bind",
&format!("127.0.0.1:{r3_health}"),
"--replica-of",
&format!("127.0.0.1:{}", cluster.primary_repl_port),
"--replication-key",
cluster.repl_key_path.to_str().unwrap(),
"--admin-bind",
&format!("127.0.0.1:{r3_promote}"),
"--journal",
replacement_journal.to_str().expect("valid path"),
"--authorized-keys",
cluster
._tmp
.path()
.join("authorized_keys")
.to_str()
.expect("valid path"),
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn replacement replica");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{r3_client}").parse().unwrap(),
health_addr: format!("127.0.0.1:{r3_health}").parse().unwrap(),
}
};
wait_for_replacement_catchup(cluster.primary.health_addr);
eprintln!("Replacement replica caught up.");
for i in 41..=50u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response after catch-up");
}
cluster.wait_replicated();
drop(client);
cluster.kill_primary();
let promote_addr: SocketAddr = format!("127.0.0.1:{r3_promote}").parse().unwrap();
promote(promote_addr, &cluster.operator_key);
set_durability_mode(promote_addr, &cluster.operator_key, "local");
let r3_health_addr: SocketAddr = format!("127.0.0.1:{r3_health}").parse().unwrap();
wait_ready(r3_health_addr, Duration::from_secs(30));
let mut client2 = connect_with_timeout(
format!("127.0.0.1:{r3_client}").parse().unwrap(),
&cluster.key2,
);
let r = submit_order(&mut client2, 51, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed on promoted replacement, got: {r:?}"
);
let r = submit_order(&mut client2, 50, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId for id=50, got: {r:?}"
);
eprintln!("PASS: replacement replica caught up from journal and has all 50 orders.");
}
#[test]
#[serial]
fn catchup_with_fills_during_gap() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=10u64 {
submit_order(&mut client, i, 2, 1, Side::Sell, 100 + i, 5);
}
cluster.wait_replicated();
let replica1_journal = cluster._tmp.path().join("replica1.journal");
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(200));
let replacement_journal = cluster._tmp.path().join("replacement_fills.journal");
std::fs::copy(&replica1_journal, &replacement_journal).expect("copy journal");
for i in 11..=20u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 200, 3);
}
cluster.wait_replicated();
let r3_client = free_port();
let r3_health = free_port();
let r3_promote = free_port();
let bin = server_bin();
let _replacement = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{r3_client}"),
"--health-bind",
&format!("127.0.0.1:{r3_health}"),
"--replica-of",
&format!("127.0.0.1:{}", cluster.primary_repl_port),
"--replication-key",
cluster.repl_key_path.to_str().unwrap(),
"--admin-bind",
&format!("127.0.0.1:{r3_promote}"),
"--journal",
replacement_journal.to_str().unwrap(),
"--authorized-keys",
cluster
._tmp
.path()
.join("authorized_keys")
.to_str()
.unwrap(),
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn replacement");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{r3_client}").parse().unwrap(),
health_addr: format!("127.0.0.1:{r3_health}").parse().unwrap(),
}
};
wait_for_replacement_catchup(cluster.primary.health_addr);
drop(client);
cluster.kill_primary();
let promote_addr: SocketAddr = format!("127.0.0.1:{r3_promote}").parse().unwrap();
promote(promote_addr, &cluster.operator_key);
set_durability_mode(promote_addr, &cluster.operator_key, "local");
wait_ready(
format!("127.0.0.1:{r3_health}").parse().unwrap(),
Duration::from_secs(30),
);
let mut client2 = connect_with_timeout(
format!("127.0.0.1:{r3_client}").parse().unwrap(),
&cluster.key2,
);
let r = submit_order(&mut client2, 21, 2, 1, Side::Sell, 500, 1);
let accepted = has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Placed { .. })
}) || has_report(&r, |rep| {
matches!(rep, melin_protocol::types::ExecutionReport::Fill { .. })
});
assert!(accepted, "expected Placed or Fill, got: {r:?}");
let r = submit_order(&mut client2, 22, 1, 1, Side::Buy, 500, 1);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Fill after catch-up with fills, got: {r:?}"
);
eprintln!("PASS: catch-up with fills — balances correct after promotion.");
}
#[test]
#[serial]
fn catchup_then_immediate_failover() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=15u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
}
cluster.wait_replicated();
let replica1_journal = cluster._tmp.path().join("replica1.journal");
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(200));
let replacement_journal = cluster._tmp.path().join("replacement_imm.journal");
std::fs::copy(&replica1_journal, &replacement_journal).expect("copy journal");
for i in 16..=30u64 {
submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
}
cluster.wait_replicated();
let r3_client = free_port();
let r3_health = free_port();
let r3_promote = free_port();
let bin = server_bin();
let _replacement = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{r3_client}"),
"--health-bind",
&format!("127.0.0.1:{r3_health}"),
"--replica-of",
&format!("127.0.0.1:{}", cluster.primary_repl_port),
"--replication-key",
cluster.repl_key_path.to_str().unwrap(),
"--admin-bind",
&format!("127.0.0.1:{r3_promote}"),
"--journal",
replacement_journal.to_str().unwrap(),
"--authorized-keys",
cluster
._tmp
.path()
.join("authorized_keys")
.to_str()
.unwrap(),
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn replacement");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{r3_client}").parse().unwrap(),
health_addr: format!("127.0.0.1:{r3_health}").parse().unwrap(),
}
};
wait_for_replacement_catchup(cluster.primary.health_addr);
drop(client);
cluster.kill_primary();
let promote_addr: SocketAddr = format!("127.0.0.1:{r3_promote}").parse().unwrap();
promote(promote_addr, &cluster.operator_key);
set_durability_mode(promote_addr, &cluster.operator_key, "local");
wait_ready(
format!("127.0.0.1:{r3_health}").parse().unwrap(),
Duration::from_secs(30),
);
let mut client2 = connect_with_timeout(
format!("127.0.0.1:{r3_client}").parse().unwrap(),
&cluster.key2,
);
let r = submit_order(&mut client2, 31, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed, got: {r:?}"
);
let r = submit_order(&mut client2, 30, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId, got: {r:?}"
);
eprintln!("PASS: catch-up then immediate failover — all 30 orders survived.");
}
#[test]
#[serial]
fn fresh_replica_full_catchup() {
let mut cluster = DualCluster::start();
let mut client = cluster.connect_primary();
for i in 1..=25u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
cluster.wait_replicated();
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(500));
let fresh_journal = cluster._tmp.path().join("fresh_replacement.journal");
let r3_client = free_port();
let r3_health = free_port();
let r3_promote = free_port();
let bin = server_bin();
let _replacement = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{r3_client}"),
"--health-bind",
&format!("127.0.0.1:{r3_health}"),
"--replica-of",
&format!("127.0.0.1:{}", cluster.primary_repl_port),
"--replication-key",
cluster.repl_key_path.to_str().unwrap(),
"--admin-bind",
&format!("127.0.0.1:{r3_promote}"),
"--journal",
fresh_journal.to_str().unwrap(),
"--authorized-keys",
cluster
._tmp
.path()
.join("authorized_keys")
.to_str()
.unwrap(),
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn fresh replacement");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{r3_client}").parse().unwrap(),
health_addr: format!("127.0.0.1:{r3_health}").parse().unwrap(),
}
};
wait_for_replacement_catchup(cluster.primary.health_addr);
eprintln!("Fresh replica caught up.");
for i in 26..=35u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response after catch-up");
}
cluster.wait_replicated();
drop(client);
cluster.kill_primary();
let promote_addr: SocketAddr = format!("127.0.0.1:{r3_promote}").parse().unwrap();
promote(promote_addr, &cluster.operator_key);
set_durability_mode(promote_addr, &cluster.operator_key, "local");
wait_ready(
format!("127.0.0.1:{r3_health}").parse().unwrap(),
Duration::from_secs(30),
);
let mut client2 = connect_with_timeout(
format!("127.0.0.1:{r3_client}").parse().unwrap(),
&cluster.key2,
);
let r = submit_order(&mut client2, 36, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected Placed on promoted fresh replacement, got: {r:?}"
);
let r = submit_order(&mut client2, 35, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId for id=35, got: {r:?}"
);
eprintln!("PASS: fresh replica caught up from primary's journal — all 35 orders present.");
}
#[test]
#[serial]
fn snapshot_transfer_when_archives_purged() {
let bin = server_bin();
let tmp = tempfile::tempdir().unwrap();
let key = SigningKey::from_bytes(&[0xFA; 32]);
let key2 = SigningKey::from_bytes(&[0xFB; 32]);
let operator_key = SigningKey::from_bytes(&[0xFD; 32]);
let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
let (keys_path, repl_key_path) =
write_auth_keys_multi(tmp.path(), &[&key, &key2], &operator_key, &repl_key);
let primary_client_port = free_port();
let primary_health_port = free_port();
let primary_journal = tmp.path().join("primary.journal");
let mut primary = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{primary_client_port}"),
"--health-bind",
&format!("127.0.0.1:{primary_health_port}"),
"--journal",
primary_journal.to_str().unwrap(),
"--authorized-keys",
keys_path.to_str().unwrap(),
"--accounts",
"10",
"--instruments",
"2",
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
"--standalone",
"--durability-mode",
"local",
"--snapshot-interval-ms",
"100",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn primary");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{primary_client_port}").parse().unwrap(),
health_addr: format!("127.0.0.1:{primary_health_port}").parse().unwrap(),
}
};
wait_healthy(primary.health_addr, Duration::from_secs(30));
let mut client = connect_with_timeout(primary.client_addr, &key);
for i in 1..=20u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
drop(client);
let snap_path = primary_journal.with_extension("snapshot");
let _ = std::fs::remove_file(&snap_path);
let start = Instant::now();
while !snap_path.exists() {
if start.elapsed() > Duration::from_secs(60) {
panic!(
"snapshot was not created within 60s at {}",
snap_path.display()
);
}
std::thread::sleep(Duration::from_millis(20));
}
eprintln!("Snapshot created at {}", snap_path.display());
unsafe { libc::kill(primary.child.id() as i32, libc::SIGINT) };
let _ = primary.child.wait();
for i in 1..=10 {
let archive = tmp.path().join(format!("primary.journal.{i}"));
if archive.exists() {
std::fs::remove_file(&archive).unwrap();
eprintln!("Deleted archive: {}", archive.display());
}
}
std::fs::remove_file(&primary_journal).ok();
eprintln!("Deleted main journal to force snapshot-only recovery");
let primary_repl_port2 = free_port();
let primary_client_port2 = free_port();
let primary_health_port2 = free_port();
let mut primary2 = {
let child = Command::new(&bin)
.args([
"--bind",
&format!("127.0.0.1:{primary_client_port2}"),
"--health-bind",
&format!("127.0.0.1:{primary_health_port2}"),
"--replication-bind",
&format!("127.0.0.1:{primary_repl_port2}"),
"--journal",
primary_journal.to_str().unwrap(),
"--authorized-keys",
keys_path.to_str().unwrap(),
"--accounts",
"10",
"--instruments",
"2",
"--connection-timeout-secs",
"0",
"--yield-idle",
"--cores",
"0,0,0,0,0,0,0,0,0",
"--snapshot-interval-ms",
"100",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("MELIN_JOURNAL_PREALLOC_MIB", "4")
.spawn()
.expect("spawn primary2");
ServerProcess {
child,
client_addr: format!("127.0.0.1:{primary_client_port2}").parse().unwrap(),
health_addr: format!("127.0.0.1:{primary_health_port2}").parse().unwrap(),
}
};
wait_for_primary_repl_ready(primary2.health_addr, Duration::from_secs(10));
let replica_client_port = free_port();
let replica_health_port = free_port();
let replica_admin_port = free_port();
let _replica = spawn_replica(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
primary_repl_port2,
replica_client_port,
replica_health_port,
replica_admin_port,
);
wait_healthy(primary2.health_addr, Duration::from_secs(30));
eprintln!("Primary healthy with replica connected");
wait_for_replacement_catchup(primary2.health_addr);
eprintln!("Replica caught up via snapshot transfer.");
let mut client2 = connect_with_timeout(primary2.client_addr, &key2);
let r = submit_order(&mut client2, 21, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)) || has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Placed or Fill after snapshot transfer, got: {r:?}"
);
let r = submit_order(&mut client2, 19, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"expected DuplicateOrderId for id=19 after snapshot transfer, got: {r:?}"
);
let (_, tail_after_orders, _, _) =
query_health(primary2.health_addr).expect("health after post-transfer orders");
let start = Instant::now();
loop {
if let Ok((_, journal_seq, lag, _)) = query_health(primary2.health_addr)
&& lag == 0
&& journal_seq >= tail_after_orders
{
break;
}
if start.elapsed() > Duration::from_secs(30) {
panic!("replica never drained post-transfer orders (lag != 0)");
}
std::thread::sleep(Duration::from_millis(20));
}
drop(client2);
unsafe { libc::kill(primary2.child.id() as i32, libc::SIGINT) };
let _ = primary2.child.wait();
let promote_addr: SocketAddr = format!("127.0.0.1:{replica_admin_port}").parse().unwrap();
promote(promote_addr, &operator_key);
set_durability_mode(promote_addr, &operator_key, "local");
wait_ready(
format!("127.0.0.1:{replica_health_port}").parse().unwrap(),
Duration::from_secs(30),
);
let mut rclient = connect_with_timeout(
format!("127.0.0.1:{replica_client_port}").parse().unwrap(),
&key2,
);
let r = submit_order(&mut rclient, 20, 1, 1, Side::Buy, 100, 10);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"promoted replica is missing pre-snapshot state (order 20 not a duplicate): {r:?}"
);
let r = submit_order(&mut rclient, 21, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Rejected {
reason: melin_protocol::types::RejectReason::DuplicateOrderId,
..
}
)),
"promoted replica is missing live-streamed state (order 21 not a duplicate): {r:?}"
);
let r = submit_order(&mut rclient, 22, 1, 1, Side::Buy, 200, 5);
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
| melin_protocol::types::ExecutionReport::Fill { .. }
)),
"expected Placed or Fill on promoted replica, got: {r:?}"
);
drop(rclient);
eprintln!("PASS: snapshot transfer — promoted replica holds pre-snapshot and streamed state.");
}
fn submit_resting_burst(client: &mut Client, first_id: u64, n: u64) {
for i in 0..n {
client
.send_request(&Request::SubmitOrder {
symbol: Symbol(1),
order: Order {
id: OrderId(first_id + i),
account: AccountId(1),
side: Side::Buy,
order_type: OrderType::Limit {
price: price(50),
post_only: false,
},
time_in_force: TimeInForce::GTC,
quantity: qty(1),
stp: melin_protocol::types::SelfTradeProtection::Allow,
expiry_ns: 0,
},
})
.expect("submit order");
}
}
fn count_archives(journal_path: &Path) -> usize {
melin_journal::segment::list_archives(journal_path)
.map(|v| v.len())
.unwrap_or(0)
}
fn walk_segments_dense(journal_path: &Path) -> (u64, u64) {
use melin_trading::trading_event::TradingEvent;
let report = melin_journal::segment::verify_lineage::<TradingEvent>(journal_path)
.unwrap_or_else(|e| panic!("lineage of {} broken: {e}", journal_path.display()));
assert_eq!(
report.live_tail_gap,
None,
"cleanly-shut journal {} must have no live-tail gap",
journal_path.display()
);
(
report
.first_sequence
.expect("lineage should contain at least one entry"),
report
.last_sequence
.expect("lineage should contain at least one entry"),
)
}
#[test]
#[serial]
fn rotation_soak_under_load() {
let bin = server_bin();
assert!(bin.exists(), "melin-server binary not found at {bin:?}");
let tmp = tempfile::Builder::new()
.prefix("melin-soak-")
.tempdir()
.expect("create temp dir");
let key = SigningKey::from_bytes(&[0xCA; 32]);
let operator_key = SigningKey::from_bytes(&[0xCD; 32]);
let repl_key = SigningKey::from_bytes(&[0xCE; 32]);
let (keys_path, repl_key_path) =
write_auth_keys_multi(tmp.path(), &[&key], &operator_key, &repl_key);
let primary_client_port = free_port();
let primary_health_port = free_port();
let primary_repl_port = free_port();
let primary_admin_port = free_port();
let replica_client_port = free_port();
let replica_health_port = free_port();
let replica_admin_port = free_port();
let primary_admin_addr = format!("127.0.0.1:{primary_admin_port}");
let primary_extra: &[&str] = &[
"--admin-bind",
&primary_admin_addr,
"--max-journal-mib",
"0", ];
let extra_env: &[(&str, &str)] = &[];
let mut primary = spawn_primary_with_extra_env(
&bin,
tmp.path(),
&keys_path,
primary_client_port,
primary_health_port,
primary_repl_port,
primary_extra,
extra_env,
);
wait_for_primary_repl_ready(primary.health_addr, Duration::from_secs(10));
let mut replica = spawn_replica_named_with_extra_env(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
primary_repl_port,
replica_client_port,
replica_health_port,
replica_admin_port,
"replica",
&[],
extra_env,
);
wait_healthy(primary.health_addr, Duration::from_secs(30));
let mut client = connect_with_timeout(primary.client_addr, &key);
let admin_addr: SocketAddr = primary_admin_addr.parse().unwrap();
let replica_admin_addr: SocketAddr = format!("127.0.0.1:{replica_admin_port}").parse().unwrap();
let per_round: u64 = 15;
let rounds: u64 = 5;
let total_orders: u64 = per_round * rounds;
let mut next_id: u64 = 1;
for round in 0..rounds {
let resp = admin_command(admin_addr, &operator_key, "ROTATE");
assert!(resp == "OK", "primary ROTATE #{round} failed: {resp}");
if round == 1 || round == 3 {
let resp = admin_command(replica_admin_addr, &operator_key, "ROTATE");
assert!(resp == "OK", "replica ROTATE #{round} failed: {resp}");
}
submit_resting_burst(&mut client, next_id, per_round);
next_id += per_round;
}
let start = Instant::now();
loop {
let h = query_health(primary.health_addr);
if let Ok((_, _, 0, _)) = h {
break;
}
assert!(
start.elapsed() < Duration::from_secs(30),
"replication lag did not reach 0; last health = {h:?}"
);
std::thread::sleep(Duration::from_millis(50));
}
drop(client);
unsafe {
libc::kill(primary.child.id() as i32, libc::SIGINT);
libc::kill(replica.child.id() as i32, libc::SIGINT);
}
let _ = primary
.child
.wait_timeout_with_kill(Duration::from_secs(10));
let _ = replica
.child
.wait_timeout_with_kill(Duration::from_secs(10));
let primary_journal = tmp.path().join("primary.journal");
let replica_journal = tmp.path().join("replica.journal");
assert_eq!(count_archives(&primary_journal), 5, "primary archive count");
assert_eq!(count_archives(&replica_journal), 5, "replica archive count");
for n in 1..=5u32 {
let p = melin_journal::segment::archive_path(&primary_journal, n);
let r = melin_journal::segment::archive_path(&replica_journal, n);
let p_bytes = std::fs::read(&p).expect("read primary archive");
let r_bytes = std::fs::read(&r).expect("read replica archive");
assert_eq!(
p_bytes, r_bytes,
"archive {n} must be byte-identical across nodes"
);
}
{
let p = melin_journal::segment::read_header_info(&primary_journal).unwrap();
let r = melin_journal::segment::read_header_info(&replica_journal).unwrap();
assert_eq!(
p.starting_sequence, r.starting_sequence,
"live segment start"
);
assert_eq!(p.anchor_hash, r.anchor_hash, "live segment anchor");
}
let (p_first, p_last) = walk_segments_dense(&primary_journal);
let (r_first, r_last) = walk_segments_dense(&replica_journal);
assert_eq!(p_first, 1, "primary lineage must begin at sequence 1");
assert_eq!(r_first, 1, "replica lineage must begin at sequence 1");
assert!(
p_last >= total_orders,
"primary tail {p_last} must cover all {total_orders} orders"
);
assert!(
r_last >= total_orders,
"replica tail {r_last} must cover all {total_orders} orders"
);
{
use melin_journal::BufferedWriter;
use melin_server::ServerApp;
use melin_trading::trading_event::TradingEvent;
use melin_transport_core::JournaledApp;
let recovered = JournaledApp::<ServerApp, BufferedWriter<TradingEvent>>::recover(
ServerApp(melin_exchange_core::exchange::Exchange::with_capacity()),
&replica_journal,
)
.expect("replica journal must recover through the production path");
assert_eq!(
recovered.next_sequence(),
r_last + 1,
"recovered replica writer must resume after its durable tail"
);
}
let primary2_extra: Vec<&str> = primary_extra
.iter()
.copied()
.chain(["--durability-mode", "local"])
.collect();
let mut primary2 = spawn_primary_with_extra_env(
&bin,
tmp.path(),
&keys_path,
free_port(),
free_port(),
free_port(),
&primary2_extra,
extra_env,
);
wait_for_primary_repl_ready(primary2.health_addr, Duration::from_secs(30));
wait_healthy(primary2.health_addr, Duration::from_secs(30));
let mut client2 = connect_with_timeout(primary2.client_addr, &key);
submit_resting_burst(&mut client2, total_orders + 1, 1);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Ok((_, _, 0, _)) = query_health(primary2.health_addr) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
drop(client2);
unsafe { libc::kill(primary2.child.id() as i32, libc::SIGINT) };
let _ = primary2
.child
.wait_timeout_with_kill(Duration::from_secs(10));
use melin_journal::JournalReader;
let mut reader =
JournalReader::<melin_trading::trading_event::TradingEvent>::open(&primary_journal)
.expect("reopen primary live segment");
while reader.next_entry().expect("scan live").is_some() {}
let post_disk_seq = reader.last_sequence().unwrap_or(0);
assert!(
post_disk_seq > p_last,
"post-restart live tail seq ({post_disk_seq}) must exceed the pre-restart on-disk \
tail ({p_last}) — indicates multi-segment recovery reseeded the writer at the \
right place"
);
}
#[test]
#[serial]
fn policy_degraded_gauge_transitions_with_cluster_shape() {
let mut cluster = DualCluster::start();
let primary_health = cluster.primary.health_addr;
wait_for_policy_degraded(primary_health, 0, Duration::from_secs(5));
cluster.kill_replica1();
std::thread::sleep(Duration::from_millis(2500));
let after_one_kill = fetch_policy_degraded(primary_health);
assert_eq!(
after_one_kill,
Some(0),
"with 1 replica down (2 nodes connected) the default policy should not be degraded; gauge = {after_one_kill:?}"
);
cluster.kill_replica2();
wait_for_policy_degraded(primary_health, 1, Duration::from_secs(5));
}
#[test]
#[serial]
fn in_memory_cursor_runs_ahead_of_persisted_under_sustained_traffic() {
let cluster = DualCluster::start_with_primary_args(&["--durability-mode", "hybrid"]);
let primary_health = cluster.primary.health_addr;
let mut client = cluster.connect_primary();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = std::sync::Arc::clone(&stop);
let sampler = std::thread::spawn(move || {
let mut saw_in_mem_ahead: usize = 0;
let mut saw_in_mem_nonzero: bool = false;
let mut inversion_seen: Option<(usize, u64, u64)> = None;
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
if let Some(cursors) = fetch_replica_cursors(primary_health) {
for (slot, (in_mem, acked)) in cursors.iter().enumerate() {
if *in_mem > 0 {
saw_in_mem_nonzero = true;
}
if *in_mem < *acked && inversion_seen.is_none() {
inversion_seen = Some((slot, *in_mem, *acked));
}
if *acked > 0 && *in_mem > *acked {
saw_in_mem_ahead += 1;
}
}
}
}
(saw_in_mem_ahead, saw_in_mem_nonzero, inversion_seen)
});
for i in 1..=200u64 {
let r = submit_order(&mut client, i, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "order {i}: no response");
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let (saw_in_mem_ahead, saw_in_mem_nonzero, inversion_seen) =
sampler.join().expect("sampler thread panicked");
cluster.wait_replicated();
assert!(
saw_in_mem_nonzero,
"melin_replica_in_memory_sequence never advanced past 0 — metric not plumbed?"
);
assert!(
inversion_seen.is_none(),
"in_memory_sequence < acked_sequence observed: {inversion_seen:?} — namespace bug?",
);
let _ = saw_in_mem_ahead;
}
#[test]
#[serial]
fn hybrid_gate_stalls_while_replica_frozen() {
let cluster = TestCluster::start();
let mut client = cluster.connect_primary();
let r = submit_order(&mut client, 1, 1, 1, Side::Buy, 100, 10);
assert!(!r.is_empty(), "warm-up order got no response");
cluster.wait_replicated();
unsafe {
libc::kill(cluster.replica.child.id() as i32, libc::SIGSTOP);
}
let (tx, rx) = std::sync::mpsc::channel();
let submitter = std::thread::spawn(move || {
let r = submit_order(&mut client, 2, 1, 1, Side::Buy, 101, 10);
let _ = tx.send(r);
});
match rx.recv_timeout(Duration::from_millis(1500)) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Ok(r) => panic!(
"durability gate released a client ack while the only replica \
was frozen (hybrid requires in_memory>=2): {r:?}"
),
Err(e) => panic!("submitter channel closed unexpectedly: {e}"),
}
unsafe {
libc::kill(cluster.replica.child.id() as i32, libc::SIGCONT);
}
let r = rx
.recv_timeout(Duration::from_secs(30))
.expect("no response after replica thawed — gate stuck");
assert!(
has_report(&r, |rep| matches!(
rep,
melin_protocol::types::ExecutionReport::Placed { .. }
)),
"expected a normal Placed ack after thaw, got: {r:?}"
);
submitter.join().expect("submitter thread panicked");
}
#[test]
#[serial]
fn evicted_replica_catchup_under_load_preserves_dense_lineage() {
let mut cluster = DualCluster::start_with_primary_args(&["--replication-ring-size", "16"]);
let primary_health = cluster.primary.health_addr;
wait_metric(
primary_health,
"melin_replicas_connected ",
Duration::from_secs(30),
"both replicas connected at startup",
|v| v == 2,
);
let stop = AtomicBool::new(false);
let submitted = [AtomicU64::new(0), AtomicU64::new(0)];
struct StopOnDrop<'a>(&'a AtomicBool);
impl Drop for StopOnDrop<'_> {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
std::thread::scope(|s| {
let _stop_guard = StopOnDrop(&stop);
for (idx, key) in [&cluster.key, &cluster.key2].into_iter().enumerate() {
let stop = &stop;
let counter = &submitted[idx];
let addr = cluster.primary.client_addr;
let key = key.clone();
s.spawn(move || {
let mut client = connect_with_timeout(addr, &key);
let mut id = 1_000_000u64 * (idx as u64 + 1);
while !stop.load(Ordering::Relaxed) {
let r = client.send_request(&Request::SubmitOrder {
symbol: Symbol(1),
order: Order {
id: OrderId(id),
account: AccountId(idx as u32 + 1),
side: Side::Buy,
order_type: OrderType::Limit {
price: price(50),
post_only: false,
},
time_in_force: TimeInForce::GTC,
quantity: qty(1),
stp: melin_protocol::types::SelfTradeProtection::Allow,
expiry_ns: 0,
},
});
if r.is_err() {
break;
}
id += 1;
counter.fetch_add(1, Ordering::Relaxed);
}
});
}
let warm_start = Instant::now();
while submitted[0].load(Ordering::Relaxed) + submitted[1].load(Ordering::Relaxed) < 200 {
assert!(
warm_start.elapsed() < Duration::from_secs(30),
"submitters made no progress against the fresh cluster"
);
std::thread::sleep(Duration::from_millis(20));
}
const CYCLES: u32 = 4;
for cycle in 0..CYCLES {
wait_metric(
primary_health,
"melin_replicas_connected ",
Duration::from_secs(60),
&format!("cycle {cycle}: cluster whole before freeze"),
|v| v == 2,
);
unsafe {
libc::kill(cluster.replica2.child.id() as i32, libc::SIGSTOP);
}
{
let deadline = Instant::now() + Duration::from_secs(300);
let mut last_count =
submitted[0].load(Ordering::Relaxed) + submitted[1].load(Ordering::Relaxed);
let mut last_progress = Instant::now();
loop {
if let Some(v) = fetch_metric_u64(primary_health, "melin_replicas_connected ")
&& v < 2
{
break;
}
let now_count =
submitted[0].load(Ordering::Relaxed) + submitted[1].load(Ordering::Relaxed);
if now_count > last_count {
last_count = now_count;
last_progress = Instant::now();
}
assert!(
last_progress.elapsed() < Duration::from_secs(30),
"cycle {cycle}: submitters stalled (stuck at {last_count} orders) \
with no eviction — the cluster wedged or both submitters \
errored out before ring backpressure could evict the frozen replica"
);
assert!(
Instant::now() < deadline,
"cycle {cycle}: no eviction under ring backpressure after 300s \
({last_count} orders submitted)"
);
std::thread::sleep(Duration::from_millis(100));
}
}
unsafe {
libc::kill(cluster.replica2.child.id() as i32, libc::SIGCONT);
}
wait_metric(
primary_health,
"melin_replicas_connected ",
Duration::from_secs(60),
&format!("cycle {cycle}: evicted replica reconnected"),
|v| v == 2,
);
for slot_line in [
"melin_replica_catching_up{slot=\"0\"} ",
"melin_replica_catching_up{slot=\"1\"} ",
] {
wait_metric(
primary_health,
slot_line,
Duration::from_secs(60),
&format!("cycle {cycle}: catch-up complete"),
|v| v == 0,
);
}
}
});
let total_submitted =
submitted[0].load(Ordering::Relaxed) + submitted[1].load(Ordering::Relaxed);
wait_metric(
primary_health,
"melin_replicas_connected ",
Duration::from_secs(60),
"cluster whole after load stopped",
|v| v == 2,
);
for slot_line in [
"melin_replica_catching_up{slot=\"0\"} ",
"melin_replica_catching_up{slot=\"1\"} ",
] {
wait_metric(
primary_health,
slot_line,
Duration::from_secs(60),
"final catch-up complete",
|v| v == 0,
);
}
let start = Instant::now();
loop {
if let Ok((_, _, 0, _)) = query_health(primary_health) {
break;
}
assert!(
start.elapsed() < Duration::from_secs(30),
"replication lag did not reach 0 after load stopped"
);
std::thread::sleep(Duration::from_millis(50));
}
unsafe {
libc::kill(cluster.primary.child.id() as i32, libc::SIGINT);
libc::kill(cluster.replica1.child.id() as i32, libc::SIGINT);
libc::kill(cluster.replica2.child.id() as i32, libc::SIGINT);
}
let _ = cluster
.primary
.child
.wait_timeout_with_kill(Duration::from_secs(10));
let _ = cluster
.replica1
.child
.wait_timeout_with_kill(Duration::from_secs(10));
let _ = cluster
.replica2
.child
.wait_timeout_with_kill(Duration::from_secs(10));
let dir = cluster._tmp.path();
let (r2_first, r2_last) = walk_segments_dense(&dir.join("replica2.journal"));
assert_eq!(r2_first, 1, "replica2 lineage must begin at sequence 1");
assert!(
r2_last >= total_submitted,
"replica2 tail {r2_last} must cover all {total_submitted} acked orders"
);
let (p_first, _) = walk_segments_dense(&dir.join("primary.journal"));
let (r1_first, _) = walk_segments_dense(&dir.join("replica1.journal"));
assert_eq!(p_first, 1, "primary lineage must begin at sequence 1");
assert_eq!(r1_first, 1, "replica1 lineage must begin at sequence 1");
}
trait ChildExt {
fn wait_timeout_with_kill(&mut self, timeout: Duration) -> std::io::Result<()>;
}
impl ChildExt for std::process::Child {
fn wait_timeout_with_kill(&mut self, timeout: Duration) -> std::io::Result<()> {
let start = Instant::now();
loop {
match self.try_wait()? {
Some(_) => return Ok(()),
None => {
if start.elapsed() > timeout {
let _ = self.kill();
let _ = self.wait();
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
}
}
#[test]
#[serial]
fn higher_epoch_handshake_fences_stale_primary() {
let bin = server_bin();
assert!(bin.exists(), "melin-server binary not found");
let tmp = tempfile::tempdir().expect("create temp dir");
let key = SigningKey::from_bytes(&[0xFA; 32]);
let operator_key = SigningKey::from_bytes(&[0xFD; 32]);
let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
let (keys_path, repl_key_path) =
write_auth_keys_multi(tmp.path(), &[&key], &operator_key, &repl_key);
let p_client = free_port();
let p_health = free_port();
let p_repl = free_port();
let keep_client = free_port();
let keep_health = free_port();
let keep_admin = free_port();
let prom_client = free_port();
let prom_health = free_port();
let prom_admin = free_port();
let mut primary = spawn_primary_with_extra(
&bin,
tmp.path(),
&keys_path,
p_client,
p_health,
p_repl,
&[],
);
wait_for_primary_repl_ready(primary.health_addr, Duration::from_secs(10));
let _keep = spawn_replica_named(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
p_repl,
keep_client,
keep_health,
keep_admin,
"fence-keep",
);
let prom = spawn_replica_named(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
p_repl,
prom_client,
prom_health,
prom_admin,
"fence-prom",
);
wait_healthy(primary.health_addr, Duration::from_secs(30));
let prom_admin_addr: SocketAddr = format!("127.0.0.1:{prom_admin}").parse().unwrap();
promote(prom_admin_addr, &operator_key);
set_durability_mode(prom_admin_addr, &operator_key, "local");
wait_ready(prom.health_addr, Duration::from_secs(30));
{
let mut pc =
connect_with_timeout(format!("127.0.0.1:{prom_client}").parse().unwrap(), &key);
let resp = submit_order(&mut pc, 1, 0, 0, Side::Buy, 100, 1);
assert!(
!resp.is_empty(),
"order to the promoted node should be acked"
);
}
drop(prom);
let (_, _, _, trading_before) =
query_health(primary.health_addr).expect("primary health before fence");
assert!(
trading_before,
"primary should still be trading before the higher-epoch node connects"
);
let ghost_client = free_port();
let ghost_health = free_port();
let ghost_admin = free_port();
let _ghost = spawn_replica_named(
&bin,
tmp.path(),
&keys_path,
&repl_key_path,
p_repl,
ghost_client,
ghost_health,
ghost_admin,
"fence-prom",
);
let start = Instant::now();
let mut fenced = false;
while start.elapsed() < Duration::from_secs(20) {
if matches!(primary.child.try_wait(), Ok(Some(_))) {
fenced = true;
break;
}
if let Ok((_, _, _, trading)) = query_health(primary.health_addr)
&& !trading
{
fenced = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(
fenced,
"stale primary was not fenced by the higher-epoch handshake"
);
}