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::pipeline::{InputSlot, OutputSlot};
70use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
71use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
72
73mod auth;
74#[cfg(feature = "dpdk")]
75mod dpdk;
76mod receiver_transport;
77mod tcp_receiver;
78mod tcp_sender;
79#[cfg(any(feature = "dpdk", test))]
84mod validation_worker;
85#[cfg(any(feature = "dpdk", test))]
89mod join_worker;
90
91use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
92
93pub(crate) fn log_replica_live(slot: usize, sent: &SentHighWater) {
97 tracing::info!(
98 slot,
99 sent_sequence = sent.get(),
100 "replica caught up, streaming live"
101 );
102}
103
104pub use melin_transport_core::replication::ack_queue::{
111 PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
112};
113pub use melin_transport_core::replication::protocol::{
114 Ack, Handshake, PrimaryMessage, ReplicaMessage,
115};
116pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
117
118#[cfg(feature = "dpdk")]
119pub(crate) use dpdk::REPL_DISPATCH_BURST;
120#[cfg(feature = "dpdk")]
121pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
122pub use tcp_receiver::{ReceiverResult, run_receiver};
123pub use tcp_sender::{ReplicationListener, Sender, run_sender};
124
125#[derive(Clone)]
132pub struct ReplicaControlPlane {
133 pub promote: crate::promotion::PromotionRequest,
136 pub tip_ready: std::sync::Arc<AtomicBool>,
140 pub journal_tip: melin_transport_core::AdvertisedJournalTip,
143 pub primary_link_up: std::sync::Arc<AtomicBool>,
150 pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
155 pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
174 pub pipeline_healthy: std::sync::Arc<AtomicBool>,
182}
183
184impl ReplicaControlPlane {
185 pub fn new() -> Self {
189 Self {
190 promote: crate::promotion::PromotionRequest::new(),
191 tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
192 journal_tip: melin_transport_core::AdvertisedJournalTip::new(
193 melin_transport_core::WireSeq::new(0),
194 ),
195 primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
196 primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
197 lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
198 pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
199 }
200 }
201}
202
203impl Default for ReplicaControlPlane {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
218 if !tracing::enabled!(tracing::Level::DEBUG) {
219 return;
220 }
221 let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
225 let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
226 let rc = unsafe {
227 libc::getsockopt(
228 fd,
229 libc::IPPROTO_TCP,
230 libc::TCP_INFO,
231 &mut info as *mut _ as *mut libc::c_void,
232 &mut len,
233 )
234 };
235 if rc != 0 {
236 tracing::debug!(
237 slot,
238 tag,
239 errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
240 "tcp_info getsockopt failed"
241 );
242 return;
243 }
244 tracing::debug!(
245 slot,
246 tag,
247 state = info.tcpi_state,
248 ca_state = info.tcpi_ca_state,
249 rtt_us = info.tcpi_rtt,
250 rttvar_us = info.tcpi_rttvar,
251 snd_cwnd = info.tcpi_snd_cwnd,
252 snd_ssthresh = info.tcpi_snd_ssthresh,
253 snd_mss = info.tcpi_snd_mss,
254 rcv_mss = info.tcpi_rcv_mss,
255 unacked = info.tcpi_unacked,
256 retrans = info.tcpi_retrans,
257 total_retrans = info.tcpi_total_retrans,
258 lost = info.tcpi_lost,
259 rcv_space = info.tcpi_rcv_space,
260 rto_us = info.tcpi_rto,
261 "tcp_info"
262 );
263}
264
265pub(super) fn sleep_checking_flags(
269 duration: std::time::Duration,
270 shutdown: &AtomicBool,
271 promote: &crate::promotion::PromotionRequest,
272) {
273 let deadline = std::time::Instant::now() + duration;
274 while std::time::Instant::now() < deadline {
275 if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
276 return;
277 }
278 std::thread::sleep(std::time::Duration::from_millis(100));
279 }
280}
281
282pub(super) fn sleep_then_double_backoff(
289 backoff: &mut std::time::Duration,
290 shutdown: &AtomicBool,
291 promote: &crate::promotion::PromotionRequest,
292) {
293 sleep_checking_flags(*backoff, shutdown, promote);
294 *backoff = (*backoff * 2).min(MAX_BACKOFF);
295}
296
297pub(super) enum TeardownOutcome<A, W> {
301 Clean(A, W),
304 JournalFailed(melin_journal::JournalError),
310 Panicked,
312}
313
314pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
329 shutdown_flag: &AtomicBool,
330 journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
331 matching_handle: std::thread::JoinHandle<A>,
332 drain_handle: std::thread::JoinHandle<()>,
333 shadow_handle: Option<std::thread::JoinHandle<()>>,
334) -> TeardownOutcome<A, W> {
335 shutdown_flag.store(true, Ordering::Release);
349 let journal_result = journal_handle.join();
354 let matching_result = matching_handle.join();
355 let _ = drain_handle.join();
356 if let Some(h) = shadow_handle {
357 let _ = h.join();
358 }
359 let writer = match journal_result {
360 Ok(Ok(w)) => w,
361 Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
363 Err(_) => return TeardownOutcome::Panicked,
364 };
365 match matching_result {
366 Ok(app) => TeardownOutcome::Clean(app, writer),
367 Err(_) => TeardownOutcome::Panicked,
368 }
369}
370
371pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
380 pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
381 pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
382 pub(super) chain_hash_lock:
387 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
388 pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
393 pub(super) genesis_entries: Option<u64>,
401 pub(super) journal_failed: Arc<AtomicBool>,
409 pub(super) pipeline_shutdown: Arc<AtomicBool>,
412 pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
413 pub(super) matching_handle: std::thread::JoinHandle<A>,
414 pub(super) drain_handle: std::thread::JoinHandle<()>,
415 pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
416}
417
418pub(super) enum ResumePoint {
420 Settled(melin_transport_core::pipeline::FsyncState),
423 JournalFailed,
427 Interrupted,
430}
431
432impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
433 pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
448 let published = self.input_producer.peek_cursor();
452 let mut outcome = ResumePoint::Interrupted;
453 melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
456 if self.journal_failed.load(Ordering::Acquire) {
462 outcome = ResumePoint::JournalFailed;
463 return true;
464 }
465 let state = self.chain_hash_lock.load();
466 if state.input_ring_seq.get() >= published {
467 outcome = ResumePoint::Settled(state);
468 return true;
469 }
470 interrupted()
471 });
472 outcome
473 }
474}
475
476pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
483 StreamingResult {
484 exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
485 heard_from_primary: false,
486 }
487}
488
489fn run_journal_stage<W, E: std::fmt::Display>(
500 run: impl FnOnce() -> Result<W, E>,
501 failed: &AtomicBool,
502 healthy: &AtomicBool,
503) -> Result<W, E> {
504 struct PanicLatch<'a> {
507 failed: &'a AtomicBool,
508 healthy: &'a AtomicBool,
509 armed: bool,
510 }
511 impl Drop for PanicLatch<'_> {
512 fn drop(&mut self) {
513 if self.armed {
514 self.failed.store(true, Ordering::Release);
515 self.healthy.store(false, Ordering::Release);
516 tracing::error!("replica journal stage panicked — session teardown");
517 }
518 }
519 }
520 let mut guard = PanicLatch {
521 failed,
522 healthy,
523 armed: true,
524 };
525 let result = run();
526 guard.armed = false;
527 if let Err(ref e) = result {
528 failed.store(true, Ordering::Release);
534 healthy.store(false, Ordering::Release);
537 tracing::error!(error = %e, "replica journal stage failed — session teardown");
538 }
539 result
540}
541
542pub(super) type ReplicaHandles<A> =
547 ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
548
549#[allow(clippy::too_many_arguments)]
553pub(super) fn build_replica_pipeline_with_threads<A>(
554 mut app: A,
555 writer: BufferedWriter<A::Event>,
556 cores: crate::layout::PipelineCores,
557 staging_mode: melin_journal::StagingMode,
562 snapshot_interval_ms: u64,
563 snapshot_path: std::path::PathBuf,
564 group_commit_delay: std::time::Duration,
565 fence_state: Arc<melin_transport_core::fence::FenceState>,
566 lineage_policy: &SharedLineagePolicy,
570 pipeline_healthy: Arc<AtomicBool>,
576 sizing: &A::Sizing,
579) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
580where
581 A: Application + Send + 'static,
582 A::Event: Send + Sync + 'static,
583 A::Report: Send + 'static,
584 A::QueryResponse: Send + 'static,
585{
586 <A as Application>::prefault(&mut app, sizing);
591 let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
592
593 let enable_shadow = snapshot_interval_ms > 0;
594 let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
599 epoch: fence_state.epoch(),
600 ack_policy: lineage_policy.load(),
601 };
602 let genesis_entries = writer.read_header_info()?.genesis_entries;
605 let starting_wire_seq = writer.next_sequence();
608 let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
609 app,
610 writer,
611 4096, group_commit_delay,
613 cores.stage_waits(),
614 enable_shadow,
615 fence_state,
616 );
617
618 let pipeline_shutdown = Arc::new(AtomicBool::new(false));
619
620 let ps = Arc::clone(&pipeline_shutdown);
621 let journal_core = cores.journal_seq.core;
622 let mut journal_stage = pipeline.journal_stage;
623 let stream_marks: melin_transport_core::pipeline::StreamMarkQueue =
628 Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
629 journal_stage.set_stream_marks(Arc::clone(&stream_marks));
630 cores.place_journal_children(&mut journal_stage);
631 journal_stage.set_staging_mode(staging_mode);
632 let journal_failed = Arc::new(AtomicBool::new(false));
633 let journal_failed_latch = Arc::clone(&journal_failed);
634 let sequencer = journal_stage.start()?;
638 pipeline_healthy.store(true, Ordering::Release);
641 let abandon = |e: String| -> Box<dyn std::error::Error> {
651 pipeline_shutdown.store(true, Ordering::Release);
652 e.into()
653 };
654 let journal_handle =
655 melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
656 run_journal_stage(
657 || sequencer.run(&ps),
658 &journal_failed_latch,
659 &pipeline_healthy,
660 )
661 })
662 .map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
663
664 let ps = Arc::clone(&pipeline_shutdown);
665 let matching_core = cores.matching.core;
666 let matching_stage = pipeline.matching_stage;
667 let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
668 matching_stage.run(&ps)
669 })
670 .map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
671
672 let ps = Arc::clone(&pipeline_shutdown);
675 let drain = cores.response;
676 let drain_consumer = pipeline.drain_consumer;
677 let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
678 let mut consumer = drain_consumer;
679 let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
680 let mut waiter = drain.wait.waiter();
681 loop {
682 if ps.load(Ordering::Relaxed) {
683 return;
684 }
685 let count = consumer.consume_batch(&mut batch, 256);
686 if count == 0 {
687 waiter.idle();
688 } else {
689 waiter.reset();
690 }
691 }
692 })
693 .map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
694
695 let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
696 let snap_path = snapshot_path;
697 let chain_lock = pipeline.chain_hash_lock.clone();
698 let ps = Arc::clone(&pipeline_shutdown);
699 let shadow = cores.shadow;
700 Some(
701 melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
702 melin_transport_core::shadow::run(
703 shadow_cons,
704 shadow_app,
705 snap_path,
706 std::time::Duration::from_millis(snapshot_interval_ms),
707 chain_lock,
708 &ps,
709 shadow.wait,
710 shadow_initial_lineage,
711 starting_wire_seq,
712 genesis_entries,
713 );
714 })
715 .map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
716 )
717 } else {
718 None
719 };
720
721 Ok(ReplicaPipelineHandles {
722 input_producer: pipeline.input_producer,
723 journal_cursor: pipeline.cursors.journal_ring_arc(),
724 chain_hash_lock: pipeline.chain_hash_lock,
725 stream_marks,
726 genesis_entries,
727 journal_failed,
728 pipeline_shutdown,
729 journal_handle,
730 matching_handle,
731 drain_handle,
732 shadow_handle,
733 })
734}
735
736pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
741 mut handles: ReplicaPipelineHandles<A, W>,
742) -> TeardownOutcome<A, W> {
743 while !handles.journal_failed.load(Ordering::Acquire) {
760 match handles
761 .input_producer
762 .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
763 {
764 Ok(_) => break,
765 Err(_) => std::thread::yield_now(),
767 }
768 }
769 shutdown_pipeline::<A, W>(
770 &handles.pipeline_shutdown,
771 handles.journal_handle,
772 handles.matching_handle,
773 handles.drain_handle,
774 handles.shadow_handle,
775 )
776}
777
778pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
789
790#[allow(clippy::type_complexity)]
807pub(super) fn recover_replica_state<A, W>(
808 journal_path: &std::path::Path,
809 snapshot_path: &std::path::Path,
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 remove_restamp_staging(journal_path)?;
823 let lineage_exists =
824 journal_path.exists() || !melin_journal::segment::list_archives(journal_path)?.is_empty();
825 if !lineage_exists {
826 lineage_policy.store(None);
829 return Ok((None, None, 0u64, [0u8; 32]));
830 }
831 let engine = if snapshot_path.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 journal_path: &std::path::Path,
960 snapshot_path: &std::path::Path,
961 fence_state: &melin_transport_core::fence::FenceState,
962 lineage_policy: &SharedLineagePolicy,
963 shutdown: &AtomicBool,
964 promote: &crate::promotion::PromotionRequest,
965 close: impl FnOnce(),
966 sizing: &A::Sizing,
969) -> AfterSession<A, W>
970where
971 A: Application + Send + 'static,
972 W: JournalWrite<A::Event> + Send + 'static,
973{
974 let StreamingResult {
975 exit,
976 heard_from_primary,
977 } = result;
978
979 close();
980
981 match exit {
982 SessionExit::Shutdown => {
983 if let Some(p) = pipeline.take() {
984 let _ = teardown_replica_pipeline::<A, W>(p);
985 }
986 AfterSession::Return(Ok(None))
987 }
988
989 SessionExit::Promote => AfterSession::Return(match pipeline.take() {
990 Some(p) => match teardown_replica_pipeline::<A, W>(p) {
991 TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
992 _ => Err("pipeline failed during promotion".into()),
993 },
994 None => Err("pipeline missing on promote".into()),
995 }),
996
997 SessionExit::Fatal(e) => {
998 let outcome = match pipeline.take() {
999 Some(p) => teardown_replica_pipeline::<A, W>(p),
1000 None => return AfterSession::Return(Err(e)),
1003 };
1004 let je = match outcome {
1015 TeardownOutcome::JournalFailed(
1016 je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
1017 ) => je,
1018 TeardownOutcome::JournalFailed(je) => {
1019 return AfterSession::Return(Err(format!("{e}: {je}").into()));
1020 }
1021 TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1022 return AfterSession::Return(Err(e));
1023 }
1024 };
1025
1026 *divergence_resyncs += 1;
1027 let attempt = *divergence_resyncs;
1028 if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1029 return AfterSession::Return(Err(format!(
1030 "mid-stream chain divergence recurred {attempt} times — giving up on \
1031 in-process resync (each cycle archives the local journal and re-seeds \
1032 from the primary; recurrence at this rate means the primary keeps \
1033 streaming history that forks from what it announces): {je}"
1034 )
1035 .into()));
1036 }
1037 tracing::warn!(
1038 error = %je,
1039 attempt,
1040 max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1041 "mid-stream chain divergence — re-deriving local state for in-process resync"
1042 );
1043 match recover_replica_state::<A, W>(
1044 journal_path,
1045 snapshot_path,
1046 fence_state,
1047 lineage_policy,
1048 sizing,
1049 ) {
1050 Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1051 app,
1052 journal_writer,
1053 last_sequence: seq,
1054 chain_hash: hash,
1055 },
1056 Err(e) => AfterSession::Return(Err(e)),
1057 }
1058 }
1059
1060 SessionExit::StreamGap(e) => {
1061 *backoff = std::time::Duration::from_secs(1);
1070 tracing::warn!(
1071 error = %e,
1072 last_sequence,
1073 "replication stream broke contiguity — reconnecting from the durable position"
1074 );
1075 sleep_then_double_backoff(backoff, shutdown, promote);
1076 AfterSession::Reconnect
1077 }
1078
1079 SessionExit::Corrupted(e) => {
1080 *backoff = std::time::Duration::from_secs(1);
1098 tracing::warn!(
1099 error = %e,
1100 last_sequence,
1101 backoff_secs = backoff.as_secs(),
1102 "replicated entry failed its integrity check — refused, reconnecting to \
1103 fetch it again; recurrence points at the network, a NIC or memory"
1104 );
1105 sleep_then_double_backoff(backoff, shutdown, promote);
1106 AfterSession::Reconnect
1107 }
1108
1109 SessionExit::Disconnected => {
1110 if heard_from_primary {
1119 *backoff = std::time::Duration::from_secs(1);
1120 }
1121 tracing::warn!(
1122 last_sequence,
1123 heard_from_primary,
1124 backoff_secs = backoff.as_secs(),
1125 "reconnecting to primary"
1126 );
1127 sleep_then_double_backoff(backoff, shutdown, promote);
1128 AfterSession::Reconnect
1129 }
1130 }
1131}
1132
1133pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1143 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1144 app: &mut Option<A>,
1145 journal_writer: &mut Option<W>,
1146) -> ReceiverResult<A, W>
1147where
1148 A: Application + Send + 'static,
1149 W: JournalWrite<A::Event> + Send + 'static,
1150{
1151 if let Some(p) = pipeline.take()
1152 && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1153 {
1154 *app = Some(e);
1155 *journal_writer = Some(w);
1156 }
1157 match (app.take(), journal_writer.take()) {
1158 (Some(e), Some(w)) => Ok(Some((e, w))),
1159 _ => Err("promotion requested but no local state available".into()),
1160 }
1161}
1162
1163struct ResyncTransfer<A> {
1165 app: A,
1167 snap_sequence: u64,
1169 snap_chain_hash: [u8; 32],
1172 seed_len: u64,
1174 lineage: melin_transport_core::lineage::Lineage,
1177}
1178
1179pub(in crate::replication) enum ResyncDecision {
1182 Ready {
1187 segment_start_sequence: u64,
1188 anchor_hash: [u8; 32],
1189 genesis_entries: Option<u64>,
1192 resume_sequence: u64,
1193 },
1194 Retry,
1198}
1199
1200fn receive_resync_transfer<A, S>(
1209 source: &mut S,
1210 snapshot_path: &std::path::Path,
1211 journal_path: &std::path::Path,
1212) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1213where
1214 A: Application + Send,
1215 S: ControlFrameSource,
1216{
1217 let (snap_len, snap_sequence, snap_chain_hash) =
1218 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1219 PrimaryMessage::SnapshotBegin {
1220 snapshot_len,
1221 snap_sequence,
1222 snap_chain_hash,
1223 } => (snapshot_len, snap_sequence, snap_chain_hash),
1224 other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1225 };
1226
1227 tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1228 let tmp_path = snapshot_path.with_extension("snapshot.tmp");
1229 receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1230 std::fs::rename(&tmp_path, snapshot_path)?;
1231 tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1232
1233 let (snap_app, snap_header) =
1236 source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1237 let snap_hash = snap_header.chain_hash;
1238 if snap_hash != snap_chain_hash {
1239 return Err(format!(
1240 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1241 loaded snapshot has {snap_hash:02x?}"
1242 )
1243 .into());
1244 }
1245
1246 let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1251 PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1252 other => {
1253 return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1254 }
1255 };
1256 let seed_tmp = journal_path.with_extension("seed.tmp");
1257 receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1258 source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1264 if let Err(e) =
1265 melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1266 {
1267 let _ = std::fs::remove_file(&seed_tmp);
1268 return Err(format!("segment seed failed structural verification: {e}").into());
1269 }
1270 std::fs::rename(&seed_tmp, journal_path)?;
1271 melin_journal::segment::fsync_parent_dir(journal_path)?;
1272 Ok(())
1273 })?;
1274 Ok(ResyncTransfer {
1275 app: snap_app,
1276 snap_sequence,
1277 snap_chain_hash,
1278 seed_len,
1279 lineage: snap_header.lineage,
1280 })
1281}
1282
1283#[allow(clippy::too_many_arguments)]
1296pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1297 divergent: bool,
1298 source: &mut S,
1299 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1300 app: &mut Option<A>,
1301 journal_writer: &mut Option<W>,
1302 journal_path: &std::path::Path,
1303 snapshot_path: &std::path::Path,
1304 fence_state: &melin_transport_core::fence::FenceState,
1305 control: &ReplicaControlPlane,
1306 last_sequence: &mut u64,
1307 chain_hash: &mut [u8; 32],
1308) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1309where
1310 A: Application + Send + 'static,
1311 A::Event: Send,
1314 W: JournalWrite<A::Event> + Send + 'static,
1315 S: ControlFrameSource,
1316{
1317 if divergent {
1322 tracing::warn!(
1323 last_sequence = *last_sequence,
1324 "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1325 );
1326 } else {
1327 tracing::info!("primary requires snapshot transfer — receiving snapshot");
1328 }
1329
1330 let old_pipeline = pipeline.take();
1337 let old_app = app.take();
1338 let old_writer = journal_writer.take();
1339
1340 let reason = if divergent {
1351 ArchiveReason::Divergent
1352 } else {
1353 ArchiveReason::Resync
1354 };
1355 source.serviced(move || {
1358 if let Some(p) = old_pipeline {
1359 let _ = teardown_replica_pipeline::<A, W>(p);
1363 }
1364 drop(old_app);
1366 drop(old_writer);
1367 archive_local_lineage(journal_path, snapshot_path, reason)
1368 })?;
1369 *last_sequence = 0;
1372 *chain_hash = [0u8; 32];
1373 control.lineage_ack_policy.store(None);
1374 control
1379 .journal_tip
1380 .reset(melin_transport_core::WireSeq::new(0));
1381
1382 let ResyncTransfer {
1383 app: snap_app,
1384 snap_sequence,
1385 snap_chain_hash,
1386 seed_len,
1387 lineage: snap_lineage,
1388 } = match receive_resync_transfer::<A, S>(source, snapshot_path, journal_path) {
1389 Ok(v) => v,
1390 Err(e) => {
1391 tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1392 let _ = std::fs::remove_file(snapshot_path);
1396 return Ok(ResyncDecision::Retry);
1397 }
1398 };
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 key_hash: 0,
1750 sequence: end_sequence,
1751 timestamp_ns: 0,
1752 event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1753 publish_ts: Default::default(),
1754 recv_ts: Default::default(),
1755 };
1756 encode_input_batch(&[slot], buf).expect("encode InputBatch");
1757 }
1758
1759 #[test]
1760 fn handshake_encode_decode_round_trip() {
1761 let handshake = Handshake {
1762 last_sequence: 42,
1763 chain_hash: [0xAB; 32],
1764 epoch: 9,
1766 };
1767 let mut buf = Vec::new();
1768 encode_handshake(&handshake, &mut buf);
1769
1770 let payload = &buf[4..];
1772 let msg = decode_replica_message(payload).unwrap();
1773 match msg {
1774 ReplicaMessage::Handshake(h) => {
1775 assert_eq!(h.last_sequence, 42);
1776 assert_eq!(h.chain_hash, [0xAB; 32]);
1777 assert_eq!(h.epoch, 9);
1778 }
1779 _ => panic!("expected Handshake"),
1780 }
1781 }
1782
1783 #[test]
1784 fn ack_encode_decode_round_trip() {
1785 let ack = Ack {
1786 acked_sequence: 1000,
1787 in_memory_sequence: 1024,
1788 };
1789 let mut buf = Vec::new();
1790 encode_ack(&ack, &mut buf);
1791
1792 let payload = &buf[4..];
1793 let msg = decode_replica_message(payload).unwrap();
1794 match msg {
1795 ReplicaMessage::Ack(a) => {
1796 assert_eq!(a.acked_sequence, 1000);
1797 assert_eq!(a.in_memory_sequence, 1024);
1798 }
1799 _ => panic!("expected Ack"),
1800 }
1801 }
1802
1803 #[test]
1811 fn ack_wire_byte_pattern() {
1812 let ack = Ack {
1813 acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1814 in_memory_sequence: 0x1122_3344_5566_7788,
1815 };
1816 let mut buf = Vec::new();
1817 encode_ack(&ack, &mut buf);
1818 let expected: &[u8] = &[
1821 0x11, 0x00, 0x00, 0x00, 0x02, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, ];
1826 assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1827 }
1828
1829 #[test]
1833 fn an_advertised_genesis_length_must_match_a_known_one() {
1834 check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1835 check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1836 let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1837 assert!(err.contains("refusing to follow the primary"), "{err}");
1838 assert!(
1839 err.contains("genesis of 4") && err.contains("records 3"),
1840 "{err}"
1841 );
1842 check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1843 check_advertised_genesis(None, Some(3)).expect("ours unknown");
1844 check_advertised_genesis(Some(3), None).expect("theirs unknown");
1845 check_advertised_genesis(None, None).expect("both unknown");
1846 }
1847
1848 #[test]
1853 fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1854 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1855 let dir = tempfile::tempdir().expect("tempdir");
1856 let anchor = [9u8; 32];
1857 let header = |w: &Option<Writer>| {
1858 w.as_ref()
1859 .expect("writer")
1860 .read_header_info()
1861 .expect("header")
1862 .genesis_entries
1863 };
1864
1865 let path = dir.path().join("empty.journal");
1867 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1868 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1869 assert_eq!(header(&w), Some(3));
1870 let adopted = w.as_ref().expect("writer");
1871 assert_eq!(adopted.next_sequence(), 1);
1872 assert_eq!(adopted.path(), path.as_path());
1873 assert_eq!(
1874 melin_journal::segment::read_header_info(&path)
1875 .expect("on disk")
1876 .anchor_hash,
1877 anchor
1878 );
1879 assert!(!path.with_extension("restamp.tmp").exists());
1880
1881 let path = dir.path().join("both-unknown.journal");
1883 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1884 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1885 assert_eq!(header(&w), None);
1886
1887 let path = dir.path().join("other-anchor.journal");
1889 let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1890 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1891 assert_eq!(header(&w), None);
1892
1893 let path = dir.path().join("known.journal");
1896 let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1897 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1898 assert_eq!(header(&w), Some(2));
1899
1900 let path = dir.path().join("history.journal");
1902 let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1903 let seq = writer.allocate_sequence();
1904 writer
1905 .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1906 .expect("encode");
1907 writer.flush_batch_sync().expect("flush");
1908 let mut w = Some(writer);
1909 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1910 assert_eq!(header(&w), None);
1911
1912 let mut none: Option<Writer> = None;
1914 adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1915 assert!(none.is_none());
1916 }
1917
1918 #[test]
1922 fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1923 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1924 let dir = tempfile::tempdir().expect("tempdir");
1925 let fence = melin_transport_core::fence::FenceState::new(0);
1926 let journal = dir.path().join("replica.journal");
1927 let snapshot = dir.path().join("replica.snapshot");
1928 let staging = journal.with_extension("restamp.tmp");
1929
1930 std::fs::write(&staging, b"debris").expect("write staging");
1932 let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1933 &journal,
1934 &snapshot,
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 &journal,
1953 &snapshot,
1954 &fence,
1955 &SharedLineagePolicy::new(None),
1956 &(),
1957 )
1958 .expect("recover");
1959 assert!(writer.is_some());
1960 assert_eq!(last, 1);
1961 assert!(!staging.exists(), "swept beside a journal with history");
1962 }
1963
1964 #[test]
1970 fn replica_recovery_stores_the_lineage_policy_whole() {
1971 use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
1972 use melin_transport_core::lineage::LineagePolicy;
1973 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1974 let dir = tempfile::tempdir().expect("tempdir");
1975 let fence = melin_transport_core::fence::FenceState::new(0);
1976 let journal = dir.path().join("replica.journal");
1977 let snapshot = dir.path().join("replica.snapshot");
1978 let stale = Some(LineagePolicy {
1981 change: AckPolicyChange {
1982 policy: AckPolicyByte(2),
1983 unwitnessed: true,
1984 },
1985 wire_seq: melin_transport_core::WireSeq::new(9),
1986 previous: Some(AckPolicyByte(1)),
1987 });
1988 let cell = SharedLineagePolicy::new(stale);
1989 let recover = |cell: &SharedLineagePolicy| {
1990 recover_replica_state::<counter_server::Counter, Writer>(
1991 &journal,
1992 &snapshot,
1993 &fence,
1994 cell,
1995 &(),
1996 )
1997 .expect("recover")
1998 };
1999
2000 recover(&cell);
2002 assert_eq!(cell.load(), None);
2003
2004 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
2006 let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
2007 let seq = w.allocate_sequence();
2008 w.encode_event(seq, 0, &event, 0).expect("encode");
2009 w.flush_batch_sync().expect("flush");
2010 };
2011 append(&mut w, JournalEvent::Tick { now_ns: 0 });
2012 cell.store(stale);
2013 recover(&cell);
2014 assert_eq!(cell.load(), None);
2015
2016 append(&mut w, JournalEvent::EpochBump { epoch: 4 });
2017 append(
2018 &mut w,
2019 JournalEvent::AckPolicyChange(AckPolicyChange {
2020 policy: AckPolicyByte(0),
2021 unwitnessed: false,
2022 }),
2023 );
2024 drop(w);
2025 cell.store(stale);
2026 recover(&cell);
2027 assert_eq!(
2028 cell.load(),
2029 Some(LineagePolicy {
2030 change: AckPolicyChange {
2031 policy: AckPolicyByte(0),
2032 unwitnessed: false,
2033 },
2034 wire_seq: melin_transport_core::WireSeq::new(3),
2035 previous: None,
2036 }),
2037 "the journal's own record, at the change's sequence"
2038 );
2039 assert_eq!(fence.epoch(), 4);
2040 }
2041
2042 #[test]
2046 fn a_stale_primary_is_refused_before_anything_is_adopted() {
2047 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2048 let dir = tempfile::tempdir().expect("tempdir");
2049 let path = dir.path().join("replica.journal");
2050 let anchor = [9u8; 32];
2051 let mut journal_writer =
2052 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2053 let fence = melin_transport_core::fence::FenceState::new(5);
2054 let control = ReplicaControlPlane::new();
2055
2056 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2057 StreamStart {
2058 start_sequence: 1,
2059 lineage: (1, anchor, Some(3)),
2060 epoch: 4,
2061 },
2062 "test",
2063 &None,
2064 &mut journal_writer,
2065 &fence,
2066 &control,
2067 )
2068 .expect("a refusal is not an error");
2069
2070 assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
2071 assert_eq!(fence.epoch(), 5);
2072 assert!(!control.primary_observed.load(Ordering::Acquire));
2073 let header = journal_writer
2074 .as_ref()
2075 .expect("writer")
2076 .read_header_info()
2077 .expect("header");
2078 assert_eq!(header.genesis_entries, None, "journal left untouched");
2079 }
2080
2081 #[test]
2086 fn following_a_primary_adopts_its_epoch_and_genesis() {
2087 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2088 let dir = tempfile::tempdir().expect("tempdir");
2089 let path = dir.path().join("replica.journal");
2090 let anchor = [9u8; 32];
2091 let mut journal_writer =
2092 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2093 let fence = melin_transport_core::fence::FenceState::new(5);
2094 let control = ReplicaControlPlane::new();
2095 let held = Some(melin_transport_core::lineage::LineagePolicy::after(
2096 None,
2097 melin_transport_core::WireSeq::new(1),
2098 melin_journal::AckPolicyChange {
2099 policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
2100 unwitnessed: false,
2101 },
2102 ));
2103 control.lineage_ack_policy.store(held);
2104
2105 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2106 StreamStart {
2107 start_sequence: 1,
2108 lineage: (1, anchor, Some(3)),
2109 epoch: 7,
2110 },
2111 "test",
2112 &None,
2113 &mut journal_writer,
2114 &fence,
2115 &control,
2116 )
2117 .expect("same lineage");
2118
2119 let StreamStartVerdict::Follow(lineage) = verdict else {
2120 panic!("expected Follow");
2121 };
2122 assert_eq!(lineage, (1, anchor, Some(3)));
2123 assert_eq!(fence.epoch(), 7);
2124 assert!(control.primary_observed.load(Ordering::Acquire));
2125 assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
2126 let header = journal_writer
2127 .as_ref()
2128 .expect("writer")
2129 .read_header_info()
2130 .expect("header");
2131 assert_eq!(header.genesis_entries, Some(3));
2132 }
2133
2134 #[test]
2137 fn a_disagreeing_genesis_length_is_fatal() {
2138 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2139 let dir = tempfile::tempdir().expect("tempdir");
2140 let path = dir.path().join("replica.journal");
2141 let anchor = [9u8; 32];
2142 let mut journal_writer =
2143 Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2144 let fence = melin_transport_core::fence::FenceState::new(5);
2145 let control = ReplicaControlPlane::new();
2146
2147 let err = accept_stream_start::<counter_server::Counter, Writer>(
2148 StreamStart {
2149 start_sequence: 1,
2150 lineage: (1, anchor, Some(3)),
2151 epoch: 7,
2152 },
2153 "test",
2154 &None,
2155 &mut journal_writer,
2156 &fence,
2157 &control,
2158 )
2159 .err()
2160 .expect("different lineages");
2161
2162 assert!(err.to_string().contains("refusing to follow the primary"));
2163 assert_eq!(fence.epoch(), 5);
2164 }
2165
2166 #[test]
2173 fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2174 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2175 let dir = tempfile::tempdir().expect("tempdir");
2176 let path = dir.path().join("replica.journal");
2177
2178 let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2179 let mut journal_writer = Some(writer);
2180 assert_eq!(
2181 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2182 .expect("header read"),
2183 Some(Some(5)),
2184 );
2185
2186 let (input_producer, mut consumers) =
2187 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2188 .add_consumer()
2189 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2190 let _consumer = consumers.pop().expect("one consumer");
2191 let owned = journal_writer.take().expect("writer");
2192 let pipeline = Some(ReplicaPipelineHandles {
2193 input_producer,
2194 journal_cursor: Arc::new(make_journal_cursor(0)),
2195 chain_hash_lock: fsync_state_at(0, 0).1,
2196 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2197 genesis_entries: Some(5),
2198 journal_failed: Arc::new(AtomicBool::new(false)),
2199 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2200 journal_handle: std::thread::spawn(
2201 move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2202 ),
2203 matching_handle: std::thread::spawn(counter_server::Counter::default),
2204 drain_handle: std::thread::spawn(|| {}),
2205 shadow_handle: None,
2206 });
2207 std::fs::remove_file(&path).expect("simulate the rotation gap");
2208 assert_eq!(
2209 local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2210 Some(Some(5)),
2211 );
2212
2213 assert_eq!(
2214 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2215 .expect("nothing to read"),
2216 None,
2217 );
2218 }
2219
2220 #[test]
2221 fn stream_start_encode_decode_round_trip() {
2222 let mut buf = Vec::new();
2223 encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
2225
2226 let payload = &buf[4..];
2227 let msg = decode_primary_message(payload).unwrap();
2228 match msg {
2229 PrimaryMessage::StreamStart {
2230 start_sequence,
2231 segment_start_sequence,
2232 anchor_hash,
2233 genesis_entries,
2234 epoch,
2235 } => {
2236 assert_eq!(start_sequence, 99);
2237 assert_eq!(segment_start_sequence, 42);
2238 assert_eq!(anchor_hash, [0xAA; 32]);
2239 assert_eq!(genesis_entries, Some(7));
2240 assert_eq!(epoch, 5);
2241 }
2242 _ => panic!("expected StreamStart"),
2243 }
2244 }
2245
2246 #[test]
2247 fn heartbeat_encode_decode_round_trip() {
2248 let mut buf = Vec::new();
2249 encode_heartbeat(123, &mut buf);
2250
2251 let payload = &buf[4..];
2252 let msg = decode_primary_message(payload).unwrap();
2253 match msg {
2254 PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
2255 _ => panic!("expected Heartbeat"),
2256 }
2257 }
2258
2259 #[test]
2260 fn need_snapshot_encode_decode_round_trip() {
2261 let mut buf = Vec::new();
2262 encode_need_snapshot(&mut buf);
2263
2264 let payload = &buf[4..];
2265 let msg = decode_primary_message(payload).unwrap();
2266 assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2267 }
2268
2269 #[test]
2270 fn hash_mismatch_encode_decode_round_trip() {
2271 let mut buf = Vec::new();
2272 encode_hash_mismatch(&mut buf);
2273
2274 let payload = &buf[4..];
2275 let msg = decode_primary_message(payload).unwrap();
2276 assert!(matches!(msg, PrimaryMessage::HashMismatch));
2277 }
2278
2279 #[test]
2280 fn rotate_encode_decode_round_trip() {
2281 let mut buf = Vec::new();
2282 encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2283
2284 let payload = &buf[4..];
2285 let msg = decode_primary_message(payload).unwrap();
2286 match msg {
2287 PrimaryMessage::Rotate {
2288 boundary_seq,
2289 tail_hash,
2290 } => {
2291 assert_eq!(boundary_seq, 7_000_000);
2292 assert_eq!(tail_hash, [0xBB; 32]);
2293 }
2294 _ => panic!("expected Rotate"),
2295 }
2296 }
2297
2298 #[test]
2299 fn chain_check_encode_decode_round_trip() {
2300 let mut buf = Vec::new();
2301 encode_chain_check(555, &[0xCC; 32], &mut buf);
2302
2303 let payload = &buf[4..];
2304 let msg = decode_primary_message(payload).unwrap();
2305 match msg {
2306 PrimaryMessage::ChainCheck {
2307 sequence,
2308 chain_hash,
2309 } => {
2310 assert_eq!(sequence, 555);
2311 assert_eq!(chain_hash, [0xCC; 32]);
2312 }
2313 _ => panic!("expected ChainCheck"),
2314 }
2315 }
2316
2317 #[test]
2321 fn rotate_wire_byte_pattern() {
2322 let mut hash = [0u8; 32];
2323 for (i, b) in hash.iter_mut().enumerate() {
2324 *b = i as u8;
2325 }
2326 let mut buf = Vec::new();
2327 encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2328 let mut expected = vec![
2331 0x29, 0x00, 0x00, 0x00, 0x16, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, ];
2335 expected.extend_from_slice(&hash);
2336 assert_eq!(
2337 buf.as_slice(),
2338 expected.as_slice(),
2339 "Rotate wire layout drifted"
2340 );
2341 }
2342
2343 #[test]
2344 fn segment_seed_begin_encode_decode_round_trip() {
2345 let mut buf = Vec::new();
2346 encode_segment_seed_begin(123_456, &mut buf);
2347
2348 let payload = &buf[4..];
2349 let msg = decode_primary_message(payload).unwrap();
2350 match msg {
2351 PrimaryMessage::SegmentSeedBegin { seed_len } => {
2352 assert_eq!(seed_len, 123_456);
2353 }
2354 _ => panic!("expected SegmentSeedBegin"),
2355 }
2356 }
2357
2358 #[test]
2359 fn unknown_replica_message_type_is_error() {
2360 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2361 let result = decode_replica_message(&payload);
2362 assert!(result.is_err());
2363 }
2364
2365 #[test]
2366 fn unknown_primary_message_type_is_error() {
2367 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2368 let result = decode_primary_message(&payload);
2369 assert!(result.is_err());
2370 }
2371
2372 #[test]
2373 fn read_frame_enforces_max_size() {
2374 let mut buf = Vec::new();
2376 buf.extend_from_slice(&1000u32.to_le_bytes());
2377 buf.extend_from_slice(&[0u8; 100]); let mut cursor = std::io::Cursor::new(buf);
2380 let result = read_frame(&mut cursor, 64);
2381 assert!(result.is_err());
2382 }
2383
2384 #[test]
2385 fn challenge_encode_decode_round_trip() {
2386 let nonce = [0x42; 32];
2387 let mut buf = Vec::new();
2388 encode_challenge(&nonce, &mut buf);
2389
2390 let payload = &buf[4..];
2391 let decoded = decode_challenge(payload).unwrap();
2392 assert_eq!(decoded, nonce);
2393 }
2394
2395 #[test]
2396 fn challenge_response_encode_decode_round_trip() {
2397 let sig = [0xAA; 64];
2398 let pubkey = [0xBB; 32];
2399 let mut buf = Vec::new();
2400 encode_challenge_response(&sig, &pubkey, &mut buf);
2401
2402 let payload = &buf[4..];
2403 let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2404 assert_eq!(decoded_sig, sig);
2405 assert_eq!(decoded_pubkey, pubkey);
2406 }
2407
2408 #[test]
2409 fn auth_ok_encode_decode_round_trip() {
2410 let mut buf = Vec::new();
2411 encode_auth_ok(&mut buf);
2412
2413 let payload = &buf[4..];
2414 assert!(decode_auth_result(payload).unwrap());
2415 }
2416
2417 #[test]
2418 fn auth_failed_encode_decode_round_trip() {
2419 let mut buf = Vec::new();
2420 encode_auth_failed(&mut buf);
2421
2422 let payload = &buf[4..];
2423 assert!(!decode_auth_result(payload).unwrap());
2424 }
2425
2426 #[test]
2427 fn decode_challenge_rejects_wrong_tag() {
2428 let mut payload = [0u8; 33];
2429 payload[0] = MSG_AUTH_OK;
2430 assert!(decode_challenge(&payload).is_err());
2431 }
2432
2433 #[test]
2434 fn decode_challenge_response_rejects_short_payload() {
2435 let payload = [MSG_CHALLENGE_RESPONSE; 10]; assert!(decode_challenge_response(&payload).is_err());
2437 }
2438
2439 #[test]
2440 fn auth_round_trip_valid_key() {
2441 use ed25519_dalek::SigningKey;
2442 use std::os::unix::net::UnixStream;
2443
2444 let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2445 let pub_b64 = base64::Engine::encode(
2446 &base64::engine::general_purpose::STANDARD,
2447 repl_key.verifying_key().to_bytes(),
2448 );
2449 let keys_content = format!("replication {pub_b64} test-replica\n");
2450 let authorized_keys =
2451 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2452 .unwrap();
2453
2454 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2455 primary_stream
2456 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2457 .unwrap();
2458 replica_stream
2459 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2460 .unwrap();
2461
2462 let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2463 let replica_handle = std::thread::spawn(move || {
2464 let mut conn = replica_stream;
2465 authenticate_with_primary(&mut conn, &repl_key_clone)
2466 });
2467
2468 let mut conn = primary_stream;
2469 authenticate_replica(&mut conn, &authorized_keys).unwrap();
2470
2471 replica_handle.join().unwrap().unwrap();
2472 }
2473
2474 #[test]
2475 fn auth_rejects_unknown_key() {
2476 use ed25519_dalek::SigningKey;
2477 use std::os::unix::net::UnixStream;
2478
2479 let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2481 let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2482 let pub_b64 = base64::Engine::encode(
2483 &base64::engine::general_purpose::STANDARD,
2484 authorized_key.verifying_key().to_bytes(),
2485 );
2486 let keys_content = format!("replication {pub_b64} authorized-replica\n");
2487 let authorized_keys =
2488 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2489 .unwrap();
2490
2491 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2492 primary_stream
2493 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2494 .unwrap();
2495 replica_stream
2496 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2497 .unwrap();
2498
2499 let replica_handle = std::thread::spawn(move || {
2500 let mut conn = replica_stream;
2501 authenticate_with_primary(&mut conn, &rogue_key)
2502 });
2503
2504 let mut conn = primary_stream;
2505 let result = authenticate_replica(&mut conn, &authorized_keys);
2506 assert!(result.is_err());
2507 assert!(result.unwrap_err().to_string().contains("unknown"));
2508
2509 let replica_result = replica_handle.join().unwrap();
2511 assert!(replica_result.is_err());
2512 }
2513
2514 #[test]
2515 fn auth_rejects_wrong_permission() {
2516 use ed25519_dalek::SigningKey;
2517 use std::os::unix::net::UnixStream;
2518
2519 let key = SigningKey::from_bytes(&[0xCC; 32]);
2521 let pub_b64 = base64::Engine::encode(
2522 &base64::engine::general_purpose::STANDARD,
2523 key.verifying_key().to_bytes(),
2524 );
2525 let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2526
2527 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2528 primary_stream
2529 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2530 .unwrap();
2531 replica_stream
2532 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2533 .unwrap();
2534
2535 let replica_handle = std::thread::spawn(move || {
2536 let mut conn = replica_stream;
2537 authenticate_with_primary(&mut conn, &key)
2538 });
2539
2540 let mut conn = primary_stream;
2541 let result = authenticate_replica(&mut conn, &authorized_keys);
2542 assert!(result.is_err());
2543 assert_eq!(
2544 result.unwrap_err().to_string(),
2545 "key listed as trader, expected replication"
2546 );
2547
2548 let replica_result = replica_handle.join().unwrap();
2549 assert!(replica_result.is_err());
2550 }
2551
2552 #[test]
2555 fn auth_rejects_invalid_signature() {
2556 use ed25519_dalek::SigningKey;
2557 use std::os::unix::net::UnixStream;
2558
2559 let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2561 let pub_b64 = base64::Engine::encode(
2562 &base64::engine::general_purpose::STANDARD,
2563 correct_key.verifying_key().to_bytes(),
2564 );
2565 let keys_content = format!("replication {pub_b64} test-replica\n");
2566 let authorized_keys =
2567 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2568 .unwrap();
2569
2570 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2571 primary_stream
2572 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2573 .unwrap();
2574 replica_stream
2575 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2576 .unwrap();
2577
2578 let replica_handle = std::thread::spawn(move || {
2581 use melin_transport_core::replication::protocol::*;
2582
2583 let mut reader = replica_stream.try_clone().unwrap();
2584 let mut writer = replica_stream;
2585
2586 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2588 let nonce = decode_challenge(&frame).unwrap();
2589
2590 let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2592 let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2593 let correct_pubkey = correct_key.verifying_key();
2594
2595 let mut buf = Vec::with_capacity(128);
2596 encode_challenge_response(
2597 &bad_signature.to_bytes(),
2598 correct_pubkey.as_bytes(),
2599 &mut buf,
2600 );
2601 writer.write_all(&buf).unwrap();
2602 writer.flush().unwrap();
2603
2604 let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2606 let ok = decode_auth_result(&result_frame).unwrap();
2607 assert!(!ok, "should receive auth failure");
2608 });
2609
2610 let mut conn = primary_stream;
2611 let result = authenticate_replica(&mut conn, &authorized_keys);
2612 assert!(result.is_err());
2613 assert!(
2614 result
2615 .unwrap_err()
2616 .to_string()
2617 .contains("signature verification failed"),
2618 "should fail on signature verification"
2619 );
2620
2621 replica_handle.join().unwrap();
2622 }
2623
2624 #[test]
2625 fn sender_receiver_end_to_end() {
2626 use std::os::unix::net::UnixStream;
2627
2628 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2630
2631 let replica_handle = std::thread::spawn(move || {
2633 let mut reader = replica_stream.try_clone().unwrap();
2634 let mut writer = replica_stream;
2635
2636 let mut buf = Vec::new();
2638 let handshake = Handshake {
2639 last_sequence: 0,
2640 chain_hash: [0u8; 32],
2641 epoch: 0,
2642 };
2643 encode_handshake(&handshake, &mut buf);
2644 writer.write_all(&buf).unwrap();
2645 writer.flush().unwrap();
2646 buf.clear();
2647
2648 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2650 let msg = decode_primary_message(&frame).unwrap();
2651 assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2652
2653 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2655 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2656 let end_seq = slots
2657 .last()
2658 .map(|s| s.sequence)
2659 .expect("InputBatch carried at least one slot");
2660
2661 let ack = Ack {
2663 acked_sequence: end_seq,
2664 in_memory_sequence: end_seq,
2665 };
2666 encode_ack(&ack, &mut buf);
2667 writer.write_all(&buf).unwrap();
2668 writer.flush().unwrap();
2669
2670 end_seq
2671 });
2672
2673 let mut p_reader = primary_stream.try_clone().unwrap();
2675 let mut p_writer = primary_stream;
2676
2677 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2679 let handshake = match decode_replica_message(&frame).unwrap() {
2680 ReplicaMessage::Handshake(h) => h,
2681 _ => panic!("expected Handshake"),
2682 };
2683 assert_eq!(handshake.last_sequence, 0);
2684
2685 let mut buf = Vec::new();
2687 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); p_writer.write_all(&buf).unwrap();
2689 p_writer.flush().unwrap();
2690 buf.clear();
2691
2692 encode_input_batch_with_seq(42, &mut buf);
2694 p_writer.write_all(&buf).unwrap();
2695 p_writer.flush().unwrap();
2696 buf.clear();
2697
2698 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2700 let ack = match decode_replica_message(&frame).unwrap() {
2701 ReplicaMessage::Ack(a) => a,
2702 _ => panic!("expected Ack"),
2703 };
2704 assert_eq!(ack.acked_sequence, 42);
2705
2706 let end_seq = replica_handle.join().unwrap();
2708 assert_eq!(end_seq, 42);
2709 }
2710
2711 #[test]
2712 fn multiple_data_batches_acked_in_order() {
2713 use std::os::unix::net::UnixStream;
2716
2717 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2718
2719 let replica_handle = std::thread::spawn(move || {
2720 let mut reader = replica_stream.try_clone().unwrap();
2721 let mut writer = replica_stream;
2722 let mut buf = Vec::new();
2723
2724 encode_handshake(
2726 &Handshake {
2727 last_sequence: 0,
2728 chain_hash: [0u8; 32],
2729 epoch: 0,
2730 },
2731 &mut buf,
2732 );
2733 writer.write_all(&buf).unwrap();
2734 writer.flush().unwrap();
2735 buf.clear();
2736
2737 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2739 assert!(matches!(
2740 decode_primary_message(&frame).unwrap(),
2741 PrimaryMessage::StreamStart { .. }
2742 ));
2743
2744 let mut acked_seqs = Vec::new();
2746 for _ in 0..3 {
2747 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2748 let slots: Vec<InputSlot> =
2749 try_decode_input_batch(&frame).expect("decode InputBatch");
2750 let end_seq = slots
2751 .last()
2752 .map(|s| s.sequence)
2753 .expect("InputBatch carried at least one slot");
2754 acked_seqs.push(end_seq);
2755
2756 encode_ack(
2757 &Ack {
2758 acked_sequence: end_seq,
2759 in_memory_sequence: end_seq,
2760 },
2761 &mut buf,
2762 );
2763 writer.write_all(&buf).unwrap();
2764 writer.flush().unwrap();
2765 buf.clear();
2766 }
2767
2768 acked_seqs
2769 });
2770
2771 let mut p_reader = primary_stream.try_clone().unwrap();
2773 let mut p_writer = primary_stream;
2774 let mut buf = Vec::new();
2775
2776 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2778 assert!(matches!(
2779 decode_replica_message(&frame).unwrap(),
2780 ReplicaMessage::Handshake(_)
2781 ));
2782
2783 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
2785 p_writer.write_all(&buf).unwrap();
2786 p_writer.flush().unwrap();
2787 buf.clear();
2788
2789 for seq in [10u64, 20, 30] {
2791 encode_input_batch_with_seq(seq, &mut buf);
2792 p_writer.write_all(&buf).unwrap();
2793 p_writer.flush().unwrap();
2794 buf.clear();
2795 }
2796
2797 for expected_seq in [10u64, 20, 30] {
2799 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2800 let ack = match decode_replica_message(&frame).unwrap() {
2801 ReplicaMessage::Ack(a) => a,
2802 other => panic!("expected Ack, got {other:?}"),
2803 };
2804 assert_eq!(ack.acked_sequence, expected_seq);
2805 }
2806
2807 let acked = replica_handle.join().unwrap();
2808 assert_eq!(acked, vec![10, 20, 30]);
2809 }
2810
2811 #[test]
2812 fn heartbeat_encode_contains_sequence() {
2813 let mut buf = Vec::new();
2816 encode_heartbeat(999, &mut buf);
2817
2818 let payload = &buf[4..];
2819 match decode_primary_message(payload).unwrap() {
2820 PrimaryMessage::Heartbeat { sequence, .. } => {
2821 assert_eq!(sequence, 999);
2822 }
2823 other => panic!("expected Heartbeat, got {other:?}"),
2824 }
2825 }
2826
2827 #[test]
2828 fn replica_mid_stream_handshake_with_nonzero_sequence() {
2829 use std::os::unix::net::UnixStream;
2834
2835 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2836
2837 let replica_handle = std::thread::spawn(move || {
2838 let mut reader = replica_stream.try_clone().unwrap();
2839 let mut writer = replica_stream;
2840 let mut buf = Vec::new();
2841
2842 encode_handshake(
2844 &Handshake {
2845 last_sequence: 100,
2846 chain_hash: [0xBB; 32],
2847 epoch: 0,
2848 },
2849 &mut buf,
2850 );
2851 writer.write_all(&buf).unwrap();
2852 writer.flush().unwrap();
2853 buf.clear();
2854
2855 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2857 match decode_primary_message(&frame).unwrap() {
2858 PrimaryMessage::StreamStart { start_sequence, .. } => {
2859 assert_eq!(
2860 start_sequence, 100,
2861 "StreamStart should echo replica's last_sequence"
2862 );
2863 }
2864 other => panic!("expected StreamStart, got {other:?}"),
2865 }
2866
2867 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2869 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2870 let end_sequence = slots
2871 .last()
2872 .map(|s| s.sequence)
2873 .expect("InputBatch carried at least one slot");
2874 assert!(
2875 end_sequence > 100,
2876 "InputBatch should be after replica's last_sequence"
2877 );
2878 });
2879
2880 let mut p_reader = primary_stream.try_clone().unwrap();
2882 let mut p_writer = primary_stream;
2883 let mut buf = Vec::new();
2884
2885 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2887 let handshake = match decode_replica_message(&frame).unwrap() {
2888 ReplicaMessage::Handshake(h) => h,
2889 _ => panic!("expected Handshake"),
2890 };
2891 assert_eq!(handshake.last_sequence, 100);
2892 assert_eq!(handshake.chain_hash, [0xBB; 32]);
2893
2894 encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
2896 p_writer.write_all(&buf).unwrap();
2897 p_writer.flush().unwrap();
2898 buf.clear();
2899
2900 encode_input_batch_with_seq(150, &mut buf);
2902 p_writer.write_all(&buf).unwrap();
2903 p_writer.flush().unwrap();
2904
2905 replica_handle.join().unwrap();
2906 }
2907
2908 #[test]
2923 fn receiver_refuses_stream_start_from_stale_primary() {
2924 use std::os::unix::net::UnixStream;
2925
2926 use melin_transport_core::fence::FenceState;
2927
2928 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2929
2930 const STALE_EPOCH: u64 = 3;
2934 let primary_handle = std::thread::spawn(move || {
2935 let mut reader = primary_stream.try_clone().unwrap();
2936 let mut writer = primary_stream;
2937
2938 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2939 let handshake = match decode_replica_message(&frame).unwrap() {
2940 ReplicaMessage::Handshake(h) => h,
2941 _ => panic!("expected Handshake"),
2942 };
2943 assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2946
2947 let mut buf = Vec::new();
2948 encode_stream_start(
2949 handshake.last_sequence,
2950 1,
2951 [0u8; 32],
2952 Some(0),
2953 STALE_EPOCH,
2954 &mut buf,
2955 );
2956 writer.write_all(&buf).unwrap();
2957 writer.flush().unwrap();
2958 });
2959
2960 let fence_state = Arc::new(FenceState::new(5));
2963 let mut r_reader = replica_stream.try_clone().unwrap();
2964 let mut r_writer = replica_stream;
2965
2966 let mut buf = Vec::new();
2967 encode_handshake(
2968 &Handshake {
2969 last_sequence: 0,
2970 chain_hash: [0u8; 32],
2971 epoch: fence_state.epoch(),
2972 },
2973 &mut buf,
2974 );
2975 r_writer.write_all(&buf).unwrap();
2976 r_writer.flush().unwrap();
2977
2978 let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2979 let stream_epoch = match decode_primary_message(&frame).unwrap() {
2980 PrimaryMessage::StreamStart { epoch, .. } => epoch,
2981 other => panic!("expected StreamStart, got {other:?}"),
2982 };
2983 assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2984
2985 assert!(
2988 fence_state.refuses_primary(stream_epoch),
2989 "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2990 );
2991 assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2992
2993 assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2996 assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2997
2998 primary_handle.join().unwrap();
2999 }
3000
3001 #[test]
3002 fn snapshot_begin_encode_decode_round_trip() {
3003 let mut buf = Vec::new();
3004 encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
3005
3006 let payload = &buf[4..];
3007 let msg = decode_primary_message(payload).unwrap();
3008 match msg {
3009 PrimaryMessage::SnapshotBegin {
3010 snapshot_len,
3011 snap_sequence,
3012 snap_chain_hash,
3013 } => {
3014 assert_eq!(snapshot_len, 1_000_000);
3015 assert_eq!(snap_sequence, 42);
3016 assert_eq!(snap_chain_hash, [0xAB; 32]);
3017 }
3018 _ => panic!("expected SnapshotBegin"),
3019 }
3020 }
3021
3022 #[test]
3023 fn snapshot_chunk_encode_decode_round_trip() {
3024 let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
3025 let mut buf = Vec::new();
3026 encode_snapshot_chunk(&data, &mut buf);
3027
3028 let payload = &buf[4..];
3029 let msg = decode_primary_message(payload).unwrap();
3030 match msg {
3031 PrimaryMessage::SnapshotChunk(chunk) => {
3032 assert_eq!(chunk, data);
3033 }
3034 _ => panic!("expected SnapshotChunk"),
3035 }
3036 }
3037
3038 #[test]
3039 fn snapshot_end_encode_decode_round_trip() {
3040 let mut buf = Vec::new();
3041 encode_snapshot_end(0xDEADBEEF, &mut buf);
3042
3043 let payload = &buf[4..];
3044 let msg = decode_primary_message(payload).unwrap();
3045 match msg {
3046 PrimaryMessage::SnapshotEnd { crc32c } => {
3047 assert_eq!(crc32c, 0xDEADBEEF);
3048 }
3049 _ => panic!("expected SnapshotEnd"),
3050 }
3051 }
3052
3053 #[test]
3057 fn snapshot_receiver_detects_length_mismatch() {
3058 use std::os::unix::net::UnixStream;
3059
3060 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3061
3062 let receiver = std::thread::spawn(move || -> String {
3064 let mut reader = replica_stream.try_clone().unwrap();
3065
3066 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3068 assert!(matches!(
3069 decode_primary_message(&frame).unwrap(),
3070 PrimaryMessage::NeedSnapshot,
3071 ));
3072
3073 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3075 let (snap_len, _snap_sequence, _snap_chain_hash) =
3076 match decode_primary_message(&frame).unwrap() {
3077 PrimaryMessage::SnapshotBegin {
3078 snapshot_len,
3079 snap_sequence,
3080 snap_chain_hash,
3081 } => (snapshot_len, snap_sequence, snap_chain_hash),
3082 other => panic!("expected SnapshotBegin, got {other:?}"),
3083 };
3084
3085 let mut received: u64 = 0;
3087 loop {
3088 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3089 match decode_primary_message(&frame).unwrap() {
3090 PrimaryMessage::SnapshotChunk(data) => {
3091 received += data.len() as u64;
3092 }
3093 PrimaryMessage::SnapshotEnd { .. } => {
3094 if received != snap_len {
3095 return format!(
3096 "snapshot length mismatch: expected {snap_len} bytes, got {received}"
3097 );
3098 }
3099 return String::new(); }
3101 other => panic!("unexpected message: {other:?}"),
3102 }
3103 }
3104 });
3105
3106 let mut writer = primary_stream;
3108 let mut buf = Vec::new();
3109
3110 let actual_data = vec![0xAA; 100];
3111 let wrong_len = 999u64; encode_need_snapshot(&mut buf);
3114 std::io::Write::write_all(&mut writer, &buf).unwrap();
3115 buf.clear();
3116
3117 encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3118 std::io::Write::write_all(&mut writer, &buf).unwrap();
3119 buf.clear();
3120
3121 encode_snapshot_chunk(&actual_data, &mut buf);
3122 std::io::Write::write_all(&mut writer, &buf).unwrap();
3123 buf.clear();
3124
3125 let crc = crc32c::crc32c(&actual_data);
3126 encode_snapshot_end(crc, &mut buf);
3127 std::io::Write::write_all(&mut writer, &buf).unwrap();
3128 std::io::Write::flush(&mut writer).unwrap();
3129
3130 let error_msg = receiver.join().unwrap();
3131 assert!(
3132 error_msg.contains("length mismatch"),
3133 "expected length mismatch error, got: {error_msg:?}"
3134 );
3135 }
3136
3137 #[test]
3141 fn snapshot_receiver_detects_crc_mismatch() {
3142 use std::os::unix::net::UnixStream;
3143
3144 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3145
3146 let receiver = std::thread::spawn(move || -> String {
3147 let mut reader = replica_stream.try_clone().unwrap();
3148
3149 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3151 assert!(matches!(
3152 decode_primary_message(&frame).unwrap(),
3153 PrimaryMessage::NeedSnapshot,
3154 ));
3155
3156 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3158 let snap_len = match decode_primary_message(&frame).unwrap() {
3159 PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3160 other => panic!("expected SnapshotBegin, got {other:?}"),
3161 };
3162
3163 let mut received_data = Vec::new();
3165 let mut received: u64 = 0;
3166 loop {
3167 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3168 match decode_primary_message(&frame).unwrap() {
3169 PrimaryMessage::SnapshotChunk(data) => {
3170 received += data.len() as u64;
3171 received_data.extend_from_slice(&data);
3172 }
3173 PrimaryMessage::SnapshotEnd {
3174 crc32c: expected_crc,
3175 } => {
3176 if received != snap_len {
3177 return format!("length mismatch: {snap_len} vs {received}");
3178 }
3179 let actual_crc = crc32c::crc32c(&received_data);
3180 if actual_crc != expected_crc {
3181 return format!(
3182 "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3183 );
3184 }
3185 return String::new();
3186 }
3187 other => panic!("unexpected message: {other:?}"),
3188 }
3189 }
3190 });
3191
3192 let mut writer = primary_stream;
3194 let mut buf = Vec::new();
3195
3196 let data = vec![0xAA; 100];
3197
3198 encode_need_snapshot(&mut buf);
3199 std::io::Write::write_all(&mut writer, &buf).unwrap();
3200 buf.clear();
3201
3202 encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3203 std::io::Write::write_all(&mut writer, &buf).unwrap();
3204 buf.clear();
3205
3206 encode_snapshot_chunk(&data, &mut buf);
3207 std::io::Write::write_all(&mut writer, &buf).unwrap();
3208 buf.clear();
3209
3210 let wrong_crc = !crc32c::crc32c(&data);
3212 encode_snapshot_end(wrong_crc, &mut buf);
3213 std::io::Write::write_all(&mut writer, &buf).unwrap();
3214 std::io::Write::flush(&mut writer).unwrap();
3215
3216 let error_msg = receiver.join().unwrap();
3217 assert!(
3218 error_msg.contains("CRC mismatch"),
3219 "expected CRC mismatch error, got: {error_msg:?}"
3220 );
3221 }
3222
3223 #[test]
3226 fn snapshot_receiver_detects_chain_hash_mismatch() {
3227 use std::os::unix::net::UnixStream;
3228
3229 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3230
3231 let receiver = std::thread::spawn(move || -> String {
3232 let mut reader = replica_stream.try_clone().unwrap();
3233
3234 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3235 assert!(matches!(
3236 decode_primary_message(&frame).unwrap(),
3237 PrimaryMessage::NeedSnapshot,
3238 ));
3239
3240 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3241 let (snap_len, _snap_sequence, snap_chain_hash) =
3242 match decode_primary_message(&frame).unwrap() {
3243 PrimaryMessage::SnapshotBegin {
3244 snapshot_len,
3245 snap_sequence,
3246 snap_chain_hash,
3247 } => (snapshot_len, snap_sequence, snap_chain_hash),
3248 other => panic!("expected SnapshotBegin, got {other:?}"),
3249 };
3250
3251 let mut received_data = Vec::new();
3253 let mut received: u64 = 0;
3254 loop {
3255 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3256 match decode_primary_message(&frame).unwrap() {
3257 PrimaryMessage::SnapshotChunk(data) => {
3258 received += data.len() as u64;
3259 received_data.extend_from_slice(&data);
3260 }
3261 PrimaryMessage::SnapshotEnd {
3262 crc32c: expected_crc,
3263 } => {
3264 assert_eq!(received, snap_len, "length should match");
3265 let actual_crc = crc32c::crc32c(&received_data);
3266 assert_eq!(actual_crc, expected_crc, "CRC should match");
3267 break;
3268 }
3269 other => panic!("unexpected message: {other:?}"),
3270 }
3271 }
3272
3273 let loaded_hash = [0xFF; 32]; if loaded_hash != snap_chain_hash {
3277 return format!(
3278 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3279 loaded snapshot has {loaded_hash:02x?}"
3280 );
3281 }
3282 String::new()
3283 });
3284
3285 let mut writer = primary_stream;
3288 let mut buf = Vec::new();
3289
3290 let data = vec![0xAA; 64];
3291 let advertised_hash = [0xBB; 32];
3293
3294 encode_need_snapshot(&mut buf);
3295 std::io::Write::write_all(&mut writer, &buf).unwrap();
3296 buf.clear();
3297
3298 encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3299 std::io::Write::write_all(&mut writer, &buf).unwrap();
3300 buf.clear();
3301
3302 encode_snapshot_chunk(&data, &mut buf);
3303 std::io::Write::write_all(&mut writer, &buf).unwrap();
3304 buf.clear();
3305
3306 let crc = crc32c::crc32c(&data);
3307 encode_snapshot_end(crc, &mut buf);
3308 std::io::Write::write_all(&mut writer, &buf).unwrap();
3309 std::io::Write::flush(&mut writer).unwrap();
3310
3311 let error_msg = receiver.join().unwrap();
3312 assert!(
3313 error_msg.contains("chain hash mismatch"),
3314 "expected chain hash mismatch error, got: {error_msg:?}"
3315 );
3316 }
3317
3318 #[test]
3321 fn primary_rejects_snapshot_with_invalid_magic() {
3322 let tmp = tempfile::tempdir().unwrap();
3323 let snap_path = tmp.path().join("test.snapshot");
3324
3325 let mut bad_snap = vec![0u8; 64];
3327 bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); std::fs::write(&snap_path, &bad_snap).unwrap();
3330
3331 let snap_data = std::fs::read(&snap_path).unwrap();
3333 assert!(
3334 snap_data.len() >= 48,
3335 "file should be big enough for header"
3336 );
3337
3338 let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3339 assert_ne!(magic, 0x534E_4150);
3340 assert_eq!(magic, 0xDEAD_BEEF);
3341 }
3342
3343 #[test]
3346 fn primary_rejects_snapshot_too_small_for_header() {
3347 let tmp = tempfile::tempdir().unwrap();
3348 let snap_path = tmp.path().join("test.snapshot");
3349
3350 std::fs::write(&snap_path, [0u8; 20]).unwrap();
3352
3353 let snap_data = std::fs::read(&snap_path).unwrap();
3354 assert!(
3355 snap_data.len() < 48,
3356 "file must be too small for header validation"
3357 );
3358 }
3359
3360 #[test]
3361 fn decode_snapshot_begin_too_short() {
3362 let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3365 let err = decode_primary_message(&payload).unwrap_err();
3366 assert!(
3367 err.to_string().contains("SnapshotBegin too short"),
3368 "expected 'SnapshotBegin too short', got: {err}"
3369 );
3370 }
3371
3372 #[test]
3373 fn decode_snapshot_end_too_short() {
3374 let payload = [MSG_SNAPSHOT_END];
3376 let err = decode_primary_message(&payload).unwrap_err();
3377 assert!(
3378 err.to_string().contains("SnapshotEnd too short"),
3379 "expected 'SnapshotEnd too short', got: {err}"
3380 );
3381 }
3382
3383 #[test]
3384 fn decode_snapshot_chunk_empty_data() {
3385 let payload = [MSG_SNAPSHOT_CHUNK];
3387 let msg = decode_primary_message(&payload).unwrap();
3388 match msg {
3389 PrimaryMessage::SnapshotChunk(data) => {
3390 assert!(data.is_empty());
3391 }
3392 _ => panic!("expected SnapshotChunk"),
3393 }
3394 }
3395
3396 fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3399 melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3400 }
3401
3402 #[test]
3403 fn pending_ack_queue_push_and_pop_ready() {
3404 let mut q = PendingAckQueue::new(8);
3405 assert!(q.is_empty());
3406 assert!(!q.is_full());
3407
3408 q.push(10, 100);
3409 q.push(20, 200);
3410 assert!(!q.is_empty());
3411
3412 let cursor = make_journal_cursor(5);
3414 assert!(q.pop_ready(&cursor).is_none());
3415
3416 cursor.get().store(15, Ordering::Relaxed);
3418 assert_eq!(q.pop_ready(&cursor), Some(100));
3419 assert!(!q.is_empty());
3421
3422 cursor.get().store(25, Ordering::Relaxed);
3424 assert_eq!(q.pop_ready(&cursor), Some(200));
3425 assert!(q.is_empty());
3426 }
3427
3428 #[test]
3429 fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3430 let mut q = PendingAckQueue::new(8);
3434 q.push(10, 100);
3435 q.push(20, 200);
3436 q.push(30, 300);
3437
3438 let cursor = make_journal_cursor(30);
3439 assert_eq!(q.pop_ready(&cursor), Some(300));
3440 assert!(q.is_empty());
3441 }
3442
3443 #[test]
3444 fn pending_ack_queue_capacity_and_full() {
3445 let mut q = PendingAckQueue::new(8);
3446 for i in 0..8 {
3447 assert!(!q.is_full());
3448 q.push(i as u64 + 1, (i + 1) as u64 * 100);
3449 }
3450 assert!(q.is_full());
3451 }
3452
3453 #[test]
3458 fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3459 let mut q = PendingAckQueue::new(4);
3460 for i in 1..=4u64 {
3461 q.push(i * 10, i * 100);
3462 }
3463 assert!(q.is_full());
3464 assert_eq!(q.merged(), 0);
3465
3466 q.push(50, 500);
3468 assert_eq!(q.merged(), 1);
3469 assert_eq!(q.len(), 4, "merge must not grow the queue");
3470
3471 let cursor = make_journal_cursor(40);
3474 assert_eq!(q.pop_ready(&cursor), Some(300));
3475 assert!(!q.is_empty(), "merged entry is not durable yet");
3476
3477 cursor.get().store(50, Ordering::Relaxed);
3480 assert_eq!(q.pop_ready(&cursor), Some(500));
3481 assert!(q.is_empty());
3482 }
3483
3484 #[test]
3487 fn pending_ack_queue_merges_repeatedly_without_growing() {
3488 let mut q = PendingAckQueue::new(2);
3489 for i in 1..=100u64 {
3490 q.push(i, i * 10);
3491 }
3492 assert_eq!(q.len(), 2);
3493 assert_eq!(q.merged(), 98);
3494
3495 let cursor = make_journal_cursor(99);
3497 assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3498 cursor.get().store(100, Ordering::Relaxed);
3500 assert_eq!(q.pop_ready(&cursor), Some(1000));
3501 }
3502
3503 #[test]
3504 fn pending_ack_queue_pop_oldest_blocking() {
3505 let mut q = PendingAckQueue::new(8);
3506 q.push(10, 100);
3507 q.push(20, 200);
3508
3509 let cursor = make_journal_cursor(25);
3512 let seq = q.pop_oldest_blocking(
3513 &cursor,
3514 melin_pipeline::wait::WaitStrategy::BusySpin,
3515 &AtomicBool::new(false),
3516 );
3517 assert_eq!(seq, Some(200));
3519 assert!(q.is_empty());
3520 }
3521
3522 #[test]
3525 fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3526 let mut q = PendingAckQueue::new(8);
3527 q.push(10, 100);
3528
3529 let cursor = make_journal_cursor(5);
3531 let abort = AtomicBool::new(true);
3532 assert_eq!(
3533 q.pop_oldest_blocking(
3534 &cursor,
3535 melin_pipeline::wait::WaitStrategy::BusySpin,
3536 &abort
3537 ),
3538 None
3539 );
3540 assert!(!q.is_empty(), "aborted wait must not pop the entry");
3541 assert_eq!(
3542 q.pop_all_blocking(
3543 &cursor,
3544 melin_pipeline::wait::WaitStrategy::BusySpin,
3545 &abort
3546 ),
3547 None
3548 );
3549 }
3550
3551 #[test]
3560 fn shutdown_pipeline_surfaces_journal_error_kind() {
3561 let flag = AtomicBool::new(false);
3562 let matching_joined = Arc::new(AtomicBool::new(false));
3563 let mj = Arc::clone(&matching_joined);
3564
3565 let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3566 Err(melin_journal::JournalError::ReplicaChainDivergence {
3567 sequence: 42,
3568 expected: [1u8; 32],
3569 actual: [2u8; 32],
3570 })
3571 });
3572 let matching = std::thread::spawn(move || {
3573 mj.store(true, Ordering::Release);
3574 7u64
3575 });
3576 let drain = std::thread::spawn(|| {});
3577
3578 let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3579 assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3580 assert!(
3581 matching_joined.load(Ordering::Acquire),
3582 "matching thread must be joined even when the journal failed"
3583 );
3584 match outcome {
3585 TeardownOutcome::JournalFailed(
3586 melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3587 ) => assert_eq!(sequence, 42),
3588 _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3589 }
3590 }
3591
3592 #[test]
3593 fn shutdown_pipeline_clean_returns_both_states() {
3594 let flag = AtomicBool::new(false);
3595 let journal =
3596 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3597 let matching = std::thread::spawn(|| 7u64);
3598 let drain = std::thread::spawn(|| {});
3599
3600 match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3601 TeardownOutcome::Clean(app, writer) => {
3602 assert_eq!(app, 7);
3603 assert_eq!(writer, 11);
3604 }
3605 _ => panic!("expected Clean"),
3606 }
3607 }
3608
3609 #[test]
3610 fn shutdown_pipeline_panicked_stage_reports_panicked() {
3611 let flag = AtomicBool::new(false);
3612 let journal =
3613 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3614 let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3615 let drain = std::thread::spawn(|| {});
3616
3617 assert!(matches!(
3618 shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3619 TeardownOutcome::Panicked
3620 ));
3621 }
3622
3623 fn teardown_fixture(
3628 capacity: usize,
3629 ) -> (
3630 ReplicaPipelineHandles<counter_server::Counter, u32>,
3631 melin_pipeline::ring::Consumer<InputSlot>,
3632 ) {
3633 handles_fixture(capacity, fsync_state_at(0, 0).1)
3634 }
3635
3636 fn fsync_state_at(
3639 journal_seq: u64,
3640 ring_pos: u64,
3641 ) -> (
3642 melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3643 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3644 ) {
3645 melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3646 journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3647 chain_hash: [0u8; 32],
3648 input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3649 })
3650 }
3651
3652 fn handles_fixture(
3654 capacity: usize,
3655 chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3656 melin_transport_core::pipeline::FsyncState,
3657 >,
3658 ) -> (
3659 ReplicaPipelineHandles<counter_server::Counter, u32>,
3660 melin_pipeline::ring::Consumer<InputSlot>,
3661 ) {
3662 let (input_producer, mut consumers) =
3663 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3664 .add_consumer()
3665 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3666 let consumer = consumers.pop().expect("one consumer");
3667 let handles = ReplicaPipelineHandles {
3668 input_producer,
3669 journal_cursor: Arc::new(make_journal_cursor(0)),
3670 chain_hash_lock,
3671 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
3672 genesis_entries: None,
3673 journal_failed: Arc::new(AtomicBool::new(false)),
3674 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3675 journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3676 Ok(11)
3677 }),
3678 matching_handle: std::thread::spawn(counter_server::Counter::default),
3679 drain_handle: std::thread::spawn(|| {}),
3680 shadow_handle: None,
3681 };
3682 (handles, consumer)
3683 }
3684
3685 #[test]
3691 fn teardown_publishes_shutdown_sentinel() {
3692 let (handles, mut consumer) = teardown_fixture(8);
3693 assert!(matches!(
3694 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3695 TeardownOutcome::Clean(..)
3696 ));
3697 let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3698 assert!(
3699 slot.event.is_shutdown(),
3700 "published slot must be the sentinel"
3701 );
3702 assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3703 }
3704
3705 fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
3707 use std::os::unix::thread::JoinHandleExt;
3708 let mut policy: libc::c_int = 0;
3709 let mut param = libc::sched_param { sched_priority: 0 };
3710 let ret =
3711 unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
3712 assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
3713 policy
3714 }
3715
3716 #[test]
3737 fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
3738 let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
3739 eprintln!("skipped: this thread may run on CPU 0 only");
3741 return;
3742 };
3743 let dir = tempfile::tempdir().expect("tempdir");
3744
3745 let policies = std::thread::spawn(move || {
3746 melin_test_node::sched::set_batch_policy();
3748
3749 let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
3750 &dir.path().join("replica.journal"),
3751 1,
3752 [7; 32],
3753 Some(5),
3754 )
3755 .expect("create journal");
3756 let placed = crate::layout::Placement::yielding(core);
3759 let cores = crate::layout::PipelineCores {
3760 journal_seq: placed,
3761 matching: placed,
3762 response: placed,
3763 shadow: placed,
3764 journal_prep: placed,
3765 journal_disk: placed,
3766 ..crate::layout::PipelineCores::unpinned()
3767 };
3768 let handles = build_replica_pipeline_with_threads(
3769 counter_server::Counter::default(),
3770 writer,
3771 cores,
3772 melin_journal::StagingMode::Allocate,
3773 3_600_000,
3776 dir.path().join("replica.snapshot"),
3777 std::time::Duration::ZERO,
3778 Arc::new(melin_transport_core::fence::FenceState::new(0)),
3779 &SharedLineagePolicy::new(None),
3780 Arc::new(AtomicBool::new(true)),
3781 &(),
3782 )
3783 .expect("build the replica pipeline");
3784
3785 let policies = [
3786 ("journal-seq", Some(policy_of(&handles.journal_handle))),
3787 ("matching", Some(policy_of(&handles.matching_handle))),
3788 ("drain", Some(policy_of(&handles.drain_handle))),
3789 (
3790 "replica-shadow",
3791 handles.shadow_handle.as_ref().map(policy_of),
3792 ),
3793 ];
3794 let outcome = teardown_replica_pipeline(handles);
3795 assert!(
3796 matches!(outcome, TeardownOutcome::Clean(..)),
3797 "the pipeline must tear down cleanly"
3798 );
3799 policies
3800 })
3801 .join()
3802 .expect("builder thread");
3803
3804 let inherited: Vec<&str> = policies
3805 .iter()
3806 .map(|(name, policy)| {
3807 (
3808 *name,
3809 policy.unwrap_or_else(|| panic!("{name} was not spawned")),
3810 )
3811 })
3812 .filter(|&(_, policy)| policy == libc::SCHED_BATCH)
3813 .map(|(name, _)| name)
3814 .collect();
3815 assert!(
3816 inherited.is_empty(),
3817 "{inherited:?} started in the builder's scheduling context instead of their \
3818 own placement; on an isolated core held by a real-time spinner they would \
3819 never run"
3820 );
3821 }
3822
3823 #[test]
3829 fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3830 let (mut fsync, reader) = fsync_state_at(10, 0);
3831 let (mut handles, _consumer) = handles_fixture(8, reader);
3832 for _ in 0..3 {
3833 handles.input_producer.publish(InputSlot::default());
3834 }
3835 let journal = std::thread::spawn(move || {
3836 std::thread::sleep(std::time::Duration::from_millis(50));
3837 fsync.store(melin_transport_core::pipeline::FsyncState {
3838 journal_seq: melin_transport_core::WireSeq::new(13),
3839 chain_hash: [0u8; 32],
3840 input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3841 });
3842 });
3843 let outcome = handles.settled_resume_point(|| false);
3844 journal.join().unwrap();
3845 assert!(
3846 matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3847 "resumes past the ring's slots"
3848 );
3849 }
3850
3851 #[test]
3854 fn resume_point_with_nothing_in_flight_is_immediate() {
3855 let (_fsync, reader) = fsync_state_at(10, 0);
3856 let (handles, _consumer) = handles_fixture(8, reader);
3857 assert!(matches!(
3858 handles.settled_resume_point(|| false),
3859 ResumePoint::Settled(s) if s.journal_seq.get() == 10
3860 ));
3861 }
3862
3863 #[test]
3867 fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3868 let (_fsync, reader) = fsync_state_at(10, 0);
3869 let (mut handles, _consumer) = handles_fixture(8, reader);
3870 handles.input_producer.publish(InputSlot::default());
3871
3872 assert!(matches!(
3873 handles.settled_resume_point(|| true),
3874 ResumePoint::Interrupted
3875 ));
3876 handles.journal_failed.store(true, Ordering::Release);
3877 assert!(matches!(
3878 handles.settled_resume_point(|| false),
3879 ResumePoint::JournalFailed
3880 ));
3881 }
3882
3883 #[test]
3889 fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3890 let (_fsync, reader) = fsync_state_at(10, 0);
3891 let (handles, _consumer) = handles_fixture(8, reader);
3892 handles.journal_failed.store(true, Ordering::Release);
3893 assert!(matches!(
3894 handles.settled_resume_point(|| false),
3895 ResumePoint::JournalFailed
3896 ));
3897 }
3898
3899 #[test]
3904 fn a_panicked_journal_stage_latches_the_failure() {
3905 let failed = Arc::new(AtomicBool::new(false));
3906 let healthy = Arc::new(AtomicBool::new(true));
3907 let journal_handle = {
3908 let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3909 std::thread::spawn(move || {
3910 run_journal_stage(
3911 || -> Result<u32, melin_journal::JournalError> {
3912 panic!("journal stage died without returning an error")
3913 },
3914 &failed,
3915 &healthy,
3916 )
3917 })
3918 };
3919 assert!(journal_handle.join().is_err(), "the stage panicked");
3921 assert!(failed.load(Ordering::Acquire), "the failure is latched");
3922 assert!(
3923 !healthy.load(Ordering::Acquire),
3924 "the node reports unhealthy"
3925 );
3926 }
3927
3928 #[test]
3932 fn resume_point_ends_when_the_journal_thread_panicked() {
3933 let (_fsync, reader) = fsync_state_at(10, 0);
3934 let (mut handles, _consumer) = handles_fixture(8, reader);
3935 handles.input_producer.publish(InputSlot::default());
3936 let failed = Arc::clone(&handles.journal_failed);
3937 let healthy = Arc::new(AtomicBool::new(true));
3938 let dead = std::thread::spawn(move || {
3939 run_journal_stage(
3940 || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3941 &failed,
3942 &healthy,
3943 )
3944 });
3945 assert!(dead.join().is_err(), "the stage panicked");
3946
3947 let give_up = AtomicBool::new(false);
3949 let outcome = std::thread::scope(|s| {
3950 let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3951 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3952 while !wait.is_finished() && std::time::Instant::now() < deadline {
3953 std::thread::sleep(std::time::Duration::from_millis(5));
3954 }
3955 give_up.store(true, Ordering::Relaxed);
3956 wait.join().expect("wait thread")
3957 });
3958 assert!(
3959 matches!(outcome, ResumePoint::JournalFailed),
3960 "the wait must end on the panicked stage, not on the test's give-up"
3961 );
3962 }
3963
3964 #[test]
3968 fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3969 let dir = tempfile::tempdir().expect("tempdir");
3970 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3971 let (input_producer, mut consumers) =
3972 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3973 .add_consumer()
3974 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3975 let _consumer = consumers.pop().expect("one consumer");
3976 let handles = ReplicaPipelineHandles {
3977 input_producer,
3978 journal_cursor: Arc::new(make_journal_cursor(0)),
3979 chain_hash_lock: fsync_state_at(0, 0).1,
3980 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
3981 genesis_entries: None,
3982 journal_failed: Arc::new(AtomicBool::new(true)),
3983 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3984 journal_handle: std::thread::spawn(
3985 || -> Result<Writer, melin_journal::JournalError> {
3986 Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3987 expected: 4,
3988 actual: 1,
3989 })
3990 },
3991 ),
3992 matching_handle: std::thread::spawn(counter_server::Counter::default),
3993 drain_handle: std::thread::spawn(|| {}),
3994 shadow_handle: None,
3995 };
3996 let mut pipeline = Some(handles);
3997 let mut divergence_resyncs = 0u32;
3998 let mut backoff = MAX_BACKOFF;
3999 let mut closes = 0;
4000 let after = handle_session_exit::<counter_server::Counter, Writer>(
4001 journal_failed_while_disconnected(),
4002 &mut pipeline,
4003 &mut divergence_resyncs,
4004 &mut backoff,
4005 0,
4006 &dir.path().join("r.journal"),
4007 &dir.path().join("r.snapshot"),
4008 &melin_transport_core::fence::FenceState::new(0),
4009 &SharedLineagePolicy::new(None),
4010 &AtomicBool::new(false),
4011 &crate::promotion::PromotionRequest::new(),
4012 || closes += 1,
4013 &(),
4014 );
4015 let AfterSession::Return(Err(err)) = after else {
4016 panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
4017 };
4018 assert!(
4019 err.to_string()
4020 .contains("refused to journal sequence 1: the next sequence is 4"),
4021 "the returned error names the journal's cause, got: {err}"
4022 );
4023 assert!(pipeline.is_none(), "the dead pipeline was torn down");
4024 assert_eq!(divergence_resyncs, 0);
4025 assert_eq!(closes, 1);
4028 }
4029
4030 #[test]
4034 fn every_session_exit_closes_the_connection_once() {
4035 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4036 type Case = (&'static str, fn() -> SessionExit);
4039 let exits: [Case; 6] = [
4040 ("shutdown", || SessionExit::Shutdown),
4041 ("promote", || SessionExit::Promote),
4042 ("fatal", || SessionExit::Fatal("protocol violation".into())),
4043 ("disconnected", || SessionExit::Disconnected),
4044 ("stream gap", || SessionExit::StreamGap("gap".into())),
4045 ("corrupted", || SessionExit::Corrupted("damaged".into())),
4046 ];
4047 for (label, exit) in exits {
4048 let dir = tempfile::tempdir().expect("tempdir");
4049 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4050 None;
4051 let mut divergence_resyncs = 0u32;
4052 let mut backoff = MAX_BACKOFF;
4053 let shutdown = AtomicBool::new(true);
4056 let mut closes = 0;
4057 let _after = handle_session_exit::<counter_server::Counter, Writer>(
4060 StreamingResult {
4061 exit: exit(),
4062 heard_from_primary: true,
4063 },
4064 &mut pipeline,
4065 &mut divergence_resyncs,
4066 &mut backoff,
4067 0,
4068 &dir.path().join("r.journal"),
4069 &dir.path().join("r.snapshot"),
4070 &melin_transport_core::fence::FenceState::new(0),
4071 &SharedLineagePolicy::new(None),
4072 &shutdown,
4073 &crate::promotion::PromotionRequest::new(),
4074 || closes += 1,
4075 &(),
4076 );
4077 assert_eq!(closes, 1, "{label}");
4078 }
4079 }
4080
4081 fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
4088 backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
4089 }
4090
4091 fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
4093 let dir = tempfile::tempdir().expect("tempdir");
4094 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4095 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
4096 let mut divergence_resyncs = 0u32;
4097 let mut backoff = MAX_BACKOFF;
4098 let shutdown = AtomicBool::new(true);
4099 let promote = crate::promotion::PromotionRequest::new();
4100 let after = handle_session_exit::<counter_server::Counter, Writer>(
4101 StreamingResult {
4102 exit,
4103 heard_from_primary,
4104 },
4105 &mut pipeline,
4106 &mut divergence_resyncs,
4107 &mut backoff,
4108 0,
4109 &dir.path().join("r.journal"),
4110 &dir.path().join("r.snapshot"),
4111 &melin_transport_core::fence::FenceState::new(0),
4112 &SharedLineagePolicy::new(None),
4113 &shutdown,
4114 &promote,
4115 || {},
4116 &(),
4117 );
4118 assert!(matches!(after, AfterSession::Reconnect));
4119 backoff
4120 }
4121
4122 #[test]
4127 fn disconnect_resets_backoff_when_primary_spoke() {
4128 assert_eq!(
4131 backoff_after_disconnect(true),
4132 std::time::Duration::from_secs(2)
4133 );
4134 }
4135
4136 #[test]
4141 fn disconnect_keeps_escalated_backoff_when_primary_silent() {
4142 assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
4143 }
4144
4145 #[test]
4150 fn corrupted_entry_resets_backoff() {
4151 assert_eq!(
4152 backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
4153 std::time::Duration::from_secs(2)
4154 );
4155 }
4156
4157 #[test]
4166 fn stream_gap_reconnects_and_keeps_pipeline() {
4167 let dir = tempfile::tempdir().expect("tempdir");
4168 type Writer = melin_journal::BufferedWriter<CounterEvent>;
4169 let (input_producer, mut consumers) =
4173 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
4174 .add_consumer()
4175 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
4176 let _consumer = consumers.pop().expect("one consumer");
4177 let writer_path = dir.path().join("w.journal");
4178 let handles = ReplicaPipelineHandles {
4179 input_producer,
4180 journal_cursor: Arc::new(make_journal_cursor(0)),
4181 chain_hash_lock: fsync_state_at(0, 0).1,
4182 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
4183 genesis_entries: None,
4184 journal_failed: Arc::new(AtomicBool::new(false)),
4185 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
4186 journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
4187 matching_handle: std::thread::spawn(counter_server::Counter::default),
4188 drain_handle: std::thread::spawn(|| {}),
4189 shadow_handle: None,
4190 };
4191 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4192 Some(handles);
4193 let mut divergence_resyncs = 0u32;
4194 let mut backoff = MAX_BACKOFF;
4195 let shutdown = AtomicBool::new(true);
4196 let promote = crate::promotion::PromotionRequest::new();
4197 let mut closed = false;
4198 let after = handle_session_exit::<counter_server::Counter, Writer>(
4199 StreamingResult {
4200 exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
4201 heard_from_primary: true,
4202 },
4203 &mut pipeline,
4204 &mut divergence_resyncs,
4205 &mut backoff,
4206 2,
4207 &dir.path().join("r.journal"),
4208 &dir.path().join("r.snapshot"),
4209 &melin_transport_core::fence::FenceState::new(0),
4210 &SharedLineagePolicy::new(None),
4211 &shutdown,
4212 &promote,
4213 || closed = true,
4214 &(),
4215 );
4216 assert!(
4217 matches!(after, AfterSession::Reconnect),
4218 "a stream gap must reconnect, not return an error"
4219 );
4220 assert!(
4221 pipeline.is_some(),
4222 "the pipeline must survive for the reconnect"
4223 );
4224 assert!(closed, "the transport must be closed before redialing");
4225 assert_eq!(
4226 backoff,
4227 std::time::Duration::from_secs(2),
4228 "backoff reset to 1s then doubled by the shared helper"
4229 );
4230 assert_eq!(divergence_resyncs, 0, "not a resync");
4231 if let Some(p) = pipeline.take() {
4232 let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4233 }
4234 }
4235
4236 #[test]
4241 fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4242 let (mut handles, mut consumer) = teardown_fixture(4);
4243 for seq in 0..4 {
4244 assert!(
4245 handles
4246 .input_producer
4247 .try_publish(InputSlot {
4248 sequence: seq,
4249 ..InputSlot::default()
4250 })
4251 .is_ok(),
4252 "ring must have space while filling"
4253 );
4254 }
4255 assert!(
4256 handles
4257 .input_producer
4258 .try_publish(InputSlot::default())
4259 .is_err(),
4260 "ring must be full"
4261 );
4262 handles.journal_failed.store(true, Ordering::Release);
4263 assert!(matches!(
4265 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4266 TeardownOutcome::Clean(..)
4267 ));
4268 while let Some((_, slot)) = consumer.try_consume() {
4269 assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4270 }
4271 }
4272
4273 #[test]
4274 fn pending_ack_queue_wraps_around() {
4275 let mut q = PendingAckQueue::new(8);
4276 let cursor = make_journal_cursor(100);
4277
4278 for round in 0..3 {
4280 for i in 0..8 {
4281 let target = (round * 8 + i) as u64 + 1;
4282 q.push(target, target * 10);
4283 }
4284 assert!(q.is_full());
4285 let seq = q.pop_ready(&cursor).expect("should be ready");
4286 assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4287 assert!(q.is_empty());
4288 }
4289 }
4290
4291 #[test]
4292 fn pending_ack_queue_pop_all_blocking_empty() {
4293 let mut q = PendingAckQueue::new(8);
4294 let cursor = make_journal_cursor(0);
4295 assert!(
4296 q.pop_all_blocking(
4297 &cursor,
4298 melin_pipeline::wait::WaitStrategy::BusySpin,
4299 &AtomicBool::new(false)
4300 )
4301 .is_none()
4302 );
4303 }
4304
4305 #[test]
4308 fn dual_track_returns_none_when_both_tracks_idle() {
4309 let mut q = PendingAckQueue::new(8);
4310 let cursor = make_journal_cursor(0);
4311 assert!(
4312 try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4313 "no advance on either track → no ack"
4314 );
4315 }
4316
4317 #[test]
4318 fn dual_track_fires_on_persisted_advance_only() {
4319 let mut q = PendingAckQueue::new(8);
4324 q.push(10, 100);
4325 let cursor = make_journal_cursor(20);
4326 let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4327 assert_eq!(ack.acked_sequence, 100);
4328 assert_eq!(ack.in_memory_sequence, 50);
4329 }
4330
4331 #[test]
4332 fn dual_track_fires_on_in_memory_advance_only() {
4333 let mut q = PendingAckQueue::new(8);
4336 let cursor = make_journal_cursor(0);
4337 let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4338 assert_eq!(ack.acked_sequence, 100);
4339 assert_eq!(ack.in_memory_sequence, 200);
4340 }
4341
4342 #[test]
4343 fn dual_track_coalesces_until_caller_updates_trackers() {
4344 let mut q = PendingAckQueue::new(8);
4350 q.push(10, 100);
4351 let cursor = make_journal_cursor(20);
4352 let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4353 assert_eq!(ack1.acked_sequence, 100);
4354 let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4356 .expect("call 2 fires on in-memory advance");
4357 assert_eq!(
4358 ack2.acked_sequence, 0,
4359 "no new persisted pop → unwrap_or(last_sent)"
4360 );
4361 assert_eq!(ack2.in_memory_sequence, 80);
4362 }
4363
4364 #[test]
4365 fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4366 let mut q = PendingAckQueue::new(4);
4384 for i in 1..=4u64 {
4387 q.push(i * 10, i * 100);
4388 }
4389 assert!(q.is_full());
4390
4391 let cursor = make_journal_cursor(40);
4394 let drained = q.pop_ready(&cursor).expect("all entries durable");
4395 assert_eq!(drained, 400);
4396 assert!(q.is_empty());
4397
4398 let mut last_sent_acked = drained;
4401 let mut last_sent_in_mem = 450u64;
4402
4403 assert!(
4408 try_flush_dual_track(
4409 &mut q,
4410 &cursor,
4411 last_sent_in_mem,
4412 last_sent_acked,
4413 last_sent_in_mem,
4414 )
4415 .is_none(),
4416 "no advance on either track after backpressure drain → no duplicate ack",
4417 );
4418
4419 q.push(50, 500);
4422 let cursor2 = make_journal_cursor(50);
4423 let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4424 .expect("fresh batch after drain fires a new ack");
4425 assert!(
4426 ack.acked_sequence >= last_sent_acked,
4427 "regression: drain sent {last_sent_acked} but next ack carries {}",
4428 ack.acked_sequence,
4429 );
4430 assert_eq!(ack.acked_sequence, 500);
4431 assert_eq!(ack.in_memory_sequence, 500);
4432
4433 last_sent_acked = ack.acked_sequence;
4434 last_sent_in_mem = ack.in_memory_sequence;
4435
4436 assert!(
4440 try_flush_dual_track(
4441 &mut q,
4442 &cursor2,
4443 last_sent_in_mem,
4444 last_sent_acked,
4445 last_sent_in_mem,
4446 )
4447 .is_none(),
4448 "post-resume idle → no further ack",
4449 );
4450 }
4451}