use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use base64::Engine as _;
use clap::Parser;
use ed25519_dalek::SigningKey;
use melin_app::auth::AuthorizedKeys;
use melin_app::unix_epoch_nanos;
use melin_journal::JournalEvent;
#[allow(unused_imports)] use melin_journal::JournalWrite;
use melin_server::exchange_app::ServerApp;
use melin_server_runtime::durability_policy::DurabilityMode;
use melin_server_runtime::replication::{
ReplicaControlPlane, ReplicationListener, ReplicationMetrics, Sender, run_receiver, run_sender,
};
use melin_server_runtime::server::PipelineCores;
use melin_trading::trading_event::TradingEvent;
type InputSlot = melin_transport_core::pipeline::InputSlot<TradingEvent>;
type OutputSlot = melin_transport_core::pipeline::OutputSlot<
melin_types::types::ExecutionReport,
melin_types::types::QueryResponse,
>;
use melin_transport_core::JournaledApp;
use melin_transport_core::pipeline::build_pipeline_with_replication;
use melin_transport_core::trace::mono_trace_ns;
use melin_types::types::{AccountId, CurrencyId};
#[derive(Parser)]
struct Args {
#[arg(long)]
no_busy_spin: bool,
#[arg(long, default_value_t = melin_transport_core::ReplicaSlotCursors::SLOTS)]
replicas: usize,
#[arg(long, value_enum, default_value_t = DurabilityMode::Hybrid)]
durability: DurabilityMode,
#[arg(long, value_delimiter = ',')]
cores: Option<Vec<usize>>,
#[arg(long, value_delimiter = ',')]
replica_cores: Option<Vec<usize>>,
}
const CORES_PER_REPLICA: usize = 5;
const PRIMARY_CORE_SLOTS: usize = 7;
#[derive(Debug, Clone, Copy, Default)]
struct PrimaryCores {
generator: usize,
journal: usize,
matching: usize,
drain: usize,
repl_sender: usize,
handler_0: usize,
handler_1: usize,
journal_disk: usize,
}
fn pin(label: &str, core: usize) {
if core == 0 {
return;
}
if let Err(e) = melin_app::affinity::pin_to_core(core) {
eprintln!("warning: could not pin {label} to core {core}: {e}");
}
}
fn resolve_cores(
cores: Option<&Vec<usize>>,
replica_cores: Option<&Vec<usize>>,
n_replicas: usize,
) -> Result<(PrimaryCores, Vec<usize>), String> {
let primary = match cores {
None => PrimaryCores::default(),
Some(v) if v.len() == PRIMARY_CORE_SLOTS || v.len() == PRIMARY_CORE_SLOTS + 1 => {
PrimaryCores {
generator: v[0],
journal: v[1],
matching: v[2],
drain: v[3],
repl_sender: v[4],
handler_0: v[5],
handler_1: v[6],
journal_disk: v.get(PRIMARY_CORE_SLOTS).copied().unwrap_or(0),
}
}
Some(v) => {
return Err(format!(
"--cores expects {PRIMARY_CORE_SLOTS} or {} comma-separated IDs \
(generator,journal,matching,drain,repl-sender,handler-0,handler-1, \
then optionally journal-disk), got {}",
PRIMARY_CORE_SLOTS + 1,
v.len()
));
}
};
let bases = match replica_cores {
None => vec![0; n_replicas],
Some(v) if v.len() == n_replicas => v.clone(),
Some(v) => {
return Err(format!(
"--replica-cores expects one base core per replica ({n_replicas}), got {}",
v.len()
));
}
};
let mut claimed: Vec<(usize, String)> = vec![
(primary.generator, "generator".to_string()),
(primary.journal, "journal".to_string()),
(primary.matching, "matching".to_string()),
(primary.drain, "drain".to_string()),
(primary.repl_sender, "repl-sender".to_string()),
(primary.handler_0, "handler-0".to_string()),
(primary.handler_1, "handler-1".to_string()),
(primary.journal_disk, "journal-disk".to_string()),
];
for (i, base) in bases.iter().enumerate() {
if *base == 0 {
continue;
}
for (offset, role) in ["journal", "matching", "drain", "receiver", "journal-disk"]
.iter()
.enumerate()
{
claimed.push((base + offset, format!("replica-{i} {role}")));
}
}
claimed.retain(|(core, _)| *core != 0);
for i in 0..claimed.len() {
for j in (i + 1)..claimed.len() {
if claimed[i].0 == claimed[j].0 {
return Err(format!(
"core {} claimed by both {} and {} — two pinned spinners on one core \
starve each other",
claimed[i].0, claimed[i].1, claimed[j].1
));
}
}
}
Ok((primary, bases))
}
const PRIMARY_REPL_ADDR: &str = "127.0.0.1:39877";
const RUN_SECS: u64 = 10;
const MAX_JOURNAL_BATCH: usize = 4096;
const REPLICATION_RING_SIZE: usize = 4096;
const BATCH_SIZE: usize = 32;
const HEARTBEAT_SECS: u64 = 5;
fn main() {
let args = Args::parse();
let busy_spin = !args.no_busy_spin;
let durability = args.durability;
let n_replicas = args.replicas;
if n_replicas == 0 || n_replicas > melin_transport_core::ReplicaSlotCursors::SLOTS {
eprintln!(
"FATAL: --replicas must be 1..={}",
melin_transport_core::ReplicaSlotCursors::SLOTS
);
std::process::exit(2);
}
let (primary_cores, replica_bases) =
match resolve_cores(args.cores.as_ref(), args.replica_cores.as_ref(), n_replicas) {
Ok(resolved) => resolved,
Err(e) => {
eprintln!("FATAL: {e}");
std::process::exit(2);
}
};
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.try_init();
eprintln!("replication-bench: setting up (busy_spin={})", busy_spin);
if args.cores.is_none() && args.replica_cores.is_none() {
eprintln!(
"warning: no --cores/--replica-cores; every thread is unpinned. On a host with \
isolated cores they will all share the one core this process started on, and the \
figure below is that core's contention rather than replication throughput."
);
} else {
eprintln!(
" primary: generator={} journal={} matching={} drain={} repl-sender={} \
handlers={},{} journal-disk={}",
primary_cores.generator,
primary_cores.journal,
primary_cores.matching,
primary_cores.drain,
primary_cores.repl_sender,
primary_cores.handler_0,
primary_cores.handler_1,
primary_cores.journal_disk,
);
for (i, base) in replica_bases.iter().enumerate() {
if *base == 0 {
eprintln!(" replica-{i}: unpinned");
} else {
eprintln!(
" replica-{i}: cores {}-{}",
base,
base + CORES_PER_REPLICA - 1
);
}
}
}
pin("generator", primary_cores.generator);
let replica_keys: Vec<SigningKey> = (0..n_replicas)
.map(|i| SigningKey::from_bytes(&[0x42u8 + i as u8; 32]))
.collect();
let mut auth_text = String::new();
for (i, key) in replica_keys.iter().enumerate() {
let pub_b64 =
base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
auth_text.push_str(&format!("replication {pub_b64} bench-replica-{i}\n"));
}
let authorized_keys =
Arc::new(AuthorizedKeys::parse(&auth_text).expect("parse authorized_keys"));
let tmp_root: PathBuf =
std::env::temp_dir().join(format!("melin-replication-bench-{}", std::process::id()));
std::fs::create_dir_all(&tmp_root).expect("mkdir tempdir");
let primary_journal: PathBuf = tmp_root.join("primary.journal");
let engine = JournaledApp::<ServerApp, melin_journal::BufferedWriter<_>>::create(
ServerApp(melin_exchange_core::exchange::Exchange::with_capacity()),
&primary_journal,
)
.expect("create primary journal");
let (exchange, writer) = engine.into_parts();
let active_connections = Arc::new(AtomicU64::new(0));
let primary_fence = Arc::new(melin_transport_core::fence::FenceState::new(0));
let pipeline = build_pipeline_with_replication(
exchange,
writer,
Duration::ZERO,
Arc::clone(&active_connections),
true, MAX_JOURNAL_BATCH,
REPLICATION_RING_SIZE,
busy_spin,
false, false, Arc::clone(&primary_fence),
);
let mut input_producer = pipeline.input_producer;
let mut journal_stage = pipeline.journal_stage;
journal_stage.set_disk_core(primary_cores.journal_disk);
let matching_stage = pipeline.matching_stage;
let mut output_consumers = pipeline.output_consumers;
let replica_slots = pipeline.cursors.replica_slot_cursors();
let (repl_consumer_1, repl_consumer_2) =
pipeline.replication_consumers.expect("replication enabled");
let replication_ring_progress = pipeline
.replication_ring_progress
.expect("replication enabled");
let output_consumer_0 = output_consumers.remove(0);
let shutdown = Arc::new(AtomicBool::new(false));
let repl_shutdown = Arc::new(AtomicBool::new(false));
let s = Arc::clone(&shutdown);
let journal_core = primary_cores.journal;
let journal_handle = std::thread::Builder::new()
.name("bench-journal".into())
.spawn(move || {
pin("journal", journal_core);
let _ = journal_stage.run(&s);
})
.expect("spawn journal");
let s = Arc::clone(&shutdown);
let matching_core = primary_cores.matching;
let matching_handle = std::thread::Builder::new()
.name("bench-matching".into())
.spawn(move || {
pin("matching", matching_core);
matching_stage.run(&s)
})
.expect("spawn matching");
let s = Arc::clone(&shutdown);
let drain_core = primary_cores.drain;
let drain_handle = std::thread::Builder::new()
.name("bench-drain".into())
.spawn(move || {
pin("drain", drain_core);
let mut consumer = output_consumer_0;
let mut batch = vec![OutputSlot::default(); 256];
loop {
if s.load(Ordering::Relaxed) {
return;
}
let n = consumer.consume_batch(&mut batch, 256);
if n == 0 {
if busy_spin {
std::hint::spin_loop();
} else {
std::thread::yield_now();
}
}
}
})
.expect("spawn drain");
let bind_addr: std::net::SocketAddr = PRIMARY_REPL_ADDR.parse().expect("parse repl addr");
let listener = ReplicationListener::new(
std::net::TcpListener::bind(bind_addr).expect("bind replication listener"),
)
.expect("set replication listener non-blocking");
let metrics = Arc::new(ReplicationMetrics::default());
let ready_flag = Arc::new(AtomicBool::new(false));
let connected_counter = Arc::new(AtomicU32::new(0));
let durability_mode = Arc::new(std::sync::atomic::AtomicU8::new(durability.as_u8()));
let sender_config = Sender {
listener,
repl_consumer_1,
repl_consumer_2,
replica_slots: Arc::clone(&replica_slots),
durability_mode: Arc::clone(&durability_mode),
journal_path: primary_journal.clone(),
authorized_keys: Arc::clone(&authorized_keys),
evict_flags: replication_ring_progress.evict_flags.clone(),
active_flags: replication_ring_progress.active_flags.clone(),
metrics: Arc::clone(&metrics),
handler_cores: [primary_cores.handler_0, primary_cores.handler_1],
batch_size: BATCH_SIZE,
heartbeat_secs: HEARTBEAT_SECS,
busy_spin,
fence_state: Arc::clone(&primary_fence),
};
let s = Arc::clone(&repl_shutdown);
let r = Arc::clone(&ready_flag);
let c = Arc::clone(&connected_counter);
let sender_core = primary_cores.repl_sender;
let sender_handle = std::thread::Builder::new()
.name("bench-repl-sender".into())
.spawn(move || {
pin("repl-sender", sender_core);
run_sender::<ServerApp>(sender_config, &s, &r, &c)
})
.expect("spawn run_sender");
let mut receiver_handles = Vec::with_capacity(n_replicas);
for (i, replica_key) in replica_keys.into_iter().enumerate() {
let base = replica_bases[i];
let replica_core = |offset: usize| if base == 0 { 0 } else { base + offset };
let cores = PipelineCores {
journal: replica_core(0),
matching: replica_core(1),
response: replica_core(2),
reader: replica_core(3),
repl_sender: 0,
event_publisher: 0,
shadow: 0,
repl_handler_0: 0,
repl_handler_1: 0,
journal_prep: 0,
journal_disk: replica_core(4),
};
let replica_journal: PathBuf = tmp_root.join(format!("replica-{i}.journal"));
let replica_snapshot: PathBuf = tmp_root.join(format!("replica-{i}.snapshot"));
let s = Arc::clone(&repl_shutdown);
let control = ReplicaControlPlane::new();
let replica_fence = Arc::new(melin_transport_core::fence::FenceState::new(0));
let handle = std::thread::Builder::new()
.name(format!("bench-repl-receiver-{i}"))
.spawn(move || {
let _ = run_receiver::<ServerApp>(
bind_addr,
&replica_journal,
&replica_key,
&s,
&control,
3_000_000, replica_snapshot,
cores,
std::time::Duration::ZERO,
8, busy_spin,
std::sync::Arc::new(melin_server::app_factory::Factory::new(
melin_server::app_factory::FactoryConfig {
accounts: 0,
instruments: 0,
max_orders_per_account: 10_000,
max_orders_per_second: 0,
max_orders_burst: 0,
},
)),
replica_fence,
);
})
.expect("spawn run_receiver");
receiver_handles.push(handle);
}
let connect_deadline = Instant::now() + Duration::from_secs(10);
while (connected_counter.load(Ordering::Acquire) as usize) < n_replicas {
if Instant::now() > connect_deadline {
eprintln!(
"FATAL: only {}/{n_replicas} replicas connected within 10s",
connected_counter.load(Ordering::Acquire)
);
shutdown.store(true, Ordering::Release);
repl_shutdown.store(true, Ordering::Release);
std::process::exit(1);
}
std::thread::sleep(Duration::from_millis(50));
}
eprintln!("{n_replicas} replica(s) connected, durability={durability:?}");
input_producer.publish(InputSlot {
connection_id: 0,
key_hash: 0,
request_seq: 0,
sequence: 0,
timestamp_ns: unix_epoch_nanos(),
event: JournalEvent::App(TradingEvent::ProvisionAccount {
account: AccountId(1),
amount: u64::MAX / 2,
}),
publish_ts: mono_trace_ns(),
recv_ts: mono_trace_ns(),
});
eprintln!("generator running for {RUN_SECS}s...");
let quorum = || replica_slots.quorum_acked().map(|s| s.get()).unwrap_or(0);
let fastest = || replica_slots.fastest_acked().map(|s| s.get()).unwrap_or(0);
let bench_start = Instant::now();
let deadline = bench_start + Duration::from_secs(RUN_SECS);
let mut prev_repl_cursor = quorum();
let mut prev_t = bench_start;
let mut total_published: u64 = 0;
let report_every = Duration::from_secs(1);
let mut next_report = bench_start + report_every;
'outer: while Instant::now() < deadline {
let lead_cap = (BATCH_SIZE * REPLICATION_RING_SIZE / 2) as u64;
let cur = quorum();
if (connected_counter.load(Ordering::Acquire) as usize) < n_replicas {
eprintln!("WARN: a replica disconnected mid-run — stopping");
break 'outer;
}
if total_published > cur + lead_cap {
std::thread::sleep(Duration::from_micros(50));
} else {
for _ in 0..1024 {
input_producer.publish(InputSlot {
connection_id: 0,
key_hash: 0,
request_seq: 0,
sequence: 0,
timestamp_ns: unix_epoch_nanos(),
event: JournalEvent::App(TradingEvent::Deposit {
account: AccountId(1),
currency: CurrencyId(1),
amount: 1,
}),
publish_ts: mono_trace_ns(),
recv_ts: mono_trace_ns(),
});
total_published += 1;
}
}
let now = Instant::now();
if now >= next_report {
let cur = quorum();
let lead = fastest();
let dt = (now - prev_t).as_secs_f64();
let dseq = cur.saturating_sub(prev_repl_cursor);
eprintln!(
" [{:>5.1}s] published {:>10} quorum {:>10} delta {:>9} ({:>7.0} ev/s) spread {:>8}",
bench_start.elapsed().as_secs_f64(),
total_published,
cur,
dseq,
dseq as f64 / dt,
lead.saturating_sub(cur),
);
prev_repl_cursor = cur;
prev_t = now;
next_report = now + report_every;
}
}
let total_wall = bench_start.elapsed().as_secs_f64();
let final_cur = quorum();
let final_lead = fastest();
eprintln!();
eprintln!("final ({total_wall:.2}s wall, {n_replicas} replica(s), durability={durability:?}):");
eprintln!(" total events published: {total_published}");
eprintln!(" quorum acked: {final_cur}");
eprintln!(" fastest replica acked: {final_lead}");
eprintln!(
" slowest-replica lag: {}",
final_lead.saturating_sub(final_cur)
);
eprintln!(
" sustained throughput: {:.0} ev/s",
final_cur as f64 / total_wall
);
repl_shutdown.store(true, Ordering::Release);
let _ = sender_handle.join();
for handle in receiver_handles {
let _ = handle.join();
}
shutdown.store(true, Ordering::Release);
let _ = journal_handle.join();
let _ = matching_handle.join();
let _ = drain_handle.join();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omitted_flags_leave_everything_unpinned() {
let (primary, bases) = resolve_cores(None, None, 2).unwrap();
assert_eq!(primary.generator, 0);
assert_eq!(primary.handler_1, 0);
assert_eq!(bases, vec![0, 0]);
}
#[test]
fn primary_cores_map_in_documented_order() {
let cores = vec![1, 2, 3, 4, 5, 6, 7];
let (primary, _) = resolve_cores(Some(&cores), None, 2).unwrap();
assert_eq!(primary.generator, 1);
assert_eq!(primary.journal, 2);
assert_eq!(primary.matching, 3);
assert_eq!(primary.drain, 4);
assert_eq!(primary.repl_sender, 5);
assert_eq!(primary.handler_0, 6);
assert_eq!(primary.handler_1, 7);
assert_eq!(primary.journal_disk, 0, "omitted journal-disk = unpinned");
}
#[test]
fn optional_eighth_entry_pins_the_journal_disk_thread() {
let cores = vec![1, 2, 3, 4, 5, 6, 7, 8];
let (primary, _) = resolve_cores(Some(&cores), None, 2).unwrap();
assert_eq!(primary.journal_disk, 8);
}
#[test]
fn journal_disk_colliding_with_a_primary_core_is_rejected() {
let cores = vec![1, 2, 3, 4, 5, 6, 7, 2];
let err = resolve_cores(Some(&cores), None, 2).unwrap_err();
assert!(err.contains("core 2"), "{err}");
assert!(err.contains("journal-disk"), "{err}");
}
#[test]
fn replica_span_covers_five_cores() {
let bases = vec![9, 13];
let err = resolve_cores(None, Some(&bases), 2).unwrap_err();
assert!(err.contains("core 13"), "{err}");
assert!(err.contains("journal-disk"), "{err}");
}
#[test]
fn wrong_primary_core_count_is_rejected() {
let cores = vec![1, 2, 3];
let err = resolve_cores(Some(&cores), None, 2).unwrap_err();
assert!(err.contains("--cores expects 7"), "{err}");
}
#[test]
fn replica_core_count_must_match_replica_count() {
let bases = vec![8];
let err = resolve_cores(None, Some(&bases), 2).unwrap_err();
assert!(err.contains("one base core per replica (2)"), "{err}");
}
#[test]
fn duplicate_primary_cores_are_rejected() {
let cores = vec![1, 2, 2, 4, 5, 6, 7];
let err = resolve_cores(Some(&cores), None, 2).unwrap_err();
assert!(err.contains("core 2"), "{err}");
assert!(err.contains("journal") && err.contains("matching"), "{err}");
}
#[test]
fn replica_span_overlapping_the_primary_is_rejected() {
let cores = vec![1, 2, 3, 4, 5, 6, 7];
let bases = vec![5, 12];
let err = resolve_cores(Some(&cores), Some(&bases), 2).unwrap_err();
assert!(err.contains("core 5"), "{err}");
assert!(err.contains("repl-sender"), "{err}");
assert!(err.contains("replica-0"), "{err}");
}
#[test]
fn replica_spans_overlapping_each_other_are_rejected() {
let bases = vec![8, 10];
let err = resolve_cores(None, Some(&bases), 2).unwrap_err();
assert!(err.contains("core 10") || err.contains("core 11"), "{err}");
}
#[test]
fn adjacent_replica_spans_are_accepted() {
let cores = vec![1, 2, 3, 4, 5, 6, 7, 8];
let bases = vec![9, 14];
let (_, resolved) = resolve_cores(Some(&cores), Some(&bases), 2).unwrap();
assert_eq!(resolved, vec![9, 14]);
}
#[test]
fn zero_replica_base_claims_nothing() {
let cores = vec![1, 2, 3, 4, 5, 6, 7];
let bases = vec![0, 12];
let (_, resolved) = resolve_cores(Some(&cores), Some(&bases), 2).unwrap();
assert_eq!(resolved, vec![0, 12]);
}
}