1use std::sync::Arc;
61#[cfg(test)]
62use std::sync::atomic::AtomicU64;
63use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
64
65use melin_journal::{BufferedWriter, JournalWrite};
66
67use melin_app::Application;
68use melin_transport_core::pipeline::{InputSlot, OutputSlot};
69use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
70use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
71
72mod auth;
73#[cfg(feature = "dpdk")]
74mod dpdk;
75mod receiver_transport;
76mod tcp_receiver;
77mod tcp_sender;
78#[cfg(any(feature = "dpdk", test))]
83mod validation_worker;
84#[cfg(any(feature = "dpdk", test))]
88mod join_worker;
89
90use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
91
92pub(crate) struct ReplicaGate<'a> {
105 count: &'a AtomicU32,
106}
107
108impl<'a> ReplicaGate<'a> {
109 pub(crate) fn new(count: &'a AtomicU32) -> Self {
110 Self { count }
111 }
112
113 pub(crate) fn lift(&self) {
116 self.count.fetch_add(1, Ordering::Release);
117 }
118
119 pub(crate) fn lower(&self) -> bool {
126 let was_last = self.count.fetch_sub(1, Ordering::Release) == 1;
127 if was_last {
128 tracing::warn!("all replicas disconnected — halted, refusing client writes");
129 }
130 was_last
131 }
132}
133
134pub use melin_transport_core::replication::ack_queue::{
141 PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
142};
143pub use melin_transport_core::replication::protocol::{
144 Ack, Handshake, PrimaryMessage, ReplicaMessage,
145};
146pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
147
148#[cfg(feature = "dpdk")]
149pub(crate) use dpdk::REPL_DISPATCH_BURST;
150#[cfg(feature = "dpdk")]
151pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
152pub use tcp_receiver::{ReceiverResult, run_receiver};
153pub use tcp_sender::{ReplicationListener, Sender, run_sender};
154
155#[derive(Clone)]
162pub struct ReplicaControlPlane {
163 pub promote: crate::promotion::PromotionRequest,
166 pub tip_ready: std::sync::Arc<AtomicBool>,
170 pub journal_tip: melin_transport_core::AdvertisedJournalTip,
173 pub primary_link_up: std::sync::Arc<AtomicBool>,
180 pub primary_ack_policy: std::sync::Arc<std::sync::atomic::AtomicU8>,
190 pub pipeline_healthy: std::sync::Arc<AtomicBool>,
198}
199
200impl ReplicaControlPlane {
201 pub fn new() -> Self {
205 Self {
206 promote: crate::promotion::PromotionRequest::new(),
207 tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
208 journal_tip: melin_transport_core::AdvertisedJournalTip::new(
209 melin_transport_core::WireSeq::new(0),
210 ),
211 primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
212 primary_ack_policy: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
213 crate::ack_policy::ACK_POLICY_UNKNOWN,
214 )),
215 pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
216 }
217 }
218}
219
220impl Default for ReplicaControlPlane {
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
235 if !tracing::enabled!(tracing::Level::DEBUG) {
236 return;
237 }
238 let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
242 let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
243 let rc = unsafe {
244 libc::getsockopt(
245 fd,
246 libc::IPPROTO_TCP,
247 libc::TCP_INFO,
248 &mut info as *mut _ as *mut libc::c_void,
249 &mut len,
250 )
251 };
252 if rc != 0 {
253 tracing::debug!(
254 slot,
255 tag,
256 errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
257 "tcp_info getsockopt failed"
258 );
259 return;
260 }
261 tracing::debug!(
262 slot,
263 tag,
264 state = info.tcpi_state,
265 ca_state = info.tcpi_ca_state,
266 rtt_us = info.tcpi_rtt,
267 rttvar_us = info.tcpi_rttvar,
268 snd_cwnd = info.tcpi_snd_cwnd,
269 snd_ssthresh = info.tcpi_snd_ssthresh,
270 snd_mss = info.tcpi_snd_mss,
271 rcv_mss = info.tcpi_rcv_mss,
272 unacked = info.tcpi_unacked,
273 retrans = info.tcpi_retrans,
274 total_retrans = info.tcpi_total_retrans,
275 lost = info.tcpi_lost,
276 rcv_space = info.tcpi_rcv_space,
277 rto_us = info.tcpi_rto,
278 "tcp_info"
279 );
280}
281
282pub(super) fn sleep_checking_flags(
286 duration: std::time::Duration,
287 shutdown: &AtomicBool,
288 promote: &crate::promotion::PromotionRequest,
289) {
290 let deadline = std::time::Instant::now() + duration;
291 while std::time::Instant::now() < deadline {
292 if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
293 return;
294 }
295 std::thread::sleep(std::time::Duration::from_millis(100));
296 }
297}
298
299pub(super) fn sleep_then_double_backoff(
306 backoff: &mut std::time::Duration,
307 shutdown: &AtomicBool,
308 promote: &crate::promotion::PromotionRequest,
309) {
310 sleep_checking_flags(*backoff, shutdown, promote);
311 *backoff = (*backoff * 2).min(MAX_BACKOFF);
312}
313
314pub(super) enum TeardownOutcome<A, W> {
318 Clean(A, W),
321 JournalFailed(melin_journal::JournalError),
327 Panicked,
329}
330
331pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
346 shutdown_flag: &AtomicBool,
347 journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
348 matching_handle: std::thread::JoinHandle<A>,
349 drain_handle: std::thread::JoinHandle<()>,
350 shadow_handle: Option<std::thread::JoinHandle<()>>,
351) -> TeardownOutcome<A, W> {
352 shutdown_flag.store(true, Ordering::Release);
366 let journal_result = journal_handle.join();
371 let matching_result = matching_handle.join();
372 let _ = drain_handle.join();
373 if let Some(h) = shadow_handle {
374 let _ = h.join();
375 }
376 let writer = match journal_result {
377 Ok(Ok(w)) => w,
378 Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
380 Err(_) => return TeardownOutcome::Panicked,
381 };
382 match matching_result {
383 Ok(app) => TeardownOutcome::Clean(app, writer),
384 Err(_) => TeardownOutcome::Panicked,
385 }
386}
387
388pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
397 pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
398 pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
399 pub(super) chain_hash_lock:
404 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
405 pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
410 pub(super) genesis_entries: Option<u64>,
418 pub(super) journal_failed: Arc<AtomicBool>,
426 pub(super) pipeline_shutdown: Arc<AtomicBool>,
429 pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
430 pub(super) matching_handle: std::thread::JoinHandle<A>,
431 pub(super) drain_handle: std::thread::JoinHandle<()>,
432 pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
433}
434
435pub(super) enum ResumePoint {
437 Settled(melin_transport_core::pipeline::FsyncState),
440 JournalFailed,
444 Interrupted,
447}
448
449impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
450 pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
465 let published = self.input_producer.peek_cursor();
469 let mut outcome = ResumePoint::Interrupted;
470 melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
473 if self.journal_failed.load(Ordering::Acquire) {
479 outcome = ResumePoint::JournalFailed;
480 return true;
481 }
482 let state = self.chain_hash_lock.load();
483 if state.input_ring_seq.get() >= published {
484 outcome = ResumePoint::Settled(state);
485 return true;
486 }
487 interrupted()
488 });
489 outcome
490 }
491}
492
493pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
500 StreamingResult {
501 exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
502 heard_from_primary: false,
503 }
504}
505
506fn run_journal_stage<W, E: std::fmt::Display>(
517 run: impl FnOnce() -> Result<W, E>,
518 failed: &AtomicBool,
519 healthy: &AtomicBool,
520) -> Result<W, E> {
521 struct PanicLatch<'a> {
524 failed: &'a AtomicBool,
525 healthy: &'a AtomicBool,
526 armed: bool,
527 }
528 impl Drop for PanicLatch<'_> {
529 fn drop(&mut self) {
530 if self.armed {
531 self.failed.store(true, Ordering::Release);
532 self.healthy.store(false, Ordering::Release);
533 tracing::error!("replica journal stage panicked — session teardown");
534 }
535 }
536 }
537 let mut guard = PanicLatch {
538 failed,
539 healthy,
540 armed: true,
541 };
542 let result = run();
543 guard.armed = false;
544 if let Err(ref e) = result {
545 failed.store(true, Ordering::Release);
551 healthy.store(false, Ordering::Release);
554 tracing::error!(error = %e, "replica journal stage failed — session teardown");
555 }
556 result
557}
558
559pub(super) type ReplicaHandles<A> =
564 ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
565
566#[allow(clippy::too_many_arguments)]
570pub(super) fn build_replica_pipeline_with_threads<A>(
571 mut app: A,
572 writer: BufferedWriter<A::Event>,
573 cores: crate::layout::PipelineCores,
574 staging_mode: melin_journal::StagingMode,
579 snapshot_interval_ms: u64,
580 snapshot_path: std::path::PathBuf,
581 group_commit_delay: std::time::Duration,
582 fence_state: Arc<melin_transport_core::fence::FenceState>,
583 pipeline_healthy: Arc<AtomicBool>,
589 sizing: &A::Sizing,
592) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
593where
594 A: Application + Send + 'static,
595 A::Event: Send + Sync + 'static,
596 A::Report: Send + 'static,
597 A::QueryResponse: Send + 'static,
598{
599 <A as Application>::prefault(&mut app, sizing);
604 let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
605
606 let enable_shadow = snapshot_interval_ms > 0;
607 let shadow_initial_epoch = fence_state.epoch();
610 let genesis_entries = writer.read_header_info()?.genesis_entries;
613 let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
614 app,
615 writer,
616 4096, group_commit_delay,
618 cores.stage_waits(),
619 enable_shadow,
620 fence_state,
621 );
622
623 let pipeline_shutdown = Arc::new(AtomicBool::new(false));
624
625 let ps = Arc::clone(&pipeline_shutdown);
626 let journal_core = cores.journal_seq.core;
627 let mut journal_stage = pipeline.journal_stage;
628 let stream_marks: melin_transport_core::pipeline::StreamMarkQueue =
633 Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
634 journal_stage.set_stream_marks(Arc::clone(&stream_marks));
635 cores.place_journal_children(&mut journal_stage);
636 journal_stage.set_staging_mode(staging_mode);
637 let journal_failed = Arc::new(AtomicBool::new(false));
638 let journal_failed_latch = Arc::clone(&journal_failed);
639 let sequencer = journal_stage.start()?;
643 pipeline_healthy.store(true, Ordering::Release);
646 let journal_handle = std::thread::Builder::new()
647 .name("journal-seq".into())
648 .spawn(move || {
649 melin_app::affinity::pin_thread("journal-seq", journal_core);
650 run_journal_stage(
651 || sequencer.run(&ps),
652 &journal_failed_latch,
653 &pipeline_healthy,
654 )
655 })
656 .expect("spawn journal thread");
657
658 let ps = Arc::clone(&pipeline_shutdown);
659 let matching_core = cores.matching.core;
660 let matching_stage = pipeline.matching_stage;
661 let matching_handle = std::thread::Builder::new()
662 .name("matching".into())
663 .spawn(move || {
664 melin_app::affinity::pin_thread("matching", matching_core);
665 matching_stage.run(&ps)
666 })
667 .expect("spawn matching thread");
668
669 let ps = Arc::clone(&pipeline_shutdown);
672 let drain = cores.response;
673 let drain_consumer = pipeline.drain_consumer;
674 let drain_handle = std::thread::Builder::new()
675 .name("drain".into())
676 .spawn(move || {
677 melin_app::affinity::pin_thread("drain", drain.core);
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 .expect("spawn drain thread");
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 std::thread::Builder::new()
702 .name("replica-shadow".into())
703 .spawn(move || {
704 melin_app::affinity::pin_thread("replica-shadow", shadow.core);
705 melin_transport_core::shadow::run(
706 shadow_cons,
707 shadow_app,
708 snap_path,
709 std::time::Duration::from_millis(snapshot_interval_ms),
710 chain_lock,
711 &ps,
712 shadow.wait,
713 shadow_initial_epoch,
714 genesis_entries,
715 );
716 })
717 .expect("spawn shadow thread"),
718 )
719 } else {
720 None
721 };
722
723 Ok(ReplicaPipelineHandles {
724 input_producer: pipeline.input_producer,
725 journal_cursor: pipeline.cursors.journal_ring_arc(),
726 chain_hash_lock: pipeline.chain_hash_lock,
727 stream_marks,
728 genesis_entries,
729 journal_failed,
730 pipeline_shutdown,
731 journal_handle,
732 matching_handle,
733 drain_handle,
734 shadow_handle,
735 })
736}
737
738pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
743 mut handles: ReplicaPipelineHandles<A, W>,
744) -> TeardownOutcome<A, W> {
745 while !handles.journal_failed.load(Ordering::Acquire) {
762 match handles
763 .input_producer
764 .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
765 {
766 Ok(_) => break,
767 Err(_) => std::thread::yield_now(),
769 }
770 }
771 shutdown_pipeline::<A, W>(
772 &handles.pipeline_shutdown,
773 handles.journal_handle,
774 handles.matching_handle,
775 handles.drain_handle,
776 handles.shadow_handle,
777 )
778}
779
780pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
791
792#[allow(clippy::type_complexity)]
809pub(super) fn recover_replica_state<A, W>(
810 journal_path: &std::path::Path,
811 snapshot_path: &std::path::Path,
812 fence_state: &melin_transport_core::fence::FenceState,
813 sizing: &A::Sizing,
816) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
817where
818 A: Application,
819 W: melin_journal::JournalWrite<A::Event>,
820{
821 remove_restamp_staging(journal_path)?;
824 let lineage_exists =
825 journal_path.exists() || !melin_journal::segment::list_archives(journal_path)?.is_empty();
826 if !lineage_exists {
827 return Ok((None, None, 0u64, [0u8; 32]));
828 }
829 let engine = if snapshot_path.exists() {
830 tracing::info!("recovering replica from snapshot + journal");
831 melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
832 snapshot_path,
833 journal_path,
834 )?
835 } else {
836 let mut app = A::default();
837 <A as Application>::prefault(&mut app, sizing);
838 melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
839 };
840 let next = engine.next_sequence();
841 let last = next.saturating_sub(1);
842 let hash = engine.chain_hash().unwrap_or([0u8; 32]);
843 fence_state.observe_epoch(engine.recovered_epoch());
846 let (app, writer) = engine.into_parts();
847 Ok((Some(app), Some(writer), last, hash))
848}
849
850pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
856
857pub(in crate::replication) enum AfterSession<A, W> {
861 Return(ReceiverResult<A, W>),
865 Resync {
870 app: Option<A>,
871 journal_writer: Option<W>,
872 last_sequence: u64,
873 chain_hash: [u8; 32],
874 },
875 Reconnect,
879}
880
881impl<A, W> AfterSession<A, W> {
882 pub(in crate::replication) fn adopt(
887 self,
888 app: &mut Option<A>,
889 journal_writer: &mut Option<W>,
890 last_sequence: &mut u64,
891 chain_hash: &mut [u8; 32],
892 ) -> Option<ReceiverResult<A, W>> {
893 match self {
894 AfterSession::Return(r) => Some(r),
895 AfterSession::Resync {
896 app: recovered_app,
897 journal_writer: recovered_writer,
898 last_sequence: recovered_sequence,
899 chain_hash: recovered_hash,
900 } => {
901 *app = recovered_app;
902 *journal_writer = recovered_writer;
903 *last_sequence = recovered_sequence;
904 *chain_hash = recovered_hash;
905 None
906 }
907 AfterSession::Reconnect => None,
908 }
909 }
910}
911
912#[allow(clippy::too_many_arguments)]
935pub(in crate::replication) fn handle_session_exit<A, W>(
936 result: StreamingResult,
937 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
938 divergence_resyncs: &mut u32,
939 backoff: &mut std::time::Duration,
940 last_sequence: u64,
941 journal_path: &std::path::Path,
942 snapshot_path: &std::path::Path,
943 fence_state: &melin_transport_core::fence::FenceState,
944 shutdown: &AtomicBool,
945 promote: &crate::promotion::PromotionRequest,
946 close: impl FnOnce(),
947 sizing: &A::Sizing,
950) -> AfterSession<A, W>
951where
952 A: Application + Send + 'static,
953 W: JournalWrite<A::Event> + Send + 'static,
954{
955 let StreamingResult {
956 exit,
957 heard_from_primary,
958 } = result;
959
960 close();
961
962 match exit {
963 SessionExit::Shutdown => {
964 if let Some(p) = pipeline.take() {
965 let _ = teardown_replica_pipeline::<A, W>(p);
966 }
967 AfterSession::Return(Ok(None))
968 }
969
970 SessionExit::Promote => AfterSession::Return(match pipeline.take() {
971 Some(p) => match teardown_replica_pipeline::<A, W>(p) {
972 TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
973 _ => Err("pipeline failed during promotion".into()),
974 },
975 None => Err("pipeline missing on promote".into()),
976 }),
977
978 SessionExit::Fatal(e) => {
979 let outcome = match pipeline.take() {
980 Some(p) => teardown_replica_pipeline::<A, W>(p),
981 None => return AfterSession::Return(Err(e)),
984 };
985 let je = match outcome {
996 TeardownOutcome::JournalFailed(
997 je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
998 ) => je,
999 TeardownOutcome::JournalFailed(je) => {
1000 return AfterSession::Return(Err(format!("{e}: {je}").into()));
1001 }
1002 TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1003 return AfterSession::Return(Err(e));
1004 }
1005 };
1006
1007 *divergence_resyncs += 1;
1008 let attempt = *divergence_resyncs;
1009 if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1010 return AfterSession::Return(Err(format!(
1011 "mid-stream chain divergence recurred {attempt} times — giving up on \
1012 in-process resync (each cycle archives the local journal and re-seeds \
1013 from the primary; recurrence at this rate means the primary keeps \
1014 streaming history that forks from what it announces): {je}"
1015 )
1016 .into()));
1017 }
1018 tracing::warn!(
1019 error = %je,
1020 attempt,
1021 max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1022 "mid-stream chain divergence — re-deriving local state for in-process resync"
1023 );
1024 match recover_replica_state::<A, W>(journal_path, snapshot_path, fence_state, sizing) {
1025 Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1026 app,
1027 journal_writer,
1028 last_sequence: seq,
1029 chain_hash: hash,
1030 },
1031 Err(e) => AfterSession::Return(Err(e)),
1032 }
1033 }
1034
1035 SessionExit::StreamGap(e) => {
1036 *backoff = std::time::Duration::from_secs(1);
1045 tracing::warn!(
1046 error = %e,
1047 last_sequence,
1048 "replication stream broke contiguity — reconnecting from the durable position"
1049 );
1050 sleep_then_double_backoff(backoff, shutdown, promote);
1051 AfterSession::Reconnect
1052 }
1053
1054 SessionExit::Corrupted(e) => {
1055 *backoff = std::time::Duration::from_secs(1);
1073 tracing::warn!(
1074 error = %e,
1075 last_sequence,
1076 backoff_secs = backoff.as_secs(),
1077 "replicated entry failed its integrity check — refused, reconnecting to \
1078 fetch it again; recurrence points at the network, a NIC or memory"
1079 );
1080 sleep_then_double_backoff(backoff, shutdown, promote);
1081 AfterSession::Reconnect
1082 }
1083
1084 SessionExit::Disconnected => {
1085 if heard_from_primary {
1094 *backoff = std::time::Duration::from_secs(1);
1095 }
1096 tracing::warn!(
1097 last_sequence,
1098 heard_from_primary,
1099 backoff_secs = backoff.as_secs(),
1100 "reconnecting to primary"
1101 );
1102 sleep_then_double_backoff(backoff, shutdown, promote);
1103 AfterSession::Reconnect
1104 }
1105 }
1106}
1107
1108pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1118 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1119 app: &mut Option<A>,
1120 journal_writer: &mut Option<W>,
1121) -> ReceiverResult<A, W>
1122where
1123 A: Application + Send + 'static,
1124 W: JournalWrite<A::Event> + Send + 'static,
1125{
1126 if let Some(p) = pipeline.take()
1127 && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1128 {
1129 *app = Some(e);
1130 *journal_writer = Some(w);
1131 }
1132 match (app.take(), journal_writer.take()) {
1133 (Some(e), Some(w)) => Ok(Some((e, w))),
1134 _ => Err("promotion requested but no local state available".into()),
1135 }
1136}
1137
1138type ResyncTransfer<A> = (A, u64, [u8; 32], u64);
1141
1142pub(in crate::replication) enum ResyncDecision {
1145 Ready {
1150 segment_start_sequence: u64,
1151 anchor_hash: [u8; 32],
1152 genesis_entries: Option<u64>,
1155 resume_sequence: u64,
1156 },
1157 Retry,
1161}
1162
1163fn receive_resync_transfer<A, S>(
1173 source: &mut S,
1174 snapshot_path: &std::path::Path,
1175 journal_path: &std::path::Path,
1176 fence_state: &melin_transport_core::fence::FenceState,
1177) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1178where
1179 A: Application + Send,
1180 S: ControlFrameSource,
1181{
1182 let (snap_len, snap_sequence, snap_chain_hash) =
1183 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1184 PrimaryMessage::SnapshotBegin {
1185 snapshot_len,
1186 snap_sequence,
1187 snap_chain_hash,
1188 } => (snapshot_len, snap_sequence, snap_chain_hash),
1189 other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1190 };
1191
1192 tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1193 let tmp_path = snapshot_path.with_extension("snapshot.tmp");
1194 receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1195 std::fs::rename(&tmp_path, snapshot_path)?;
1196 tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1197
1198 let (snap_app, _snap_seq, snap_hash, snap_epoch) =
1201 source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1202 if snap_hash != snap_chain_hash {
1203 return Err(format!(
1204 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1205 loaded snapshot has {snap_hash:02x?}"
1206 )
1207 .into());
1208 }
1209 fence_state.observe_epoch(snap_epoch);
1212
1213 let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1218 PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1219 other => {
1220 return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1221 }
1222 };
1223 let seed_tmp = journal_path.with_extension("seed.tmp");
1224 receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1225 source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1231 if let Err(e) =
1232 melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1233 {
1234 let _ = std::fs::remove_file(&seed_tmp);
1235 return Err(format!("segment seed failed structural verification: {e}").into());
1236 }
1237 std::fs::rename(&seed_tmp, journal_path)?;
1238 melin_journal::segment::fsync_parent_dir(journal_path)?;
1239 Ok(())
1240 })?;
1241 Ok((snap_app, snap_sequence, snap_chain_hash, seed_len))
1242}
1243
1244#[allow(clippy::too_many_arguments)]
1257pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1258 divergent: bool,
1259 source: &mut S,
1260 pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1261 app: &mut Option<A>,
1262 journal_writer: &mut Option<W>,
1263 journal_path: &std::path::Path,
1264 snapshot_path: &std::path::Path,
1265 fence_state: &melin_transport_core::fence::FenceState,
1266 control: &ReplicaControlPlane,
1267 last_sequence: &mut u64,
1268 chain_hash: &mut [u8; 32],
1269) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1270where
1271 A: Application + Send + 'static,
1272 A::Event: Send,
1275 W: JournalWrite<A::Event> + Send + 'static,
1276 S: ControlFrameSource,
1277{
1278 if divergent {
1283 tracing::warn!(
1284 last_sequence = *last_sequence,
1285 "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1286 );
1287 } else {
1288 tracing::info!("primary requires snapshot transfer — receiving snapshot");
1289 }
1290
1291 let old_pipeline = pipeline.take();
1298 let old_app = app.take();
1299 let old_writer = journal_writer.take();
1300
1301 let reason = if divergent {
1312 ArchiveReason::Divergent
1313 } else {
1314 ArchiveReason::Resync
1315 };
1316 source.serviced(move || {
1319 if let Some(p) = old_pipeline {
1320 let _ = teardown_replica_pipeline::<A, W>(p);
1324 }
1325 drop(old_app);
1327 drop(old_writer);
1328 archive_local_lineage(journal_path, snapshot_path, reason)
1329 })?;
1330 *last_sequence = 0;
1332 *chain_hash = [0u8; 32];
1333 control
1338 .journal_tip
1339 .reset(melin_transport_core::WireSeq::new(0));
1340
1341 let (snap_app, snap_sequence, snap_chain_hash, seed_len) =
1342 match receive_resync_transfer::<A, S>(source, snapshot_path, journal_path, fence_state) {
1343 Ok(v) => v,
1344 Err(e) => {
1345 tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1346 let _ = std::fs::remove_file(snapshot_path);
1350 return Ok(ResyncDecision::Retry);
1351 }
1352 };
1353 *app = Some(snap_app);
1354
1355 let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
1363 let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1364 if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1365 return Err(format!(
1366 "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1367 hash — inconsistent primary"
1368 )
1369 .into());
1370 }
1371 let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1372 *journal_writer = Some(writer);
1373
1374 match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1377 PrimaryMessage::StreamStart {
1378 start_sequence,
1379 segment_start_sequence,
1380 anchor_hash,
1381 genesis_entries,
1382 epoch,
1383 ack_policy,
1384 } => {
1385 if segment_start_sequence != seeded_info.starting_sequence
1386 || anchor_hash != seeded_info.anchor_hash
1387 || genesis_entries != seeded_info.genesis_entries
1388 {
1389 return Err(format!(
1390 "post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
1391 {}) disagrees with the transferred segment seed (start {}, genesis {}) — \
1392 inconsistent primary",
1393 describe_genesis(genesis_entries),
1394 seeded_info.starting_sequence,
1395 describe_genesis(seeded_info.genesis_entries),
1396 )
1397 .into());
1398 }
1399 fence_state.observe_epoch(epoch);
1403 control
1404 .primary_ack_policy
1405 .store(ack_policy, Ordering::Release);
1406 tracing::info!(
1407 start_sequence,
1408 epoch,
1409 ack_policy,
1410 "streaming resumed after snapshot transfer"
1411 );
1412 control
1415 .journal_tip
1416 .reset(melin_transport_core::WireSeq::new(snap_sequence));
1417 Ok(ResyncDecision::Ready {
1418 segment_start_sequence,
1419 anchor_hash,
1420 genesis_entries,
1421 resume_sequence: snap_sequence,
1422 })
1423 }
1424 other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1425 }
1426}
1427
1428fn describe_genesis(genesis_entries: Option<u64>) -> String {
1431 match genesis_entries {
1432 Some(n) => format!("{n} entries"),
1433 None => "unknown".to_string(),
1434 }
1435}
1436
1437pub(in crate::replication) fn local_lineage_genesis<A, W>(
1448 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1449 journal_writer: &Option<W>,
1450) -> Result<Option<Option<u64>>, melin_journal::JournalError>
1451where
1452 A: Application,
1453 W: JournalWrite<A::Event> + Send + 'static,
1454{
1455 match (pipeline, journal_writer) {
1456 (Some(p), _) => Ok(Some(p.genesis_entries)),
1457 (None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
1460 (None, None) => Ok(None),
1461 }
1462}
1463
1464fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
1467 journal_path.with_extension("restamp.tmp")
1468}
1469
1470fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
1474 match std::fs::remove_file(restamp_staging_path(journal_path)) {
1475 Ok(()) => Ok(()),
1476 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1478 Err(e) => Err(e),
1479 }
1480}
1481
1482pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
1507 journal_writer: &mut Option<W>,
1508 lineage: (u64, [u8; 32], Option<u64>),
1509) -> Result<(), melin_journal::JournalError>
1510where
1511 E: melin_app::AppEvent,
1512 W: JournalWrite<E>,
1513{
1514 let (segment_start_sequence, anchor_hash, advertised) = lineage;
1515 let Some(writer) = journal_writer.as_ref() else {
1516 return Ok(());
1517 };
1518 if advertised.is_none()
1519 || segment_start_sequence != 1
1520 || writer.segment_starting_sequence() != 1
1521 || writer.next_sequence() != 1
1522 {
1523 return Ok(());
1524 }
1525 let info = writer.read_header_info()?;
1526 if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
1527 return Ok(());
1528 }
1529 let journal_path = writer.path().to_path_buf();
1530 if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
1531 return Ok(());
1532 }
1533
1534 let staging = restamp_staging_path(&journal_path);
1535 remove_restamp_staging(&journal_path)?;
1536 let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
1537 let valid_end = staged.valid_end();
1538 drop(staged);
1539 drop(journal_writer.take());
1541 std::fs::rename(&staging, &journal_path)?;
1542 melin_journal::segment::fsync_parent_dir(&journal_path)?;
1543 *journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
1544 tracing::info!(
1545 journal = %journal_path.display(),
1546 genesis_entries = ?advertised,
1547 "recorded the primary's genesis length in this replica's empty journal"
1548 );
1549 Ok(())
1550}
1551
1552pub(in crate::replication) enum StreamStartVerdict {
1555 StalePrimary,
1560 Follow((u64, [u8; 32], Option<u64>)),
1564}
1565
1566pub(in crate::replication) struct StreamStart {
1570 pub start_sequence: u64,
1572 pub lineage: (u64, [u8; 32], Option<u64>),
1576 pub epoch: u64,
1578 pub ack_policy: u8,
1580}
1581
1582pub(in crate::replication) fn accept_stream_start<A, W>(
1601 start: StreamStart,
1602 transport: &'static str,
1603 pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1604 journal_writer: &mut Option<W>,
1605 fence_state: &melin_transport_core::fence::FenceState,
1606 control: &ReplicaControlPlane,
1607) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
1608where
1609 A: Application,
1610 W: JournalWrite<A::Event> + Send + 'static,
1611{
1612 let StreamStart {
1613 start_sequence,
1614 lineage,
1615 epoch,
1616 ack_policy,
1617 } = start;
1618 if fence_state.refuses_primary(epoch) {
1619 tracing::warn!(
1620 transport,
1621 primary_epoch = epoch,
1622 our_epoch = fence_state.epoch(),
1623 "primary is behind our fencing epoch — refusing to follow stale primary"
1624 );
1625 return Ok(StreamStartVerdict::StalePrimary);
1626 }
1627 if pipeline.is_none() {
1630 adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
1631 }
1632 if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
1633 check_advertised_genesis(local, lineage.2)?;
1634 }
1635 fence_state.observe_epoch(epoch);
1636 control
1637 .primary_ack_policy
1638 .store(ack_policy, Ordering::Release);
1639 tracing::info!(
1640 transport,
1641 start_sequence,
1642 epoch,
1643 ack_policy,
1644 "streaming started"
1645 );
1646 Ok(StreamStartVerdict::Follow(lineage))
1647}
1648
1649pub(in crate::replication) fn check_advertised_genesis(
1664 local: Option<u64>,
1665 advertised: Option<u64>,
1666) -> Result<(), String> {
1667 match (local, advertised) {
1668 (Some(local), Some(advertised)) if local != advertised => Err(format!(
1669 "refusing to follow the primary: its journal records a genesis of {advertised} \
1670 entries but this replica's records {local} — the two journals do not share one \
1671 lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
1672 then resync this replica from scratch (move its journal, archives and snapshots \
1673 aside) or restore the primary"
1674 )),
1675 _ => Ok(()),
1676 }
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681 use std::io::Write;
1682 use std::sync::Arc;
1683
1684 use super::auth::{authenticate_replica, authenticate_with_primary};
1685 use super::*;
1686 use counter_server::CounterEvent;
1692 type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1693 use melin_transport_core::replication::protocol::{
1694 MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
1695 MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
1696 decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
1697 encode_auth_failed, encode_auth_ok, encode_chain_check, encode_challenge,
1698 encode_challenge_response, encode_handshake, encode_hash_mismatch, encode_heartbeat,
1699 encode_input_batch, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1700 encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1701 read_frame, try_decode_input_batch,
1702 };
1703
1704 fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1708 let slot = InputSlot {
1709 connection_id: 0,
1710 key_hash: 0,
1711 sequence: end_sequence,
1712 timestamp_ns: 0,
1713 event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1714 publish_ts: Default::default(),
1715 recv_ts: Default::default(),
1716 };
1717 encode_input_batch(&[slot], buf).expect("encode InputBatch");
1718 }
1719
1720 #[test]
1721 fn handshake_encode_decode_round_trip() {
1722 let handshake = Handshake {
1723 last_sequence: 42,
1724 chain_hash: [0xAB; 32],
1725 epoch: 9,
1727 };
1728 let mut buf = Vec::new();
1729 encode_handshake(&handshake, &mut buf);
1730
1731 let payload = &buf[4..];
1733 let msg = decode_replica_message(payload).unwrap();
1734 match msg {
1735 ReplicaMessage::Handshake(h) => {
1736 assert_eq!(h.last_sequence, 42);
1737 assert_eq!(h.chain_hash, [0xAB; 32]);
1738 assert_eq!(h.epoch, 9);
1739 }
1740 _ => panic!("expected Handshake"),
1741 }
1742 }
1743
1744 #[test]
1745 fn ack_encode_decode_round_trip() {
1746 let ack = Ack {
1747 acked_sequence: 1000,
1748 in_memory_sequence: 1024,
1749 };
1750 let mut buf = Vec::new();
1751 encode_ack(&ack, &mut buf);
1752
1753 let payload = &buf[4..];
1754 let msg = decode_replica_message(payload).unwrap();
1755 match msg {
1756 ReplicaMessage::Ack(a) => {
1757 assert_eq!(a.acked_sequence, 1000);
1758 assert_eq!(a.in_memory_sequence, 1024);
1759 }
1760 _ => panic!("expected Ack"),
1761 }
1762 }
1763
1764 #[test]
1772 fn ack_wire_byte_pattern() {
1773 let ack = Ack {
1774 acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1775 in_memory_sequence: 0x1122_3344_5566_7788,
1776 };
1777 let mut buf = Vec::new();
1778 encode_ack(&ack, &mut buf);
1779 let expected: &[u8] = &[
1782 0x11, 0x00, 0x00, 0x00, 0x02, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, ];
1787 assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1788 }
1789
1790 #[test]
1794 fn an_advertised_genesis_length_must_match_a_known_one() {
1795 check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1796 check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1797 let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1798 assert!(err.contains("refusing to follow the primary"), "{err}");
1799 assert!(
1800 err.contains("genesis of 4") && err.contains("records 3"),
1801 "{err}"
1802 );
1803 check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1804 check_advertised_genesis(None, Some(3)).expect("ours unknown");
1805 check_advertised_genesis(Some(3), None).expect("theirs unknown");
1806 check_advertised_genesis(None, None).expect("both unknown");
1807 }
1808
1809 #[test]
1814 fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1815 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1816 let dir = tempfile::tempdir().expect("tempdir");
1817 let anchor = [9u8; 32];
1818 let header = |w: &Option<Writer>| {
1819 w.as_ref()
1820 .expect("writer")
1821 .read_header_info()
1822 .expect("header")
1823 .genesis_entries
1824 };
1825
1826 let path = dir.path().join("empty.journal");
1828 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1829 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1830 assert_eq!(header(&w), Some(3));
1831 let adopted = w.as_ref().expect("writer");
1832 assert_eq!(adopted.next_sequence(), 1);
1833 assert_eq!(adopted.path(), path.as_path());
1834 assert_eq!(
1835 melin_journal::segment::read_header_info(&path)
1836 .expect("on disk")
1837 .anchor_hash,
1838 anchor
1839 );
1840 assert!(!path.with_extension("restamp.tmp").exists());
1841
1842 let path = dir.path().join("both-unknown.journal");
1844 let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1845 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1846 assert_eq!(header(&w), None);
1847
1848 let path = dir.path().join("other-anchor.journal");
1850 let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1851 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1852 assert_eq!(header(&w), None);
1853
1854 let path = dir.path().join("known.journal");
1857 let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1858 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1859 assert_eq!(header(&w), Some(2));
1860
1861 let path = dir.path().join("history.journal");
1863 let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1864 let seq = writer.allocate_sequence();
1865 writer
1866 .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1867 .expect("encode");
1868 writer.flush_batch_sync().expect("flush");
1869 let mut w = Some(writer);
1870 adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1871 assert_eq!(header(&w), None);
1872
1873 let mut none: Option<Writer> = None;
1875 adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1876 assert!(none.is_none());
1877 }
1878
1879 #[test]
1883 fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1884 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1885 let dir = tempfile::tempdir().expect("tempdir");
1886 let fence = melin_transport_core::fence::FenceState::new(0);
1887 let journal = dir.path().join("replica.journal");
1888 let snapshot = dir.path().join("replica.snapshot");
1889 let staging = journal.with_extension("restamp.tmp");
1890
1891 std::fs::write(&staging, b"debris").expect("write staging");
1893 let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1894 &journal,
1895 &snapshot,
1896 &fence,
1897 &(),
1898 )
1899 .expect("recover");
1900 assert!(app.is_none() && writer.is_none());
1901 assert!(!staging.exists(), "swept without a journal");
1902
1903 let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1905 let seq = w.allocate_sequence();
1906 w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1907 .expect("encode");
1908 w.flush_batch_sync().expect("flush");
1909 drop(w);
1910 std::fs::write(&staging, b"debris").expect("write staging");
1911 let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
1912 &journal,
1913 &snapshot,
1914 &fence,
1915 &(),
1916 )
1917 .expect("recover");
1918 assert!(writer.is_some());
1919 assert_eq!(last, 1);
1920 assert!(!staging.exists(), "swept beside a journal with history");
1921 }
1922
1923 #[test]
1927 fn a_stale_primary_is_refused_before_anything_is_adopted() {
1928 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1929 let dir = tempfile::tempdir().expect("tempdir");
1930 let path = dir.path().join("replica.journal");
1931 let anchor = [9u8; 32];
1932 let mut journal_writer =
1933 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1934 let fence = melin_transport_core::fence::FenceState::new(5);
1935 let control = ReplicaControlPlane::new();
1936
1937 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
1938 StreamStart {
1939 start_sequence: 1,
1940 lineage: (1, anchor, Some(3)),
1941 epoch: 4,
1942 ack_policy: crate::ack_policy::AckPolicy::Disk.as_u8(),
1943 },
1944 "test",
1945 &None,
1946 &mut journal_writer,
1947 &fence,
1948 &control,
1949 )
1950 .expect("a refusal is not an error");
1951
1952 assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
1953 assert_eq!(fence.epoch(), 5);
1954 assert_eq!(
1955 control.primary_ack_policy.load(Ordering::Acquire),
1956 crate::ack_policy::ACK_POLICY_UNKNOWN
1957 );
1958 let header = journal_writer
1959 .as_ref()
1960 .expect("writer")
1961 .read_header_info()
1962 .expect("header");
1963 assert_eq!(header.genesis_entries, None, "journal left untouched");
1964 }
1965
1966 #[test]
1970 fn following_a_primary_adopts_its_epoch_policy_and_genesis() {
1971 type Writer = melin_journal::BufferedWriter<CounterEvent>;
1972 let dir = tempfile::tempdir().expect("tempdir");
1973 let path = dir.path().join("replica.journal");
1974 let anchor = [9u8; 32];
1975 let mut journal_writer =
1976 Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1977 let fence = melin_transport_core::fence::FenceState::new(5);
1978 let control = ReplicaControlPlane::new();
1979 let policy = crate::ack_policy::AckPolicy::Disk.as_u8();
1980
1981 let verdict = accept_stream_start::<counter_server::Counter, Writer>(
1982 StreamStart {
1983 start_sequence: 1,
1984 lineage: (1, anchor, Some(3)),
1985 epoch: 7,
1986 ack_policy: policy,
1987 },
1988 "test",
1989 &None,
1990 &mut journal_writer,
1991 &fence,
1992 &control,
1993 )
1994 .expect("same lineage");
1995
1996 let StreamStartVerdict::Follow(lineage) = verdict else {
1997 panic!("expected Follow");
1998 };
1999 assert_eq!(lineage, (1, anchor, Some(3)));
2000 assert_eq!(fence.epoch(), 7);
2001 assert_eq!(control.primary_ack_policy.load(Ordering::Acquire), policy);
2002 let header = journal_writer
2003 .as_ref()
2004 .expect("writer")
2005 .read_header_info()
2006 .expect("header");
2007 assert_eq!(header.genesis_entries, Some(3));
2008 }
2009
2010 #[test]
2013 fn a_disagreeing_genesis_length_is_fatal() {
2014 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2015 let dir = tempfile::tempdir().expect("tempdir");
2016 let path = dir.path().join("replica.journal");
2017 let anchor = [9u8; 32];
2018 let mut journal_writer =
2019 Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2020 let fence = melin_transport_core::fence::FenceState::new(5);
2021 let control = ReplicaControlPlane::new();
2022
2023 let err = accept_stream_start::<counter_server::Counter, Writer>(
2024 StreamStart {
2025 start_sequence: 1,
2026 lineage: (1, anchor, Some(3)),
2027 epoch: 7,
2028 ack_policy: crate::ack_policy::AckPolicy::Disk.as_u8(),
2029 },
2030 "test",
2031 &None,
2032 &mut journal_writer,
2033 &fence,
2034 &control,
2035 )
2036 .err()
2037 .expect("different lineages");
2038
2039 assert!(err.to_string().contains("refusing to follow the primary"));
2040 assert_eq!(fence.epoch(), 5);
2041 }
2042
2043 #[test]
2050 fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2051 type Writer = melin_journal::BufferedWriter<CounterEvent>;
2052 let dir = tempfile::tempdir().expect("tempdir");
2053 let path = dir.path().join("replica.journal");
2054
2055 let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2056 let mut journal_writer = Some(writer);
2057 assert_eq!(
2058 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2059 .expect("header read"),
2060 Some(Some(5)),
2061 );
2062
2063 let (input_producer, mut consumers) =
2064 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2065 .add_consumer()
2066 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2067 let _consumer = consumers.pop().expect("one consumer");
2068 let owned = journal_writer.take().expect("writer");
2069 let pipeline = Some(ReplicaPipelineHandles {
2070 input_producer,
2071 journal_cursor: Arc::new(make_journal_cursor(0)),
2072 chain_hash_lock: fsync_state_at(0, 0).1,
2073 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2074 genesis_entries: Some(5),
2075 journal_failed: Arc::new(AtomicBool::new(false)),
2076 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2077 journal_handle: std::thread::spawn(
2078 move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2079 ),
2080 matching_handle: std::thread::spawn(counter_server::Counter::default),
2081 drain_handle: std::thread::spawn(|| {}),
2082 shadow_handle: None,
2083 });
2084 std::fs::remove_file(&path).expect("simulate the rotation gap");
2085 assert_eq!(
2086 local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2087 Some(Some(5)),
2088 );
2089
2090 assert_eq!(
2091 local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2092 .expect("nothing to read"),
2093 None,
2094 );
2095 }
2096
2097 #[test]
2098 fn stream_start_encode_decode_round_trip() {
2099 let mut buf = Vec::new();
2100 encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, 2, &mut buf);
2102
2103 let payload = &buf[4..];
2104 let msg = decode_primary_message(payload).unwrap();
2105 match msg {
2106 PrimaryMessage::StreamStart {
2107 start_sequence,
2108 segment_start_sequence,
2109 anchor_hash,
2110 genesis_entries,
2111 epoch,
2112 ack_policy,
2113 } => {
2114 assert_eq!(start_sequence, 99);
2115 assert_eq!(segment_start_sequence, 42);
2116 assert_eq!(anchor_hash, [0xAA; 32]);
2117 assert_eq!(genesis_entries, Some(7));
2118 assert_eq!(ack_policy, 2);
2119 assert_eq!(epoch, 5);
2120 }
2121 _ => panic!("expected StreamStart"),
2122 }
2123 }
2124
2125 #[test]
2126 fn heartbeat_encode_decode_round_trip() {
2127 let mut buf = Vec::new();
2128 encode_heartbeat(123, 2, &mut buf);
2129
2130 let payload = &buf[4..];
2131 let msg = decode_primary_message(payload).unwrap();
2132 match msg {
2133 PrimaryMessage::Heartbeat {
2134 sequence,
2135 ack_policy,
2136 } => {
2137 assert_eq!(sequence, 123);
2138 assert_eq!(ack_policy, 2);
2139 }
2140 _ => panic!("expected Heartbeat"),
2141 }
2142 }
2143
2144 #[test]
2145 fn need_snapshot_encode_decode_round_trip() {
2146 let mut buf = Vec::new();
2147 encode_need_snapshot(&mut buf);
2148
2149 let payload = &buf[4..];
2150 let msg = decode_primary_message(payload).unwrap();
2151 assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2152 }
2153
2154 #[test]
2155 fn hash_mismatch_encode_decode_round_trip() {
2156 let mut buf = Vec::new();
2157 encode_hash_mismatch(&mut buf);
2158
2159 let payload = &buf[4..];
2160 let msg = decode_primary_message(payload).unwrap();
2161 assert!(matches!(msg, PrimaryMessage::HashMismatch));
2162 }
2163
2164 #[test]
2165 fn rotate_encode_decode_round_trip() {
2166 let mut buf = Vec::new();
2167 encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2168
2169 let payload = &buf[4..];
2170 let msg = decode_primary_message(payload).unwrap();
2171 match msg {
2172 PrimaryMessage::Rotate {
2173 boundary_seq,
2174 tail_hash,
2175 } => {
2176 assert_eq!(boundary_seq, 7_000_000);
2177 assert_eq!(tail_hash, [0xBB; 32]);
2178 }
2179 _ => panic!("expected Rotate"),
2180 }
2181 }
2182
2183 #[test]
2184 fn chain_check_encode_decode_round_trip() {
2185 let mut buf = Vec::new();
2186 encode_chain_check(555, &[0xCC; 32], &mut buf);
2187
2188 let payload = &buf[4..];
2189 let msg = decode_primary_message(payload).unwrap();
2190 match msg {
2191 PrimaryMessage::ChainCheck {
2192 sequence,
2193 chain_hash,
2194 } => {
2195 assert_eq!(sequence, 555);
2196 assert_eq!(chain_hash, [0xCC; 32]);
2197 }
2198 _ => panic!("expected ChainCheck"),
2199 }
2200 }
2201
2202 #[test]
2206 fn rotate_wire_byte_pattern() {
2207 let mut hash = [0u8; 32];
2208 for (i, b) in hash.iter_mut().enumerate() {
2209 *b = i as u8;
2210 }
2211 let mut buf = Vec::new();
2212 encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2213 let mut expected = vec![
2216 0x29, 0x00, 0x00, 0x00, 0x16, 0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, ];
2220 expected.extend_from_slice(&hash);
2221 assert_eq!(
2222 buf.as_slice(),
2223 expected.as_slice(),
2224 "Rotate wire layout drifted"
2225 );
2226 }
2227
2228 #[test]
2229 fn segment_seed_begin_encode_decode_round_trip() {
2230 let mut buf = Vec::new();
2231 encode_segment_seed_begin(123_456, &mut buf);
2232
2233 let payload = &buf[4..];
2234 let msg = decode_primary_message(payload).unwrap();
2235 match msg {
2236 PrimaryMessage::SegmentSeedBegin { seed_len } => {
2237 assert_eq!(seed_len, 123_456);
2238 }
2239 _ => panic!("expected SegmentSeedBegin"),
2240 }
2241 }
2242
2243 #[test]
2244 fn unknown_replica_message_type_is_error() {
2245 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2246 let result = decode_replica_message(&payload);
2247 assert!(result.is_err());
2248 }
2249
2250 #[test]
2251 fn unknown_primary_message_type_is_error() {
2252 let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2253 let result = decode_primary_message(&payload);
2254 assert!(result.is_err());
2255 }
2256
2257 #[test]
2258 fn read_frame_enforces_max_size() {
2259 let mut buf = Vec::new();
2261 buf.extend_from_slice(&1000u32.to_le_bytes());
2262 buf.extend_from_slice(&[0u8; 100]); let mut cursor = std::io::Cursor::new(buf);
2265 let result = read_frame(&mut cursor, 64);
2266 assert!(result.is_err());
2267 }
2268
2269 #[test]
2270 fn challenge_encode_decode_round_trip() {
2271 let nonce = [0x42; 32];
2272 let mut buf = Vec::new();
2273 encode_challenge(&nonce, &mut buf);
2274
2275 let payload = &buf[4..];
2276 let decoded = decode_challenge(payload).unwrap();
2277 assert_eq!(decoded, nonce);
2278 }
2279
2280 #[test]
2281 fn challenge_response_encode_decode_round_trip() {
2282 let sig = [0xAA; 64];
2283 let pubkey = [0xBB; 32];
2284 let mut buf = Vec::new();
2285 encode_challenge_response(&sig, &pubkey, &mut buf);
2286
2287 let payload = &buf[4..];
2288 let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2289 assert_eq!(decoded_sig, sig);
2290 assert_eq!(decoded_pubkey, pubkey);
2291 }
2292
2293 #[test]
2294 fn auth_ok_encode_decode_round_trip() {
2295 let mut buf = Vec::new();
2296 encode_auth_ok(&mut buf);
2297
2298 let payload = &buf[4..];
2299 assert!(decode_auth_result(payload).unwrap());
2300 }
2301
2302 #[test]
2303 fn auth_failed_encode_decode_round_trip() {
2304 let mut buf = Vec::new();
2305 encode_auth_failed(&mut buf);
2306
2307 let payload = &buf[4..];
2308 assert!(!decode_auth_result(payload).unwrap());
2309 }
2310
2311 #[test]
2312 fn decode_challenge_rejects_wrong_tag() {
2313 let mut payload = [0u8; 33];
2314 payload[0] = MSG_AUTH_OK;
2315 assert!(decode_challenge(&payload).is_err());
2316 }
2317
2318 #[test]
2319 fn decode_challenge_response_rejects_short_payload() {
2320 let payload = [MSG_CHALLENGE_RESPONSE; 10]; assert!(decode_challenge_response(&payload).is_err());
2322 }
2323
2324 #[test]
2325 fn auth_round_trip_valid_key() {
2326 use ed25519_dalek::SigningKey;
2327 use std::os::unix::net::UnixStream;
2328
2329 let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2330 let pub_b64 = base64::Engine::encode(
2331 &base64::engine::general_purpose::STANDARD,
2332 repl_key.verifying_key().to_bytes(),
2333 );
2334 let keys_content = format!("replication {pub_b64} test-replica\n");
2335 let authorized_keys =
2336 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2337 .unwrap();
2338
2339 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2340 primary_stream
2341 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2342 .unwrap();
2343 replica_stream
2344 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2345 .unwrap();
2346
2347 let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2348 let replica_handle = std::thread::spawn(move || {
2349 let mut conn = replica_stream;
2350 authenticate_with_primary(&mut conn, &repl_key_clone)
2351 });
2352
2353 let mut conn = primary_stream;
2354 authenticate_replica(&mut conn, &authorized_keys).unwrap();
2355
2356 replica_handle.join().unwrap().unwrap();
2357 }
2358
2359 #[test]
2360 fn auth_rejects_unknown_key() {
2361 use ed25519_dalek::SigningKey;
2362 use std::os::unix::net::UnixStream;
2363
2364 let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2366 let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2367 let pub_b64 = base64::Engine::encode(
2368 &base64::engine::general_purpose::STANDARD,
2369 authorized_key.verifying_key().to_bytes(),
2370 );
2371 let keys_content = format!("replication {pub_b64} authorized-replica\n");
2372 let authorized_keys =
2373 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2374 .unwrap();
2375
2376 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2377 primary_stream
2378 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2379 .unwrap();
2380 replica_stream
2381 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2382 .unwrap();
2383
2384 let replica_handle = std::thread::spawn(move || {
2385 let mut conn = replica_stream;
2386 authenticate_with_primary(&mut conn, &rogue_key)
2387 });
2388
2389 let mut conn = primary_stream;
2390 let result = authenticate_replica(&mut conn, &authorized_keys);
2391 assert!(result.is_err());
2392 assert!(result.unwrap_err().to_string().contains("unknown"));
2393
2394 let replica_result = replica_handle.join().unwrap();
2396 assert!(replica_result.is_err());
2397 }
2398
2399 #[test]
2400 fn auth_rejects_wrong_permission() {
2401 use ed25519_dalek::SigningKey;
2402 use std::os::unix::net::UnixStream;
2403
2404 let key = SigningKey::from_bytes(&[0xCC; 32]);
2406 let pub_b64 = base64::Engine::encode(
2407 &base64::engine::general_purpose::STANDARD,
2408 key.verifying_key().to_bytes(),
2409 );
2410 let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2411
2412 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2413 primary_stream
2414 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2415 .unwrap();
2416 replica_stream
2417 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2418 .unwrap();
2419
2420 let replica_handle = std::thread::spawn(move || {
2421 let mut conn = replica_stream;
2422 authenticate_with_primary(&mut conn, &key)
2423 });
2424
2425 let mut conn = primary_stream;
2426 let result = authenticate_replica(&mut conn, &authorized_keys);
2427 assert!(result.is_err());
2428 assert_eq!(
2429 result.unwrap_err().to_string(),
2430 "key listed as trader, expected replication"
2431 );
2432
2433 let replica_result = replica_handle.join().unwrap();
2434 assert!(replica_result.is_err());
2435 }
2436
2437 #[test]
2440 fn auth_rejects_invalid_signature() {
2441 use ed25519_dalek::SigningKey;
2442 use std::os::unix::net::UnixStream;
2443
2444 let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2446 let pub_b64 = base64::Engine::encode(
2447 &base64::engine::general_purpose::STANDARD,
2448 correct_key.verifying_key().to_bytes(),
2449 );
2450 let keys_content = format!("replication {pub_b64} test-replica\n");
2451 let authorized_keys =
2452 melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2453 .unwrap();
2454
2455 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2456 primary_stream
2457 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2458 .unwrap();
2459 replica_stream
2460 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2461 .unwrap();
2462
2463 let replica_handle = std::thread::spawn(move || {
2466 use melin_transport_core::replication::protocol::*;
2467
2468 let mut reader = replica_stream.try_clone().unwrap();
2469 let mut writer = replica_stream;
2470
2471 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2473 let nonce = decode_challenge(&frame).unwrap();
2474
2475 let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2477 let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2478 let correct_pubkey = correct_key.verifying_key();
2479
2480 let mut buf = Vec::with_capacity(128);
2481 encode_challenge_response(
2482 &bad_signature.to_bytes(),
2483 correct_pubkey.as_bytes(),
2484 &mut buf,
2485 );
2486 writer.write_all(&buf).unwrap();
2487 writer.flush().unwrap();
2488
2489 let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2491 let ok = decode_auth_result(&result_frame).unwrap();
2492 assert!(!ok, "should receive auth failure");
2493 });
2494
2495 let mut conn = primary_stream;
2496 let result = authenticate_replica(&mut conn, &authorized_keys);
2497 assert!(result.is_err());
2498 assert!(
2499 result
2500 .unwrap_err()
2501 .to_string()
2502 .contains("signature verification failed"),
2503 "should fail on signature verification"
2504 );
2505
2506 replica_handle.join().unwrap();
2507 }
2508
2509 #[test]
2510 fn sender_receiver_end_to_end() {
2511 use std::os::unix::net::UnixStream;
2512
2513 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2515
2516 let replica_handle = std::thread::spawn(move || {
2518 let mut reader = replica_stream.try_clone().unwrap();
2519 let mut writer = replica_stream;
2520
2521 let mut buf = Vec::new();
2523 let handshake = Handshake {
2524 last_sequence: 0,
2525 chain_hash: [0u8; 32],
2526 epoch: 0,
2527 };
2528 encode_handshake(&handshake, &mut buf);
2529 writer.write_all(&buf).unwrap();
2530 writer.flush().unwrap();
2531 buf.clear();
2532
2533 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2535 let msg = decode_primary_message(&frame).unwrap();
2536 assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2537
2538 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2540 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2541 let end_seq = slots
2542 .last()
2543 .map(|s| s.sequence)
2544 .expect("InputBatch carried at least one slot");
2545
2546 let ack = Ack {
2548 acked_sequence: end_seq,
2549 in_memory_sequence: end_seq,
2550 };
2551 encode_ack(&ack, &mut buf);
2552 writer.write_all(&buf).unwrap();
2553 writer.flush().unwrap();
2554
2555 end_seq
2556 });
2557
2558 let mut p_reader = primary_stream.try_clone().unwrap();
2560 let mut p_writer = primary_stream;
2561
2562 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2564 let handshake = match decode_replica_message(&frame).unwrap() {
2565 ReplicaMessage::Handshake(h) => h,
2566 _ => panic!("expected Handshake"),
2567 };
2568 assert_eq!(handshake.last_sequence, 0);
2569
2570 let mut buf = Vec::new();
2572 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, 1, &mut buf); p_writer.write_all(&buf).unwrap();
2574 p_writer.flush().unwrap();
2575 buf.clear();
2576
2577 encode_input_batch_with_seq(42, &mut buf);
2579 p_writer.write_all(&buf).unwrap();
2580 p_writer.flush().unwrap();
2581 buf.clear();
2582
2583 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2585 let ack = match decode_replica_message(&frame).unwrap() {
2586 ReplicaMessage::Ack(a) => a,
2587 _ => panic!("expected Ack"),
2588 };
2589 assert_eq!(ack.acked_sequence, 42);
2590
2591 let end_seq = replica_handle.join().unwrap();
2593 assert_eq!(end_seq, 42);
2594 }
2595
2596 #[test]
2597 fn multiple_data_batches_acked_in_order() {
2598 use std::os::unix::net::UnixStream;
2601
2602 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2603
2604 let replica_handle = std::thread::spawn(move || {
2605 let mut reader = replica_stream.try_clone().unwrap();
2606 let mut writer = replica_stream;
2607 let mut buf = Vec::new();
2608
2609 encode_handshake(
2611 &Handshake {
2612 last_sequence: 0,
2613 chain_hash: [0u8; 32],
2614 epoch: 0,
2615 },
2616 &mut buf,
2617 );
2618 writer.write_all(&buf).unwrap();
2619 writer.flush().unwrap();
2620 buf.clear();
2621
2622 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2624 assert!(matches!(
2625 decode_primary_message(&frame).unwrap(),
2626 PrimaryMessage::StreamStart { .. }
2627 ));
2628
2629 let mut acked_seqs = Vec::new();
2631 for _ in 0..3 {
2632 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2633 let slots: Vec<InputSlot> =
2634 try_decode_input_batch(&frame).expect("decode InputBatch");
2635 let end_seq = slots
2636 .last()
2637 .map(|s| s.sequence)
2638 .expect("InputBatch carried at least one slot");
2639 acked_seqs.push(end_seq);
2640
2641 encode_ack(
2642 &Ack {
2643 acked_sequence: end_seq,
2644 in_memory_sequence: end_seq,
2645 },
2646 &mut buf,
2647 );
2648 writer.write_all(&buf).unwrap();
2649 writer.flush().unwrap();
2650 buf.clear();
2651 }
2652
2653 acked_seqs
2654 });
2655
2656 let mut p_reader = primary_stream.try_clone().unwrap();
2658 let mut p_writer = primary_stream;
2659 let mut buf = Vec::new();
2660
2661 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2663 assert!(matches!(
2664 decode_replica_message(&frame).unwrap(),
2665 ReplicaMessage::Handshake(_)
2666 ));
2667
2668 encode_stream_start(0, 1, [0u8; 32], Some(0), 0, 1, &mut buf);
2670 p_writer.write_all(&buf).unwrap();
2671 p_writer.flush().unwrap();
2672 buf.clear();
2673
2674 for seq in [10u64, 20, 30] {
2676 encode_input_batch_with_seq(seq, &mut buf);
2677 p_writer.write_all(&buf).unwrap();
2678 p_writer.flush().unwrap();
2679 buf.clear();
2680 }
2681
2682 for expected_seq in [10u64, 20, 30] {
2684 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2685 let ack = match decode_replica_message(&frame).unwrap() {
2686 ReplicaMessage::Ack(a) => a,
2687 other => panic!("expected Ack, got {other:?}"),
2688 };
2689 assert_eq!(ack.acked_sequence, expected_seq);
2690 }
2691
2692 let acked = replica_handle.join().unwrap();
2693 assert_eq!(acked, vec![10, 20, 30]);
2694 }
2695
2696 #[test]
2697 fn heartbeat_encode_contains_sequence() {
2698 let mut buf = Vec::new();
2701 encode_heartbeat(999, 1, &mut buf);
2702
2703 let payload = &buf[4..];
2704 match decode_primary_message(payload).unwrap() {
2705 PrimaryMessage::Heartbeat { sequence, .. } => {
2706 assert_eq!(sequence, 999);
2707 }
2708 other => panic!("expected Heartbeat, got {other:?}"),
2709 }
2710 }
2711
2712 #[test]
2713 fn replica_mid_stream_handshake_with_nonzero_sequence() {
2714 use std::os::unix::net::UnixStream;
2719
2720 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2721
2722 let replica_handle = std::thread::spawn(move || {
2723 let mut reader = replica_stream.try_clone().unwrap();
2724 let mut writer = replica_stream;
2725 let mut buf = Vec::new();
2726
2727 encode_handshake(
2729 &Handshake {
2730 last_sequence: 100,
2731 chain_hash: [0xBB; 32],
2732 epoch: 0,
2733 },
2734 &mut buf,
2735 );
2736 writer.write_all(&buf).unwrap();
2737 writer.flush().unwrap();
2738 buf.clear();
2739
2740 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2742 match decode_primary_message(&frame).unwrap() {
2743 PrimaryMessage::StreamStart { start_sequence, .. } => {
2744 assert_eq!(
2745 start_sequence, 100,
2746 "StreamStart should echo replica's last_sequence"
2747 );
2748 }
2749 other => panic!("expected StreamStart, got {other:?}"),
2750 }
2751
2752 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2754 let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2755 let end_sequence = slots
2756 .last()
2757 .map(|s| s.sequence)
2758 .expect("InputBatch carried at least one slot");
2759 assert!(
2760 end_sequence > 100,
2761 "InputBatch should be after replica's last_sequence"
2762 );
2763 });
2764
2765 let mut p_reader = primary_stream.try_clone().unwrap();
2767 let mut p_writer = primary_stream;
2768 let mut buf = Vec::new();
2769
2770 let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2772 let handshake = match decode_replica_message(&frame).unwrap() {
2773 ReplicaMessage::Handshake(h) => h,
2774 _ => panic!("expected Handshake"),
2775 };
2776 assert_eq!(handshake.last_sequence, 100);
2777 assert_eq!(handshake.chain_hash, [0xBB; 32]);
2778
2779 encode_stream_start(
2781 handshake.last_sequence,
2782 1,
2783 [0u8; 32],
2784 Some(0),
2785 0,
2786 1,
2787 &mut buf,
2788 );
2789 p_writer.write_all(&buf).unwrap();
2790 p_writer.flush().unwrap();
2791 buf.clear();
2792
2793 encode_input_batch_with_seq(150, &mut buf);
2795 p_writer.write_all(&buf).unwrap();
2796 p_writer.flush().unwrap();
2797
2798 replica_handle.join().unwrap();
2799 }
2800
2801 #[test]
2816 fn receiver_refuses_stream_start_from_stale_primary() {
2817 use std::os::unix::net::UnixStream;
2818
2819 use melin_transport_core::fence::FenceState;
2820
2821 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2822
2823 const STALE_EPOCH: u64 = 3;
2827 let primary_handle = std::thread::spawn(move || {
2828 let mut reader = primary_stream.try_clone().unwrap();
2829 let mut writer = primary_stream;
2830
2831 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2832 let handshake = match decode_replica_message(&frame).unwrap() {
2833 ReplicaMessage::Handshake(h) => h,
2834 _ => panic!("expected Handshake"),
2835 };
2836 assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2839
2840 let mut buf = Vec::new();
2841 encode_stream_start(
2842 handshake.last_sequence,
2843 1,
2844 [0u8; 32],
2845 Some(0),
2846 STALE_EPOCH,
2847 1,
2848 &mut buf,
2849 );
2850 writer.write_all(&buf).unwrap();
2851 writer.flush().unwrap();
2852 });
2853
2854 let fence_state = Arc::new(FenceState::new(5));
2857 let mut r_reader = replica_stream.try_clone().unwrap();
2858 let mut r_writer = replica_stream;
2859
2860 let mut buf = Vec::new();
2861 encode_handshake(
2862 &Handshake {
2863 last_sequence: 0,
2864 chain_hash: [0u8; 32],
2865 epoch: fence_state.epoch(),
2866 },
2867 &mut buf,
2868 );
2869 r_writer.write_all(&buf).unwrap();
2870 r_writer.flush().unwrap();
2871
2872 let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2873 let stream_epoch = match decode_primary_message(&frame).unwrap() {
2874 PrimaryMessage::StreamStart { epoch, .. } => epoch,
2875 other => panic!("expected StreamStart, got {other:?}"),
2876 };
2877 assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2878
2879 assert!(
2882 fence_state.refuses_primary(stream_epoch),
2883 "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2884 );
2885 assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2886
2887 assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2890 assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2891
2892 primary_handle.join().unwrap();
2893 }
2894
2895 #[test]
2896 fn snapshot_begin_encode_decode_round_trip() {
2897 let mut buf = Vec::new();
2898 encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2899
2900 let payload = &buf[4..];
2901 let msg = decode_primary_message(payload).unwrap();
2902 match msg {
2903 PrimaryMessage::SnapshotBegin {
2904 snapshot_len,
2905 snap_sequence,
2906 snap_chain_hash,
2907 } => {
2908 assert_eq!(snapshot_len, 1_000_000);
2909 assert_eq!(snap_sequence, 42);
2910 assert_eq!(snap_chain_hash, [0xAB; 32]);
2911 }
2912 _ => panic!("expected SnapshotBegin"),
2913 }
2914 }
2915
2916 #[test]
2917 fn snapshot_chunk_encode_decode_round_trip() {
2918 let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
2919 let mut buf = Vec::new();
2920 encode_snapshot_chunk(&data, &mut buf);
2921
2922 let payload = &buf[4..];
2923 let msg = decode_primary_message(payload).unwrap();
2924 match msg {
2925 PrimaryMessage::SnapshotChunk(chunk) => {
2926 assert_eq!(chunk, data);
2927 }
2928 _ => panic!("expected SnapshotChunk"),
2929 }
2930 }
2931
2932 #[test]
2933 fn snapshot_end_encode_decode_round_trip() {
2934 let mut buf = Vec::new();
2935 encode_snapshot_end(0xDEADBEEF, &mut buf);
2936
2937 let payload = &buf[4..];
2938 let msg = decode_primary_message(payload).unwrap();
2939 match msg {
2940 PrimaryMessage::SnapshotEnd { crc32c } => {
2941 assert_eq!(crc32c, 0xDEADBEEF);
2942 }
2943 _ => panic!("expected SnapshotEnd"),
2944 }
2945 }
2946
2947 #[test]
2951 fn snapshot_receiver_detects_length_mismatch() {
2952 use std::os::unix::net::UnixStream;
2953
2954 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2955
2956 let receiver = std::thread::spawn(move || -> String {
2958 let mut reader = replica_stream.try_clone().unwrap();
2959
2960 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2962 assert!(matches!(
2963 decode_primary_message(&frame).unwrap(),
2964 PrimaryMessage::NeedSnapshot,
2965 ));
2966
2967 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2969 let (snap_len, _snap_sequence, _snap_chain_hash) =
2970 match decode_primary_message(&frame).unwrap() {
2971 PrimaryMessage::SnapshotBegin {
2972 snapshot_len,
2973 snap_sequence,
2974 snap_chain_hash,
2975 } => (snapshot_len, snap_sequence, snap_chain_hash),
2976 other => panic!("expected SnapshotBegin, got {other:?}"),
2977 };
2978
2979 let mut received: u64 = 0;
2981 loop {
2982 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2983 match decode_primary_message(&frame).unwrap() {
2984 PrimaryMessage::SnapshotChunk(data) => {
2985 received += data.len() as u64;
2986 }
2987 PrimaryMessage::SnapshotEnd { .. } => {
2988 if received != snap_len {
2989 return format!(
2990 "snapshot length mismatch: expected {snap_len} bytes, got {received}"
2991 );
2992 }
2993 return String::new(); }
2995 other => panic!("unexpected message: {other:?}"),
2996 }
2997 }
2998 });
2999
3000 let mut writer = primary_stream;
3002 let mut buf = Vec::new();
3003
3004 let actual_data = vec![0xAA; 100];
3005 let wrong_len = 999u64; encode_need_snapshot(&mut buf);
3008 std::io::Write::write_all(&mut writer, &buf).unwrap();
3009 buf.clear();
3010
3011 encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3012 std::io::Write::write_all(&mut writer, &buf).unwrap();
3013 buf.clear();
3014
3015 encode_snapshot_chunk(&actual_data, &mut buf);
3016 std::io::Write::write_all(&mut writer, &buf).unwrap();
3017 buf.clear();
3018
3019 let crc = crc32c::crc32c(&actual_data);
3020 encode_snapshot_end(crc, &mut buf);
3021 std::io::Write::write_all(&mut writer, &buf).unwrap();
3022 std::io::Write::flush(&mut writer).unwrap();
3023
3024 let error_msg = receiver.join().unwrap();
3025 assert!(
3026 error_msg.contains("length mismatch"),
3027 "expected length mismatch error, got: {error_msg:?}"
3028 );
3029 }
3030
3031 #[test]
3035 fn snapshot_receiver_detects_crc_mismatch() {
3036 use std::os::unix::net::UnixStream;
3037
3038 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3039
3040 let receiver = std::thread::spawn(move || -> String {
3041 let mut reader = replica_stream.try_clone().unwrap();
3042
3043 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3045 assert!(matches!(
3046 decode_primary_message(&frame).unwrap(),
3047 PrimaryMessage::NeedSnapshot,
3048 ));
3049
3050 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3052 let snap_len = match decode_primary_message(&frame).unwrap() {
3053 PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3054 other => panic!("expected SnapshotBegin, got {other:?}"),
3055 };
3056
3057 let mut received_data = Vec::new();
3059 let mut received: u64 = 0;
3060 loop {
3061 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3062 match decode_primary_message(&frame).unwrap() {
3063 PrimaryMessage::SnapshotChunk(data) => {
3064 received += data.len() as u64;
3065 received_data.extend_from_slice(&data);
3066 }
3067 PrimaryMessage::SnapshotEnd {
3068 crc32c: expected_crc,
3069 } => {
3070 if received != snap_len {
3071 return format!("length mismatch: {snap_len} vs {received}");
3072 }
3073 let actual_crc = crc32c::crc32c(&received_data);
3074 if actual_crc != expected_crc {
3075 return format!(
3076 "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3077 );
3078 }
3079 return String::new();
3080 }
3081 other => panic!("unexpected message: {other:?}"),
3082 }
3083 }
3084 });
3085
3086 let mut writer = primary_stream;
3088 let mut buf = Vec::new();
3089
3090 let data = vec![0xAA; 100];
3091
3092 encode_need_snapshot(&mut buf);
3093 std::io::Write::write_all(&mut writer, &buf).unwrap();
3094 buf.clear();
3095
3096 encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3097 std::io::Write::write_all(&mut writer, &buf).unwrap();
3098 buf.clear();
3099
3100 encode_snapshot_chunk(&data, &mut buf);
3101 std::io::Write::write_all(&mut writer, &buf).unwrap();
3102 buf.clear();
3103
3104 let wrong_crc = !crc32c::crc32c(&data);
3106 encode_snapshot_end(wrong_crc, &mut buf);
3107 std::io::Write::write_all(&mut writer, &buf).unwrap();
3108 std::io::Write::flush(&mut writer).unwrap();
3109
3110 let error_msg = receiver.join().unwrap();
3111 assert!(
3112 error_msg.contains("CRC mismatch"),
3113 "expected CRC mismatch error, got: {error_msg:?}"
3114 );
3115 }
3116
3117 #[test]
3120 fn snapshot_receiver_detects_chain_hash_mismatch() {
3121 use std::os::unix::net::UnixStream;
3122
3123 let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3124
3125 let receiver = std::thread::spawn(move || -> String {
3126 let mut reader = replica_stream.try_clone().unwrap();
3127
3128 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3129 assert!(matches!(
3130 decode_primary_message(&frame).unwrap(),
3131 PrimaryMessage::NeedSnapshot,
3132 ));
3133
3134 let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3135 let (snap_len, _snap_sequence, snap_chain_hash) =
3136 match decode_primary_message(&frame).unwrap() {
3137 PrimaryMessage::SnapshotBegin {
3138 snapshot_len,
3139 snap_sequence,
3140 snap_chain_hash,
3141 } => (snapshot_len, snap_sequence, snap_chain_hash),
3142 other => panic!("expected SnapshotBegin, got {other:?}"),
3143 };
3144
3145 let mut received_data = Vec::new();
3147 let mut received: u64 = 0;
3148 loop {
3149 let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3150 match decode_primary_message(&frame).unwrap() {
3151 PrimaryMessage::SnapshotChunk(data) => {
3152 received += data.len() as u64;
3153 received_data.extend_from_slice(&data);
3154 }
3155 PrimaryMessage::SnapshotEnd {
3156 crc32c: expected_crc,
3157 } => {
3158 assert_eq!(received, snap_len, "length should match");
3159 let actual_crc = crc32c::crc32c(&received_data);
3160 assert_eq!(actual_crc, expected_crc, "CRC should match");
3161 break;
3162 }
3163 other => panic!("unexpected message: {other:?}"),
3164 }
3165 }
3166
3167 let loaded_hash = [0xFF; 32]; if loaded_hash != snap_chain_hash {
3171 return format!(
3172 "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3173 loaded snapshot has {loaded_hash:02x?}"
3174 );
3175 }
3176 String::new()
3177 });
3178
3179 let mut writer = primary_stream;
3182 let mut buf = Vec::new();
3183
3184 let data = vec![0xAA; 64];
3185 let advertised_hash = [0xBB; 32];
3187
3188 encode_need_snapshot(&mut buf);
3189 std::io::Write::write_all(&mut writer, &buf).unwrap();
3190 buf.clear();
3191
3192 encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3193 std::io::Write::write_all(&mut writer, &buf).unwrap();
3194 buf.clear();
3195
3196 encode_snapshot_chunk(&data, &mut buf);
3197 std::io::Write::write_all(&mut writer, &buf).unwrap();
3198 buf.clear();
3199
3200 let crc = crc32c::crc32c(&data);
3201 encode_snapshot_end(crc, &mut buf);
3202 std::io::Write::write_all(&mut writer, &buf).unwrap();
3203 std::io::Write::flush(&mut writer).unwrap();
3204
3205 let error_msg = receiver.join().unwrap();
3206 assert!(
3207 error_msg.contains("chain hash mismatch"),
3208 "expected chain hash mismatch error, got: {error_msg:?}"
3209 );
3210 }
3211
3212 #[test]
3215 fn primary_rejects_snapshot_with_invalid_magic() {
3216 let tmp = tempfile::tempdir().unwrap();
3217 let snap_path = tmp.path().join("test.snapshot");
3218
3219 let mut bad_snap = vec![0u8; 64];
3221 bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); std::fs::write(&snap_path, &bad_snap).unwrap();
3224
3225 let snap_data = std::fs::read(&snap_path).unwrap();
3227 assert!(
3228 snap_data.len() >= 48,
3229 "file should be big enough for header"
3230 );
3231
3232 let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3233 assert_ne!(magic, 0x534E_4150);
3234 assert_eq!(magic, 0xDEAD_BEEF);
3235 }
3236
3237 #[test]
3240 fn primary_rejects_snapshot_too_small_for_header() {
3241 let tmp = tempfile::tempdir().unwrap();
3242 let snap_path = tmp.path().join("test.snapshot");
3243
3244 std::fs::write(&snap_path, [0u8; 20]).unwrap();
3246
3247 let snap_data = std::fs::read(&snap_path).unwrap();
3248 assert!(
3249 snap_data.len() < 48,
3250 "file must be too small for header validation"
3251 );
3252 }
3253
3254 #[test]
3255 fn decode_snapshot_begin_too_short() {
3256 let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3259 let err = decode_primary_message(&payload).unwrap_err();
3260 assert!(
3261 err.to_string().contains("SnapshotBegin too short"),
3262 "expected 'SnapshotBegin too short', got: {err}"
3263 );
3264 }
3265
3266 #[test]
3267 fn decode_snapshot_end_too_short() {
3268 let payload = [MSG_SNAPSHOT_END];
3270 let err = decode_primary_message(&payload).unwrap_err();
3271 assert!(
3272 err.to_string().contains("SnapshotEnd too short"),
3273 "expected 'SnapshotEnd too short', got: {err}"
3274 );
3275 }
3276
3277 #[test]
3278 fn decode_snapshot_chunk_empty_data() {
3279 let payload = [MSG_SNAPSHOT_CHUNK];
3281 let msg = decode_primary_message(&payload).unwrap();
3282 match msg {
3283 PrimaryMessage::SnapshotChunk(data) => {
3284 assert!(data.is_empty());
3285 }
3286 _ => panic!("expected SnapshotChunk"),
3287 }
3288 }
3289
3290 fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3293 melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3294 }
3295
3296 #[test]
3297 fn pending_ack_queue_push_and_pop_ready() {
3298 let mut q = PendingAckQueue::new(8);
3299 assert!(q.is_empty());
3300 assert!(!q.is_full());
3301
3302 q.push(10, 100);
3303 q.push(20, 200);
3304 assert!(!q.is_empty());
3305
3306 let cursor = make_journal_cursor(5);
3308 assert!(q.pop_ready(&cursor).is_none());
3309
3310 cursor.get().store(15, Ordering::Relaxed);
3312 assert_eq!(q.pop_ready(&cursor), Some(100));
3313 assert!(!q.is_empty());
3315
3316 cursor.get().store(25, Ordering::Relaxed);
3318 assert_eq!(q.pop_ready(&cursor), Some(200));
3319 assert!(q.is_empty());
3320 }
3321
3322 #[test]
3323 fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3324 let mut q = PendingAckQueue::new(8);
3328 q.push(10, 100);
3329 q.push(20, 200);
3330 q.push(30, 300);
3331
3332 let cursor = make_journal_cursor(30);
3333 assert_eq!(q.pop_ready(&cursor), Some(300));
3334 assert!(q.is_empty());
3335 }
3336
3337 #[test]
3338 fn pending_ack_queue_capacity_and_full() {
3339 let mut q = PendingAckQueue::new(8);
3340 for i in 0..8 {
3341 assert!(!q.is_full());
3342 q.push(i as u64 + 1, (i + 1) as u64 * 100);
3343 }
3344 assert!(q.is_full());
3345 }
3346
3347 #[test]
3352 fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3353 let mut q = PendingAckQueue::new(4);
3354 for i in 1..=4u64 {
3355 q.push(i * 10, i * 100);
3356 }
3357 assert!(q.is_full());
3358 assert_eq!(q.merged(), 0);
3359
3360 q.push(50, 500);
3362 assert_eq!(q.merged(), 1);
3363 assert_eq!(q.len(), 4, "merge must not grow the queue");
3364
3365 let cursor = make_journal_cursor(40);
3368 assert_eq!(q.pop_ready(&cursor), Some(300));
3369 assert!(!q.is_empty(), "merged entry is not durable yet");
3370
3371 cursor.get().store(50, Ordering::Relaxed);
3374 assert_eq!(q.pop_ready(&cursor), Some(500));
3375 assert!(q.is_empty());
3376 }
3377
3378 #[test]
3381 fn pending_ack_queue_merges_repeatedly_without_growing() {
3382 let mut q = PendingAckQueue::new(2);
3383 for i in 1..=100u64 {
3384 q.push(i, i * 10);
3385 }
3386 assert_eq!(q.len(), 2);
3387 assert_eq!(q.merged(), 98);
3388
3389 let cursor = make_journal_cursor(99);
3391 assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3392 cursor.get().store(100, Ordering::Relaxed);
3394 assert_eq!(q.pop_ready(&cursor), Some(1000));
3395 }
3396
3397 #[test]
3398 fn pending_ack_queue_pop_oldest_blocking() {
3399 let mut q = PendingAckQueue::new(8);
3400 q.push(10, 100);
3401 q.push(20, 200);
3402
3403 let cursor = make_journal_cursor(25);
3406 let seq = q.pop_oldest_blocking(
3407 &cursor,
3408 melin_pipeline::wait::WaitStrategy::BusySpin,
3409 &AtomicBool::new(false),
3410 );
3411 assert_eq!(seq, Some(200));
3413 assert!(q.is_empty());
3414 }
3415
3416 #[test]
3419 fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3420 let mut q = PendingAckQueue::new(8);
3421 q.push(10, 100);
3422
3423 let cursor = make_journal_cursor(5);
3425 let abort = AtomicBool::new(true);
3426 assert_eq!(
3427 q.pop_oldest_blocking(
3428 &cursor,
3429 melin_pipeline::wait::WaitStrategy::BusySpin,
3430 &abort
3431 ),
3432 None
3433 );
3434 assert!(!q.is_empty(), "aborted wait must not pop the entry");
3435 assert_eq!(
3436 q.pop_all_blocking(
3437 &cursor,
3438 melin_pipeline::wait::WaitStrategy::BusySpin,
3439 &abort
3440 ),
3441 None
3442 );
3443 }
3444
3445 #[test]
3454 fn shutdown_pipeline_surfaces_journal_error_kind() {
3455 let flag = AtomicBool::new(false);
3456 let matching_joined = Arc::new(AtomicBool::new(false));
3457 let mj = Arc::clone(&matching_joined);
3458
3459 let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3460 Err(melin_journal::JournalError::ReplicaChainDivergence {
3461 sequence: 42,
3462 expected: [1u8; 32],
3463 actual: [2u8; 32],
3464 })
3465 });
3466 let matching = std::thread::spawn(move || {
3467 mj.store(true, Ordering::Release);
3468 7u64
3469 });
3470 let drain = std::thread::spawn(|| {});
3471
3472 let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3473 assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3474 assert!(
3475 matching_joined.load(Ordering::Acquire),
3476 "matching thread must be joined even when the journal failed"
3477 );
3478 match outcome {
3479 TeardownOutcome::JournalFailed(
3480 melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3481 ) => assert_eq!(sequence, 42),
3482 _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3483 }
3484 }
3485
3486 #[test]
3487 fn shutdown_pipeline_clean_returns_both_states() {
3488 let flag = AtomicBool::new(false);
3489 let journal =
3490 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3491 let matching = std::thread::spawn(|| 7u64);
3492 let drain = std::thread::spawn(|| {});
3493
3494 match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3495 TeardownOutcome::Clean(app, writer) => {
3496 assert_eq!(app, 7);
3497 assert_eq!(writer, 11);
3498 }
3499 _ => panic!("expected Clean"),
3500 }
3501 }
3502
3503 #[test]
3504 fn shutdown_pipeline_panicked_stage_reports_panicked() {
3505 let flag = AtomicBool::new(false);
3506 let journal =
3507 std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3508 let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3509 let drain = std::thread::spawn(|| {});
3510
3511 assert!(matches!(
3512 shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3513 TeardownOutcome::Panicked
3514 ));
3515 }
3516
3517 fn teardown_fixture(
3522 capacity: usize,
3523 ) -> (
3524 ReplicaPipelineHandles<counter_server::Counter, u32>,
3525 melin_pipeline::ring::Consumer<InputSlot>,
3526 ) {
3527 handles_fixture(capacity, fsync_state_at(0, 0).1)
3528 }
3529
3530 fn fsync_state_at(
3533 journal_seq: u64,
3534 ring_pos: u64,
3535 ) -> (
3536 melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3537 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3538 ) {
3539 melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3540 journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3541 chain_hash: [0u8; 32],
3542 input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3543 })
3544 }
3545
3546 fn handles_fixture(
3548 capacity: usize,
3549 chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3550 melin_transport_core::pipeline::FsyncState,
3551 >,
3552 ) -> (
3553 ReplicaPipelineHandles<counter_server::Counter, u32>,
3554 melin_pipeline::ring::Consumer<InputSlot>,
3555 ) {
3556 let (input_producer, mut consumers) =
3557 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3558 .add_consumer()
3559 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3560 let consumer = consumers.pop().expect("one consumer");
3561 let handles = ReplicaPipelineHandles {
3562 input_producer,
3563 journal_cursor: Arc::new(make_journal_cursor(0)),
3564 chain_hash_lock,
3565 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
3566 genesis_entries: None,
3567 journal_failed: Arc::new(AtomicBool::new(false)),
3568 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3569 journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3570 Ok(11)
3571 }),
3572 matching_handle: std::thread::spawn(counter_server::Counter::default),
3573 drain_handle: std::thread::spawn(|| {}),
3574 shadow_handle: None,
3575 };
3576 (handles, consumer)
3577 }
3578
3579 #[test]
3585 fn teardown_publishes_shutdown_sentinel() {
3586 let (handles, mut consumer) = teardown_fixture(8);
3587 assert!(matches!(
3588 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3589 TeardownOutcome::Clean(..)
3590 ));
3591 let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3592 assert!(
3593 slot.event.is_shutdown(),
3594 "published slot must be the sentinel"
3595 );
3596 assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3597 }
3598
3599 #[test]
3605 fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3606 let (mut fsync, reader) = fsync_state_at(10, 0);
3607 let (mut handles, _consumer) = handles_fixture(8, reader);
3608 for _ in 0..3 {
3609 handles.input_producer.publish(InputSlot::default());
3610 }
3611 let journal = std::thread::spawn(move || {
3612 std::thread::sleep(std::time::Duration::from_millis(50));
3613 fsync.store(melin_transport_core::pipeline::FsyncState {
3614 journal_seq: melin_transport_core::WireSeq::new(13),
3615 chain_hash: [0u8; 32],
3616 input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3617 });
3618 });
3619 let outcome = handles.settled_resume_point(|| false);
3620 journal.join().unwrap();
3621 assert!(
3622 matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3623 "resumes past the ring's slots"
3624 );
3625 }
3626
3627 #[test]
3630 fn resume_point_with_nothing_in_flight_is_immediate() {
3631 let (_fsync, reader) = fsync_state_at(10, 0);
3632 let (handles, _consumer) = handles_fixture(8, reader);
3633 assert!(matches!(
3634 handles.settled_resume_point(|| false),
3635 ResumePoint::Settled(s) if s.journal_seq.get() == 10
3636 ));
3637 }
3638
3639 #[test]
3643 fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3644 let (_fsync, reader) = fsync_state_at(10, 0);
3645 let (mut handles, _consumer) = handles_fixture(8, reader);
3646 handles.input_producer.publish(InputSlot::default());
3647
3648 assert!(matches!(
3649 handles.settled_resume_point(|| true),
3650 ResumePoint::Interrupted
3651 ));
3652 handles.journal_failed.store(true, Ordering::Release);
3653 assert!(matches!(
3654 handles.settled_resume_point(|| false),
3655 ResumePoint::JournalFailed
3656 ));
3657 }
3658
3659 #[test]
3665 fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3666 let (_fsync, reader) = fsync_state_at(10, 0);
3667 let (handles, _consumer) = handles_fixture(8, reader);
3668 handles.journal_failed.store(true, Ordering::Release);
3669 assert!(matches!(
3670 handles.settled_resume_point(|| false),
3671 ResumePoint::JournalFailed
3672 ));
3673 }
3674
3675 #[test]
3680 fn a_panicked_journal_stage_latches_the_failure() {
3681 let failed = Arc::new(AtomicBool::new(false));
3682 let healthy = Arc::new(AtomicBool::new(true));
3683 let journal_handle = {
3684 let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3685 std::thread::spawn(move || {
3686 run_journal_stage(
3687 || -> Result<u32, melin_journal::JournalError> {
3688 panic!("journal stage died without returning an error")
3689 },
3690 &failed,
3691 &healthy,
3692 )
3693 })
3694 };
3695 assert!(journal_handle.join().is_err(), "the stage panicked");
3697 assert!(failed.load(Ordering::Acquire), "the failure is latched");
3698 assert!(
3699 !healthy.load(Ordering::Acquire),
3700 "the node reports unhealthy"
3701 );
3702 }
3703
3704 #[test]
3708 fn resume_point_ends_when_the_journal_thread_panicked() {
3709 let (_fsync, reader) = fsync_state_at(10, 0);
3710 let (mut handles, _consumer) = handles_fixture(8, reader);
3711 handles.input_producer.publish(InputSlot::default());
3712 let failed = Arc::clone(&handles.journal_failed);
3713 let healthy = Arc::new(AtomicBool::new(true));
3714 let dead = std::thread::spawn(move || {
3715 run_journal_stage(
3716 || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3717 &failed,
3718 &healthy,
3719 )
3720 });
3721 assert!(dead.join().is_err(), "the stage panicked");
3722
3723 let give_up = AtomicBool::new(false);
3725 let outcome = std::thread::scope(|s| {
3726 let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3727 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3728 while !wait.is_finished() && std::time::Instant::now() < deadline {
3729 std::thread::sleep(std::time::Duration::from_millis(5));
3730 }
3731 give_up.store(true, Ordering::Relaxed);
3732 wait.join().expect("wait thread")
3733 });
3734 assert!(
3735 matches!(outcome, ResumePoint::JournalFailed),
3736 "the wait must end on the panicked stage, not on the test's give-up"
3737 );
3738 }
3739
3740 #[test]
3744 fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3745 let dir = tempfile::tempdir().expect("tempdir");
3746 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3747 let (input_producer, mut consumers) =
3748 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3749 .add_consumer()
3750 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3751 let _consumer = consumers.pop().expect("one consumer");
3752 let handles = ReplicaPipelineHandles {
3753 input_producer,
3754 journal_cursor: Arc::new(make_journal_cursor(0)),
3755 chain_hash_lock: fsync_state_at(0, 0).1,
3756 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
3757 genesis_entries: None,
3758 journal_failed: Arc::new(AtomicBool::new(true)),
3759 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3760 journal_handle: std::thread::spawn(
3761 || -> Result<Writer, melin_journal::JournalError> {
3762 Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3763 expected: 4,
3764 actual: 1,
3765 })
3766 },
3767 ),
3768 matching_handle: std::thread::spawn(counter_server::Counter::default),
3769 drain_handle: std::thread::spawn(|| {}),
3770 shadow_handle: None,
3771 };
3772 let mut pipeline = Some(handles);
3773 let mut divergence_resyncs = 0u32;
3774 let mut backoff = MAX_BACKOFF;
3775 let mut closes = 0;
3776 let after = handle_session_exit::<counter_server::Counter, Writer>(
3777 journal_failed_while_disconnected(),
3778 &mut pipeline,
3779 &mut divergence_resyncs,
3780 &mut backoff,
3781 0,
3782 &dir.path().join("r.journal"),
3783 &dir.path().join("r.snapshot"),
3784 &melin_transport_core::fence::FenceState::new(0),
3785 &AtomicBool::new(false),
3786 &crate::promotion::PromotionRequest::new(),
3787 || closes += 1,
3788 &(),
3789 );
3790 let AfterSession::Return(Err(err)) = after else {
3791 panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
3792 };
3793 assert!(
3794 err.to_string()
3795 .contains("refused to journal sequence 1: the next sequence is 4"),
3796 "the returned error names the journal's cause, got: {err}"
3797 );
3798 assert!(pipeline.is_none(), "the dead pipeline was torn down");
3799 assert_eq!(divergence_resyncs, 0);
3800 assert_eq!(closes, 1);
3803 }
3804
3805 #[test]
3809 fn every_session_exit_closes_the_connection_once() {
3810 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3811 type Case = (&'static str, fn() -> SessionExit);
3814 let exits: [Case; 6] = [
3815 ("shutdown", || SessionExit::Shutdown),
3816 ("promote", || SessionExit::Promote),
3817 ("fatal", || SessionExit::Fatal("protocol violation".into())),
3818 ("disconnected", || SessionExit::Disconnected),
3819 ("stream gap", || SessionExit::StreamGap("gap".into())),
3820 ("corrupted", || SessionExit::Corrupted("damaged".into())),
3821 ];
3822 for (label, exit) in exits {
3823 let dir = tempfile::tempdir().expect("tempdir");
3824 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
3825 None;
3826 let mut divergence_resyncs = 0u32;
3827 let mut backoff = MAX_BACKOFF;
3828 let shutdown = AtomicBool::new(true);
3831 let mut closes = 0;
3832 let _after = handle_session_exit::<counter_server::Counter, Writer>(
3835 StreamingResult {
3836 exit: exit(),
3837 heard_from_primary: true,
3838 },
3839 &mut pipeline,
3840 &mut divergence_resyncs,
3841 &mut backoff,
3842 0,
3843 &dir.path().join("r.journal"),
3844 &dir.path().join("r.snapshot"),
3845 &melin_transport_core::fence::FenceState::new(0),
3846 &shutdown,
3847 &crate::promotion::PromotionRequest::new(),
3848 || closes += 1,
3849 &(),
3850 );
3851 assert_eq!(closes, 1, "{label}");
3852 }
3853 }
3854
3855 fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
3862 backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
3863 }
3864
3865 fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
3867 let dir = tempfile::tempdir().expect("tempdir");
3868 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3869 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
3870 let mut divergence_resyncs = 0u32;
3871 let mut backoff = MAX_BACKOFF;
3872 let shutdown = AtomicBool::new(true);
3873 let promote = crate::promotion::PromotionRequest::new();
3874 let after = handle_session_exit::<counter_server::Counter, Writer>(
3875 StreamingResult {
3876 exit,
3877 heard_from_primary,
3878 },
3879 &mut pipeline,
3880 &mut divergence_resyncs,
3881 &mut backoff,
3882 0,
3883 &dir.path().join("r.journal"),
3884 &dir.path().join("r.snapshot"),
3885 &melin_transport_core::fence::FenceState::new(0),
3886 &shutdown,
3887 &promote,
3888 || {},
3889 &(),
3890 );
3891 assert!(matches!(after, AfterSession::Reconnect));
3892 backoff
3893 }
3894
3895 #[test]
3900 fn disconnect_resets_backoff_when_primary_spoke() {
3901 assert_eq!(
3904 backoff_after_disconnect(true),
3905 std::time::Duration::from_secs(2)
3906 );
3907 }
3908
3909 #[test]
3914 fn disconnect_keeps_escalated_backoff_when_primary_silent() {
3915 assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
3916 }
3917
3918 #[test]
3923 fn corrupted_entry_resets_backoff() {
3924 assert_eq!(
3925 backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
3926 std::time::Duration::from_secs(2)
3927 );
3928 }
3929
3930 #[test]
3939 fn stream_gap_reconnects_and_keeps_pipeline() {
3940 let dir = tempfile::tempdir().expect("tempdir");
3941 type Writer = melin_journal::BufferedWriter<CounterEvent>;
3942 let (input_producer, mut consumers) =
3946 melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3947 .add_consumer()
3948 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3949 let _consumer = consumers.pop().expect("one consumer");
3950 let writer_path = dir.path().join("w.journal");
3951 let handles = ReplicaPipelineHandles {
3952 input_producer,
3953 journal_cursor: Arc::new(make_journal_cursor(0)),
3954 chain_hash_lock: fsync_state_at(0, 0).1,
3955 stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
3956 genesis_entries: None,
3957 journal_failed: Arc::new(AtomicBool::new(false)),
3958 pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3959 journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
3960 matching_handle: std::thread::spawn(counter_server::Counter::default),
3961 drain_handle: std::thread::spawn(|| {}),
3962 shadow_handle: None,
3963 };
3964 let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
3965 Some(handles);
3966 let mut divergence_resyncs = 0u32;
3967 let mut backoff = MAX_BACKOFF;
3968 let shutdown = AtomicBool::new(true);
3969 let promote = crate::promotion::PromotionRequest::new();
3970 let mut closed = false;
3971 let after = handle_session_exit::<counter_server::Counter, Writer>(
3972 StreamingResult {
3973 exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
3974 heard_from_primary: true,
3975 },
3976 &mut pipeline,
3977 &mut divergence_resyncs,
3978 &mut backoff,
3979 2,
3980 &dir.path().join("r.journal"),
3981 &dir.path().join("r.snapshot"),
3982 &melin_transport_core::fence::FenceState::new(0),
3983 &shutdown,
3984 &promote,
3985 || closed = true,
3986 &(),
3987 );
3988 assert!(
3989 matches!(after, AfterSession::Reconnect),
3990 "a stream gap must reconnect, not return an error"
3991 );
3992 assert!(
3993 pipeline.is_some(),
3994 "the pipeline must survive for the reconnect"
3995 );
3996 assert!(closed, "the transport must be closed before redialing");
3997 assert_eq!(
3998 backoff,
3999 std::time::Duration::from_secs(2),
4000 "backoff reset to 1s then doubled by the shared helper"
4001 );
4002 assert_eq!(divergence_resyncs, 0, "not a resync");
4003 if let Some(p) = pipeline.take() {
4004 let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4005 }
4006 }
4007
4008 #[test]
4013 fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4014 let (mut handles, mut consumer) = teardown_fixture(4);
4015 for seq in 0..4 {
4016 assert!(
4017 handles
4018 .input_producer
4019 .try_publish(InputSlot {
4020 sequence: seq,
4021 ..InputSlot::default()
4022 })
4023 .is_ok(),
4024 "ring must have space while filling"
4025 );
4026 }
4027 assert!(
4028 handles
4029 .input_producer
4030 .try_publish(InputSlot::default())
4031 .is_err(),
4032 "ring must be full"
4033 );
4034 handles.journal_failed.store(true, Ordering::Release);
4035 assert!(matches!(
4037 teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4038 TeardownOutcome::Clean(..)
4039 ));
4040 while let Some((_, slot)) = consumer.try_consume() {
4041 assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4042 }
4043 }
4044
4045 #[test]
4046 fn pending_ack_queue_wraps_around() {
4047 let mut q = PendingAckQueue::new(8);
4048 let cursor = make_journal_cursor(100);
4049
4050 for round in 0..3 {
4052 for i in 0..8 {
4053 let target = (round * 8 + i) as u64 + 1;
4054 q.push(target, target * 10);
4055 }
4056 assert!(q.is_full());
4057 let seq = q.pop_ready(&cursor).expect("should be ready");
4058 assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4059 assert!(q.is_empty());
4060 }
4061 }
4062
4063 #[test]
4064 fn pending_ack_queue_pop_all_blocking_empty() {
4065 let mut q = PendingAckQueue::new(8);
4066 let cursor = make_journal_cursor(0);
4067 assert!(
4068 q.pop_all_blocking(
4069 &cursor,
4070 melin_pipeline::wait::WaitStrategy::BusySpin,
4071 &AtomicBool::new(false)
4072 )
4073 .is_none()
4074 );
4075 }
4076
4077 #[test]
4080 fn dual_track_returns_none_when_both_tracks_idle() {
4081 let mut q = PendingAckQueue::new(8);
4082 let cursor = make_journal_cursor(0);
4083 assert!(
4084 try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4085 "no advance on either track → no ack"
4086 );
4087 }
4088
4089 #[test]
4090 fn dual_track_fires_on_persisted_advance_only() {
4091 let mut q = PendingAckQueue::new(8);
4096 q.push(10, 100);
4097 let cursor = make_journal_cursor(20);
4098 let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4099 assert_eq!(ack.acked_sequence, 100);
4100 assert_eq!(ack.in_memory_sequence, 50);
4101 }
4102
4103 #[test]
4104 fn dual_track_fires_on_in_memory_advance_only() {
4105 let mut q = PendingAckQueue::new(8);
4108 let cursor = make_journal_cursor(0);
4109 let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4110 assert_eq!(ack.acked_sequence, 100);
4111 assert_eq!(ack.in_memory_sequence, 200);
4112 }
4113
4114 #[test]
4115 fn dual_track_coalesces_until_caller_updates_trackers() {
4116 let mut q = PendingAckQueue::new(8);
4122 q.push(10, 100);
4123 let cursor = make_journal_cursor(20);
4124 let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4125 assert_eq!(ack1.acked_sequence, 100);
4126 let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4128 .expect("call 2 fires on in-memory advance");
4129 assert_eq!(
4130 ack2.acked_sequence, 0,
4131 "no new persisted pop → unwrap_or(last_sent)"
4132 );
4133 assert_eq!(ack2.in_memory_sequence, 80);
4134 }
4135
4136 #[test]
4137 fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4138 let mut q = PendingAckQueue::new(4);
4156 for i in 1..=4u64 {
4159 q.push(i * 10, i * 100);
4160 }
4161 assert!(q.is_full());
4162
4163 let cursor = make_journal_cursor(40);
4166 let drained = q.pop_ready(&cursor).expect("all entries durable");
4167 assert_eq!(drained, 400);
4168 assert!(q.is_empty());
4169
4170 let mut last_sent_acked = drained;
4173 let mut last_sent_in_mem = 450u64;
4174
4175 assert!(
4180 try_flush_dual_track(
4181 &mut q,
4182 &cursor,
4183 last_sent_in_mem,
4184 last_sent_acked,
4185 last_sent_in_mem,
4186 )
4187 .is_none(),
4188 "no advance on either track after backpressure drain → no duplicate ack",
4189 );
4190
4191 q.push(50, 500);
4194 let cursor2 = make_journal_cursor(50);
4195 let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4196 .expect("fresh batch after drain fires a new ack");
4197 assert!(
4198 ack.acked_sequence >= last_sent_acked,
4199 "regression: drain sent {last_sent_acked} but next ack carries {}",
4200 ack.acked_sequence,
4201 );
4202 assert_eq!(ack.acked_sequence, 500);
4203 assert_eq!(ack.in_memory_sequence, 500);
4204
4205 last_sent_acked = ack.acked_sequence;
4206 last_sent_in_mem = ack.in_memory_sequence;
4207
4208 assert!(
4212 try_flush_dual_track(
4213 &mut q,
4214 &cursor2,
4215 last_sent_in_mem,
4216 last_sent_acked,
4217 last_sent_in_mem,
4218 )
4219 .is_none(),
4220 "post-resume idle → no further ack",
4221 );
4222 }
4223}