1use std::sync::Arc;
61#[cfg(test)]
62use std::sync::atomic::AtomicU64;
63use std::sync::atomic::{AtomicBool, Ordering};
64
65use melin_journal::{BufferedWriter, JournalWrite};
66
67use melin_app::Application;
68use melin_transport_core::lineage::SharedLineagePolicy;
69use melin_transport_core::node_store::NodeStore;
70use melin_transport_core::pipeline::{InputSlot, OutputSlot};
71use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
72use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
73
74mod auth;
75#[cfg(feature = "dpdk")]
76mod dpdk;
77mod receiver_transport;
78mod tcp_receiver;
79mod tcp_sender;
80#[cfg(any(feature = "dpdk", test))]
85mod validation_worker;
86#[cfg(any(feature = "dpdk", test))]
90mod join_worker;
91
92use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
93
94pub(crate) fn log_replica_live(slot: usize, sent: &SentHighWater) {
98 tracing::info!(
99 slot,
100 sent_sequence = sent.get(),
101 "replica caught up, streaming live"
102 );
103}
104
105pub use melin_transport_core::replication::ReplicationMetrics;
110pub(crate) use melin_transport_core::replication::ack_queue::{
111 PendingAckQueue, try_flush_dual_track,
112};
113pub(crate) use melin_transport_core::replication::protocol::PrimaryMessage;
114pub(crate) use melin_transport_core::replication::{ReplicaCursors, SentHighWater};
115
116#[cfg(feature = "dpdk")]
117pub(crate) use dpdk::REPL_DISPATCH_BURST;
118#[cfg(feature = "dpdk")]
119pub(crate) use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
120pub use tcp_receiver::{ReceiverResult, run_receiver};
121pub use tcp_sender::{ReplicationListener, Sender, run_sender};
122
123#[derive(Clone)]
130pub struct ReplicaControlPlane {
131 pub(crate) promote: crate::promotion::PromotionRequest,
134 pub(crate) tip_ready: std::sync::Arc<AtomicBool>,
138 pub(crate) journal_tip: melin_transport_core::AdvertisedJournalTip,
141 pub(crate) primary_link_up: std::sync::Arc<AtomicBool>,
148 pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
153 pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
172 pub(crate) pipeline_healthy: std::sync::Arc<AtomicBool>,
180}
181
182impl ReplicaControlPlane {
183 pub fn new() -> Self {
187 Self {
188 promote: crate::promotion::PromotionRequest::new(),
189 tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
190 journal_tip: melin_transport_core::AdvertisedJournalTip::new(
191 melin_transport_core::WireSeq::new(0),
192 ),
193 primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
194 primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
195 lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
196 pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
197 }
198 }
199}
200
201impl Default for ReplicaControlPlane {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
216 if !tracing::enabled!(tracing::Level::DEBUG) {
217 return;
218 }
219 let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
223 let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
224 let rc = unsafe {
225 libc::getsockopt(
226 fd,
227 libc::IPPROTO_TCP,
228 libc::TCP_INFO,
229 &mut info as *mut _ as *mut libc::c_void,
230 &mut len,
231 )
232 };
233 if rc != 0 {
234 tracing::debug!(
235 slot,
236 tag,
237 errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
238 "tcp_info getsockopt failed"
239 );
240 return;
241 }
242 tracing::debug!(
243 slot,
244 tag,
245 state = info.tcpi_state,
246 ca_state = info.tcpi_ca_state,
247 rtt_us = info.tcpi_rtt,
248 rttvar_us = info.tcpi_rttvar,
249 snd_cwnd = info.tcpi_snd_cwnd,
250 snd_ssthresh = info.tcpi_snd_ssthresh,
251 snd_mss = info.tcpi_snd_mss,
252 rcv_mss = info.tcpi_rcv_mss,
253 unacked = info.tcpi_unacked,
254 retrans = info.tcpi_retrans,
255 total_retrans = info.tcpi_total_retrans,
256 lost = info.tcpi_lost,
257 rcv_space = info.tcpi_rcv_space,
258 rto_us = info.tcpi_rto,
259 "tcp_info"
260 );
261}
262
263pub(super) fn sleep_checking_flags(
267 duration: std::time::Duration,
268 shutdown: &AtomicBool,
269 promote: &crate::promotion::PromotionRequest,
270) {
271 let deadline = std::time::Instant::now() + duration;
272 while std::time::Instant::now() < deadline {
273 if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
274 return;
275 }
276 std::thread::sleep(std::time::Duration::from_millis(100));
277 }
278}
279
280pub(super) fn sleep_then_double_backoff(
287 backoff: &mut std::time::Duration,
288 shutdown: &AtomicBool,
289 promote: &crate::promotion::PromotionRequest,
290) {
291 sleep_checking_flags(*backoff, shutdown, promote);
292 *backoff = (*backoff * 2).min(MAX_BACKOFF);
293}
294
295pub(super) enum TeardownOutcome<A, W> {
299 Clean(A, W),
302 JournalFailed(melin_journal::JournalError),
308 Panicked,
310}
311
312pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
327 shutdown_flag: &AtomicBool,
328 journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
329 matching_handle: std::thread::JoinHandle<A>,
330 drain_handle: std::thread::JoinHandle<()>,
331 shadow_handle: Option<std::thread::JoinHandle<()>>,
332) -> TeardownOutcome<A, W> {
333 shutdown_flag.store(true, Ordering::Release);
347 let journal_result = journal_handle.join();
352 let matching_result = matching_handle.join();
353 let _ = drain_handle.join();
354 if let Some(h) = shadow_handle {
355 let _ = h.join();
356 }
357 let writer = match journal_result {
358 Ok(Ok(w)) => w,
359 Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
361 Err(_) => return TeardownOutcome::Panicked,
362 };
363 match matching_result {
364 Ok(app) => TeardownOutcome::Clean(app, writer),
365 Err(_) => TeardownOutcome::Panicked,
366 }
367}
368
369pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
378 pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
379 pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
380 pub(super) chain_hash_lock:
385 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
386 pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
391 pub(super) genesis_entries: Option<u64>,
399 pub(super) journal_failed: Arc<AtomicBool>,
407 pub(super) pipeline_shutdown: Arc<AtomicBool>,
410 pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
411 pub(super) matching_handle: std::thread::JoinHandle<A>,
412 pub(super) drain_handle: std::thread::JoinHandle<()>,
413 pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
414}
415
416pub(super) enum ResumePoint {
418 Settled(melin_transport_core::pipeline::FsyncState),
421 JournalFailed,
425 Interrupted,
428}
429
430impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
431 pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
446 let published = self.input_producer.peek_cursor();
450 let mut outcome = ResumePoint::Interrupted;
451 melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
454 if self.journal_failed.load(Ordering::Acquire) {
460 outcome = ResumePoint::JournalFailed;
461 return true;
462 }
463 let state = self.chain_hash_lock.load();
464 if state.input_ring_seq.get() >= published {
465 outcome = ResumePoint::Settled(state);
466 return true;
467 }
468 interrupted()
469 });
470 outcome
471 }
472}
473
474pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
481 StreamingResult {
482 exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
483 heard_from_primary: false,
484 }
485}
486
487fn run_journal_stage<W, E: std::fmt::Display>(
498 run: impl FnOnce() -> Result<W, E>,
499 failed: &AtomicBool,
500 healthy: &AtomicBool,
501) -> Result<W, E> {
502 struct PanicLatch<'a> {
505 failed: &'a AtomicBool,
506 healthy: &'a AtomicBool,
507 armed: bool,
508 }
509 impl Drop for PanicLatch<'_> {
510 fn drop(&mut self) {
511 if self.armed {
512 self.failed.store(true, Ordering::Release);
513 self.healthy.store(false, Ordering::Release);
514 tracing::error!("replica journal stage panicked — session teardown");
515 }
516 }
517 }
518 let mut guard = PanicLatch {
519 failed,
520 healthy,
521 armed: true,
522 };
523 let result = run();
524 guard.armed = false;
525 if let Err(ref e) = result {
526 failed.store(true, Ordering::Release);
532 healthy.store(false, Ordering::Release);
535 tracing::error!(error = %e, "replica journal stage failed — session teardown");
536 }
537 result
538}
539
540pub(super) type ReplicaHandles<A> =
545 ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
546
547#[allow(clippy::too_many_arguments)]
551pub(super) fn build_replica_pipeline_with_threads<A>(
552 mut app: A,
553 writer: BufferedWriter<A::Event>,
554 cores: crate::layout::PipelineCores,
555 staging_mode: melin_journal::StagingMode,
560 snapshot_interval_ms: u64,
561 store: &NodeStore,
563 group_commit_delay: std::time::Duration,
564 fence_state: Arc<melin_transport_core::fence::FenceState>,
565 lineage_policy: &SharedLineagePolicy,
569 pipeline_healthy: Arc<AtomicBool>,
575 sizing: &A::Sizing,
578) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
579where
580 A: Application + Send + 'static,
581 A::Event: Send + Sync + 'static,
582 A::Report: Send + 'static,
583 A::QueryResponse: Send + 'static,
584{
585 <A as Application>::prefault(&mut app, sizing);
590 let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
591
592 let enable_shadow = snapshot_interval_ms > 0;
593 let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
598 epoch: fence_state.epoch(),
599 ack_policy: lineage_policy.load(),
600 };
601 let genesis_entries = writer.read_header_info()?.genesis_entries;
604 let starting_wire_seq = writer.next_sequence();
607 let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
608 app,
609 writer,
610 4096, group_commit_delay,
612 cores.stage_waits(),
613 enable_shadow,
614 fence_state,
615 );
616
617 let pipeline_shutdown = Arc::new(AtomicBool::new(false));
618
619 let ps = Arc::clone(&pipeline_shutdown);
620 let journal_core = cores.journal_seq.core;
621 let mut journal_stage = pipeline.journal_stage;
622 let stream_marks = melin_transport_core::pipeline::StreamMarks::shared();
627 journal_stage.set_stream_marks(Arc::clone(&stream_marks));
628 cores.place_journal_children(&mut journal_stage);
629 journal_stage.set_staging_mode(staging_mode);
630 let journal_failed = Arc::new(AtomicBool::new(false));
631 let journal_failed_latch = Arc::clone(&journal_failed);
632 let sequencer = journal_stage.start()?;
636 pipeline_healthy.store(true, Ordering::Release);
639 let abandon = |e: String| -> Box<dyn std::error::Error> {
649 pipeline_shutdown.store(true, Ordering::Release);
650 e.into()
651 };
652 let journal_handle =
653 melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
654 run_journal_stage(
655 || sequencer.run(&ps),
656 &journal_failed_latch,
657 &pipeline_healthy,
658 )
659 })
660 .map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
661
662 let ps = Arc::clone(&pipeline_shutdown);
663 let matching_core = cores.matching.core;
664 let matching_stage = pipeline.matching_stage;
665 let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
666 matching_stage.run(&ps)
667 })
668 .map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
669
670 let ps = Arc::clone(&pipeline_shutdown);
673 let drain = cores.response;
674 let drain_consumer = pipeline.drain_consumer;
675 let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
676 let mut consumer = drain_consumer;
677 let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
678 let mut waiter = drain.wait.waiter();
679 loop {
680 if ps.load(Ordering::Relaxed) {
681 return;
682 }
683 let count = consumer.consume_batch(&mut batch, 256);
684 if count == 0 {
685 waiter.idle();
686 } else {
687 waiter.reset();
688 }
689 }
690 })
691 .map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
692
693 let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
694 let snap_path = store.snapshot().to_path_buf();
695 let chain_lock = pipeline.chain_hash_lock.clone();
696 let ps = Arc::clone(&pipeline_shutdown);
697 let shadow = cores.shadow;
698 Some(
699 melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
700 melin_transport_core::shadow::run(
701 shadow_cons,
702 shadow_app,
703 snap_path,
704 std::time::Duration::from_millis(snapshot_interval_ms),
705 chain_lock,
706 &ps,
707 shadow.wait,
708 shadow_initial_lineage,
709 starting_wire_seq,
710 genesis_entries,
711 );
712 })
713 .map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
714 )
715 } else {
716 None
717 };
718
719 Ok(ReplicaPipelineHandles {
720 input_producer: pipeline.input_producer,
721 journal_cursor: pipeline.cursors.journal_ring_arc(),
722 chain_hash_lock: pipeline.chain_hash_lock,
723 stream_marks,
724 genesis_entries,
725 journal_failed,
726 pipeline_shutdown,
727 journal_handle,
728 matching_handle,
729 drain_handle,
730 shadow_handle,
731 })
732}
733
734pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
739 mut handles: ReplicaPipelineHandles<A, W>,
740) -> TeardownOutcome<A, W> {
741 while !handles.journal_failed.load(Ordering::Acquire) {
758 match handles
759 .input_producer
760 .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
761 {
762 Ok(_) => break,
763 Err(_) => std::thread::yield_now(),
765 }
766 }
767 shutdown_pipeline::<A, W>(
768 &handles.pipeline_shutdown,
769 handles.journal_handle,
770 handles.matching_handle,
771 handles.drain_handle,
772 handles.shadow_handle,
773 )
774}
775
776pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
787
788#[allow(clippy::type_complexity)]
805pub(super) fn recover_replica_state<A, W>(
806 store: &NodeStore,
807 fence_state: &melin_transport_core::fence::FenceState,
808 lineage_policy: &SharedLineagePolicy,
809 sizing: &A::Sizing,
812) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
813where
814 A: Application,
815 W: melin_journal::JournalWrite<A::Event>,
816{
817 let journal_path = store.journal();
820 let snapshot_path = store.snapshot();
821 remove_restamp_staging(journal_path)?;
822 if !store.journal_lineage_exists()? {
823 lineage_policy.store(None);
826 return Ok((None, None, 0u64, [0u8; 32]));
827 }
828 let engine = if snapshot_path.try_exists()? {
829 tracing::info!("recovering replica from snapshot + journal");
830 melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
831 snapshot_path,
832 journal_path,
833 )?
834 } else {
835 let mut app = A::default();
836 <A as Application>::prefault(&mut app, sizing);
837 melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
838 };
839 let next = engine.next_sequence();
840 let last = next.saturating_sub(1);
841 let hash = engine.chain_hash().unwrap_or([0u8; 32]);
842 adopt_recovered_lineage(fence_state, lineage_policy, engine.recovered_lineage());
846 let (app, writer) = engine.into_parts();
847 Ok((Some(app), Some(writer), last, hash))
848}
849
850fn adopt_recovered_lineage(
856 fence_state: &melin_transport_core::fence::FenceState,
857 lineage_policy: &SharedLineagePolicy,
858 lineage: melin_transport_core::lineage::Lineage,
859) {
860 fence_state.observe_epoch(lineage.epoch);
861 lineage_policy.store(lineage.ack_policy);
862}
863
864pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
870
871pub(in crate::replication) enum AfterSession<A, W> {
875 Return(ReceiverResult<A, W>),
879 Resync {
884 app: Option<A>,
885 journal_writer: Option<W>,
886 last_sequence: u64,
887 chain_hash: [u8; 32],
888 },
889 Reconnect,
893}
894
895impl<A, W> AfterSession<A, W> {
896 pub(in crate::replication) fn adopt(
901 self,
902 app: &mut Option<A>,
903 journal_writer: &mut Option<W>,
904 last_sequence: &mut u64,
905 chain_hash: &mut [u8; 32],
906 ) -> Option<ReceiverResult<A, W>> {
907 match self {
908 AfterSession::Return(r) => Some(r),
909 AfterSession::Resync {
910 app: recovered_app,
911 journal_writer: recovered_writer,
912 last_sequence: recovered_sequence,
913 chain_hash: recovered_hash,
914 } => {
915 *app = recovered_app;
916 *journal_writer = recovered_writer;
917 *last_sequence = recovered_sequence;
918 *chain_hash = recovered_hash;
919 None
920 }
921 AfterSession::Reconnect => None,
922 }
923 }
924}
925
926#[allow(clippy::too_many_arguments)]
950pub(in crate::replication) fn handle_session_exit<A, W>(
951 result: StreamingResult,
952 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
953 divergence_resyncs: &mut u32,
954 backoff: &mut std::time::Duration,
955 last_sequence: u64,
956 store: &NodeStore,
957 fence_state: &melin_transport_core::fence::FenceState,
958 lineage_policy: &SharedLineagePolicy,
959 shutdown: &AtomicBool,
960 promote: &crate::promotion::PromotionRequest,
961 close: impl FnOnce(),
962 sizing: &A::Sizing,
965) -> AfterSession<A, W>
966where
967 A: Application + Send + 'static,
968 W: JournalWrite<A::Event> + Send + 'static,
969{
970 let StreamingResult {
971 exit,
972 heard_from_primary,
973 } = result;
974
975 close();
976
977 match exit {
978 SessionExit::Shutdown => {
979 if let Some(p) = pipeline.take() {
980 let _ = teardown_replica_pipeline::<A, W>(p);
981 }
982 AfterSession::Return(Ok(None))
983 }
984
985 SessionExit::Promote => AfterSession::Return(match pipeline.take() {
986 Some(p) => match teardown_replica_pipeline::<A, W>(p) {
987 TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
988 _ => Err("pipeline failed during promotion".into()),
989 },
990 None => Err("pipeline missing on promote".into()),
991 }),
992
993 SessionExit::Fatal(e) => {
994 let outcome = match pipeline.take() {
995 Some(p) => teardown_replica_pipeline::<A, W>(p),
996 None => return AfterSession::Return(Err(e)),
999 };
1000 let je = match outcome {
1011 TeardownOutcome::JournalFailed(
1012 je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
1013 ) => je,
1014 TeardownOutcome::JournalFailed(je) => {
1015 return AfterSession::Return(Err(format!("{e}: {je}").into()));
1016 }
1017 TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1018 return AfterSession::Return(Err(e));
1019 }
1020 };
1021
1022 *divergence_resyncs += 1;
1023 let attempt = *divergence_resyncs;
1024 if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1025 return AfterSession::Return(Err(format!(
1026 "mid-stream chain divergence recurred {attempt} times — giving up on \
1027 in-process resync (each cycle archives the local journal and re-seeds \
1028 from the primary; recurrence at this rate means the primary keeps \
1029 streaming history that forks from what it announces): {je}"
1030 )
1031 .into()));
1032 }
1033 tracing::warn!(
1034 error = %je,
1035 attempt,
1036 max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1037 "mid-stream chain divergence — re-deriving local state for in-process resync"
1038 );
1039 match recover_replica_state::<A, W>(store, fence_state, lineage_policy, sizing) {
1040 Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1041 app,
1042 journal_writer,
1043 last_sequence: seq,
1044 chain_hash: hash,
1045 },
1046 Err(e) => AfterSession::Return(Err(e)),
1047 }
1048 }
1049
1050 SessionExit::StreamGap(e) => {
1051 *backoff = std::time::Duration::from_secs(1);
1060 tracing::warn!(
1061 error = %e,
1062 last_sequence,
1063 "replication stream broke contiguity — reconnecting from the durable position"
1064 );
1065 sleep_then_double_backoff(backoff, shutdown, promote);
1066 AfterSession::Reconnect
1067 }
1068
1069 SessionExit::Corrupted(e) => {
1070 *backoff = std::time::Duration::from_secs(1);
1088 tracing::warn!(
1089 error = %e,
1090 last_sequence,
1091 backoff_secs = backoff.as_secs(),
1092 "replicated entry failed its integrity check — refused, reconnecting to \
1093 fetch it again; recurrence points at the network, a NIC or memory"
1094 );
1095 sleep_then_double_backoff(backoff, shutdown, promote);
1096 AfterSession::Reconnect
1097 }
1098
1099 SessionExit::Disconnected => {
1100 if heard_from_primary {
1109 *backoff = std::time::Duration::from_secs(1);
1110 }
1111 tracing::warn!(
1112 last_sequence,
1113 heard_from_primary,
1114 backoff_secs = backoff.as_secs(),
1115 "reconnecting to primary"
1116 );
1117 sleep_then_double_backoff(backoff, shutdown, promote);
1118 AfterSession::Reconnect
1119 }
1120 }
1121}
1122
1123pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1133 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1134 app: &mut Option<A>,
1135 journal_writer: &mut Option<W>,
1136) -> ReceiverResult<A, W>
1137where
1138 A: Application + Send + 'static,
1139 W: JournalWrite<A::Event> + Send + 'static,
1140{
1141 if let Some(p) = pipeline.take()
1142 && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1143 {
1144 *app = Some(e);
1145 *journal_writer = Some(w);
1146 }
1147 match (app.take(), journal_writer.take()) {
1148 (Some(e), Some(w)) => Ok(Some((e, w))),
1149 _ => Err("promotion requested but no local state available".into()),
1150 }
1151}
1152
1153struct ResyncTransfer<A> {
1155 app: A,
1157 snap_sequence: u64,
1159 snap_chain_hash: [u8; 32],
1162 seed_len: u64,
1164 lineage: melin_transport_core::lineage::Lineage,
1167}
1168
1169pub(in crate::replication) enum ResyncDecision {
1172 Ready {
1177 segment_start_sequence: u64,
1178 anchor_hash: [u8; 32],
1179 genesis_entries: Option<u64>,
1182 resume_sequence: u64,
1183 },
1184 Retry,
1188}
1189
1190fn receive_resync_transfer<A, S>(
1200 source: &mut S,
1201 store: &NodeStore,
1202) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1203where
1204 A: Application + Send,
1205 S: ControlFrameSource,
1206{
1207 let (snap_len, snap_sequence, snap_chain_hash) =
1208 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1209 PrimaryMessage::SnapshotBegin {
1210 snapshot_len,
1211 snap_sequence,
1212 snap_chain_hash,
1213 } => (snapshot_len, snap_sequence, snap_chain_hash),
1214 other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1215 };
1216
1217 tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1218 let snapshot_path = store.snapshot();
1219 let journal_path = store.journal();
1220 let tmp_path = store.snapshot_tmp();
1221 receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1222 std::fs::rename(&tmp_path, snapshot_path)?;
1223 melin_journal::segment::fsync_parent_dir(snapshot_path)?;
1226 tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1227
1228 let (snap_app, snap_header) =
1231 source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1232 let snap_hash = snap_header.chain_hash;
1233 if snap_hash != snap_chain_hash {
1234 return Err(format!(
1235 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1236 loaded snapshot has {snap_hash:02x?}"
1237 )
1238 .into());
1239 }
1240
1241 let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1246 PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1247 other => {
1248 return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1249 }
1250 };
1251 let seed_tmp = journal_path.with_extension("seed.tmp");
1252 receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1253 source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1259 if let Err(e) =
1260 melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1261 {
1262 let _ = std::fs::remove_file(&seed_tmp);
1263 return Err(format!("segment seed failed structural verification: {e}").into());
1264 }
1265 std::fs::rename(&seed_tmp, journal_path)?;
1266 melin_journal::segment::fsync_parent_dir(journal_path)?;
1267 Ok(())
1268 })?;
1269 Ok(ResyncTransfer {
1270 app: snap_app,
1271 snap_sequence,
1272 snap_chain_hash,
1273 seed_len,
1274 lineage: snap_header.lineage,
1275 })
1276}
1277
1278#[allow(clippy::too_many_arguments)]
1291pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1292 divergent: bool,
1293 source: &mut S,
1294 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1295 app: &mut Option<A>,
1296 journal_writer: &mut Option<W>,
1297 store: &NodeStore,
1298 fence_state: &melin_transport_core::fence::FenceState,
1299 control: &ReplicaControlPlane,
1300 last_sequence: &mut u64,
1301 chain_hash: &mut [u8; 32],
1302) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1303where
1304 A: Application + Send + 'static,
1305 A::Event: Send,
1308 W: JournalWrite<A::Event> + Send + 'static,
1309 S: ControlFrameSource,
1310{
1311 if divergent {
1316 tracing::warn!(
1317 last_sequence = *last_sequence,
1318 "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1319 );
1320 } else {
1321 tracing::info!("primary requires snapshot transfer — receiving snapshot");
1322 }
1323
1324 let old_pipeline = pipeline.take();
1331 let old_app = app.take();
1332 let old_writer = journal_writer.take();
1333
1334 let reason = if divergent {
1345 ArchiveReason::Divergent
1346 } else {
1347 ArchiveReason::Resync
1348 };
1349 source.serviced(move || {
1352 if let Some(p) = old_pipeline {
1353 let _ = teardown_replica_pipeline::<A, W>(p);
1357 }
1358 drop(old_app);
1360 drop(old_writer);
1361 archive_local_lineage(store, reason).map(drop)
1364 })?;
1365 *last_sequence = 0;
1368 *chain_hash = [0u8; 32];
1369 control.lineage_ack_policy.store(None);
1370 control
1375 .journal_tip
1376 .reset(melin_transport_core::WireSeq::new(0));
1377
1378 let ResyncTransfer {
1379 app: snap_app,
1380 snap_sequence,
1381 snap_chain_hash,
1382 seed_len,
1383 lineage: snap_lineage,
1384 } = match receive_resync_transfer::<A, S>(source, store) {
1385 Ok(v) => v,
1386 Err(e) => {
1387 tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1388 let _ = std::fs::remove_file(store.snapshot());
1392 return Ok(ResyncDecision::Retry);
1393 }
1394 };
1395 let journal_path = store.journal();
1396 adopt_recovered_lineage(fence_state, &control.lineage_ack_policy, snap_lineage);
1400 *app = Some(snap_app);
1401
1402 let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
1410 let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1411 if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1412 return Err(format!(
1413 "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1414 hash — inconsistent primary"
1415 )
1416 .into());
1417 }
1418 let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1419 *journal_writer = Some(writer);
1420
1421 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1424 PrimaryMessage::StreamStart {
1425 start_sequence,
1426 segment_start_sequence,
1427 anchor_hash,
1428 genesis_entries,
1429 epoch,
1430 } => {
1431 if segment_start_sequence != seeded_info.starting_sequence
1432 || anchor_hash != seeded_info.anchor_hash
1433 || genesis_entries != seeded_info.genesis_entries
1434 {
1435 return Err(format!(
1436 "post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
1437 {}) disagrees with the transferred segment seed (start {}, genesis {}) — \
1438 inconsistent primary",
1439 describe_genesis(genesis_entries),
1440 seeded_info.starting_sequence,
1441 describe_genesis(seeded_info.genesis_entries),
1442 )
1443 .into());
1444 }
1445 fence_state.observe_epoch(epoch);
1452 control.primary_observed.store(true, Ordering::Release);
1453 tracing::info!(
1454 start_sequence,
1455 epoch,
1456 "streaming resumed after snapshot transfer"
1457 );
1458 control
1461 .journal_tip
1462 .reset(melin_transport_core::WireSeq::new(snap_sequence));
1463 Ok(ResyncDecision::Ready {
1464 segment_start_sequence,
1465 anchor_hash,
1466 genesis_entries,
1467 resume_sequence: snap_sequence,
1468 })
1469 }
1470 other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1471 }
1472}
1473
1474fn describe_genesis(genesis_entries: Option<u64>) -> String {
1477 match genesis_entries {
1478 Some(n) => format!("{n} entries"),
1479 None => "unknown".to_string(),
1480 }
1481}
1482
1483pub(in crate::replication) fn local_lineage_genesis<A, W>(
1494 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1495 journal_writer: &Option<W>,
1496) -> Result<Option<Option<u64>>, melin_journal::JournalError>
1497where
1498 A: Application,
1499 W: JournalWrite<A::Event> + Send + 'static,
1500{
1501 match (pipeline, journal_writer) {
1502 (Some(p), _) => Ok(Some(p.genesis_entries)),
1503 (None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
1506 (None, None) => Ok(None),
1507 }
1508}
1509
1510fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
1513 journal_path.with_extension("restamp.tmp")
1514}
1515
1516fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
1520 match std::fs::remove_file(restamp_staging_path(journal_path)) {
1521 Ok(()) => Ok(()),
1522 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1524 Err(e) => Err(e),
1525 }
1526}
1527
1528pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
1553 journal_writer: &mut Option<W>,
1554 lineage: (u64, [u8; 32], Option<u64>),
1555) -> Result<(), melin_journal::JournalError>
1556where
1557 E: melin_app::AppEvent,
1558 W: JournalWrite<E>,
1559{
1560 let (segment_start_sequence, anchor_hash, advertised) = lineage;
1561 let Some(writer) = journal_writer.as_ref() else {
1562 return Ok(());
1563 };
1564 if advertised.is_none()
1565 || segment_start_sequence != 1
1566 || writer.segment_starting_sequence() != 1
1567 || writer.next_sequence() != 1
1568 {
1569 return Ok(());
1570 }
1571 let info = writer.read_header_info()?;
1572 if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
1573 return Ok(());
1574 }
1575 let journal_path = writer.path().to_path_buf();
1576 if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
1577 return Ok(());
1578 }
1579
1580 let staging = restamp_staging_path(&journal_path);
1581 remove_restamp_staging(&journal_path)?;
1582 let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
1583 let valid_end = staged.valid_end();
1584 drop(staged);
1585 drop(journal_writer.take());
1587 std::fs::rename(&staging, &journal_path)?;
1588 melin_journal::segment::fsync_parent_dir(&journal_path)?;
1589 *journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
1590 tracing::info!(
1591 journal = %journal_path.display(),
1592 genesis_entries = ?advertised,
1593 "recorded the primary's genesis length in this replica's empty journal"
1594 );
1595 Ok(())
1596}
1597
1598pub(in crate::replication) enum StreamStartVerdict {
1601 StalePrimary,
1606 Follow((u64, [u8; 32], Option<u64>)),
1610}
1611
1612pub(in crate::replication) struct StreamStart {
1616 pub start_sequence: u64,
1618 pub lineage: (u64, [u8; 32], Option<u64>),
1622 pub epoch: u64,
1624}
1625
1626pub(in crate::replication) fn accept_stream_start<A, W>(
1646 start: StreamStart,
1647 transport: &'static str,
1648 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1649 journal_writer: &mut Option<W>,
1650 fence_state: &melin_transport_core::fence::FenceState,
1651 control: &ReplicaControlPlane,
1652) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
1653where
1654 A: Application,
1655 W: JournalWrite<A::Event> + Send + 'static,
1656{
1657 let StreamStart {
1658 start_sequence,
1659 lineage,
1660 epoch,
1661 } = start;
1662 if fence_state.refuses_primary(epoch) {
1663 tracing::warn!(
1664 transport,
1665 primary_epoch = epoch,
1666 our_epoch = fence_state.epoch(),
1667 "primary is behind our fencing epoch — refusing to follow stale primary"
1668 );
1669 return Ok(StreamStartVerdict::StalePrimary);
1670 }
1671 if pipeline.is_none() {
1674 adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
1675 }
1676 if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
1677 check_advertised_genesis(local, lineage.2)?;
1678 }
1679 fence_state.observe_epoch(epoch);
1680 control.primary_observed.store(true, Ordering::Release);
1681 tracing::info!(transport, start_sequence, epoch, "streaming started");
1682 Ok(StreamStartVerdict::Follow(lineage))
1683}
1684
1685pub(in crate::replication) fn check_advertised_genesis(
1700 local: Option<u64>,
1701 advertised: Option<u64>,
1702) -> Result<(), String> {
1703 match (local, advertised) {
1704 (Some(local), Some(advertised)) if local != advertised => Err(format!(
1705 "refusing to follow the primary: its journal records a genesis of {advertised} \
1706 entries but this replica's records {local} — the two journals do not share one \
1707 lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
1708 then resync this replica from scratch (move its journal, archives and snapshots \
1709 aside) or restore the primary"
1710 )),
1711 _ => Ok(()),
1712 }
1713}
1714
1715#[cfg(test)]
1716mod tests {
1717 use std::io::Write;
1718 use std::sync::Arc;
1719
1720 use super::auth::{authenticate_replica, authenticate_with_primary};
1721 use super::*;
1722 use counter_server::CounterEvent;
1728 type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1729 use melin_transport_core::replication::protocol::{
1730 Ack, Handshake, MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE,
1731 MSG_SNAPSHOT_BEGIN, MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, ReplicaMessage,
1732 decode_auth_result, decode_challenge, decode_challenge_response, decode_primary_message,
1733 decode_replica_message, encode_ack, encode_auth_failed, encode_auth_ok, encode_chain_check,
1734 encode_challenge, encode_challenge_response, encode_handshake, encode_hash_mismatch,
1735 encode_heartbeat, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1736 encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1737 read_frame,
1738 };
1739 use melin_transport_core::replication_wire::{encode_input_batch, try_decode_input_batch};
1740
1741 fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1745 let slot = InputSlot {
1746 connection_id: 0,
1747 request_id: melin_transport_core::pipeline::RequestId(0),
1748 key_hash: 0,
1749 sequence: end_sequence,
1750 timestamp_ns: 0,
1751 event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1752 publish_ts: Default::default(),
1753 recv_ts: Default::default(),
1754 };
1755 encode_input_batch(&[slot], buf).expect("encode InputBatch");
1756 }
1757
1758 #[test]
1759 fn handshake_encode_decode_round_trip() {
1760 let handshake = Handshake {
1761 last_sequence: 42,
1762 chain_hash: [0xAB; 32],
1763 epoch: 9,
1765 };
1766 let mut buf = Vec::new();
1767 encode_handshake(&handshake, &mut buf);
1768
1769 let payload = &buf[4..];
1771 let msg = decode_replica_message(payload).unwrap();
1772 match msg {
1773 ReplicaMessage::Handshake(h) => {
1774 assert_eq!(h.last_sequence, 42);
1775 assert_eq!(h.chain_hash, [0xAB; 32]);
1776 assert_eq!(h.epoch, 9);
1777 }
1778 _ => panic!("expected Handshake"),
1779 }
1780 }
1781
1782 #[test]
1783 fn ack_encode_decode_round_trip() {
1784 let ack = Ack {
1785 acked_sequence: 1000,
1786 in_memory_sequence: 1024,
1787 };
1788 let mut buf = Vec::new();
1789 encode_ack(&ack, &mut buf);
1790
1791 let payload = &buf[4..];
1792 let msg = decode_replica_message(payload).unwrap();
1793 match msg {
1794 ReplicaMessage::Ack(a) => {
1795 assert_eq!(a.acked_sequence, 1000);
1796 assert_eq!(a.in_memory_sequence, 1024);
1797 }
1798 _ => panic!("expected Ack"),
1799 }
1800 }
1801
1802 #[test]
1810 fn ack_wire_byte_pattern() {
1811 let ack = Ack {
1812 acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1813 in_memory_sequence: 0x1122_3344_5566_7788,
1814 };
1815 let mut buf = Vec::new();
1816 encode_ack(&ack, &mut buf);
1817 let expected: &[u8] = &[
1820 0x11, 0x00, 0x00, 0x00, 0x02, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, ];
1825 assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1826 }
1827
1828 #[test]
1832 fn an_advertised_genesis_length_must_match_a_known_one() {
1833 check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1834 check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1835 let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1836 assert!(err.contains("refusing to follow the primary"), "{err}");
1837 assert!(
1838 err.contains("genesis of 4") && err.contains("records 3"),
1839 "{err}"
1840 );
1841 check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1842 check_advertised_genesis(None, Some(3)).expect("ours unknown");
1843 check_advertised_genesis(Some(3), None).expect("theirs unknown");
1844 check_advertised_genesis(None, None).expect("both unknown");
1845 }
1846
1847 #[test]
1852 fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1853 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1854 let dir = tempfile::tempdir().expect("tempdir");
1855 let anchor = [9u8; 32];
1856 let header = |w: &Option<Writer>| {
1857 w.as_ref()
1858 .expect("writer")
1859 .read_header_info()
1860 .expect("header")
1861 .genesis_entries
1862 };
1863
1864 let path = dir.path().join("empty.journal");
1866 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1867 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1868 assert_eq!(header(&w), Some(3));
1869 let adopted = w.as_ref().expect("writer");
1870 assert_eq!(adopted.next_sequence(), 1);
1871 assert_eq!(adopted.path(), path.as_path());
1872 assert_eq!(
1873 melin_journal::segment::read_header_info(&path)
1874 .expect("on disk")
1875 .anchor_hash,
1876 anchor
1877 );
1878 assert!(!path.with_extension("restamp.tmp").exists());
1879
1880 let path = dir.path().join("both-unknown.journal");
1882 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1883 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1884 assert_eq!(header(&w), None);
1885
1886 let path = dir.path().join("other-anchor.journal");
1888 let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1889 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1890 assert_eq!(header(&w), None);
1891
1892 let path = dir.path().join("known.journal");
1895 let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1896 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1897 assert_eq!(header(&w), Some(2));
1898
1899 let path = dir.path().join("history.journal");
1901 let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1902 let seq = writer.allocate_sequence();
1903 writer
1904 .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1905 .expect("encode");
1906 writer.flush_batch_sync().expect("flush");
1907 let mut w = Some(writer);
1908 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1909 assert_eq!(header(&w), None);
1910
1911 let mut none: Option<Writer> = None;
1913 adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1914 assert!(none.is_none());
1915 }
1916
1917 #[test]
1921 fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1922 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1923 let dir = tempfile::tempdir().expect("tempdir");
1924 let fence = melin_transport_core::fence::FenceState::new(0);
1925 let journal = dir.path().join("replica.journal");
1926 let store = NodeStore::resolve(journal.clone(), None).expect("store");
1927 let staging = journal.with_extension("restamp.tmp");
1928
1929 std::fs::write(&staging, b"debris").expect("write staging");
1931 let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1932 &store,
1933 &fence,
1934 &SharedLineagePolicy::new(None),
1935 &(),
1936 )
1937 .expect("recover");
1938 assert!(app.is_none() && writer.is_none());
1939 assert!(!staging.exists(), "swept without a journal");
1940
1941 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1943 let seq = w.allocate_sequence();
1944 w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1945 .expect("encode");
1946 w.flush_batch_sync().expect("flush");
1947 drop(w);
1948 std::fs::write(&staging, b"debris").expect("write staging");
1949 let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
1950 &store,
1951 &fence,
1952 &SharedLineagePolicy::new(None),
1953 &(),
1954 )
1955 .expect("recover");
1956 assert!(writer.is_some());
1957 assert_eq!(last, 1);
1958 assert!(!staging.exists(), "swept beside a journal with history");
1959 }
1960
1961 #[test]
1967 fn replica_recovery_stores_the_lineage_policy_whole() {
1968 use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
1969 use melin_transport_core::lineage::LineagePolicy;
1970 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1971 let dir = tempfile::tempdir().expect("tempdir");
1972 let fence = melin_transport_core::fence::FenceState::new(0);
1973 let journal = dir.path().join("replica.journal");
1974 let store = NodeStore::resolve(journal.clone(), None).expect("store");
1975 let stale = Some(LineagePolicy {
1978 change: AckPolicyChange {
1979 policy: AckPolicyByte(2),
1980 unwitnessed: true,
1981 },
1982 wire_seq: melin_transport_core::WireSeq::new(9),
1983 previous: Some(AckPolicyByte(1)),
1984 });
1985 let cell = SharedLineagePolicy::new(stale);
1986 let recover = |cell: &SharedLineagePolicy| {
1987 recover_replica_state::<counter_server::Counter, Writer>(&store, &fence, cell, &())
1988 .expect("recover")
1989 };
1990
1991 recover(&cell);
1993 assert_eq!(cell.load(), None);
1994
1995 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1997 let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
1998 let seq = w.allocate_sequence();
1999 w.encode_event(seq, 0, &event, 0).expect("encode");
2000 w.flush_batch_sync().expect("flush");
2001 };
2002 append(&mut w, JournalEvent::Tick { now_ns: 0 });
2003 cell.store(stale);
2004 recover(&cell);
2005 assert_eq!(cell.load(), None);
2006
2007 append(&mut w, JournalEvent::EpochBump { epoch: 4 });
2008 append(
2009 &mut w,
2010 JournalEvent::AckPolicyChange(AckPolicyChange {
2011 policy: AckPolicyByte(0),
2012 unwitnessed: false,
2013 }),
2014 );
2015 drop(w);
2016 cell.store(stale);
2017 recover(&cell);
2018 assert_eq!(
2019 cell.load(),
2020 Some(LineagePolicy {
2021 change: AckPolicyChange {
2022 policy: AckPolicyByte(0),
2023 unwitnessed: false,
2024 },
2025 wire_seq: melin_transport_core::WireSeq::new(3),
2026 previous: None,
2027 }),
2028 "the journal's own record, at the change's sequence"
2029 );
2030 assert_eq!(fence.epoch(), 4);
2031 }
2032
2033 #[test]
2037 fn a_stale_primary_is_refused_before_anything_is_adopted() {
2038 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2039 let dir = tempfile::tempdir().expect("tempdir");
2040 let path = dir.path().join("replica.journal");
2041 let anchor = [9u8; 32];
2042 let mut journal_writer =
2043 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2044 let fence = melin_transport_core::fence::FenceState::new(5);
2045 let control = ReplicaControlPlane::new();
2046
2047 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2048 StreamStart {
2049 start_sequence: 1,
2050 lineage: (1, anchor, Some(3)),
2051 epoch: 4,
2052 },
2053 "test",
2054 &None,
2055 &mut journal_writer,
2056 &fence,
2057 &control,
2058 )
2059 .expect("a refusal is not an error");
2060
2061 assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
2062 assert_eq!(fence.epoch(), 5);
2063 assert!(!control.primary_observed.load(Ordering::Acquire));
2064 let header = journal_writer
2065 .as_ref()
2066 .expect("writer")
2067 .read_header_info()
2068 .expect("header");
2069 assert_eq!(header.genesis_entries, None, "journal left untouched");
2070 }
2071
2072 #[test]
2077 fn following_a_primary_adopts_its_epoch_and_genesis() {
2078 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2079 let dir = tempfile::tempdir().expect("tempdir");
2080 let path = dir.path().join("replica.journal");
2081 let anchor = [9u8; 32];
2082 let mut journal_writer =
2083 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2084 let fence = melin_transport_core::fence::FenceState::new(5);
2085 let control = ReplicaControlPlane::new();
2086 let held = Some(melin_transport_core::lineage::LineagePolicy::after(
2087 None,
2088 melin_transport_core::WireSeq::new(1),
2089 melin_journal::AckPolicyChange {
2090 policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
2091 unwitnessed: false,
2092 },
2093 ));
2094 control.lineage_ack_policy.store(held);
2095
2096 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2097 StreamStart {
2098 start_sequence: 1,
2099 lineage: (1, anchor, Some(3)),
2100 epoch: 7,
2101 },
2102 "test",
2103 &None,
2104 &mut journal_writer,
2105 &fence,
2106 &control,
2107 )
2108 .expect("same lineage");
2109
2110 let StreamStartVerdict::Follow(lineage) = verdict else {
2111 panic!("expected Follow");
2112 };
2113 assert_eq!(lineage, (1, anchor, Some(3)));
2114 assert_eq!(fence.epoch(), 7);
2115 assert!(control.primary_observed.load(Ordering::Acquire));
2116 assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
2117 let header = journal_writer
2118 .as_ref()
2119 .expect("writer")
2120 .read_header_info()
2121 .expect("header");
2122 assert_eq!(header.genesis_entries, Some(3));
2123 }
2124
2125 #[test]
2128 fn a_disagreeing_genesis_length_is_fatal() {
2129 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2130 let dir = tempfile::tempdir().expect("tempdir");
2131 let path = dir.path().join("replica.journal");
2132 let anchor = [9u8; 32];
2133 let mut journal_writer =
2134 Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2135 let fence = melin_transport_core::fence::FenceState::new(5);
2136 let control = ReplicaControlPlane::new();
2137
2138 let err = accept_stream_start::<counter_server::Counter, Writer>(
2139 StreamStart {
2140 start_sequence: 1,
2141 lineage: (1, anchor, Some(3)),
2142 epoch: 7,
2143 },
2144 "test",
2145 &None,
2146 &mut journal_writer,
2147 &fence,
2148 &control,
2149 )
2150 .err()
2151 .expect("different lineages");
2152
2153 assert!(err.to_string().contains("refusing to follow the primary"));
2154 assert_eq!(fence.epoch(), 5);
2155 }
2156
2157 #[test]
2164 fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2165 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2166 let dir = tempfile::tempdir().expect("tempdir");
2167 let path = dir.path().join("replica.journal");
2168
2169 let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2170 let mut journal_writer = Some(writer);
2171 assert_eq!(
2172 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2173 .expect("header read"),
2174 Some(Some(5)),
2175 );
2176
2177 let (input_producer, mut consumers) =
2178 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2179 .add_consumer()
2180 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2181 let _consumer = consumers.pop().expect("one consumer");
2182 let owned = journal_writer.take().expect("writer");
2183 let pipeline = Some(ReplicaPipelineHandles {
2184 input_producer,
2185 journal_cursor: Arc::new(make_journal_cursor(0)),
2186 chain_hash_lock: fsync_state_at(0, 0).1,
2187 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
2188 genesis_entries: Some(5),
2189 journal_failed: Arc::new(AtomicBool::new(false)),
2190 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2191 journal_handle: std::thread::spawn(
2192 move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2193 ),
2194 matching_handle: std::thread::spawn(counter_server::Counter::default),
2195 drain_handle: std::thread::spawn(|| {}),
2196 shadow_handle: None,
2197 });
2198 std::fs::remove_file(&path).expect("simulate the rotation gap");
2199 assert_eq!(
2200 local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2201 Some(Some(5)),
2202 );
2203
2204 assert_eq!(
2205 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2206 .expect("nothing to read"),
2207 None,
2208 );
2209 }
2210
2211 #[test]
2212 fn stream_start_encode_decode_round_trip() {
2213 let mut buf = Vec::new();
2214 encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
2216
2217 let payload = &buf[4..];
2218 let msg = decode_primary_message(payload).unwrap();
2219 match msg {
2220 PrimaryMessage::StreamStart {
2221 start_sequence,
2222 segment_start_sequence,
2223 anchor_hash,
2224 genesis_entries,
2225 epoch,
2226 } => {
2227 assert_eq!(start_sequence, 99);
2228 assert_eq!(segment_start_sequence, 42);
2229 assert_eq!(anchor_hash, [0xAA; 32]);
2230 assert_eq!(genesis_entries, Some(7));
2231 assert_eq!(epoch, 5);
2232 }
2233 _ => panic!("expected StreamStart"),
2234 }
2235 }
2236
2237 #[test]
2238 fn heartbeat_encode_decode_round_trip() {
2239 let mut buf = Vec::new();
2240 encode_heartbeat(123, &mut buf);
2241
2242 let payload = &buf[4..];
2243 let msg = decode_primary_message(payload).unwrap();
2244 match msg {
2245 PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
2246 _ => panic!("expected Heartbeat"),
2247 }
2248 }
2249
2250 #[test]
2251 fn need_snapshot_encode_decode_round_trip() {
2252 let mut buf = Vec::new();
2253 encode_need_snapshot(&mut buf);
2254
2255 let payload = &buf[4..];
2256 let msg = decode_primary_message(payload).unwrap();
2257 assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2258 }
2259
2260 #[test]
2261 fn hash_mismatch_encode_decode_round_trip() {
2262 let mut buf = Vec::new();
2263 encode_hash_mismatch(&mut buf);
2264
2265 let payload = &buf[4..];
2266 let msg = decode_primary_message(payload).unwrap();
2267 assert!(matches!(msg, PrimaryMessage::HashMismatch));
2268 }
2269
2270 #[test]
2271 fn rotate_encode_decode_round_trip() {
2272 let mut buf = Vec::new();
2273 encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2274
2275 let payload = &buf[4..];
2276 let msg = decode_primary_message(payload).unwrap();
2277 match msg {
2278 PrimaryMessage::Rotate {
2279 boundary_seq,
2280 tail_hash,
2281 } => {
2282 assert_eq!(boundary_seq, 7_000_000);
2283 assert_eq!(tail_hash, [0xBB; 32]);
2284 }
2285 _ => panic!("expected Rotate"),
2286 }
2287 }
2288
2289 #[test]
2290 fn chain_check_encode_decode_round_trip() {
2291 let mut buf = Vec::new();
2292 encode_chain_check(555, &[0xCC; 32], &mut buf);
2293
2294 let payload = &buf[4..];
2295 let msg = decode_primary_message(payload).unwrap();
2296 match msg {
2297 PrimaryMessage::ChainCheck {
2298 sequence,
2299 chain_hash,
2300 } => {
2301 assert_eq!(sequence, 555);
2302 assert_eq!(chain_hash, [0xCC; 32]);
2303 }
2304 _ => panic!("expected ChainCheck"),
2305 }
2306 }
2307
2308 #[test]
2312 fn rotate_wire_byte_pattern() {
2313 let mut hash = [0u8; 32];
2314 for (i, b) in hash.iter_mut().enumerate() {
2315 *b = i as u8;
2316 }
2317 let mut buf = Vec::new();
2318 encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2319 let mut expected = vec![
2322 0x29, 0x00, 0x00, 0x00, 0x16, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, ];
2326 expected.extend_from_slice(&hash);
2327 assert_eq!(
2328 buf.as_slice(),
2329 expected.as_slice(),
2330 "Rotate wire layout drifted"
2331 );
2332 }
2333
2334 #[test]
2335 fn segment_seed_begin_encode_decode_round_trip() {
2336 let mut buf = Vec::new();
2337 encode_segment_seed_begin(123_456, &mut buf);
2338
2339 let payload = &buf[4..];
2340 let msg = decode_primary_message(payload).unwrap();
2341 match msg {
2342 PrimaryMessage::SegmentSeedBegin { seed_len } => {
2343 assert_eq!(seed_len, 123_456);
2344 }
2345 _ => panic!("expected SegmentSeedBegin"),
2346 }
2347 }
2348
2349 #[test]
2350 fn unknown_replica_message_type_is_error() {
2351 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2352 let result = decode_replica_message(&payload);
2353 assert!(result.is_err());
2354 }
2355
2356 #[test]
2357 fn unknown_primary_message_type_is_error() {
2358 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2359 let result = decode_primary_message(&payload);
2360 assert!(result.is_err());
2361 }
2362
2363 #[test]
2364 fn read_frame_enforces_max_size() {
2365 let mut buf = Vec::new();
2367 buf.extend_from_slice(&1000u32.to_le_bytes());
2368 buf.extend_from_slice(&[0u8; 100]); let mut cursor = std::io::Cursor::new(buf);
2371 let result = read_frame(&mut cursor, 64);
2372 assert!(result.is_err());
2373 }
2374
2375 #[test]
2376 fn challenge_encode_decode_round_trip() {
2377 let nonce = [0x42; 32];
2378 let mut buf = Vec::new();
2379 encode_challenge(&nonce, &mut buf);
2380
2381 let payload = &buf[4..];
2382 let decoded = decode_challenge(payload).unwrap();
2383 assert_eq!(decoded, nonce);
2384 }
2385
2386 #[test]
2387 fn challenge_response_encode_decode_round_trip() {
2388 let sig = [0xAA; 64];
2389 let pubkey = [0xBB; 32];
2390 let mut buf = Vec::new();
2391 encode_challenge_response(&sig, &pubkey, &mut buf);
2392
2393 let payload = &buf[4..];
2394 let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2395 assert_eq!(decoded_sig, sig);
2396 assert_eq!(decoded_pubkey, pubkey);
2397 }
2398
2399 #[test]
2400 fn auth_ok_encode_decode_round_trip() {
2401 let mut buf = Vec::new();
2402 encode_auth_ok(&mut buf);
2403
2404 let payload = &buf[4..];
2405 assert!(decode_auth_result(payload).unwrap());
2406 }
2407
2408 #[test]
2409 fn auth_failed_encode_decode_round_trip() {
2410 let mut buf = Vec::new();
2411 encode_auth_failed(&mut buf);
2412
2413 let payload = &buf[4..];
2414 assert!(!decode_auth_result(payload).unwrap());
2415 }
2416
2417 #[test]
2418 fn decode_challenge_rejects_wrong_tag() {
2419 let mut payload = [0u8; 33];
2420 payload[0] = MSG_AUTH_OK;
2421 assert!(decode_challenge(&payload).is_err());
2422 }
2423
2424 #[test]
2425 fn decode_challenge_response_rejects_short_payload() {
2426 let payload = [MSG_CHALLENGE_RESPONSE; 10]; assert!(decode_challenge_response(&payload).is_err());
2428 }
2429
2430 #[test]
2431 fn auth_round_trip_valid_key() {
2432 use ed25519_dalek::SigningKey;
2433 use std::os::unix::net::UnixStream;
2434
2435 let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2436 let pub_b64 = base64::Engine::encode(
2437 &base64::engine::general_purpose::STANDARD,
2438 repl_key.verifying_key().to_bytes(),
2439 );
2440 let keys_content = format!("replication {pub_b64} test-replica\n");
2441 let authorized_keys =
2442 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2443 .unwrap();
2444
2445 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2446 primary_stream
2447 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2448 .unwrap();
2449 replica_stream
2450 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2451 .unwrap();
2452
2453 let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2454 let replica_handle = std::thread::spawn(move || {
2455 let mut conn = replica_stream;
2456 authenticate_with_primary(&mut conn, &repl_key_clone)
2457 });
2458
2459 let mut conn = primary_stream;
2460 authenticate_replica(&mut conn, &authorized_keys).unwrap();
2461
2462 replica_handle.join().unwrap().unwrap();
2463 }
2464
2465 #[test]
2466 fn auth_rejects_unknown_key() {
2467 use ed25519_dalek::SigningKey;
2468 use std::os::unix::net::UnixStream;
2469
2470 let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2472 let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2473 let pub_b64 = base64::Engine::encode(
2474 &base64::engine::general_purpose::STANDARD,
2475 authorized_key.verifying_key().to_bytes(),
2476 );
2477 let keys_content = format!("replication {pub_b64} authorized-replica\n");
2478 let authorized_keys =
2479 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2480 .unwrap();
2481
2482 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2483 primary_stream
2484 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2485 .unwrap();
2486 replica_stream
2487 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2488 .unwrap();
2489
2490 let replica_handle = std::thread::spawn(move || {
2491 let mut conn = replica_stream;
2492 authenticate_with_primary(&mut conn, &rogue_key)
2493 });
2494
2495 let mut conn = primary_stream;
2496 let result = authenticate_replica(&mut conn, &authorized_keys);
2497 assert!(result.is_err());
2498 assert!(result.unwrap_err().to_string().contains("unknown"));
2499
2500 let replica_result = replica_handle.join().unwrap();
2502 assert!(replica_result.is_err());
2503 }
2504
2505 #[test]
2506 fn auth_rejects_wrong_permission() {
2507 use ed25519_dalek::SigningKey;
2508 use std::os::unix::net::UnixStream;
2509
2510 let key = SigningKey::from_bytes(&[0xCC; 32]);
2512 let pub_b64 = base64::Engine::encode(
2513 &base64::engine::general_purpose::STANDARD,
2514 key.verifying_key().to_bytes(),
2515 );
2516 let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2517
2518 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2519 primary_stream
2520 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2521 .unwrap();
2522 replica_stream
2523 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2524 .unwrap();
2525
2526 let replica_handle = std::thread::spawn(move || {
2527 let mut conn = replica_stream;
2528 authenticate_with_primary(&mut conn, &key)
2529 });
2530
2531 let mut conn = primary_stream;
2532 let result = authenticate_replica(&mut conn, &authorized_keys);
2533 assert!(result.is_err());
2534 assert_eq!(
2535 result.unwrap_err().to_string(),
2536 "key listed as trader, expected replication"
2537 );
2538
2539 let replica_result = replica_handle.join().unwrap();
2540 assert!(replica_result.is_err());
2541 }
2542
2543 #[test]
2546 fn auth_rejects_invalid_signature() {
2547 use ed25519_dalek::SigningKey;
2548 use std::os::unix::net::UnixStream;
2549
2550 let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2552 let pub_b64 = base64::Engine::encode(
2553 &base64::engine::general_purpose::STANDARD,
2554 correct_key.verifying_key().to_bytes(),
2555 );
2556 let keys_content = format!("replication {pub_b64} test-replica\n");
2557 let authorized_keys =
2558 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2559 .unwrap();
2560
2561 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2562 primary_stream
2563 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2564 .unwrap();
2565 replica_stream
2566 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2567 .unwrap();
2568
2569 let replica_handle = std::thread::spawn(move || {
2572 use melin_transport_core::replication::protocol::*;
2573
2574 let mut reader = replica_stream.try_clone().unwrap();
2575 let mut writer = replica_stream;
2576
2577 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2579 let nonce = decode_challenge(&frame).unwrap();
2580
2581 let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2583 let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2584 let correct_pubkey = correct_key.verifying_key();
2585
2586 let mut buf = Vec::with_capacity(128);
2587 encode_challenge_response(
2588 &bad_signature.to_bytes(),
2589 correct_pubkey.as_bytes(),
2590 &mut buf,
2591 );
2592 writer.write_all(&buf).unwrap();
2593 writer.flush().unwrap();
2594
2595 let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2597 let ok = decode_auth_result(&result_frame).unwrap();
2598 assert!(!ok, "should receive auth failure");
2599 });
2600
2601 let mut conn = primary_stream;
2602 let result = authenticate_replica(&mut conn, &authorized_keys);
2603 assert!(result.is_err());
2604 assert!(
2605 result
2606 .unwrap_err()
2607 .to_string()
2608 .contains("signature verification failed"),
2609 "should fail on signature verification"
2610 );
2611
2612 replica_handle.join().unwrap();
2613 }
2614
2615 #[test]
2616 fn sender_receiver_end_to_end() {
2617 use std::os::unix::net::UnixStream;
2618
2619 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2621
2622 let replica_handle = std::thread::spawn(move || {
2624 let mut reader = replica_stream.try_clone().unwrap();
2625 let mut writer = replica_stream;
2626
2627 let mut buf = Vec::new();
2629 let handshake = Handshake {
2630 last_sequence: 0,
2631 chain_hash: [0u8; 32],
2632 epoch: 0,
2633 };
2634 encode_handshake(&handshake, &mut buf);
2635 writer.write_all(&buf).unwrap();
2636 writer.flush().unwrap();
2637 buf.clear();
2638
2639 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2641 let msg = decode_primary_message(&frame).unwrap();
2642 assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2643
2644 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2646 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2647 let end_seq = slots
2648 .last()
2649 .map(|s| s.sequence)
2650 .expect("InputBatch carried at least one slot");
2651
2652 let ack = Ack {
2654 acked_sequence: end_seq,
2655 in_memory_sequence: end_seq,
2656 };
2657 encode_ack(&ack, &mut buf);
2658 writer.write_all(&buf).unwrap();
2659 writer.flush().unwrap();
2660
2661 end_seq
2662 });
2663
2664 let mut p_reader = primary_stream.try_clone().unwrap();
2666 let mut p_writer = primary_stream;
2667
2668 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2670 let handshake = match decode_replica_message(&frame).unwrap() {
2671 ReplicaMessage::Handshake(h) => h,
2672 _ => panic!("expected Handshake"),
2673 };
2674 assert_eq!(handshake.last_sequence, 0);
2675
2676 let mut buf = Vec::new();
2678 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); p_writer.write_all(&buf).unwrap();
2680 p_writer.flush().unwrap();
2681 buf.clear();
2682
2683 encode_input_batch_with_seq(42, &mut buf);
2685 p_writer.write_all(&buf).unwrap();
2686 p_writer.flush().unwrap();
2687 buf.clear();
2688
2689 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2691 let ack = match decode_replica_message(&frame).unwrap() {
2692 ReplicaMessage::Ack(a) => a,
2693 _ => panic!("expected Ack"),
2694 };
2695 assert_eq!(ack.acked_sequence, 42);
2696
2697 let end_seq = replica_handle.join().unwrap();
2699 assert_eq!(end_seq, 42);
2700 }
2701
2702 #[test]
2703 fn multiple_data_batches_acked_in_order() {
2704 use std::os::unix::net::UnixStream;
2707
2708 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2709
2710 let replica_handle = std::thread::spawn(move || {
2711 let mut reader = replica_stream.try_clone().unwrap();
2712 let mut writer = replica_stream;
2713 let mut buf = Vec::new();
2714
2715 encode_handshake(
2717 &Handshake {
2718 last_sequence: 0,
2719 chain_hash: [0u8; 32],
2720 epoch: 0,
2721 },
2722 &mut buf,
2723 );
2724 writer.write_all(&buf).unwrap();
2725 writer.flush().unwrap();
2726 buf.clear();
2727
2728 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2730 assert!(matches!(
2731 decode_primary_message(&frame).unwrap(),
2732 PrimaryMessage::StreamStart { .. }
2733 ));
2734
2735 let mut acked_seqs = Vec::new();
2737 for _ in 0..3 {
2738 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2739 let slots: Vec<InputSlot> =
2740 try_decode_input_batch(&frame).expect("decode InputBatch");
2741 let end_seq = slots
2742 .last()
2743 .map(|s| s.sequence)
2744 .expect("InputBatch carried at least one slot");
2745 acked_seqs.push(end_seq);
2746
2747 encode_ack(
2748 &Ack {
2749 acked_sequence: end_seq,
2750 in_memory_sequence: end_seq,
2751 },
2752 &mut buf,
2753 );
2754 writer.write_all(&buf).unwrap();
2755 writer.flush().unwrap();
2756 buf.clear();
2757 }
2758
2759 acked_seqs
2760 });
2761
2762 let mut p_reader = primary_stream.try_clone().unwrap();
2764 let mut p_writer = primary_stream;
2765 let mut buf = Vec::new();
2766
2767 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2769 assert!(matches!(
2770 decode_replica_message(&frame).unwrap(),
2771 ReplicaMessage::Handshake(_)
2772 ));
2773
2774 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
2776 p_writer.write_all(&buf).unwrap();
2777 p_writer.flush().unwrap();
2778 buf.clear();
2779
2780 for seq in [10u64, 20, 30] {
2782 encode_input_batch_with_seq(seq, &mut buf);
2783 p_writer.write_all(&buf).unwrap();
2784 p_writer.flush().unwrap();
2785 buf.clear();
2786 }
2787
2788 for expected_seq in [10u64, 20, 30] {
2790 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2791 let ack = match decode_replica_message(&frame).unwrap() {
2792 ReplicaMessage::Ack(a) => a,
2793 other => panic!("expected Ack, got {other:?}"),
2794 };
2795 assert_eq!(ack.acked_sequence, expected_seq);
2796 }
2797
2798 let acked = replica_handle.join().unwrap();
2799 assert_eq!(acked, vec![10, 20, 30]);
2800 }
2801
2802 #[test]
2803 fn heartbeat_encode_contains_sequence() {
2804 let mut buf = Vec::new();
2807 encode_heartbeat(999, &mut buf);
2808
2809 let payload = &buf[4..];
2810 match decode_primary_message(payload).unwrap() {
2811 PrimaryMessage::Heartbeat { sequence, .. } => {
2812 assert_eq!(sequence, 999);
2813 }
2814 other => panic!("expected Heartbeat, got {other:?}"),
2815 }
2816 }
2817
2818 #[test]
2819 fn replica_mid_stream_handshake_with_nonzero_sequence() {
2820 use std::os::unix::net::UnixStream;
2825
2826 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2827
2828 let replica_handle = std::thread::spawn(move || {
2829 let mut reader = replica_stream.try_clone().unwrap();
2830 let mut writer = replica_stream;
2831 let mut buf = Vec::new();
2832
2833 encode_handshake(
2835 &Handshake {
2836 last_sequence: 100,
2837 chain_hash: [0xBB; 32],
2838 epoch: 0,
2839 },
2840 &mut buf,
2841 );
2842 writer.write_all(&buf).unwrap();
2843 writer.flush().unwrap();
2844 buf.clear();
2845
2846 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2848 match decode_primary_message(&frame).unwrap() {
2849 PrimaryMessage::StreamStart { start_sequence, .. } => {
2850 assert_eq!(
2851 start_sequence, 100,
2852 "StreamStart should echo replica's last_sequence"
2853 );
2854 }
2855 other => panic!("expected StreamStart, got {other:?}"),
2856 }
2857
2858 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2860 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2861 let end_sequence = slots
2862 .last()
2863 .map(|s| s.sequence)
2864 .expect("InputBatch carried at least one slot");
2865 assert!(
2866 end_sequence > 100,
2867 "InputBatch should be after replica's last_sequence"
2868 );
2869 });
2870
2871 let mut p_reader = primary_stream.try_clone().unwrap();
2873 let mut p_writer = primary_stream;
2874 let mut buf = Vec::new();
2875
2876 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2878 let handshake = match decode_replica_message(&frame).unwrap() {
2879 ReplicaMessage::Handshake(h) => h,
2880 _ => panic!("expected Handshake"),
2881 };
2882 assert_eq!(handshake.last_sequence, 100);
2883 assert_eq!(handshake.chain_hash, [0xBB; 32]);
2884
2885 encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
2887 p_writer.write_all(&buf).unwrap();
2888 p_writer.flush().unwrap();
2889 buf.clear();
2890
2891 encode_input_batch_with_seq(150, &mut buf);
2893 p_writer.write_all(&buf).unwrap();
2894 p_writer.flush().unwrap();
2895
2896 replica_handle.join().unwrap();
2897 }
2898
2899 #[test]
2914 fn receiver_refuses_stream_start_from_stale_primary() {
2915 use std::os::unix::net::UnixStream;
2916
2917 use melin_transport_core::fence::FenceState;
2918
2919 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2920
2921 const STALE_EPOCH: u64 = 3;
2925 let primary_handle = std::thread::spawn(move || {
2926 let mut reader = primary_stream.try_clone().unwrap();
2927 let mut writer = primary_stream;
2928
2929 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2930 let handshake = match decode_replica_message(&frame).unwrap() {
2931 ReplicaMessage::Handshake(h) => h,
2932 _ => panic!("expected Handshake"),
2933 };
2934 assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2937
2938 let mut buf = Vec::new();
2939 encode_stream_start(
2940 handshake.last_sequence,
2941 1,
2942 [0u8; 32],
2943 Some(0),
2944 STALE_EPOCH,
2945 &mut buf,
2946 );
2947 writer.write_all(&buf).unwrap();
2948 writer.flush().unwrap();
2949 });
2950
2951 let fence_state = Arc::new(FenceState::new(5));
2954 let mut r_reader = replica_stream.try_clone().unwrap();
2955 let mut r_writer = replica_stream;
2956
2957 let mut buf = Vec::new();
2958 encode_handshake(
2959 &Handshake {
2960 last_sequence: 0,
2961 chain_hash: [0u8; 32],
2962 epoch: fence_state.epoch(),
2963 },
2964 &mut buf,
2965 );
2966 r_writer.write_all(&buf).unwrap();
2967 r_writer.flush().unwrap();
2968
2969 let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2970 let stream_epoch = match decode_primary_message(&frame).unwrap() {
2971 PrimaryMessage::StreamStart { epoch, .. } => epoch,
2972 other => panic!("expected StreamStart, got {other:?}"),
2973 };
2974 assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2975
2976 assert!(
2979 fence_state.refuses_primary(stream_epoch),
2980 "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2981 );
2982 assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2983
2984 assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2987 assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2988
2989 primary_handle.join().unwrap();
2990 }
2991
2992 #[test]
2993 fn snapshot_begin_encode_decode_round_trip() {
2994 let mut buf = Vec::new();
2995 encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2996
2997 let payload = &buf[4..];
2998 let msg = decode_primary_message(payload).unwrap();
2999 match msg {
3000 PrimaryMessage::SnapshotBegin {
3001 snapshot_len,
3002 snap_sequence,
3003 snap_chain_hash,
3004 } => {
3005 assert_eq!(snapshot_len, 1_000_000);
3006 assert_eq!(snap_sequence, 42);
3007 assert_eq!(snap_chain_hash, [0xAB; 32]);
3008 }
3009 _ => panic!("expected SnapshotBegin"),
3010 }
3011 }
3012
3013 #[test]
3014 fn snapshot_chunk_encode_decode_round_trip() {
3015 let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
3016 let mut buf = Vec::new();
3017 encode_snapshot_chunk(&data, &mut buf);
3018
3019 let payload = &buf[4..];
3020 let msg = decode_primary_message(payload).unwrap();
3021 match msg {
3022 PrimaryMessage::SnapshotChunk(chunk) => {
3023 assert_eq!(chunk, data);
3024 }
3025 _ => panic!("expected SnapshotChunk"),
3026 }
3027 }
3028
3029 #[test]
3030 fn snapshot_end_encode_decode_round_trip() {
3031 let mut buf = Vec::new();
3032 encode_snapshot_end(0xDEADBEEF, &mut buf);
3033
3034 let payload = &buf[4..];
3035 let msg = decode_primary_message(payload).unwrap();
3036 match msg {
3037 PrimaryMessage::SnapshotEnd { crc32c } => {
3038 assert_eq!(crc32c, 0xDEADBEEF);
3039 }
3040 _ => panic!("expected SnapshotEnd"),
3041 }
3042 }
3043
3044 #[test]
3048 fn snapshot_receiver_detects_length_mismatch() {
3049 use std::os::unix::net::UnixStream;
3050
3051 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3052
3053 let receiver = std::thread::spawn(move || -> String {
3055 let mut reader = replica_stream.try_clone().unwrap();
3056
3057 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3059 assert!(matches!(
3060 decode_primary_message(&frame).unwrap(),
3061 PrimaryMessage::NeedSnapshot,
3062 ));
3063
3064 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3066 let (snap_len, _snap_sequence, _snap_chain_hash) =
3067 match decode_primary_message(&frame).unwrap() {
3068 PrimaryMessage::SnapshotBegin {
3069 snapshot_len,
3070 snap_sequence,
3071 snap_chain_hash,
3072 } => (snapshot_len, snap_sequence, snap_chain_hash),
3073 other => panic!("expected SnapshotBegin, got {other:?}"),
3074 };
3075
3076 let mut received: u64 = 0;
3078 loop {
3079 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3080 match decode_primary_message(&frame).unwrap() {
3081 PrimaryMessage::SnapshotChunk(data) => {
3082 received += data.len() as u64;
3083 }
3084 PrimaryMessage::SnapshotEnd { .. } => {
3085 if received != snap_len {
3086 return format!(
3087 "snapshot length mismatch: expected {snap_len} bytes, got {received}"
3088 );
3089 }
3090 return String::new(); }
3092 other => panic!("unexpected message: {other:?}"),
3093 }
3094 }
3095 });
3096
3097 let mut writer = primary_stream;
3099 let mut buf = Vec::new();
3100
3101 let actual_data = vec![0xAA; 100];
3102 let wrong_len = 999u64; encode_need_snapshot(&mut buf);
3105 std::io::Write::write_all(&mut writer, &buf).unwrap();
3106 buf.clear();
3107
3108 encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3109 std::io::Write::write_all(&mut writer, &buf).unwrap();
3110 buf.clear();
3111
3112 encode_snapshot_chunk(&actual_data, &mut buf);
3113 std::io::Write::write_all(&mut writer, &buf).unwrap();
3114 buf.clear();
3115
3116 let crc = crc32c::crc32c(&actual_data);
3117 encode_snapshot_end(crc, &mut buf);
3118 std::io::Write::write_all(&mut writer, &buf).unwrap();
3119 std::io::Write::flush(&mut writer).unwrap();
3120
3121 let error_msg = receiver.join().unwrap();
3122 assert!(
3123 error_msg.contains("length mismatch"),
3124 "expected length mismatch error, got: {error_msg:?}"
3125 );
3126 }
3127
3128 #[test]
3132 fn snapshot_receiver_detects_crc_mismatch() {
3133 use std::os::unix::net::UnixStream;
3134
3135 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3136
3137 let receiver = std::thread::spawn(move || -> String {
3138 let mut reader = replica_stream.try_clone().unwrap();
3139
3140 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3142 assert!(matches!(
3143 decode_primary_message(&frame).unwrap(),
3144 PrimaryMessage::NeedSnapshot,
3145 ));
3146
3147 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3149 let snap_len = match decode_primary_message(&frame).unwrap() {
3150 PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3151 other => panic!("expected SnapshotBegin, got {other:?}"),
3152 };
3153
3154 let mut received_data = Vec::new();
3156 let mut received: u64 = 0;
3157 loop {
3158 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3159 match decode_primary_message(&frame).unwrap() {
3160 PrimaryMessage::SnapshotChunk(data) => {
3161 received += data.len() as u64;
3162 received_data.extend_from_slice(&data);
3163 }
3164 PrimaryMessage::SnapshotEnd {
3165 crc32c: expected_crc,
3166 } => {
3167 if received != snap_len {
3168 return format!("length mismatch: {snap_len} vs {received}");
3169 }
3170 let actual_crc = crc32c::crc32c(&received_data);
3171 if actual_crc != expected_crc {
3172 return format!(
3173 "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3174 );
3175 }
3176 return String::new();
3177 }
3178 other => panic!("unexpected message: {other:?}"),
3179 }
3180 }
3181 });
3182
3183 let mut writer = primary_stream;
3185 let mut buf = Vec::new();
3186
3187 let data = vec![0xAA; 100];
3188
3189 encode_need_snapshot(&mut buf);
3190 std::io::Write::write_all(&mut writer, &buf).unwrap();
3191 buf.clear();
3192
3193 encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3194 std::io::Write::write_all(&mut writer, &buf).unwrap();
3195 buf.clear();
3196
3197 encode_snapshot_chunk(&data, &mut buf);
3198 std::io::Write::write_all(&mut writer, &buf).unwrap();
3199 buf.clear();
3200
3201 let wrong_crc = !crc32c::crc32c(&data);
3203 encode_snapshot_end(wrong_crc, &mut buf);
3204 std::io::Write::write_all(&mut writer, &buf).unwrap();
3205 std::io::Write::flush(&mut writer).unwrap();
3206
3207 let error_msg = receiver.join().unwrap();
3208 assert!(
3209 error_msg.contains("CRC mismatch"),
3210 "expected CRC mismatch error, got: {error_msg:?}"
3211 );
3212 }
3213
3214 #[test]
3217 fn snapshot_receiver_detects_chain_hash_mismatch() {
3218 use std::os::unix::net::UnixStream;
3219
3220 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3221
3222 let receiver = std::thread::spawn(move || -> String {
3223 let mut reader = replica_stream.try_clone().unwrap();
3224
3225 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3226 assert!(matches!(
3227 decode_primary_message(&frame).unwrap(),
3228 PrimaryMessage::NeedSnapshot,
3229 ));
3230
3231 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3232 let (snap_len, _snap_sequence, snap_chain_hash) =
3233 match decode_primary_message(&frame).unwrap() {
3234 PrimaryMessage::SnapshotBegin {
3235 snapshot_len,
3236 snap_sequence,
3237 snap_chain_hash,
3238 } => (snapshot_len, snap_sequence, snap_chain_hash),
3239 other => panic!("expected SnapshotBegin, got {other:?}"),
3240 };
3241
3242 let mut received_data = Vec::new();
3244 let mut received: u64 = 0;
3245 loop {
3246 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3247 match decode_primary_message(&frame).unwrap() {
3248 PrimaryMessage::SnapshotChunk(data) => {
3249 received += data.len() as u64;
3250 received_data.extend_from_slice(&data);
3251 }
3252 PrimaryMessage::SnapshotEnd {
3253 crc32c: expected_crc,
3254 } => {
3255 assert_eq!(received, snap_len, "length should match");
3256 let actual_crc = crc32c::crc32c(&received_data);
3257 assert_eq!(actual_crc, expected_crc, "CRC should match");
3258 break;
3259 }
3260 other => panic!("unexpected message: {other:?}"),
3261 }
3262 }
3263
3264 let loaded_hash = [0xFF; 32]; if loaded_hash != snap_chain_hash {
3268 return format!(
3269 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3270 loaded snapshot has {loaded_hash:02x?}"
3271 );
3272 }
3273 String::new()
3274 });
3275
3276 let mut writer = primary_stream;
3279 let mut buf = Vec::new();
3280
3281 let data = vec![0xAA; 64];
3282 let advertised_hash = [0xBB; 32];
3284
3285 encode_need_snapshot(&mut buf);
3286 std::io::Write::write_all(&mut writer, &buf).unwrap();
3287 buf.clear();
3288
3289 encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3290 std::io::Write::write_all(&mut writer, &buf).unwrap();
3291 buf.clear();
3292
3293 encode_snapshot_chunk(&data, &mut buf);
3294 std::io::Write::write_all(&mut writer, &buf).unwrap();
3295 buf.clear();
3296
3297 let crc = crc32c::crc32c(&data);
3298 encode_snapshot_end(crc, &mut buf);
3299 std::io::Write::write_all(&mut writer, &buf).unwrap();
3300 std::io::Write::flush(&mut writer).unwrap();
3301
3302 let error_msg = receiver.join().unwrap();
3303 assert!(
3304 error_msg.contains("chain hash mismatch"),
3305 "expected chain hash mismatch error, got: {error_msg:?}"
3306 );
3307 }
3308
3309 #[test]
3312 fn primary_rejects_snapshot_with_invalid_magic() {
3313 let tmp = tempfile::tempdir().unwrap();
3314 let snap_path = tmp.path().join("test.snapshot");
3315
3316 let mut bad_snap = vec![0u8; 64];
3318 bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); std::fs::write(&snap_path, &bad_snap).unwrap();
3321
3322 let snap_data = std::fs::read(&snap_path).unwrap();
3324 assert!(
3325 snap_data.len() >= 48,
3326 "file should be big enough for header"
3327 );
3328
3329 let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3330 assert_ne!(magic, 0x534E_4150);
3331 assert_eq!(magic, 0xDEAD_BEEF);
3332 }
3333
3334 #[test]
3337 fn primary_rejects_snapshot_too_small_for_header() {
3338 let tmp = tempfile::tempdir().unwrap();
3339 let snap_path = tmp.path().join("test.snapshot");
3340
3341 std::fs::write(&snap_path, [0u8; 20]).unwrap();
3343
3344 let snap_data = std::fs::read(&snap_path).unwrap();
3345 assert!(
3346 snap_data.len() < 48,
3347 "file must be too small for header validation"
3348 );
3349 }
3350
3351 #[test]
3352 fn decode_snapshot_begin_too_short() {
3353 let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3356 let err = decode_primary_message(&payload).unwrap_err();
3357 assert!(
3358 err.to_string().contains("SnapshotBegin too short"),
3359 "expected 'SnapshotBegin too short', got: {err}"
3360 );
3361 }
3362
3363 #[test]
3364 fn decode_snapshot_end_too_short() {
3365 let payload = [MSG_SNAPSHOT_END];
3367 let err = decode_primary_message(&payload).unwrap_err();
3368 assert!(
3369 err.to_string().contains("SnapshotEnd too short"),
3370 "expected 'SnapshotEnd too short', got: {err}"
3371 );
3372 }
3373
3374 #[test]
3375 fn decode_snapshot_chunk_empty_data() {
3376 let payload = [MSG_SNAPSHOT_CHUNK];
3378 let msg = decode_primary_message(&payload).unwrap();
3379 match msg {
3380 PrimaryMessage::SnapshotChunk(data) => {
3381 assert!(data.is_empty());
3382 }
3383 _ => panic!("expected SnapshotChunk"),
3384 }
3385 }
3386
3387 fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3390 melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3391 }
3392
3393 #[test]
3394 fn pending_ack_queue_push_and_pop_ready() {
3395 let mut q = PendingAckQueue::new(8);
3396 assert!(q.is_empty());
3397 assert!(!q.is_full());
3398
3399 q.push(10, 100);
3400 q.push(20, 200);
3401 assert!(!q.is_empty());
3402
3403 let cursor = make_journal_cursor(5);
3405 assert!(q.pop_ready(&cursor).is_none());
3406
3407 cursor.get().store(15, Ordering::Relaxed);
3409 assert_eq!(q.pop_ready(&cursor), Some(100));
3410 assert!(!q.is_empty());
3412
3413 cursor.get().store(25, Ordering::Relaxed);
3415 assert_eq!(q.pop_ready(&cursor), Some(200));
3416 assert!(q.is_empty());
3417 }
3418
3419 #[test]
3420 fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3421 let mut q = PendingAckQueue::new(8);
3425 q.push(10, 100);
3426 q.push(20, 200);
3427 q.push(30, 300);
3428
3429 let cursor = make_journal_cursor(30);
3430 assert_eq!(q.pop_ready(&cursor), Some(300));
3431 assert!(q.is_empty());
3432 }
3433
3434 #[test]
3435 fn pending_ack_queue_capacity_and_full() {
3436 let mut q = PendingAckQueue::new(8);
3437 for i in 0..8 {
3438 assert!(!q.is_full());
3439 q.push(i as u64 + 1, (i + 1) as u64 * 100);
3440 }
3441 assert!(q.is_full());
3442 }
3443
3444 #[test]
3449 fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3450 let mut q = PendingAckQueue::new(4);
3451 for i in 1..=4u64 {
3452 q.push(i * 10, i * 100);
3453 }
3454 assert!(q.is_full());
3455 assert_eq!(q.merged(), 0);
3456
3457 q.push(50, 500);
3459 assert_eq!(q.merged(), 1);
3460 assert_eq!(q.len(), 4, "merge must not grow the queue");
3461
3462 let cursor = make_journal_cursor(40);
3465 assert_eq!(q.pop_ready(&cursor), Some(300));
3466 assert!(!q.is_empty(), "merged entry is not durable yet");
3467
3468 cursor.get().store(50, Ordering::Relaxed);
3471 assert_eq!(q.pop_ready(&cursor), Some(500));
3472 assert!(q.is_empty());
3473 }
3474
3475 #[test]
3478 fn pending_ack_queue_merges_repeatedly_without_growing() {
3479 let mut q = PendingAckQueue::new(2);
3480 for i in 1..=100u64 {
3481 q.push(i, i * 10);
3482 }
3483 assert_eq!(q.len(), 2);
3484 assert_eq!(q.merged(), 98);
3485
3486 let cursor = make_journal_cursor(99);
3488 assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3489 cursor.get().store(100, Ordering::Relaxed);
3491 assert_eq!(q.pop_ready(&cursor), Some(1000));
3492 }
3493
3494 #[test]
3495 fn pending_ack_queue_pop_oldest_blocking() {
3496 let mut q = PendingAckQueue::new(8);
3497 q.push(10, 100);
3498 q.push(20, 200);
3499
3500 let cursor = make_journal_cursor(25);
3503 let seq = q.pop_oldest_blocking(
3504 &cursor,
3505 melin_pipeline::wait::WaitStrategy::BusySpin,
3506 &AtomicBool::new(false),
3507 );
3508 assert_eq!(seq, Some(200));
3510 assert!(q.is_empty());
3511 }
3512
3513 #[test]
3516 fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3517 let mut q = PendingAckQueue::new(8);
3518 q.push(10, 100);
3519
3520 let cursor = make_journal_cursor(5);
3522 let abort = AtomicBool::new(true);
3523 assert_eq!(
3524 q.pop_oldest_blocking(
3525 &cursor,
3526 melin_pipeline::wait::WaitStrategy::BusySpin,
3527 &abort
3528 ),
3529 None
3530 );
3531 assert!(!q.is_empty(), "aborted wait must not pop the entry");
3532 assert_eq!(
3533 q.pop_all_blocking(
3534 &cursor,
3535 melin_pipeline::wait::WaitStrategy::BusySpin,
3536 &abort
3537 ),
3538 None
3539 );
3540 }
3541
3542 #[test]
3551 fn shutdown_pipeline_surfaces_journal_error_kind() {
3552 let flag = AtomicBool::new(false);
3553 let matching_joined = Arc::new(AtomicBool::new(false));
3554 let mj = Arc::clone(&matching_joined);
3555
3556 let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3557 Err(melin_journal::JournalError::ReplicaChainDivergence {
3558 sequence: 42,
3559 expected: [1u8; 32],
3560 actual: [2u8; 32],
3561 })
3562 });
3563 let matching = std::thread::spawn(move || {
3564 mj.store(true, Ordering::Release);
3565 7u64
3566 });
3567 let drain = std::thread::spawn(|| {});
3568
3569 let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3570 assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3571 assert!(
3572 matching_joined.load(Ordering::Acquire),
3573 "matching thread must be joined even when the journal failed"
3574 );
3575 match outcome {
3576 TeardownOutcome::JournalFailed(
3577 melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3578 ) => assert_eq!(sequence, 42),
3579 _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3580 }
3581 }
3582
3583 #[test]
3584 fn shutdown_pipeline_clean_returns_both_states() {
3585 let flag = AtomicBool::new(false);
3586 let journal =
3587 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3588 let matching = std::thread::spawn(|| 7u64);
3589 let drain = std::thread::spawn(|| {});
3590
3591 match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3592 TeardownOutcome::Clean(app, writer) => {
3593 assert_eq!(app, 7);
3594 assert_eq!(writer, 11);
3595 }
3596 _ => panic!("expected Clean"),
3597 }
3598 }
3599
3600 #[test]
3601 fn shutdown_pipeline_panicked_stage_reports_panicked() {
3602 let flag = AtomicBool::new(false);
3603 let journal =
3604 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3605 let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3606 let drain = std::thread::spawn(|| {});
3607
3608 assert!(matches!(
3609 shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3610 TeardownOutcome::Panicked
3611 ));
3612 }
3613
3614 fn teardown_fixture(
3619 capacity: usize,
3620 ) -> (
3621 ReplicaPipelineHandles<counter_server::Counter, u32>,
3622 melin_pipeline::ring::Consumer<InputSlot>,
3623 ) {
3624 handles_fixture(capacity, fsync_state_at(0, 0).1)
3625 }
3626
3627 fn fsync_state_at(
3630 journal_seq: u64,
3631 ring_pos: u64,
3632 ) -> (
3633 melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3634 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3635 ) {
3636 melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3637 journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3638 chain_hash: [0u8; 32],
3639 input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3640 })
3641 }
3642
3643 fn handles_fixture(
3645 capacity: usize,
3646 chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3647 melin_transport_core::pipeline::FsyncState,
3648 >,
3649 ) -> (
3650 ReplicaPipelineHandles<counter_server::Counter, u32>,
3651 melin_pipeline::ring::Consumer<InputSlot>,
3652 ) {
3653 let (input_producer, mut consumers) =
3654 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3655 .add_consumer()
3656 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3657 let consumer = consumers.pop().expect("one consumer");
3658 let handles = ReplicaPipelineHandles {
3659 input_producer,
3660 journal_cursor: Arc::new(make_journal_cursor(0)),
3661 chain_hash_lock,
3662 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3663 genesis_entries: None,
3664 journal_failed: Arc::new(AtomicBool::new(false)),
3665 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3666 journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3667 Ok(11)
3668 }),
3669 matching_handle: std::thread::spawn(counter_server::Counter::default),
3670 drain_handle: std::thread::spawn(|| {}),
3671 shadow_handle: None,
3672 };
3673 (handles, consumer)
3674 }
3675
3676 #[test]
3682 fn teardown_publishes_shutdown_sentinel() {
3683 let (handles, mut consumer) = teardown_fixture(8);
3684 assert!(matches!(
3685 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3686 TeardownOutcome::Clean(..)
3687 ));
3688 let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3689 assert!(
3690 slot.event.is_shutdown(),
3691 "published slot must be the sentinel"
3692 );
3693 assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3694 }
3695
3696 fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
3698 use std::os::unix::thread::JoinHandleExt;
3699 let mut policy: libc::c_int = 0;
3700 let mut param = libc::sched_param { sched_priority: 0 };
3701 let ret =
3702 unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
3703 assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
3704 policy
3705 }
3706
3707 #[test]
3728 fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
3729 let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
3730 eprintln!("skipped: this thread may run on CPU 0 only");
3732 return;
3733 };
3734 let dir = tempfile::tempdir().expect("tempdir");
3735
3736 let policies = std::thread::spawn(move || {
3737 melin_test_node::sched::set_batch_policy();
3739
3740 let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
3741 &dir.path().join("replica.journal"),
3742 1,
3743 [7; 32],
3744 Some(5),
3745 )
3746 .expect("create journal");
3747 let placed = crate::layout::Placement::yielding(core);
3750 let cores = crate::layout::PipelineCores {
3751 journal_seq: placed,
3752 matching: placed,
3753 response: placed,
3754 shadow: placed,
3755 journal_prep: placed,
3756 journal_disk: placed,
3757 ..crate::layout::PipelineCores::unpinned()
3758 };
3759 let handles = build_replica_pipeline_with_threads(
3760 counter_server::Counter::default(),
3761 writer,
3762 cores,
3763 melin_journal::StagingMode::Allocate,
3764 3_600_000,
3767 &NodeStore::resolve(dir.path().join("replica.journal"), None).expect("store"),
3768 std::time::Duration::ZERO,
3769 Arc::new(melin_transport_core::fence::FenceState::new(0)),
3770 &SharedLineagePolicy::new(None),
3771 Arc::new(AtomicBool::new(true)),
3772 &(),
3773 )
3774 .expect("build the replica pipeline");
3775
3776 let policies = [
3777 ("journal-seq", Some(policy_of(&handles.journal_handle))),
3778 ("matching", Some(policy_of(&handles.matching_handle))),
3779 ("drain", Some(policy_of(&handles.drain_handle))),
3780 (
3781 "replica-shadow",
3782 handles.shadow_handle.as_ref().map(policy_of),
3783 ),
3784 ];
3785 let outcome = teardown_replica_pipeline(handles);
3786 assert!(
3787 matches!(outcome, TeardownOutcome::Clean(..)),
3788 "the pipeline must tear down cleanly"
3789 );
3790 policies
3791 })
3792 .join()
3793 .expect("builder thread");
3794
3795 let inherited: Vec<&str> = policies
3796 .iter()
3797 .map(|(name, policy)| {
3798 (
3799 *name,
3800 policy.unwrap_or_else(|| panic!("{name} was not spawned")),
3801 )
3802 })
3803 .filter(|&(_, policy)| policy == libc::SCHED_BATCH)
3804 .map(|(name, _)| name)
3805 .collect();
3806 assert!(
3807 inherited.is_empty(),
3808 "{inherited:?} started in the builder's scheduling context instead of their \
3809 own placement; on an isolated core held by a real-time spinner they would \
3810 never run"
3811 );
3812 }
3813
3814 #[test]
3820 fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3821 let (mut fsync, reader) = fsync_state_at(10, 0);
3822 let (mut handles, _consumer) = handles_fixture(8, reader);
3823 for _ in 0..3 {
3824 handles.input_producer.publish(InputSlot::default());
3825 }
3826 let journal = std::thread::spawn(move || {
3827 std::thread::sleep(std::time::Duration::from_millis(50));
3828 fsync.store(melin_transport_core::pipeline::FsyncState {
3829 journal_seq: melin_transport_core::WireSeq::new(13),
3830 chain_hash: [0u8; 32],
3831 input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3832 });
3833 });
3834 let outcome = handles.settled_resume_point(|| false);
3835 journal.join().unwrap();
3836 assert!(
3837 matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3838 "resumes past the ring's slots"
3839 );
3840 }
3841
3842 #[test]
3845 fn resume_point_with_nothing_in_flight_is_immediate() {
3846 let (_fsync, reader) = fsync_state_at(10, 0);
3847 let (handles, _consumer) = handles_fixture(8, reader);
3848 assert!(matches!(
3849 handles.settled_resume_point(|| false),
3850 ResumePoint::Settled(s) if s.journal_seq.get() == 10
3851 ));
3852 }
3853
3854 #[test]
3858 fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3859 let (_fsync, reader) = fsync_state_at(10, 0);
3860 let (mut handles, _consumer) = handles_fixture(8, reader);
3861 handles.input_producer.publish(InputSlot::default());
3862
3863 assert!(matches!(
3864 handles.settled_resume_point(|| true),
3865 ResumePoint::Interrupted
3866 ));
3867 handles.journal_failed.store(true, Ordering::Release);
3868 assert!(matches!(
3869 handles.settled_resume_point(|| false),
3870 ResumePoint::JournalFailed
3871 ));
3872 }
3873
3874 #[test]
3880 fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3881 let (_fsync, reader) = fsync_state_at(10, 0);
3882 let (handles, _consumer) = handles_fixture(8, reader);
3883 handles.journal_failed.store(true, Ordering::Release);
3884 assert!(matches!(
3885 handles.settled_resume_point(|| false),
3886 ResumePoint::JournalFailed
3887 ));
3888 }
3889
3890 #[test]
3895 fn a_panicked_journal_stage_latches_the_failure() {
3896 let failed = Arc::new(AtomicBool::new(false));
3897 let healthy = Arc::new(AtomicBool::new(true));
3898 let journal_handle = {
3899 let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3900 std::thread::spawn(move || {
3901 run_journal_stage(
3902 || -> Result<u32, melin_journal::JournalError> {
3903 panic!("journal stage died without returning an error")
3904 },
3905 &failed,
3906 &healthy,
3907 )
3908 })
3909 };
3910 assert!(journal_handle.join().is_err(), "the stage panicked");
3912 assert!(failed.load(Ordering::Acquire), "the failure is latched");
3913 assert!(
3914 !healthy.load(Ordering::Acquire),
3915 "the node reports unhealthy"
3916 );
3917 }
3918
3919 #[test]
3923 fn resume_point_ends_when_the_journal_thread_panicked() {
3924 let (_fsync, reader) = fsync_state_at(10, 0);
3925 let (mut handles, _consumer) = handles_fixture(8, reader);
3926 handles.input_producer.publish(InputSlot::default());
3927 let failed = Arc::clone(&handles.journal_failed);
3928 let healthy = Arc::new(AtomicBool::new(true));
3929 let dead = std::thread::spawn(move || {
3930 run_journal_stage(
3931 || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3932 &failed,
3933 &healthy,
3934 )
3935 });
3936 assert!(dead.join().is_err(), "the stage panicked");
3937
3938 let give_up = AtomicBool::new(false);
3940 let outcome = std::thread::scope(|s| {
3941 let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3942 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3943 while !wait.is_finished() && std::time::Instant::now() < deadline {
3944 std::thread::sleep(std::time::Duration::from_millis(5));
3945 }
3946 give_up.store(true, Ordering::Relaxed);
3947 wait.join().expect("wait thread")
3948 });
3949 assert!(
3950 matches!(outcome, ResumePoint::JournalFailed),
3951 "the wait must end on the panicked stage, not on the test's give-up"
3952 );
3953 }
3954
3955 #[test]
3959 fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3960 let dir = tempfile::tempdir().expect("tempdir");
3961 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3962 let (input_producer, mut consumers) =
3963 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3964 .add_consumer()
3965 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3966 let _consumer = consumers.pop().expect("one consumer");
3967 let handles = ReplicaPipelineHandles {
3968 input_producer,
3969 journal_cursor: Arc::new(make_journal_cursor(0)),
3970 chain_hash_lock: fsync_state_at(0, 0).1,
3971 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3972 genesis_entries: None,
3973 journal_failed: Arc::new(AtomicBool::new(true)),
3974 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3975 journal_handle: std::thread::spawn(
3976 || -> Result<Writer, melin_journal::JournalError> {
3977 Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3978 expected: 4,
3979 actual: 1,
3980 })
3981 },
3982 ),
3983 matching_handle: std::thread::spawn(counter_server::Counter::default),
3984 drain_handle: std::thread::spawn(|| {}),
3985 shadow_handle: None,
3986 };
3987 let mut pipeline = Some(handles);
3988 let mut divergence_resyncs = 0u32;
3989 let mut backoff = MAX_BACKOFF;
3990 let mut closes = 0;
3991 let after = handle_session_exit::<counter_server::Counter, Writer>(
3992 journal_failed_while_disconnected(),
3993 &mut pipeline,
3994 &mut divergence_resyncs,
3995 &mut backoff,
3996 0,
3997 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
3998 &melin_transport_core::fence::FenceState::new(0),
3999 &SharedLineagePolicy::new(None),
4000 &AtomicBool::new(false),
4001 &crate::promotion::PromotionRequest::new(),
4002 || closes += 1,
4003 &(),
4004 );
4005 let AfterSession::Return(Err(err)) = after else {
4006 panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
4007 };
4008 assert!(
4009 err.to_string()
4010 .contains("refused to journal sequence 1: the next sequence is 4"),
4011 "the returned error names the journal's cause, got: {err}"
4012 );
4013 assert!(pipeline.is_none(), "the dead pipeline was torn down");
4014 assert_eq!(divergence_resyncs, 0);
4015 assert_eq!(closes, 1);
4018 }
4019
4020 #[test]
4024 fn every_session_exit_closes_the_connection_once() {
4025 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4026 type Case = (&'static str, fn() -> SessionExit);
4029 let exits: [Case; 6] = [
4030 ("shutdown", || SessionExit::Shutdown),
4031 ("promote", || SessionExit::Promote),
4032 ("fatal", || SessionExit::Fatal("protocol violation".into())),
4033 ("disconnected", || SessionExit::Disconnected),
4034 ("stream gap", || SessionExit::StreamGap("gap".into())),
4035 ("corrupted", || SessionExit::Corrupted("damaged".into())),
4036 ];
4037 for (label, exit) in exits {
4038 let dir = tempfile::tempdir().expect("tempdir");
4039 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4040 None;
4041 let mut divergence_resyncs = 0u32;
4042 let mut backoff = MAX_BACKOFF;
4043 let shutdown = AtomicBool::new(true);
4046 let mut closes = 0;
4047 let _after = handle_session_exit::<counter_server::Counter, Writer>(
4050 StreamingResult {
4051 exit: exit(),
4052 heard_from_primary: true,
4053 },
4054 &mut pipeline,
4055 &mut divergence_resyncs,
4056 &mut backoff,
4057 0,
4058 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4059 &melin_transport_core::fence::FenceState::new(0),
4060 &SharedLineagePolicy::new(None),
4061 &shutdown,
4062 &crate::promotion::PromotionRequest::new(),
4063 || closes += 1,
4064 &(),
4065 );
4066 assert_eq!(closes, 1, "{label}");
4067 }
4068 }
4069
4070 fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
4077 backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
4078 }
4079
4080 fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
4082 let dir = tempfile::tempdir().expect("tempdir");
4083 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4084 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
4085 let mut divergence_resyncs = 0u32;
4086 let mut backoff = MAX_BACKOFF;
4087 let shutdown = AtomicBool::new(true);
4088 let promote = crate::promotion::PromotionRequest::new();
4089 let after = handle_session_exit::<counter_server::Counter, Writer>(
4090 StreamingResult {
4091 exit,
4092 heard_from_primary,
4093 },
4094 &mut pipeline,
4095 &mut divergence_resyncs,
4096 &mut backoff,
4097 0,
4098 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4099 &melin_transport_core::fence::FenceState::new(0),
4100 &SharedLineagePolicy::new(None),
4101 &shutdown,
4102 &promote,
4103 || {},
4104 &(),
4105 );
4106 assert!(matches!(after, AfterSession::Reconnect));
4107 backoff
4108 }
4109
4110 #[test]
4115 fn disconnect_resets_backoff_when_primary_spoke() {
4116 assert_eq!(
4119 backoff_after_disconnect(true),
4120 std::time::Duration::from_secs(2)
4121 );
4122 }
4123
4124 #[test]
4129 fn disconnect_keeps_escalated_backoff_when_primary_silent() {
4130 assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
4131 }
4132
4133 #[test]
4138 fn corrupted_entry_resets_backoff() {
4139 assert_eq!(
4140 backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
4141 std::time::Duration::from_secs(2)
4142 );
4143 }
4144
4145 #[test]
4154 fn stream_gap_reconnects_and_keeps_pipeline() {
4155 let dir = tempfile::tempdir().expect("tempdir");
4156 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4157 let (input_producer, mut consumers) =
4161 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
4162 .add_consumer()
4163 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
4164 let _consumer = consumers.pop().expect("one consumer");
4165 let writer_path = dir.path().join("w.journal");
4166 let handles = ReplicaPipelineHandles {
4167 input_producer,
4168 journal_cursor: Arc::new(make_journal_cursor(0)),
4169 chain_hash_lock: fsync_state_at(0, 0).1,
4170 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
4171 genesis_entries: None,
4172 journal_failed: Arc::new(AtomicBool::new(false)),
4173 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
4174 journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
4175 matching_handle: std::thread::spawn(counter_server::Counter::default),
4176 drain_handle: std::thread::spawn(|| {}),
4177 shadow_handle: None,
4178 };
4179 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4180 Some(handles);
4181 let mut divergence_resyncs = 0u32;
4182 let mut backoff = MAX_BACKOFF;
4183 let shutdown = AtomicBool::new(true);
4184 let promote = crate::promotion::PromotionRequest::new();
4185 let mut closed = false;
4186 let after = handle_session_exit::<counter_server::Counter, Writer>(
4187 StreamingResult {
4188 exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
4189 heard_from_primary: true,
4190 },
4191 &mut pipeline,
4192 &mut divergence_resyncs,
4193 &mut backoff,
4194 2,
4195 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4196 &melin_transport_core::fence::FenceState::new(0),
4197 &SharedLineagePolicy::new(None),
4198 &shutdown,
4199 &promote,
4200 || closed = true,
4201 &(),
4202 );
4203 assert!(
4204 matches!(after, AfterSession::Reconnect),
4205 "a stream gap must reconnect, not return an error"
4206 );
4207 assert!(
4208 pipeline.is_some(),
4209 "the pipeline must survive for the reconnect"
4210 );
4211 assert!(closed, "the transport must be closed before redialing");
4212 assert_eq!(
4213 backoff,
4214 std::time::Duration::from_secs(2),
4215 "backoff reset to 1s then doubled by the shared helper"
4216 );
4217 assert_eq!(divergence_resyncs, 0, "not a resync");
4218 if let Some(p) = pipeline.take() {
4219 let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4220 }
4221 }
4222
4223 #[test]
4228 fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4229 let (mut handles, mut consumer) = teardown_fixture(4);
4230 for seq in 0..4 {
4231 assert!(
4232 handles
4233 .input_producer
4234 .try_publish(InputSlot {
4235 sequence: seq,
4236 ..InputSlot::default()
4237 })
4238 .is_ok(),
4239 "ring must have space while filling"
4240 );
4241 }
4242 assert!(
4243 handles
4244 .input_producer
4245 .try_publish(InputSlot::default())
4246 .is_err(),
4247 "ring must be full"
4248 );
4249 handles.journal_failed.store(true, Ordering::Release);
4250 assert!(matches!(
4252 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4253 TeardownOutcome::Clean(..)
4254 ));
4255 while let Some((_, slot)) = consumer.try_consume() {
4256 assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4257 }
4258 }
4259
4260 #[test]
4261 fn pending_ack_queue_wraps_around() {
4262 let mut q = PendingAckQueue::new(8);
4263 let cursor = make_journal_cursor(100);
4264
4265 for round in 0..3 {
4267 for i in 0..8 {
4268 let target = (round * 8 + i) as u64 + 1;
4269 q.push(target, target * 10);
4270 }
4271 assert!(q.is_full());
4272 let seq = q.pop_ready(&cursor).expect("should be ready");
4273 assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4274 assert!(q.is_empty());
4275 }
4276 }
4277
4278 #[test]
4279 fn pending_ack_queue_pop_all_blocking_empty() {
4280 let mut q = PendingAckQueue::new(8);
4281 let cursor = make_journal_cursor(0);
4282 assert!(
4283 q.pop_all_blocking(
4284 &cursor,
4285 melin_pipeline::wait::WaitStrategy::BusySpin,
4286 &AtomicBool::new(false)
4287 )
4288 .is_none()
4289 );
4290 }
4291
4292 #[test]
4295 fn dual_track_returns_none_when_both_tracks_idle() {
4296 let mut q = PendingAckQueue::new(8);
4297 let cursor = make_journal_cursor(0);
4298 assert!(
4299 try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4300 "no advance on either track → no ack"
4301 );
4302 }
4303
4304 #[test]
4305 fn dual_track_fires_on_persisted_advance_only() {
4306 let mut q = PendingAckQueue::new(8);
4311 q.push(10, 100);
4312 let cursor = make_journal_cursor(20);
4313 let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4314 assert_eq!(ack.acked_sequence, 100);
4315 assert_eq!(ack.in_memory_sequence, 50);
4316 }
4317
4318 #[test]
4319 fn dual_track_fires_on_in_memory_advance_only() {
4320 let mut q = PendingAckQueue::new(8);
4323 let cursor = make_journal_cursor(0);
4324 let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4325 assert_eq!(ack.acked_sequence, 100);
4326 assert_eq!(ack.in_memory_sequence, 200);
4327 }
4328
4329 #[test]
4330 fn dual_track_coalesces_until_caller_updates_trackers() {
4331 let mut q = PendingAckQueue::new(8);
4337 q.push(10, 100);
4338 let cursor = make_journal_cursor(20);
4339 let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4340 assert_eq!(ack1.acked_sequence, 100);
4341 let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4343 .expect("call 2 fires on in-memory advance");
4344 assert_eq!(
4345 ack2.acked_sequence, 0,
4346 "no new persisted pop → unwrap_or(last_sent)"
4347 );
4348 assert_eq!(ack2.in_memory_sequence, 80);
4349 }
4350
4351 #[test]
4352 fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4353 let mut q = PendingAckQueue::new(4);
4371 for i in 1..=4u64 {
4374 q.push(i * 10, i * 100);
4375 }
4376 assert!(q.is_full());
4377
4378 let cursor = make_journal_cursor(40);
4381 let drained = q.pop_ready(&cursor).expect("all entries durable");
4382 assert_eq!(drained, 400);
4383 assert!(q.is_empty());
4384
4385 let mut last_sent_acked = drained;
4388 let mut last_sent_in_mem = 450u64;
4389
4390 assert!(
4395 try_flush_dual_track(
4396 &mut q,
4397 &cursor,
4398 last_sent_in_mem,
4399 last_sent_acked,
4400 last_sent_in_mem,
4401 )
4402 .is_none(),
4403 "no advance on either track after backpressure drain → no duplicate ack",
4404 );
4405
4406 q.push(50, 500);
4409 let cursor2 = make_journal_cursor(50);
4410 let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4411 .expect("fresh batch after drain fires a new ack");
4412 assert!(
4413 ack.acked_sequence >= last_sent_acked,
4414 "regression: drain sent {last_sent_acked} but next ack carries {}",
4415 ack.acked_sequence,
4416 );
4417 assert_eq!(ack.acked_sequence, 500);
4418 assert_eq!(ack.in_memory_sequence, 500);
4419
4420 last_sent_acked = ack.acked_sequence;
4421 last_sent_in_mem = ack.in_memory_sequence;
4422
4423 assert!(
4427 try_flush_dual_track(
4428 &mut q,
4429 &cursor2,
4430 last_sent_in_mem,
4431 last_sent_acked,
4432 last_sent_in_mem,
4433 )
4434 .is_none(),
4435 "post-resume idle → no further ack",
4436 );
4437 }
4438}