Skip to main content

melin_server_runtime/replication/
tcp_sender.rs

1//! TCP replication sender (primary side).
2//!
3//! Listens for replica connections, handles authentication, journal
4//! catch-up, snapshot transfer, and live streaming. The wire protocol
5//! is defined in `super::protocol`.
6
7use std::io::{self, Write};
8use std::net::{TcpListener, TcpStream};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
11
12use tracing::{debug, error, info, warn};
13
14use melin_journal::replication::ReplicationConsumer;
15
16use super::auth::authenticate_replica;
17use super::{ReplicaCursors, ReplicaGate, ReplicationMetrics, SentHighWater};
18use melin_app::Application;
19use melin_transport_core::replication::catchup::{
20    CatchUpResult, bridge_catchup_to_live, can_catch_up_from_journal, catch_up_from_journal,
21    preflight_snapshot_transfer, snapshot_transfer_with,
22};
23use melin_transport_core::replication::protocol::{
24    MAX_CONTROL_FRAME, ReplicaMessage, decode_replica_message, encode_hash_mismatch,
25    encode_heartbeat, encode_need_snapshot, encode_stream_start, read_frame,
26};
27use melin_transport_core::replication::validate::{
28    HandshakeValidation, validate_replica_handshake_settled,
29};
30
31// --- Replication Sender (Primary side) ---
32
33/// Owned state for the replication sender thread.
34/// A TCP listener guaranteed non-blocking: the only constructor sets
35/// the flag, so the guarantee is carried by the type. [`run_sender`]'s
36/// accept loop polls the shutdown flag between accepts — handed a
37/// blocking listener it would park inside `accept()` and only observe
38/// shutdown when the next replica happened to dial in. This newtype
39/// makes that state unrepresentable instead of documenting it away.
40pub struct ReplicationListener(TcpListener);
41
42impl ReplicationListener {
43    /// Wrap a bound listener, setting it non-blocking.
44    pub fn new(listener: TcpListener) -> std::io::Result<Self> {
45        listener.set_nonblocking(true)?;
46        Ok(Self(listener))
47    }
48}
49
50pub struct Sender {
51    /// Pre-bound replication listener, non-blocking by construction.
52    /// Bound by the caller at startup — not here — so a bad or stolen
53    /// `--replication-bind` port fails the boot with a clear error.
54    /// Binding on this thread meant a failure only killed the sender
55    /// silently, leaving a primary that looked healthy but could never
56    /// satisfy hybrid/durably-replicated acks.
57    pub listener: ReplicationListener,
58    pub repl_consumer_1: ReplicationConsumer,
59    pub repl_consumer_2: ReplicationConsumer,
60    pub replica_slots: Arc<melin_transport_core::ReplicaSlotCursors>,
61    pub journal_path: std::path::PathBuf,
62    pub authorized_keys: Arc<melin_app::auth::AuthorizedKeys>,
63    pub evict_flags: [Arc<AtomicBool>; 2],
64    pub active_flags: [Arc<AtomicBool>; 2],
65    pub metrics: Arc<ReplicationMetrics>,
66    pub handler_cores: [usize; 2],
67    pub batch_size: usize,
68    pub heartbeat_secs: u64,
69    pub busy_spin: bool,
70    /// Node fencing state. Read to stamp the primary's epoch onto each
71    /// `StreamStart`, and to self-demote when a replica handshakes with a
72    /// higher epoch (this primary has been superseded). See `crate::fence`.
73    pub fence_state: Arc<melin_transport_core::fence::FenceState>,
74    /// The active durability (acking) mode, shared with the response
75    /// gate and the admin `DURABILITY` command. Stamped on every
76    /// `StreamStart` and `Heartbeat` so replicas judge auto-promotion
77    /// against the mode this primary actually acks under.
78    pub durability_mode: Arc<std::sync::atomic::AtomicU8>,
79}
80
81/// Run the replication sender. Listens for replica connections,
82/// streams journal data batches, processes acks, and updates the
83/// replication cursor.
84///
85/// Runs on a dedicated thread. Blocks until shutdown.
86pub fn run_sender<A: Application>(
87    config: Sender,
88    shutdown: &AtomicBool,
89    replica_ready: &AtomicBool,
90    replicas_connected: &AtomicU32,
91) {
92    let Sender {
93        listener,
94        repl_consumer_1,
95        repl_consumer_2,
96        replica_slots,
97        journal_path,
98        authorized_keys,
99        evict_flags,
100        active_flags,
101        metrics,
102        handler_cores,
103        batch_size,
104        heartbeat_secs,
105        busy_spin,
106        fence_state,
107        durability_mode,
108    } = config;
109    // Unwrap the invariant-carrying newtype — from here `listener` is a
110    // plain TcpListener, known non-blocking.
111    let ReplicationListener(listener) = listener;
112    match listener.local_addr() {
113        Ok(addr) => info!(%addr, "replication sender listening"),
114        // Best-effort log detail — the pre-bound listener works either way.
115        Err(_) => info!("replication sender listening"),
116    }
117
118    // Two replica slots, each with its own ring consumer and thread handle.
119    // The accept loop fills empty slots. When a replica disconnects, its
120    // slot becomes available for a new connection. The consumer is `None`
121    // while a handler thread owns it (moved into the thread, returned on exit).
122    struct ReplicaSlot {
123        consumer: Option<ReplicationConsumer>,
124        handle: Option<std::thread::JoinHandle<ReplicationConsumer>>,
125    }
126
127    let mut slots = [
128        ReplicaSlot {
129            consumer: Some(repl_consumer_1),
130            handle: None,
131        },
132        ReplicaSlot {
133            consumer: Some(repl_consumer_2),
134            handle: None,
135        },
136    ];
137
138    // Single owner of the per-replica progress cursors (per-slot acked
139    // positions and the gate's gauge pair). Shared by both handler
140    // threads — see `ReplicaCursors` for the ordering contract relative
141    // to the active flags.
142    let cursors = Arc::new(ReplicaCursors::new(replica_slots, Arc::clone(&metrics)));
143
144    // Per-slot "this connection authenticated" latch. The trading-halt gate
145    // (`replicas_connected`) is lifted by the handler thread only after auth
146    // succeeds, so the main loop must lower it on teardown only when the
147    // handler got that far — a connection that drops mid-auth never counted.
148    // Per-slot `Arc<AtomicBool>` mirrors `active_flags`/`evict_flags`; shared
149    // with the handler thread, which flips it true post-auth.
150    let authenticated_flags: [Arc<AtomicBool>; 2] = [
151        Arc::new(AtomicBool::new(false)),
152        Arc::new(AtomicBool::new(false)),
153    ];
154
155    loop {
156        if shutdown.load(Ordering::Relaxed) {
157            info!("replication sender shutting down");
158            // Wait for active replica threads to finish.
159            for slot in &mut slots {
160                if let Some(handle) = slot.handle.take()
161                    && let Ok(consumer) = handle.join()
162                {
163                    slot.consumer = Some(consumer);
164                }
165            }
166            return;
167        }
168
169        // Check eviction flags from the journal stage. When set, the
170        // journal stage timed out publishing to this slot's ring. We need
171        // to reclaim the consumer so the idle drain loop can clear the ring,
172        // allowing the journal stage to resume publishing.
173        for (i, slot) in slots.iter_mut().enumerate() {
174            if evict_flags[i].load(Ordering::Acquire) && slot.handle.is_some() {
175                metrics.evictions_total.fetch_add(1, Ordering::Relaxed);
176                warn!(
177                    slot = i,
178                    "evicting slow replica (ring backpressure timeout)"
179                );
180                // The handler thread checks shutdown — we can't signal it
181                // individually without adding per-slot flags. Instead, join
182                // the thread with a short timeout by checking is_finished.
183                // The handler's TCP read timeout (5s) will cause it to exit
184                // on the next iteration when it checks shutdown. But we
185                // want faster eviction, so we shutdown the TCP stream to
186                // unblock the read.
187                //
188                // For now, mark the slot and let it be collected below
189                // when the handler finishes naturally (TCP timeout or
190                // next send failure after the ring stops being fed).
191            }
192        }
193
194        // Disengage a finished slot's shared state. Both the clean-exit and
195        // panic arms below call this, so they cannot drift: a panicked handler
196        // must tear down exactly like a clean exit, or it leaks the trading-halt
197        // gate and — far worse — leaves the slot's progress cursors engaged,
198        // so the primary stops acking client requests even with a healthy
199        // surviving replica. Borrows only atomics (interior
200        // mutability), so it coexists with the `&mut` iteration over `slots`.
201        let disengage_slot = |slot_idx: usize| {
202            // Lower the gate only if this connection authenticated (and so
203            // lifted it); `swap` reads and resets the latch in one op, and the
204            // `join()` ordered the handler's post-auth writes before here.
205            // `lower` warns "trading halted" if this was the last replica.
206            if authenticated_flags[slot_idx].swap(false, Ordering::AcqRel) {
207                ReplicaGate::new(replicas_connected).lower();
208            }
209            // Disengage cursors BEFORE clearing the active flag — ordering
210            // contract B2 (see `ReplicaCursors`).
211            cursors.clear_on_disconnect(slot_idx);
212            metrics.catching_up[slot_idx].store(false, Ordering::Relaxed);
213            // Journal stage stops publishing to this ring.
214            active_flags[slot_idx].store(false, Ordering::Release);
215        };
216
217        // Collect finished replica threads (disconnected replicas).
218        for (i, slot) in slots.iter_mut().enumerate() {
219            if let Some(ref handle) = slot.handle
220                && handle.is_finished()
221            {
222                let handle = slot.handle.take().expect("just checked is_some");
223                match handle.join() {
224                    Ok(mut consumer) => {
225                        // Drop any unread entries before the consumer is
226                        // stashed back in the slot. The journal stage may
227                        // have published batches into the ring that the
228                        // evicted handler never got to forward — if we
229                        // left them in place, the NEXT handler on this
230                        // slot would drain them to its replica and
231                        // acknowledge with pre-eviction sequences. Those
232                        // acks would stall the slot's acked-progress
233                        // cursor at the old position and gate the
234                        // primary's response stage at the slow-replica
235                        // rate.
236                        //
237                        // Fast-forward to the producer cursor so the
238                        // live-streaming loop starts with a clean ring.
239                        consumer.skip_to_producer();
240                        slot.consumer = Some(consumer);
241                        disengage_slot(i);
242                        // Clear eviction flag after reclaiming the consumer.
243                        if evict_flags[i].load(Ordering::Relaxed) {
244                            evict_flags[i].store(false, Ordering::Release);
245                            warn!(slot = i, "evicted replica — ring ready for reconnection");
246                        } else {
247                            warn!(slot = i, "replica disconnected");
248                        }
249                    }
250                    Err(_) => {
251                        error!(slot = i, "replica handler thread panicked");
252                        // Consumer is lost — the slot stays handle=None /
253                        // consumer=None, so the accept loop skips it. With
254                        // independent rings, only this slot's ring is affected;
255                        // the other replica continues normally. Still disengage
256                        // its shared state, or the gate leaks and the cursor
257                        // freezes (see `disengage_slot`).
258                        disengage_slot(i);
259                        evict_flags[i].store(false, Ordering::Release);
260                    }
261                }
262            }
263        }
264
265        // Find a slot that has a consumer available (not in use by a thread).
266        let empty_slot = slots
267            .iter()
268            .position(|s| s.handle.is_none() && s.consumer.is_some());
269
270        // Accept a connection if there's an empty slot.
271        if let Some(slot_idx) = empty_slot {
272            match listener.accept() {
273                Ok((stream, addr)) => {
274                    info!(addr = %addr, slot = slot_idx, "replica connected");
275                    if let Err(e) = stream.set_nodelay(true) {
276                        debug!(error = %e, "failed to set TCP_NODELAY on replica connection");
277                    }
278                    // SO_BUSY_POLL on the sender side: the per-replica thread
279                    // spins on `recv` for ack frames, so kernel busy-polling
280                    // removes the softirq->wakeup handoff from the ack path.
281                    if let Err(e) =
282                        crate::server::set_busy_poll(&stream, crate::server::BUSY_POLL_US)
283                    {
284                        debug!(error = %e, "failed to set SO_BUSY_POLL on replica connection");
285                    }
286
287                    // The trading-halt gate is NOT lifted here — a bare connect
288                    // hasn't proven a Replication key. The handler lifts it after
289                    // auth succeeds (see `handle_replica_connection`).
290
291                    // Take the consumer out of the slot for the handler thread.
292                    // The slot's consumer becomes None while the thread owns it.
293                    let consumer = slots[slot_idx]
294                        .consumer
295                        .take()
296                        .expect("empty_slot check guarantees consumer is Some");
297
298                    let slot_cursors = Arc::clone(&cursors);
299                    let jpath = journal_path.clone();
300                    let auth_keys = Arc::clone(&authorized_keys);
301                    let slot_metrics = Arc::clone(&metrics);
302                    let slot_active = Arc::clone(&active_flags[slot_idx]);
303                    let slot_evict = Arc::clone(&evict_flags[slot_idx]);
304                    let slot_fence = Arc::clone(&fence_state);
305                    let slot_durability = Arc::clone(&durability_mode);
306                    let slot_authenticated = Arc::clone(&authenticated_flags[slot_idx]);
307                    let handler_core = handler_cores[slot_idx];
308                    let shutdown_flag = shutdown as *const AtomicBool as usize;
309                    let ready_flag = replica_ready as *const AtomicBool as usize;
310                    let connected_flag = replicas_connected as *const AtomicU32 as usize;
311                    let handle = std::thread::Builder::new()
312                        .name(format!("repl-{slot_idx}"))
313                        .spawn(move || {
314                            // Pin to a dedicated core if configured (> 0),
315                            // otherwise clear inherited affinity from the
316                            // sender thread so the OS can schedule freely.
317                            if handler_core > 0 {
318                                match melin_app::affinity::pin_to_core(handler_core) {
319                                    Ok(c) => tracing::info!(
320                                        core = c,
321                                        slot = slot_idx,
322                                        "replication handler pinned to core"
323                                    ),
324                                    Err(e) => tracing::warn!(
325                                        error = e,
326                                        slot = slot_idx,
327                                        "failed to pin handler"
328                                    ),
329                                }
330                            } else if let Err(e) = melin_app::affinity::clear_affinity() {
331                                tracing::warn!(error = e, "failed to clear handler affinity");
332                            }
333                            // Safety: shutdown and replica_ready outlive this thread
334                            // (they're on the parent's stack, which blocks on join
335                            // during shutdown).
336                            let shutdown_ref = unsafe { &*(shutdown_flag as *const AtomicBool) };
337                            let ready_ref = unsafe { &*(ready_flag as *const AtomicBool) };
338                            // Safety: replicas_connected outlives this thread —
339                            // same lifetime argument as shutdown/replica_ready
340                            // above (the parent blocks on join during shutdown).
341                            let connected_ref = unsafe { &*(connected_flag as *const AtomicU32) };
342                            let ctx = SlotContext {
343                                cursors: &slot_cursors,
344                                journal_path: &jpath,
345                                authorized_keys: &auth_keys,
346                                shutdown: shutdown_ref,
347                                replica_ready: ready_ref,
348                                active_flag: &slot_active,
349                                evict_flag: &slot_evict,
350                                metrics: &slot_metrics,
351                                fence_state: &slot_fence,
352                                durability_mode: &slot_durability,
353                                slot_idx,
354                                batch_size,
355                                heartbeat_secs,
356                                busy_spin,
357                                replicas_connected: connected_ref,
358                                authenticated: &slot_authenticated,
359                            };
360                            run_replica_slot::<A>(stream, consumer, &ctx)
361                        })
362                        .expect("spawn replica handler thread");
363                    slots[slot_idx].handle = Some(handle);
364                }
365                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
366                    // No pending connection.
367                }
368                Err(e) => {
369                    error!(error = %e, "replication accept error");
370                }
371            }
372        }
373
374        // No idle drain needed — the journal stage only publishes to
375        // rings where active_flag is true (set by handler on live loop
376        // entry, cleared on disconnect). Idle consumers stay empty.
377
378        std::thread::sleep(std::time::Duration::from_millis(50));
379    }
380}
381
382/// Per-slot state shared across the replica handler call chain
383/// (`run_replica_slot` → `handle_replica_connection` → `live_stream_uring`).
384struct SlotContext<'a> {
385    cursors: &'a Arc<ReplicaCursors>,
386    journal_path: &'a std::path::Path,
387    authorized_keys: &'a melin_app::auth::AuthorizedKeys,
388    shutdown: &'a AtomicBool,
389    replica_ready: &'a AtomicBool,
390    active_flag: &'a AtomicBool,
391    evict_flag: &'a AtomicBool,
392    metrics: &'a ReplicationMetrics,
393    fence_state: &'a melin_transport_core::fence::FenceState,
394    /// The primary's active acking mode — stamped on `StreamStart` and
395    /// `Heartbeat` (see `Sender::durability_mode`).
396    durability_mode: &'a std::sync::atomic::AtomicU8,
397    slot_idx: usize,
398    batch_size: usize,
399    heartbeat_secs: u64,
400    busy_spin: bool,
401    /// Trading-halt gate. The handler lifts it (`fetch_add`) only after auth
402    /// succeeds; the main loop lowers it on teardown, gated by `authenticated`.
403    replicas_connected: &'a AtomicU32,
404    /// Per-slot latch the handler sets true once this connection passes auth,
405    /// so the main loop knows whether this slot lifted the gate.
406    authenticated: &'a AtomicBool,
407}
408
409/// Handle a single replica connection on a dedicated thread.
410/// Returns the consumer when the connection ends (for slot reuse).
411fn run_replica_slot<A: Application>(
412    stream: TcpStream,
413    mut consumer: ReplicationConsumer,
414    ctx: &SlotContext<'_>,
415) -> ReplicationConsumer {
416    match handle_replica_connection::<A>(stream, &mut consumer, ctx) {
417        Ok(()) => info!("replica disconnected cleanly"),
418        Err(e) => warn!(error = %e, "replica connection error"),
419    }
420    consumer
421}
422
423fn handle_replica_connection<A: Application>(
424    stream: TcpStream,
425    repl_consumer: &mut ReplicationConsumer,
426    ctx: &SlotContext<'_>,
427) -> io::Result<()> {
428    let SlotContext {
429        cursors,
430        journal_path,
431        authorized_keys,
432        shutdown,
433        replica_ready,
434        active_flag,
435        evict_flag: _,
436        metrics,
437        fence_state,
438        durability_mode,
439        slot_idx,
440        batch_size: _,
441        heartbeat_secs,
442        busy_spin: _,
443        replicas_connected,
444        authenticated,
445    } = ctx;
446    let slot_idx = *slot_idx;
447    let heartbeat_secs = *heartbeat_secs;
448
449    let mut reader = stream.try_clone()?;
450    let mut writer = stream;
451
452    // Set a read timeout for the handshake and auth.
453    reader.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
454
455    // Authenticate before any data exchange. `reader` and `writer` are clones
456    // of the same socket; auth is sequential (write challenge, read response,
457    // write result) so a single handle suffices. Use `reader` — it carries the
458    // read timeout set above.
459    authenticate_replica(&mut reader, authorized_keys)?;
460    info!("replica authenticated");
461
462    // Auth passed — only now lift the trading-halt gate. The main loop lowers
463    // it on slot teardown, reading `authenticated` to know this connection
464    // lifted it. Set the latch after the increment so a post-join read of the
465    // latch implies the increment is visible too.
466    ReplicaGate::new(replicas_connected).lift();
467    authenticated.store(true, Ordering::Release);
468
469    // Read handshake.
470    let handshake_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
471    let handshake = match decode_replica_message(&handshake_frame)? {
472        ReplicaMessage::Handshake(h) => h,
473        ReplicaMessage::Ack(_) => {
474            return Err(io::Error::other("expected Handshake, got Ack"));
475        }
476    };
477
478    info!(
479        last_sequence = handshake.last_sequence,
480        replica_epoch = handshake.epoch,
481        "replica handshake received"
482    );
483
484    // Fence: a replica advertising an epoch higher than ours means a
485    // promotion happened that this node missed — we are a stale ex-primary.
486    // The policy (latch fenced + trigger shutdown) lives on `FenceState` so
487    // the kernel-TCP and DPDK senders cannot drift; the matching stage
488    // halts new client writes, the response gate stops acking, and the node
489    // winds down for the operator to restart it as a replica. Refuse this
490    // connection so we send no stale stream.
491    let our_epoch = fence_state.epoch();
492    if let Some(first_latch) = fence_state.fence_if_superseded(handshake.epoch, shutdown) {
493        if first_latch {
494            error!(
495                replica_epoch = handshake.epoch,
496                our_epoch,
497                "fenced: a replica advertises a higher epoch — this primary has been \
498                 superseded; self-demoting and shutting down"
499            );
500        }
501        return Err(io::Error::other("fenced by higher-epoch replica"));
502    }
503
504    // Mark this slot as catching up. Cleared when entering the live loop.
505    metrics.catching_up[slot_idx].store(true, Ordering::Relaxed);
506
507    let mut send_buf = Vec::with_capacity(128);
508
509    // Chain validation: recompute our chain at the replica's claimed
510    // position and compare with the hash it reported. A mismatch (or a
511    // position beyond our tip) means its journal holds divergent
512    // history — streaming on top would silently fork the audit trail.
513    // Send HashMismatch (the replica archives its local journal on
514    // receipt) and route it through snapshot resync on this same
515    // connection.
516    let divergent = match validate_replica_handshake_settled(journal_path, &handshake)? {
517        HandshakeValidation::Ok => false,
518        HandshakeValidation::Divergent(kind) => {
519            // Alertable on /metrics: divergence outside an expected
520            // failover rejoin means corruption or a serious bug.
521            metrics.divergence_total.fetch_add(1, Ordering::Relaxed);
522            warn!(
523                last_sequence = handshake.last_sequence,
524                ?kind,
525                "replica journal divergent at handshake — routing through snapshot resync"
526            );
527            true
528        }
529    };
530
531    // Probe whether journal catch-up is possible before committing to
532    // a protocol path. This avoids sending StreamStart only to discover
533    // the journals are too old.
534    let can_catch_up =
535        !divergent && can_catch_up_from_journal(journal_path, handshake.last_sequence)?;
536
537    let mut publish = |buf: &[u8]| -> io::Result<()> {
538        writer.write_all(buf)?;
539        writer.flush()
540    };
541
542    // Cursor/stream floor for everything downstream. A divergent
543    // replica's claimed position is meaningless (its history forked —
544    // possibly ahead of our tip); the resync rebases it, so seed from 0
545    // like a fresh replica: the ack cursors understate until real acks
546    // arrive (safe), and the sent high-water seeds from the catch-up
547    // end instead of a bogus claim that would mark live ring chunks as
548    // already covered.
549    let stream_base = if divergent {
550        0
551    } else {
552        handshake.last_sequence
553    };
554
555    let catchup_end = if can_catch_up {
556        // The lineage origin (oldest segment's header identity) lets a
557        // fresh replica create a byte-identical journal before
558        // consuming the stream. Replicas with local state ignore it.
559        let (lineage_start, lineage_anchor) =
560            melin_transport_core::replication::catchup::lineage_origin(journal_path)?;
561        encode_stream_start(
562            handshake.last_sequence,
563            lineage_start,
564            lineage_anchor,
565            fence_state.epoch(),
566            durability_mode.load(Ordering::Relaxed),
567            &mut send_buf,
568        );
569        publish(&send_buf)?;
570        send_buf.clear();
571
572        match catch_up_from_journal::<A::Event>(
573            journal_path,
574            handshake.last_sequence,
575            &mut writer,
576            shutdown,
577        )? {
578            CatchUpResult::Ok(end) => end,
579            CatchUpResult::NeedSnapshot => {
580                return Err(io::Error::other("catch-up failed unexpectedly after probe"));
581            }
582        }
583    } else {
584        // Resync verdict precedes the snapshot data: `HashMismatch`
585        // tells the replica its journal is divergent (it archives its
586        // local lineage on receipt); plain `NeedSnapshot` is the
587        // too-far-behind rebase. The receiver expects `SnapshotBegin`
588        // as the very next frame after the verdict, so
589        // `snapshot_transfer_with` must follow immediately.
590        //
591        // Pre-flight before the verdict goes on the wire: the replica
592        // archives its lineage on receipt, so promising a snapshot we
593        // cannot produce (none configured, boundary segment pruned)
594        // would strand it with its history moved aside. Failing here
595        // drops the connection with the replica's journal intact.
596        preflight_snapshot_transfer(journal_path)?;
597        if divergent {
598            encode_hash_mismatch(&mut send_buf);
599        } else {
600            encode_need_snapshot(&mut send_buf);
601        }
602        publish(&send_buf)?;
603        send_buf.clear();
604        match snapshot_transfer_with::<A::Event>(
605            journal_path,
606            &mut publish,
607            shutdown,
608            durability_mode.load(Ordering::Relaxed),
609        )? {
610            CatchUpResult::Ok(end) => end,
611            CatchUpResult::NeedSnapshot => {
612                return Err(io::Error::other(
613                    "catch-up failed even after snapshot transfer",
614                ));
615            }
616        }
617    };
618
619    // Engage this slot's cursors and seed the gauge pair the response
620    // gate's `evaluate_durability` reads. Must happen BEFORE the
621    // bridge's active_flag Release — see `ReplicaCursors` for the
622    // ordering contract.
623    cursors.seed_on_handshake(slot_idx, stream_base);
624
625    // Bridge into live streaming: activates the ring, re-reads from the
626    // journal the entries that fell into the activation window, then
627    // drains the ring into sequence-contiguity (back-filling from disk
628    // if a skipped entry hasn't flushed yet) before going live. Returns
629    // the slot's sent high-water — the bound the ack-sanity invariant
630    // checks acks against, advanced by every chunk the live loop
631    // forwards. The bridge closes the catch-up→live gap under load (the
632    // receiver's contiguity gate backstops only the rare quiescent
633    // corner). See `bridge_catchup_to_live`.
634    let mut sent = {
635        let mut publish = |buf: &[u8]| -> io::Result<()> {
636            writer.write_all(buf)?;
637            writer.flush()
638        };
639        bridge_catchup_to_live::<A::Event>(
640            journal_path,
641            stream_base,
642            catchup_end,
643            active_flag,
644            repl_consumer,
645            &mut publish,
646            shutdown,
647        )?
648    };
649
650    // Catch-up complete — replica is entering the live streaming loop.
651    metrics.catching_up[slot_idx].store(false, Ordering::Relaxed);
652
653    // Signal that this replica is ready to consume from the replication
654    // ring. The main thread waits on this before seeding test data.
655    // Must happen AFTER catch-up and overlap drain complete — otherwise
656    // seeding fills the replication ring faster than we can drain it,
657    // deadlocking the journal stage.
658    replica_ready.store(true, Ordering::Release);
659
660    let heartbeat_interval = std::time::Duration::from_secs(heartbeat_secs);
661    let mut last_send = std::time::Instant::now();
662
663    live_stream_uring(
664        writer,
665        repl_consumer,
666        ctx,
667        heartbeat_interval,
668        &mut send_buf,
669        &mut last_send,
670        &mut sent,
671    )
672}
673
674/// io_uring live streaming loop for the primary replication handler.
675///
676/// Live streaming loop using async RECV/SEND via io_uring. A single RECV is always
677/// in-flight for ack frames; SEND is submitted when the replication ring
678/// has data. Both complete via the memory-mapped CQ with zero syscalls
679/// in the hot path.
680fn live_stream_uring(
681    writer: TcpStream,
682    repl_consumer: &mut ReplicationConsumer,
683    ctx: &SlotContext<'_>,
684    heartbeat_interval: std::time::Duration,
685    send_buf: &mut Vec<u8>,
686    last_send: &mut std::time::Instant,
687    sent: &mut SentHighWater,
688) -> io::Result<()> {
689    let SlotContext {
690        cursors,
691        shutdown,
692        evict_flag,
693        metrics,
694        slot_idx,
695        batch_size,
696        busy_spin,
697        durability_mode,
698        // Only used during handshake/catch-up (handle_replica_connection).
699        journal_path: _,
700        authorized_keys: _,
701        replica_ready: _,
702        active_flag: _,
703        heartbeat_secs: _,
704        fence_state: _,
705        replicas_connected: _,
706        authenticated: _,
707    } = ctx;
708    let slot_idx = *slot_idx;
709    let batch_size = *batch_size;
710    let busy_spin = *busy_spin;
711
712    use io_uring::{IoUring, opcode, types};
713    use std::os::unix::io::AsRawFd;
714
715    const TOKEN_RECV: u64 = 0;
716    const TOKEN_SEND: u64 = 1;
717
718    let tcp_fd = writer.as_raw_fd();
719
720    let mut ring: IoUring = IoUring::builder()
721        .setup_single_issuer()
722        .build(8)
723        .map_err(|e| io::Error::other(format!("io_uring init failed: {e}")))?;
724
725    ring.submitter()
726        .register_files(&[tcp_fd])
727        .map_err(|e| io::Error::other(format!("io_uring register_files: {e}")))?;
728
729    // Pin io-wq workers to core 0 (keep them off pipeline cores).
730    {
731        let mut cpuset: libc::cpu_set_t = unsafe { std::mem::zeroed() };
732        unsafe { libc::CPU_SET(0, &mut cpuset) };
733        let _ = ring.submitter().register_iowq_aff(&cpuset);
734    }
735
736    // RECV buffer for ack frames (13 bytes each, but kernel may
737    // coalesce multiple). 4 KiB is plenty.
738    let mut recv_buf = vec![0u8; 4096];
739    // Accumulation buffer for partial ack frame parsing.
740    let mut parse_buf: Vec<u8> = Vec::with_capacity(MAX_CONTROL_FRAME + 4);
741    // RECV is always resubmitted after CQE processing — no explicit
742    // tracking needed. The io_uring kernel guarantees ordering.
743    let mut send_in_flight = false;
744    let mut send_offset: usize = 0;
745    let mut idle_spins: u32 = 0;
746    let mut heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
747
748    // Diagnostic (RUST_LOG=debug): per-slot TCP_INFO snapshot once a
749    // second, slow-SEND detection (CQE elapsed >= threshold), and a
750    // TCP_INFO capture at the evict-exit point. Amortized so the
751    // per-iteration cost is a single `AND` + predictable branch.
752    let mut info_log_timer = melin_app::amortized_timer::AmortizedTimer::new();
753    let mut send_submit_ts: Option<std::time::Instant> = None;
754    const SLOW_SEND_THRESHOLD_MS: u128 = 5;
755
756    // Submit initial RECV.
757    let sqe = opcode::Recv::new(
758        types::Fixed(0),
759        recv_buf.as_mut_ptr(),
760        recv_buf.len() as u32,
761    )
762    .build()
763    .user_data(TOKEN_RECV);
764    // SAFETY: `recv_buf` is owned by this task and lives across the
765    // event loop until the corresponding CQE is drained. The ring is
766    // single-threaded — only this task pushes/reaps on it.
767    unsafe { ring.submission().push(&sqe).expect("SQ full") };
768
769    loop {
770        // --- Check flags ---
771        if shutdown.load(Ordering::Relaxed) {
772            return Ok(());
773        }
774        if evict_flag.load(Ordering::Relaxed) {
775            // Capture the TCP state at the moment of eviction — the
776            // critical frame for comparing an evicted slot's teardown
777            // against the still-live slot's socket state when
778            // diagnosing post-eviction regressions.
779            super::log_tcp_info(tcp_fd, "evict_exit", slot_idx);
780            info!(slot = slot_idx, "handler exiting: evicted by journal stage");
781            return Ok(());
782        }
783
784        // --- Drain replication ring into send_buf (memory, non-blocking) ---
785        //
786        // Ring chunks are wire-ready `InputBatch` frames produced by the
787        // journal stage (phase 3 of the unified-pipeline plan), so the
788        // sender is a passthrough — no decode + re-encode here.
789        if !send_in_flight {
790            let mut coalesced = 0;
791            while coalesced < batch_size {
792                if let Some((meta, data)) = repl_consumer.try_read() {
793                    send_buf.extend_from_slice(data);
794                    repl_consumer.commit();
795                    sent.advance(meta.end_sequence);
796                    coalesced += 1;
797                } else {
798                    break;
799                }
800            }
801
802            if coalesced > 0 {
803                // Submit SEND for the coalesced buffer.
804                let sqe =
805                    opcode::Send::new(types::Fixed(0), send_buf.as_ptr(), send_buf.len() as u32)
806                        .build()
807                        .user_data(TOKEN_SEND);
808                // SAFETY: `send_buf` is owned by this task and pinned
809                // by `send_in_flight = true` below — it isn't mutated
810                // until the matching TOKEN_SEND CQE clears the flag.
811                // The ring is single-threaded.
812                unsafe { ring.submission().push(&sqe).expect("SQ full") };
813                send_in_flight = true;
814                send_offset = 0;
815                *last_send = std::time::Instant::now();
816                send_submit_ts = Some(*last_send);
817                idle_spins = 0;
818                heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
819            } else {
820                // Heartbeat check: amortized when spinning (mask keeps the
821                // clock read at ~10/s at 10M iter/s). In yield mode the loop
822                // already pays a syscall per iteration, so the clock read is
823                // free and must not be skipped — see AmortizedTimer docs.
824                let spinning = busy_spin || idle_spins < 1000;
825                if heartbeat_timer.tick(heartbeat_interval, spinning).is_some() {
826                    encode_heartbeat(
827                        sent.get(),
828                        durability_mode.load(Ordering::Relaxed),
829                        send_buf,
830                    );
831                    let sqe = opcode::Send::new(
832                        types::Fixed(0),
833                        send_buf.as_ptr(),
834                        send_buf.len() as u32,
835                    )
836                    .build()
837                    .user_data(TOKEN_SEND);
838                    // SAFETY: same as the coalesced send above —
839                    // `send_buf` is owned and pinned by `send_in_flight`
840                    // until the TOKEN_SEND CQE clears it.
841                    unsafe { ring.submission().push(&sqe).expect("SQ full") };
842                    send_in_flight = true;
843                    send_offset = 0;
844                    *last_send = std::time::Instant::now();
845                    send_submit_ts = Some(*last_send);
846                }
847            }
848        }
849
850        // Periodic TCP_INFO dump — debug level. Amortized so the
851        // per-iteration cost is a single `AND` + predictable branch.
852        if info_log_timer
853            .tick(
854                std::time::Duration::from_secs(1),
855                busy_spin || idle_spins < 1000,
856            )
857            .is_some()
858        {
859            super::log_tcp_info(tcp_fd, "live_stream", slot_idx);
860        }
861
862        // --- Submit SQEs to kernel (non-blocking) ---
863        // Skip the syscall when no new SQEs were pushed — an empty
864        // io_uring_enter still costs ~200 ns of mode-switch overhead and
865        // showed up as 6 % of total CPU in profiles of the sender loop.
866        let pending = ring.submission().len();
867        if pending > 0 {
868            ring.submit()
869                .map_err(|e| io::Error::other(format!("io_uring submit: {e}")))?;
870        }
871
872        // --- Collect CQEs (must drain before pushing new SQEs) ---
873        // Collecting into a small stack array avoids the CQ borrow
874        // conflicting with SQ pushes during processing.
875        let mut cqes: [(u64, i32); 4] = [(0, 0); 4];
876        let mut cqe_count = 0;
877        for cqe in ring.completion() {
878            if cqe_count < cqes.len() {
879                cqes[cqe_count] = (cqe.user_data(), cqe.result());
880                cqe_count += 1;
881            }
882        }
883
884        let any_cqe = cqe_count > 0;
885        for &(token, result) in &cqes[..cqe_count] {
886            idle_spins = 0;
887            match token {
888                TOKEN_RECV => {
889                    if result <= 0 {
890                        return Err(io::Error::other(format!(
891                            "replica disconnected (recv returned {result})"
892                        )));
893                    }
894                    let n = result as usize;
895                    parse_buf.extend_from_slice(&recv_buf[..n]);
896
897                    // Extract complete ack frames from parse_buf.
898                    let mut cursor = 0;
899                    while cursor + 4 <= parse_buf.len() {
900                        let frame_len = u32::from_le_bytes(
901                            parse_buf[cursor..cursor + 4]
902                                .try_into()
903                                .expect("bounds checked: 4-byte slice"),
904                        ) as usize;
905                        if frame_len == 0 || frame_len > MAX_CONTROL_FRAME {
906                            return Err(io::Error::other(format!(
907                                "invalid ack frame length: {frame_len}"
908                            )));
909                        }
910                        if cursor + 4 + frame_len > parse_buf.len() {
911                            break; // Incomplete frame.
912                        }
913                        let payload = &parse_buf[cursor + 4..cursor + 4 + frame_len];
914                        if let Ok(ReplicaMessage::Ack(ack)) = decode_replica_message(payload) {
915                            // Eviction on violation: returning Err tears the
916                            // connection down; the accept loop's cleanup
917                            // disengages the cursors and frees the slot.
918                            cursors
919                                .record_ack(slot_idx, &ack, sent.get())
920                                .map_err(io::Error::other)?;
921                            metrics.ack_latency_us[slot_idx]
922                                .store(last_send.elapsed().as_micros() as u64, Ordering::Relaxed);
923                        }
924                        cursor += 4 + frame_len;
925                    }
926                    // Compact parse_buf.
927                    if cursor > 0 {
928                        let remaining = parse_buf.len() - cursor;
929                        parse_buf.copy_within(cursor.., 0);
930                        parse_buf.truncate(remaining);
931                    }
932
933                    // Resubmit RECV.
934                    let sqe = opcode::Recv::new(
935                        types::Fixed(0),
936                        recv_buf.as_mut_ptr(),
937                        recv_buf.len() as u32,
938                    )
939                    .build()
940                    .user_data(TOKEN_RECV);
941                    // SAFETY: `recv_buf` is owned by this task; only
942                    // one RECV is ever in flight (we just consumed the
943                    // previous CQE). Ring is single-threaded.
944                    unsafe { ring.submission().push(&sqe).expect("SQ full") };
945                }
946
947                TOKEN_SEND => {
948                    if result < 0 {
949                        return Err(io::Error::other(format!("send error (returned {result})")));
950                    }
951                    let sent = result as usize;
952                    send_offset += sent;
953                    if send_offset >= send_buf.len() {
954                        // Fully sent. Measure end-to-end SEND latency: a
955                        // healthy io_uring TCP SEND completes in
956                        // microseconds; > threshold implies the kernel
957                        // waited on cwnd / peer window / retransmit.
958                        if let Some(ts) = send_submit_ts.take() {
959                            let elapsed = ts.elapsed();
960                            if elapsed.as_millis() >= SLOW_SEND_THRESHOLD_MS {
961                                tracing::debug!(
962                                    slot = slot_idx,
963                                    elapsed_us = elapsed.as_micros() as u64,
964                                    bytes = send_buf.len(),
965                                    "slow SEND completion"
966                                );
967                                super::log_tcp_info(tcp_fd, "slow_send", slot_idx);
968                            }
969                        }
970                        metrics.bytes_sent[slot_idx]
971                            .fetch_add(send_buf.len() as u64, Ordering::Relaxed);
972                        send_buf.clear();
973                        send_offset = 0;
974                        send_in_flight = false;
975                    } else {
976                        // Partial send — resubmit remainder.
977                        let sqe = opcode::Send::new(
978                            types::Fixed(0),
979                            send_buf[send_offset..].as_ptr(),
980                            (send_buf.len() - send_offset) as u32,
981                        )
982                        .build()
983                        .user_data(TOKEN_SEND);
984                        // SAFETY: `send_buf` is owned and still pinned
985                        // (`send_in_flight` stays true across partial
986                        // sends); ring is single-threaded.
987                        unsafe { ring.submission().push(&sqe).expect("SQ full") };
988                    }
989                }
990
991                _ => {}
992            }
993        }
994
995        // --- Idle wait ---
996        if !any_cqe && send_buf.is_empty() {
997            if busy_spin || idle_spins < 1000 {
998                idle_spins = idle_spins.wrapping_add(1);
999                std::hint::spin_loop();
1000            } else {
1001                std::thread::yield_now();
1002            }
1003        }
1004    }
1005}