Skip to main content

melin_server_runtime/replication/
mod.rs

1//! Replication — synchronous input-stream streaming from primary to replica.
2//!
3//! The primary's JournalStage encodes each `InputSlot` it just durably
4//! journaled into a wire-ready `InputBatch` frame in the replication ring
5//! (separately from the journal-codec bytes it writes to disk). The
6//! `ReplicationSender` thread forwards those frames as-is over TCP/DPDK
7//! — no decode + re-encode on the hot path. The replica decodes the
8//! frames straight back into `InputSlot`s, publishes them to its local
9//! input disruptor with the primary's pre-assigned sequences and
10//! timestamps, and the replica's JournalStage re-encodes them through
11//! its own writer for byte-exact-on-replay durability.
12//!
13//! ## Wire Protocol
14//!
15//! Length-prefixed frames, little-endian, over a dedicated TCP connection
16//! (or DPDK pipe). The full `InputBatch` payload layout lives in
17//! `melin_transport_core::replication::protocol` /
18//! `melin_transport_core::replication_wire`.
19//!
20//! ### Auth (before handshake)
21//! - **Challenge** (Primary → Replica): `[len:u32][0x03][nonce:[u8;32]]`
22//! - **ChallengeResponse** (Replica → Primary): `[len:u32][0x04][signature:[u8;64]][pubkey:[u8;32]]`
23//! - **AuthOk** (Primary → Replica): `[len:u32][0x05]`
24//! - **AuthFailed** (Primary → Replica): `[len:u32][0x06]`
25//!
26//! ### Replica → Primary
27//! - **Handshake**: `[len:u32][0x01][last_sequence:u64][chain_hash:[u8;32]]`
28//! - **Ack**: `[len:u32][0x02][acked_sequence:u64][in_memory_sequence:u64]`
29//!
30//! ### Primary → Replica
31//! - **StreamStart**: `[len:u32][0x10][start_sequence:u64][segment_start_sequence:u64][anchor_hash:[u8;32]][epoch:u64][genesis_known:u8][genesis_entries:u64]`
32//!   — the segment header identity a fresh replica creates its journal
33//!   with (lineage origin for full catch-up, the seeded segment's
34//!   identity after a snapshot transfer), and the lineage's genesis
35//!   length, which a fresh replica records and one with a journal checks
36//!   against its own
37//! - **NeedSnapshot**: `[len:u32][0x11]`
38//! - **HashMismatch**: `[len:u32][0x12]` — divergent replica journal;
39//!   the replica archives its lineage, then the snapshot flow follows
40//! - **SnapshotBegin**: `[len:u32][0x13][snapshot_len:u64][snap_sequence:u64][snap_chain_hash:[u8;32]]`
41//! - **SnapshotChunk**: `[len:u32][0x14][data...]`
42//! - **SnapshotEnd**: `[len:u32][0x15][crc32c:u32]`
43//! - **Rotate**: `[len:u32][0x16][boundary_seq:u64][tail_hash:[u8;32]]`
44//!   — primary-driven rotation, verified + adopted by the replica
45//! - **ChainCheck**: `[len:u32][0x17][sequence:u64][chain_hash:[u8;32]]`
46//!   — periodic live-stream chain validation
47//! - **SegmentSeedBegin**: `[len:u32][0x18][seed_len:u64]` — raw byte
48//!   prefix of the primary's segment containing the snapshot boundary;
49//!   body rides SnapshotChunk frames, ends with SnapshotEnd
50//! - **InputBatch**: `[len:u32][0x21][count:u16][slot...]` — see
51//!   `transport-core::replication_wire` for the per-slot layout
52//! - **Heartbeat**: `[len:u32][0x30][sequence:u64]`
53//!
54//! ## Limitations
55//!
56//! - Dual replication (up to 2 replicas in parallel)
57//!
58//! See `docs/replication.md` for the full design document and limitation details.
59
60use std::sync::Arc;
61#[cfg(test)]
62use std::sync::atomic::AtomicU64;
63use std::sync::atomic::{AtomicBool, Ordering};
64
65use melin_journal::{BufferedWriter, JournalWrite};
66
67use melin_app::Application;
68use melin_transport_core::lineage::SharedLineagePolicy;
69use melin_transport_core::node_store::NodeStore;
70use melin_transport_core::pipeline::{InputSlot, OutputSlot};
71use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
72use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
73
74mod auth;
75#[cfg(feature = "dpdk")]
76mod dpdk;
77mod receiver_transport;
78mod tcp_receiver;
79mod tcp_sender;
80// Only the DPDK sender uses the worker, but it is pure thread/channel
81// plumbing with no libdpdk dependency — so it is also compiled under
82// `test`, keeping its mechanics (and the affinity trap it exists to
83// avoid) testable on a build without the `dpdk` feature.
84#[cfg(any(feature = "dpdk", test))]
85mod validation_worker;
86// The DPDK sender's join worker: likewise thread/channel plumbing over the
87// transport-agnostic catch-up steps, compiled under `test` so it is tested
88// without libdpdk.
89#[cfg(any(feature = "dpdk", test))]
90mod join_worker;
91
92use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
93
94/// Log a replica's join ending, on either sender: from here its slot
95/// streams live and the replica's acks count. Without it, a join that
96/// never ends and one that ended look alike in the log.
97pub(crate) fn log_replica_live(slot: usize, sent: &SentHighWater) {
98    tracing::info!(
99        slot,
100        sent_sequence = sent.get(),
101        "replica caught up, streaming live"
102    );
103}
104
105// Wire-protocol types, auth, catch-up, ack queueing, dual-track
106// cursor management, and per-replica metrics now live in
107// `melin_transport_core::replication`. Re-export the public types
108// here so the module's public API surface (e.g.
109// `melin_server_runtime::replication::Ack` / `::ReplicationMetrics`) is
110// unchanged for downstream consumers and tests.
111pub use melin_transport_core::replication::ack_queue::{
112    PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
113};
114pub use melin_transport_core::replication::protocol::{
115    Ack, Handshake, PrimaryMessage, ReplicaMessage,
116};
117pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
118
119#[cfg(feature = "dpdk")]
120pub(crate) use dpdk::REPL_DISPATCH_BURST;
121#[cfg(feature = "dpdk")]
122pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
123pub use tcp_receiver::{ReceiverResult, run_receiver};
124pub use tcp_sender::{ReplicationListener, Sender, run_sender};
125
126/// The handles a replica's receive loop shares with the rest of the
127/// node — the admin endpoint and the control-plane raft driver.
128/// Bundled (rather than five positional parameters) because they
129/// always travel together through the kernel-TCP and DPDK receiver
130/// signatures, and a transposition between same-typed flags is exactly
131/// the bug a bundle prevents.
132#[derive(Clone)]
133pub struct ReplicaControlPlane {
134    /// Promotion request: polled by the receive loop; filed by the
135    /// admin `PROMOTE` command or the raft driver (auto-promotion).
136    pub promote: crate::promotion::PromotionRequest,
137    /// Flipped `true` once journal recovery has seeded the fence epoch,
138    /// so the raft driver can trust this node's advertised tip — see
139    /// `melin_raft::recency::TipSource`.
140    pub tip_ready: std::sync::Arc<AtomicBool>,
141    /// Sequence half of the advertised journal tip — see
142    /// [`melin_transport_core::AdvertisedJournalTip`].
143    pub journal_tip: melin_transport_core::AdvertisedJournalTip,
144    /// `true` while this replica holds an authenticated replication
145    /// connection to its primary. The raft driver refuses to
146    /// auto-promote while set: a live link means the primary is
147    /// demonstrably alive, and winning a control-plane election (e.g.
148    /// because a *different* node's raft died) must not depose a
149    /// healthy primary.
150    pub primary_link_up: std::sync::Arc<AtomicBool>,
151    /// Set once a primary's `StreamStart` has been accepted since this
152    /// process booted, and never cleared: the auto-promotion rule's
153    /// "a primary has been seen" input, which tells a blank genesis node
154    /// from a replica that followed a primary before it died.
155    pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
156    /// The ack policy the lineage this replica holds has in force: the
157    /// newest journaled `AckPolicyChange` it holds, with the transition to
158    /// it (`LineagePolicy`), or `None` when it holds none. The raft
159    /// driver's auto-promotion refusal judges by the newest policy, so it
160    /// judges the policy the entries this replica holds were acked under,
161    /// never one advertised ahead of them. It seeds the replica's shadow
162    /// snapshots and a promoted primary's lineage and gate too: the gate
163    /// starts under the policy before the transition, with the change
164    /// pending, as a restarted primary's does.
165    ///
166    /// One writer, the receive thread: stored whole from the recovered
167    /// lineage at boot and after a divergence recovery, from the snapshot
168    /// at a resync, cleared when the local lineage is archived (so a change
169    /// in a divergent tail disappears with the tail), and folded as each
170    /// change is accepted from the stream, before the advertised tip moves
171    /// past it (see `receiver_transport::process_streaming_frames`). Not by
172    /// the replica's matching stage, which lags the receive thread: two
173    /// writers would let an older value overwrite a newer one.
174    pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
175    /// Whether the replica's pipeline is healthy — fed to the replica's
176    /// minimal health endpoint (`/health` OK/ERR and the
177    /// `melin_pipeline_healthy` gauge). Process-lifetime (the per-session
178    /// pipeline is torn down and rebuilt across resyncs, so its own
179    /// `journal_failed` latch cannot be handed to the endpoint):
180    /// `true` from boot, latched `false` by the journal stage's failure
181    /// wrapper, reset `true` when a fresh pipeline is built.
182    pub pipeline_healthy: std::sync::Arc<AtomicBool>,
183}
184
185impl ReplicaControlPlane {
186    /// Fresh handles for a replica boot: nothing requested, tip not
187    /// trustworthy yet, tip sequence 0, primary link down and never seen,
188    /// no lineage policy held, pipeline healthy (nothing has failed yet).
189    pub fn new() -> Self {
190        Self {
191            promote: crate::promotion::PromotionRequest::new(),
192            tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
193            journal_tip: melin_transport_core::AdvertisedJournalTip::new(
194                melin_transport_core::WireSeq::new(0),
195            ),
196            primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
197            primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
198            lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
199            pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
200        }
201    }
202}
203
204impl Default for ReplicaControlPlane {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210/// Diagnostic: emit a `tcp_info` span at debug level describing the
211/// kernel's view of the socket (rtt, cwnd, retrans, unacked, rcv_space,
212/// rto). Guarded internally with `tracing::enabled!` so the
213/// `getsockopt(TCP_INFO)` syscall is skipped when debug logging is off —
214/// the call is free at runtime unless `RUST_LOG=debug` (or a more
215/// specific filter) is active. Used to distinguish user-space stalls
216/// from TCP-level congestion collapse when diagnosing replication
217/// slowdowns.
218pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
219    if !tracing::enabled!(tracing::Level::DEBUG) {
220        return;
221    }
222    // SAFETY: all-zero pattern is a valid `tcp_info` (all numeric fields).
223    // The kernel fills in whatever the running version supports and
224    // returns the written length in `len`.
225    let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
226    let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
227    let rc = unsafe {
228        libc::getsockopt(
229            fd,
230            libc::IPPROTO_TCP,
231            libc::TCP_INFO,
232            &mut info as *mut _ as *mut libc::c_void,
233            &mut len,
234        )
235    };
236    if rc != 0 {
237        tracing::debug!(
238            slot,
239            tag,
240            errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
241            "tcp_info getsockopt failed"
242        );
243        return;
244    }
245    tracing::debug!(
246        slot,
247        tag,
248        state = info.tcpi_state,
249        ca_state = info.tcpi_ca_state,
250        rtt_us = info.tcpi_rtt,
251        rttvar_us = info.tcpi_rttvar,
252        snd_cwnd = info.tcpi_snd_cwnd,
253        snd_ssthresh = info.tcpi_snd_ssthresh,
254        snd_mss = info.tcpi_snd_mss,
255        rcv_mss = info.tcpi_rcv_mss,
256        unacked = info.tcpi_unacked,
257        retrans = info.tcpi_retrans,
258        total_retrans = info.tcpi_total_retrans,
259        lost = info.tcpi_lost,
260        rcv_space = info.tcpi_rcv_space,
261        rto_us = info.tcpi_rto,
262        "tcp_info"
263    );
264}
265
266/// Sleep for the given duration in 100ms increments, checking the shutdown
267/// flag and the promotion request between increments. Returns early if
268/// either is set.
269pub(super) fn sleep_checking_flags(
270    duration: std::time::Duration,
271    shutdown: &AtomicBool,
272    promote: &crate::promotion::PromotionRequest,
273) {
274    let deadline = std::time::Instant::now() + duration;
275    while std::time::Instant::now() < deadline {
276        if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
277            return;
278        }
279        std::thread::sleep(std::time::Duration::from_millis(100));
280    }
281}
282
283/// Wait out one retry delay: sleep for the current `backoff` (checking
284/// the shutdown and promotion flags — see [`sleep_checking_flags`]),
285/// then double it, capped at [`MAX_BACKOFF`], for the next attempt.
286/// Sleep-then-double, so the first retry waits the base delay rather
287/// than twice it. Every retry arm in both receivers goes through this —
288/// don't hand-roll the idiom, the copies drift.
289pub(super) fn sleep_then_double_backoff(
290    backoff: &mut std::time::Duration,
291    shutdown: &AtomicBool,
292    promote: &crate::promotion::PromotionRequest,
293) {
294    sleep_checking_flags(*backoff, shutdown, promote);
295    *backoff = (*backoff * 2).min(MAX_BACKOFF);
296}
297
298/// Outcome of shutting down a replica pipeline. All stage threads are
299/// joined in every variant — by the time the caller sees this, no
300/// pipeline thread can still touch the journal or snapshot files.
301pub(super) enum TeardownOutcome<A, W> {
302    /// Clean exit: both stages returned their state, reusable for the
303    /// next pipeline build (post-snapshot) or promotion.
304    Clean(A, W),
305    /// The journal stage exited with an error — its writer is gone and
306    /// the matching stage's state is unusable (it may have applied
307    /// events the journal never persisted). Carries the error so the
308    /// orchestrator can distinguish repairable chain divergence
309    /// (in-process resync) from journal I/O death (exit).
310    JournalFailed(melin_journal::JournalError),
311    /// A stage thread panicked; no state survives.
312    Panicked,
313}
314
315/// Shut down the replica pipeline and extract the application + journal writer
316/// from the stage threads.
317///
318/// Relies on the caller having published a `JournalEvent::Shutdown`
319/// sentinel to the input ring before invoking this — the journal and
320/// matching stages exit when they consume the sentinel via the normal
321/// event-processing path. We deliberately don't flip `shutdown_flag`
322/// here: doing so could cause a stage to take its emergency-abort
323/// branch *before* consuming the sentinel, hitting the
324/// drain-vs-cursor race that the sentinel design exists to avoid.
325///
326/// `drain_handle` and `shadow_handle` still observe `shutdown_flag`
327/// because they don't process the input ring (no sentinel reaches
328/// them); set the flag for them only.
329pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
330    shutdown_flag: &AtomicBool,
331    journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
332    matching_handle: std::thread::JoinHandle<A>,
333    drain_handle: std::thread::JoinHandle<()>,
334    shadow_handle: Option<std::thread::JoinHandle<()>>,
335) -> TeardownOutcome<A, W> {
336    // Defense-in-depth: set the flag before joining. The sentinel was
337    // already published by `teardown_replica_pipeline` before this call,
338    // so no further events can arrive in the input ring — setting the
339    // flag here cannot race with new publishes. The flag is the fallback
340    // exit signal for paths that don't observe the sentinel: the sequencer
341    // in `no-persist` builds, the drain consumer, the shadow stage, and
342    // any case where the receiver thread panicked before publishing the
343    // sentinel.
344    //
345    // The flag is also what makes these joins safe when the journal
346    // stage is already dead: the matching stage's gate on the (frozen)
347    // journal cursor is a non-blocking spin that re-checks the flag
348    // every iteration, and its shutdown drain is `try_consume`-bounded.
349    shutdown_flag.store(true, Ordering::Release);
350    // Join EVERY thread before reporting the outcome, even on failure:
351    // the in-process divergence-resync path archives the journal and
352    // snapshot right after this returns, and a still-running shadow
353    // thread could be mid-snapshot-write during those renames.
354    let journal_result = journal_handle.join();
355    let matching_result = matching_handle.join();
356    let _ = drain_handle.join();
357    if let Some(h) = shadow_handle {
358        let _ = h.join();
359    }
360    let writer = match journal_result {
361        Ok(Ok(w)) => w,
362        // Already error!-logged by the journal thread's spawn wrapper.
363        Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
364        Err(_) => return TeardownOutcome::Panicked,
365    };
366    match matching_result {
367        Ok(app) => TeardownOutcome::Clean(app, writer),
368        Err(_) => TeardownOutcome::Panicked,
369    }
370}
371
372/// Live replica pipeline — built once on first connect (or after a snapshot
373/// transfer), persists across `Disconnected` reconnects so the orchestrator
374/// doesn't pay the journal-recover + thread-spawn cost on every drop.
375///
376/// Shared between the kernel-TCP and DPDK receivers; both build pipelines
377/// with the same shape (journal / matching / drain / optional shadow), and
378/// both take the next handshake from [`Self::settled_resume_point`] at
379/// reconnect time.
380pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
381    pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
382    pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
383    /// Seqlock-published fsync state (journal seq + chain hash + ring
384    /// position), read half: the reconnect handshake's resume point, read
385    /// without owning the writer. Seeded with the journal the pipeline
386    /// was built over, so it is never behind what is on disk.
387    pub(super) chain_hash_lock:
388        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
389    /// Primary-announced rotation hand-off: the receiver thread pushes
390    /// `Rotate` boundaries here (in stream order), the journal stage
391    /// pops and rotates at exactly those sequences. Replicas have no
392    /// local rotation triggers.
393    pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
394    /// The lineage's genesis length (`None`: unknown, a pre-v16 header),
395    /// read from the writer's header when the pipeline was built. Fixed
396    /// for the pipeline's life: rotation carries it forward unchanged and
397    /// anything that changes the lineage (a resync) tears the pipeline
398    /// down first. Kept here so the reconnect handshake can compare it
399    /// against the primary's without re-reading the live segment by path,
400    /// which the journal's disk thread may be renaming mid-rotation.
401    pub(super) genesis_entries: Option<u64>,
402    /// Latched by the journal thread's spawn wrapper when the stage
403    /// exits with an error (chain divergence, journal I/O failure, a
404    /// refused sequence) or panics (see `run_journal_stage`).
405    /// The streaming receiver checks it and tears the session down —
406    /// without this, a dead journal stage freezes the journal cursor
407    /// and the receiver wedges forever on ring backpressure or the
408    /// ack-durability wait.
409    pub(super) journal_failed: Arc<AtomicBool>,
410    /// Per-pipeline shutdown flag — flipped only on a controlled teardown
411    /// (Promote/Shutdown/Fatal/Snapshot). NOT flipped on `Disconnected`.
412    pub(super) pipeline_shutdown: Arc<AtomicBool>,
413    pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
414    pub(super) matching_handle: std::thread::JoinHandle<A>,
415    pub(super) drain_handle: std::thread::JoinHandle<()>,
416    pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
417}
418
419/// What [`ReplicaPipelineHandles::settled_resume_point`] found.
420pub(super) enum ResumePoint {
421    /// The fsync state, covering every slot published into the input
422    /// ring: the next handshake's pair.
423    Settled(melin_transport_core::pipeline::FsyncState),
424    /// The journal stage has died. The pipeline cannot take another
425    /// session; the caller tears it down as a fatal session exit would
426    /// (see [`journal_failed_while_disconnected`]) instead of dialing.
427    JournalFailed,
428    /// Shutdown or promotion was requested; the caller handles both
429    /// before any handshake.
430    Interrupted,
431}
432
433impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
434    /// The next handshake's resume point: the fsync state, once it covers
435    /// every slot published into the input ring.
436    ///
437    /// A session publishes ahead of durability, so when it ends the ring
438    /// can still hold entries the journal has not made durable. A
439    /// handshake at the durable position taken before they land has the
440    /// primary resend them, and the next session's contiguity gate, which
441    /// starts at that position, publishes them a second time: the matching
442    /// stage applies them twice, and the journal stage refuses them,
443    /// stopping the replica. Once the fsync state covers the ring, its
444    /// position is exactly what the replica holds.
445    ///
446    /// Stops waiting when the journal stage has failed (nothing more will
447    /// be journaled) or `interrupted` returns true.
448    pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
449        // Only a streaming session publishes into the ring, and the
450        // previous one has ended (kernel TCP joined its thread; DPDK ran
451        // it on this one), so the cursor is final for this wait.
452        let published = self.input_producer.peek_cursor();
453        let mut outcome = ResumePoint::Interrupted;
454        // Yielding, whatever the ingress strategy: this is the reconnect
455        // path, and what it waits out is a disk stall.
456        melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
457            // First, even when the ring is covered: a stage that died
458            // after its last durable batch would otherwise hand out a
459            // ready pair, and the loop would dial the primary and take
460            // one of its replica slots only for the session to fail on
461            // its first iteration.
462            if self.journal_failed.load(Ordering::Acquire) {
463                outcome = ResumePoint::JournalFailed;
464                return true;
465            }
466            let state = self.chain_hash_lock.load();
467            if state.input_ring_seq.get() >= published {
468                outcome = ResumePoint::Settled(state);
469                return true;
470            }
471            interrupted()
472        });
473        outcome
474    }
475}
476
477/// The session exit a reconnect loop dispatches when
478/// [`ReplicaPipelineHandles::settled_resume_point`] finds the journal
479/// stage dead: the same fatal teardown a streaming session takes when it
480/// sees the latch. A chain divergence is repaired in-process; anything
481/// else stops the replica. Nothing was heard from a primary, so the
482/// backoff stays escalated.
483pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
484    StreamingResult {
485        exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
486        heard_from_primary: false,
487    }
488}
489
490/// Run the replica's journal stage on its thread, latching `failed` (and
491/// clearing `healthy`) if it stops for any reason other than a clean
492/// teardown: an error, or a panic.
493///
494/// Every wait on the journal's progress — the streaming loop's
495/// backpressure and ack waits, the reconnect's resume-point wait, the
496/// teardown's sentinel publish — gives up on that latch. A panic never
497/// returns an error, so without the guard below it would leave them all
498/// spinning on a cursor that will never move. (A release build aborts on
499/// panic; this matters wherever the profile unwinds.)
500fn run_journal_stage<W, E: std::fmt::Display>(
501    run: impl FnOnce() -> Result<W, E>,
502    failed: &AtomicBool,
503    healthy: &AtomicBool,
504) -> Result<W, E> {
505    /// Latches on drop unless defused: dropped during unwinding, it
506    /// reports the panic the result never will.
507    struct PanicLatch<'a> {
508        failed: &'a AtomicBool,
509        healthy: &'a AtomicBool,
510        armed: bool,
511    }
512    impl Drop for PanicLatch<'_> {
513        fn drop(&mut self) {
514            if self.armed {
515                self.failed.store(true, Ordering::Release);
516                self.healthy.store(false, Ordering::Release);
517                tracing::error!("replica journal stage panicked — session teardown");
518            }
519        }
520    }
521    let mut guard = PanicLatch {
522        failed,
523        healthy,
524        armed: true,
525    };
526    let result = run();
527    guard.armed = false;
528    if let Err(ref e) = result {
529        // Latch before logging so the receiver reacts even if logging
530        // stalls. A dead journal stage freezes the journal cursor; every
531        // downstream wait on it (ring backpressure, ack durability) would
532        // spin forever — the streaming loop polls this latch and tears
533        // the session down instead.
534        failed.store(true, Ordering::Release);
535        // Mirror into the process-lifetime gauge the replica health
536        // endpoint serves.
537        healthy.store(false, Ordering::Release);
538        tracing::error!(error = %e, "replica journal stage failed — session teardown");
539    }
540    result
541}
542
543/// [`ReplicaPipelineHandles`] over the journal's writer — the only
544/// instantiation that exists. The struct keeps its writer parameter
545/// because the teardown helpers are writer-agnostic plumbing; this
546/// alias spares every signature from spelling the concrete type out.
547pub(super) type ReplicaHandles<A> =
548    ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
549
550/// Build the replica pipeline and spawn its stage threads on the configured
551/// cores. Returns the bundle of state the orchestrator keeps across
552/// `Disconnected` reconnects.
553#[allow(clippy::too_many_arguments)]
554pub(super) fn build_replica_pipeline_with_threads<A>(
555    mut app: A,
556    writer: BufferedWriter<A::Event>,
557    cores: crate::layout::PipelineCores,
558    // How the replica's segment preparer materialises staged extents.
559    // A replica's rotation stall sits on the ack path — under
560    // `disk+ram`/`two-disks` it delays the primary's ack gate — so this
561    // is not a primary-only tuning knob.
562    staging_mode: melin_journal::StagingMode,
563    snapshot_interval_ms: u64,
564    // Where the replica's shadow stage writes its snapshots.
565    store: &NodeStore,
566    group_commit_delay: std::time::Duration,
567    fence_state: Arc<melin_transport_core::fence::FenceState>,
568    // The lineage's ack policy as recovered (see
569    // `ReplicaControlPlane::lineage_ack_policy`), seeding the shadow
570    // stage's next to the fence epoch.
571    lineage_policy: &SharedLineagePolicy,
572    // Process-lifetime health mirror for the replica health endpoint —
573    // see `ReplicaControlPlane::pipeline_healthy`. Reset `true` here (a
574    // fresh pipeline is healthy) and latched `false` by the journal
575    // thread's failure wrapper, alongside the per-pipeline
576    // `journal_failed` latch.
577    pipeline_healthy: Arc<AtomicBool>,
578    // The node's sizing, applied to every instance a pipeline is built
579    // around — see `Application::prefault`.
580    sizing: &A::Sizing,
581) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
582where
583    A: Application + Send + 'static,
584    A::Event: Send + Sync + 'static,
585    A::Report: Send + 'static,
586    A::QueryResponse: Send + 'static,
587{
588    // Before the shadow copy is taken, so that neither the stream's
589    // first events nor the copy's first snapshot grow the collections on
590    // the matching thread. A replica's apply sits on the primary's ack
591    // path under `disk+ram`, so its page faults are the primary's tail.
592    <A as Application>::prefault(&mut app, sizing);
593    let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
594
595    let enable_shadow = snapshot_interval_ms > 0;
596    // Shadow snapshot seeds its lineage from the fence state's epoch and
597    // the lineage policy cell (both set from the replica's recovered
598    // journal, or the snapshot a resync installed, before this builder
599    // runs).
600    let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
601        epoch: fence_state.epoch(),
602        ack_policy: lineage_policy.load(),
603    };
604    // The lineage's genesis length, stamped into the replica's own
605    // snapshots — the same value its journal header holds.
606    let genesis_entries = writer.read_header_info()?.genesis_entries;
607    // The first sequence the stream will bring, which the shadow counts
608    // from (see `shadow::run`).
609    let starting_wire_seq = writer.next_sequence();
610    let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
611        app,
612        writer,
613        4096, // max_journal_batch
614        group_commit_delay,
615        cores.stage_waits(),
616        enable_shadow,
617        fence_state,
618    );
619
620    let pipeline_shutdown = Arc::new(AtomicBool::new(false));
621
622    let ps = Arc::clone(&pipeline_shutdown);
623    let journal_core = cores.journal_seq.core;
624    let mut journal_stage = pipeline.journal_stage;
625    // Replicas never rotate on local triggers (size or operator
626    // command) — they adopt the boundaries the primary announces over
627    // the stream, which keeps segment boundaries (and with them chain
628    // values and journal bytes) identical across nodes.
629    let stream_marks = melin_transport_core::pipeline::StreamMarks::shared();
630    journal_stage.set_stream_marks(Arc::clone(&stream_marks));
631    cores.place_journal_children(&mut journal_stage);
632    journal_stage.set_staging_mode(staging_mode);
633    let journal_failed = Arc::new(AtomicBool::new(false));
634    let journal_failed_latch = Arc::clone(&journal_failed);
635    // Start the journal's disk and preparer threads here rather than on
636    // journal-seq — see `JournalStage::start`. Before the health latch
637    // below, so a failed start never reports a healthy pipeline.
638    let sequencer = journal_stage.start()?;
639    // A fresh pipeline is healthy — this also clears the latch after a
640    // successful in-process resync rebuild.
641    pipeline_healthy.store(true, Ordering::Release);
642    // Every stage thread is created already in its own placement's
643    // context, and this thread is put back where it was after each. Left
644    // to inherit it, a stage thread starts wherever this thread is — after
645    // `start` above, possibly on the disk thread's isolated core, where
646    // that thread spins at real-time priority and the stage never runs.
647    //
648    // A stage thread that cannot be created, or does not start, fails the
649    // build. The stages already running are told to stop first, so a
650    // failed build leaves nothing spinning behind it.
651    let abandon = |e: String| -> Box<dyn std::error::Error> {
652        pipeline_shutdown.store(true, Ordering::Release);
653        e.into()
654    };
655    let journal_handle =
656        melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
657            run_journal_stage(
658                || sequencer.run(&ps),
659                &journal_failed_latch,
660                &pipeline_healthy,
661            )
662        })
663        .map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
664
665    let ps = Arc::clone(&pipeline_shutdown);
666    let matching_core = cores.matching.core;
667    let matching_stage = pipeline.matching_stage;
668    let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
669        matching_stage.run(&ps)
670    })
671    .map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
672
673    // Drain thread uses the response core — replicas have no response stage,
674    // but the consumer needs to be drained so the output ring doesn't fill.
675    let ps = Arc::clone(&pipeline_shutdown);
676    let drain = cores.response;
677    let drain_consumer = pipeline.drain_consumer;
678    let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
679        let mut consumer = drain_consumer;
680        let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
681        let mut waiter = drain.wait.waiter();
682        loop {
683            if ps.load(Ordering::Relaxed) {
684                return;
685            }
686            let count = consumer.consume_batch(&mut batch, 256);
687            if count == 0 {
688                waiter.idle();
689            } else {
690                waiter.reset();
691            }
692        }
693    })
694    .map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
695
696    let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
697        let snap_path = store.snapshot().to_path_buf();
698        let chain_lock = pipeline.chain_hash_lock.clone();
699        let ps = Arc::clone(&pipeline_shutdown);
700        let shadow = cores.shadow;
701        Some(
702            melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
703                melin_transport_core::shadow::run(
704                    shadow_cons,
705                    shadow_app,
706                    snap_path,
707                    std::time::Duration::from_millis(snapshot_interval_ms),
708                    chain_lock,
709                    &ps,
710                    shadow.wait,
711                    shadow_initial_lineage,
712                    starting_wire_seq,
713                    genesis_entries,
714                );
715            })
716            .map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
717        )
718    } else {
719        None
720    };
721
722    Ok(ReplicaPipelineHandles {
723        input_producer: pipeline.input_producer,
724        journal_cursor: pipeline.cursors.journal_ring_arc(),
725        chain_hash_lock: pipeline.chain_hash_lock,
726        stream_marks,
727        genesis_entries,
728        journal_failed,
729        pipeline_shutdown,
730        journal_handle,
731        matching_handle,
732        drain_handle,
733        shadow_handle,
734    })
735}
736
737/// Tear down the pipeline: publish the shutdown sentinel, join all
738/// threads, return the recovered (App, journal writer) so the orchestrator
739/// can use them for the next pipeline build (e.g., post-snapshot) or
740/// pass them up on promotion.
741pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
742    mut handles: ReplicaPipelineHandles<A, W>,
743) -> TeardownOutcome<A, W> {
744    // The sentinel is published here, not by callers, so no teardown
745    // path can forget it — the journal and matching stages exit by
746    // consuming it through the normal event path, draining anything
747    // received-but-not-yet-journaled (see `shutdown_pipeline` for why
748    // the flag alone is only the emergency fallback). Bounded retry
749    // rather than the blocking `publish`: if the journal stage dies
750    // with the ring full, its gate cursor freezes and a blocking
751    // publish would spin forever — re-check the failure latch between
752    // attempts and fall through to the flag-only teardown once it
753    // trips (the sentinel has no reader then anyway: the matching
754    // stage is gated behind the frozen journal cursor).
755    //
756    // Deliberately a plain yield rather than the node's wait strategy:
757    // this is the orchestrator thread on a teardown path, where
758    // latency is irrelevant and handing the CPU to the stage threads
759    // is unconditionally the right move, whatever the node's policy.
760    while !handles.journal_failed.load(Ordering::Acquire) {
761        match handles
762            .input_producer
763            .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
764        {
765            Ok(_) => break,
766            // Ring full — consumers need to make progress first.
767            Err(_) => std::thread::yield_now(),
768        }
769    }
770    shutdown_pipeline::<A, W>(
771        &handles.pipeline_shutdown,
772        handles.journal_handle,
773        handles.matching_handle,
774        handles.drain_handle,
775        handles.shadow_handle,
776    )
777}
778
779/// How many mid-stream divergence resyncs the receiver attempts
780/// in-process (per process lifetime) before giving up. Mid-stream
781/// divergence is never expected in a healthy cluster — it means
782/// corruption or a serious bug somewhere — so the budget is exactly
783/// one: the first occurrence repairs automatically (archive the local
784/// lineage as `.divergent.<n>`, re-seed from the primary) and pages
785/// the operator via `melin_replica_divergence_total`; a second in the
786/// same process lifetime is systematic, and continuing to repair
787/// would fill the disk with archives while masking the underlying
788/// fault. Exit hard instead.
789pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
790
791/// Recover replica boot state from disk.
792///
793/// Recovers whenever any journal segment survives — live OR archived
794/// (a crash between rotation's rename and the new live file's creation
795/// leaves archives with no live segment, and recovery handles that
796/// layout; treating it as a fresh replica would discard local durable
797/// history and then fail `create_new` against the surviving lineage).
798/// Returns `(None, None, 0, zeros)` for a genuinely fresh replica.
799/// Also seeds the fencing epoch from the recovered journal.
800///
801/// Called at receiver startup, and again after a mid-stream chain
802/// divergence tears the pipeline down: the on-disk journal is
803/// self-consistent (merely forked from the primary's history), so
804/// recovery re-derives a truthful handshake pair `(last_sequence,
805/// chain_hash)` and the next connection takes the primary's
806/// HashMismatch → archive → reseed path in-process.
807#[allow(clippy::type_complexity)]
808pub(super) fn recover_replica_state<A, W>(
809    store: &NodeStore,
810    fence_state: &melin_transport_core::fence::FenceState,
811    lineage_policy: &SharedLineagePolicy,
812    // Applied to a genesis instance before the journal is replayed into
813    // it — see `init_engine`. The pipeline build sizes the result again.
814    sizing: &A::Sizing,
815) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
816where
817    A: Application,
818    W: melin_journal::JournalWrite<A::Event>,
819{
820    // Swept every boot, like the genesis staging file: only a later adopt
821    // that meets its narrow guard would otherwise remove it.
822    let journal_path = store.journal();
823    let snapshot_path = store.snapshot();
824    remove_restamp_staging(journal_path)?;
825    if !store.journal_lineage_exists()? {
826        // Nothing held, so no policy either (the epoch stays: fencing
827        // never moves it back).
828        lineage_policy.store(None);
829        return Ok((None, None, 0u64, [0u8; 32]));
830    }
831    let engine = if snapshot_path.try_exists()? {
832        tracing::info!("recovering replica from snapshot + journal");
833        melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
834            snapshot_path,
835            journal_path,
836        )?
837    } else {
838        let mut app = A::default();
839        <A as Application>::prefault(&mut app, sizing);
840        melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
841    };
842    let next = engine.next_sequence();
843    let last = next.saturating_sub(1);
844    let hash = engine.chain_hash().unwrap_or([0u8; 32]);
845    // Seed the lineage from the replica's own recovered journal.
846    // Streaming `EpochBump`s and the snapshot-resync path raise the epoch
847    // later.
848    adopt_recovered_lineage(fence_state, lineage_policy, engine.recovered_lineage());
849    let (app, writer) = engine.into_parts();
850    Ok((Some(app), Some(writer), last, hash))
851}
852
853/// Adopt a lineage recovered from disk (a journal replay, or a snapshot
854/// the primary sent) as this replica's. The epoch is observed, a maximum,
855/// since fencing never moves it back; the ack policy is stored whole,
856/// since the recovered lineage replaces whatever this node held, and a
857/// change in a tail it dropped must go with the tail.
858fn adopt_recovered_lineage(
859    fence_state: &melin_transport_core::fence::FenceState,
860    lineage_policy: &SharedLineagePolicy,
861    lineage: melin_transport_core::lineage::Lineage,
862) {
863    fence_state.observe_epoch(lineage.epoch);
864    lineage_policy.store(lineage.ack_policy);
865}
866
867/// Reconnect backoff cap shared by both receivers — exponential from
868/// 1 s, clamped here so a long outage settles to one attempt every 30 s
869/// without hammering a flapping primary. Reset to 1 s only when a
870/// session heard the primary speak (data or heartbeat) — see
871/// [`handle_session_exit`].
872pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
873
874/// What the receiver's reconnect loop does after a streaming session
875/// ends — the transport-independent half of the exit dispatch, returned
876/// by [`handle_session_exit`].
877pub(in crate::replication) enum AfterSession<A, W> {
878    /// Terminal: `run_receiver*` returns this verbatim — clean shutdown
879    /// (`Ok(None)`), a promotion hand-off (`Ok(Some(state))`), or a
880    /// fatal error.
881    Return(ReceiverResult<A, W>),
882    /// A mid-stream divergence was repaired in-process: the recovered
883    /// on-disk state is the new handshake position. The caller adopts it
884    /// and reconnects; the primary's `HashMismatch` verdict then routes
885    /// the replica through archive + re-seed.
886    Resync {
887        app: Option<A>,
888        journal_writer: Option<W>,
889        last_sequence: u64,
890        chain_hash: [u8; 32],
891    },
892    /// A plain disconnect or a stream contiguity break — backoff has
893    /// already been applied (and the flags checked); the caller
894    /// reconnects, reusing the still-live pipeline.
895    Reconnect,
896}
897
898impl<A, W> AfterSession<A, W> {
899    /// Fold this outcome into the reconnect loop's state: the result
900    /// `run_receiver*` must return, or `None` to go round the loop again
901    /// (having adopted a resync's recovered state). One copy for every
902    /// dispatch site in both receivers, so they cannot drift apart.
903    pub(in crate::replication) fn adopt(
904        self,
905        app: &mut Option<A>,
906        journal_writer: &mut Option<W>,
907        last_sequence: &mut u64,
908        chain_hash: &mut [u8; 32],
909    ) -> Option<ReceiverResult<A, W>> {
910        match self {
911            AfterSession::Return(r) => Some(r),
912            AfterSession::Resync {
913                app: recovered_app,
914                journal_writer: recovered_writer,
915                last_sequence: recovered_sequence,
916                chain_hash: recovered_hash,
917            } => {
918                *app = recovered_app;
919                *journal_writer = recovered_writer;
920                *last_sequence = recovered_sequence;
921                *chain_hash = recovered_hash;
922                None
923            }
924            AfterSession::Reconnect => None,
925        }
926    }
927}
928
929/// Dispatch a finished streaming session — shared by the kernel-TCP and
930/// DPDK receivers.
931///
932/// Folds the behaviours that were previously copied between the two
933/// receiver loops (and had drifted — the copies reset the post-resync
934/// writer differently, a latent corruption bug): the
935/// `Shutdown`/`Promote`/`Fatal` teardown (including the once-per-process
936/// in-process divergence-resync policy) and the `Disconnected` reconnect
937/// backoff. The shutdown-sentinel publish lives in
938/// [`teardown_replica_pipeline`].
939///
940/// `close` ends the session's connection, transport-specifically. It is
941/// invoked exactly once, first, on every exit, terminal or not: the
942/// session is over whatever follows, and the primary must learn so now
943/// rather than after the pipeline teardown or the backoff. The DPDK
944/// receiver resets its smoltcp socket, which tells the primary (a DPDK
945/// node is its own TCP stack, so nothing else would once it stops) and
946/// frees the local socket-set entry; the kernel-TCP receiver passes a
947/// no-op (its `TcpStream` is dropped by the caller, and the kernel tells
948/// the primary).
949// A long argument list, but each is a distinct piece of the receiver
950// loop's state, borrowed from its locals; bundling them would only move
951// the noise.
952#[allow(clippy::too_many_arguments)]
953pub(in crate::replication) fn handle_session_exit<A, W>(
954    result: StreamingResult,
955    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
956    divergence_resyncs: &mut u32,
957    backoff: &mut std::time::Duration,
958    last_sequence: u64,
959    store: &NodeStore,
960    fence_state: &melin_transport_core::fence::FenceState,
961    lineage_policy: &SharedLineagePolicy,
962    shutdown: &AtomicBool,
963    promote: &crate::promotion::PromotionRequest,
964    close: impl FnOnce(),
965    // For the resync path, which recovers the local journal afresh — see
966    // `recover_replica_state`.
967    sizing: &A::Sizing,
968) -> AfterSession<A, W>
969where
970    A: Application + Send + 'static,
971    W: JournalWrite<A::Event> + Send + 'static,
972{
973    let StreamingResult {
974        exit,
975        heard_from_primary,
976    } = result;
977
978    close();
979
980    match exit {
981        SessionExit::Shutdown => {
982            if let Some(p) = pipeline.take() {
983                let _ = teardown_replica_pipeline::<A, W>(p);
984            }
985            AfterSession::Return(Ok(None))
986        }
987
988        SessionExit::Promote => AfterSession::Return(match pipeline.take() {
989            Some(p) => match teardown_replica_pipeline::<A, W>(p) {
990                TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
991                _ => Err("pipeline failed during promotion".into()),
992            },
993            None => Err("pipeline missing on promote".into()),
994        }),
995
996        SessionExit::Fatal(e) => {
997            let outcome = match pipeline.take() {
998                Some(p) => teardown_replica_pipeline::<A, W>(p),
999                // Fatal implies a streaming session, which implies a
1000                // pipeline — but don't turn a missing one into a resync.
1001                None => return AfterSession::Return(Err(e)),
1002            };
1003            // Mid-stream chain divergence is repairable in-process: the
1004            // on-disk journal is self-consistent (merely forked from the
1005            // primary's history), so re-derive the handshake state from
1006            // disk and reconnect — the primary judges the recovered
1007            // position divergent and the next session takes the
1008            // HashMismatch → archive → reseed path, no restart needed.
1009            // Every other fatal exits as before: protocol violations and
1010            // journal I/O death (ENOSPC, RO-FS) would fail the same way
1011            // after a resync. The journal's own error names the cause; the
1012            // exit's message alone may not (the reconnect wait's does not).
1013            let je = match outcome {
1014                TeardownOutcome::JournalFailed(
1015                    je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
1016                ) => je,
1017                TeardownOutcome::JournalFailed(je) => {
1018                    return AfterSession::Return(Err(format!("{e}: {je}").into()));
1019                }
1020                TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1021                    return AfterSession::Return(Err(e));
1022                }
1023            };
1024
1025            *divergence_resyncs += 1;
1026            let attempt = *divergence_resyncs;
1027            if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1028                return AfterSession::Return(Err(format!(
1029                    "mid-stream chain divergence recurred {attempt} times — giving up on \
1030                     in-process resync (each cycle archives the local journal and re-seeds \
1031                     from the primary; recurrence at this rate means the primary keeps \
1032                     streaming history that forks from what it announces): {je}"
1033                )
1034                .into()));
1035            }
1036            tracing::warn!(
1037                error = %je,
1038                attempt,
1039                max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1040                "mid-stream chain divergence — re-deriving local state for in-process resync"
1041            );
1042            match recover_replica_state::<A, W>(store, fence_state, lineage_policy, sizing) {
1043                Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1044                    app,
1045                    journal_writer,
1046                    last_sequence: seq,
1047                    chain_hash: hash,
1048                },
1049                Err(e) => AfterSession::Return(Err(e)),
1050            }
1051        }
1052
1053        SessionExit::StreamGap(e) => {
1054            // The primary's stream skipped past what we hold; the
1055            // contiguous prefix is committed and the journal stage keeps
1056            // flushing it, so re-handshaking at the durable position
1057            // (re-read from `FsyncState` at the top of the reconnect
1058            // loop, after the backoff) lets the primary re-stream the
1059            // hole. The primary evidently spoke, so the backoff resets
1060            // exactly as a heard-from disconnect does. Not a resync:
1061            // nothing on disk is wrong.
1062            *backoff = std::time::Duration::from_secs(1);
1063            tracing::warn!(
1064                error = %e,
1065                last_sequence,
1066                "replication stream broke contiguity — reconnecting from the durable position"
1067            );
1068            sleep_then_double_backoff(backoff, shutdown, promote);
1069            AfterSession::Reconnect
1070        }
1071
1072        SessionExit::Corrupted(e) => {
1073            // A replicated entry arrived damaged and was refused before
1074            // anything could apply, journal or acknowledge it; the frames
1075            // before it are committed. The primary still holds the intact
1076            // entries, so re-handshaking at the durable position has them
1077            // sent again. Not a resync: nothing local is wrong.
1078            //
1079            // The backoff resets, exactly as for a stream gap: frames
1080            // arrived, so the primary evidently spoke, and nothing else
1081            // resets the backoff after a long clean session — leaving it
1082            // to escalate would make one isolated bit flip, days after a
1083            // start-up that escalated it, cost a `MAX_BACKOFF` stall
1084            // (writes blocked under a replica-requiring ack policy). A
1085            // link that keeps damaging frames is redialled at the floor
1086            // rate, each time logged below; every redial re-streams
1087            // useful data, so that costs nothing the primary cannot bear.
1088            // `warn!`, not `error!`: the server is working as designed —
1089            // what needs attention is the path between the nodes.
1090            *backoff = std::time::Duration::from_secs(1);
1091            tracing::warn!(
1092                error = %e,
1093                last_sequence,
1094                backoff_secs = backoff.as_secs(),
1095                "replicated entry failed its integrity check — refused, reconnecting to \
1096                 fetch it again; recurrence points at the network, a NIC or memory"
1097            );
1098            sleep_then_double_backoff(backoff, shutdown, promote);
1099            AfterSession::Reconnect
1100        }
1101
1102        SessionExit::Disconnected => {
1103            // A session in which the primary spoke — data or heartbeat
1104            // (heartbeats flow even on a quiet system) — proves it
1105            // alive and serving: treat the drop as transient and reset
1106            // the backoff. A session with no word from the primary
1107            // keeps escalating: that covers instant-drop flaps and
1108            // synthetic results from sessions that never started
1109            // streaming (a persistent local transport failure must not
1110            // redial at 1 Hz forever).
1111            if heard_from_primary {
1112                *backoff = std::time::Duration::from_secs(1);
1113            }
1114            tracing::warn!(
1115                last_sequence,
1116                heard_from_primary,
1117                backoff_secs = backoff.as_secs(),
1118                "reconnecting to primary"
1119            );
1120            sleep_then_double_backoff(backoff, shutdown, promote);
1121            AfterSession::Reconnect
1122        }
1123    }
1124}
1125
1126/// Tear the live pipeline down for a promotion that fired while the
1127/// receiver was disconnected — at the top of the reconnect loop or
1128/// during reconnect backoff — and return the warm application + writer for
1129/// the promoted primary. Shared by both receivers.
1130///
1131/// A clean teardown hands back the warm state; if there is no pipeline
1132/// (promotion before the first connect, or after a resync that left the
1133/// state in the receiver's locals) those locals carry it. A missing
1134/// pair is a hard error — a promote with nothing to promote.
1135pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1136    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1137    app: &mut Option<A>,
1138    journal_writer: &mut Option<W>,
1139) -> ReceiverResult<A, W>
1140where
1141    A: Application + Send + 'static,
1142    W: JournalWrite<A::Event> + Send + 'static,
1143{
1144    if let Some(p) = pipeline.take()
1145        && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1146    {
1147        *app = Some(e);
1148        *journal_writer = Some(w);
1149    }
1150    match (app.take(), journal_writer.take()) {
1151        (Some(e), Some(w)) => Ok(Some((e, w))),
1152        _ => Err("promotion requested but no local state available".into()),
1153    }
1154}
1155
1156/// The facts a successful snapshot + segment-seed transfer yields.
1157struct ResyncTransfer<A> {
1158    /// The application loaded from the transferred snapshot.
1159    app: A,
1160    /// The snapshot's sequence: where the seeded segment is opened.
1161    snap_sequence: u64,
1162    /// The snapshot's chain hash, as the primary announced and the loaded
1163    /// snapshot confirmed.
1164    snap_chain_hash: [u8; 32],
1165    /// The seed's length: the seeded journal's `valid_end`.
1166    seed_len: u64,
1167    /// The snapshot's lineage. Adopted by the caller once the whole
1168    /// transfer has succeeded, so a failed one leaves none behind.
1169    lineage: melin_transport_core::lineage::Lineage,
1170}
1171
1172/// What [`handle_resync_verdict`] resolved a `NeedSnapshot` /
1173/// `HashMismatch` verdict to.
1174pub(in crate::replication) enum ResyncDecision {
1175    /// Resync complete — resume streaming from `resume_sequence` on the
1176    /// re-seeded lineage `(segment_start_sequence, anchor_hash)`. The
1177    /// recovered App + writer are left in the receiver's `app` /
1178    /// `journal_writer` locals.
1179    Ready {
1180        segment_start_sequence: u64,
1181        anchor_hash: [u8; 32],
1182        /// The lineage's genesis length, as the seeded segment's header
1183        /// (and the `StreamStart` that matched it) records it.
1184        genesis_entries: Option<u64>,
1185        resume_sequence: u64,
1186    },
1187    /// The transfer failed network-shaped (drop / restart mid-body); the
1188    /// caller backs off and reconnects. The pre-resync lineage is already
1189    /// archived and half-applied transfer state has been cleaned up.
1190    Retry,
1191}
1192
1193/// Receive the snapshot + segment-seed transfer that follows a resync
1194/// verdict, installing both (snapshot → the store's snapshot, by way of
1195/// its `.tmp`, the shadow stage's own temp name; seed → the store's
1196/// journal), and returns what they yield ([`ResyncTransfer`]).
1197/// Shared by both receivers via [`ControlFrameSource`].
1198///
1199/// Errors here are network-shaped — the caller retries (see
1200/// [`ResyncDecision::Retry`]); the post-transfer install in
1201/// [`handle_resync_verdict`] is what's fatal.
1202fn receive_resync_transfer<A, S>(
1203    source: &mut S,
1204    store: &NodeStore,
1205) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1206where
1207    A: Application + Send,
1208    S: ControlFrameSource,
1209{
1210    let (snap_len, snap_sequence, snap_chain_hash) =
1211        match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1212            PrimaryMessage::SnapshotBegin {
1213                snapshot_len,
1214                snap_sequence,
1215                snap_chain_hash,
1216            } => (snapshot_len, snap_sequence, snap_chain_hash),
1217            other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1218        };
1219
1220    tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1221    let snapshot_path = store.snapshot();
1222    let journal_path = store.journal();
1223    let tmp_path = store.snapshot_tmp();
1224    receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1225    std::fs::rename(&tmp_path, snapshot_path)?;
1226    // The snapshot's own directory: the seed's sync below covers only the
1227    // journal's, which the snapshot need not share.
1228    melin_journal::segment::fsync_parent_dir(snapshot_path)?;
1229    tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1230
1231    // The primary is already sending the seed: a large state must not
1232    // leave the link unanswered while it loads (see `serviced`).
1233    let (snap_app, snap_header) =
1234        source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1235    let snap_hash = snap_header.chain_hash;
1236    if snap_hash != snap_chain_hash {
1237        return Err(format!(
1238            "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1239             loaded snapshot has {snap_hash:02x?}"
1240        )
1241        .into());
1242    }
1243
1244    // Segment seed: the raw byte prefix of the primary's segment
1245    // containing `snap_sequence`. Written verbatim as our live segment, it
1246    // makes our segmentation a byte-copy of the primary's from birth —
1247    // chain values comparable and `Rotate` verification valid immediately.
1248    let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1249        PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1250        other => {
1251            return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1252        }
1253    };
1254    let seed_tmp = journal_path.with_extension("seed.tmp");
1255    receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1256    // Structural check before installing: the CRC only proves transport
1257    // integrity, not that the primary sent a well-formed prefix ending at
1258    // the snapshot sequence. (With hash-chain on, the chain cross-check in
1259    // `handle_resync_verdict` subsumes this; without it, this is the only
1260    // guard.) Reads the whole seed, so it runs serviced too.
1261    source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1262        if let Err(e) =
1263            melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1264        {
1265            let _ = std::fs::remove_file(&seed_tmp);
1266            return Err(format!("segment seed failed structural verification: {e}").into());
1267        }
1268        std::fs::rename(&seed_tmp, journal_path)?;
1269        melin_journal::segment::fsync_parent_dir(journal_path)?;
1270        Ok(())
1271    })?;
1272    Ok(ResyncTransfer {
1273        app: snap_app,
1274        snap_sequence,
1275        snap_chain_hash,
1276        seed_len,
1277        lineage: snap_header.lineage,
1278    })
1279}
1280
1281/// Handle a `NeedSnapshot` / `HashMismatch` resync verdict — shared by
1282/// both receivers. Tears the pipeline down, archives the local lineage
1283/// (never deleted), resets the handshake position, receives + installs
1284/// the snapshot and segment seed via `source`, opens the re-seeded
1285/// segment and ties its chain to the snapshot, then validates the
1286/// post-snapshot `StreamStart` inline before resuming.
1287///
1288/// On success the recovered App + writer are left in `app` /
1289/// `journal_writer` and [`ResyncDecision::Ready`] carries the resume
1290/// lineage. A network-shaped transfer failure yields
1291/// [`ResyncDecision::Retry`] (the caller backs off and reconnects). An
1292/// inconsistent primary or a local install failure is fatal (`Err`).
1293#[allow(clippy::too_many_arguments)]
1294pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1295    divergent: bool,
1296    source: &mut S,
1297    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1298    app: &mut Option<A>,
1299    journal_writer: &mut Option<W>,
1300    store: &NodeStore,
1301    fence_state: &melin_transport_core::fence::FenceState,
1302    control: &ReplicaControlPlane,
1303    last_sequence: &mut u64,
1304    chain_hash: &mut [u8; 32],
1305) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1306where
1307    A: Application + Send + 'static,
1308    // The pipeline's rings carry events, and its teardown runs serviced
1309    // on a helper thread.
1310    A::Event: Send,
1311    W: JournalWrite<A::Event> + Send + 'static,
1312    S: ControlFrameSource,
1313{
1314    // HashMismatch is NeedSnapshot plus the verdict that our local journal
1315    // holds divergent history (forked from the primary's — e.g. an
1316    // ex-primary rejoining after failover with an acked-but-unreplicated
1317    // suffix).
1318    if divergent {
1319        tracing::warn!(
1320            last_sequence = *last_sequence,
1321            "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1322        );
1323    } else {
1324        tracing::info!("primary requires snapshot transfer — receiving snapshot");
1325    }
1326
1327    // Invalidate the in-memory App + writer before moving their backing
1328    // files aside. On the in-process divergence repair path these still
1329    // hold the recovered handles; a transfer failure returns `Retry`, and
1330    // without this reset the stale writer — now pointing at an
1331    // archived-away journal — would survive the fresh-replica create gate
1332    // and get rebuilt into the next pipeline.
1333    let old_pipeline = pipeline.take();
1334    let old_app = app.take();
1335    let old_writer = journal_writer.take();
1336
1337    // Move the local lineage aside — never delete. Divergent journals are
1338    // audit-trail material; stale ones may be the last copy of pruned
1339    // history.
1340    //
1341    // No `check_advertised_genesis` here: a resync adopts the primary's
1342    // lineage wholesale (its seed carries the primary's header) and keeps
1343    // ours intact in the archive, so no recorded genesis length is
1344    // overwritten — the hazard that check guards on the streaming path.
1345    // The genesis length is treated like the chain anchor, which a resync
1346    // also replaces.
1347    let reason = if divergent {
1348        ArchiveReason::Divergent
1349    } else {
1350        ArchiveReason::Resync
1351    };
1352    // The primary starts the transfer as soon as it has sent its verdict,
1353    // and the teardown waits on the journal: serviced (see `serviced`).
1354    source.serviced(move || {
1355        if let Some(p) = old_pipeline {
1356            // What the teardown hands back (the app and writer, or the
1357            // stage's failure) is discarded: the resync archives this
1358            // lineage and installs the primary's in its place.
1359            let _ = teardown_replica_pipeline::<A, W>(p);
1360        }
1361        // Dropped after the teardown and before the archive, as before.
1362        drop(old_app);
1363        drop(old_writer);
1364        // Where the lineage went is logged by the archive itself; the
1365        // resync needs only the paths freed.
1366        archive_local_lineage(store, reason).map(drop)
1367    })?;
1368    // Archived — a retried handshake must present as a fresh replica,
1369    // holding no lineage, so no policy either.
1370    *last_sequence = 0;
1371    *chain_hash = [0u8; 32];
1372    control.lineage_ack_policy.store(None);
1373    // The advertised control-plane tip must shrink with the holdings:
1374    // the archived suffix is either divergent (never acked) or being
1375    // replaced wholesale, so this is the one legitimate tip regression
1376    // (see `AdvertisedJournalTip::reset`).
1377    control
1378        .journal_tip
1379        .reset(melin_transport_core::WireSeq::new(0));
1380
1381    let ResyncTransfer {
1382        app: snap_app,
1383        snap_sequence,
1384        snap_chain_hash,
1385        seed_len,
1386        lineage: snap_lineage,
1387    } = match receive_resync_transfer::<A, S>(source, store) {
1388        Ok(v) => v,
1389        Err(e) => {
1390            tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1391            // Half-applied resync state, not audit material — drop it
1392            // so the retry starts clean (the pre-resync lineage is
1393            // already archived; this is not it).
1394            let _ = std::fs::remove_file(store.snapshot());
1395            return Ok(ResyncDecision::Retry);
1396        }
1397    };
1398    let journal_path = store.journal();
1399    // Adopt the primary's snapshot lineage — the resync rebases this
1400    // replica onto the primary's lineage, its epoch and ack policy
1401    // included. Only now: a transfer that failed part-way adopts nothing.
1402    adopt_recovered_lineage(fence_state, &control.lineage_ack_policy, snap_lineage);
1403    *app = Some(snap_app);
1404
1405    // Open the seeded segment for appending at the snapshot position —
1406    // recovery's resume path: the chain rebuilds from the seeded bytes and
1407    // its value at `snap_sequence` must equal the (verified) snapshot's
1408    // chain hash. `chain_hash()` is `None` only with `hash-chain` disabled
1409    // (nothing to tie); an all-zeros snapshot hash means the primary runs
1410    // without `hash-chain` (also nothing to tie).
1411    // Rebuilds the chain over the whole seed: serviced (see `serviced`).
1412    let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
1413    let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1414    if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1415        return Err(format!(
1416            "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1417             hash — inconsistent primary"
1418        )
1419        .into());
1420    }
1421    let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1422    *journal_writer = Some(writer);
1423
1424    // Validate the post-snapshot StreamStart inline before resuming — its
1425    // lineage must agree with the seed the primary just transferred.
1426    match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1427        PrimaryMessage::StreamStart {
1428            start_sequence,
1429            segment_start_sequence,
1430            anchor_hash,
1431            genesis_entries,
1432            epoch,
1433        } => {
1434            if segment_start_sequence != seeded_info.starting_sequence
1435                || anchor_hash != seeded_info.anchor_hash
1436                || genesis_entries != seeded_info.genesis_entries
1437            {
1438                return Err(format!(
1439                    "post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
1440                     {}) disagrees with the transferred segment seed (start {}, genesis {}) — \
1441                     inconsistent primary",
1442                    describe_genesis(genesis_entries),
1443                    seeded_info.starting_sequence,
1444                    describe_genesis(seeded_info.genesis_entries),
1445                )
1446                .into());
1447            }
1448            // We just rebased onto this primary's snapshot, so adopt its
1449            // epoch wholesale (no stale-primary refusal — our prior state
1450            // was discarded).
1451            // The lineage policy came with the snapshot (adopted above,
1452            // once the transfer succeeded); the stream's changes keep it
1453            // current from here.
1454            fence_state.observe_epoch(epoch);
1455            control.primary_observed.store(true, Ordering::Release);
1456            tracing::info!(
1457                start_sequence,
1458                epoch,
1459                "streaming resumed after snapshot transfer"
1460            );
1461            // The transfer is installed and validated: the node's holdings
1462            // are exactly the snapshot position again.
1463            control
1464                .journal_tip
1465                .reset(melin_transport_core::WireSeq::new(snap_sequence));
1466            Ok(ResyncDecision::Ready {
1467                segment_start_sequence,
1468                anchor_hash,
1469                genesis_entries,
1470                resume_sequence: snap_sequence,
1471            })
1472        }
1473        other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1474    }
1475}
1476
1477/// A genesis length for a log line or an error: the count, or that it
1478/// is unknown.
1479fn describe_genesis(genesis_entries: Option<u64>) -> String {
1480    match genesis_entries {
1481        Some(n) => format!("{n} entries"),
1482        None => "unknown".to_string(),
1483    }
1484}
1485
1486/// The genesis length this replica's journal records, for the
1487/// `StreamStart` cross-check: `None` when the replica has no journal yet
1488/// (nothing to compare), `Some(None)` for a lineage whose length is
1489/// unknown, `Some(Some(n))` otherwise.
1490///
1491/// Never reads the live segment by path while a pipeline runs: the
1492/// journal's disk thread owns the writer then and a rotation renames the
1493/// live segment away before installing its successor, so a read in that
1494/// window would find no file. The pipeline carries the value captured at
1495/// build time instead.
1496pub(in crate::replication) fn local_lineage_genesis<A, W>(
1497    pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1498    journal_writer: &Option<W>,
1499) -> Result<Option<Option<u64>>, melin_journal::JournalError>
1500where
1501    A: Application,
1502    W: JournalWrite<A::Event> + Send + 'static,
1503{
1504    match (pipeline, journal_writer) {
1505        (Some(p), _) => Ok(Some(p.genesis_entries)),
1506        // No pipeline: this thread owns the writer, so the header read
1507        // cannot race a rotation.
1508        (None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
1509        (None, None) => Ok(None),
1510    }
1511}
1512
1513/// Where [`adopt_genesis_into_empty_unknown_journal`] stages the
1514/// rewritten segment before renaming it over the journal.
1515fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
1516    journal_path.with_extension("restamp.tmp")
1517}
1518
1519/// Remove a restamp staging file left by a crash before its rename. The
1520/// rename had not happened, so the live journal is the old segment and
1521/// the staged copy is only debris.
1522fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
1523    match std::fs::remove_file(restamp_staging_path(journal_path)) {
1524        Ok(()) => Ok(()),
1525        // No leftover from an interrupted earlier attempt.
1526        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1527        Err(e) => Err(e),
1528    }
1529}
1530
1531/// Record the primary's genesis length in this replica's journal when
1532/// the journal holds no entry and its header has no length (`None`,
1533/// written by a release before the field existed).
1534///
1535/// The case it closes: a cluster created under such a release whose
1536/// primary stopped before journaling anything, after this replica copied
1537/// its empty journal. The upgraded primary journals its genesis into a
1538/// new header that keeps the anchor (see `genesis_target` in the server
1539/// boot path), so this replica's chain still matches at sequence 0 and it
1540/// streams the genesis — but, left with `None`, it would skip the
1541/// promotion check for exactly the partial genesis that check exists to
1542/// refuse, and its segment would no longer be a copy of the primary's.
1543///
1544/// Only an empty segment starting at sequence 1 with no archive and the
1545/// primary's own starting sequence and anchor is rewritten: nothing was
1546/// journaled, acknowledged or served from it, so replacing its header
1547/// changes no history. The new segment is staged and renamed over the old
1548/// one, so a crash leaves one or the other. Anything else is left alone.
1549///
1550/// Requires no running pipeline: the caller owns `journal_writer`. So it
1551/// covers the upgrade that restarts the replica as well as the primary; a
1552/// replica left running across the primary's re-genesis keeps the unknown
1553/// length — the unchecked behaviour of earlier releases, never a wrong
1554/// refusal.
1555pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
1556    journal_writer: &mut Option<W>,
1557    lineage: (u64, [u8; 32], Option<u64>),
1558) -> Result<(), melin_journal::JournalError>
1559where
1560    E: melin_app::AppEvent,
1561    W: JournalWrite<E>,
1562{
1563    let (segment_start_sequence, anchor_hash, advertised) = lineage;
1564    let Some(writer) = journal_writer.as_ref() else {
1565        return Ok(());
1566    };
1567    if advertised.is_none()
1568        || segment_start_sequence != 1
1569        || writer.segment_starting_sequence() != 1
1570        || writer.next_sequence() != 1
1571    {
1572        return Ok(());
1573    }
1574    let info = writer.read_header_info()?;
1575    if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
1576        return Ok(());
1577    }
1578    let journal_path = writer.path().to_path_buf();
1579    if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
1580        return Ok(());
1581    }
1582
1583    let staging = restamp_staging_path(&journal_path);
1584    remove_restamp_staging(&journal_path)?;
1585    let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
1586    let valid_end = staged.valid_end();
1587    drop(staged);
1588    // Close the old segment before its name is taken over.
1589    drop(journal_writer.take());
1590    std::fs::rename(&staging, &journal_path)?;
1591    melin_journal::segment::fsync_parent_dir(&journal_path)?;
1592    *journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
1593    tracing::info!(
1594        journal = %journal_path.display(),
1595        genesis_entries = ?advertised,
1596        "recorded the primary's genesis length in this replica's empty journal"
1597    );
1598    Ok(())
1599}
1600
1601/// The replica's answer to a live `StreamStart` (not the post-snapshot
1602/// one, which `handle_resync_verdict` validates itself).
1603pub(in crate::replication) enum StreamStartVerdict {
1604    /// The primary is behind this node's fencing epoch: a stale
1605    /// ex-primary. Nothing was adopted; the caller drops the connection
1606    /// and retries with backoff. The handshake already carried our higher
1607    /// epoch, so the stale primary fences itself on its side.
1608    StalePrimary,
1609    /// Follow the primary. Its epoch is adopted, and the primary recorded
1610    /// as observed; the lineage — `(segment start, anchor, genesis length)` — is what a
1611    /// fresh replica creates its journal from.
1612    Follow((u64, [u8; 32], Option<u64>)),
1613}
1614
1615/// The fields of a live `StreamStart` that [`accept_stream_start`] acts
1616/// on, grouped so both receivers hand them over by name rather than as a
1617/// run of positional integers.
1618pub(in crate::replication) struct StreamStart {
1619    /// The sequence the primary resumes streaming from (logged only).
1620    pub start_sequence: u64,
1621    /// The primary's lineage: `(segment start, anchor, genesis length)`.
1622    /// A tuple because it is handed back whole in
1623    /// [`StreamStartVerdict::Follow`] and to the fresh-journal path.
1624    pub lineage: (u64, [u8; 32], Option<u64>),
1625    /// The primary's fencing epoch.
1626    pub epoch: u64,
1627}
1628
1629/// Accept or refuse a primary's `StreamStart`, shared by the kernel-TCP
1630/// and DPDK receivers so the two cannot drift on what following a
1631/// primary means.
1632///
1633/// In order: refuse a primary behind our fencing epoch — following its
1634/// divergent lineage on top of our more-current state would corrupt the
1635/// journal — before anything is touched; record the primary's genesis
1636/// length in an empty pre-upgrade journal; refuse a lineage whose genesis
1637/// length disagrees with ours; then adopt the primary's epoch (streamed
1638/// `EpochBump`s keep it current thereafter) and record that a primary has
1639/// been observed. The ack policy is not taken from the primary: the
1640/// replica learns it from the changes its lineage holds.
1641///
1642/// An `Err` is fatal for the receive loop: the journal could not be read
1643/// or rewritten, or the two journals do not share one lineage.
1644///
1645/// `transport` names the receiver (`"tcp"` or `"dpdk"`) in the refusal
1646/// and streaming-started logs, so an operator running both can tell
1647/// which path a line came from.
1648pub(in crate::replication) fn accept_stream_start<A, W>(
1649    start: StreamStart,
1650    transport: &'static str,
1651    pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1652    journal_writer: &mut Option<W>,
1653    fence_state: &melin_transport_core::fence::FenceState,
1654    control: &ReplicaControlPlane,
1655) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
1656where
1657    A: Application,
1658    W: JournalWrite<A::Event> + Send + 'static,
1659{
1660    let StreamStart {
1661        start_sequence,
1662        lineage,
1663        epoch,
1664    } = start;
1665    if fence_state.refuses_primary(epoch) {
1666        tracing::warn!(
1667            transport,
1668            primary_epoch = epoch,
1669            our_epoch = fence_state.epoch(),
1670            "primary is behind our fencing epoch — refusing to follow stale primary"
1671        );
1672        return Ok(StreamStartVerdict::StalePrimary);
1673    }
1674    // A replica with a journal already records its lineage's genesis
1675    // length; the primary's must agree with it.
1676    if pipeline.is_none() {
1677        adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
1678    }
1679    if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
1680        check_advertised_genesis(local, lineage.2)?;
1681    }
1682    fence_state.observe_epoch(epoch);
1683    control.primary_observed.store(true, Ordering::Release);
1684    tracing::info!(transport, start_sequence, epoch, "streaming started");
1685    Ok(StreamStartVerdict::Follow(lineage))
1686}
1687
1688/// Refuse to follow a primary whose lineage records a different genesis
1689/// length than this replica's journal does.
1690///
1691/// The length is lineage metadata, fixed when the journal was created
1692/// and copied into every segment and every replica's journal since, so
1693/// within one lineage two known values always agree. A difference means
1694/// the two journals do not share the lineage the matching chain hashes
1695/// suggest — a header damaged or edited by hand, or a bug — and nothing
1696/// says which side is right: overwriting ours would silently change what
1697/// a later promotion checks against. Exit and let the operator decide.
1698///
1699/// An unknown value on either side is no conflict: it is a lineage begun
1700/// before the length was recorded (or a peer that kept such a header),
1701/// and it stays as it is — headers are written once.
1702pub(in crate::replication) fn check_advertised_genesis(
1703    local: Option<u64>,
1704    advertised: Option<u64>,
1705) -> Result<(), String> {
1706    match (local, advertised) {
1707        (Some(local), Some(advertised)) if local != advertised => Err(format!(
1708            "refusing to follow the primary: its journal records a genesis of {advertised} \
1709             entries but this replica's records {local} — the two journals do not share one \
1710             lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
1711             then resync this replica from scratch (move its journal, archives and snapshots \
1712             aside) or restore the primary"
1713        )),
1714        _ => Ok(()),
1715    }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use std::io::Write;
1721    use std::sync::Arc;
1722
1723    use super::auth::{authenticate_replica, authenticate_with_primary};
1724    use super::*;
1725    // Any `AppEvent` works as the pipeline's event type here — these
1726    // protocol-level tests never construct a real app event (the slot's
1727    // event is always `JournalEvent::Tick`), so the counter example's
1728    // event type stands in for a production application's event and keeps
1729    // the runtime's test deps free of any application crate but the example.
1730    use counter_server::CounterEvent;
1731    type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1732    use melin_transport_core::replication::protocol::{
1733        MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
1734        MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
1735        decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
1736        encode_auth_failed, encode_auth_ok, encode_chain_check, encode_challenge,
1737        encode_challenge_response, encode_handshake, encode_hash_mismatch, encode_heartbeat,
1738        encode_input_batch, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1739        encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1740        read_frame, try_decode_input_batch,
1741    };
1742
1743    /// Build a wire-ready `InputBatch` frame containing a single `Tick`
1744    /// slot at the given sequence — the protocol-level tests don't need
1745    /// real journal payloads, just something with a known max sequence.
1746    fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1747        let slot = InputSlot {
1748            connection_id: 0,
1749            request_id: melin_transport_core::pipeline::RequestId(0),
1750            key_hash: 0,
1751            sequence: end_sequence,
1752            timestamp_ns: 0,
1753            event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1754            publish_ts: Default::default(),
1755            recv_ts: Default::default(),
1756        };
1757        encode_input_batch(&[slot], buf).expect("encode InputBatch");
1758    }
1759
1760    #[test]
1761    fn handshake_encode_decode_round_trip() {
1762        let handshake = Handshake {
1763            last_sequence: 42,
1764            chain_hash: [0xAB; 32],
1765            // Non-zero so a dropped/zeroed epoch field is caught.
1766            epoch: 9,
1767        };
1768        let mut buf = Vec::new();
1769        encode_handshake(&handshake, &mut buf);
1770
1771        // Read frame: skip 4-byte length prefix.
1772        let payload = &buf[4..];
1773        let msg = decode_replica_message(payload).unwrap();
1774        match msg {
1775            ReplicaMessage::Handshake(h) => {
1776                assert_eq!(h.last_sequence, 42);
1777                assert_eq!(h.chain_hash, [0xAB; 32]);
1778                assert_eq!(h.epoch, 9);
1779            }
1780            _ => panic!("expected Handshake"),
1781        }
1782    }
1783
1784    #[test]
1785    fn ack_encode_decode_round_trip() {
1786        let ack = Ack {
1787            acked_sequence: 1000,
1788            in_memory_sequence: 1024,
1789        };
1790        let mut buf = Vec::new();
1791        encode_ack(&ack, &mut buf);
1792
1793        let payload = &buf[4..];
1794        let msg = decode_replica_message(payload).unwrap();
1795        match msg {
1796            ReplicaMessage::Ack(a) => {
1797                assert_eq!(a.acked_sequence, 1000);
1798                assert_eq!(a.in_memory_sequence, 1024);
1799            }
1800            _ => panic!("expected Ack"),
1801        }
1802    }
1803
1804    /// Pin the exact on-the-wire byte layout of an Ack frame. A future
1805    /// `repr(C)` field reorder, alignment change, or accidental
1806    /// big-endian wrapper substitution would silently break replica/
1807    /// primary compatibility — the const `size_of` assert in
1808    /// `protocol.rs` only catches size changes, not layout changes.
1809    /// This test pins the expected bytes so any such break shows up
1810    /// loudly on the next CI run.
1811    #[test]
1812    fn ack_wire_byte_pattern() {
1813        let ack = Ack {
1814            acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1815            in_memory_sequence: 0x1122_3344_5566_7788,
1816        };
1817        let mut buf = Vec::new();
1818        encode_ack(&ack, &mut buf);
1819        // [length:u32 LE = 17][tag:u8 = MSG_ACK = 0x02]
1820        // [acked_sequence:u64 LE][in_memory_sequence:u64 LE]
1821        let expected: &[u8] = &[
1822            0x11, 0x00, 0x00, 0x00, // length = 17 LE
1823            0x02, // MSG_ACK
1824            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // acked_sequence LE
1825            0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // in_memory_sequence LE
1826        ];
1827        assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1828    }
1829
1830    /// Two known genesis lengths must agree; an unknown one on either
1831    /// side is no conflict (a lineage begun before the length was
1832    /// recorded), and nothing is ever adopted over a known one.
1833    #[test]
1834    fn an_advertised_genesis_length_must_match_a_known_one() {
1835        check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1836        check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1837        let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1838        assert!(err.contains("refusing to follow the primary"), "{err}");
1839        assert!(
1840            err.contains("genesis of 4") && err.contains("records 3"),
1841            "{err}"
1842        );
1843        check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1844        check_advertised_genesis(None, Some(3)).expect("ours unknown");
1845        check_advertised_genesis(Some(3), None).expect("theirs unknown");
1846        check_advertised_genesis(None, None).expect("both unknown");
1847    }
1848
1849    /// An empty journal written before the genesis length existed takes
1850    /// the primary's, so the promotion check guards it; a journal that
1851    /// holds an entry, already has a length, or carries another anchor is
1852    /// left as it is.
1853    #[test]
1854    fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1855        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1856        let dir = tempfile::tempdir().expect("tempdir");
1857        let anchor = [9u8; 32];
1858        let header = |w: &Option<Writer>| {
1859            w.as_ref()
1860                .expect("writer")
1861                .read_header_info()
1862                .expect("header")
1863                .genesis_entries
1864        };
1865
1866        // The pre-upgrade empty journal: adopted, still empty, same anchor.
1867        let path = dir.path().join("empty.journal");
1868        let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1869        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1870        assert_eq!(header(&w), Some(3));
1871        let adopted = w.as_ref().expect("writer");
1872        assert_eq!(adopted.next_sequence(), 1);
1873        assert_eq!(adopted.path(), path.as_path());
1874        assert_eq!(
1875            melin_journal::segment::read_header_info(&path)
1876                .expect("on disk")
1877                .anchor_hash,
1878            anchor
1879        );
1880        assert!(!path.with_extension("restamp.tmp").exists());
1881
1882        // The primary's length is unknown too: nothing to adopt.
1883        let path = dir.path().join("both-unknown.journal");
1884        let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1885        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1886        assert_eq!(header(&w), None);
1887
1888        // Another lineage's anchor: not this primary's journal.
1889        let path = dir.path().join("other-anchor.journal");
1890        let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1891        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1892        assert_eq!(header(&w), None);
1893
1894        // A known length is never rewritten (a mismatch is refused by
1895        // `check_advertised_genesis` instead).
1896        let path = dir.path().join("known.journal");
1897        let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1898        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1899        assert_eq!(header(&w), Some(2));
1900
1901        // A journal holding an entry has history: its header stays.
1902        let path = dir.path().join("history.journal");
1903        let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1904        let seq = writer.allocate_sequence();
1905        writer
1906            .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1907            .expect("encode");
1908        writer.flush_batch_sync().expect("flush");
1909        let mut w = Some(writer);
1910        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1911        assert_eq!(header(&w), None);
1912
1913        // No journal: nothing to do.
1914        let mut none: Option<Writer> = None;
1915        adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1916        assert!(none.is_none());
1917    }
1918
1919    /// A restamp staging file left by a crash before its rename is swept
1920    /// at replica recovery, whatever the journal's state — not only by a
1921    /// later adopt that happens to meet its guard.
1922    #[test]
1923    fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1924        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1925        let dir = tempfile::tempdir().expect("tempdir");
1926        let fence = melin_transport_core::fence::FenceState::new(0);
1927        let journal = dir.path().join("replica.journal");
1928        let store = NodeStore::resolve(journal.clone(), None).expect("store");
1929        let staging = journal.with_extension("restamp.tmp");
1930
1931        // No lineage at all.
1932        std::fs::write(&staging, b"debris").expect("write staging");
1933        let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1934            &store,
1935            &fence,
1936            &SharedLineagePolicy::new(None),
1937            &(),
1938        )
1939        .expect("recover");
1940        assert!(app.is_none() && writer.is_none());
1941        assert!(!staging.exists(), "swept without a journal");
1942
1943        // A journal that holds history, which the adopt guard never touches.
1944        let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1945        let seq = w.allocate_sequence();
1946        w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1947            .expect("encode");
1948        w.flush_batch_sync().expect("flush");
1949        drop(w);
1950        std::fs::write(&staging, b"debris").expect("write staging");
1951        let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
1952            &store,
1953            &fence,
1954            &SharedLineagePolicy::new(None),
1955            &(),
1956        )
1957        .expect("recover");
1958        assert!(writer.is_some());
1959        assert_eq!(last, 1);
1960        assert!(!staging.exists(), "swept beside a journal with history");
1961    }
1962
1963    /// Replica recovery adopts the recovered lineage: the epoch as a
1964    /// maximum, the ack policy stored whole over whatever the cell held —
1965    /// the journaled change (a lower byte than the cell's: stored, not a
1966    /// maximum), or nothing when the journal holds none, or when there is
1967    /// no lineage at all.
1968    #[test]
1969    fn replica_recovery_stores_the_lineage_policy_whole() {
1970        use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
1971        use melin_transport_core::lineage::LineagePolicy;
1972        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1973        let dir = tempfile::tempdir().expect("tempdir");
1974        let fence = melin_transport_core::fence::FenceState::new(0);
1975        let journal = dir.path().join("replica.journal");
1976        let store = NodeStore::resolve(journal.clone(), None).expect("store");
1977        // A value no recovery below produces, so a record left alone
1978        // shows.
1979        let stale = Some(LineagePolicy {
1980            change: AckPolicyChange {
1981                policy: AckPolicyByte(2),
1982                unwitnessed: true,
1983            },
1984            wire_seq: melin_transport_core::WireSeq::new(9),
1985            previous: Some(AckPolicyByte(1)),
1986        });
1987        let cell = SharedLineagePolicy::new(stale);
1988        let recover = |cell: &SharedLineagePolicy| {
1989            recover_replica_state::<counter_server::Counter, Writer>(&store, &fence, cell, &())
1990                .expect("recover")
1991        };
1992
1993        // No lineage: nothing held.
1994        recover(&cell);
1995        assert_eq!(cell.load(), None);
1996
1997        // A journal with no change: nothing held either.
1998        let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1999        let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
2000            let seq = w.allocate_sequence();
2001            w.encode_event(seq, 0, &event, 0).expect("encode");
2002            w.flush_batch_sync().expect("flush");
2003        };
2004        append(&mut w, JournalEvent::Tick { now_ns: 0 });
2005        cell.store(stale);
2006        recover(&cell);
2007        assert_eq!(cell.load(), None);
2008
2009        append(&mut w, JournalEvent::EpochBump { epoch: 4 });
2010        append(
2011            &mut w,
2012            JournalEvent::AckPolicyChange(AckPolicyChange {
2013                policy: AckPolicyByte(0),
2014                unwitnessed: false,
2015            }),
2016        );
2017        drop(w);
2018        cell.store(stale);
2019        recover(&cell);
2020        assert_eq!(
2021            cell.load(),
2022            Some(LineagePolicy {
2023                change: AckPolicyChange {
2024                    policy: AckPolicyByte(0),
2025                    unwitnessed: false,
2026                },
2027                wire_seq: melin_transport_core::WireSeq::new(3),
2028                previous: None,
2029            }),
2030            "the journal's own record, at the change's sequence"
2031        );
2032        assert_eq!(fence.epoch(), 4);
2033    }
2034
2035    /// A primary behind this node's fencing epoch is refused before
2036    /// anything is adopted: not its epoch, not its genesis length into our
2037    /// journal, and it does not count as a primary observed.
2038    #[test]
2039    fn a_stale_primary_is_refused_before_anything_is_adopted() {
2040        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2041        let dir = tempfile::tempdir().expect("tempdir");
2042        let path = dir.path().join("replica.journal");
2043        let anchor = [9u8; 32];
2044        let mut journal_writer =
2045            Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2046        let fence = melin_transport_core::fence::FenceState::new(5);
2047        let control = ReplicaControlPlane::new();
2048
2049        let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2050            StreamStart {
2051                start_sequence: 1,
2052                lineage: (1, anchor, Some(3)),
2053                epoch: 4,
2054            },
2055            "test",
2056            &None,
2057            &mut journal_writer,
2058            &fence,
2059            &control,
2060        )
2061        .expect("a refusal is not an error");
2062
2063        assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
2064        assert_eq!(fence.epoch(), 5);
2065        assert!(!control.primary_observed.load(Ordering::Acquire));
2066        let header = journal_writer
2067            .as_ref()
2068            .expect("writer")
2069            .read_header_info()
2070            .expect("header");
2071        assert_eq!(header.genesis_entries, None, "journal left untouched");
2072    }
2073
2074    /// Following a primary adopts its epoch, records it as observed and
2075    /// its genesis length in an empty pre-upgrade journal, and hands back
2076    /// the lineage the fresh-journal path creates from. The lineage's ack
2077    /// policy is left to the entries this replica holds.
2078    #[test]
2079    fn following_a_primary_adopts_its_epoch_and_genesis() {
2080        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2081        let dir = tempfile::tempdir().expect("tempdir");
2082        let path = dir.path().join("replica.journal");
2083        let anchor = [9u8; 32];
2084        let mut journal_writer =
2085            Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2086        let fence = melin_transport_core::fence::FenceState::new(5);
2087        let control = ReplicaControlPlane::new();
2088        let held = Some(melin_transport_core::lineage::LineagePolicy::after(
2089            None,
2090            melin_transport_core::WireSeq::new(1),
2091            melin_journal::AckPolicyChange {
2092                policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
2093                unwitnessed: false,
2094            },
2095        ));
2096        control.lineage_ack_policy.store(held);
2097
2098        let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2099            StreamStart {
2100                start_sequence: 1,
2101                lineage: (1, anchor, Some(3)),
2102                epoch: 7,
2103            },
2104            "test",
2105            &None,
2106            &mut journal_writer,
2107            &fence,
2108            &control,
2109        )
2110        .expect("same lineage");
2111
2112        let StreamStartVerdict::Follow(lineage) = verdict else {
2113            panic!("expected Follow");
2114        };
2115        assert_eq!(lineage, (1, anchor, Some(3)));
2116        assert_eq!(fence.epoch(), 7);
2117        assert!(control.primary_observed.load(Ordering::Acquire));
2118        assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
2119        let header = journal_writer
2120            .as_ref()
2121            .expect("writer")
2122            .read_header_info()
2123            .expect("header");
2124        assert_eq!(header.genesis_entries, Some(3));
2125    }
2126
2127    /// A primary whose genesis length disagrees with ours is fatal, and
2128    /// its epoch is not adopted on the way out.
2129    #[test]
2130    fn a_disagreeing_genesis_length_is_fatal() {
2131        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2132        let dir = tempfile::tempdir().expect("tempdir");
2133        let path = dir.path().join("replica.journal");
2134        let anchor = [9u8; 32];
2135        let mut journal_writer =
2136            Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2137        let fence = melin_transport_core::fence::FenceState::new(5);
2138        let control = ReplicaControlPlane::new();
2139
2140        let err = accept_stream_start::<counter_server::Counter, Writer>(
2141            StreamStart {
2142                start_sequence: 1,
2143                lineage: (1, anchor, Some(3)),
2144                epoch: 7,
2145            },
2146            "test",
2147            &None,
2148            &mut journal_writer,
2149            &fence,
2150            &control,
2151        )
2152        .err()
2153        .expect("different lineages");
2154
2155        assert!(err.to_string().contains("refusing to follow the primary"));
2156        assert_eq!(fence.epoch(), 5);
2157    }
2158
2159    /// The handshake's local genesis length comes from the running
2160    /// pipeline, never from the live segment's path: a rotation renames
2161    /// that file away before installing its successor, and a read in the
2162    /// gap would fail the session. Removing the file stands in for that
2163    /// gap. Without a pipeline the writer's own header is read, and with
2164    /// no journal at all there is nothing to compare.
2165    #[test]
2166    fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2167        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2168        let dir = tempfile::tempdir().expect("tempdir");
2169        let path = dir.path().join("replica.journal");
2170
2171        let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2172        let mut journal_writer = Some(writer);
2173        assert_eq!(
2174            local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2175                .expect("header read"),
2176            Some(Some(5)),
2177        );
2178
2179        let (input_producer, mut consumers) =
2180            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2181                .add_consumer()
2182                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2183        let _consumer = consumers.pop().expect("one consumer");
2184        let owned = journal_writer.take().expect("writer");
2185        let pipeline = Some(ReplicaPipelineHandles {
2186            input_producer,
2187            journal_cursor: Arc::new(make_journal_cursor(0)),
2188            chain_hash_lock: fsync_state_at(0, 0).1,
2189            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
2190            genesis_entries: Some(5),
2191            journal_failed: Arc::new(AtomicBool::new(false)),
2192            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2193            journal_handle: std::thread::spawn(
2194                move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2195            ),
2196            matching_handle: std::thread::spawn(counter_server::Counter::default),
2197            drain_handle: std::thread::spawn(|| {}),
2198            shadow_handle: None,
2199        });
2200        std::fs::remove_file(&path).expect("simulate the rotation gap");
2201        assert_eq!(
2202            local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2203            Some(Some(5)),
2204        );
2205
2206        assert_eq!(
2207            local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2208                .expect("nothing to read"),
2209            None,
2210        );
2211    }
2212
2213    #[test]
2214    fn stream_start_encode_decode_round_trip() {
2215        let mut buf = Vec::new();
2216        // Non-zero epoch so a dropped/zeroed field is caught.
2217        encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
2218
2219        let payload = &buf[4..];
2220        let msg = decode_primary_message(payload).unwrap();
2221        match msg {
2222            PrimaryMessage::StreamStart {
2223                start_sequence,
2224                segment_start_sequence,
2225                anchor_hash,
2226                genesis_entries,
2227                epoch,
2228            } => {
2229                assert_eq!(start_sequence, 99);
2230                assert_eq!(segment_start_sequence, 42);
2231                assert_eq!(anchor_hash, [0xAA; 32]);
2232                assert_eq!(genesis_entries, Some(7));
2233                assert_eq!(epoch, 5);
2234            }
2235            _ => panic!("expected StreamStart"),
2236        }
2237    }
2238
2239    #[test]
2240    fn heartbeat_encode_decode_round_trip() {
2241        let mut buf = Vec::new();
2242        encode_heartbeat(123, &mut buf);
2243
2244        let payload = &buf[4..];
2245        let msg = decode_primary_message(payload).unwrap();
2246        match msg {
2247            PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
2248            _ => panic!("expected Heartbeat"),
2249        }
2250    }
2251
2252    #[test]
2253    fn need_snapshot_encode_decode_round_trip() {
2254        let mut buf = Vec::new();
2255        encode_need_snapshot(&mut buf);
2256
2257        let payload = &buf[4..];
2258        let msg = decode_primary_message(payload).unwrap();
2259        assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2260    }
2261
2262    #[test]
2263    fn hash_mismatch_encode_decode_round_trip() {
2264        let mut buf = Vec::new();
2265        encode_hash_mismatch(&mut buf);
2266
2267        let payload = &buf[4..];
2268        let msg = decode_primary_message(payload).unwrap();
2269        assert!(matches!(msg, PrimaryMessage::HashMismatch));
2270    }
2271
2272    #[test]
2273    fn rotate_encode_decode_round_trip() {
2274        let mut buf = Vec::new();
2275        encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2276
2277        let payload = &buf[4..];
2278        let msg = decode_primary_message(payload).unwrap();
2279        match msg {
2280            PrimaryMessage::Rotate {
2281                boundary_seq,
2282                tail_hash,
2283            } => {
2284                assert_eq!(boundary_seq, 7_000_000);
2285                assert_eq!(tail_hash, [0xBB; 32]);
2286            }
2287            _ => panic!("expected Rotate"),
2288        }
2289    }
2290
2291    #[test]
2292    fn chain_check_encode_decode_round_trip() {
2293        let mut buf = Vec::new();
2294        encode_chain_check(555, &[0xCC; 32], &mut buf);
2295
2296        let payload = &buf[4..];
2297        let msg = decode_primary_message(payload).unwrap();
2298        match msg {
2299            PrimaryMessage::ChainCheck {
2300                sequence,
2301                chain_hash,
2302            } => {
2303                assert_eq!(sequence, 555);
2304                assert_eq!(chain_hash, [0xCC; 32]);
2305            }
2306            _ => panic!("expected ChainCheck"),
2307        }
2308    }
2309
2310    /// Pin the wire layout of the shared `(sequence, hash)` frame body —
2311    /// same rationale as `ack_wire_byte_pattern`. Rotate and ChainCheck
2312    /// share the layout; only the tag differs.
2313    #[test]
2314    fn rotate_wire_byte_pattern() {
2315        let mut hash = [0u8; 32];
2316        for (i, b) in hash.iter_mut().enumerate() {
2317            *b = i as u8;
2318        }
2319        let mut buf = Vec::new();
2320        encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2321        // [length:u32 LE = 41][tag:u8 = MSG_ROTATE = 0x16]
2322        // [boundary_seq:u64 LE][tail_hash:32 bytes verbatim]
2323        let mut expected = vec![
2324            0x29, 0x00, 0x00, 0x00, // length = 41 LE
2325            0x16, // MSG_ROTATE
2326            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // boundary_seq LE
2327        ];
2328        expected.extend_from_slice(&hash);
2329        assert_eq!(
2330            buf.as_slice(),
2331            expected.as_slice(),
2332            "Rotate wire layout drifted"
2333        );
2334    }
2335
2336    #[test]
2337    fn segment_seed_begin_encode_decode_round_trip() {
2338        let mut buf = Vec::new();
2339        encode_segment_seed_begin(123_456, &mut buf);
2340
2341        let payload = &buf[4..];
2342        let msg = decode_primary_message(payload).unwrap();
2343        match msg {
2344            PrimaryMessage::SegmentSeedBegin { seed_len } => {
2345                assert_eq!(seed_len, 123_456);
2346            }
2347            _ => panic!("expected SegmentSeedBegin"),
2348        }
2349    }
2350
2351    #[test]
2352    fn unknown_replica_message_type_is_error() {
2353        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2354        let result = decode_replica_message(&payload);
2355        assert!(result.is_err());
2356    }
2357
2358    #[test]
2359    fn unknown_primary_message_type_is_error() {
2360        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2361        let result = decode_primary_message(&payload);
2362        assert!(result.is_err());
2363    }
2364
2365    #[test]
2366    fn read_frame_enforces_max_size() {
2367        // Create a buffer with a length prefix claiming 1000 bytes.
2368        let mut buf = Vec::new();
2369        buf.extend_from_slice(&1000u32.to_le_bytes());
2370        buf.extend_from_slice(&[0u8; 100]); // not enough data, but max_size check comes first
2371
2372        let mut cursor = std::io::Cursor::new(buf);
2373        let result = read_frame(&mut cursor, 64);
2374        assert!(result.is_err());
2375    }
2376
2377    #[test]
2378    fn challenge_encode_decode_round_trip() {
2379        let nonce = [0x42; 32];
2380        let mut buf = Vec::new();
2381        encode_challenge(&nonce, &mut buf);
2382
2383        let payload = &buf[4..];
2384        let decoded = decode_challenge(payload).unwrap();
2385        assert_eq!(decoded, nonce);
2386    }
2387
2388    #[test]
2389    fn challenge_response_encode_decode_round_trip() {
2390        let sig = [0xAA; 64];
2391        let pubkey = [0xBB; 32];
2392        let mut buf = Vec::new();
2393        encode_challenge_response(&sig, &pubkey, &mut buf);
2394
2395        let payload = &buf[4..];
2396        let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2397        assert_eq!(decoded_sig, sig);
2398        assert_eq!(decoded_pubkey, pubkey);
2399    }
2400
2401    #[test]
2402    fn auth_ok_encode_decode_round_trip() {
2403        let mut buf = Vec::new();
2404        encode_auth_ok(&mut buf);
2405
2406        let payload = &buf[4..];
2407        assert!(decode_auth_result(payload).unwrap());
2408    }
2409
2410    #[test]
2411    fn auth_failed_encode_decode_round_trip() {
2412        let mut buf = Vec::new();
2413        encode_auth_failed(&mut buf);
2414
2415        let payload = &buf[4..];
2416        assert!(!decode_auth_result(payload).unwrap());
2417    }
2418
2419    #[test]
2420    fn decode_challenge_rejects_wrong_tag() {
2421        let mut payload = [0u8; 33];
2422        payload[0] = MSG_AUTH_OK;
2423        assert!(decode_challenge(&payload).is_err());
2424    }
2425
2426    #[test]
2427    fn decode_challenge_response_rejects_short_payload() {
2428        let payload = [MSG_CHALLENGE_RESPONSE; 10]; // too short
2429        assert!(decode_challenge_response(&payload).is_err());
2430    }
2431
2432    #[test]
2433    fn auth_round_trip_valid_key() {
2434        use ed25519_dalek::SigningKey;
2435        use std::os::unix::net::UnixStream;
2436
2437        let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2438        let pub_b64 = base64::Engine::encode(
2439            &base64::engine::general_purpose::STANDARD,
2440            repl_key.verifying_key().to_bytes(),
2441        );
2442        let keys_content = format!("replication {pub_b64} test-replica\n");
2443        let authorized_keys =
2444            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2445                .unwrap();
2446
2447        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2448        primary_stream
2449            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2450            .unwrap();
2451        replica_stream
2452            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2453            .unwrap();
2454
2455        let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2456        let replica_handle = std::thread::spawn(move || {
2457            let mut conn = replica_stream;
2458            authenticate_with_primary(&mut conn, &repl_key_clone)
2459        });
2460
2461        let mut conn = primary_stream;
2462        authenticate_replica(&mut conn, &authorized_keys).unwrap();
2463
2464        replica_handle.join().unwrap().unwrap();
2465    }
2466
2467    #[test]
2468    fn auth_rejects_unknown_key() {
2469        use ed25519_dalek::SigningKey;
2470        use std::os::unix::net::UnixStream;
2471
2472        // authorized_keys has one key, but the replica uses a different one.
2473        let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2474        let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2475        let pub_b64 = base64::Engine::encode(
2476            &base64::engine::general_purpose::STANDARD,
2477            authorized_key.verifying_key().to_bytes(),
2478        );
2479        let keys_content = format!("replication {pub_b64} authorized-replica\n");
2480        let authorized_keys =
2481            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2482                .unwrap();
2483
2484        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2485        primary_stream
2486            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2487            .unwrap();
2488        replica_stream
2489            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2490            .unwrap();
2491
2492        let replica_handle = std::thread::spawn(move || {
2493            let mut conn = replica_stream;
2494            authenticate_with_primary(&mut conn, &rogue_key)
2495        });
2496
2497        let mut conn = primary_stream;
2498        let result = authenticate_replica(&mut conn, &authorized_keys);
2499        assert!(result.is_err());
2500        assert!(result.unwrap_err().to_string().contains("unknown"));
2501
2502        // Replica should also get a rejection.
2503        let replica_result = replica_handle.join().unwrap();
2504        assert!(replica_result.is_err());
2505    }
2506
2507    #[test]
2508    fn auth_rejects_wrong_permission() {
2509        use ed25519_dalek::SigningKey;
2510        use std::os::unix::net::UnixStream;
2511
2512        // Key exists but is listed as trader, not replication.
2513        let key = SigningKey::from_bytes(&[0xCC; 32]);
2514        let pub_b64 = base64::Engine::encode(
2515            &base64::engine::general_purpose::STANDARD,
2516            key.verifying_key().to_bytes(),
2517        );
2518        let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2519
2520        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2521        primary_stream
2522            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2523            .unwrap();
2524        replica_stream
2525            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2526            .unwrap();
2527
2528        let replica_handle = std::thread::spawn(move || {
2529            let mut conn = replica_stream;
2530            authenticate_with_primary(&mut conn, &key)
2531        });
2532
2533        let mut conn = primary_stream;
2534        let result = authenticate_replica(&mut conn, &authorized_keys);
2535        assert!(result.is_err());
2536        assert_eq!(
2537            result.unwrap_err().to_string(),
2538            "key listed as trader, expected replication"
2539        );
2540
2541        let replica_result = replica_handle.join().unwrap();
2542        assert!(replica_result.is_err());
2543    }
2544
2545    /// A replica that sends a validly-formatted but tampered signature
2546    /// (correct public key, wrong signature bytes) is rejected.
2547    #[test]
2548    fn auth_rejects_invalid_signature() {
2549        use ed25519_dalek::SigningKey;
2550        use std::os::unix::net::UnixStream;
2551
2552        // Register the correct key.
2553        let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2554        let pub_b64 = base64::Engine::encode(
2555            &base64::engine::general_purpose::STANDARD,
2556            correct_key.verifying_key().to_bytes(),
2557        );
2558        let keys_content = format!("replication {pub_b64} test-replica\n");
2559        let authorized_keys =
2560            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2561                .unwrap();
2562
2563        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2564        primary_stream
2565            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2566            .unwrap();
2567        replica_stream
2568            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2569            .unwrap();
2570
2571        // Replica side: read challenge, but sign with a DIFFERENT key,
2572        // then send the response with the correct public key (spoofing).
2573        let replica_handle = std::thread::spawn(move || {
2574            use melin_transport_core::replication::protocol::*;
2575
2576            let mut reader = replica_stream.try_clone().unwrap();
2577            let mut writer = replica_stream;
2578
2579            // Read the challenge.
2580            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2581            let nonce = decode_challenge(&frame).unwrap();
2582
2583            // Sign with a WRONG key but send the CORRECT public key.
2584            let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2585            let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2586            let correct_pubkey = correct_key.verifying_key();
2587
2588            let mut buf = Vec::with_capacity(128);
2589            encode_challenge_response(
2590                &bad_signature.to_bytes(),
2591                correct_pubkey.as_bytes(),
2592                &mut buf,
2593            );
2594            writer.write_all(&buf).unwrap();
2595            writer.flush().unwrap();
2596
2597            // Should receive AuthFailed.
2598            let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2599            let ok = decode_auth_result(&result_frame).unwrap();
2600            assert!(!ok, "should receive auth failure");
2601        });
2602
2603        let mut conn = primary_stream;
2604        let result = authenticate_replica(&mut conn, &authorized_keys);
2605        assert!(result.is_err());
2606        assert!(
2607            result
2608                .unwrap_err()
2609                .to_string()
2610                .contains("signature verification failed"),
2611            "should fail on signature verification"
2612        );
2613
2614        replica_handle.join().unwrap();
2615    }
2616
2617    #[test]
2618    fn sender_receiver_end_to_end() {
2619        use std::os::unix::net::UnixStream;
2620
2621        // Create a mock connection.
2622        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2623
2624        // Spawn a thread simulating the replica side.
2625        let replica_handle = std::thread::spawn(move || {
2626            let mut reader = replica_stream.try_clone().unwrap();
2627            let mut writer = replica_stream;
2628
2629            // Send handshake.
2630            let mut buf = Vec::new();
2631            let handshake = Handshake {
2632                last_sequence: 0,
2633                chain_hash: [0u8; 32],
2634                epoch: 0,
2635            };
2636            encode_handshake(&handshake, &mut buf);
2637            writer.write_all(&buf).unwrap();
2638            writer.flush().unwrap();
2639            buf.clear();
2640
2641            // Read StreamStart.
2642            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2643            let msg = decode_primary_message(&frame).unwrap();
2644            assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2645
2646            // Read InputBatch.
2647            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2648            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2649            let end_seq = slots
2650                .last()
2651                .map(|s| s.sequence)
2652                .expect("InputBatch carried at least one slot");
2653
2654            // Send ack.
2655            let ack = Ack {
2656                acked_sequence: end_seq,
2657                in_memory_sequence: end_seq,
2658            };
2659            encode_ack(&ack, &mut buf);
2660            writer.write_all(&buf).unwrap();
2661            writer.flush().unwrap();
2662
2663            end_seq
2664        });
2665
2666        // Primary side: simulate handle_replica_connection partially.
2667        let mut p_reader = primary_stream.try_clone().unwrap();
2668        let mut p_writer = primary_stream;
2669
2670        // Read handshake.
2671        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2672        let handshake = match decode_replica_message(&frame).unwrap() {
2673            ReplicaMessage::Handshake(h) => h,
2674            _ => panic!("expected Handshake"),
2675        };
2676        assert_eq!(handshake.last_sequence, 0);
2677
2678        // Send StreamStart.
2679        let mut buf = Vec::new();
2680        encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); // fake lineage for test
2681        p_writer.write_all(&buf).unwrap();
2682        p_writer.flush().unwrap();
2683        buf.clear();
2684
2685        // Send an InputBatch with a single Tick slot at seq 42.
2686        encode_input_batch_with_seq(42, &mut buf);
2687        p_writer.write_all(&buf).unwrap();
2688        p_writer.flush().unwrap();
2689        buf.clear();
2690
2691        // Read ack.
2692        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2693        let ack = match decode_replica_message(&frame).unwrap() {
2694            ReplicaMessage::Ack(a) => a,
2695            _ => panic!("expected Ack"),
2696        };
2697        assert_eq!(ack.acked_sequence, 42);
2698
2699        // Join replica thread.
2700        let end_seq = replica_handle.join().unwrap();
2701        assert_eq!(end_seq, 42);
2702    }
2703
2704    #[test]
2705    fn multiple_data_batches_acked_in_order() {
2706        // Send multiple InputBatch frames, verify replica acks each one
2707        // and the cursor advances correctly.
2708        use std::os::unix::net::UnixStream;
2709
2710        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2711
2712        let replica_handle = std::thread::spawn(move || {
2713            let mut reader = replica_stream.try_clone().unwrap();
2714            let mut writer = replica_stream;
2715            let mut buf = Vec::new();
2716
2717            // Send handshake.
2718            encode_handshake(
2719                &Handshake {
2720                    last_sequence: 0,
2721                    chain_hash: [0u8; 32],
2722                    epoch: 0,
2723                },
2724                &mut buf,
2725            );
2726            writer.write_all(&buf).unwrap();
2727            writer.flush().unwrap();
2728            buf.clear();
2729
2730            // Read StreamStart.
2731            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2732            assert!(matches!(
2733                decode_primary_message(&frame).unwrap(),
2734                PrimaryMessage::StreamStart { .. }
2735            ));
2736
2737            // Read and ack 3 InputBatches.
2738            let mut acked_seqs = Vec::new();
2739            for _ in 0..3 {
2740                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2741                let slots: Vec<InputSlot> =
2742                    try_decode_input_batch(&frame).expect("decode InputBatch");
2743                let end_seq = slots
2744                    .last()
2745                    .map(|s| s.sequence)
2746                    .expect("InputBatch carried at least one slot");
2747                acked_seqs.push(end_seq);
2748
2749                encode_ack(
2750                    &Ack {
2751                        acked_sequence: end_seq,
2752                        in_memory_sequence: end_seq,
2753                    },
2754                    &mut buf,
2755                );
2756                writer.write_all(&buf).unwrap();
2757                writer.flush().unwrap();
2758                buf.clear();
2759            }
2760
2761            acked_seqs
2762        });
2763
2764        // Primary side.
2765        let mut p_reader = primary_stream.try_clone().unwrap();
2766        let mut p_writer = primary_stream;
2767        let mut buf = Vec::new();
2768
2769        // Read handshake.
2770        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2771        assert!(matches!(
2772            decode_replica_message(&frame).unwrap(),
2773            ReplicaMessage::Handshake(_)
2774        ));
2775
2776        // Send StreamStart.
2777        encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
2778        p_writer.write_all(&buf).unwrap();
2779        p_writer.flush().unwrap();
2780        buf.clear();
2781
2782        // Send 3 InputBatches with increasing sequence numbers.
2783        for seq in [10u64, 20, 30] {
2784            encode_input_batch_with_seq(seq, &mut buf);
2785            p_writer.write_all(&buf).unwrap();
2786            p_writer.flush().unwrap();
2787            buf.clear();
2788        }
2789
2790        // Read 3 acks.
2791        for expected_seq in [10u64, 20, 30] {
2792            let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2793            let ack = match decode_replica_message(&frame).unwrap() {
2794                ReplicaMessage::Ack(a) => a,
2795                other => panic!("expected Ack, got {other:?}"),
2796            };
2797            assert_eq!(ack.acked_sequence, expected_seq);
2798        }
2799
2800        let acked = replica_handle.join().unwrap();
2801        assert_eq!(acked, vec![10, 20, 30]);
2802    }
2803
2804    #[test]
2805    fn heartbeat_encode_contains_sequence() {
2806        // Heartbeat messages carry the last known sequence so the replica
2807        // can verify it hasn't missed any data.
2808        let mut buf = Vec::new();
2809        encode_heartbeat(999, &mut buf);
2810
2811        let payload = &buf[4..];
2812        match decode_primary_message(payload).unwrap() {
2813            PrimaryMessage::Heartbeat { sequence, .. } => {
2814                assert_eq!(sequence, 999);
2815            }
2816            other => panic!("expected Heartbeat, got {other:?}"),
2817        }
2818    }
2819
2820    #[test]
2821    fn replica_mid_stream_handshake_with_nonzero_sequence() {
2822        // A replica that already has some data sends a non-zero last_sequence
2823        // in its handshake. The primary should respond with StreamStart
2824        // containing that sequence, and the replica should only receive
2825        // events after that point.
2826        use std::os::unix::net::UnixStream;
2827
2828        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2829
2830        let replica_handle = std::thread::spawn(move || {
2831            let mut reader = replica_stream.try_clone().unwrap();
2832            let mut writer = replica_stream;
2833            let mut buf = Vec::new();
2834
2835            // Replica already has events up to sequence 100.
2836            encode_handshake(
2837                &Handshake {
2838                    last_sequence: 100,
2839                    chain_hash: [0xBB; 32],
2840                    epoch: 0,
2841                },
2842                &mut buf,
2843            );
2844            writer.write_all(&buf).unwrap();
2845            writer.flush().unwrap();
2846            buf.clear();
2847
2848            // Read StreamStart — should echo back our last_sequence.
2849            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2850            match decode_primary_message(&frame).unwrap() {
2851                PrimaryMessage::StreamStart { start_sequence, .. } => {
2852                    assert_eq!(
2853                        start_sequence, 100,
2854                        "StreamStart should echo replica's last_sequence"
2855                    );
2856                }
2857                other => panic!("expected StreamStart, got {other:?}"),
2858            }
2859
2860            // Read an InputBatch — should be for events AFTER 100.
2861            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2862            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2863            let end_sequence = slots
2864                .last()
2865                .map(|s| s.sequence)
2866                .expect("InputBatch carried at least one slot");
2867            assert!(
2868                end_sequence > 100,
2869                "InputBatch should be after replica's last_sequence"
2870            );
2871        });
2872
2873        // Primary side.
2874        let mut p_reader = primary_stream.try_clone().unwrap();
2875        let mut p_writer = primary_stream;
2876        let mut buf = Vec::new();
2877
2878        // Read handshake.
2879        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2880        let handshake = match decode_replica_message(&frame).unwrap() {
2881            ReplicaMessage::Handshake(h) => h,
2882            _ => panic!("expected Handshake"),
2883        };
2884        assert_eq!(handshake.last_sequence, 100);
2885        assert_eq!(handshake.chain_hash, [0xBB; 32]);
2886
2887        // Send StreamStart echoing the replica's sequence.
2888        encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
2889        p_writer.write_all(&buf).unwrap();
2890        p_writer.flush().unwrap();
2891        buf.clear();
2892
2893        // Send InputBatch with sequence 150 (after replica's 100).
2894        encode_input_batch_with_seq(150, &mut buf);
2895        p_writer.write_all(&buf).unwrap();
2896        p_writer.flush().unwrap();
2897
2898        replica_handle.join().unwrap();
2899    }
2900
2901    /// The receiver's stale-primary refusal: a replica that has observed
2902    /// epoch 5 must refuse a `StreamStart` advertising a *lower* epoch
2903    /// rather than follow that (divergent) lineage on top of its newer
2904    /// state. This reproduces the exact decision `run_receiver` makes on
2905    /// the normal-resume `StreamStart` — decode the frame off the wire,
2906    /// then `fence_state.refuses_primary(epoch)` — over a real socket so
2907    /// the epoch wire field and the policy are exercised together.
2908    ///
2909    /// In a current-build cluster this branch is a *second* line of
2910    /// defense: a primary reads the replica's higher-epoch handshake and
2911    /// self-demotes before it ever sends a `StreamStart` (see
2912    /// `tcp_sender::handle_replica_connection`). The receiver check guards
2913    /// the case where the primary does *not* fence — a non-fencing or
2914    /// older build — which is exactly what the mock primary below is.
2915    #[test]
2916    fn receiver_refuses_stream_start_from_stale_primary() {
2917        use std::os::unix::net::UnixStream;
2918
2919        use melin_transport_core::fence::FenceState;
2920
2921        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2922
2923        // Mock primary that — unlike a current build — does *not* fence on
2924        // the handshake and streams its stale lineage anyway. It advertises
2925        // epoch 3 on the StreamStart.
2926        const STALE_EPOCH: u64 = 3;
2927        let primary_handle = std::thread::spawn(move || {
2928            let mut reader = primary_stream.try_clone().unwrap();
2929            let mut writer = primary_stream;
2930
2931            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2932            let handshake = match decode_replica_message(&frame).unwrap() {
2933                ReplicaMessage::Handshake(h) => h,
2934                _ => panic!("expected Handshake"),
2935            };
2936            // The replica truthfully advertises its higher epoch; a correct
2937            // primary would fence here. This mock deliberately doesn't.
2938            assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2939
2940            let mut buf = Vec::new();
2941            encode_stream_start(
2942                handshake.last_sequence,
2943                1,
2944                [0u8; 32],
2945                Some(0),
2946                STALE_EPOCH,
2947                &mut buf,
2948            );
2949            writer.write_all(&buf).unwrap();
2950            writer.flush().unwrap();
2951        });
2952
2953        // Replica side: epoch 5, mirroring `run_receiver`'s handshake +
2954        // StreamStart handling.
2955        let fence_state = Arc::new(FenceState::new(5));
2956        let mut r_reader = replica_stream.try_clone().unwrap();
2957        let mut r_writer = replica_stream;
2958
2959        let mut buf = Vec::new();
2960        encode_handshake(
2961            &Handshake {
2962                last_sequence: 0,
2963                chain_hash: [0u8; 32],
2964                epoch: fence_state.epoch(),
2965            },
2966            &mut buf,
2967        );
2968        r_writer.write_all(&buf).unwrap();
2969        r_writer.flush().unwrap();
2970
2971        let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2972        let stream_epoch = match decode_primary_message(&frame).unwrap() {
2973            PrimaryMessage::StreamStart { epoch, .. } => epoch,
2974            other => panic!("expected StreamStart, got {other:?}"),
2975        };
2976        assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2977
2978        // This is the receiver's refusal decision. A stale primary must be
2979        // refused; the replica's own epoch must not have been lowered.
2980        assert!(
2981            fence_state.refuses_primary(stream_epoch),
2982            "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2983        );
2984        assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2985
2986        // Sanity: an equal or newer epoch is followed, not refused (the
2987        // ex-primary-rejoin path). Guards against an inverted comparison.
2988        assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2989        assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2990
2991        primary_handle.join().unwrap();
2992    }
2993
2994    #[test]
2995    fn snapshot_begin_encode_decode_round_trip() {
2996        let mut buf = Vec::new();
2997        encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2998
2999        let payload = &buf[4..];
3000        let msg = decode_primary_message(payload).unwrap();
3001        match msg {
3002            PrimaryMessage::SnapshotBegin {
3003                snapshot_len,
3004                snap_sequence,
3005                snap_chain_hash,
3006            } => {
3007                assert_eq!(snapshot_len, 1_000_000);
3008                assert_eq!(snap_sequence, 42);
3009                assert_eq!(snap_chain_hash, [0xAB; 32]);
3010            }
3011            _ => panic!("expected SnapshotBegin"),
3012        }
3013    }
3014
3015    #[test]
3016    fn snapshot_chunk_encode_decode_round_trip() {
3017        let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
3018        let mut buf = Vec::new();
3019        encode_snapshot_chunk(&data, &mut buf);
3020
3021        let payload = &buf[4..];
3022        let msg = decode_primary_message(payload).unwrap();
3023        match msg {
3024            PrimaryMessage::SnapshotChunk(chunk) => {
3025                assert_eq!(chunk, data);
3026            }
3027            _ => panic!("expected SnapshotChunk"),
3028        }
3029    }
3030
3031    #[test]
3032    fn snapshot_end_encode_decode_round_trip() {
3033        let mut buf = Vec::new();
3034        encode_snapshot_end(0xDEADBEEF, &mut buf);
3035
3036        let payload = &buf[4..];
3037        let msg = decode_primary_message(payload).unwrap();
3038        match msg {
3039            PrimaryMessage::SnapshotEnd { crc32c } => {
3040                assert_eq!(crc32c, 0xDEADBEEF);
3041            }
3042            _ => panic!("expected SnapshotEnd"),
3043        }
3044    }
3045
3046    /// Simulate the receiver side of a snapshot transfer where the
3047    /// advertised snap_len doesn't match the actual bytes sent.
3048    /// The receiver must detect this and return an error.
3049    #[test]
3050    fn snapshot_receiver_detects_length_mismatch() {
3051        use std::os::unix::net::UnixStream;
3052
3053        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3054
3055        // Receiver thread — reads NeedSnapshot, then the snapshot transfer.
3056        let receiver = std::thread::spawn(move || -> String {
3057            let mut reader = replica_stream.try_clone().unwrap();
3058
3059            // Read NeedSnapshot.
3060            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3061            assert!(matches!(
3062                decode_primary_message(&frame).unwrap(),
3063                PrimaryMessage::NeedSnapshot,
3064            ));
3065
3066            // Read SnapshotBegin.
3067            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3068            let (snap_len, _snap_sequence, _snap_chain_hash) =
3069                match decode_primary_message(&frame).unwrap() {
3070                    PrimaryMessage::SnapshotBegin {
3071                        snapshot_len,
3072                        snap_sequence,
3073                        snap_chain_hash,
3074                    } => (snapshot_len, snap_sequence, snap_chain_hash),
3075                    other => panic!("expected SnapshotBegin, got {other:?}"),
3076                };
3077
3078            // Receive chunks and check length at SnapshotEnd.
3079            let mut received: u64 = 0;
3080            loop {
3081                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3082                match decode_primary_message(&frame).unwrap() {
3083                    PrimaryMessage::SnapshotChunk(data) => {
3084                        received += data.len() as u64;
3085                    }
3086                    PrimaryMessage::SnapshotEnd { .. } => {
3087                        if received != snap_len {
3088                            return format!(
3089                                "snapshot length mismatch: expected {snap_len} bytes, got {received}"
3090                            );
3091                        }
3092                        return String::new(); // no error
3093                    }
3094                    other => panic!("unexpected message: {other:?}"),
3095                }
3096            }
3097        });
3098
3099        // Primary side — send snapshot with wrong advertised length.
3100        let mut writer = primary_stream;
3101        let mut buf = Vec::new();
3102
3103        let actual_data = vec![0xAA; 100];
3104        let wrong_len = 999u64; // advertise 999 bytes, send only 100
3105
3106        encode_need_snapshot(&mut buf);
3107        std::io::Write::write_all(&mut writer, &buf).unwrap();
3108        buf.clear();
3109
3110        encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3111        std::io::Write::write_all(&mut writer, &buf).unwrap();
3112        buf.clear();
3113
3114        encode_snapshot_chunk(&actual_data, &mut buf);
3115        std::io::Write::write_all(&mut writer, &buf).unwrap();
3116        buf.clear();
3117
3118        let crc = crc32c::crc32c(&actual_data);
3119        encode_snapshot_end(crc, &mut buf);
3120        std::io::Write::write_all(&mut writer, &buf).unwrap();
3121        std::io::Write::flush(&mut writer).unwrap();
3122
3123        let error_msg = receiver.join().unwrap();
3124        assert!(
3125            error_msg.contains("length mismatch"),
3126            "expected length mismatch error, got: {error_msg:?}"
3127        );
3128    }
3129
3130    /// Simulate the receiver side of a snapshot transfer where the CRC
3131    /// in SnapshotEnd doesn't match the actual data. The receiver must
3132    /// detect and reject the transfer.
3133    #[test]
3134    fn snapshot_receiver_detects_crc_mismatch() {
3135        use std::os::unix::net::UnixStream;
3136
3137        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3138
3139        let receiver = std::thread::spawn(move || -> String {
3140            let mut reader = replica_stream.try_clone().unwrap();
3141
3142            // Read NeedSnapshot.
3143            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3144            assert!(matches!(
3145                decode_primary_message(&frame).unwrap(),
3146                PrimaryMessage::NeedSnapshot,
3147            ));
3148
3149            // Read SnapshotBegin.
3150            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3151            let snap_len = match decode_primary_message(&frame).unwrap() {
3152                PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3153                other => panic!("expected SnapshotBegin, got {other:?}"),
3154            };
3155
3156            // Receive chunks, verify CRC at SnapshotEnd.
3157            let mut received_data = Vec::new();
3158            let mut received: u64 = 0;
3159            loop {
3160                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3161                match decode_primary_message(&frame).unwrap() {
3162                    PrimaryMessage::SnapshotChunk(data) => {
3163                        received += data.len() as u64;
3164                        received_data.extend_from_slice(&data);
3165                    }
3166                    PrimaryMessage::SnapshotEnd {
3167                        crc32c: expected_crc,
3168                    } => {
3169                        if received != snap_len {
3170                            return format!("length mismatch: {snap_len} vs {received}");
3171                        }
3172                        let actual_crc = crc32c::crc32c(&received_data);
3173                        if actual_crc != expected_crc {
3174                            return format!(
3175                                "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3176                            );
3177                        }
3178                        return String::new();
3179                    }
3180                    other => panic!("unexpected message: {other:?}"),
3181                }
3182            }
3183        });
3184
3185        // Primary side — send correct length but wrong CRC.
3186        let mut writer = primary_stream;
3187        let mut buf = Vec::new();
3188
3189        let data = vec![0xAA; 100];
3190
3191        encode_need_snapshot(&mut buf);
3192        std::io::Write::write_all(&mut writer, &buf).unwrap();
3193        buf.clear();
3194
3195        encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3196        std::io::Write::write_all(&mut writer, &buf).unwrap();
3197        buf.clear();
3198
3199        encode_snapshot_chunk(&data, &mut buf);
3200        std::io::Write::write_all(&mut writer, &buf).unwrap();
3201        buf.clear();
3202
3203        // Send a wrong CRC (flip bits).
3204        let wrong_crc = !crc32c::crc32c(&data);
3205        encode_snapshot_end(wrong_crc, &mut buf);
3206        std::io::Write::write_all(&mut writer, &buf).unwrap();
3207        std::io::Write::flush(&mut writer).unwrap();
3208
3209        let error_msg = receiver.join().unwrap();
3210        assert!(
3211            error_msg.contains("CRC mismatch"),
3212            "expected CRC mismatch error, got: {error_msg:?}"
3213        );
3214    }
3215
3216    /// The receiver verifies the chain hash from the loaded snapshot
3217    /// matches the one advertised in SnapshotBegin. Simulate a mismatch.
3218    #[test]
3219    fn snapshot_receiver_detects_chain_hash_mismatch() {
3220        use std::os::unix::net::UnixStream;
3221
3222        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3223
3224        let receiver = std::thread::spawn(move || -> String {
3225            let mut reader = replica_stream.try_clone().unwrap();
3226
3227            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3228            assert!(matches!(
3229                decode_primary_message(&frame).unwrap(),
3230                PrimaryMessage::NeedSnapshot,
3231            ));
3232
3233            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3234            let (snap_len, _snap_sequence, snap_chain_hash) =
3235                match decode_primary_message(&frame).unwrap() {
3236                    PrimaryMessage::SnapshotBegin {
3237                        snapshot_len,
3238                        snap_sequence,
3239                        snap_chain_hash,
3240                    } => (snapshot_len, snap_sequence, snap_chain_hash),
3241                    other => panic!("expected SnapshotBegin, got {other:?}"),
3242                };
3243
3244            // Receive the snapshot data.
3245            let mut received_data = Vec::new();
3246            let mut received: u64 = 0;
3247            loop {
3248                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3249                match decode_primary_message(&frame).unwrap() {
3250                    PrimaryMessage::SnapshotChunk(data) => {
3251                        received += data.len() as u64;
3252                        received_data.extend_from_slice(&data);
3253                    }
3254                    PrimaryMessage::SnapshotEnd {
3255                        crc32c: expected_crc,
3256                    } => {
3257                        assert_eq!(received, snap_len, "length should match");
3258                        let actual_crc = crc32c::crc32c(&received_data);
3259                        assert_eq!(actual_crc, expected_crc, "CRC should match");
3260                        break;
3261                    }
3262                    other => panic!("unexpected message: {other:?}"),
3263                }
3264            }
3265
3266            // Simulate chain hash verification: the loaded snapshot would
3267            // have a different chain hash than what SnapshotBegin advertised.
3268            let loaded_hash = [0xFF; 32]; // different from snap_chain_hash
3269            if loaded_hash != snap_chain_hash {
3270                return format!(
3271                    "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3272                     loaded snapshot has {loaded_hash:02x?}"
3273                );
3274            }
3275            String::new()
3276        });
3277
3278        // Primary side — send valid snapshot but with a chain hash in
3279        // SnapshotBegin that won't match what the replica "loads".
3280        let mut writer = primary_stream;
3281        let mut buf = Vec::new();
3282
3283        let data = vec![0xAA; 64];
3284        // Advertise chain hash [0xBB; 32] — receiver will "load" [0xFF; 32].
3285        let advertised_hash = [0xBB; 32];
3286
3287        encode_need_snapshot(&mut buf);
3288        std::io::Write::write_all(&mut writer, &buf).unwrap();
3289        buf.clear();
3290
3291        encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3292        std::io::Write::write_all(&mut writer, &buf).unwrap();
3293        buf.clear();
3294
3295        encode_snapshot_chunk(&data, &mut buf);
3296        std::io::Write::write_all(&mut writer, &buf).unwrap();
3297        buf.clear();
3298
3299        let crc = crc32c::crc32c(&data);
3300        encode_snapshot_end(crc, &mut buf);
3301        std::io::Write::write_all(&mut writer, &buf).unwrap();
3302        std::io::Write::flush(&mut writer).unwrap();
3303
3304        let error_msg = receiver.join().unwrap();
3305        assert!(
3306            error_msg.contains("chain hash mismatch"),
3307            "expected chain hash mismatch error, got: {error_msg:?}"
3308        );
3309    }
3310
3311    /// Primary-side magic validation: a file without the SNAP magic
3312    /// (0x534E4150) must be rejected before transfer.
3313    #[test]
3314    fn primary_rejects_snapshot_with_invalid_magic() {
3315        let tmp = tempfile::tempdir().unwrap();
3316        let snap_path = tmp.path().join("test.snapshot");
3317
3318        // Write a file with wrong magic but enough bytes for a header.
3319        let mut bad_snap = vec![0u8; 64];
3320        bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong magic
3321
3322        std::fs::write(&snap_path, &bad_snap).unwrap();
3323
3324        // Replicate the primary's validation logic.
3325        let snap_data = std::fs::read(&snap_path).unwrap();
3326        assert!(
3327            snap_data.len() >= 48,
3328            "file should be big enough for header"
3329        );
3330
3331        let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3332        assert_ne!(magic, 0x534E_4150);
3333        assert_eq!(magic, 0xDEAD_BEEF);
3334    }
3335
3336    /// Primary-side: a snapshot file smaller than the 48-byte header
3337    /// must be rejected.
3338    #[test]
3339    fn primary_rejects_snapshot_too_small_for_header() {
3340        let tmp = tempfile::tempdir().unwrap();
3341        let snap_path = tmp.path().join("test.snapshot");
3342
3343        // Write a file smaller than the 48-byte header.
3344        std::fs::write(&snap_path, [0u8; 20]).unwrap();
3345
3346        let snap_data = std::fs::read(&snap_path).unwrap();
3347        assert!(
3348            snap_data.len() < 48,
3349            "file must be too small for header validation"
3350        );
3351    }
3352
3353    #[test]
3354    fn decode_snapshot_begin_too_short() {
3355        // SnapshotBegin needs type(1) + snapshot_len(8) + snap_sequence(8) + chain_hash(32) = 49.
3356        // Send only the type byte + a few extra bytes.
3357        let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3358        let err = decode_primary_message(&payload).unwrap_err();
3359        assert!(
3360            err.to_string().contains("SnapshotBegin too short"),
3361            "expected 'SnapshotBegin too short', got: {err}"
3362        );
3363    }
3364
3365    #[test]
3366    fn decode_snapshot_end_too_short() {
3367        // SnapshotEnd needs type(1) + crc32c(4) = 5. Send only the type byte.
3368        let payload = [MSG_SNAPSHOT_END];
3369        let err = decode_primary_message(&payload).unwrap_err();
3370        assert!(
3371            err.to_string().contains("SnapshotEnd too short"),
3372            "expected 'SnapshotEnd too short', got: {err}"
3373        );
3374    }
3375
3376    #[test]
3377    fn decode_snapshot_chunk_empty_data() {
3378        // SnapshotChunk with just the type byte — valid but empty payload.
3379        let payload = [MSG_SNAPSHOT_CHUNK];
3380        let msg = decode_primary_message(&payload).unwrap();
3381        match msg {
3382            PrimaryMessage::SnapshotChunk(data) => {
3383                assert!(data.is_empty());
3384            }
3385            _ => panic!("expected SnapshotChunk"),
3386        }
3387    }
3388
3389    // --- PendingAckQueue tests ---
3390
3391    fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3392        melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3393    }
3394
3395    #[test]
3396    fn pending_ack_queue_push_and_pop_ready() {
3397        let mut q = PendingAckQueue::new(8);
3398        assert!(q.is_empty());
3399        assert!(!q.is_full());
3400
3401        q.push(10, 100);
3402        q.push(20, 200);
3403        assert!(!q.is_empty());
3404
3405        // Cursor at 5 — neither ready.
3406        let cursor = make_journal_cursor(5);
3407        assert!(q.pop_ready(&cursor).is_none());
3408
3409        // Cursor at 15 — first ready, second not.
3410        cursor.get().store(15, Ordering::Relaxed);
3411        assert_eq!(q.pop_ready(&cursor), Some(100));
3412        // Only one popped — second still pending.
3413        assert!(!q.is_empty());
3414
3415        // Cursor at 25 — second now ready.
3416        cursor.get().store(25, Ordering::Relaxed);
3417        assert_eq!(q.pop_ready(&cursor), Some(200));
3418        assert!(q.is_empty());
3419    }
3420
3421    #[test]
3422    fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3423        // When multiple acks become ready simultaneously, pop_ready
3424        // returns the highest acked_sequence (ack semantics are
3425        // cumulative — "everything up to this sequence is durable").
3426        let mut q = PendingAckQueue::new(8);
3427        q.push(10, 100);
3428        q.push(20, 200);
3429        q.push(30, 300);
3430
3431        let cursor = make_journal_cursor(30);
3432        assert_eq!(q.pop_ready(&cursor), Some(300));
3433        assert!(q.is_empty());
3434    }
3435
3436    #[test]
3437    fn pending_ack_queue_capacity_and_full() {
3438        let mut q = PendingAckQueue::new(8);
3439        for i in 0..8 {
3440            assert!(!q.is_full());
3441            q.push(i as u64 + 1, (i + 1) as u64 * 100);
3442        }
3443        assert!(q.is_full());
3444    }
3445
3446    /// A full queue must absorb the push by merging it into the newest
3447    /// entry — never block, never drop, and never release a sequence
3448    /// before its own journal target. The merge is a *coarsening*: the
3449    /// entry it absorbed is acked later than it would have been.
3450    #[test]
3451    fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3452        let mut q = PendingAckQueue::new(4);
3453        for i in 1..=4u64 {
3454            q.push(i * 10, i * 100);
3455        }
3456        assert!(q.is_full());
3457        assert_eq!(q.merged(), 0);
3458
3459        // Fifth batch: target 50, sequence 500. Merges into (40, 400).
3460        q.push(50, 500);
3461        assert_eq!(q.merged(), 1);
3462        assert_eq!(q.len(), 4, "merge must not grow the queue");
3463
3464        // Cursor at 40 would have released 400 before the merge; now the
3465        // newest entry needs 50, so 300 is the highest safe ack.
3466        let cursor = make_journal_cursor(40);
3467        assert_eq!(q.pop_ready(&cursor), Some(300));
3468        assert!(!q.is_empty(), "merged entry is not durable yet");
3469
3470        // Cursor reaches the merged target — the merged entry releases
3471        // the newer sequence, subsuming the one it absorbed.
3472        cursor.get().store(50, Ordering::Relaxed);
3473        assert_eq!(q.pop_ready(&cursor), Some(500));
3474        assert!(q.is_empty());
3475    }
3476
3477    /// Repeated merges keep collapsing onto the same slot, so a journal
3478    /// stall of any length costs granularity and nothing else.
3479    #[test]
3480    fn pending_ack_queue_merges_repeatedly_without_growing() {
3481        let mut q = PendingAckQueue::new(2);
3482        for i in 1..=100u64 {
3483            q.push(i, i * 10);
3484        }
3485        assert_eq!(q.len(), 2);
3486        assert_eq!(q.merged(), 98);
3487
3488        // Nothing releases before its target...
3489        let cursor = make_journal_cursor(99);
3490        assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3491        // ...and the merged tail carries the newest pair.
3492        cursor.get().store(100, Ordering::Relaxed);
3493        assert_eq!(q.pop_ready(&cursor), Some(1000));
3494    }
3495
3496    #[test]
3497    fn pending_ack_queue_pop_oldest_blocking() {
3498        let mut q = PendingAckQueue::new(8);
3499        q.push(10, 100);
3500        q.push(20, 200);
3501
3502        // Cursor already past both targets — pop_oldest_blocking
3503        // returns immediately.
3504        let cursor = make_journal_cursor(25);
3505        let seq = q.pop_oldest_blocking(
3506            &cursor,
3507            melin_pipeline::wait::WaitStrategy::BusySpin,
3508            &AtomicBool::new(false),
3509        );
3510        // Should pop both (oldest + any others that became ready).
3511        assert_eq!(seq, Some(200));
3512        assert!(q.is_empty());
3513    }
3514
3515    /// A dead journal stage (abort latch set) must abort the blocking
3516    /// wait instead of spinning forever on the frozen cursor.
3517    #[test]
3518    fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3519        let mut q = PendingAckQueue::new(8);
3520        q.push(10, 100);
3521
3522        // Cursor frozen BELOW the target; abort pre-latched.
3523        let cursor = make_journal_cursor(5);
3524        let abort = AtomicBool::new(true);
3525        assert_eq!(
3526            q.pop_oldest_blocking(
3527                &cursor,
3528                melin_pipeline::wait::WaitStrategy::BusySpin,
3529                &abort
3530            ),
3531            None
3532        );
3533        assert!(!q.is_empty(), "aborted wait must not pop the entry");
3534        assert_eq!(
3535            q.pop_all_blocking(
3536                &cursor,
3537                melin_pipeline::wait::WaitStrategy::BusySpin,
3538                &abort
3539            ),
3540            None
3541        );
3542    }
3543
3544    /// `shutdown_pipeline` must surface the journal stage's error kind —
3545    /// the orchestrator routes `ReplicaChainDivergence` into in-process
3546    /// resync and everything else into process exit, so collapsing the
3547    /// error into a None (the old behavior) breaks that dispatch. It
3548    /// must also join EVERY thread in the failure arms: the resync path
3549    /// archives journal + snapshot right after teardown, and a
3550    /// still-running shadow thread mid-snapshot-write would race the
3551    /// renames.
3552    #[test]
3553    fn shutdown_pipeline_surfaces_journal_error_kind() {
3554        let flag = AtomicBool::new(false);
3555        let matching_joined = Arc::new(AtomicBool::new(false));
3556        let mj = Arc::clone(&matching_joined);
3557
3558        let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3559            Err(melin_journal::JournalError::ReplicaChainDivergence {
3560                sequence: 42,
3561                expected: [1u8; 32],
3562                actual: [2u8; 32],
3563            })
3564        });
3565        let matching = std::thread::spawn(move || {
3566            mj.store(true, Ordering::Release);
3567            7u64
3568        });
3569        let drain = std::thread::spawn(|| {});
3570
3571        let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3572        assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3573        assert!(
3574            matching_joined.load(Ordering::Acquire),
3575            "matching thread must be joined even when the journal failed"
3576        );
3577        match outcome {
3578            TeardownOutcome::JournalFailed(
3579                melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3580            ) => assert_eq!(sequence, 42),
3581            _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3582        }
3583    }
3584
3585    #[test]
3586    fn shutdown_pipeline_clean_returns_both_states() {
3587        let flag = AtomicBool::new(false);
3588        let journal =
3589            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3590        let matching = std::thread::spawn(|| 7u64);
3591        let drain = std::thread::spawn(|| {});
3592
3593        match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3594            TeardownOutcome::Clean(app, writer) => {
3595                assert_eq!(app, 7);
3596                assert_eq!(writer, 11);
3597            }
3598            _ => panic!("expected Clean"),
3599        }
3600    }
3601
3602    #[test]
3603    fn shutdown_pipeline_panicked_stage_reports_panicked() {
3604        let flag = AtomicBool::new(false);
3605        let journal =
3606            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3607        let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3608        let drain = std::thread::spawn(|| {});
3609
3610        assert!(matches!(
3611            shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3612            TeardownOutcome::Panicked
3613        ));
3614    }
3615
3616    /// `ReplicaPipelineHandles` over a real input ring (one gate
3617    /// consumer) with immediately-returning stage threads — just enough
3618    /// structure for `teardown_replica_pipeline` to run for real. The
3619    /// returned consumer observes what teardown published.
3620    fn teardown_fixture(
3621        capacity: usize,
3622    ) -> (
3623        ReplicaPipelineHandles<counter_server::Counter, u32>,
3624        melin_pipeline::ring::Consumer<InputSlot>,
3625    ) {
3626        handles_fixture(capacity, fsync_state_at(0, 0).1)
3627    }
3628
3629    /// A seqlock holding fsync state at `journal_seq` and ring position
3630    /// `ring_pos`, as the journal stage would publish it.
3631    fn fsync_state_at(
3632        journal_seq: u64,
3633        ring_pos: u64,
3634    ) -> (
3635        melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3636        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3637    ) {
3638        melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3639            journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3640            chain_hash: [0u8; 32],
3641            input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3642        })
3643    }
3644
3645    /// [`teardown_fixture`] reading the given fsync state.
3646    fn handles_fixture(
3647        capacity: usize,
3648        chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3649            melin_transport_core::pipeline::FsyncState,
3650        >,
3651    ) -> (
3652        ReplicaPipelineHandles<counter_server::Counter, u32>,
3653        melin_pipeline::ring::Consumer<InputSlot>,
3654    ) {
3655        let (input_producer, mut consumers) =
3656            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3657                .add_consumer()
3658                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3659        let consumer = consumers.pop().expect("one consumer");
3660        let handles = ReplicaPipelineHandles {
3661            input_producer,
3662            journal_cursor: Arc::new(make_journal_cursor(0)),
3663            chain_hash_lock,
3664            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3665            genesis_entries: None,
3666            journal_failed: Arc::new(AtomicBool::new(false)),
3667            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3668            journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3669                Ok(11)
3670            }),
3671            matching_handle: std::thread::spawn(counter_server::Counter::default),
3672            drain_handle: std::thread::spawn(|| {}),
3673            shadow_handle: None,
3674        };
3675        (handles, consumer)
3676    }
3677
3678    /// The invariant behind moving the sentinel publish into
3679    /// `teardown_replica_pipeline`: a healthy teardown always puts the
3680    /// shutdown sentinel on the ring, so the stages exit by consuming it
3681    /// through the normal event path (draining anything ahead of it),
3682    /// not via the flag's emergency-abort branch.
3683    #[test]
3684    fn teardown_publishes_shutdown_sentinel() {
3685        let (handles, mut consumer) = teardown_fixture(8);
3686        assert!(matches!(
3687            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3688            TeardownOutcome::Clean(..)
3689        ));
3690        let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3691        assert!(
3692            slot.event.is_shutdown(),
3693            "published slot must be the sentinel"
3694        );
3695        assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3696    }
3697
3698    /// The scheduling policy a running thread has now.
3699    fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
3700        use std::os::unix::thread::JoinHandleExt;
3701        let mut policy: libc::c_int = 0;
3702        let mut param = libc::sched_param { sched_priority: 0 };
3703        let ret =
3704            unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
3705        assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
3706        policy
3707    }
3708
3709    /// Every thread the replica pipeline builder spawns starts in the
3710    /// scheduling context of its own placement, not in the builder's.
3711    ///
3712    /// A new thread inherits its creator's CPU mask and policy, and Linux
3713    /// offers no way to change them before it first runs. On a tuned host
3714    /// the builder can sit on an isolated core: the receiver after a
3715    /// rebuild, or any thread whose home mask includes isolated CPUs. A
3716    /// child created there has to run once to pin itself elsewhere, and
3717    /// if a real-time thread spins on that core it never does — the stage
3718    /// never starts, and the replica wedges when its input ring fills.
3719    /// `JournalStage::start` hands the disk thread and the preparer their
3720    /// context before they exist for that reason; the stage threads need
3721    /// the same.
3722    ///
3723    /// Where a child started cannot be read back once it has pinned
3724    /// itself, but its policy can (see `melin_test_node::sched`): the
3725    /// builder runs at `SCHED_BATCH`, and a child that inherited its
3726    /// context keeps it after its own pin, while one handed its own starts
3727    /// at `SCHED_OTHER`. The end-to-end form, both nodes on both
3728    /// transports, is `tests/stall_guards.rs`.
3729    #[test]
3730    fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
3731        let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
3732            // No core to place a child on but the `0` sentinel.
3733            eprintln!("skipped: this thread may run on CPU 0 only");
3734            return;
3735        };
3736        let dir = tempfile::tempdir().expect("tempdir");
3737
3738        let policies = std::thread::spawn(move || {
3739            // The context no child may start in.
3740            melin_test_node::sched::set_batch_policy();
3741
3742            let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
3743                &dir.path().join("replica.journal"),
3744                1,
3745                [7; 32],
3746                Some(5),
3747            )
3748            .expect("create journal");
3749            // Every thread on the same core, yielding: what matters is
3750            // that each has a core of its own to be placed on, not which.
3751            let placed = crate::layout::Placement::yielding(core);
3752            let cores = crate::layout::PipelineCores {
3753                journal_seq: placed,
3754                matching: placed,
3755                response: placed,
3756                shadow: placed,
3757                journal_prep: placed,
3758                journal_disk: placed,
3759                ..crate::layout::PipelineCores::unpinned()
3760            };
3761            let handles = build_replica_pipeline_with_threads(
3762                counter_server::Counter::default(),
3763                writer,
3764                cores,
3765                melin_journal::StagingMode::Allocate,
3766                // Non-zero so the shadow thread is spawned; long enough
3767                // that it never takes a snapshot.
3768                3_600_000,
3769                &NodeStore::resolve(dir.path().join("replica.journal"), None).expect("store"),
3770                std::time::Duration::ZERO,
3771                Arc::new(melin_transport_core::fence::FenceState::new(0)),
3772                &SharedLineagePolicy::new(None),
3773                Arc::new(AtomicBool::new(true)),
3774                &(),
3775            )
3776            .expect("build the replica pipeline");
3777
3778            let policies = [
3779                ("journal-seq", Some(policy_of(&handles.journal_handle))),
3780                ("matching", Some(policy_of(&handles.matching_handle))),
3781                ("drain", Some(policy_of(&handles.drain_handle))),
3782                (
3783                    "replica-shadow",
3784                    handles.shadow_handle.as_ref().map(policy_of),
3785                ),
3786            ];
3787            let outcome = teardown_replica_pipeline(handles);
3788            assert!(
3789                matches!(outcome, TeardownOutcome::Clean(..)),
3790                "the pipeline must tear down cleanly"
3791            );
3792            policies
3793        })
3794        .join()
3795        .expect("builder thread");
3796
3797        let inherited: Vec<&str> = policies
3798            .iter()
3799            .map(|(name, policy)| {
3800                (
3801                    *name,
3802                    policy.unwrap_or_else(|| panic!("{name} was not spawned")),
3803                )
3804            })
3805            .filter(|&(_, policy)| policy == libc::SCHED_BATCH)
3806            .map(|(name, _)| name)
3807            .collect();
3808        assert!(
3809            inherited.is_empty(),
3810            "{inherited:?} started in the builder's scheduling context instead of their \
3811             own placement; on an isolated core held by a real-time spinner they would \
3812             never run"
3813        );
3814    }
3815
3816    /// A session left three slots in the ring that the journal has not
3817    /// made durable. The resume point waits for the fsync that covers
3818    /// them rather than handing the handshake the older durable position,
3819    /// from which the primary would resend them and the next session
3820    /// would publish them a second time.
3821    #[test]
3822    fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3823        let (mut fsync, reader) = fsync_state_at(10, 0);
3824        let (mut handles, _consumer) = handles_fixture(8, reader);
3825        for _ in 0..3 {
3826            handles.input_producer.publish(InputSlot::default());
3827        }
3828        let journal = std::thread::spawn(move || {
3829            std::thread::sleep(std::time::Duration::from_millis(50));
3830            fsync.store(melin_transport_core::pipeline::FsyncState {
3831                journal_seq: melin_transport_core::WireSeq::new(13),
3832                chain_hash: [0u8; 32],
3833                input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3834            });
3835        });
3836        let outcome = handles.settled_resume_point(|| false);
3837        journal.join().unwrap();
3838        assert!(
3839            matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3840            "resumes past the ring's slots"
3841        );
3842    }
3843
3844    /// Nothing published since the fsync state was last updated (here,
3845    /// its seed): the resume point is that state, at once.
3846    #[test]
3847    fn resume_point_with_nothing_in_flight_is_immediate() {
3848        let (_fsync, reader) = fsync_state_at(10, 0);
3849        let (handles, _consumer) = handles_fixture(8, reader);
3850        assert!(matches!(
3851            handles.settled_resume_point(|| false),
3852            ResumePoint::Settled(s) if s.journal_seq.get() == 10
3853        ));
3854    }
3855
3856    /// A failed journal stage never covers the ring, and neither does one
3857    /// the node is abandoning: the wait gives up on either, and says
3858    /// which.
3859    #[test]
3860    fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3861        let (_fsync, reader) = fsync_state_at(10, 0);
3862        let (mut handles, _consumer) = handles_fixture(8, reader);
3863        handles.input_producer.publish(InputSlot::default());
3864
3865        assert!(matches!(
3866            handles.settled_resume_point(|| true),
3867            ResumePoint::Interrupted
3868        ));
3869        handles.journal_failed.store(true, Ordering::Release);
3870        assert!(matches!(
3871            handles.settled_resume_point(|| false),
3872            ResumePoint::JournalFailed
3873        ));
3874    }
3875
3876    /// A journal stage that died after its last durable batch leaves the
3877    /// ring covered. The wait must still report it dead, ahead of the
3878    /// covered state: handed that state, the reconnect loop would dial
3879    /// the primary and take one of its replica slots with a pipeline that
3880    /// cannot take the session.
3881    #[test]
3882    fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3883        let (_fsync, reader) = fsync_state_at(10, 0);
3884        let (handles, _consumer) = handles_fixture(8, reader);
3885        handles.journal_failed.store(true, Ordering::Release);
3886        assert!(matches!(
3887            handles.settled_resume_point(|| false),
3888            ResumePoint::JournalFailed
3889        ));
3890    }
3891
3892    /// A journal thread that panics returns no error, so the stage's
3893    /// wrapper must latch the failure on the way out: otherwise every
3894    /// wait on the journal's progress, the reconnect's included, spins on
3895    /// a cursor that will never move.
3896    #[test]
3897    fn a_panicked_journal_stage_latches_the_failure() {
3898        let failed = Arc::new(AtomicBool::new(false));
3899        let healthy = Arc::new(AtomicBool::new(true));
3900        let journal_handle = {
3901            let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3902            std::thread::spawn(move || {
3903                run_journal_stage(
3904                    || -> Result<u32, melin_journal::JournalError> {
3905                        panic!("journal stage died without returning an error")
3906                    },
3907                    &failed,
3908                    &healthy,
3909                )
3910            })
3911        };
3912        // The thread's panic is the point; joining reports it.
3913        assert!(journal_handle.join().is_err(), "the stage panicked");
3914        assert!(failed.load(Ordering::Acquire), "the failure is latched");
3915        assert!(
3916            !healthy.load(Ordering::Acquire),
3917            "the node reports unhealthy"
3918        );
3919    }
3920
3921    /// End to end over the handles: a slot the panicked stage will never
3922    /// cover is in the ring, and the reconnect's wait must end anyway
3923    /// rather than spin until shutdown.
3924    #[test]
3925    fn resume_point_ends_when_the_journal_thread_panicked() {
3926        let (_fsync, reader) = fsync_state_at(10, 0);
3927        let (mut handles, _consumer) = handles_fixture(8, reader);
3928        handles.input_producer.publish(InputSlot::default());
3929        let failed = Arc::clone(&handles.journal_failed);
3930        let healthy = Arc::new(AtomicBool::new(true));
3931        let dead = std::thread::spawn(move || {
3932            run_journal_stage(
3933                || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3934                &failed,
3935                &healthy,
3936            )
3937        });
3938        assert!(dead.join().is_err(), "the stage panicked");
3939
3940        // Bounded, so a regression fails instead of hanging the suite.
3941        let give_up = AtomicBool::new(false);
3942        let outcome = std::thread::scope(|s| {
3943            let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3944            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3945            while !wait.is_finished() && std::time::Instant::now() < deadline {
3946                std::thread::sleep(std::time::Duration::from_millis(5));
3947            }
3948            give_up.store(true, Ordering::Relaxed);
3949            wait.join().expect("wait thread")
3950        });
3951        assert!(
3952            matches!(outcome, ResumePoint::JournalFailed),
3953            "the wait must end on the panicked stage, not on the test's give-up"
3954        );
3955    }
3956
3957    /// The reconnect loop's dispatch for a journal found dead by the
3958    /// wait: the fatal teardown, never a reconnect with the dead
3959    /// pipeline. Its error stops the replica; the pipeline is gone.
3960    #[test]
3961    fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3962        let dir = tempfile::tempdir().expect("tempdir");
3963        type Writer = melin_journal::BufferedWriter<CounterEvent>;
3964        let (input_producer, mut consumers) =
3965            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3966                .add_consumer()
3967                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3968        let _consumer = consumers.pop().expect("one consumer");
3969        let handles = ReplicaPipelineHandles {
3970            input_producer,
3971            journal_cursor: Arc::new(make_journal_cursor(0)),
3972            chain_hash_lock: fsync_state_at(0, 0).1,
3973            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3974            genesis_entries: None,
3975            journal_failed: Arc::new(AtomicBool::new(true)),
3976            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3977            journal_handle: std::thread::spawn(
3978                || -> Result<Writer, melin_journal::JournalError> {
3979                    Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3980                        expected: 4,
3981                        actual: 1,
3982                    })
3983                },
3984            ),
3985            matching_handle: std::thread::spawn(counter_server::Counter::default),
3986            drain_handle: std::thread::spawn(|| {}),
3987            shadow_handle: None,
3988        };
3989        let mut pipeline = Some(handles);
3990        let mut divergence_resyncs = 0u32;
3991        let mut backoff = MAX_BACKOFF;
3992        let mut closes = 0;
3993        let after = handle_session_exit::<counter_server::Counter, Writer>(
3994            journal_failed_while_disconnected(),
3995            &mut pipeline,
3996            &mut divergence_resyncs,
3997            &mut backoff,
3998            0,
3999            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4000            &melin_transport_core::fence::FenceState::new(0),
4001            &SharedLineagePolicy::new(None),
4002            &AtomicBool::new(false),
4003            &crate::promotion::PromotionRequest::new(),
4004            || closes += 1,
4005            &(),
4006        );
4007        let AfterSession::Return(Err(err)) = after else {
4008            panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
4009        };
4010        assert!(
4011            err.to_string()
4012                .contains("refused to journal sequence 1: the next sequence is 4"),
4013            "the returned error names the journal's cause, got: {err}"
4014        );
4015        assert!(pipeline.is_none(), "the dead pipeline was torn down");
4016        assert_eq!(divergence_resyncs, 0);
4017        // Once, as on every exit (the receivers pass a no-op here, as no
4018        // session is open while the wait runs).
4019        assert_eq!(closes, 1);
4020    }
4021
4022    /// Every exit ends the connection, exactly once, terminal or not:
4023    /// a replica that stops or promotes must tell its primary as surely
4024    /// as one that reconnects (on DPDK nothing else will).
4025    #[test]
4026    fn every_session_exit_closes_the_connection_once() {
4027        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4028        // A constructor per exit: `SessionExit` is neither `Clone` nor
4029        // `Debug`, so each case is built fresh and labelled by hand.
4030        type Case = (&'static str, fn() -> SessionExit);
4031        let exits: [Case; 6] = [
4032            ("shutdown", || SessionExit::Shutdown),
4033            ("promote", || SessionExit::Promote),
4034            ("fatal", || SessionExit::Fatal("protocol violation".into())),
4035            ("disconnected", || SessionExit::Disconnected),
4036            ("stream gap", || SessionExit::StreamGap("gap".into())),
4037            ("corrupted", || SessionExit::Corrupted("damaged".into())),
4038        ];
4039        for (label, exit) in exits {
4040            let dir = tempfile::tempdir().expect("tempdir");
4041            let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4042                None;
4043            let mut divergence_resyncs = 0u32;
4044            let mut backoff = MAX_BACKOFF;
4045            // Latched so the reconnecting exits' backoff sleep returns
4046            // at once.
4047            let shutdown = AtomicBool::new(true);
4048            let mut closes = 0;
4049            // The outcome is beside the point here (with no pipeline the
4050            // terminal exits return errors); only the close count is.
4051            let _after = handle_session_exit::<counter_server::Counter, Writer>(
4052                StreamingResult {
4053                    exit: exit(),
4054                    heard_from_primary: true,
4055                },
4056                &mut pipeline,
4057                &mut divergence_resyncs,
4058                &mut backoff,
4059                0,
4060                &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4061                &melin_transport_core::fence::FenceState::new(0),
4062                &SharedLineagePolicy::new(None),
4063                &shutdown,
4064                &crate::promotion::PromotionRequest::new(),
4065                || closes += 1,
4066                &(),
4067            );
4068            assert_eq!(closes, 1, "{label}");
4069        }
4070    }
4071
4072    /// Drive `handle_session_exit` through one `Disconnected` exit with
4073    /// the given liveness flag and a backoff pre-escalated to
4074    /// `MAX_BACKOFF`, returning the post-exit backoff. Shutdown is
4075    /// latched so the backoff sleep returns immediately (the receiver's
4076    /// loop top would handle it on the next turn); the journal and fence
4077    /// arguments are inert on the `Disconnected` path.
4078    fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
4079        backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
4080    }
4081
4082    /// As [`backoff_after_disconnect`], for any reconnecting exit.
4083    fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
4084        let dir = tempfile::tempdir().expect("tempdir");
4085        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4086        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
4087        let mut divergence_resyncs = 0u32;
4088        let mut backoff = MAX_BACKOFF;
4089        let shutdown = AtomicBool::new(true);
4090        let promote = crate::promotion::PromotionRequest::new();
4091        let after = handle_session_exit::<counter_server::Counter, Writer>(
4092            StreamingResult {
4093                exit,
4094                heard_from_primary,
4095            },
4096            &mut pipeline,
4097            &mut divergence_resyncs,
4098            &mut backoff,
4099            0,
4100            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4101            &melin_transport_core::fence::FenceState::new(0),
4102            &SharedLineagePolicy::new(None),
4103            &shutdown,
4104            &promote,
4105            || {},
4106            &(),
4107        );
4108        assert!(matches!(after, AfterSession::Reconnect));
4109        backoff
4110    }
4111
4112    /// A session in which the primary spoke — even heartbeat-only on a
4113    /// quiet system — must reset the disconnect backoff. A stale
4114    /// escalated backoff delays primary-link recovery, and with it the
4115    /// auto-promotion veto, by up to MAX_BACKOFF.
4116    #[test]
4117    fn disconnect_resets_backoff_when_primary_spoke() {
4118        // Reset to 1s, then doubled for the next attempt by the shared
4119        // helper — NOT stuck at MAX_BACKOFF.
4120        assert_eq!(
4121            backoff_after_disconnect(true),
4122            std::time::Duration::from_secs(2)
4123        );
4124    }
4125
4126    /// A session with no word from the primary — an instant-drop flap,
4127    /// or the synthetic result of a session that never started
4128    /// streaming (local transport init failure) — must keep its
4129    /// escalated backoff rather than redialing at 1 Hz forever.
4130    #[test]
4131    fn disconnect_keeps_escalated_backoff_when_primary_silent() {
4132        assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
4133    }
4134
4135    /// A damaged replicated entry proves the primary spoke, so it resets
4136    /// the backoff: one bit flip after a long clean session (with a
4137    /// backoff escalated at start-up and never reset since) must not
4138    /// cost a `MAX_BACKOFF` stall before the entry is fetched again.
4139    #[test]
4140    fn corrupted_entry_resets_backoff() {
4141        assert_eq!(
4142            backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
4143            std::time::Duration::from_secs(2)
4144        );
4145    }
4146
4147    /// A stream contiguity break is a reconnect, never a process exit:
4148    /// the contiguous prefix is committed and the journal is intact, so
4149    /// the receiver re-handshakes from its durable position and the
4150    /// primary re-streams the hole. It used to fall through the `Fatal`
4151    /// arm and take the replica down for a documented-benign primary
4152    /// handoff corner. The pipeline must be left standing (it is what
4153    /// the reconnect resumes into) and the backoff reset — the primary
4154    /// was evidently speaking.
4155    #[test]
4156    fn stream_gap_reconnects_and_keeps_pipeline() {
4157        let dir = tempfile::tempdir().expect("tempdir");
4158        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4159        // Same shape as `teardown_fixture`, but with a real writer type:
4160        // `handle_session_exit` is bounded on `JournalWrite` for the
4161        // resync arm even though this path never touches it.
4162        let (input_producer, mut consumers) =
4163            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
4164                .add_consumer()
4165                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
4166        let _consumer = consumers.pop().expect("one consumer");
4167        let writer_path = dir.path().join("w.journal");
4168        let handles = ReplicaPipelineHandles {
4169            input_producer,
4170            journal_cursor: Arc::new(make_journal_cursor(0)),
4171            chain_hash_lock: fsync_state_at(0, 0).1,
4172            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
4173            genesis_entries: None,
4174            journal_failed: Arc::new(AtomicBool::new(false)),
4175            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
4176            journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
4177            matching_handle: std::thread::spawn(counter_server::Counter::default),
4178            drain_handle: std::thread::spawn(|| {}),
4179            shadow_handle: None,
4180        };
4181        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4182            Some(handles);
4183        let mut divergence_resyncs = 0u32;
4184        let mut backoff = MAX_BACKOFF;
4185        let shutdown = AtomicBool::new(true);
4186        let promote = crate::promotion::PromotionRequest::new();
4187        let mut closed = false;
4188        let after = handle_session_exit::<counter_server::Counter, Writer>(
4189            StreamingResult {
4190                exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
4191                heard_from_primary: true,
4192            },
4193            &mut pipeline,
4194            &mut divergence_resyncs,
4195            &mut backoff,
4196            2,
4197            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4198            &melin_transport_core::fence::FenceState::new(0),
4199            &SharedLineagePolicy::new(None),
4200            &shutdown,
4201            &promote,
4202            || closed = true,
4203            &(),
4204        );
4205        assert!(
4206            matches!(after, AfterSession::Reconnect),
4207            "a stream gap must reconnect, not return an error"
4208        );
4209        assert!(
4210            pipeline.is_some(),
4211            "the pipeline must survive for the reconnect"
4212        );
4213        assert!(closed, "the transport must be closed before redialing");
4214        assert_eq!(
4215            backoff,
4216            std::time::Duration::from_secs(2),
4217            "backoff reset to 1s then doubled by the shared helper"
4218        );
4219        assert_eq!(divergence_resyncs, 0, "not a resync");
4220        if let Some(p) = pipeline.take() {
4221            let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4222        }
4223    }
4224
4225    /// With the journal-failure latch set and the ring full — the state
4226    /// a dead journal stage leaves behind (its frozen gate cursor means
4227    /// the ring never drains) — teardown must skip the sentinel and
4228    /// return, instead of spinning forever on ring backpressure.
4229    #[test]
4230    fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4231        let (mut handles, mut consumer) = teardown_fixture(4);
4232        for seq in 0..4 {
4233            assert!(
4234                handles
4235                    .input_producer
4236                    .try_publish(InputSlot {
4237                        sequence: seq,
4238                        ..InputSlot::default()
4239                    })
4240                    .is_ok(),
4241                "ring must have space while filling"
4242            );
4243        }
4244        assert!(
4245            handles
4246                .input_producer
4247                .try_publish(InputSlot::default())
4248                .is_err(),
4249            "ring must be full"
4250        );
4251        handles.journal_failed.store(true, Ordering::Release);
4252        // Would spin forever on the frozen gate without the latch check.
4253        assert!(matches!(
4254            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4255            TeardownOutcome::Clean(..)
4256        ));
4257        while let Some((_, slot)) = consumer.try_consume() {
4258            assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4259        }
4260    }
4261
4262    #[test]
4263    fn pending_ack_queue_wraps_around() {
4264        let mut q = PendingAckQueue::new(8);
4265        let cursor = make_journal_cursor(100);
4266
4267        // Fill and drain multiple times to exercise circular buffer wrap.
4268        for round in 0..3 {
4269            for i in 0..8 {
4270                let target = (round * 8 + i) as u64 + 1;
4271                q.push(target, target * 10);
4272            }
4273            assert!(q.is_full());
4274            let seq = q.pop_ready(&cursor).expect("should be ready");
4275            assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4276            assert!(q.is_empty());
4277        }
4278    }
4279
4280    #[test]
4281    fn pending_ack_queue_pop_all_blocking_empty() {
4282        let mut q = PendingAckQueue::new(8);
4283        let cursor = make_journal_cursor(0);
4284        assert!(
4285            q.pop_all_blocking(
4286                &cursor,
4287                melin_pipeline::wait::WaitStrategy::BusySpin,
4288                &AtomicBool::new(false)
4289            )
4290            .is_none()
4291        );
4292    }
4293
4294    // --- try_flush_dual_track tests ---
4295
4296    #[test]
4297    fn dual_track_returns_none_when_both_tracks_idle() {
4298        let mut q = PendingAckQueue::new(8);
4299        let cursor = make_journal_cursor(0);
4300        assert!(
4301            try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4302            "no advance on either track → no ack"
4303        );
4304    }
4305
4306    #[test]
4307    fn dual_track_fires_on_persisted_advance_only() {
4308        // Persisted track moves: pop_ready returns 100; in-memory
4309        // stayed at 50 (last_sent). Expect ack with acked=100,
4310        // in_memory=50 (no regression — the wire field carries the
4311        // current sample, not a "no change" marker).
4312        let mut q = PendingAckQueue::new(8);
4313        q.push(10, 100);
4314        let cursor = make_journal_cursor(20);
4315        let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4316        assert_eq!(ack.acked_sequence, 100);
4317        assert_eq!(ack.in_memory_sequence, 50);
4318    }
4319
4320    #[test]
4321    fn dual_track_fires_on_in_memory_advance_only() {
4322        // Persisted is idle (queue empty): unwrap_or keeps the
4323        // last-sent acked at 100. In-memory bumped from 100 to 200.
4324        let mut q = PendingAckQueue::new(8);
4325        let cursor = make_journal_cursor(0);
4326        let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4327        assert_eq!(ack.acked_sequence, 100);
4328        assert_eq!(ack.in_memory_sequence, 200);
4329    }
4330
4331    #[test]
4332    fn dual_track_coalesces_until_caller_updates_trackers() {
4333        // Caller did not advance trackers between calls. The queue
4334        // popped on call 1; on call 2 it's empty so persisted stays
4335        // at 100 (unwrap_or). In-memory advanced 50 → 80 between
4336        // calls. Second call must still fire because tracker is
4337        // still 50.
4338        let mut q = PendingAckQueue::new(8);
4339        q.push(10, 100);
4340        let cursor = make_journal_cursor(20);
4341        let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4342        assert_eq!(ack1.acked_sequence, 100);
4343        // Caller "forgot" to update trackers (simulates send failure).
4344        let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4345            .expect("call 2 fires on in-memory advance");
4346        assert_eq!(
4347            ack2.acked_sequence, 0,
4348            "no new persisted pop → unwrap_or(last_sent)"
4349        );
4350        assert_eq!(ack2.in_memory_sequence, 80);
4351    }
4352
4353    #[test]
4354    fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4355        // Models the backpressure-drain → resume-normal-flush path
4356        // taken by every receiver when `PendingAckQueue` fills up.
4357        // The receiver must drain the queue (via the
4358        // pop_oldest_blocking path in production; pop_ready here),
4359        // update *both* trackers from the drained ack, and only
4360        // then resume the normal cursor-driven flush. The bug
4361        // classes this pins are:
4362        //   (a) emitting a follow-up ack whose `acked_sequence`
4363        //       regresses below what the drain already sent
4364        //       (caught by the debug_assert! in
4365        //       `try_flush_dual_track`, also asserted at value
4366        //       level here);
4367        //   (b) emitting a duplicate ack carrying the same
4368        //       cursors as the drain when neither track actually
4369        //       advanced — the leak class fixed by ensuring every
4370        //       send site updates both `last_sent_acked_seq` and
4371        //       `last_sent_in_memory_seq`.
4372        let mut q = PendingAckQueue::new(4);
4373        // Fill the queue: four pending acks at primary seqs
4374        // 100, 200, 300, 400 with journal targets 10..=40.
4375        for i in 1..=4u64 {
4376            q.push(i * 10, i * 100);
4377        }
4378        assert!(q.is_full());
4379
4380        // Backpressure path: caller drains all ready entries
4381        // before pushing more. Highest acked seen = 400.
4382        let cursor = make_journal_cursor(40);
4383        let drained = q.pop_ready(&cursor).expect("all entries durable");
4384        assert_eq!(drained, 400);
4385        assert!(q.is_empty());
4386
4387        // Caller updates BOTH trackers from the drain. in_memory
4388        // at the time of the drained batch was 450.
4389        let mut last_sent_acked = drained;
4390        let mut last_sent_in_mem = 450u64;
4391
4392        // Quiescent immediately after drain: no fresh push, no
4393        // in-memory advance → try_flush must return None.
4394        // Failing here means we'd emit a duplicate ack carrying
4395        // the same (400, 450) the backpressure drain already sent.
4396        assert!(
4397            try_flush_dual_track(
4398                &mut q,
4399                &cursor,
4400                last_sent_in_mem,
4401                last_sent_acked,
4402                last_sent_in_mem,
4403            )
4404            .is_none(),
4405            "no advance on either track after backpressure drain → no duplicate ack",
4406        );
4407
4408        // Push a fresh batch (primary seq 500, journal target 50),
4409        // cursor catches up, in_memory advances to 500.
4410        q.push(50, 500);
4411        let cursor2 = make_journal_cursor(50);
4412        let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4413            .expect("fresh batch after drain fires a new ack");
4414        assert!(
4415            ack.acked_sequence >= last_sent_acked,
4416            "regression: drain sent {last_sent_acked} but next ack carries {}",
4417            ack.acked_sequence,
4418        );
4419        assert_eq!(ack.acked_sequence, 500);
4420        assert_eq!(ack.in_memory_sequence, 500);
4421
4422        last_sent_acked = ack.acked_sequence;
4423        last_sent_in_mem = ack.in_memory_sequence;
4424
4425        // Post-resume quiescent: confirms idempotency past the
4426        // resume point — the second flush sees neither track
4427        // advance and must stay silent.
4428        assert!(
4429            try_flush_dual_track(
4430                &mut q,
4431                &cursor2,
4432                last_sent_in_mem,
4433                last_sent_acked,
4434                last_sent_in_mem,
4435            )
4436            .is_none(),
4437            "post-resume idle → no further ack",
4438        );
4439    }
4440}