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::ack_queue::{
112 PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
113};
114pub use melin_transport_core::replication::protocol::{
115 Ack, Handshake, PrimaryMessage, ReplicaMessage,
116};
117pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
118
119#[cfg(feature = "dpdk")]
120pub(crate) use dpdk::REPL_DISPATCH_BURST;
121#[cfg(feature = "dpdk")]
122pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
123pub use tcp_receiver::{ReceiverResult, run_receiver};
124pub use tcp_sender::{ReplicationListener, Sender, run_sender};
125
126#[derive(Clone)]
133pub struct ReplicaControlPlane {
134 pub promote: crate::promotion::PromotionRequest,
137 pub tip_ready: std::sync::Arc<AtomicBool>,
141 pub journal_tip: melin_transport_core::AdvertisedJournalTip,
144 pub primary_link_up: std::sync::Arc<AtomicBool>,
151 pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
156 pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
175 pub pipeline_healthy: std::sync::Arc<AtomicBool>,
183}
184
185impl ReplicaControlPlane {
186 pub fn new() -> Self {
190 Self {
191 promote: crate::promotion::PromotionRequest::new(),
192 tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
193 journal_tip: melin_transport_core::AdvertisedJournalTip::new(
194 melin_transport_core::WireSeq::new(0),
195 ),
196 primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
197 primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
198 lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
199 pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
200 }
201 }
202}
203
204impl Default for ReplicaControlPlane {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
219 if !tracing::enabled!(tracing::Level::DEBUG) {
220 return;
221 }
222 let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
226 let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
227 let rc = unsafe {
228 libc::getsockopt(
229 fd,
230 libc::IPPROTO_TCP,
231 libc::TCP_INFO,
232 &mut info as *mut _ as *mut libc::c_void,
233 &mut len,
234 )
235 };
236 if rc != 0 {
237 tracing::debug!(
238 slot,
239 tag,
240 errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
241 "tcp_info getsockopt failed"
242 );
243 return;
244 }
245 tracing::debug!(
246 slot,
247 tag,
248 state = info.tcpi_state,
249 ca_state = info.tcpi_ca_state,
250 rtt_us = info.tcpi_rtt,
251 rttvar_us = info.tcpi_rttvar,
252 snd_cwnd = info.tcpi_snd_cwnd,
253 snd_ssthresh = info.tcpi_snd_ssthresh,
254 snd_mss = info.tcpi_snd_mss,
255 rcv_mss = info.tcpi_rcv_mss,
256 unacked = info.tcpi_unacked,
257 retrans = info.tcpi_retrans,
258 total_retrans = info.tcpi_total_retrans,
259 lost = info.tcpi_lost,
260 rcv_space = info.tcpi_rcv_space,
261 rto_us = info.tcpi_rto,
262 "tcp_info"
263 );
264}
265
266pub(super) fn sleep_checking_flags(
270 duration: std::time::Duration,
271 shutdown: &AtomicBool,
272 promote: &crate::promotion::PromotionRequest,
273) {
274 let deadline = std::time::Instant::now() + duration;
275 while std::time::Instant::now() < deadline {
276 if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
277 return;
278 }
279 std::thread::sleep(std::time::Duration::from_millis(100));
280 }
281}
282
283pub(super) fn sleep_then_double_backoff(
290 backoff: &mut std::time::Duration,
291 shutdown: &AtomicBool,
292 promote: &crate::promotion::PromotionRequest,
293) {
294 sleep_checking_flags(*backoff, shutdown, promote);
295 *backoff = (*backoff * 2).min(MAX_BACKOFF);
296}
297
298pub(super) enum TeardownOutcome<A, W> {
302 Clean(A, W),
305 JournalFailed(melin_journal::JournalError),
311 Panicked,
313}
314
315pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
330 shutdown_flag: &AtomicBool,
331 journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
332 matching_handle: std::thread::JoinHandle<A>,
333 drain_handle: std::thread::JoinHandle<()>,
334 shadow_handle: Option<std::thread::JoinHandle<()>>,
335) -> TeardownOutcome<A, W> {
336 shutdown_flag.store(true, Ordering::Release);
350 let journal_result = journal_handle.join();
355 let matching_result = matching_handle.join();
356 let _ = drain_handle.join();
357 if let Some(h) = shadow_handle {
358 let _ = h.join();
359 }
360 let writer = match journal_result {
361 Ok(Ok(w)) => w,
362 Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
364 Err(_) => return TeardownOutcome::Panicked,
365 };
366 match matching_result {
367 Ok(app) => TeardownOutcome::Clean(app, writer),
368 Err(_) => TeardownOutcome::Panicked,
369 }
370}
371
372pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
381 pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
382 pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
383 pub(super) chain_hash_lock:
388 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
389 pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
394 pub(super) genesis_entries: Option<u64>,
402 pub(super) journal_failed: Arc<AtomicBool>,
410 pub(super) pipeline_shutdown: Arc<AtomicBool>,
413 pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
414 pub(super) matching_handle: std::thread::JoinHandle<A>,
415 pub(super) drain_handle: std::thread::JoinHandle<()>,
416 pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
417}
418
419pub(super) enum ResumePoint {
421 Settled(melin_transport_core::pipeline::FsyncState),
424 JournalFailed,
428 Interrupted,
431}
432
433impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
434 pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
449 let published = self.input_producer.peek_cursor();
453 let mut outcome = ResumePoint::Interrupted;
454 melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
457 if self.journal_failed.load(Ordering::Acquire) {
463 outcome = ResumePoint::JournalFailed;
464 return true;
465 }
466 let state = self.chain_hash_lock.load();
467 if state.input_ring_seq.get() >= published {
468 outcome = ResumePoint::Settled(state);
469 return true;
470 }
471 interrupted()
472 });
473 outcome
474 }
475}
476
477pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
484 StreamingResult {
485 exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
486 heard_from_primary: false,
487 }
488}
489
490fn run_journal_stage<W, E: std::fmt::Display>(
501 run: impl FnOnce() -> Result<W, E>,
502 failed: &AtomicBool,
503 healthy: &AtomicBool,
504) -> Result<W, E> {
505 struct PanicLatch<'a> {
508 failed: &'a AtomicBool,
509 healthy: &'a AtomicBool,
510 armed: bool,
511 }
512 impl Drop for PanicLatch<'_> {
513 fn drop(&mut self) {
514 if self.armed {
515 self.failed.store(true, Ordering::Release);
516 self.healthy.store(false, Ordering::Release);
517 tracing::error!("replica journal stage panicked — session teardown");
518 }
519 }
520 }
521 let mut guard = PanicLatch {
522 failed,
523 healthy,
524 armed: true,
525 };
526 let result = run();
527 guard.armed = false;
528 if let Err(ref e) = result {
529 failed.store(true, Ordering::Release);
535 healthy.store(false, Ordering::Release);
538 tracing::error!(error = %e, "replica journal stage failed — session teardown");
539 }
540 result
541}
542
543pub(super) type ReplicaHandles<A> =
548 ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
549
550#[allow(clippy::too_many_arguments)]
554pub(super) fn build_replica_pipeline_with_threads<A>(
555 mut app: A,
556 writer: BufferedWriter<A::Event>,
557 cores: crate::layout::PipelineCores,
558 staging_mode: melin_journal::StagingMode,
563 snapshot_interval_ms: u64,
564 store: &NodeStore,
566 group_commit_delay: std::time::Duration,
567 fence_state: Arc<melin_transport_core::fence::FenceState>,
568 lineage_policy: &SharedLineagePolicy,
572 pipeline_healthy: Arc<AtomicBool>,
578 sizing: &A::Sizing,
581) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
582where
583 A: Application + Send + 'static,
584 A::Event: Send + Sync + 'static,
585 A::Report: Send + 'static,
586 A::QueryResponse: Send + 'static,
587{
588 <A as Application>::prefault(&mut app, sizing);
593 let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
594
595 let enable_shadow = snapshot_interval_ms > 0;
596 let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
601 epoch: fence_state.epoch(),
602 ack_policy: lineage_policy.load(),
603 };
604 let genesis_entries = writer.read_header_info()?.genesis_entries;
607 let starting_wire_seq = writer.next_sequence();
610 let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
611 app,
612 writer,
613 4096, group_commit_delay,
615 cores.stage_waits(),
616 enable_shadow,
617 fence_state,
618 );
619
620 let pipeline_shutdown = Arc::new(AtomicBool::new(false));
621
622 let ps = Arc::clone(&pipeline_shutdown);
623 let journal_core = cores.journal_seq.core;
624 let mut journal_stage = pipeline.journal_stage;
625 let stream_marks = melin_transport_core::pipeline::StreamMarks::shared();
630 journal_stage.set_stream_marks(Arc::clone(&stream_marks));
631 cores.place_journal_children(&mut journal_stage);
632 journal_stage.set_staging_mode(staging_mode);
633 let journal_failed = Arc::new(AtomicBool::new(false));
634 let journal_failed_latch = Arc::clone(&journal_failed);
635 let sequencer = journal_stage.start()?;
639 pipeline_healthy.store(true, Ordering::Release);
642 let abandon = |e: String| -> Box<dyn std::error::Error> {
652 pipeline_shutdown.store(true, Ordering::Release);
653 e.into()
654 };
655 let journal_handle =
656 melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
657 run_journal_stage(
658 || sequencer.run(&ps),
659 &journal_failed_latch,
660 &pipeline_healthy,
661 )
662 })
663 .map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
664
665 let ps = Arc::clone(&pipeline_shutdown);
666 let matching_core = cores.matching.core;
667 let matching_stage = pipeline.matching_stage;
668 let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
669 matching_stage.run(&ps)
670 })
671 .map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
672
673 let ps = Arc::clone(&pipeline_shutdown);
676 let drain = cores.response;
677 let drain_consumer = pipeline.drain_consumer;
678 let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
679 let mut consumer = drain_consumer;
680 let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
681 let mut waiter = drain.wait.waiter();
682 loop {
683 if ps.load(Ordering::Relaxed) {
684 return;
685 }
686 let count = consumer.consume_batch(&mut batch, 256);
687 if count == 0 {
688 waiter.idle();
689 } else {
690 waiter.reset();
691 }
692 }
693 })
694 .map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
695
696 let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
697 let snap_path = store.snapshot().to_path_buf();
698 let chain_lock = pipeline.chain_hash_lock.clone();
699 let ps = Arc::clone(&pipeline_shutdown);
700 let shadow = cores.shadow;
701 Some(
702 melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
703 melin_transport_core::shadow::run(
704 shadow_cons,
705 shadow_app,
706 snap_path,
707 std::time::Duration::from_millis(snapshot_interval_ms),
708 chain_lock,
709 &ps,
710 shadow.wait,
711 shadow_initial_lineage,
712 starting_wire_seq,
713 genesis_entries,
714 );
715 })
716 .map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
717 )
718 } else {
719 None
720 };
721
722 Ok(ReplicaPipelineHandles {
723 input_producer: pipeline.input_producer,
724 journal_cursor: pipeline.cursors.journal_ring_arc(),
725 chain_hash_lock: pipeline.chain_hash_lock,
726 stream_marks,
727 genesis_entries,
728 journal_failed,
729 pipeline_shutdown,
730 journal_handle,
731 matching_handle,
732 drain_handle,
733 shadow_handle,
734 })
735}
736
737pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
742 mut handles: ReplicaPipelineHandles<A, W>,
743) -> TeardownOutcome<A, W> {
744 while !handles.journal_failed.load(Ordering::Acquire) {
761 match handles
762 .input_producer
763 .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
764 {
765 Ok(_) => break,
766 Err(_) => std::thread::yield_now(),
768 }
769 }
770 shutdown_pipeline::<A, W>(
771 &handles.pipeline_shutdown,
772 handles.journal_handle,
773 handles.matching_handle,
774 handles.drain_handle,
775 handles.shadow_handle,
776 )
777}
778
779pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
790
791#[allow(clippy::type_complexity)]
808pub(super) fn recover_replica_state<A, W>(
809 store: &NodeStore,
810 fence_state: &melin_transport_core::fence::FenceState,
811 lineage_policy: &SharedLineagePolicy,
812 sizing: &A::Sizing,
815) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
816where
817 A: Application,
818 W: melin_journal::JournalWrite<A::Event>,
819{
820 let journal_path = store.journal();
823 let snapshot_path = store.snapshot();
824 remove_restamp_staging(journal_path)?;
825 if !store.journal_lineage_exists()? {
826 lineage_policy.store(None);
829 return Ok((None, None, 0u64, [0u8; 32]));
830 }
831 let engine = if snapshot_path.try_exists()? {
832 tracing::info!("recovering replica from snapshot + journal");
833 melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
834 snapshot_path,
835 journal_path,
836 )?
837 } else {
838 let mut app = A::default();
839 <A as Application>::prefault(&mut app, sizing);
840 melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
841 };
842 let next = engine.next_sequence();
843 let last = next.saturating_sub(1);
844 let hash = engine.chain_hash().unwrap_or([0u8; 32]);
845 adopt_recovered_lineage(fence_state, lineage_policy, engine.recovered_lineage());
849 let (app, writer) = engine.into_parts();
850 Ok((Some(app), Some(writer), last, hash))
851}
852
853fn adopt_recovered_lineage(
859 fence_state: &melin_transport_core::fence::FenceState,
860 lineage_policy: &SharedLineagePolicy,
861 lineage: melin_transport_core::lineage::Lineage,
862) {
863 fence_state.observe_epoch(lineage.epoch);
864 lineage_policy.store(lineage.ack_policy);
865}
866
867pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
873
874pub(in crate::replication) enum AfterSession<A, W> {
878 Return(ReceiverResult<A, W>),
882 Resync {
887 app: Option<A>,
888 journal_writer: Option<W>,
889 last_sequence: u64,
890 chain_hash: [u8; 32],
891 },
892 Reconnect,
896}
897
898impl<A, W> AfterSession<A, W> {
899 pub(in crate::replication) fn adopt(
904 self,
905 app: &mut Option<A>,
906 journal_writer: &mut Option<W>,
907 last_sequence: &mut u64,
908 chain_hash: &mut [u8; 32],
909 ) -> Option<ReceiverResult<A, W>> {
910 match self {
911 AfterSession::Return(r) => Some(r),
912 AfterSession::Resync {
913 app: recovered_app,
914 journal_writer: recovered_writer,
915 last_sequence: recovered_sequence,
916 chain_hash: recovered_hash,
917 } => {
918 *app = recovered_app;
919 *journal_writer = recovered_writer;
920 *last_sequence = recovered_sequence;
921 *chain_hash = recovered_hash;
922 None
923 }
924 AfterSession::Reconnect => None,
925 }
926 }
927}
928
929#[allow(clippy::too_many_arguments)]
953pub(in crate::replication) fn handle_session_exit<A, W>(
954 result: StreamingResult,
955 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
956 divergence_resyncs: &mut u32,
957 backoff: &mut std::time::Duration,
958 last_sequence: u64,
959 store: &NodeStore,
960 fence_state: &melin_transport_core::fence::FenceState,
961 lineage_policy: &SharedLineagePolicy,
962 shutdown: &AtomicBool,
963 promote: &crate::promotion::PromotionRequest,
964 close: impl FnOnce(),
965 sizing: &A::Sizing,
968) -> AfterSession<A, W>
969where
970 A: Application + Send + 'static,
971 W: JournalWrite<A::Event> + Send + 'static,
972{
973 let StreamingResult {
974 exit,
975 heard_from_primary,
976 } = result;
977
978 close();
979
980 match exit {
981 SessionExit::Shutdown => {
982 if let Some(p) = pipeline.take() {
983 let _ = teardown_replica_pipeline::<A, W>(p);
984 }
985 AfterSession::Return(Ok(None))
986 }
987
988 SessionExit::Promote => AfterSession::Return(match pipeline.take() {
989 Some(p) => match teardown_replica_pipeline::<A, W>(p) {
990 TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
991 _ => Err("pipeline failed during promotion".into()),
992 },
993 None => Err("pipeline missing on promote".into()),
994 }),
995
996 SessionExit::Fatal(e) => {
997 let outcome = match pipeline.take() {
998 Some(p) => teardown_replica_pipeline::<A, W>(p),
999 None => return AfterSession::Return(Err(e)),
1002 };
1003 let je = match outcome {
1014 TeardownOutcome::JournalFailed(
1015 je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
1016 ) => je,
1017 TeardownOutcome::JournalFailed(je) => {
1018 return AfterSession::Return(Err(format!("{e}: {je}").into()));
1019 }
1020 TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1021 return AfterSession::Return(Err(e));
1022 }
1023 };
1024
1025 *divergence_resyncs += 1;
1026 let attempt = *divergence_resyncs;
1027 if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1028 return AfterSession::Return(Err(format!(
1029 "mid-stream chain divergence recurred {attempt} times — giving up on \
1030 in-process resync (each cycle archives the local journal and re-seeds \
1031 from the primary; recurrence at this rate means the primary keeps \
1032 streaming history that forks from what it announces): {je}"
1033 )
1034 .into()));
1035 }
1036 tracing::warn!(
1037 error = %je,
1038 attempt,
1039 max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1040 "mid-stream chain divergence — re-deriving local state for in-process resync"
1041 );
1042 match recover_replica_state::<A, W>(store, fence_state, lineage_policy, sizing) {
1043 Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1044 app,
1045 journal_writer,
1046 last_sequence: seq,
1047 chain_hash: hash,
1048 },
1049 Err(e) => AfterSession::Return(Err(e)),
1050 }
1051 }
1052
1053 SessionExit::StreamGap(e) => {
1054 *backoff = std::time::Duration::from_secs(1);
1063 tracing::warn!(
1064 error = %e,
1065 last_sequence,
1066 "replication stream broke contiguity — reconnecting from the durable position"
1067 );
1068 sleep_then_double_backoff(backoff, shutdown, promote);
1069 AfterSession::Reconnect
1070 }
1071
1072 SessionExit::Corrupted(e) => {
1073 *backoff = std::time::Duration::from_secs(1);
1091 tracing::warn!(
1092 error = %e,
1093 last_sequence,
1094 backoff_secs = backoff.as_secs(),
1095 "replicated entry failed its integrity check — refused, reconnecting to \
1096 fetch it again; recurrence points at the network, a NIC or memory"
1097 );
1098 sleep_then_double_backoff(backoff, shutdown, promote);
1099 AfterSession::Reconnect
1100 }
1101
1102 SessionExit::Disconnected => {
1103 if heard_from_primary {
1112 *backoff = std::time::Duration::from_secs(1);
1113 }
1114 tracing::warn!(
1115 last_sequence,
1116 heard_from_primary,
1117 backoff_secs = backoff.as_secs(),
1118 "reconnecting to primary"
1119 );
1120 sleep_then_double_backoff(backoff, shutdown, promote);
1121 AfterSession::Reconnect
1122 }
1123 }
1124}
1125
1126pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1136 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1137 app: &mut Option<A>,
1138 journal_writer: &mut Option<W>,
1139) -> ReceiverResult<A, W>
1140where
1141 A: Application + Send + 'static,
1142 W: JournalWrite<A::Event> + Send + 'static,
1143{
1144 if let Some(p) = pipeline.take()
1145 && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1146 {
1147 *app = Some(e);
1148 *journal_writer = Some(w);
1149 }
1150 match (app.take(), journal_writer.take()) {
1151 (Some(e), Some(w)) => Ok(Some((e, w))),
1152 _ => Err("promotion requested but no local state available".into()),
1153 }
1154}
1155
1156struct ResyncTransfer<A> {
1158 app: A,
1160 snap_sequence: u64,
1162 snap_chain_hash: [u8; 32],
1165 seed_len: u64,
1167 lineage: melin_transport_core::lineage::Lineage,
1170}
1171
1172pub(in crate::replication) enum ResyncDecision {
1175 Ready {
1180 segment_start_sequence: u64,
1181 anchor_hash: [u8; 32],
1182 genesis_entries: Option<u64>,
1185 resume_sequence: u64,
1186 },
1187 Retry,
1191}
1192
1193fn receive_resync_transfer<A, S>(
1203 source: &mut S,
1204 store: &NodeStore,
1205) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1206where
1207 A: Application + Send,
1208 S: ControlFrameSource,
1209{
1210 let (snap_len, snap_sequence, snap_chain_hash) =
1211 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1212 PrimaryMessage::SnapshotBegin {
1213 snapshot_len,
1214 snap_sequence,
1215 snap_chain_hash,
1216 } => (snapshot_len, snap_sequence, snap_chain_hash),
1217 other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1218 };
1219
1220 tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1221 let snapshot_path = store.snapshot();
1222 let journal_path = store.journal();
1223 let tmp_path = store.snapshot_tmp();
1224 receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1225 std::fs::rename(&tmp_path, snapshot_path)?;
1226 melin_journal::segment::fsync_parent_dir(snapshot_path)?;
1229 tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1230
1231 let (snap_app, snap_header) =
1234 source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1235 let snap_hash = snap_header.chain_hash;
1236 if snap_hash != snap_chain_hash {
1237 return Err(format!(
1238 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1239 loaded snapshot has {snap_hash:02x?}"
1240 )
1241 .into());
1242 }
1243
1244 let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1249 PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1250 other => {
1251 return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1252 }
1253 };
1254 let seed_tmp = journal_path.with_extension("seed.tmp");
1255 receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1256 source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1262 if let Err(e) =
1263 melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1264 {
1265 let _ = std::fs::remove_file(&seed_tmp);
1266 return Err(format!("segment seed failed structural verification: {e}").into());
1267 }
1268 std::fs::rename(&seed_tmp, journal_path)?;
1269 melin_journal::segment::fsync_parent_dir(journal_path)?;
1270 Ok(())
1271 })?;
1272 Ok(ResyncTransfer {
1273 app: snap_app,
1274 snap_sequence,
1275 snap_chain_hash,
1276 seed_len,
1277 lineage: snap_header.lineage,
1278 })
1279}
1280
1281#[allow(clippy::too_many_arguments)]
1294pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1295 divergent: bool,
1296 source: &mut S,
1297 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1298 app: &mut Option<A>,
1299 journal_writer: &mut Option<W>,
1300 store: &NodeStore,
1301 fence_state: &melin_transport_core::fence::FenceState,
1302 control: &ReplicaControlPlane,
1303 last_sequence: &mut u64,
1304 chain_hash: &mut [u8; 32],
1305) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1306where
1307 A: Application + Send + 'static,
1308 A::Event: Send,
1311 W: JournalWrite<A::Event> + Send + 'static,
1312 S: ControlFrameSource,
1313{
1314 if divergent {
1319 tracing::warn!(
1320 last_sequence = *last_sequence,
1321 "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1322 );
1323 } else {
1324 tracing::info!("primary requires snapshot transfer — receiving snapshot");
1325 }
1326
1327 let old_pipeline = pipeline.take();
1334 let old_app = app.take();
1335 let old_writer = journal_writer.take();
1336
1337 let reason = if divergent {
1348 ArchiveReason::Divergent
1349 } else {
1350 ArchiveReason::Resync
1351 };
1352 source.serviced(move || {
1355 if let Some(p) = old_pipeline {
1356 let _ = teardown_replica_pipeline::<A, W>(p);
1360 }
1361 drop(old_app);
1363 drop(old_writer);
1364 archive_local_lineage(store, reason).map(drop)
1367 })?;
1368 *last_sequence = 0;
1371 *chain_hash = [0u8; 32];
1372 control.lineage_ack_policy.store(None);
1373 control
1378 .journal_tip
1379 .reset(melin_transport_core::WireSeq::new(0));
1380
1381 let ResyncTransfer {
1382 app: snap_app,
1383 snap_sequence,
1384 snap_chain_hash,
1385 seed_len,
1386 lineage: snap_lineage,
1387 } = match receive_resync_transfer::<A, S>(source, store) {
1388 Ok(v) => v,
1389 Err(e) => {
1390 tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1391 let _ = std::fs::remove_file(store.snapshot());
1395 return Ok(ResyncDecision::Retry);
1396 }
1397 };
1398 let journal_path = store.journal();
1399 adopt_recovered_lineage(fence_state, &control.lineage_ack_policy, snap_lineage);
1403 *app = Some(snap_app);
1404
1405 let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
1413 let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1414 if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1415 return Err(format!(
1416 "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1417 hash — inconsistent primary"
1418 )
1419 .into());
1420 }
1421 let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1422 *journal_writer = Some(writer);
1423
1424 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1427 PrimaryMessage::StreamStart {
1428 start_sequence,
1429 segment_start_sequence,
1430 anchor_hash,
1431 genesis_entries,
1432 epoch,
1433 } => {
1434 if segment_start_sequence != seeded_info.starting_sequence
1435 || anchor_hash != seeded_info.anchor_hash
1436 || genesis_entries != seeded_info.genesis_entries
1437 {
1438 return Err(format!(
1439 "post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
1440 {}) disagrees with the transferred segment seed (start {}, genesis {}) — \
1441 inconsistent primary",
1442 describe_genesis(genesis_entries),
1443 seeded_info.starting_sequence,
1444 describe_genesis(seeded_info.genesis_entries),
1445 )
1446 .into());
1447 }
1448 fence_state.observe_epoch(epoch);
1455 control.primary_observed.store(true, Ordering::Release);
1456 tracing::info!(
1457 start_sequence,
1458 epoch,
1459 "streaming resumed after snapshot transfer"
1460 );
1461 control
1464 .journal_tip
1465 .reset(melin_transport_core::WireSeq::new(snap_sequence));
1466 Ok(ResyncDecision::Ready {
1467 segment_start_sequence,
1468 anchor_hash,
1469 genesis_entries,
1470 resume_sequence: snap_sequence,
1471 })
1472 }
1473 other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1474 }
1475}
1476
1477fn describe_genesis(genesis_entries: Option<u64>) -> String {
1480 match genesis_entries {
1481 Some(n) => format!("{n} entries"),
1482 None => "unknown".to_string(),
1483 }
1484}
1485
1486pub(in crate::replication) fn local_lineage_genesis<A, W>(
1497 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1498 journal_writer: &Option<W>,
1499) -> Result<Option<Option<u64>>, melin_journal::JournalError>
1500where
1501 A: Application,
1502 W: JournalWrite<A::Event> + Send + 'static,
1503{
1504 match (pipeline, journal_writer) {
1505 (Some(p), _) => Ok(Some(p.genesis_entries)),
1506 (None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
1509 (None, None) => Ok(None),
1510 }
1511}
1512
1513fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
1516 journal_path.with_extension("restamp.tmp")
1517}
1518
1519fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
1523 match std::fs::remove_file(restamp_staging_path(journal_path)) {
1524 Ok(()) => Ok(()),
1525 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1527 Err(e) => Err(e),
1528 }
1529}
1530
1531pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
1556 journal_writer: &mut Option<W>,
1557 lineage: (u64, [u8; 32], Option<u64>),
1558) -> Result<(), melin_journal::JournalError>
1559where
1560 E: melin_app::AppEvent,
1561 W: JournalWrite<E>,
1562{
1563 let (segment_start_sequence, anchor_hash, advertised) = lineage;
1564 let Some(writer) = journal_writer.as_ref() else {
1565 return Ok(());
1566 };
1567 if advertised.is_none()
1568 || segment_start_sequence != 1
1569 || writer.segment_starting_sequence() != 1
1570 || writer.next_sequence() != 1
1571 {
1572 return Ok(());
1573 }
1574 let info = writer.read_header_info()?;
1575 if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
1576 return Ok(());
1577 }
1578 let journal_path = writer.path().to_path_buf();
1579 if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
1580 return Ok(());
1581 }
1582
1583 let staging = restamp_staging_path(&journal_path);
1584 remove_restamp_staging(&journal_path)?;
1585 let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
1586 let valid_end = staged.valid_end();
1587 drop(staged);
1588 drop(journal_writer.take());
1590 std::fs::rename(&staging, &journal_path)?;
1591 melin_journal::segment::fsync_parent_dir(&journal_path)?;
1592 *journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
1593 tracing::info!(
1594 journal = %journal_path.display(),
1595 genesis_entries = ?advertised,
1596 "recorded the primary's genesis length in this replica's empty journal"
1597 );
1598 Ok(())
1599}
1600
1601pub(in crate::replication) enum StreamStartVerdict {
1604 StalePrimary,
1609 Follow((u64, [u8; 32], Option<u64>)),
1613}
1614
1615pub(in crate::replication) struct StreamStart {
1619 pub start_sequence: u64,
1621 pub lineage: (u64, [u8; 32], Option<u64>),
1625 pub epoch: u64,
1627}
1628
1629pub(in crate::replication) fn accept_stream_start<A, W>(
1649 start: StreamStart,
1650 transport: &'static str,
1651 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1652 journal_writer: &mut Option<W>,
1653 fence_state: &melin_transport_core::fence::FenceState,
1654 control: &ReplicaControlPlane,
1655) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
1656where
1657 A: Application,
1658 W: JournalWrite<A::Event> + Send + 'static,
1659{
1660 let StreamStart {
1661 start_sequence,
1662 lineage,
1663 epoch,
1664 } = start;
1665 if fence_state.refuses_primary(epoch) {
1666 tracing::warn!(
1667 transport,
1668 primary_epoch = epoch,
1669 our_epoch = fence_state.epoch(),
1670 "primary is behind our fencing epoch — refusing to follow stale primary"
1671 );
1672 return Ok(StreamStartVerdict::StalePrimary);
1673 }
1674 if pipeline.is_none() {
1677 adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
1678 }
1679 if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
1680 check_advertised_genesis(local, lineage.2)?;
1681 }
1682 fence_state.observe_epoch(epoch);
1683 control.primary_observed.store(true, Ordering::Release);
1684 tracing::info!(transport, start_sequence, epoch, "streaming started");
1685 Ok(StreamStartVerdict::Follow(lineage))
1686}
1687
1688pub(in crate::replication) fn check_advertised_genesis(
1703 local: Option<u64>,
1704 advertised: Option<u64>,
1705) -> Result<(), String> {
1706 match (local, advertised) {
1707 (Some(local), Some(advertised)) if local != advertised => Err(format!(
1708 "refusing to follow the primary: its journal records a genesis of {advertised} \
1709 entries but this replica's records {local} — the two journals do not share one \
1710 lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
1711 then resync this replica from scratch (move its journal, archives and snapshots \
1712 aside) or restore the primary"
1713 )),
1714 _ => Ok(()),
1715 }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use std::io::Write;
1721 use std::sync::Arc;
1722
1723 use super::auth::{authenticate_replica, authenticate_with_primary};
1724 use super::*;
1725 use counter_server::CounterEvent;
1731 type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1732 use melin_transport_core::replication::protocol::{
1733 MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
1734 MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
1735 decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
1736 encode_auth_failed, encode_auth_ok, encode_chain_check, encode_challenge,
1737 encode_challenge_response, encode_handshake, encode_hash_mismatch, encode_heartbeat,
1738 encode_input_batch, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1739 encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1740 read_frame, try_decode_input_batch,
1741 };
1742
1743 fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1747 let slot = InputSlot {
1748 connection_id: 0,
1749 request_id: melin_transport_core::pipeline::RequestId(0),
1750 key_hash: 0,
1751 sequence: end_sequence,
1752 timestamp_ns: 0,
1753 event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1754 publish_ts: Default::default(),
1755 recv_ts: Default::default(),
1756 };
1757 encode_input_batch(&[slot], buf).expect("encode InputBatch");
1758 }
1759
1760 #[test]
1761 fn handshake_encode_decode_round_trip() {
1762 let handshake = Handshake {
1763 last_sequence: 42,
1764 chain_hash: [0xAB; 32],
1765 epoch: 9,
1767 };
1768 let mut buf = Vec::new();
1769 encode_handshake(&handshake, &mut buf);
1770
1771 let payload = &buf[4..];
1773 let msg = decode_replica_message(payload).unwrap();
1774 match msg {
1775 ReplicaMessage::Handshake(h) => {
1776 assert_eq!(h.last_sequence, 42);
1777 assert_eq!(h.chain_hash, [0xAB; 32]);
1778 assert_eq!(h.epoch, 9);
1779 }
1780 _ => panic!("expected Handshake"),
1781 }
1782 }
1783
1784 #[test]
1785 fn ack_encode_decode_round_trip() {
1786 let ack = Ack {
1787 acked_sequence: 1000,
1788 in_memory_sequence: 1024,
1789 };
1790 let mut buf = Vec::new();
1791 encode_ack(&ack, &mut buf);
1792
1793 let payload = &buf[4..];
1794 let msg = decode_replica_message(payload).unwrap();
1795 match msg {
1796 ReplicaMessage::Ack(a) => {
1797 assert_eq!(a.acked_sequence, 1000);
1798 assert_eq!(a.in_memory_sequence, 1024);
1799 }
1800 _ => panic!("expected Ack"),
1801 }
1802 }
1803
1804 #[test]
1812 fn ack_wire_byte_pattern() {
1813 let ack = Ack {
1814 acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1815 in_memory_sequence: 0x1122_3344_5566_7788,
1816 };
1817 let mut buf = Vec::new();
1818 encode_ack(&ack, &mut buf);
1819 let expected: &[u8] = &[
1822 0x11, 0x00, 0x00, 0x00, 0x02, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, ];
1827 assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1828 }
1829
1830 #[test]
1834 fn an_advertised_genesis_length_must_match_a_known_one() {
1835 check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1836 check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1837 let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1838 assert!(err.contains("refusing to follow the primary"), "{err}");
1839 assert!(
1840 err.contains("genesis of 4") && err.contains("records 3"),
1841 "{err}"
1842 );
1843 check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1844 check_advertised_genesis(None, Some(3)).expect("ours unknown");
1845 check_advertised_genesis(Some(3), None).expect("theirs unknown");
1846 check_advertised_genesis(None, None).expect("both unknown");
1847 }
1848
1849 #[test]
1854 fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1855 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1856 let dir = tempfile::tempdir().expect("tempdir");
1857 let anchor = [9u8; 32];
1858 let header = |w: &Option<Writer>| {
1859 w.as_ref()
1860 .expect("writer")
1861 .read_header_info()
1862 .expect("header")
1863 .genesis_entries
1864 };
1865
1866 let path = dir.path().join("empty.journal");
1868 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1869 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1870 assert_eq!(header(&w), Some(3));
1871 let adopted = w.as_ref().expect("writer");
1872 assert_eq!(adopted.next_sequence(), 1);
1873 assert_eq!(adopted.path(), path.as_path());
1874 assert_eq!(
1875 melin_journal::segment::read_header_info(&path)
1876 .expect("on disk")
1877 .anchor_hash,
1878 anchor
1879 );
1880 assert!(!path.with_extension("restamp.tmp").exists());
1881
1882 let path = dir.path().join("both-unknown.journal");
1884 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1885 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1886 assert_eq!(header(&w), None);
1887
1888 let path = dir.path().join("other-anchor.journal");
1890 let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1891 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1892 assert_eq!(header(&w), None);
1893
1894 let path = dir.path().join("known.journal");
1897 let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1898 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1899 assert_eq!(header(&w), Some(2));
1900
1901 let path = dir.path().join("history.journal");
1903 let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1904 let seq = writer.allocate_sequence();
1905 writer
1906 .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1907 .expect("encode");
1908 writer.flush_batch_sync().expect("flush");
1909 let mut w = Some(writer);
1910 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1911 assert_eq!(header(&w), None);
1912
1913 let mut none: Option<Writer> = None;
1915 adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1916 assert!(none.is_none());
1917 }
1918
1919 #[test]
1923 fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1924 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1925 let dir = tempfile::tempdir().expect("tempdir");
1926 let fence = melin_transport_core::fence::FenceState::new(0);
1927 let journal = dir.path().join("replica.journal");
1928 let store = NodeStore::resolve(journal.clone(), None).expect("store");
1929 let staging = journal.with_extension("restamp.tmp");
1930
1931 std::fs::write(&staging, b"debris").expect("write staging");
1933 let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1934 &store,
1935 &fence,
1936 &SharedLineagePolicy::new(None),
1937 &(),
1938 )
1939 .expect("recover");
1940 assert!(app.is_none() && writer.is_none());
1941 assert!(!staging.exists(), "swept without a journal");
1942
1943 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1945 let seq = w.allocate_sequence();
1946 w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1947 .expect("encode");
1948 w.flush_batch_sync().expect("flush");
1949 drop(w);
1950 std::fs::write(&staging, b"debris").expect("write staging");
1951 let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
1952 &store,
1953 &fence,
1954 &SharedLineagePolicy::new(None),
1955 &(),
1956 )
1957 .expect("recover");
1958 assert!(writer.is_some());
1959 assert_eq!(last, 1);
1960 assert!(!staging.exists(), "swept beside a journal with history");
1961 }
1962
1963 #[test]
1969 fn replica_recovery_stores_the_lineage_policy_whole() {
1970 use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
1971 use melin_transport_core::lineage::LineagePolicy;
1972 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1973 let dir = tempfile::tempdir().expect("tempdir");
1974 let fence = melin_transport_core::fence::FenceState::new(0);
1975 let journal = dir.path().join("replica.journal");
1976 let store = NodeStore::resolve(journal.clone(), None).expect("store");
1977 let stale = Some(LineagePolicy {
1980 change: AckPolicyChange {
1981 policy: AckPolicyByte(2),
1982 unwitnessed: true,
1983 },
1984 wire_seq: melin_transport_core::WireSeq::new(9),
1985 previous: Some(AckPolicyByte(1)),
1986 });
1987 let cell = SharedLineagePolicy::new(stale);
1988 let recover = |cell: &SharedLineagePolicy| {
1989 recover_replica_state::<counter_server::Counter, Writer>(&store, &fence, cell, &())
1990 .expect("recover")
1991 };
1992
1993 recover(&cell);
1995 assert_eq!(cell.load(), None);
1996
1997 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1999 let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
2000 let seq = w.allocate_sequence();
2001 w.encode_event(seq, 0, &event, 0).expect("encode");
2002 w.flush_batch_sync().expect("flush");
2003 };
2004 append(&mut w, JournalEvent::Tick { now_ns: 0 });
2005 cell.store(stale);
2006 recover(&cell);
2007 assert_eq!(cell.load(), None);
2008
2009 append(&mut w, JournalEvent::EpochBump { epoch: 4 });
2010 append(
2011 &mut w,
2012 JournalEvent::AckPolicyChange(AckPolicyChange {
2013 policy: AckPolicyByte(0),
2014 unwitnessed: false,
2015 }),
2016 );
2017 drop(w);
2018 cell.store(stale);
2019 recover(&cell);
2020 assert_eq!(
2021 cell.load(),
2022 Some(LineagePolicy {
2023 change: AckPolicyChange {
2024 policy: AckPolicyByte(0),
2025 unwitnessed: false,
2026 },
2027 wire_seq: melin_transport_core::WireSeq::new(3),
2028 previous: None,
2029 }),
2030 "the journal's own record, at the change's sequence"
2031 );
2032 assert_eq!(fence.epoch(), 4);
2033 }
2034
2035 #[test]
2039 fn a_stale_primary_is_refused_before_anything_is_adopted() {
2040 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2041 let dir = tempfile::tempdir().expect("tempdir");
2042 let path = dir.path().join("replica.journal");
2043 let anchor = [9u8; 32];
2044 let mut journal_writer =
2045 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2046 let fence = melin_transport_core::fence::FenceState::new(5);
2047 let control = ReplicaControlPlane::new();
2048
2049 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2050 StreamStart {
2051 start_sequence: 1,
2052 lineage: (1, anchor, Some(3)),
2053 epoch: 4,
2054 },
2055 "test",
2056 &None,
2057 &mut journal_writer,
2058 &fence,
2059 &control,
2060 )
2061 .expect("a refusal is not an error");
2062
2063 assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
2064 assert_eq!(fence.epoch(), 5);
2065 assert!(!control.primary_observed.load(Ordering::Acquire));
2066 let header = journal_writer
2067 .as_ref()
2068 .expect("writer")
2069 .read_header_info()
2070 .expect("header");
2071 assert_eq!(header.genesis_entries, None, "journal left untouched");
2072 }
2073
2074 #[test]
2079 fn following_a_primary_adopts_its_epoch_and_genesis() {
2080 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2081 let dir = tempfile::tempdir().expect("tempdir");
2082 let path = dir.path().join("replica.journal");
2083 let anchor = [9u8; 32];
2084 let mut journal_writer =
2085 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2086 let fence = melin_transport_core::fence::FenceState::new(5);
2087 let control = ReplicaControlPlane::new();
2088 let held = Some(melin_transport_core::lineage::LineagePolicy::after(
2089 None,
2090 melin_transport_core::WireSeq::new(1),
2091 melin_journal::AckPolicyChange {
2092 policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
2093 unwitnessed: false,
2094 },
2095 ));
2096 control.lineage_ack_policy.store(held);
2097
2098 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2099 StreamStart {
2100 start_sequence: 1,
2101 lineage: (1, anchor, Some(3)),
2102 epoch: 7,
2103 },
2104 "test",
2105 &None,
2106 &mut journal_writer,
2107 &fence,
2108 &control,
2109 )
2110 .expect("same lineage");
2111
2112 let StreamStartVerdict::Follow(lineage) = verdict else {
2113 panic!("expected Follow");
2114 };
2115 assert_eq!(lineage, (1, anchor, Some(3)));
2116 assert_eq!(fence.epoch(), 7);
2117 assert!(control.primary_observed.load(Ordering::Acquire));
2118 assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
2119 let header = journal_writer
2120 .as_ref()
2121 .expect("writer")
2122 .read_header_info()
2123 .expect("header");
2124 assert_eq!(header.genesis_entries, Some(3));
2125 }
2126
2127 #[test]
2130 fn a_disagreeing_genesis_length_is_fatal() {
2131 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2132 let dir = tempfile::tempdir().expect("tempdir");
2133 let path = dir.path().join("replica.journal");
2134 let anchor = [9u8; 32];
2135 let mut journal_writer =
2136 Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2137 let fence = melin_transport_core::fence::FenceState::new(5);
2138 let control = ReplicaControlPlane::new();
2139
2140 let err = accept_stream_start::<counter_server::Counter, Writer>(
2141 StreamStart {
2142 start_sequence: 1,
2143 lineage: (1, anchor, Some(3)),
2144 epoch: 7,
2145 },
2146 "test",
2147 &None,
2148 &mut journal_writer,
2149 &fence,
2150 &control,
2151 )
2152 .err()
2153 .expect("different lineages");
2154
2155 assert!(err.to_string().contains("refusing to follow the primary"));
2156 assert_eq!(fence.epoch(), 5);
2157 }
2158
2159 #[test]
2166 fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2167 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2168 let dir = tempfile::tempdir().expect("tempdir");
2169 let path = dir.path().join("replica.journal");
2170
2171 let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2172 let mut journal_writer = Some(writer);
2173 assert_eq!(
2174 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2175 .expect("header read"),
2176 Some(Some(5)),
2177 );
2178
2179 let (input_producer, mut consumers) =
2180 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2181 .add_consumer()
2182 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2183 let _consumer = consumers.pop().expect("one consumer");
2184 let owned = journal_writer.take().expect("writer");
2185 let pipeline = Some(ReplicaPipelineHandles {
2186 input_producer,
2187 journal_cursor: Arc::new(make_journal_cursor(0)),
2188 chain_hash_lock: fsync_state_at(0, 0).1,
2189 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
2190 genesis_entries: Some(5),
2191 journal_failed: Arc::new(AtomicBool::new(false)),
2192 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2193 journal_handle: std::thread::spawn(
2194 move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2195 ),
2196 matching_handle: std::thread::spawn(counter_server::Counter::default),
2197 drain_handle: std::thread::spawn(|| {}),
2198 shadow_handle: None,
2199 });
2200 std::fs::remove_file(&path).expect("simulate the rotation gap");
2201 assert_eq!(
2202 local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2203 Some(Some(5)),
2204 );
2205
2206 assert_eq!(
2207 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2208 .expect("nothing to read"),
2209 None,
2210 );
2211 }
2212
2213 #[test]
2214 fn stream_start_encode_decode_round_trip() {
2215 let mut buf = Vec::new();
2216 encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
2218
2219 let payload = &buf[4..];
2220 let msg = decode_primary_message(payload).unwrap();
2221 match msg {
2222 PrimaryMessage::StreamStart {
2223 start_sequence,
2224 segment_start_sequence,
2225 anchor_hash,
2226 genesis_entries,
2227 epoch,
2228 } => {
2229 assert_eq!(start_sequence, 99);
2230 assert_eq!(segment_start_sequence, 42);
2231 assert_eq!(anchor_hash, [0xAA; 32]);
2232 assert_eq!(genesis_entries, Some(7));
2233 assert_eq!(epoch, 5);
2234 }
2235 _ => panic!("expected StreamStart"),
2236 }
2237 }
2238
2239 #[test]
2240 fn heartbeat_encode_decode_round_trip() {
2241 let mut buf = Vec::new();
2242 encode_heartbeat(123, &mut buf);
2243
2244 let payload = &buf[4..];
2245 let msg = decode_primary_message(payload).unwrap();
2246 match msg {
2247 PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
2248 _ => panic!("expected Heartbeat"),
2249 }
2250 }
2251
2252 #[test]
2253 fn need_snapshot_encode_decode_round_trip() {
2254 let mut buf = Vec::new();
2255 encode_need_snapshot(&mut buf);
2256
2257 let payload = &buf[4..];
2258 let msg = decode_primary_message(payload).unwrap();
2259 assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2260 }
2261
2262 #[test]
2263 fn hash_mismatch_encode_decode_round_trip() {
2264 let mut buf = Vec::new();
2265 encode_hash_mismatch(&mut buf);
2266
2267 let payload = &buf[4..];
2268 let msg = decode_primary_message(payload).unwrap();
2269 assert!(matches!(msg, PrimaryMessage::HashMismatch));
2270 }
2271
2272 #[test]
2273 fn rotate_encode_decode_round_trip() {
2274 let mut buf = Vec::new();
2275 encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2276
2277 let payload = &buf[4..];
2278 let msg = decode_primary_message(payload).unwrap();
2279 match msg {
2280 PrimaryMessage::Rotate {
2281 boundary_seq,
2282 tail_hash,
2283 } => {
2284 assert_eq!(boundary_seq, 7_000_000);
2285 assert_eq!(tail_hash, [0xBB; 32]);
2286 }
2287 _ => panic!("expected Rotate"),
2288 }
2289 }
2290
2291 #[test]
2292 fn chain_check_encode_decode_round_trip() {
2293 let mut buf = Vec::new();
2294 encode_chain_check(555, &[0xCC; 32], &mut buf);
2295
2296 let payload = &buf[4..];
2297 let msg = decode_primary_message(payload).unwrap();
2298 match msg {
2299 PrimaryMessage::ChainCheck {
2300 sequence,
2301 chain_hash,
2302 } => {
2303 assert_eq!(sequence, 555);
2304 assert_eq!(chain_hash, [0xCC; 32]);
2305 }
2306 _ => panic!("expected ChainCheck"),
2307 }
2308 }
2309
2310 #[test]
2314 fn rotate_wire_byte_pattern() {
2315 let mut hash = [0u8; 32];
2316 for (i, b) in hash.iter_mut().enumerate() {
2317 *b = i as u8;
2318 }
2319 let mut buf = Vec::new();
2320 encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2321 let mut expected = vec![
2324 0x29, 0x00, 0x00, 0x00, 0x16, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, ];
2328 expected.extend_from_slice(&hash);
2329 assert_eq!(
2330 buf.as_slice(),
2331 expected.as_slice(),
2332 "Rotate wire layout drifted"
2333 );
2334 }
2335
2336 #[test]
2337 fn segment_seed_begin_encode_decode_round_trip() {
2338 let mut buf = Vec::new();
2339 encode_segment_seed_begin(123_456, &mut buf);
2340
2341 let payload = &buf[4..];
2342 let msg = decode_primary_message(payload).unwrap();
2343 match msg {
2344 PrimaryMessage::SegmentSeedBegin { seed_len } => {
2345 assert_eq!(seed_len, 123_456);
2346 }
2347 _ => panic!("expected SegmentSeedBegin"),
2348 }
2349 }
2350
2351 #[test]
2352 fn unknown_replica_message_type_is_error() {
2353 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2354 let result = decode_replica_message(&payload);
2355 assert!(result.is_err());
2356 }
2357
2358 #[test]
2359 fn unknown_primary_message_type_is_error() {
2360 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2361 let result = decode_primary_message(&payload);
2362 assert!(result.is_err());
2363 }
2364
2365 #[test]
2366 fn read_frame_enforces_max_size() {
2367 let mut buf = Vec::new();
2369 buf.extend_from_slice(&1000u32.to_le_bytes());
2370 buf.extend_from_slice(&[0u8; 100]); let mut cursor = std::io::Cursor::new(buf);
2373 let result = read_frame(&mut cursor, 64);
2374 assert!(result.is_err());
2375 }
2376
2377 #[test]
2378 fn challenge_encode_decode_round_trip() {
2379 let nonce = [0x42; 32];
2380 let mut buf = Vec::new();
2381 encode_challenge(&nonce, &mut buf);
2382
2383 let payload = &buf[4..];
2384 let decoded = decode_challenge(payload).unwrap();
2385 assert_eq!(decoded, nonce);
2386 }
2387
2388 #[test]
2389 fn challenge_response_encode_decode_round_trip() {
2390 let sig = [0xAA; 64];
2391 let pubkey = [0xBB; 32];
2392 let mut buf = Vec::new();
2393 encode_challenge_response(&sig, &pubkey, &mut buf);
2394
2395 let payload = &buf[4..];
2396 let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2397 assert_eq!(decoded_sig, sig);
2398 assert_eq!(decoded_pubkey, pubkey);
2399 }
2400
2401 #[test]
2402 fn auth_ok_encode_decode_round_trip() {
2403 let mut buf = Vec::new();
2404 encode_auth_ok(&mut buf);
2405
2406 let payload = &buf[4..];
2407 assert!(decode_auth_result(payload).unwrap());
2408 }
2409
2410 #[test]
2411 fn auth_failed_encode_decode_round_trip() {
2412 let mut buf = Vec::new();
2413 encode_auth_failed(&mut buf);
2414
2415 let payload = &buf[4..];
2416 assert!(!decode_auth_result(payload).unwrap());
2417 }
2418
2419 #[test]
2420 fn decode_challenge_rejects_wrong_tag() {
2421 let mut payload = [0u8; 33];
2422 payload[0] = MSG_AUTH_OK;
2423 assert!(decode_challenge(&payload).is_err());
2424 }
2425
2426 #[test]
2427 fn decode_challenge_response_rejects_short_payload() {
2428 let payload = [MSG_CHALLENGE_RESPONSE; 10]; assert!(decode_challenge_response(&payload).is_err());
2430 }
2431
2432 #[test]
2433 fn auth_round_trip_valid_key() {
2434 use ed25519_dalek::SigningKey;
2435 use std::os::unix::net::UnixStream;
2436
2437 let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2438 let pub_b64 = base64::Engine::encode(
2439 &base64::engine::general_purpose::STANDARD,
2440 repl_key.verifying_key().to_bytes(),
2441 );
2442 let keys_content = format!("replication {pub_b64} test-replica\n");
2443 let authorized_keys =
2444 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2445 .unwrap();
2446
2447 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2448 primary_stream
2449 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2450 .unwrap();
2451 replica_stream
2452 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2453 .unwrap();
2454
2455 let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2456 let replica_handle = std::thread::spawn(move || {
2457 let mut conn = replica_stream;
2458 authenticate_with_primary(&mut conn, &repl_key_clone)
2459 });
2460
2461 let mut conn = primary_stream;
2462 authenticate_replica(&mut conn, &authorized_keys).unwrap();
2463
2464 replica_handle.join().unwrap().unwrap();
2465 }
2466
2467 #[test]
2468 fn auth_rejects_unknown_key() {
2469 use ed25519_dalek::SigningKey;
2470 use std::os::unix::net::UnixStream;
2471
2472 let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2474 let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2475 let pub_b64 = base64::Engine::encode(
2476 &base64::engine::general_purpose::STANDARD,
2477 authorized_key.verifying_key().to_bytes(),
2478 );
2479 let keys_content = format!("replication {pub_b64} authorized-replica\n");
2480 let authorized_keys =
2481 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2482 .unwrap();
2483
2484 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2485 primary_stream
2486 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2487 .unwrap();
2488 replica_stream
2489 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2490 .unwrap();
2491
2492 let replica_handle = std::thread::spawn(move || {
2493 let mut conn = replica_stream;
2494 authenticate_with_primary(&mut conn, &rogue_key)
2495 });
2496
2497 let mut conn = primary_stream;
2498 let result = authenticate_replica(&mut conn, &authorized_keys);
2499 assert!(result.is_err());
2500 assert!(result.unwrap_err().to_string().contains("unknown"));
2501
2502 let replica_result = replica_handle.join().unwrap();
2504 assert!(replica_result.is_err());
2505 }
2506
2507 #[test]
2508 fn auth_rejects_wrong_permission() {
2509 use ed25519_dalek::SigningKey;
2510 use std::os::unix::net::UnixStream;
2511
2512 let key = SigningKey::from_bytes(&[0xCC; 32]);
2514 let pub_b64 = base64::Engine::encode(
2515 &base64::engine::general_purpose::STANDARD,
2516 key.verifying_key().to_bytes(),
2517 );
2518 let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2519
2520 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2521 primary_stream
2522 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2523 .unwrap();
2524 replica_stream
2525 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2526 .unwrap();
2527
2528 let replica_handle = std::thread::spawn(move || {
2529 let mut conn = replica_stream;
2530 authenticate_with_primary(&mut conn, &key)
2531 });
2532
2533 let mut conn = primary_stream;
2534 let result = authenticate_replica(&mut conn, &authorized_keys);
2535 assert!(result.is_err());
2536 assert_eq!(
2537 result.unwrap_err().to_string(),
2538 "key listed as trader, expected replication"
2539 );
2540
2541 let replica_result = replica_handle.join().unwrap();
2542 assert!(replica_result.is_err());
2543 }
2544
2545 #[test]
2548 fn auth_rejects_invalid_signature() {
2549 use ed25519_dalek::SigningKey;
2550 use std::os::unix::net::UnixStream;
2551
2552 let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2554 let pub_b64 = base64::Engine::encode(
2555 &base64::engine::general_purpose::STANDARD,
2556 correct_key.verifying_key().to_bytes(),
2557 );
2558 let keys_content = format!("replication {pub_b64} test-replica\n");
2559 let authorized_keys =
2560 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2561 .unwrap();
2562
2563 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2564 primary_stream
2565 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2566 .unwrap();
2567 replica_stream
2568 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2569 .unwrap();
2570
2571 let replica_handle = std::thread::spawn(move || {
2574 use melin_transport_core::replication::protocol::*;
2575
2576 let mut reader = replica_stream.try_clone().unwrap();
2577 let mut writer = replica_stream;
2578
2579 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2581 let nonce = decode_challenge(&frame).unwrap();
2582
2583 let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2585 let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2586 let correct_pubkey = correct_key.verifying_key();
2587
2588 let mut buf = Vec::with_capacity(128);
2589 encode_challenge_response(
2590 &bad_signature.to_bytes(),
2591 correct_pubkey.as_bytes(),
2592 &mut buf,
2593 );
2594 writer.write_all(&buf).unwrap();
2595 writer.flush().unwrap();
2596
2597 let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2599 let ok = decode_auth_result(&result_frame).unwrap();
2600 assert!(!ok, "should receive auth failure");
2601 });
2602
2603 let mut conn = primary_stream;
2604 let result = authenticate_replica(&mut conn, &authorized_keys);
2605 assert!(result.is_err());
2606 assert!(
2607 result
2608 .unwrap_err()
2609 .to_string()
2610 .contains("signature verification failed"),
2611 "should fail on signature verification"
2612 );
2613
2614 replica_handle.join().unwrap();
2615 }
2616
2617 #[test]
2618 fn sender_receiver_end_to_end() {
2619 use std::os::unix::net::UnixStream;
2620
2621 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2623
2624 let replica_handle = std::thread::spawn(move || {
2626 let mut reader = replica_stream.try_clone().unwrap();
2627 let mut writer = replica_stream;
2628
2629 let mut buf = Vec::new();
2631 let handshake = Handshake {
2632 last_sequence: 0,
2633 chain_hash: [0u8; 32],
2634 epoch: 0,
2635 };
2636 encode_handshake(&handshake, &mut buf);
2637 writer.write_all(&buf).unwrap();
2638 writer.flush().unwrap();
2639 buf.clear();
2640
2641 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2643 let msg = decode_primary_message(&frame).unwrap();
2644 assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2645
2646 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2648 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2649 let end_seq = slots
2650 .last()
2651 .map(|s| s.sequence)
2652 .expect("InputBatch carried at least one slot");
2653
2654 let ack = Ack {
2656 acked_sequence: end_seq,
2657 in_memory_sequence: end_seq,
2658 };
2659 encode_ack(&ack, &mut buf);
2660 writer.write_all(&buf).unwrap();
2661 writer.flush().unwrap();
2662
2663 end_seq
2664 });
2665
2666 let mut p_reader = primary_stream.try_clone().unwrap();
2668 let mut p_writer = primary_stream;
2669
2670 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2672 let handshake = match decode_replica_message(&frame).unwrap() {
2673 ReplicaMessage::Handshake(h) => h,
2674 _ => panic!("expected Handshake"),
2675 };
2676 assert_eq!(handshake.last_sequence, 0);
2677
2678 let mut buf = Vec::new();
2680 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); p_writer.write_all(&buf).unwrap();
2682 p_writer.flush().unwrap();
2683 buf.clear();
2684
2685 encode_input_batch_with_seq(42, &mut buf);
2687 p_writer.write_all(&buf).unwrap();
2688 p_writer.flush().unwrap();
2689 buf.clear();
2690
2691 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2693 let ack = match decode_replica_message(&frame).unwrap() {
2694 ReplicaMessage::Ack(a) => a,
2695 _ => panic!("expected Ack"),
2696 };
2697 assert_eq!(ack.acked_sequence, 42);
2698
2699 let end_seq = replica_handle.join().unwrap();
2701 assert_eq!(end_seq, 42);
2702 }
2703
2704 #[test]
2705 fn multiple_data_batches_acked_in_order() {
2706 use std::os::unix::net::UnixStream;
2709
2710 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2711
2712 let replica_handle = std::thread::spawn(move || {
2713 let mut reader = replica_stream.try_clone().unwrap();
2714 let mut writer = replica_stream;
2715 let mut buf = Vec::new();
2716
2717 encode_handshake(
2719 &Handshake {
2720 last_sequence: 0,
2721 chain_hash: [0u8; 32],
2722 epoch: 0,
2723 },
2724 &mut buf,
2725 );
2726 writer.write_all(&buf).unwrap();
2727 writer.flush().unwrap();
2728 buf.clear();
2729
2730 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2732 assert!(matches!(
2733 decode_primary_message(&frame).unwrap(),
2734 PrimaryMessage::StreamStart { .. }
2735 ));
2736
2737 let mut acked_seqs = Vec::new();
2739 for _ in 0..3 {
2740 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2741 let slots: Vec<InputSlot> =
2742 try_decode_input_batch(&frame).expect("decode InputBatch");
2743 let end_seq = slots
2744 .last()
2745 .map(|s| s.sequence)
2746 .expect("InputBatch carried at least one slot");
2747 acked_seqs.push(end_seq);
2748
2749 encode_ack(
2750 &Ack {
2751 acked_sequence: end_seq,
2752 in_memory_sequence: end_seq,
2753 },
2754 &mut buf,
2755 );
2756 writer.write_all(&buf).unwrap();
2757 writer.flush().unwrap();
2758 buf.clear();
2759 }
2760
2761 acked_seqs
2762 });
2763
2764 let mut p_reader = primary_stream.try_clone().unwrap();
2766 let mut p_writer = primary_stream;
2767 let mut buf = Vec::new();
2768
2769 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2771 assert!(matches!(
2772 decode_replica_message(&frame).unwrap(),
2773 ReplicaMessage::Handshake(_)
2774 ));
2775
2776 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
2778 p_writer.write_all(&buf).unwrap();
2779 p_writer.flush().unwrap();
2780 buf.clear();
2781
2782 for seq in [10u64, 20, 30] {
2784 encode_input_batch_with_seq(seq, &mut buf);
2785 p_writer.write_all(&buf).unwrap();
2786 p_writer.flush().unwrap();
2787 buf.clear();
2788 }
2789
2790 for expected_seq in [10u64, 20, 30] {
2792 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2793 let ack = match decode_replica_message(&frame).unwrap() {
2794 ReplicaMessage::Ack(a) => a,
2795 other => panic!("expected Ack, got {other:?}"),
2796 };
2797 assert_eq!(ack.acked_sequence, expected_seq);
2798 }
2799
2800 let acked = replica_handle.join().unwrap();
2801 assert_eq!(acked, vec![10, 20, 30]);
2802 }
2803
2804 #[test]
2805 fn heartbeat_encode_contains_sequence() {
2806 let mut buf = Vec::new();
2809 encode_heartbeat(999, &mut buf);
2810
2811 let payload = &buf[4..];
2812 match decode_primary_message(payload).unwrap() {
2813 PrimaryMessage::Heartbeat { sequence, .. } => {
2814 assert_eq!(sequence, 999);
2815 }
2816 other => panic!("expected Heartbeat, got {other:?}"),
2817 }
2818 }
2819
2820 #[test]
2821 fn replica_mid_stream_handshake_with_nonzero_sequence() {
2822 use std::os::unix::net::UnixStream;
2827
2828 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2829
2830 let replica_handle = std::thread::spawn(move || {
2831 let mut reader = replica_stream.try_clone().unwrap();
2832 let mut writer = replica_stream;
2833 let mut buf = Vec::new();
2834
2835 encode_handshake(
2837 &Handshake {
2838 last_sequence: 100,
2839 chain_hash: [0xBB; 32],
2840 epoch: 0,
2841 },
2842 &mut buf,
2843 );
2844 writer.write_all(&buf).unwrap();
2845 writer.flush().unwrap();
2846 buf.clear();
2847
2848 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2850 match decode_primary_message(&frame).unwrap() {
2851 PrimaryMessage::StreamStart { start_sequence, .. } => {
2852 assert_eq!(
2853 start_sequence, 100,
2854 "StreamStart should echo replica's last_sequence"
2855 );
2856 }
2857 other => panic!("expected StreamStart, got {other:?}"),
2858 }
2859
2860 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2862 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2863 let end_sequence = slots
2864 .last()
2865 .map(|s| s.sequence)
2866 .expect("InputBatch carried at least one slot");
2867 assert!(
2868 end_sequence > 100,
2869 "InputBatch should be after replica's last_sequence"
2870 );
2871 });
2872
2873 let mut p_reader = primary_stream.try_clone().unwrap();
2875 let mut p_writer = primary_stream;
2876 let mut buf = Vec::new();
2877
2878 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2880 let handshake = match decode_replica_message(&frame).unwrap() {
2881 ReplicaMessage::Handshake(h) => h,
2882 _ => panic!("expected Handshake"),
2883 };
2884 assert_eq!(handshake.last_sequence, 100);
2885 assert_eq!(handshake.chain_hash, [0xBB; 32]);
2886
2887 encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
2889 p_writer.write_all(&buf).unwrap();
2890 p_writer.flush().unwrap();
2891 buf.clear();
2892
2893 encode_input_batch_with_seq(150, &mut buf);
2895 p_writer.write_all(&buf).unwrap();
2896 p_writer.flush().unwrap();
2897
2898 replica_handle.join().unwrap();
2899 }
2900
2901 #[test]
2916 fn receiver_refuses_stream_start_from_stale_primary() {
2917 use std::os::unix::net::UnixStream;
2918
2919 use melin_transport_core::fence::FenceState;
2920
2921 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2922
2923 const STALE_EPOCH: u64 = 3;
2927 let primary_handle = std::thread::spawn(move || {
2928 let mut reader = primary_stream.try_clone().unwrap();
2929 let mut writer = primary_stream;
2930
2931 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2932 let handshake = match decode_replica_message(&frame).unwrap() {
2933 ReplicaMessage::Handshake(h) => h,
2934 _ => panic!("expected Handshake"),
2935 };
2936 assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2939
2940 let mut buf = Vec::new();
2941 encode_stream_start(
2942 handshake.last_sequence,
2943 1,
2944 [0u8; 32],
2945 Some(0),
2946 STALE_EPOCH,
2947 &mut buf,
2948 );
2949 writer.write_all(&buf).unwrap();
2950 writer.flush().unwrap();
2951 });
2952
2953 let fence_state = Arc::new(FenceState::new(5));
2956 let mut r_reader = replica_stream.try_clone().unwrap();
2957 let mut r_writer = replica_stream;
2958
2959 let mut buf = Vec::new();
2960 encode_handshake(
2961 &Handshake {
2962 last_sequence: 0,
2963 chain_hash: [0u8; 32],
2964 epoch: fence_state.epoch(),
2965 },
2966 &mut buf,
2967 );
2968 r_writer.write_all(&buf).unwrap();
2969 r_writer.flush().unwrap();
2970
2971 let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2972 let stream_epoch = match decode_primary_message(&frame).unwrap() {
2973 PrimaryMessage::StreamStart { epoch, .. } => epoch,
2974 other => panic!("expected StreamStart, got {other:?}"),
2975 };
2976 assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2977
2978 assert!(
2981 fence_state.refuses_primary(stream_epoch),
2982 "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2983 );
2984 assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2985
2986 assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2989 assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2990
2991 primary_handle.join().unwrap();
2992 }
2993
2994 #[test]
2995 fn snapshot_begin_encode_decode_round_trip() {
2996 let mut buf = Vec::new();
2997 encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2998
2999 let payload = &buf[4..];
3000 let msg = decode_primary_message(payload).unwrap();
3001 match msg {
3002 PrimaryMessage::SnapshotBegin {
3003 snapshot_len,
3004 snap_sequence,
3005 snap_chain_hash,
3006 } => {
3007 assert_eq!(snapshot_len, 1_000_000);
3008 assert_eq!(snap_sequence, 42);
3009 assert_eq!(snap_chain_hash, [0xAB; 32]);
3010 }
3011 _ => panic!("expected SnapshotBegin"),
3012 }
3013 }
3014
3015 #[test]
3016 fn snapshot_chunk_encode_decode_round_trip() {
3017 let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
3018 let mut buf = Vec::new();
3019 encode_snapshot_chunk(&data, &mut buf);
3020
3021 let payload = &buf[4..];
3022 let msg = decode_primary_message(payload).unwrap();
3023 match msg {
3024 PrimaryMessage::SnapshotChunk(chunk) => {
3025 assert_eq!(chunk, data);
3026 }
3027 _ => panic!("expected SnapshotChunk"),
3028 }
3029 }
3030
3031 #[test]
3032 fn snapshot_end_encode_decode_round_trip() {
3033 let mut buf = Vec::new();
3034 encode_snapshot_end(0xDEADBEEF, &mut buf);
3035
3036 let payload = &buf[4..];
3037 let msg = decode_primary_message(payload).unwrap();
3038 match msg {
3039 PrimaryMessage::SnapshotEnd { crc32c } => {
3040 assert_eq!(crc32c, 0xDEADBEEF);
3041 }
3042 _ => panic!("expected SnapshotEnd"),
3043 }
3044 }
3045
3046 #[test]
3050 fn snapshot_receiver_detects_length_mismatch() {
3051 use std::os::unix::net::UnixStream;
3052
3053 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3054
3055 let receiver = std::thread::spawn(move || -> String {
3057 let mut reader = replica_stream.try_clone().unwrap();
3058
3059 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3061 assert!(matches!(
3062 decode_primary_message(&frame).unwrap(),
3063 PrimaryMessage::NeedSnapshot,
3064 ));
3065
3066 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3068 let (snap_len, _snap_sequence, _snap_chain_hash) =
3069 match decode_primary_message(&frame).unwrap() {
3070 PrimaryMessage::SnapshotBegin {
3071 snapshot_len,
3072 snap_sequence,
3073 snap_chain_hash,
3074 } => (snapshot_len, snap_sequence, snap_chain_hash),
3075 other => panic!("expected SnapshotBegin, got {other:?}"),
3076 };
3077
3078 let mut received: u64 = 0;
3080 loop {
3081 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3082 match decode_primary_message(&frame).unwrap() {
3083 PrimaryMessage::SnapshotChunk(data) => {
3084 received += data.len() as u64;
3085 }
3086 PrimaryMessage::SnapshotEnd { .. } => {
3087 if received != snap_len {
3088 return format!(
3089 "snapshot length mismatch: expected {snap_len} bytes, got {received}"
3090 );
3091 }
3092 return String::new(); }
3094 other => panic!("unexpected message: {other:?}"),
3095 }
3096 }
3097 });
3098
3099 let mut writer = primary_stream;
3101 let mut buf = Vec::new();
3102
3103 let actual_data = vec![0xAA; 100];
3104 let wrong_len = 999u64; encode_need_snapshot(&mut buf);
3107 std::io::Write::write_all(&mut writer, &buf).unwrap();
3108 buf.clear();
3109
3110 encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3111 std::io::Write::write_all(&mut writer, &buf).unwrap();
3112 buf.clear();
3113
3114 encode_snapshot_chunk(&actual_data, &mut buf);
3115 std::io::Write::write_all(&mut writer, &buf).unwrap();
3116 buf.clear();
3117
3118 let crc = crc32c::crc32c(&actual_data);
3119 encode_snapshot_end(crc, &mut buf);
3120 std::io::Write::write_all(&mut writer, &buf).unwrap();
3121 std::io::Write::flush(&mut writer).unwrap();
3122
3123 let error_msg = receiver.join().unwrap();
3124 assert!(
3125 error_msg.contains("length mismatch"),
3126 "expected length mismatch error, got: {error_msg:?}"
3127 );
3128 }
3129
3130 #[test]
3134 fn snapshot_receiver_detects_crc_mismatch() {
3135 use std::os::unix::net::UnixStream;
3136
3137 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3138
3139 let receiver = std::thread::spawn(move || -> String {
3140 let mut reader = replica_stream.try_clone().unwrap();
3141
3142 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3144 assert!(matches!(
3145 decode_primary_message(&frame).unwrap(),
3146 PrimaryMessage::NeedSnapshot,
3147 ));
3148
3149 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3151 let snap_len = match decode_primary_message(&frame).unwrap() {
3152 PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3153 other => panic!("expected SnapshotBegin, got {other:?}"),
3154 };
3155
3156 let mut received_data = Vec::new();
3158 let mut received: u64 = 0;
3159 loop {
3160 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3161 match decode_primary_message(&frame).unwrap() {
3162 PrimaryMessage::SnapshotChunk(data) => {
3163 received += data.len() as u64;
3164 received_data.extend_from_slice(&data);
3165 }
3166 PrimaryMessage::SnapshotEnd {
3167 crc32c: expected_crc,
3168 } => {
3169 if received != snap_len {
3170 return format!("length mismatch: {snap_len} vs {received}");
3171 }
3172 let actual_crc = crc32c::crc32c(&received_data);
3173 if actual_crc != expected_crc {
3174 return format!(
3175 "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3176 );
3177 }
3178 return String::new();
3179 }
3180 other => panic!("unexpected message: {other:?}"),
3181 }
3182 }
3183 });
3184
3185 let mut writer = primary_stream;
3187 let mut buf = Vec::new();
3188
3189 let data = vec![0xAA; 100];
3190
3191 encode_need_snapshot(&mut buf);
3192 std::io::Write::write_all(&mut writer, &buf).unwrap();
3193 buf.clear();
3194
3195 encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3196 std::io::Write::write_all(&mut writer, &buf).unwrap();
3197 buf.clear();
3198
3199 encode_snapshot_chunk(&data, &mut buf);
3200 std::io::Write::write_all(&mut writer, &buf).unwrap();
3201 buf.clear();
3202
3203 let wrong_crc = !crc32c::crc32c(&data);
3205 encode_snapshot_end(wrong_crc, &mut buf);
3206 std::io::Write::write_all(&mut writer, &buf).unwrap();
3207 std::io::Write::flush(&mut writer).unwrap();
3208
3209 let error_msg = receiver.join().unwrap();
3210 assert!(
3211 error_msg.contains("CRC mismatch"),
3212 "expected CRC mismatch error, got: {error_msg:?}"
3213 );
3214 }
3215
3216 #[test]
3219 fn snapshot_receiver_detects_chain_hash_mismatch() {
3220 use std::os::unix::net::UnixStream;
3221
3222 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3223
3224 let receiver = std::thread::spawn(move || -> String {
3225 let mut reader = replica_stream.try_clone().unwrap();
3226
3227 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3228 assert!(matches!(
3229 decode_primary_message(&frame).unwrap(),
3230 PrimaryMessage::NeedSnapshot,
3231 ));
3232
3233 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3234 let (snap_len, _snap_sequence, snap_chain_hash) =
3235 match decode_primary_message(&frame).unwrap() {
3236 PrimaryMessage::SnapshotBegin {
3237 snapshot_len,
3238 snap_sequence,
3239 snap_chain_hash,
3240 } => (snapshot_len, snap_sequence, snap_chain_hash),
3241 other => panic!("expected SnapshotBegin, got {other:?}"),
3242 };
3243
3244 let mut received_data = Vec::new();
3246 let mut received: u64 = 0;
3247 loop {
3248 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3249 match decode_primary_message(&frame).unwrap() {
3250 PrimaryMessage::SnapshotChunk(data) => {
3251 received += data.len() as u64;
3252 received_data.extend_from_slice(&data);
3253 }
3254 PrimaryMessage::SnapshotEnd {
3255 crc32c: expected_crc,
3256 } => {
3257 assert_eq!(received, snap_len, "length should match");
3258 let actual_crc = crc32c::crc32c(&received_data);
3259 assert_eq!(actual_crc, expected_crc, "CRC should match");
3260 break;
3261 }
3262 other => panic!("unexpected message: {other:?}"),
3263 }
3264 }
3265
3266 let loaded_hash = [0xFF; 32]; if loaded_hash != snap_chain_hash {
3270 return format!(
3271 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3272 loaded snapshot has {loaded_hash:02x?}"
3273 );
3274 }
3275 String::new()
3276 });
3277
3278 let mut writer = primary_stream;
3281 let mut buf = Vec::new();
3282
3283 let data = vec![0xAA; 64];
3284 let advertised_hash = [0xBB; 32];
3286
3287 encode_need_snapshot(&mut buf);
3288 std::io::Write::write_all(&mut writer, &buf).unwrap();
3289 buf.clear();
3290
3291 encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3292 std::io::Write::write_all(&mut writer, &buf).unwrap();
3293 buf.clear();
3294
3295 encode_snapshot_chunk(&data, &mut buf);
3296 std::io::Write::write_all(&mut writer, &buf).unwrap();
3297 buf.clear();
3298
3299 let crc = crc32c::crc32c(&data);
3300 encode_snapshot_end(crc, &mut buf);
3301 std::io::Write::write_all(&mut writer, &buf).unwrap();
3302 std::io::Write::flush(&mut writer).unwrap();
3303
3304 let error_msg = receiver.join().unwrap();
3305 assert!(
3306 error_msg.contains("chain hash mismatch"),
3307 "expected chain hash mismatch error, got: {error_msg:?}"
3308 );
3309 }
3310
3311 #[test]
3314 fn primary_rejects_snapshot_with_invalid_magic() {
3315 let tmp = tempfile::tempdir().unwrap();
3316 let snap_path = tmp.path().join("test.snapshot");
3317
3318 let mut bad_snap = vec![0u8; 64];
3320 bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); std::fs::write(&snap_path, &bad_snap).unwrap();
3323
3324 let snap_data = std::fs::read(&snap_path).unwrap();
3326 assert!(
3327 snap_data.len() >= 48,
3328 "file should be big enough for header"
3329 );
3330
3331 let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3332 assert_ne!(magic, 0x534E_4150);
3333 assert_eq!(magic, 0xDEAD_BEEF);
3334 }
3335
3336 #[test]
3339 fn primary_rejects_snapshot_too_small_for_header() {
3340 let tmp = tempfile::tempdir().unwrap();
3341 let snap_path = tmp.path().join("test.snapshot");
3342
3343 std::fs::write(&snap_path, [0u8; 20]).unwrap();
3345
3346 let snap_data = std::fs::read(&snap_path).unwrap();
3347 assert!(
3348 snap_data.len() < 48,
3349 "file must be too small for header validation"
3350 );
3351 }
3352
3353 #[test]
3354 fn decode_snapshot_begin_too_short() {
3355 let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3358 let err = decode_primary_message(&payload).unwrap_err();
3359 assert!(
3360 err.to_string().contains("SnapshotBegin too short"),
3361 "expected 'SnapshotBegin too short', got: {err}"
3362 );
3363 }
3364
3365 #[test]
3366 fn decode_snapshot_end_too_short() {
3367 let payload = [MSG_SNAPSHOT_END];
3369 let err = decode_primary_message(&payload).unwrap_err();
3370 assert!(
3371 err.to_string().contains("SnapshotEnd too short"),
3372 "expected 'SnapshotEnd too short', got: {err}"
3373 );
3374 }
3375
3376 #[test]
3377 fn decode_snapshot_chunk_empty_data() {
3378 let payload = [MSG_SNAPSHOT_CHUNK];
3380 let msg = decode_primary_message(&payload).unwrap();
3381 match msg {
3382 PrimaryMessage::SnapshotChunk(data) => {
3383 assert!(data.is_empty());
3384 }
3385 _ => panic!("expected SnapshotChunk"),
3386 }
3387 }
3388
3389 fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3392 melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3393 }
3394
3395 #[test]
3396 fn pending_ack_queue_push_and_pop_ready() {
3397 let mut q = PendingAckQueue::new(8);
3398 assert!(q.is_empty());
3399 assert!(!q.is_full());
3400
3401 q.push(10, 100);
3402 q.push(20, 200);
3403 assert!(!q.is_empty());
3404
3405 let cursor = make_journal_cursor(5);
3407 assert!(q.pop_ready(&cursor).is_none());
3408
3409 cursor.get().store(15, Ordering::Relaxed);
3411 assert_eq!(q.pop_ready(&cursor), Some(100));
3412 assert!(!q.is_empty());
3414
3415 cursor.get().store(25, Ordering::Relaxed);
3417 assert_eq!(q.pop_ready(&cursor), Some(200));
3418 assert!(q.is_empty());
3419 }
3420
3421 #[test]
3422 fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3423 let mut q = PendingAckQueue::new(8);
3427 q.push(10, 100);
3428 q.push(20, 200);
3429 q.push(30, 300);
3430
3431 let cursor = make_journal_cursor(30);
3432 assert_eq!(q.pop_ready(&cursor), Some(300));
3433 assert!(q.is_empty());
3434 }
3435
3436 #[test]
3437 fn pending_ack_queue_capacity_and_full() {
3438 let mut q = PendingAckQueue::new(8);
3439 for i in 0..8 {
3440 assert!(!q.is_full());
3441 q.push(i as u64 + 1, (i + 1) as u64 * 100);
3442 }
3443 assert!(q.is_full());
3444 }
3445
3446 #[test]
3451 fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3452 let mut q = PendingAckQueue::new(4);
3453 for i in 1..=4u64 {
3454 q.push(i * 10, i * 100);
3455 }
3456 assert!(q.is_full());
3457 assert_eq!(q.merged(), 0);
3458
3459 q.push(50, 500);
3461 assert_eq!(q.merged(), 1);
3462 assert_eq!(q.len(), 4, "merge must not grow the queue");
3463
3464 let cursor = make_journal_cursor(40);
3467 assert_eq!(q.pop_ready(&cursor), Some(300));
3468 assert!(!q.is_empty(), "merged entry is not durable yet");
3469
3470 cursor.get().store(50, Ordering::Relaxed);
3473 assert_eq!(q.pop_ready(&cursor), Some(500));
3474 assert!(q.is_empty());
3475 }
3476
3477 #[test]
3480 fn pending_ack_queue_merges_repeatedly_without_growing() {
3481 let mut q = PendingAckQueue::new(2);
3482 for i in 1..=100u64 {
3483 q.push(i, i * 10);
3484 }
3485 assert_eq!(q.len(), 2);
3486 assert_eq!(q.merged(), 98);
3487
3488 let cursor = make_journal_cursor(99);
3490 assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3491 cursor.get().store(100, Ordering::Relaxed);
3493 assert_eq!(q.pop_ready(&cursor), Some(1000));
3494 }
3495
3496 #[test]
3497 fn pending_ack_queue_pop_oldest_blocking() {
3498 let mut q = PendingAckQueue::new(8);
3499 q.push(10, 100);
3500 q.push(20, 200);
3501
3502 let cursor = make_journal_cursor(25);
3505 let seq = q.pop_oldest_blocking(
3506 &cursor,
3507 melin_pipeline::wait::WaitStrategy::BusySpin,
3508 &AtomicBool::new(false),
3509 );
3510 assert_eq!(seq, Some(200));
3512 assert!(q.is_empty());
3513 }
3514
3515 #[test]
3518 fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3519 let mut q = PendingAckQueue::new(8);
3520 q.push(10, 100);
3521
3522 let cursor = make_journal_cursor(5);
3524 let abort = AtomicBool::new(true);
3525 assert_eq!(
3526 q.pop_oldest_blocking(
3527 &cursor,
3528 melin_pipeline::wait::WaitStrategy::BusySpin,
3529 &abort
3530 ),
3531 None
3532 );
3533 assert!(!q.is_empty(), "aborted wait must not pop the entry");
3534 assert_eq!(
3535 q.pop_all_blocking(
3536 &cursor,
3537 melin_pipeline::wait::WaitStrategy::BusySpin,
3538 &abort
3539 ),
3540 None
3541 );
3542 }
3543
3544 #[test]
3553 fn shutdown_pipeline_surfaces_journal_error_kind() {
3554 let flag = AtomicBool::new(false);
3555 let matching_joined = Arc::new(AtomicBool::new(false));
3556 let mj = Arc::clone(&matching_joined);
3557
3558 let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3559 Err(melin_journal::JournalError::ReplicaChainDivergence {
3560 sequence: 42,
3561 expected: [1u8; 32],
3562 actual: [2u8; 32],
3563 })
3564 });
3565 let matching = std::thread::spawn(move || {
3566 mj.store(true, Ordering::Release);
3567 7u64
3568 });
3569 let drain = std::thread::spawn(|| {});
3570
3571 let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3572 assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3573 assert!(
3574 matching_joined.load(Ordering::Acquire),
3575 "matching thread must be joined even when the journal failed"
3576 );
3577 match outcome {
3578 TeardownOutcome::JournalFailed(
3579 melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3580 ) => assert_eq!(sequence, 42),
3581 _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3582 }
3583 }
3584
3585 #[test]
3586 fn shutdown_pipeline_clean_returns_both_states() {
3587 let flag = AtomicBool::new(false);
3588 let journal =
3589 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3590 let matching = std::thread::spawn(|| 7u64);
3591 let drain = std::thread::spawn(|| {});
3592
3593 match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3594 TeardownOutcome::Clean(app, writer) => {
3595 assert_eq!(app, 7);
3596 assert_eq!(writer, 11);
3597 }
3598 _ => panic!("expected Clean"),
3599 }
3600 }
3601
3602 #[test]
3603 fn shutdown_pipeline_panicked_stage_reports_panicked() {
3604 let flag = AtomicBool::new(false);
3605 let journal =
3606 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3607 let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3608 let drain = std::thread::spawn(|| {});
3609
3610 assert!(matches!(
3611 shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3612 TeardownOutcome::Panicked
3613 ));
3614 }
3615
3616 fn teardown_fixture(
3621 capacity: usize,
3622 ) -> (
3623 ReplicaPipelineHandles<counter_server::Counter, u32>,
3624 melin_pipeline::ring::Consumer<InputSlot>,
3625 ) {
3626 handles_fixture(capacity, fsync_state_at(0, 0).1)
3627 }
3628
3629 fn fsync_state_at(
3632 journal_seq: u64,
3633 ring_pos: u64,
3634 ) -> (
3635 melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3636 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3637 ) {
3638 melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3639 journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3640 chain_hash: [0u8; 32],
3641 input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3642 })
3643 }
3644
3645 fn handles_fixture(
3647 capacity: usize,
3648 chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3649 melin_transport_core::pipeline::FsyncState,
3650 >,
3651 ) -> (
3652 ReplicaPipelineHandles<counter_server::Counter, u32>,
3653 melin_pipeline::ring::Consumer<InputSlot>,
3654 ) {
3655 let (input_producer, mut consumers) =
3656 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3657 .add_consumer()
3658 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3659 let consumer = consumers.pop().expect("one consumer");
3660 let handles = ReplicaPipelineHandles {
3661 input_producer,
3662 journal_cursor: Arc::new(make_journal_cursor(0)),
3663 chain_hash_lock,
3664 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3665 genesis_entries: None,
3666 journal_failed: Arc::new(AtomicBool::new(false)),
3667 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3668 journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3669 Ok(11)
3670 }),
3671 matching_handle: std::thread::spawn(counter_server::Counter::default),
3672 drain_handle: std::thread::spawn(|| {}),
3673 shadow_handle: None,
3674 };
3675 (handles, consumer)
3676 }
3677
3678 #[test]
3684 fn teardown_publishes_shutdown_sentinel() {
3685 let (handles, mut consumer) = teardown_fixture(8);
3686 assert!(matches!(
3687 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3688 TeardownOutcome::Clean(..)
3689 ));
3690 let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3691 assert!(
3692 slot.event.is_shutdown(),
3693 "published slot must be the sentinel"
3694 );
3695 assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3696 }
3697
3698 fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
3700 use std::os::unix::thread::JoinHandleExt;
3701 let mut policy: libc::c_int = 0;
3702 let mut param = libc::sched_param { sched_priority: 0 };
3703 let ret =
3704 unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
3705 assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
3706 policy
3707 }
3708
3709 #[test]
3730 fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
3731 let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
3732 eprintln!("skipped: this thread may run on CPU 0 only");
3734 return;
3735 };
3736 let dir = tempfile::tempdir().expect("tempdir");
3737
3738 let policies = std::thread::spawn(move || {
3739 melin_test_node::sched::set_batch_policy();
3741
3742 let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
3743 &dir.path().join("replica.journal"),
3744 1,
3745 [7; 32],
3746 Some(5),
3747 )
3748 .expect("create journal");
3749 let placed = crate::layout::Placement::yielding(core);
3752 let cores = crate::layout::PipelineCores {
3753 journal_seq: placed,
3754 matching: placed,
3755 response: placed,
3756 shadow: placed,
3757 journal_prep: placed,
3758 journal_disk: placed,
3759 ..crate::layout::PipelineCores::unpinned()
3760 };
3761 let handles = build_replica_pipeline_with_threads(
3762 counter_server::Counter::default(),
3763 writer,
3764 cores,
3765 melin_journal::StagingMode::Allocate,
3766 3_600_000,
3769 &NodeStore::resolve(dir.path().join("replica.journal"), None).expect("store"),
3770 std::time::Duration::ZERO,
3771 Arc::new(melin_transport_core::fence::FenceState::new(0)),
3772 &SharedLineagePolicy::new(None),
3773 Arc::new(AtomicBool::new(true)),
3774 &(),
3775 )
3776 .expect("build the replica pipeline");
3777
3778 let policies = [
3779 ("journal-seq", Some(policy_of(&handles.journal_handle))),
3780 ("matching", Some(policy_of(&handles.matching_handle))),
3781 ("drain", Some(policy_of(&handles.drain_handle))),
3782 (
3783 "replica-shadow",
3784 handles.shadow_handle.as_ref().map(policy_of),
3785 ),
3786 ];
3787 let outcome = teardown_replica_pipeline(handles);
3788 assert!(
3789 matches!(outcome, TeardownOutcome::Clean(..)),
3790 "the pipeline must tear down cleanly"
3791 );
3792 policies
3793 })
3794 .join()
3795 .expect("builder thread");
3796
3797 let inherited: Vec<&str> = policies
3798 .iter()
3799 .map(|(name, policy)| {
3800 (
3801 *name,
3802 policy.unwrap_or_else(|| panic!("{name} was not spawned")),
3803 )
3804 })
3805 .filter(|&(_, policy)| policy == libc::SCHED_BATCH)
3806 .map(|(name, _)| name)
3807 .collect();
3808 assert!(
3809 inherited.is_empty(),
3810 "{inherited:?} started in the builder's scheduling context instead of their \
3811 own placement; on an isolated core held by a real-time spinner they would \
3812 never run"
3813 );
3814 }
3815
3816 #[test]
3822 fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3823 let (mut fsync, reader) = fsync_state_at(10, 0);
3824 let (mut handles, _consumer) = handles_fixture(8, reader);
3825 for _ in 0..3 {
3826 handles.input_producer.publish(InputSlot::default());
3827 }
3828 let journal = std::thread::spawn(move || {
3829 std::thread::sleep(std::time::Duration::from_millis(50));
3830 fsync.store(melin_transport_core::pipeline::FsyncState {
3831 journal_seq: melin_transport_core::WireSeq::new(13),
3832 chain_hash: [0u8; 32],
3833 input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3834 });
3835 });
3836 let outcome = handles.settled_resume_point(|| false);
3837 journal.join().unwrap();
3838 assert!(
3839 matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3840 "resumes past the ring's slots"
3841 );
3842 }
3843
3844 #[test]
3847 fn resume_point_with_nothing_in_flight_is_immediate() {
3848 let (_fsync, reader) = fsync_state_at(10, 0);
3849 let (handles, _consumer) = handles_fixture(8, reader);
3850 assert!(matches!(
3851 handles.settled_resume_point(|| false),
3852 ResumePoint::Settled(s) if s.journal_seq.get() == 10
3853 ));
3854 }
3855
3856 #[test]
3860 fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3861 let (_fsync, reader) = fsync_state_at(10, 0);
3862 let (mut handles, _consumer) = handles_fixture(8, reader);
3863 handles.input_producer.publish(InputSlot::default());
3864
3865 assert!(matches!(
3866 handles.settled_resume_point(|| true),
3867 ResumePoint::Interrupted
3868 ));
3869 handles.journal_failed.store(true, Ordering::Release);
3870 assert!(matches!(
3871 handles.settled_resume_point(|| false),
3872 ResumePoint::JournalFailed
3873 ));
3874 }
3875
3876 #[test]
3882 fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3883 let (_fsync, reader) = fsync_state_at(10, 0);
3884 let (handles, _consumer) = handles_fixture(8, reader);
3885 handles.journal_failed.store(true, Ordering::Release);
3886 assert!(matches!(
3887 handles.settled_resume_point(|| false),
3888 ResumePoint::JournalFailed
3889 ));
3890 }
3891
3892 #[test]
3897 fn a_panicked_journal_stage_latches_the_failure() {
3898 let failed = Arc::new(AtomicBool::new(false));
3899 let healthy = Arc::new(AtomicBool::new(true));
3900 let journal_handle = {
3901 let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3902 std::thread::spawn(move || {
3903 run_journal_stage(
3904 || -> Result<u32, melin_journal::JournalError> {
3905 panic!("journal stage died without returning an error")
3906 },
3907 &failed,
3908 &healthy,
3909 )
3910 })
3911 };
3912 assert!(journal_handle.join().is_err(), "the stage panicked");
3914 assert!(failed.load(Ordering::Acquire), "the failure is latched");
3915 assert!(
3916 !healthy.load(Ordering::Acquire),
3917 "the node reports unhealthy"
3918 );
3919 }
3920
3921 #[test]
3925 fn resume_point_ends_when_the_journal_thread_panicked() {
3926 let (_fsync, reader) = fsync_state_at(10, 0);
3927 let (mut handles, _consumer) = handles_fixture(8, reader);
3928 handles.input_producer.publish(InputSlot::default());
3929 let failed = Arc::clone(&handles.journal_failed);
3930 let healthy = Arc::new(AtomicBool::new(true));
3931 let dead = std::thread::spawn(move || {
3932 run_journal_stage(
3933 || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3934 &failed,
3935 &healthy,
3936 )
3937 });
3938 assert!(dead.join().is_err(), "the stage panicked");
3939
3940 let give_up = AtomicBool::new(false);
3942 let outcome = std::thread::scope(|s| {
3943 let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3944 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3945 while !wait.is_finished() && std::time::Instant::now() < deadline {
3946 std::thread::sleep(std::time::Duration::from_millis(5));
3947 }
3948 give_up.store(true, Ordering::Relaxed);
3949 wait.join().expect("wait thread")
3950 });
3951 assert!(
3952 matches!(outcome, ResumePoint::JournalFailed),
3953 "the wait must end on the panicked stage, not on the test's give-up"
3954 );
3955 }
3956
3957 #[test]
3961 fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3962 let dir = tempfile::tempdir().expect("tempdir");
3963 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3964 let (input_producer, mut consumers) =
3965 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3966 .add_consumer()
3967 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3968 let _consumer = consumers.pop().expect("one consumer");
3969 let handles = ReplicaPipelineHandles {
3970 input_producer,
3971 journal_cursor: Arc::new(make_journal_cursor(0)),
3972 chain_hash_lock: fsync_state_at(0, 0).1,
3973 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3974 genesis_entries: None,
3975 journal_failed: Arc::new(AtomicBool::new(true)),
3976 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3977 journal_handle: std::thread::spawn(
3978 || -> Result<Writer, melin_journal::JournalError> {
3979 Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3980 expected: 4,
3981 actual: 1,
3982 })
3983 },
3984 ),
3985 matching_handle: std::thread::spawn(counter_server::Counter::default),
3986 drain_handle: std::thread::spawn(|| {}),
3987 shadow_handle: None,
3988 };
3989 let mut pipeline = Some(handles);
3990 let mut divergence_resyncs = 0u32;
3991 let mut backoff = MAX_BACKOFF;
3992 let mut closes = 0;
3993 let after = handle_session_exit::<counter_server::Counter, Writer>(
3994 journal_failed_while_disconnected(),
3995 &mut pipeline,
3996 &mut divergence_resyncs,
3997 &mut backoff,
3998 0,
3999 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4000 &melin_transport_core::fence::FenceState::new(0),
4001 &SharedLineagePolicy::new(None),
4002 &AtomicBool::new(false),
4003 &crate::promotion::PromotionRequest::new(),
4004 || closes += 1,
4005 &(),
4006 );
4007 let AfterSession::Return(Err(err)) = after else {
4008 panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
4009 };
4010 assert!(
4011 err.to_string()
4012 .contains("refused to journal sequence 1: the next sequence is 4"),
4013 "the returned error names the journal's cause, got: {err}"
4014 );
4015 assert!(pipeline.is_none(), "the dead pipeline was torn down");
4016 assert_eq!(divergence_resyncs, 0);
4017 assert_eq!(closes, 1);
4020 }
4021
4022 #[test]
4026 fn every_session_exit_closes_the_connection_once() {
4027 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4028 type Case = (&'static str, fn() -> SessionExit);
4031 let exits: [Case; 6] = [
4032 ("shutdown", || SessionExit::Shutdown),
4033 ("promote", || SessionExit::Promote),
4034 ("fatal", || SessionExit::Fatal("protocol violation".into())),
4035 ("disconnected", || SessionExit::Disconnected),
4036 ("stream gap", || SessionExit::StreamGap("gap".into())),
4037 ("corrupted", || SessionExit::Corrupted("damaged".into())),
4038 ];
4039 for (label, exit) in exits {
4040 let dir = tempfile::tempdir().expect("tempdir");
4041 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4042 None;
4043 let mut divergence_resyncs = 0u32;
4044 let mut backoff = MAX_BACKOFF;
4045 let shutdown = AtomicBool::new(true);
4048 let mut closes = 0;
4049 let _after = handle_session_exit::<counter_server::Counter, Writer>(
4052 StreamingResult {
4053 exit: exit(),
4054 heard_from_primary: true,
4055 },
4056 &mut pipeline,
4057 &mut divergence_resyncs,
4058 &mut backoff,
4059 0,
4060 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4061 &melin_transport_core::fence::FenceState::new(0),
4062 &SharedLineagePolicy::new(None),
4063 &shutdown,
4064 &crate::promotion::PromotionRequest::new(),
4065 || closes += 1,
4066 &(),
4067 );
4068 assert_eq!(closes, 1, "{label}");
4069 }
4070 }
4071
4072 fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
4079 backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
4080 }
4081
4082 fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
4084 let dir = tempfile::tempdir().expect("tempdir");
4085 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4086 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
4087 let mut divergence_resyncs = 0u32;
4088 let mut backoff = MAX_BACKOFF;
4089 let shutdown = AtomicBool::new(true);
4090 let promote = crate::promotion::PromotionRequest::new();
4091 let after = handle_session_exit::<counter_server::Counter, Writer>(
4092 StreamingResult {
4093 exit,
4094 heard_from_primary,
4095 },
4096 &mut pipeline,
4097 &mut divergence_resyncs,
4098 &mut backoff,
4099 0,
4100 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4101 &melin_transport_core::fence::FenceState::new(0),
4102 &SharedLineagePolicy::new(None),
4103 &shutdown,
4104 &promote,
4105 || {},
4106 &(),
4107 );
4108 assert!(matches!(after, AfterSession::Reconnect));
4109 backoff
4110 }
4111
4112 #[test]
4117 fn disconnect_resets_backoff_when_primary_spoke() {
4118 assert_eq!(
4121 backoff_after_disconnect(true),
4122 std::time::Duration::from_secs(2)
4123 );
4124 }
4125
4126 #[test]
4131 fn disconnect_keeps_escalated_backoff_when_primary_silent() {
4132 assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
4133 }
4134
4135 #[test]
4140 fn corrupted_entry_resets_backoff() {
4141 assert_eq!(
4142 backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
4143 std::time::Duration::from_secs(2)
4144 );
4145 }
4146
4147 #[test]
4156 fn stream_gap_reconnects_and_keeps_pipeline() {
4157 let dir = tempfile::tempdir().expect("tempdir");
4158 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4159 let (input_producer, mut consumers) =
4163 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
4164 .add_consumer()
4165 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
4166 let _consumer = consumers.pop().expect("one consumer");
4167 let writer_path = dir.path().join("w.journal");
4168 let handles = ReplicaPipelineHandles {
4169 input_producer,
4170 journal_cursor: Arc::new(make_journal_cursor(0)),
4171 chain_hash_lock: fsync_state_at(0, 0).1,
4172 stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
4173 genesis_entries: None,
4174 journal_failed: Arc::new(AtomicBool::new(false)),
4175 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
4176 journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
4177 matching_handle: std::thread::spawn(counter_server::Counter::default),
4178 drain_handle: std::thread::spawn(|| {}),
4179 shadow_handle: None,
4180 };
4181 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4182 Some(handles);
4183 let mut divergence_resyncs = 0u32;
4184 let mut backoff = MAX_BACKOFF;
4185 let shutdown = AtomicBool::new(true);
4186 let promote = crate::promotion::PromotionRequest::new();
4187 let mut closed = false;
4188 let after = handle_session_exit::<counter_server::Counter, Writer>(
4189 StreamingResult {
4190 exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
4191 heard_from_primary: true,
4192 },
4193 &mut pipeline,
4194 &mut divergence_resyncs,
4195 &mut backoff,
4196 2,
4197 &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4198 &melin_transport_core::fence::FenceState::new(0),
4199 &SharedLineagePolicy::new(None),
4200 &shutdown,
4201 &promote,
4202 || closed = true,
4203 &(),
4204 );
4205 assert!(
4206 matches!(after, AfterSession::Reconnect),
4207 "a stream gap must reconnect, not return an error"
4208 );
4209 assert!(
4210 pipeline.is_some(),
4211 "the pipeline must survive for the reconnect"
4212 );
4213 assert!(closed, "the transport must be closed before redialing");
4214 assert_eq!(
4215 backoff,
4216 std::time::Duration::from_secs(2),
4217 "backoff reset to 1s then doubled by the shared helper"
4218 );
4219 assert_eq!(divergence_resyncs, 0, "not a resync");
4220 if let Some(p) = pipeline.take() {
4221 let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4222 }
4223 }
4224
4225 #[test]
4230 fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4231 let (mut handles, mut consumer) = teardown_fixture(4);
4232 for seq in 0..4 {
4233 assert!(
4234 handles
4235 .input_producer
4236 .try_publish(InputSlot {
4237 sequence: seq,
4238 ..InputSlot::default()
4239 })
4240 .is_ok(),
4241 "ring must have space while filling"
4242 );
4243 }
4244 assert!(
4245 handles
4246 .input_producer
4247 .try_publish(InputSlot::default())
4248 .is_err(),
4249 "ring must be full"
4250 );
4251 handles.journal_failed.store(true, Ordering::Release);
4252 assert!(matches!(
4254 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4255 TeardownOutcome::Clean(..)
4256 ));
4257 while let Some((_, slot)) = consumer.try_consume() {
4258 assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4259 }
4260 }
4261
4262 #[test]
4263 fn pending_ack_queue_wraps_around() {
4264 let mut q = PendingAckQueue::new(8);
4265 let cursor = make_journal_cursor(100);
4266
4267 for round in 0..3 {
4269 for i in 0..8 {
4270 let target = (round * 8 + i) as u64 + 1;
4271 q.push(target, target * 10);
4272 }
4273 assert!(q.is_full());
4274 let seq = q.pop_ready(&cursor).expect("should be ready");
4275 assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4276 assert!(q.is_empty());
4277 }
4278 }
4279
4280 #[test]
4281 fn pending_ack_queue_pop_all_blocking_empty() {
4282 let mut q = PendingAckQueue::new(8);
4283 let cursor = make_journal_cursor(0);
4284 assert!(
4285 q.pop_all_blocking(
4286 &cursor,
4287 melin_pipeline::wait::WaitStrategy::BusySpin,
4288 &AtomicBool::new(false)
4289 )
4290 .is_none()
4291 );
4292 }
4293
4294 #[test]
4297 fn dual_track_returns_none_when_both_tracks_idle() {
4298 let mut q = PendingAckQueue::new(8);
4299 let cursor = make_journal_cursor(0);
4300 assert!(
4301 try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4302 "no advance on either track → no ack"
4303 );
4304 }
4305
4306 #[test]
4307 fn dual_track_fires_on_persisted_advance_only() {
4308 let mut q = PendingAckQueue::new(8);
4313 q.push(10, 100);
4314 let cursor = make_journal_cursor(20);
4315 let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4316 assert_eq!(ack.acked_sequence, 100);
4317 assert_eq!(ack.in_memory_sequence, 50);
4318 }
4319
4320 #[test]
4321 fn dual_track_fires_on_in_memory_advance_only() {
4322 let mut q = PendingAckQueue::new(8);
4325 let cursor = make_journal_cursor(0);
4326 let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4327 assert_eq!(ack.acked_sequence, 100);
4328 assert_eq!(ack.in_memory_sequence, 200);
4329 }
4330
4331 #[test]
4332 fn dual_track_coalesces_until_caller_updates_trackers() {
4333 let mut q = PendingAckQueue::new(8);
4339 q.push(10, 100);
4340 let cursor = make_journal_cursor(20);
4341 let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4342 assert_eq!(ack1.acked_sequence, 100);
4343 let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4345 .expect("call 2 fires on in-memory advance");
4346 assert_eq!(
4347 ack2.acked_sequence, 0,
4348 "no new persisted pop → unwrap_or(last_sent)"
4349 );
4350 assert_eq!(ack2.in_memory_sequence, 80);
4351 }
4352
4353 #[test]
4354 fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4355 let mut q = PendingAckQueue::new(4);
4373 for i in 1..=4u64 {
4376 q.push(i * 10, i * 100);
4377 }
4378 assert!(q.is_full());
4379
4380 let cursor = make_journal_cursor(40);
4383 let drained = q.pop_ready(&cursor).expect("all entries durable");
4384 assert_eq!(drained, 400);
4385 assert!(q.is_empty());
4386
4387 let mut last_sent_acked = drained;
4390 let mut last_sent_in_mem = 450u64;
4391
4392 assert!(
4397 try_flush_dual_track(
4398 &mut q,
4399 &cursor,
4400 last_sent_in_mem,
4401 last_sent_acked,
4402 last_sent_in_mem,
4403 )
4404 .is_none(),
4405 "no advance on either track after backpressure drain → no duplicate ack",
4406 );
4407
4408 q.push(50, 500);
4411 let cursor2 = make_journal_cursor(50);
4412 let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4413 .expect("fresh batch after drain fires a new ack");
4414 assert!(
4415 ack.acked_sequence >= last_sent_acked,
4416 "regression: drain sent {last_sent_acked} but next ack carries {}",
4417 ack.acked_sequence,
4418 );
4419 assert_eq!(ack.acked_sequence, 500);
4420 assert_eq!(ack.in_memory_sequence, 500);
4421
4422 last_sent_acked = ack.acked_sequence;
4423 last_sent_in_mem = ack.in_memory_sequence;
4424
4425 assert!(
4429 try_flush_dual_track(
4430 &mut q,
4431 &cursor2,
4432 last_sent_in_mem,
4433 last_sent_acked,
4434 last_sent_in_mem,
4435 )
4436 .is_none(),
4437 "post-resume idle → no further ack",
4438 );
4439 }
4440}