use std::sync::Arc;
#[cfg(test)]
use std::sync::atomic::AtomicU64;
use std::sync::atomic::{AtomicBool, Ordering};
use melin_journal::{BufferedWriter, JournalWrite};
use melin_app::Application;
use melin_transport_core::lineage::SharedLineagePolicy;
use melin_transport_core::pipeline::{InputSlot, OutputSlot};
use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
mod auth;
#[cfg(feature = "dpdk")]
mod dpdk;
mod receiver_transport;
mod tcp_receiver;
mod tcp_sender;
#[cfg(any(feature = "dpdk", test))]
mod validation_worker;
#[cfg(any(feature = "dpdk", test))]
mod join_worker;
use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
pub(crate) fn log_replica_live(slot: usize, sent: &SentHighWater) {
tracing::info!(
slot,
sent_sequence = sent.get(),
"replica caught up, streaming live"
);
}
pub use melin_transport_core::replication::ack_queue::{
PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
};
pub use melin_transport_core::replication::protocol::{
Ack, Handshake, PrimaryMessage, ReplicaMessage,
};
pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
#[cfg(feature = "dpdk")]
pub(crate) use dpdk::REPL_DISPATCH_BURST;
#[cfg(feature = "dpdk")]
pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
pub use tcp_receiver::{ReceiverResult, run_receiver};
pub use tcp_sender::{ReplicationListener, Sender, run_sender};
#[derive(Clone)]
pub struct ReplicaControlPlane {
pub promote: crate::promotion::PromotionRequest,
pub tip_ready: std::sync::Arc<AtomicBool>,
pub journal_tip: melin_transport_core::AdvertisedJournalTip,
pub primary_link_up: std::sync::Arc<AtomicBool>,
pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
pub pipeline_healthy: std::sync::Arc<AtomicBool>,
}
impl ReplicaControlPlane {
pub fn new() -> Self {
Self {
promote: crate::promotion::PromotionRequest::new(),
tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
journal_tip: melin_transport_core::AdvertisedJournalTip::new(
melin_transport_core::WireSeq::new(0),
),
primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
}
}
}
impl Default for ReplicaControlPlane {
fn default() -> Self {
Self::new()
}
}
pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
if !tracing::enabled!(tracing::Level::DEBUG) {
return;
}
let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_INFO,
&mut info as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc != 0 {
tracing::debug!(
slot,
tag,
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
"tcp_info getsockopt failed"
);
return;
}
tracing::debug!(
slot,
tag,
state = info.tcpi_state,
ca_state = info.tcpi_ca_state,
rtt_us = info.tcpi_rtt,
rttvar_us = info.tcpi_rttvar,
snd_cwnd = info.tcpi_snd_cwnd,
snd_ssthresh = info.tcpi_snd_ssthresh,
snd_mss = info.tcpi_snd_mss,
rcv_mss = info.tcpi_rcv_mss,
unacked = info.tcpi_unacked,
retrans = info.tcpi_retrans,
total_retrans = info.tcpi_total_retrans,
lost = info.tcpi_lost,
rcv_space = info.tcpi_rcv_space,
rto_us = info.tcpi_rto,
"tcp_info"
);
}
pub(super) fn sleep_checking_flags(
duration: std::time::Duration,
shutdown: &AtomicBool,
promote: &crate::promotion::PromotionRequest,
) {
let deadline = std::time::Instant::now() + duration;
while std::time::Instant::now() < deadline {
if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
pub(super) fn sleep_then_double_backoff(
backoff: &mut std::time::Duration,
shutdown: &AtomicBool,
promote: &crate::promotion::PromotionRequest,
) {
sleep_checking_flags(*backoff, shutdown, promote);
*backoff = (*backoff * 2).min(MAX_BACKOFF);
}
pub(super) enum TeardownOutcome<A, W> {
Clean(A, W),
JournalFailed(melin_journal::JournalError),
Panicked,
}
pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
shutdown_flag: &AtomicBool,
journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
matching_handle: std::thread::JoinHandle<A>,
drain_handle: std::thread::JoinHandle<()>,
shadow_handle: Option<std::thread::JoinHandle<()>>,
) -> TeardownOutcome<A, W> {
shutdown_flag.store(true, Ordering::Release);
let journal_result = journal_handle.join();
let matching_result = matching_handle.join();
let _ = drain_handle.join();
if let Some(h) = shadow_handle {
let _ = h.join();
}
let writer = match journal_result {
Ok(Ok(w)) => w,
Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
Err(_) => return TeardownOutcome::Panicked,
};
match matching_result {
Ok(app) => TeardownOutcome::Clean(app, writer),
Err(_) => TeardownOutcome::Panicked,
}
}
pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
pub(super) chain_hash_lock:
melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
pub(super) genesis_entries: Option<u64>,
pub(super) journal_failed: Arc<AtomicBool>,
pub(super) pipeline_shutdown: Arc<AtomicBool>,
pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
pub(super) matching_handle: std::thread::JoinHandle<A>,
pub(super) drain_handle: std::thread::JoinHandle<()>,
pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
}
pub(super) enum ResumePoint {
Settled(melin_transport_core::pipeline::FsyncState),
JournalFailed,
Interrupted,
}
impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
let published = self.input_producer.peek_cursor();
let mut outcome = ResumePoint::Interrupted;
melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
if self.journal_failed.load(Ordering::Acquire) {
outcome = ResumePoint::JournalFailed;
return true;
}
let state = self.chain_hash_lock.load();
if state.input_ring_seq.get() >= published {
outcome = ResumePoint::Settled(state);
return true;
}
interrupted()
});
outcome
}
}
pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
StreamingResult {
exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
heard_from_primary: false,
}
}
fn run_journal_stage<W, E: std::fmt::Display>(
run: impl FnOnce() -> Result<W, E>,
failed: &AtomicBool,
healthy: &AtomicBool,
) -> Result<W, E> {
struct PanicLatch<'a> {
failed: &'a AtomicBool,
healthy: &'a AtomicBool,
armed: bool,
}
impl Drop for PanicLatch<'_> {
fn drop(&mut self) {
if self.armed {
self.failed.store(true, Ordering::Release);
self.healthy.store(false, Ordering::Release);
tracing::error!("replica journal stage panicked — session teardown");
}
}
}
let mut guard = PanicLatch {
failed,
healthy,
armed: true,
};
let result = run();
guard.armed = false;
if let Err(ref e) = result {
failed.store(true, Ordering::Release);
healthy.store(false, Ordering::Release);
tracing::error!(error = %e, "replica journal stage failed — session teardown");
}
result
}
pub(super) type ReplicaHandles<A> =
ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
#[allow(clippy::too_many_arguments)]
pub(super) fn build_replica_pipeline_with_threads<A>(
mut app: A,
writer: BufferedWriter<A::Event>,
cores: crate::layout::PipelineCores,
staging_mode: melin_journal::StagingMode,
snapshot_interval_ms: u64,
snapshot_path: std::path::PathBuf,
group_commit_delay: std::time::Duration,
fence_state: Arc<melin_transport_core::fence::FenceState>,
lineage_policy: &SharedLineagePolicy,
pipeline_healthy: Arc<AtomicBool>,
sizing: &A::Sizing,
) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
where
A: Application + Send + 'static,
A::Event: Send + Sync + 'static,
A::Report: Send + 'static,
A::QueryResponse: Send + 'static,
{
<A as Application>::prefault(&mut app, sizing);
let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
let enable_shadow = snapshot_interval_ms > 0;
let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
epoch: fence_state.epoch(),
ack_policy: lineage_policy.load(),
};
let genesis_entries = writer.read_header_info()?.genesis_entries;
let starting_wire_seq = writer.next_sequence();
let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
app,
writer,
4096, group_commit_delay,
cores.stage_waits(),
enable_shadow,
fence_state,
);
let pipeline_shutdown = Arc::new(AtomicBool::new(false));
let ps = Arc::clone(&pipeline_shutdown);
let journal_core = cores.journal_seq.core;
let mut journal_stage = pipeline.journal_stage;
let stream_marks: melin_transport_core::pipeline::StreamMarkQueue =
Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
journal_stage.set_stream_marks(Arc::clone(&stream_marks));
cores.place_journal_children(&mut journal_stage);
journal_stage.set_staging_mode(staging_mode);
let journal_failed = Arc::new(AtomicBool::new(false));
let journal_failed_latch = Arc::clone(&journal_failed);
let sequencer = journal_stage.start()?;
pipeline_healthy.store(true, Ordering::Release);
let abandon = |e: String| -> Box<dyn std::error::Error> {
pipeline_shutdown.store(true, Ordering::Release);
e.into()
};
let journal_handle =
melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
run_journal_stage(
|| sequencer.run(&ps),
&journal_failed_latch,
&pipeline_healthy,
)
})
.map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
let ps = Arc::clone(&pipeline_shutdown);
let matching_core = cores.matching.core;
let matching_stage = pipeline.matching_stage;
let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
matching_stage.run(&ps)
})
.map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
let ps = Arc::clone(&pipeline_shutdown);
let drain = cores.response;
let drain_consumer = pipeline.drain_consumer;
let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
let mut consumer = drain_consumer;
let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
let mut waiter = drain.wait.waiter();
loop {
if ps.load(Ordering::Relaxed) {
return;
}
let count = consumer.consume_batch(&mut batch, 256);
if count == 0 {
waiter.idle();
} else {
waiter.reset();
}
}
})
.map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
let snap_path = snapshot_path;
let chain_lock = pipeline.chain_hash_lock.clone();
let ps = Arc::clone(&pipeline_shutdown);
let shadow = cores.shadow;
Some(
melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
melin_transport_core::shadow::run(
shadow_cons,
shadow_app,
snap_path,
std::time::Duration::from_millis(snapshot_interval_ms),
chain_lock,
&ps,
shadow.wait,
shadow_initial_lineage,
starting_wire_seq,
genesis_entries,
);
})
.map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
)
} else {
None
};
Ok(ReplicaPipelineHandles {
input_producer: pipeline.input_producer,
journal_cursor: pipeline.cursors.journal_ring_arc(),
chain_hash_lock: pipeline.chain_hash_lock,
stream_marks,
genesis_entries,
journal_failed,
pipeline_shutdown,
journal_handle,
matching_handle,
drain_handle,
shadow_handle,
})
}
pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
mut handles: ReplicaPipelineHandles<A, W>,
) -> TeardownOutcome<A, W> {
while !handles.journal_failed.load(Ordering::Acquire) {
match handles
.input_producer
.try_publish(InputSlot::<A::Event>::shutdown_sentinel())
{
Ok(_) => break,
Err(_) => std::thread::yield_now(),
}
}
shutdown_pipeline::<A, W>(
&handles.pipeline_shutdown,
handles.journal_handle,
handles.matching_handle,
handles.drain_handle,
handles.shadow_handle,
)
}
pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
#[allow(clippy::type_complexity)]
pub(super) fn recover_replica_state<A, W>(
journal_path: &std::path::Path,
snapshot_path: &std::path::Path,
fence_state: &melin_transport_core::fence::FenceState,
lineage_policy: &SharedLineagePolicy,
sizing: &A::Sizing,
) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
where
A: Application,
W: melin_journal::JournalWrite<A::Event>,
{
remove_restamp_staging(journal_path)?;
let lineage_exists =
journal_path.exists() || !melin_journal::segment::list_archives(journal_path)?.is_empty();
if !lineage_exists {
lineage_policy.store(None);
return Ok((None, None, 0u64, [0u8; 32]));
}
let engine = if snapshot_path.exists() {
tracing::info!("recovering replica from snapshot + journal");
melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
snapshot_path,
journal_path,
)?
} else {
let mut app = A::default();
<A as Application>::prefault(&mut app, sizing);
melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
};
let next = engine.next_sequence();
let last = next.saturating_sub(1);
let hash = engine.chain_hash().unwrap_or([0u8; 32]);
adopt_recovered_lineage(fence_state, lineage_policy, engine.recovered_lineage());
let (app, writer) = engine.into_parts();
Ok((Some(app), Some(writer), last, hash))
}
fn adopt_recovered_lineage(
fence_state: &melin_transport_core::fence::FenceState,
lineage_policy: &SharedLineagePolicy,
lineage: melin_transport_core::lineage::Lineage,
) {
fence_state.observe_epoch(lineage.epoch);
lineage_policy.store(lineage.ack_policy);
}
pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
pub(in crate::replication) enum AfterSession<A, W> {
Return(ReceiverResult<A, W>),
Resync {
app: Option<A>,
journal_writer: Option<W>,
last_sequence: u64,
chain_hash: [u8; 32],
},
Reconnect,
}
impl<A, W> AfterSession<A, W> {
pub(in crate::replication) fn adopt(
self,
app: &mut Option<A>,
journal_writer: &mut Option<W>,
last_sequence: &mut u64,
chain_hash: &mut [u8; 32],
) -> Option<ReceiverResult<A, W>> {
match self {
AfterSession::Return(r) => Some(r),
AfterSession::Resync {
app: recovered_app,
journal_writer: recovered_writer,
last_sequence: recovered_sequence,
chain_hash: recovered_hash,
} => {
*app = recovered_app;
*journal_writer = recovered_writer;
*last_sequence = recovered_sequence;
*chain_hash = recovered_hash;
None
}
AfterSession::Reconnect => None,
}
}
}
#[allow(clippy::too_many_arguments)]
pub(in crate::replication) fn handle_session_exit<A, W>(
result: StreamingResult,
pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
divergence_resyncs: &mut u32,
backoff: &mut std::time::Duration,
last_sequence: u64,
journal_path: &std::path::Path,
snapshot_path: &std::path::Path,
fence_state: &melin_transport_core::fence::FenceState,
lineage_policy: &SharedLineagePolicy,
shutdown: &AtomicBool,
promote: &crate::promotion::PromotionRequest,
close: impl FnOnce(),
sizing: &A::Sizing,
) -> AfterSession<A, W>
where
A: Application + Send + 'static,
W: JournalWrite<A::Event> + Send + 'static,
{
let StreamingResult {
exit,
heard_from_primary,
} = result;
close();
match exit {
SessionExit::Shutdown => {
if let Some(p) = pipeline.take() {
let _ = teardown_replica_pipeline::<A, W>(p);
}
AfterSession::Return(Ok(None))
}
SessionExit::Promote => AfterSession::Return(match pipeline.take() {
Some(p) => match teardown_replica_pipeline::<A, W>(p) {
TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
_ => Err("pipeline failed during promotion".into()),
},
None => Err("pipeline missing on promote".into()),
}),
SessionExit::Fatal(e) => {
let outcome = match pipeline.take() {
Some(p) => teardown_replica_pipeline::<A, W>(p),
None => return AfterSession::Return(Err(e)),
};
let je = match outcome {
TeardownOutcome::JournalFailed(
je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
) => je,
TeardownOutcome::JournalFailed(je) => {
return AfterSession::Return(Err(format!("{e}: {je}").into()));
}
TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
return AfterSession::Return(Err(e));
}
};
*divergence_resyncs += 1;
let attempt = *divergence_resyncs;
if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
return AfterSession::Return(Err(format!(
"mid-stream chain divergence recurred {attempt} times — giving up on \
in-process resync (each cycle archives the local journal and re-seeds \
from the primary; recurrence at this rate means the primary keeps \
streaming history that forks from what it announces): {je}"
)
.into()));
}
tracing::warn!(
error = %je,
attempt,
max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
"mid-stream chain divergence — re-deriving local state for in-process resync"
);
match recover_replica_state::<A, W>(
journal_path,
snapshot_path,
fence_state,
lineage_policy,
sizing,
) {
Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
app,
journal_writer,
last_sequence: seq,
chain_hash: hash,
},
Err(e) => AfterSession::Return(Err(e)),
}
}
SessionExit::StreamGap(e) => {
*backoff = std::time::Duration::from_secs(1);
tracing::warn!(
error = %e,
last_sequence,
"replication stream broke contiguity — reconnecting from the durable position"
);
sleep_then_double_backoff(backoff, shutdown, promote);
AfterSession::Reconnect
}
SessionExit::Corrupted(e) => {
*backoff = std::time::Duration::from_secs(1);
tracing::warn!(
error = %e,
last_sequence,
backoff_secs = backoff.as_secs(),
"replicated entry failed its integrity check — refused, reconnecting to \
fetch it again; recurrence points at the network, a NIC or memory"
);
sleep_then_double_backoff(backoff, shutdown, promote);
AfterSession::Reconnect
}
SessionExit::Disconnected => {
if heard_from_primary {
*backoff = std::time::Duration::from_secs(1);
}
tracing::warn!(
last_sequence,
heard_from_primary,
backoff_secs = backoff.as_secs(),
"reconnecting to primary"
);
sleep_then_double_backoff(backoff, shutdown, promote);
AfterSession::Reconnect
}
}
}
pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
app: &mut Option<A>,
journal_writer: &mut Option<W>,
) -> ReceiverResult<A, W>
where
A: Application + Send + 'static,
W: JournalWrite<A::Event> + Send + 'static,
{
if let Some(p) = pipeline.take()
&& let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
{
*app = Some(e);
*journal_writer = Some(w);
}
match (app.take(), journal_writer.take()) {
(Some(e), Some(w)) => Ok(Some((e, w))),
_ => Err("promotion requested but no local state available".into()),
}
}
struct ResyncTransfer<A> {
app: A,
snap_sequence: u64,
snap_chain_hash: [u8; 32],
seed_len: u64,
lineage: melin_transport_core::lineage::Lineage,
}
pub(in crate::replication) enum ResyncDecision {
Ready {
segment_start_sequence: u64,
anchor_hash: [u8; 32],
genesis_entries: Option<u64>,
resume_sequence: u64,
},
Retry,
}
fn receive_resync_transfer<A, S>(
source: &mut S,
snapshot_path: &std::path::Path,
journal_path: &std::path::Path,
) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
where
A: Application + Send,
S: ControlFrameSource,
{
let (snap_len, snap_sequence, snap_chain_hash) =
match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
PrimaryMessage::SnapshotBegin {
snapshot_len,
snap_sequence,
snap_chain_hash,
} => (snapshot_len, snap_sequence, snap_chain_hash),
other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
};
tracing::info!(snap_sequence, snap_len, "receiving snapshot");
let tmp_path = snapshot_path.with_extension("snapshot.tmp");
receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
std::fs::rename(&tmp_path, snapshot_path)?;
tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
let (snap_app, snap_header) =
source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
let snap_hash = snap_header.chain_hash;
if snap_hash != snap_chain_hash {
return Err(format!(
"snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
loaded snapshot has {snap_hash:02x?}"
)
.into());
}
let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
other => {
return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
}
};
let seed_tmp = journal_path.with_extension("seed.tmp");
receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Err(e) =
melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
{
let _ = std::fs::remove_file(&seed_tmp);
return Err(format!("segment seed failed structural verification: {e}").into());
}
std::fs::rename(&seed_tmp, journal_path)?;
melin_journal::segment::fsync_parent_dir(journal_path)?;
Ok(())
})?;
Ok(ResyncTransfer {
app: snap_app,
snap_sequence,
snap_chain_hash,
seed_len,
lineage: snap_header.lineage,
})
}
#[allow(clippy::too_many_arguments)]
pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
divergent: bool,
source: &mut S,
pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
app: &mut Option<A>,
journal_writer: &mut Option<W>,
journal_path: &std::path::Path,
snapshot_path: &std::path::Path,
fence_state: &melin_transport_core::fence::FenceState,
control: &ReplicaControlPlane,
last_sequence: &mut u64,
chain_hash: &mut [u8; 32],
) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
where
A: Application + Send + 'static,
A::Event: Send,
W: JournalWrite<A::Event> + Send + 'static,
S: ControlFrameSource,
{
if divergent {
tracing::warn!(
last_sequence = *last_sequence,
"primary reports chain divergence — archiving local journal, resyncing from snapshot"
);
} else {
tracing::info!("primary requires snapshot transfer — receiving snapshot");
}
let old_pipeline = pipeline.take();
let old_app = app.take();
let old_writer = journal_writer.take();
let reason = if divergent {
ArchiveReason::Divergent
} else {
ArchiveReason::Resync
};
source.serviced(move || {
if let Some(p) = old_pipeline {
let _ = teardown_replica_pipeline::<A, W>(p);
}
drop(old_app);
drop(old_writer);
archive_local_lineage(journal_path, snapshot_path, reason)
})?;
*last_sequence = 0;
*chain_hash = [0u8; 32];
control.lineage_ack_policy.store(None);
control
.journal_tip
.reset(melin_transport_core::WireSeq::new(0));
let ResyncTransfer {
app: snap_app,
snap_sequence,
snap_chain_hash,
seed_len,
lineage: snap_lineage,
} = match receive_resync_transfer::<A, S>(source, snapshot_path, journal_path) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "snapshot transfer failed — retrying");
let _ = std::fs::remove_file(snapshot_path);
return Ok(ResyncDecision::Retry);
}
};
adopt_recovered_lineage(fence_state, &control.lineage_ack_policy, snap_lineage);
*app = Some(snap_app);
let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
return Err(format!(
"segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
hash — inconsistent primary"
)
.into());
}
let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
*journal_writer = Some(writer);
match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
PrimaryMessage::StreamStart {
start_sequence,
segment_start_sequence,
anchor_hash,
genesis_entries,
epoch,
} => {
if segment_start_sequence != seeded_info.starting_sequence
|| anchor_hash != seeded_info.anchor_hash
|| genesis_entries != seeded_info.genesis_entries
{
return Err(format!(
"post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
{}) disagrees with the transferred segment seed (start {}, genesis {}) — \
inconsistent primary",
describe_genesis(genesis_entries),
seeded_info.starting_sequence,
describe_genesis(seeded_info.genesis_entries),
)
.into());
}
fence_state.observe_epoch(epoch);
control.primary_observed.store(true, Ordering::Release);
tracing::info!(
start_sequence,
epoch,
"streaming resumed after snapshot transfer"
);
control
.journal_tip
.reset(melin_transport_core::WireSeq::new(snap_sequence));
Ok(ResyncDecision::Ready {
segment_start_sequence,
anchor_hash,
genesis_entries,
resume_sequence: snap_sequence,
})
}
other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
}
}
fn describe_genesis(genesis_entries: Option<u64>) -> String {
match genesis_entries {
Some(n) => format!("{n} entries"),
None => "unknown".to_string(),
}
}
pub(in crate::replication) fn local_lineage_genesis<A, W>(
pipeline: &Option<ReplicaPipelineHandles<A, W>>,
journal_writer: &Option<W>,
) -> Result<Option<Option<u64>>, melin_journal::JournalError>
where
A: Application,
W: JournalWrite<A::Event> + Send + 'static,
{
match (pipeline, journal_writer) {
(Some(p), _) => Ok(Some(p.genesis_entries)),
(None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
(None, None) => Ok(None),
}
}
fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
journal_path.with_extension("restamp.tmp")
}
fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(restamp_staging_path(journal_path)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
journal_writer: &mut Option<W>,
lineage: (u64, [u8; 32], Option<u64>),
) -> Result<(), melin_journal::JournalError>
where
E: melin_app::AppEvent,
W: JournalWrite<E>,
{
let (segment_start_sequence, anchor_hash, advertised) = lineage;
let Some(writer) = journal_writer.as_ref() else {
return Ok(());
};
if advertised.is_none()
|| segment_start_sequence != 1
|| writer.segment_starting_sequence() != 1
|| writer.next_sequence() != 1
{
return Ok(());
}
let info = writer.read_header_info()?;
if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
return Ok(());
}
let journal_path = writer.path().to_path_buf();
if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
return Ok(());
}
let staging = restamp_staging_path(&journal_path);
remove_restamp_staging(&journal_path)?;
let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
let valid_end = staged.valid_end();
drop(staged);
drop(journal_writer.take());
std::fs::rename(&staging, &journal_path)?;
melin_journal::segment::fsync_parent_dir(&journal_path)?;
*journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
tracing::info!(
journal = %journal_path.display(),
genesis_entries = ?advertised,
"recorded the primary's genesis length in this replica's empty journal"
);
Ok(())
}
pub(in crate::replication) enum StreamStartVerdict {
StalePrimary,
Follow((u64, [u8; 32], Option<u64>)),
}
pub(in crate::replication) struct StreamStart {
pub start_sequence: u64,
pub lineage: (u64, [u8; 32], Option<u64>),
pub epoch: u64,
}
pub(in crate::replication) fn accept_stream_start<A, W>(
start: StreamStart,
transport: &'static str,
pipeline: &Option<ReplicaPipelineHandles<A, W>>,
journal_writer: &mut Option<W>,
fence_state: &melin_transport_core::fence::FenceState,
control: &ReplicaControlPlane,
) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
where
A: Application,
W: JournalWrite<A::Event> + Send + 'static,
{
let StreamStart {
start_sequence,
lineage,
epoch,
} = start;
if fence_state.refuses_primary(epoch) {
tracing::warn!(
transport,
primary_epoch = epoch,
our_epoch = fence_state.epoch(),
"primary is behind our fencing epoch — refusing to follow stale primary"
);
return Ok(StreamStartVerdict::StalePrimary);
}
if pipeline.is_none() {
adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
}
if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
check_advertised_genesis(local, lineage.2)?;
}
fence_state.observe_epoch(epoch);
control.primary_observed.store(true, Ordering::Release);
tracing::info!(transport, start_sequence, epoch, "streaming started");
Ok(StreamStartVerdict::Follow(lineage))
}
pub(in crate::replication) fn check_advertised_genesis(
local: Option<u64>,
advertised: Option<u64>,
) -> Result<(), String> {
match (local, advertised) {
(Some(local), Some(advertised)) if local != advertised => Err(format!(
"refusing to follow the primary: its journal records a genesis of {advertised} \
entries but this replica's records {local} — the two journals do not share one \
lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
then resync this replica from scratch (move its journal, archives and snapshots \
aside) or restore the primary"
)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::sync::Arc;
use super::auth::{authenticate_replica, authenticate_with_primary};
use super::*;
use counter_server::CounterEvent;
type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
use melin_transport_core::replication::protocol::{
MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
encode_auth_failed, encode_auth_ok, encode_chain_check, encode_challenge,
encode_challenge_response, encode_handshake, encode_hash_mismatch, encode_heartbeat,
encode_input_batch, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
read_frame, try_decode_input_batch,
};
fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
let slot = InputSlot {
connection_id: 0,
key_hash: 0,
sequence: end_sequence,
timestamp_ns: 0,
event: melin_journal::JournalEvent::Tick { now_ns: 0 },
publish_ts: Default::default(),
recv_ts: Default::default(),
};
encode_input_batch(&[slot], buf).expect("encode InputBatch");
}
#[test]
fn handshake_encode_decode_round_trip() {
let handshake = Handshake {
last_sequence: 42,
chain_hash: [0xAB; 32],
epoch: 9,
};
let mut buf = Vec::new();
encode_handshake(&handshake, &mut buf);
let payload = &buf[4..];
let msg = decode_replica_message(payload).unwrap();
match msg {
ReplicaMessage::Handshake(h) => {
assert_eq!(h.last_sequence, 42);
assert_eq!(h.chain_hash, [0xAB; 32]);
assert_eq!(h.epoch, 9);
}
_ => panic!("expected Handshake"),
}
}
#[test]
fn ack_encode_decode_round_trip() {
let ack = Ack {
acked_sequence: 1000,
in_memory_sequence: 1024,
};
let mut buf = Vec::new();
encode_ack(&ack, &mut buf);
let payload = &buf[4..];
let msg = decode_replica_message(payload).unwrap();
match msg {
ReplicaMessage::Ack(a) => {
assert_eq!(a.acked_sequence, 1000);
assert_eq!(a.in_memory_sequence, 1024);
}
_ => panic!("expected Ack"),
}
}
#[test]
fn ack_wire_byte_pattern() {
let ack = Ack {
acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
in_memory_sequence: 0x1122_3344_5566_7788,
};
let mut buf = Vec::new();
encode_ack(&ack, &mut buf);
let expected: &[u8] = &[
0x11, 0x00, 0x00, 0x00, 0x02, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, ];
assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
}
#[test]
fn an_advertised_genesis_length_must_match_a_known_one() {
check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
assert!(err.contains("refusing to follow the primary"), "{err}");
assert!(
err.contains("genesis of 4") && err.contains("records 3"),
"{err}"
);
check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
check_advertised_genesis(None, Some(3)).expect("ours unknown");
check_advertised_genesis(Some(3), None).expect("theirs unknown");
check_advertised_genesis(None, None).expect("both unknown");
}
#[test]
fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let anchor = [9u8; 32];
let header = |w: &Option<Writer>| {
w.as_ref()
.expect("writer")
.read_header_info()
.expect("header")
.genesis_entries
};
let path = dir.path().join("empty.journal");
let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
assert_eq!(header(&w), Some(3));
let adopted = w.as_ref().expect("writer");
assert_eq!(adopted.next_sequence(), 1);
assert_eq!(adopted.path(), path.as_path());
assert_eq!(
melin_journal::segment::read_header_info(&path)
.expect("on disk")
.anchor_hash,
anchor
);
assert!(!path.with_extension("restamp.tmp").exists());
let path = dir.path().join("both-unknown.journal");
let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
assert_eq!(header(&w), None);
let path = dir.path().join("other-anchor.journal");
let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
assert_eq!(header(&w), None);
let path = dir.path().join("known.journal");
let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
assert_eq!(header(&w), Some(2));
let path = dir.path().join("history.journal");
let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
let seq = writer.allocate_sequence();
writer
.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
.expect("encode");
writer.flush_batch_sync().expect("flush");
let mut w = Some(writer);
adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
assert_eq!(header(&w), None);
let mut none: Option<Writer> = None;
adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
assert!(none.is_none());
}
#[test]
fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let fence = melin_transport_core::fence::FenceState::new(0);
let journal = dir.path().join("replica.journal");
let snapshot = dir.path().join("replica.snapshot");
let staging = journal.with_extension("restamp.tmp");
std::fs::write(&staging, b"debris").expect("write staging");
let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
&journal,
&snapshot,
&fence,
&SharedLineagePolicy::new(None),
&(),
)
.expect("recover");
assert!(app.is_none() && writer.is_none());
assert!(!staging.exists(), "swept without a journal");
let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
let seq = w.allocate_sequence();
w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
.expect("encode");
w.flush_batch_sync().expect("flush");
drop(w);
std::fs::write(&staging, b"debris").expect("write staging");
let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
&journal,
&snapshot,
&fence,
&SharedLineagePolicy::new(None),
&(),
)
.expect("recover");
assert!(writer.is_some());
assert_eq!(last, 1);
assert!(!staging.exists(), "swept beside a journal with history");
}
#[test]
fn replica_recovery_stores_the_lineage_policy_whole() {
use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
use melin_transport_core::lineage::LineagePolicy;
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let fence = melin_transport_core::fence::FenceState::new(0);
let journal = dir.path().join("replica.journal");
let snapshot = dir.path().join("replica.snapshot");
let stale = Some(LineagePolicy {
change: AckPolicyChange {
policy: AckPolicyByte(2),
unwitnessed: true,
},
wire_seq: melin_transport_core::WireSeq::new(9),
previous: Some(AckPolicyByte(1)),
});
let cell = SharedLineagePolicy::new(stale);
let recover = |cell: &SharedLineagePolicy| {
recover_replica_state::<counter_server::Counter, Writer>(
&journal,
&snapshot,
&fence,
cell,
&(),
)
.expect("recover")
};
recover(&cell);
assert_eq!(cell.load(), None);
let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
let seq = w.allocate_sequence();
w.encode_event(seq, 0, &event, 0).expect("encode");
w.flush_batch_sync().expect("flush");
};
append(&mut w, JournalEvent::Tick { now_ns: 0 });
cell.store(stale);
recover(&cell);
assert_eq!(cell.load(), None);
append(&mut w, JournalEvent::EpochBump { epoch: 4 });
append(
&mut w,
JournalEvent::AckPolicyChange(AckPolicyChange {
policy: AckPolicyByte(0),
unwitnessed: false,
}),
);
drop(w);
cell.store(stale);
recover(&cell);
assert_eq!(
cell.load(),
Some(LineagePolicy {
change: AckPolicyChange {
policy: AckPolicyByte(0),
unwitnessed: false,
},
wire_seq: melin_transport_core::WireSeq::new(3),
previous: None,
}),
"the journal's own record, at the change's sequence"
);
assert_eq!(fence.epoch(), 4);
}
#[test]
fn a_stale_primary_is_refused_before_anything_is_adopted() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("replica.journal");
let anchor = [9u8; 32];
let mut journal_writer =
Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
let fence = melin_transport_core::fence::FenceState::new(5);
let control = ReplicaControlPlane::new();
let verdict = accept_stream_start::<counter_server::Counter, Writer>(
StreamStart {
start_sequence: 1,
lineage: (1, anchor, Some(3)),
epoch: 4,
},
"test",
&None,
&mut journal_writer,
&fence,
&control,
)
.expect("a refusal is not an error");
assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
assert_eq!(fence.epoch(), 5);
assert!(!control.primary_observed.load(Ordering::Acquire));
let header = journal_writer
.as_ref()
.expect("writer")
.read_header_info()
.expect("header");
assert_eq!(header.genesis_entries, None, "journal left untouched");
}
#[test]
fn following_a_primary_adopts_its_epoch_and_genesis() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("replica.journal");
let anchor = [9u8; 32];
let mut journal_writer =
Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
let fence = melin_transport_core::fence::FenceState::new(5);
let control = ReplicaControlPlane::new();
let held = Some(melin_transport_core::lineage::LineagePolicy::after(
None,
melin_transport_core::WireSeq::new(1),
melin_journal::AckPolicyChange {
policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
unwitnessed: false,
},
));
control.lineage_ack_policy.store(held);
let verdict = accept_stream_start::<counter_server::Counter, Writer>(
StreamStart {
start_sequence: 1,
lineage: (1, anchor, Some(3)),
epoch: 7,
},
"test",
&None,
&mut journal_writer,
&fence,
&control,
)
.expect("same lineage");
let StreamStartVerdict::Follow(lineage) = verdict else {
panic!("expected Follow");
};
assert_eq!(lineage, (1, anchor, Some(3)));
assert_eq!(fence.epoch(), 7);
assert!(control.primary_observed.load(Ordering::Acquire));
assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
let header = journal_writer
.as_ref()
.expect("writer")
.read_header_info()
.expect("header");
assert_eq!(header.genesis_entries, Some(3));
}
#[test]
fn a_disagreeing_genesis_length_is_fatal() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("replica.journal");
let anchor = [9u8; 32];
let mut journal_writer =
Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
let fence = melin_transport_core::fence::FenceState::new(5);
let control = ReplicaControlPlane::new();
let err = accept_stream_start::<counter_server::Counter, Writer>(
StreamStart {
start_sequence: 1,
lineage: (1, anchor, Some(3)),
epoch: 7,
},
"test",
&None,
&mut journal_writer,
&fence,
&control,
)
.err()
.expect("different lineages");
assert!(err.to_string().contains("refusing to follow the primary"));
assert_eq!(fence.epoch(), 5);
}
#[test]
fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("replica.journal");
let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
let mut journal_writer = Some(writer);
assert_eq!(
local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
.expect("header read"),
Some(Some(5)),
);
let (input_producer, mut consumers) =
melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
.add_consumer()
.build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
let _consumer = consumers.pop().expect("one consumer");
let owned = journal_writer.take().expect("writer");
let pipeline = Some(ReplicaPipelineHandles {
input_producer,
journal_cursor: Arc::new(make_journal_cursor(0)),
chain_hash_lock: fsync_state_at(0, 0).1,
stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
genesis_entries: Some(5),
journal_failed: Arc::new(AtomicBool::new(false)),
pipeline_shutdown: Arc::new(AtomicBool::new(false)),
journal_handle: std::thread::spawn(
move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
),
matching_handle: std::thread::spawn(counter_server::Counter::default),
drain_handle: std::thread::spawn(|| {}),
shadow_handle: None,
});
std::fs::remove_file(&path).expect("simulate the rotation gap");
assert_eq!(
local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
Some(Some(5)),
);
assert_eq!(
local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
.expect("nothing to read"),
None,
);
}
#[test]
fn stream_start_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::StreamStart {
start_sequence,
segment_start_sequence,
anchor_hash,
genesis_entries,
epoch,
} => {
assert_eq!(start_sequence, 99);
assert_eq!(segment_start_sequence, 42);
assert_eq!(anchor_hash, [0xAA; 32]);
assert_eq!(genesis_entries, Some(7));
assert_eq!(epoch, 5);
}
_ => panic!("expected StreamStart"),
}
}
#[test]
fn heartbeat_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_heartbeat(123, &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
_ => panic!("expected Heartbeat"),
}
}
#[test]
fn need_snapshot_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_need_snapshot(&mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
}
#[test]
fn hash_mismatch_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_hash_mismatch(&mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
assert!(matches!(msg, PrimaryMessage::HashMismatch));
}
#[test]
fn rotate_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::Rotate {
boundary_seq,
tail_hash,
} => {
assert_eq!(boundary_seq, 7_000_000);
assert_eq!(tail_hash, [0xBB; 32]);
}
_ => panic!("expected Rotate"),
}
}
#[test]
fn chain_check_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_chain_check(555, &[0xCC; 32], &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::ChainCheck {
sequence,
chain_hash,
} => {
assert_eq!(sequence, 555);
assert_eq!(chain_hash, [0xCC; 32]);
}
_ => panic!("expected ChainCheck"),
}
}
#[test]
fn rotate_wire_byte_pattern() {
let mut hash = [0u8; 32];
for (i, b) in hash.iter_mut().enumerate() {
*b = i as u8;
}
let mut buf = Vec::new();
encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
let mut expected = vec![
0x29, 0x00, 0x00, 0x00, 0x16, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, ];
expected.extend_from_slice(&hash);
assert_eq!(
buf.as_slice(),
expected.as_slice(),
"Rotate wire layout drifted"
);
}
#[test]
fn segment_seed_begin_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_segment_seed_begin(123_456, &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::SegmentSeedBegin { seed_len } => {
assert_eq!(seed_len, 123_456);
}
_ => panic!("expected SegmentSeedBegin"),
}
}
#[test]
fn unknown_replica_message_type_is_error() {
let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
let result = decode_replica_message(&payload);
assert!(result.is_err());
}
#[test]
fn unknown_primary_message_type_is_error() {
let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
let result = decode_primary_message(&payload);
assert!(result.is_err());
}
#[test]
fn read_frame_enforces_max_size() {
let mut buf = Vec::new();
buf.extend_from_slice(&1000u32.to_le_bytes());
buf.extend_from_slice(&[0u8; 100]);
let mut cursor = std::io::Cursor::new(buf);
let result = read_frame(&mut cursor, 64);
assert!(result.is_err());
}
#[test]
fn challenge_encode_decode_round_trip() {
let nonce = [0x42; 32];
let mut buf = Vec::new();
encode_challenge(&nonce, &mut buf);
let payload = &buf[4..];
let decoded = decode_challenge(payload).unwrap();
assert_eq!(decoded, nonce);
}
#[test]
fn challenge_response_encode_decode_round_trip() {
let sig = [0xAA; 64];
let pubkey = [0xBB; 32];
let mut buf = Vec::new();
encode_challenge_response(&sig, &pubkey, &mut buf);
let payload = &buf[4..];
let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
assert_eq!(decoded_sig, sig);
assert_eq!(decoded_pubkey, pubkey);
}
#[test]
fn auth_ok_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_auth_ok(&mut buf);
let payload = &buf[4..];
assert!(decode_auth_result(payload).unwrap());
}
#[test]
fn auth_failed_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_auth_failed(&mut buf);
let payload = &buf[4..];
assert!(!decode_auth_result(payload).unwrap());
}
#[test]
fn decode_challenge_rejects_wrong_tag() {
let mut payload = [0u8; 33];
payload[0] = MSG_AUTH_OK;
assert!(decode_challenge(&payload).is_err());
}
#[test]
fn decode_challenge_response_rejects_short_payload() {
let payload = [MSG_CHALLENGE_RESPONSE; 10]; assert!(decode_challenge_response(&payload).is_err());
}
#[test]
fn auth_round_trip_valid_key() {
use ed25519_dalek::SigningKey;
use std::os::unix::net::UnixStream;
let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
let pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
repl_key.verifying_key().to_bytes(),
);
let keys_content = format!("replication {pub_b64} test-replica\n");
let authorized_keys =
melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
.unwrap();
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
primary_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
replica_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
let replica_handle = std::thread::spawn(move || {
let mut conn = replica_stream;
authenticate_with_primary(&mut conn, &repl_key_clone)
});
let mut conn = primary_stream;
authenticate_replica(&mut conn, &authorized_keys).unwrap();
replica_handle.join().unwrap().unwrap();
}
#[test]
fn auth_rejects_unknown_key() {
use ed25519_dalek::SigningKey;
use std::os::unix::net::UnixStream;
let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
let pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
authorized_key.verifying_key().to_bytes(),
);
let keys_content = format!("replication {pub_b64} authorized-replica\n");
let authorized_keys =
melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
.unwrap();
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
primary_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
replica_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let replica_handle = std::thread::spawn(move || {
let mut conn = replica_stream;
authenticate_with_primary(&mut conn, &rogue_key)
});
let mut conn = primary_stream;
let result = authenticate_replica(&mut conn, &authorized_keys);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown"));
let replica_result = replica_handle.join().unwrap();
assert!(replica_result.is_err());
}
#[test]
fn auth_rejects_wrong_permission() {
use ed25519_dalek::SigningKey;
use std::os::unix::net::UnixStream;
let key = SigningKey::from_bytes(&[0xCC; 32]);
let pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key.verifying_key().to_bytes(),
);
let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
primary_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
replica_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let replica_handle = std::thread::spawn(move || {
let mut conn = replica_stream;
authenticate_with_primary(&mut conn, &key)
});
let mut conn = primary_stream;
let result = authenticate_replica(&mut conn, &authorized_keys);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"key listed as trader, expected replication"
);
let replica_result = replica_handle.join().unwrap();
assert!(replica_result.is_err());
}
#[test]
fn auth_rejects_invalid_signature() {
use ed25519_dalek::SigningKey;
use std::os::unix::net::UnixStream;
let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
let pub_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
correct_key.verifying_key().to_bytes(),
);
let keys_content = format!("replication {pub_b64} test-replica\n");
let authorized_keys =
melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
.unwrap();
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
primary_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
replica_stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let replica_handle = std::thread::spawn(move || {
use melin_transport_core::replication::protocol::*;
let mut reader = replica_stream.try_clone().unwrap();
let mut writer = replica_stream;
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let nonce = decode_challenge(&frame).unwrap();
let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
let correct_pubkey = correct_key.verifying_key();
let mut buf = Vec::with_capacity(128);
encode_challenge_response(
&bad_signature.to_bytes(),
correct_pubkey.as_bytes(),
&mut buf,
);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let ok = decode_auth_result(&result_frame).unwrap();
assert!(!ok, "should receive auth failure");
});
let mut conn = primary_stream;
let result = authenticate_replica(&mut conn, &authorized_keys);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("signature verification failed"),
"should fail on signature verification"
);
replica_handle.join().unwrap();
}
#[test]
fn sender_receiver_end_to_end() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let replica_handle = std::thread::spawn(move || {
let mut reader = replica_stream.try_clone().unwrap();
let mut writer = replica_stream;
let mut buf = Vec::new();
let handshake = Handshake {
last_sequence: 0,
chain_hash: [0u8; 32],
epoch: 0,
};
encode_handshake(&handshake, &mut buf);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
buf.clear();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let msg = decode_primary_message(&frame).unwrap();
assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
let end_seq = slots
.last()
.map(|s| s.sequence)
.expect("InputBatch carried at least one slot");
let ack = Ack {
acked_sequence: end_seq,
in_memory_sequence: end_seq,
};
encode_ack(&ack, &mut buf);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
end_seq
});
let mut p_reader = primary_stream.try_clone().unwrap();
let mut p_writer = primary_stream;
let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
let handshake = match decode_replica_message(&frame).unwrap() {
ReplicaMessage::Handshake(h) => h,
_ => panic!("expected Handshake"),
};
assert_eq!(handshake.last_sequence, 0);
let mut buf = Vec::new();
encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
buf.clear();
encode_input_batch_with_seq(42, &mut buf);
p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
buf.clear();
let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
let ack = match decode_replica_message(&frame).unwrap() {
ReplicaMessage::Ack(a) => a,
_ => panic!("expected Ack"),
};
assert_eq!(ack.acked_sequence, 42);
let end_seq = replica_handle.join().unwrap();
assert_eq!(end_seq, 42);
}
#[test]
fn multiple_data_batches_acked_in_order() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let replica_handle = std::thread::spawn(move || {
let mut reader = replica_stream.try_clone().unwrap();
let mut writer = replica_stream;
let mut buf = Vec::new();
encode_handshake(
&Handshake {
last_sequence: 0,
chain_hash: [0u8; 32],
epoch: 0,
},
&mut buf,
);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
buf.clear();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
assert!(matches!(
decode_primary_message(&frame).unwrap(),
PrimaryMessage::StreamStart { .. }
));
let mut acked_seqs = Vec::new();
for _ in 0..3 {
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
let slots: Vec<InputSlot> =
try_decode_input_batch(&frame).expect("decode InputBatch");
let end_seq = slots
.last()
.map(|s| s.sequence)
.expect("InputBatch carried at least one slot");
acked_seqs.push(end_seq);
encode_ack(
&Ack {
acked_sequence: end_seq,
in_memory_sequence: end_seq,
},
&mut buf,
);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
buf.clear();
}
acked_seqs
});
let mut p_reader = primary_stream.try_clone().unwrap();
let mut p_writer = primary_stream;
let mut buf = Vec::new();
let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
assert!(matches!(
decode_replica_message(&frame).unwrap(),
ReplicaMessage::Handshake(_)
));
encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
buf.clear();
for seq in [10u64, 20, 30] {
encode_input_batch_with_seq(seq, &mut buf);
p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
buf.clear();
}
for expected_seq in [10u64, 20, 30] {
let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
let ack = match decode_replica_message(&frame).unwrap() {
ReplicaMessage::Ack(a) => a,
other => panic!("expected Ack, got {other:?}"),
};
assert_eq!(ack.acked_sequence, expected_seq);
}
let acked = replica_handle.join().unwrap();
assert_eq!(acked, vec![10, 20, 30]);
}
#[test]
fn heartbeat_encode_contains_sequence() {
let mut buf = Vec::new();
encode_heartbeat(999, &mut buf);
let payload = &buf[4..];
match decode_primary_message(payload).unwrap() {
PrimaryMessage::Heartbeat { sequence, .. } => {
assert_eq!(sequence, 999);
}
other => panic!("expected Heartbeat, got {other:?}"),
}
}
#[test]
fn replica_mid_stream_handshake_with_nonzero_sequence() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let replica_handle = std::thread::spawn(move || {
let mut reader = replica_stream.try_clone().unwrap();
let mut writer = replica_stream;
let mut buf = Vec::new();
encode_handshake(
&Handshake {
last_sequence: 100,
chain_hash: [0xBB; 32],
epoch: 0,
},
&mut buf,
);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
buf.clear();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::StreamStart { start_sequence, .. } => {
assert_eq!(
start_sequence, 100,
"StreamStart should echo replica's last_sequence"
);
}
other => panic!("expected StreamStart, got {other:?}"),
}
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
let end_sequence = slots
.last()
.map(|s| s.sequence)
.expect("InputBatch carried at least one slot");
assert!(
end_sequence > 100,
"InputBatch should be after replica's last_sequence"
);
});
let mut p_reader = primary_stream.try_clone().unwrap();
let mut p_writer = primary_stream;
let mut buf = Vec::new();
let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
let handshake = match decode_replica_message(&frame).unwrap() {
ReplicaMessage::Handshake(h) => h,
_ => panic!("expected Handshake"),
};
assert_eq!(handshake.last_sequence, 100);
assert_eq!(handshake.chain_hash, [0xBB; 32]);
encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
buf.clear();
encode_input_batch_with_seq(150, &mut buf);
p_writer.write_all(&buf).unwrap();
p_writer.flush().unwrap();
replica_handle.join().unwrap();
}
#[test]
fn receiver_refuses_stream_start_from_stale_primary() {
use std::os::unix::net::UnixStream;
use melin_transport_core::fence::FenceState;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
const STALE_EPOCH: u64 = 3;
let primary_handle = std::thread::spawn(move || {
let mut reader = primary_stream.try_clone().unwrap();
let mut writer = primary_stream;
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let handshake = match decode_replica_message(&frame).unwrap() {
ReplicaMessage::Handshake(h) => h,
_ => panic!("expected Handshake"),
};
assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
let mut buf = Vec::new();
encode_stream_start(
handshake.last_sequence,
1,
[0u8; 32],
Some(0),
STALE_EPOCH,
&mut buf,
);
writer.write_all(&buf).unwrap();
writer.flush().unwrap();
});
let fence_state = Arc::new(FenceState::new(5));
let mut r_reader = replica_stream.try_clone().unwrap();
let mut r_writer = replica_stream;
let mut buf = Vec::new();
encode_handshake(
&Handshake {
last_sequence: 0,
chain_hash: [0u8; 32],
epoch: fence_state.epoch(),
},
&mut buf,
);
r_writer.write_all(&buf).unwrap();
r_writer.flush().unwrap();
let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
let stream_epoch = match decode_primary_message(&frame).unwrap() {
PrimaryMessage::StreamStart { epoch, .. } => epoch,
other => panic!("expected StreamStart, got {other:?}"),
};
assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
assert!(
fence_state.refuses_primary(stream_epoch),
"replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
);
assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
assert!(!fence_state.refuses_primary(5), "same tenure is followed");
assert!(!fence_state.refuses_primary(6), "newer primary is followed");
primary_handle.join().unwrap();
}
#[test]
fn snapshot_begin_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::SnapshotBegin {
snapshot_len,
snap_sequence,
snap_chain_hash,
} => {
assert_eq!(snapshot_len, 1_000_000);
assert_eq!(snap_sequence, 42);
assert_eq!(snap_chain_hash, [0xAB; 32]);
}
_ => panic!("expected SnapshotBegin"),
}
}
#[test]
fn snapshot_chunk_encode_decode_round_trip() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
let mut buf = Vec::new();
encode_snapshot_chunk(&data, &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::SnapshotChunk(chunk) => {
assert_eq!(chunk, data);
}
_ => panic!("expected SnapshotChunk"),
}
}
#[test]
fn snapshot_end_encode_decode_round_trip() {
let mut buf = Vec::new();
encode_snapshot_end(0xDEADBEEF, &mut buf);
let payload = &buf[4..];
let msg = decode_primary_message(payload).unwrap();
match msg {
PrimaryMessage::SnapshotEnd { crc32c } => {
assert_eq!(crc32c, 0xDEADBEEF);
}
_ => panic!("expected SnapshotEnd"),
}
}
#[test]
fn snapshot_receiver_detects_length_mismatch() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let receiver = std::thread::spawn(move || -> String {
let mut reader = replica_stream.try_clone().unwrap();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
assert!(matches!(
decode_primary_message(&frame).unwrap(),
PrimaryMessage::NeedSnapshot,
));
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let (snap_len, _snap_sequence, _snap_chain_hash) =
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotBegin {
snapshot_len,
snap_sequence,
snap_chain_hash,
} => (snapshot_len, snap_sequence, snap_chain_hash),
other => panic!("expected SnapshotBegin, got {other:?}"),
};
let mut received: u64 = 0;
loop {
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotChunk(data) => {
received += data.len() as u64;
}
PrimaryMessage::SnapshotEnd { .. } => {
if received != snap_len {
return format!(
"snapshot length mismatch: expected {snap_len} bytes, got {received}"
);
}
return String::new(); }
other => panic!("unexpected message: {other:?}"),
}
}
});
let mut writer = primary_stream;
let mut buf = Vec::new();
let actual_data = vec![0xAA; 100];
let wrong_len = 999u64;
encode_need_snapshot(&mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_chunk(&actual_data, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
let crc = crc32c::crc32c(&actual_data);
encode_snapshot_end(crc, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
std::io::Write::flush(&mut writer).unwrap();
let error_msg = receiver.join().unwrap();
assert!(
error_msg.contains("length mismatch"),
"expected length mismatch error, got: {error_msg:?}"
);
}
#[test]
fn snapshot_receiver_detects_crc_mismatch() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let receiver = std::thread::spawn(move || -> String {
let mut reader = replica_stream.try_clone().unwrap();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
assert!(matches!(
decode_primary_message(&frame).unwrap(),
PrimaryMessage::NeedSnapshot,
));
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let snap_len = match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
other => panic!("expected SnapshotBegin, got {other:?}"),
};
let mut received_data = Vec::new();
let mut received: u64 = 0;
loop {
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotChunk(data) => {
received += data.len() as u64;
received_data.extend_from_slice(&data);
}
PrimaryMessage::SnapshotEnd {
crc32c: expected_crc,
} => {
if received != snap_len {
return format!("length mismatch: {snap_len} vs {received}");
}
let actual_crc = crc32c::crc32c(&received_data);
if actual_crc != expected_crc {
return format!(
"CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
);
}
return String::new();
}
other => panic!("unexpected message: {other:?}"),
}
}
});
let mut writer = primary_stream;
let mut buf = Vec::new();
let data = vec![0xAA; 100];
encode_need_snapshot(&mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_chunk(&data, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
let wrong_crc = !crc32c::crc32c(&data);
encode_snapshot_end(wrong_crc, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
std::io::Write::flush(&mut writer).unwrap();
let error_msg = receiver.join().unwrap();
assert!(
error_msg.contains("CRC mismatch"),
"expected CRC mismatch error, got: {error_msg:?}"
);
}
#[test]
fn snapshot_receiver_detects_chain_hash_mismatch() {
use std::os::unix::net::UnixStream;
let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
let receiver = std::thread::spawn(move || -> String {
let mut reader = replica_stream.try_clone().unwrap();
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
assert!(matches!(
decode_primary_message(&frame).unwrap(),
PrimaryMessage::NeedSnapshot,
));
let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
let (snap_len, _snap_sequence, snap_chain_hash) =
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotBegin {
snapshot_len,
snap_sequence,
snap_chain_hash,
} => (snapshot_len, snap_sequence, snap_chain_hash),
other => panic!("expected SnapshotBegin, got {other:?}"),
};
let mut received_data = Vec::new();
let mut received: u64 = 0;
loop {
let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
match decode_primary_message(&frame).unwrap() {
PrimaryMessage::SnapshotChunk(data) => {
received += data.len() as u64;
received_data.extend_from_slice(&data);
}
PrimaryMessage::SnapshotEnd {
crc32c: expected_crc,
} => {
assert_eq!(received, snap_len, "length should match");
let actual_crc = crc32c::crc32c(&received_data);
assert_eq!(actual_crc, expected_crc, "CRC should match");
break;
}
other => panic!("unexpected message: {other:?}"),
}
}
let loaded_hash = [0xFF; 32]; if loaded_hash != snap_chain_hash {
return format!(
"snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
loaded snapshot has {loaded_hash:02x?}"
);
}
String::new()
});
let mut writer = primary_stream;
let mut buf = Vec::new();
let data = vec![0xAA; 64];
let advertised_hash = [0xBB; 32];
encode_need_snapshot(&mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
encode_snapshot_chunk(&data, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
buf.clear();
let crc = crc32c::crc32c(&data);
encode_snapshot_end(crc, &mut buf);
std::io::Write::write_all(&mut writer, &buf).unwrap();
std::io::Write::flush(&mut writer).unwrap();
let error_msg = receiver.join().unwrap();
assert!(
error_msg.contains("chain hash mismatch"),
"expected chain hash mismatch error, got: {error_msg:?}"
);
}
#[test]
fn primary_rejects_snapshot_with_invalid_magic() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("test.snapshot");
let mut bad_snap = vec![0u8; 64];
bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
std::fs::write(&snap_path, &bad_snap).unwrap();
let snap_data = std::fs::read(&snap_path).unwrap();
assert!(
snap_data.len() >= 48,
"file should be big enough for header"
);
let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
assert_ne!(magic, 0x534E_4150);
assert_eq!(magic, 0xDEAD_BEEF);
}
#[test]
fn primary_rejects_snapshot_too_small_for_header() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("test.snapshot");
std::fs::write(&snap_path, [0u8; 20]).unwrap();
let snap_data = std::fs::read(&snap_path).unwrap();
assert!(
snap_data.len() < 48,
"file must be too small for header validation"
);
}
#[test]
fn decode_snapshot_begin_too_short() {
let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
let err = decode_primary_message(&payload).unwrap_err();
assert!(
err.to_string().contains("SnapshotBegin too short"),
"expected 'SnapshotBegin too short', got: {err}"
);
}
#[test]
fn decode_snapshot_end_too_short() {
let payload = [MSG_SNAPSHOT_END];
let err = decode_primary_message(&payload).unwrap_err();
assert!(
err.to_string().contains("SnapshotEnd too short"),
"expected 'SnapshotEnd too short', got: {err}"
);
}
#[test]
fn decode_snapshot_chunk_empty_data() {
let payload = [MSG_SNAPSHOT_CHUNK];
let msg = decode_primary_message(&payload).unwrap();
match msg {
PrimaryMessage::SnapshotChunk(data) => {
assert!(data.is_empty());
}
_ => panic!("expected SnapshotChunk"),
}
}
fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
}
#[test]
fn pending_ack_queue_push_and_pop_ready() {
let mut q = PendingAckQueue::new(8);
assert!(q.is_empty());
assert!(!q.is_full());
q.push(10, 100);
q.push(20, 200);
assert!(!q.is_empty());
let cursor = make_journal_cursor(5);
assert!(q.pop_ready(&cursor).is_none());
cursor.get().store(15, Ordering::Relaxed);
assert_eq!(q.pop_ready(&cursor), Some(100));
assert!(!q.is_empty());
cursor.get().store(25, Ordering::Relaxed);
assert_eq!(q.pop_ready(&cursor), Some(200));
assert!(q.is_empty());
}
#[test]
fn pending_ack_queue_pop_ready_returns_highest_sequence() {
let mut q = PendingAckQueue::new(8);
q.push(10, 100);
q.push(20, 200);
q.push(30, 300);
let cursor = make_journal_cursor(30);
assert_eq!(q.pop_ready(&cursor), Some(300));
assert!(q.is_empty());
}
#[test]
fn pending_ack_queue_capacity_and_full() {
let mut q = PendingAckQueue::new(8);
for i in 0..8 {
assert!(!q.is_full());
q.push(i as u64 + 1, (i + 1) as u64 * 100);
}
assert!(q.is_full());
}
#[test]
fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
let mut q = PendingAckQueue::new(4);
for i in 1..=4u64 {
q.push(i * 10, i * 100);
}
assert!(q.is_full());
assert_eq!(q.merged(), 0);
q.push(50, 500);
assert_eq!(q.merged(), 1);
assert_eq!(q.len(), 4, "merge must not grow the queue");
let cursor = make_journal_cursor(40);
assert_eq!(q.pop_ready(&cursor), Some(300));
assert!(!q.is_empty(), "merged entry is not durable yet");
cursor.get().store(50, Ordering::Relaxed);
assert_eq!(q.pop_ready(&cursor), Some(500));
assert!(q.is_empty());
}
#[test]
fn pending_ack_queue_merges_repeatedly_without_growing() {
let mut q = PendingAckQueue::new(2);
for i in 1..=100u64 {
q.push(i, i * 10);
}
assert_eq!(q.len(), 2);
assert_eq!(q.merged(), 98);
let cursor = make_journal_cursor(99);
assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
cursor.get().store(100, Ordering::Relaxed);
assert_eq!(q.pop_ready(&cursor), Some(1000));
}
#[test]
fn pending_ack_queue_pop_oldest_blocking() {
let mut q = PendingAckQueue::new(8);
q.push(10, 100);
q.push(20, 200);
let cursor = make_journal_cursor(25);
let seq = q.pop_oldest_blocking(
&cursor,
melin_pipeline::wait::WaitStrategy::BusySpin,
&AtomicBool::new(false),
);
assert_eq!(seq, Some(200));
assert!(q.is_empty());
}
#[test]
fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
let mut q = PendingAckQueue::new(8);
q.push(10, 100);
let cursor = make_journal_cursor(5);
let abort = AtomicBool::new(true);
assert_eq!(
q.pop_oldest_blocking(
&cursor,
melin_pipeline::wait::WaitStrategy::BusySpin,
&abort
),
None
);
assert!(!q.is_empty(), "aborted wait must not pop the entry");
assert_eq!(
q.pop_all_blocking(
&cursor,
melin_pipeline::wait::WaitStrategy::BusySpin,
&abort
),
None
);
}
#[test]
fn shutdown_pipeline_surfaces_journal_error_kind() {
let flag = AtomicBool::new(false);
let matching_joined = Arc::new(AtomicBool::new(false));
let mj = Arc::clone(&matching_joined);
let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
Err(melin_journal::JournalError::ReplicaChainDivergence {
sequence: 42,
expected: [1u8; 32],
actual: [2u8; 32],
})
});
let matching = std::thread::spawn(move || {
mj.store(true, Ordering::Release);
7u64
});
let drain = std::thread::spawn(|| {});
let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
assert!(
matching_joined.load(Ordering::Acquire),
"matching thread must be joined even when the journal failed"
);
match outcome {
TeardownOutcome::JournalFailed(
melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
) => assert_eq!(sequence, 42),
_ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
}
}
#[test]
fn shutdown_pipeline_clean_returns_both_states() {
let flag = AtomicBool::new(false);
let journal =
std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
let matching = std::thread::spawn(|| 7u64);
let drain = std::thread::spawn(|| {});
match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
TeardownOutcome::Clean(app, writer) => {
assert_eq!(app, 7);
assert_eq!(writer, 11);
}
_ => panic!("expected Clean"),
}
}
#[test]
fn shutdown_pipeline_panicked_stage_reports_panicked() {
let flag = AtomicBool::new(false);
let journal =
std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
let drain = std::thread::spawn(|| {});
assert!(matches!(
shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
TeardownOutcome::Panicked
));
}
fn teardown_fixture(
capacity: usize,
) -> (
ReplicaPipelineHandles<counter_server::Counter, u32>,
melin_pipeline::ring::Consumer<InputSlot>,
) {
handles_fixture(capacity, fsync_state_at(0, 0).1)
}
fn fsync_state_at(
journal_seq: u64,
ring_pos: u64,
) -> (
melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
) {
melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
journal_seq: melin_transport_core::WireSeq::new(journal_seq),
chain_hash: [0u8; 32],
input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
})
}
fn handles_fixture(
capacity: usize,
chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
melin_transport_core::pipeline::FsyncState,
>,
) -> (
ReplicaPipelineHandles<counter_server::Counter, u32>,
melin_pipeline::ring::Consumer<InputSlot>,
) {
let (input_producer, mut consumers) =
melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
.add_consumer()
.build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
let consumer = consumers.pop().expect("one consumer");
let handles = ReplicaPipelineHandles {
input_producer,
journal_cursor: Arc::new(make_journal_cursor(0)),
chain_hash_lock,
stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
genesis_entries: None,
journal_failed: Arc::new(AtomicBool::new(false)),
pipeline_shutdown: Arc::new(AtomicBool::new(false)),
journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
Ok(11)
}),
matching_handle: std::thread::spawn(counter_server::Counter::default),
drain_handle: std::thread::spawn(|| {}),
shadow_handle: None,
};
(handles, consumer)
}
#[test]
fn teardown_publishes_shutdown_sentinel() {
let (handles, mut consumer) = teardown_fixture(8);
assert!(matches!(
teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
TeardownOutcome::Clean(..)
));
let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
assert!(
slot.event.is_shutdown(),
"published slot must be the sentinel"
);
assert!(consumer.try_consume().is_none(), "exactly one sentinel");
}
fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
use std::os::unix::thread::JoinHandleExt;
let mut policy: libc::c_int = 0;
let mut param = libc::sched_param { sched_priority: 0 };
let ret =
unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
policy
}
#[test]
fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
eprintln!("skipped: this thread may run on CPU 0 only");
return;
};
let dir = tempfile::tempdir().expect("tempdir");
let policies = std::thread::spawn(move || {
melin_test_node::sched::set_batch_policy();
let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
&dir.path().join("replica.journal"),
1,
[7; 32],
Some(5),
)
.expect("create journal");
let placed = crate::layout::Placement::yielding(core);
let cores = crate::layout::PipelineCores {
journal_seq: placed,
matching: placed,
response: placed,
shadow: placed,
journal_prep: placed,
journal_disk: placed,
..crate::layout::PipelineCores::unpinned()
};
let handles = build_replica_pipeline_with_threads(
counter_server::Counter::default(),
writer,
cores,
melin_journal::StagingMode::Allocate,
3_600_000,
dir.path().join("replica.snapshot"),
std::time::Duration::ZERO,
Arc::new(melin_transport_core::fence::FenceState::new(0)),
&SharedLineagePolicy::new(None),
Arc::new(AtomicBool::new(true)),
&(),
)
.expect("build the replica pipeline");
let policies = [
("journal-seq", Some(policy_of(&handles.journal_handle))),
("matching", Some(policy_of(&handles.matching_handle))),
("drain", Some(policy_of(&handles.drain_handle))),
(
"replica-shadow",
handles.shadow_handle.as_ref().map(policy_of),
),
];
let outcome = teardown_replica_pipeline(handles);
assert!(
matches!(outcome, TeardownOutcome::Clean(..)),
"the pipeline must tear down cleanly"
);
policies
})
.join()
.expect("builder thread");
let inherited: Vec<&str> = policies
.iter()
.map(|(name, policy)| {
(
*name,
policy.unwrap_or_else(|| panic!("{name} was not spawned")),
)
})
.filter(|&(_, policy)| policy == libc::SCHED_BATCH)
.map(|(name, _)| name)
.collect();
assert!(
inherited.is_empty(),
"{inherited:?} started in the builder's scheduling context instead of their \
own placement; on an isolated core held by a real-time spinner they would \
never run"
);
}
#[test]
fn resume_point_waits_for_the_journal_to_cover_the_ring() {
let (mut fsync, reader) = fsync_state_at(10, 0);
let (mut handles, _consumer) = handles_fixture(8, reader);
for _ in 0..3 {
handles.input_producer.publish(InputSlot::default());
}
let journal = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
fsync.store(melin_transport_core::pipeline::FsyncState {
journal_seq: melin_transport_core::WireSeq::new(13),
chain_hash: [0u8; 32],
input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
});
});
let outcome = handles.settled_resume_point(|| false);
journal.join().unwrap();
assert!(
matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
"resumes past the ring's slots"
);
}
#[test]
fn resume_point_with_nothing_in_flight_is_immediate() {
let (_fsync, reader) = fsync_state_at(10, 0);
let (handles, _consumer) = handles_fixture(8, reader);
assert!(matches!(
handles.settled_resume_point(|| false),
ResumePoint::Settled(s) if s.journal_seq.get() == 10
));
}
#[test]
fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
let (_fsync, reader) = fsync_state_at(10, 0);
let (mut handles, _consumer) = handles_fixture(8, reader);
handles.input_producer.publish(InputSlot::default());
assert!(matches!(
handles.settled_resume_point(|| true),
ResumePoint::Interrupted
));
handles.journal_failed.store(true, Ordering::Release);
assert!(matches!(
handles.settled_resume_point(|| false),
ResumePoint::JournalFailed
));
}
#[test]
fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
let (_fsync, reader) = fsync_state_at(10, 0);
let (handles, _consumer) = handles_fixture(8, reader);
handles.journal_failed.store(true, Ordering::Release);
assert!(matches!(
handles.settled_resume_point(|| false),
ResumePoint::JournalFailed
));
}
#[test]
fn a_panicked_journal_stage_latches_the_failure() {
let failed = Arc::new(AtomicBool::new(false));
let healthy = Arc::new(AtomicBool::new(true));
let journal_handle = {
let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
std::thread::spawn(move || {
run_journal_stage(
|| -> Result<u32, melin_journal::JournalError> {
panic!("journal stage died without returning an error")
},
&failed,
&healthy,
)
})
};
assert!(journal_handle.join().is_err(), "the stage panicked");
assert!(failed.load(Ordering::Acquire), "the failure is latched");
assert!(
!healthy.load(Ordering::Acquire),
"the node reports unhealthy"
);
}
#[test]
fn resume_point_ends_when_the_journal_thread_panicked() {
let (_fsync, reader) = fsync_state_at(10, 0);
let (mut handles, _consumer) = handles_fixture(8, reader);
handles.input_producer.publish(InputSlot::default());
let failed = Arc::clone(&handles.journal_failed);
let healthy = Arc::new(AtomicBool::new(true));
let dead = std::thread::spawn(move || {
run_journal_stage(
|| -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
&failed,
&healthy,
)
});
assert!(dead.join().is_err(), "the stage panicked");
let give_up = AtomicBool::new(false);
let outcome = std::thread::scope(|s| {
let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !wait.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
give_up.store(true, Ordering::Relaxed);
wait.join().expect("wait thread")
});
assert!(
matches!(outcome, ResumePoint::JournalFailed),
"the wait must end on the panicked stage, not on the test's give-up"
);
}
#[test]
fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
let dir = tempfile::tempdir().expect("tempdir");
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let (input_producer, mut consumers) =
melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
.add_consumer()
.build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
let _consumer = consumers.pop().expect("one consumer");
let handles = ReplicaPipelineHandles {
input_producer,
journal_cursor: Arc::new(make_journal_cursor(0)),
chain_hash_lock: fsync_state_at(0, 0).1,
stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
genesis_entries: None,
journal_failed: Arc::new(AtomicBool::new(true)),
pipeline_shutdown: Arc::new(AtomicBool::new(false)),
journal_handle: std::thread::spawn(
|| -> Result<Writer, melin_journal::JournalError> {
Err(melin_journal::JournalError::ReplicaSequenceMismatch {
expected: 4,
actual: 1,
})
},
),
matching_handle: std::thread::spawn(counter_server::Counter::default),
drain_handle: std::thread::spawn(|| {}),
shadow_handle: None,
};
let mut pipeline = Some(handles);
let mut divergence_resyncs = 0u32;
let mut backoff = MAX_BACKOFF;
let mut closes = 0;
let after = handle_session_exit::<counter_server::Counter, Writer>(
journal_failed_while_disconnected(),
&mut pipeline,
&mut divergence_resyncs,
&mut backoff,
0,
&dir.path().join("r.journal"),
&dir.path().join("r.snapshot"),
&melin_transport_core::fence::FenceState::new(0),
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
&crate::promotion::PromotionRequest::new(),
|| closes += 1,
&(),
);
let AfterSession::Return(Err(err)) = after else {
panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
};
assert!(
err.to_string()
.contains("refused to journal sequence 1: the next sequence is 4"),
"the returned error names the journal's cause, got: {err}"
);
assert!(pipeline.is_none(), "the dead pipeline was torn down");
assert_eq!(divergence_resyncs, 0);
assert_eq!(closes, 1);
}
#[test]
fn every_session_exit_closes_the_connection_once() {
type Writer = melin_journal::BufferedWriter<CounterEvent>;
type Case = (&'static str, fn() -> SessionExit);
let exits: [Case; 6] = [
("shutdown", || SessionExit::Shutdown),
("promote", || SessionExit::Promote),
("fatal", || SessionExit::Fatal("protocol violation".into())),
("disconnected", || SessionExit::Disconnected),
("stream gap", || SessionExit::StreamGap("gap".into())),
("corrupted", || SessionExit::Corrupted("damaged".into())),
];
for (label, exit) in exits {
let dir = tempfile::tempdir().expect("tempdir");
let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
None;
let mut divergence_resyncs = 0u32;
let mut backoff = MAX_BACKOFF;
let shutdown = AtomicBool::new(true);
let mut closes = 0;
let _after = handle_session_exit::<counter_server::Counter, Writer>(
StreamingResult {
exit: exit(),
heard_from_primary: true,
},
&mut pipeline,
&mut divergence_resyncs,
&mut backoff,
0,
&dir.path().join("r.journal"),
&dir.path().join("r.snapshot"),
&melin_transport_core::fence::FenceState::new(0),
&SharedLineagePolicy::new(None),
&shutdown,
&crate::promotion::PromotionRequest::new(),
|| closes += 1,
&(),
);
assert_eq!(closes, 1, "{label}");
}
}
fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
}
fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
let dir = tempfile::tempdir().expect("tempdir");
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
let mut divergence_resyncs = 0u32;
let mut backoff = MAX_BACKOFF;
let shutdown = AtomicBool::new(true);
let promote = crate::promotion::PromotionRequest::new();
let after = handle_session_exit::<counter_server::Counter, Writer>(
StreamingResult {
exit,
heard_from_primary,
},
&mut pipeline,
&mut divergence_resyncs,
&mut backoff,
0,
&dir.path().join("r.journal"),
&dir.path().join("r.snapshot"),
&melin_transport_core::fence::FenceState::new(0),
&SharedLineagePolicy::new(None),
&shutdown,
&promote,
|| {},
&(),
);
assert!(matches!(after, AfterSession::Reconnect));
backoff
}
#[test]
fn disconnect_resets_backoff_when_primary_spoke() {
assert_eq!(
backoff_after_disconnect(true),
std::time::Duration::from_secs(2)
);
}
#[test]
fn disconnect_keeps_escalated_backoff_when_primary_silent() {
assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
}
#[test]
fn corrupted_entry_resets_backoff() {
assert_eq!(
backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
std::time::Duration::from_secs(2)
);
}
#[test]
fn stream_gap_reconnects_and_keeps_pipeline() {
let dir = tempfile::tempdir().expect("tempdir");
type Writer = melin_journal::BufferedWriter<CounterEvent>;
let (input_producer, mut consumers) =
melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
.add_consumer()
.build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
let _consumer = consumers.pop().expect("one consumer");
let writer_path = dir.path().join("w.journal");
let handles = ReplicaPipelineHandles {
input_producer,
journal_cursor: Arc::new(make_journal_cursor(0)),
chain_hash_lock: fsync_state_at(0, 0).1,
stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
genesis_entries: None,
journal_failed: Arc::new(AtomicBool::new(false)),
pipeline_shutdown: Arc::new(AtomicBool::new(false)),
journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
matching_handle: std::thread::spawn(counter_server::Counter::default),
drain_handle: std::thread::spawn(|| {}),
shadow_handle: None,
};
let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
Some(handles);
let mut divergence_resyncs = 0u32;
let mut backoff = MAX_BACKOFF;
let shutdown = AtomicBool::new(true);
let promote = crate::promotion::PromotionRequest::new();
let mut closed = false;
let after = handle_session_exit::<counter_server::Counter, Writer>(
StreamingResult {
exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
heard_from_primary: true,
},
&mut pipeline,
&mut divergence_resyncs,
&mut backoff,
2,
&dir.path().join("r.journal"),
&dir.path().join("r.snapshot"),
&melin_transport_core::fence::FenceState::new(0),
&SharedLineagePolicy::new(None),
&shutdown,
&promote,
|| closed = true,
&(),
);
assert!(
matches!(after, AfterSession::Reconnect),
"a stream gap must reconnect, not return an error"
);
assert!(
pipeline.is_some(),
"the pipeline must survive for the reconnect"
);
assert!(closed, "the transport must be closed before redialing");
assert_eq!(
backoff,
std::time::Duration::from_secs(2),
"backoff reset to 1s then doubled by the shared helper"
);
assert_eq!(divergence_resyncs, 0, "not a resync");
if let Some(p) = pipeline.take() {
let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
}
}
#[test]
fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
let (mut handles, mut consumer) = teardown_fixture(4);
for seq in 0..4 {
assert!(
handles
.input_producer
.try_publish(InputSlot {
sequence: seq,
..InputSlot::default()
})
.is_ok(),
"ring must have space while filling"
);
}
assert!(
handles
.input_producer
.try_publish(InputSlot::default())
.is_err(),
"ring must be full"
);
handles.journal_failed.store(true, Ordering::Release);
assert!(matches!(
teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
TeardownOutcome::Clean(..)
));
while let Some((_, slot)) = consumer.try_consume() {
assert!(!slot.event.is_shutdown(), "no sentinel may be published");
}
}
#[test]
fn pending_ack_queue_wraps_around() {
let mut q = PendingAckQueue::new(8);
let cursor = make_journal_cursor(100);
for round in 0..3 {
for i in 0..8 {
let target = (round * 8 + i) as u64 + 1;
q.push(target, target * 10);
}
assert!(q.is_full());
let seq = q.pop_ready(&cursor).expect("should be ready");
assert_eq!(seq, (round * 8 + 8) as u64 * 10);
assert!(q.is_empty());
}
}
#[test]
fn pending_ack_queue_pop_all_blocking_empty() {
let mut q = PendingAckQueue::new(8);
let cursor = make_journal_cursor(0);
assert!(
q.pop_all_blocking(
&cursor,
melin_pipeline::wait::WaitStrategy::BusySpin,
&AtomicBool::new(false)
)
.is_none()
);
}
#[test]
fn dual_track_returns_none_when_both_tracks_idle() {
let mut q = PendingAckQueue::new(8);
let cursor = make_journal_cursor(0);
assert!(
try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
"no advance on either track → no ack"
);
}
#[test]
fn dual_track_fires_on_persisted_advance_only() {
let mut q = PendingAckQueue::new(8);
q.push(10, 100);
let cursor = make_journal_cursor(20);
let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
assert_eq!(ack.acked_sequence, 100);
assert_eq!(ack.in_memory_sequence, 50);
}
#[test]
fn dual_track_fires_on_in_memory_advance_only() {
let mut q = PendingAckQueue::new(8);
let cursor = make_journal_cursor(0);
let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
assert_eq!(ack.acked_sequence, 100);
assert_eq!(ack.in_memory_sequence, 200);
}
#[test]
fn dual_track_coalesces_until_caller_updates_trackers() {
let mut q = PendingAckQueue::new(8);
q.push(10, 100);
let cursor = make_journal_cursor(20);
let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
assert_eq!(ack1.acked_sequence, 100);
let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
.expect("call 2 fires on in-memory advance");
assert_eq!(
ack2.acked_sequence, 0,
"no new persisted pop → unwrap_or(last_sent)"
);
assert_eq!(ack2.in_memory_sequence, 80);
}
#[test]
fn dual_track_no_duplicate_ack_after_backpressure_drain() {
let mut q = PendingAckQueue::new(4);
for i in 1..=4u64 {
q.push(i * 10, i * 100);
}
assert!(q.is_full());
let cursor = make_journal_cursor(40);
let drained = q.pop_ready(&cursor).expect("all entries durable");
assert_eq!(drained, 400);
assert!(q.is_empty());
let mut last_sent_acked = drained;
let mut last_sent_in_mem = 450u64;
assert!(
try_flush_dual_track(
&mut q,
&cursor,
last_sent_in_mem,
last_sent_acked,
last_sent_in_mem,
)
.is_none(),
"no advance on either track after backpressure drain → no duplicate ack",
);
q.push(50, 500);
let cursor2 = make_journal_cursor(50);
let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
.expect("fresh batch after drain fires a new ack");
assert!(
ack.acked_sequence >= last_sent_acked,
"regression: drain sent {last_sent_acked} but next ack carries {}",
ack.acked_sequence,
);
assert_eq!(ack.acked_sequence, 500);
assert_eq!(ack.in_memory_sequence, 500);
last_sent_acked = ack.acked_sequence;
last_sent_in_mem = ack.in_memory_sequence;
assert!(
try_flush_dual_track(
&mut q,
&cursor2,
last_sent_in_mem,
last_sent_acked,
last_sent_in_mem,
)
.is_none(),
"post-resume idle → no further ack",
);
}
}