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::{SocketAddr, TcpListener, TcpStream};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
11
12use tracing::{debug, error, info, warn};
13
14use melin_journal::replication::ReplicationConsumer;
15
16use super::auth::authenticate_replica;
17use super::{ReplicationMetrics, update_dual_replication_cursor};
18use melin_app::Application;
19use melin_transport_core::replication::catchup::{
20    CatchUpResult, can_catch_up_from_journal, catch_up_from_journal, snapshot_transfer_with,
21};
22use melin_transport_core::replication::protocol::{
23    MAX_CONTROL_FRAME, ReplicaMessage, decode_replica_message, encode_heartbeat,
24    encode_stream_start, read_frame,
25};
26
27// --- Replication Sender (Primary side) ---
28
29/// Owned state for the replication sender thread.
30pub struct Sender {
31    pub bind_addr: SocketAddr,
32    pub repl_consumer_1: ReplicationConsumer,
33    pub repl_consumer_2: ReplicationConsumer,
34    pub replication_cursor: Arc<AtomicU64>,
35    pub fastest_replica_cursor: Arc<AtomicU64>,
36    /// Raw genesis entry bytes (encoded GenesisHash journal entry), sent to
37    /// replicas in `StreamStart` so they write a byte-identical genesis.
38    pub genesis_entry: Vec<u8>,
39    pub journal_path: std::path::PathBuf,
40    pub authorized_keys: Arc<melin_app::auth::AuthorizedKeys>,
41    pub evict_flags: [Arc<AtomicBool>; 2],
42    pub active_flags: [Arc<AtomicBool>; 2],
43    pub metrics: Arc<ReplicationMetrics>,
44    pub handler_cores: [usize; 2],
45    pub batch_size: usize,
46    pub heartbeat_secs: u64,
47    pub busy_spin: bool,
48}
49
50/// Run the replication sender. Listens for replica connections,
51/// streams journal data batches, processes acks, and updates the
52/// replication cursor.
53///
54/// Runs on a dedicated thread. Blocks until shutdown.
55pub fn run_sender<A: Application>(
56    config: Sender,
57    shutdown: &AtomicBool,
58    replica_ready: &AtomicBool,
59    replicas_connected: &AtomicU32,
60) {
61    let Sender {
62        bind_addr,
63        repl_consumer_1,
64        repl_consumer_2,
65        replication_cursor,
66        fastest_replica_cursor,
67        genesis_entry,
68        journal_path,
69        authorized_keys,
70        evict_flags,
71        active_flags,
72        metrics,
73        handler_cores,
74        batch_size,
75        heartbeat_secs,
76        busy_spin,
77    } = config;
78    let listener = match TcpListener::bind(bind_addr) {
79        Ok(l) => l,
80        Err(e) => {
81            error!(addr = %bind_addr, error = %e, "failed to bind replication listener");
82            return;
83        }
84    };
85    // Non-blocking accept so we can check shutdown.
86    if let Err(e) = listener.set_nonblocking(true) {
87        error!(error = %e, "failed to set non-blocking on replication listener");
88        return;
89    }
90
91    info!(addr = %bind_addr, "replication sender listening");
92
93    // Two replica slots, each with its own ring consumer and thread handle.
94    // The accept loop fills empty slots. When a replica disconnects, its
95    // slot becomes available for a new connection. The consumer is `None`
96    // while a handler thread owns it (moved into the thread, returned on exit).
97    struct ReplicaSlot {
98        consumer: Option<ReplicationConsumer>,
99        handle: Option<std::thread::JoinHandle<ReplicationConsumer>>,
100    }
101
102    let mut slots = [
103        ReplicaSlot {
104            consumer: Some(repl_consumer_1),
105            handle: None,
106        },
107        ReplicaSlot {
108            consumer: Some(repl_consumer_2),
109            handle: None,
110        },
111    ];
112
113    // Per-slot acked positions. Each handler thread writes its own slot and
114    // reads the other to compute the shared min/max cursors. Initialized to
115    // u64::MAX (idle — not gating). This mirrors the DPDK path's per-slot
116    // `acked_cursor` fields.
117    let slot_acked: [Arc<AtomicU64>; 2] = [
118        Arc::new(AtomicU64::new(u64::MAX)),
119        Arc::new(AtomicU64::new(u64::MAX)),
120    ];
121
122    loop {
123        if shutdown.load(Ordering::Relaxed) {
124            info!("replication sender shutting down");
125            // Wait for active replica threads to finish.
126            for slot in &mut slots {
127                if let Some(handle) = slot.handle.take()
128                    && let Ok(consumer) = handle.join()
129                {
130                    slot.consumer = Some(consumer);
131                }
132            }
133            return;
134        }
135
136        // Check eviction flags from the journal stage. When set, the
137        // journal stage timed out publishing to this slot's ring. We need
138        // to reclaim the consumer so the idle drain loop can clear the ring,
139        // allowing the journal stage to resume publishing.
140        for (i, slot) in slots.iter_mut().enumerate() {
141            if evict_flags[i].load(Ordering::Acquire) && slot.handle.is_some() {
142                metrics.evictions_total.fetch_add(1, Ordering::Relaxed);
143                warn!(
144                    slot = i,
145                    "evicting slow replica (ring backpressure timeout)"
146                );
147                // The handler thread checks shutdown — we can't signal it
148                // individually without adding per-slot flags. Instead, join
149                // the thread with a short timeout by checking is_finished.
150                // The handler's TCP read timeout (5s) will cause it to exit
151                // on the next iteration when it checks shutdown. But we
152                // want faster eviction, so we shutdown the TCP stream to
153                // unblock the read.
154                //
155                // For now, mark the slot and let it be collected below
156                // when the handler finishes naturally (TCP timeout or
157                // next send failure after the ring stops being fed).
158            }
159        }
160
161        // Collect finished replica threads (disconnected replicas).
162        for (i, slot) in slots.iter_mut().enumerate() {
163            if let Some(ref handle) = slot.handle
164                && handle.is_finished()
165            {
166                let handle = slot.handle.take().expect("just checked is_some");
167                match handle.join() {
168                    Ok(mut consumer) => {
169                        // Drop any unread entries before the consumer is
170                        // stashed back in the slot. The journal stage may
171                        // have published batches into the ring that the
172                        // evicted handler never got to forward — if we
173                        // left them in place, the NEXT handler on this
174                        // slot would drain them to its replica and
175                        // acknowledge with pre-eviction sequences. Those
176                        // acks would stall `replication_cursor` at the
177                        // old position and gate the primary's response
178                        // stage at the slow-replica rate.
179                        //
180                        // Fast-forward to the producer cursor so the
181                        // live-streaming loop starts with a clean ring.
182                        consumer.skip_to_producer();
183                        slot.consumer = Some(consumer);
184                        replicas_connected.fetch_sub(1, Ordering::Release);
185                        // Reset per-slot metrics BEFORE clearing the active
186                        // flag so a reader that observes active=false also
187                        // observes the zeroed cursors via the Release pair.
188                        // Reversing this order would leave a window on weak-
189                        // memory architectures (ARM/AArch64) where a reader
190                        // sees active=true (stale) paired with cursor=0
191                        // (fresh) — see `evaluate_durability` and the B2
192                        // notes in `docs/durability-policy-followups.md`.
193                        metrics.acked_sequence[i].store(0, Ordering::Relaxed);
194                        metrics.in_memory_sequence[i].store(0, Ordering::Relaxed);
195                        metrics.catching_up[i].store(false, Ordering::Relaxed);
196                        // Clear active flag — journal stage stops publishing
197                        // to this ring. Must happen before clearing evict.
198                        active_flags[i].store(false, Ordering::Release);
199                        // Clear eviction flag after reclaiming the consumer.
200                        if evict_flags[i].load(Ordering::Relaxed) {
201                            evict_flags[i].store(false, Ordering::Release);
202                            warn!(slot = i, "evicted replica — ring ready for reconnection");
203                        } else {
204                            warn!(slot = i, "replica disconnected");
205                        }
206                        // Reset this slot's acked position and recompute
207                        // shared cursors from the two per-slot values.
208                        slot_acked[i].store(u64::MAX, Ordering::Release);
209                        let other = slot_acked[1 - i].load(Ordering::Acquire);
210                        update_dual_replication_cursor(
211                            u64::MAX,
212                            other,
213                            &replication_cursor,
214                            &fastest_replica_cursor,
215                        );
216                        if replicas_connected.load(Ordering::Relaxed) == 0 {
217                            warn!("all replicas disconnected — trading halted");
218                        }
219                    }
220                    Err(_) => {
221                        error!(slot = i, "replica handler thread panicked");
222                        // Consumer is lost — can't recover this slot.
223                        // With independent rings, only this slot's ring is
224                        // affected. The other replica continues normally.
225                        active_flags[i].store(false, Ordering::Release);
226                        evict_flags[i].store(false, Ordering::Release);
227                    }
228                }
229            }
230        }
231
232        // Find a slot that has a consumer available (not in use by a thread).
233        let empty_slot = slots
234            .iter()
235            .position(|s| s.handle.is_none() && s.consumer.is_some());
236
237        // Accept a connection if there's an empty slot.
238        if let Some(slot_idx) = empty_slot {
239            match listener.accept() {
240                Ok((stream, addr)) => {
241                    info!(addr = %addr, slot = slot_idx, "replica connected");
242                    if let Err(e) = stream.set_nodelay(true) {
243                        debug!(error = %e, "failed to set TCP_NODELAY on replica connection");
244                    }
245                    // SO_BUSY_POLL on the sender side: the per-replica thread
246                    // spins on `recv` for ack frames, so kernel busy-polling
247                    // removes the softirq->wakeup handoff from the ack path.
248                    if let Err(e) =
249                        crate::server::set_busy_poll(&stream, crate::server::BUSY_POLL_US)
250                    {
251                        debug!(error = %e, "failed to set SO_BUSY_POLL on replica connection");
252                    }
253
254                    replicas_connected.fetch_add(1, Ordering::Release);
255
256                    // Take the consumer out of the slot for the handler thread.
257                    // The slot's consumer becomes None while the thread owns it.
258                    let consumer = slots[slot_idx]
259                        .consumer
260                        .take()
261                        .expect("empty_slot check guarantees consumer is Some");
262
263                    let cursor = Arc::clone(&replication_cursor);
264                    let fastest_cursor = Arc::clone(&fastest_replica_cursor);
265                    let this_slot_acked = Arc::clone(&slot_acked[slot_idx]);
266                    let other_slot_acked = Arc::clone(&slot_acked[1 - slot_idx]);
267                    let genesis = genesis_entry.clone();
268                    let jpath = journal_path.clone();
269                    let auth_keys = Arc::clone(&authorized_keys);
270                    let slot_metrics = Arc::clone(&metrics);
271                    let slot_active = Arc::clone(&active_flags[slot_idx]);
272                    let slot_evict = Arc::clone(&evict_flags[slot_idx]);
273                    let handler_core = handler_cores[slot_idx];
274                    let shutdown_flag = shutdown as *const AtomicBool as usize;
275                    let ready_flag = replica_ready as *const AtomicBool as usize;
276                    let handle = std::thread::Builder::new()
277                        .name(format!("repl-{slot_idx}"))
278                        .spawn(move || {
279                            // Pin to a dedicated core if configured (> 0),
280                            // otherwise clear inherited affinity from the
281                            // sender thread so the OS can schedule freely.
282                            if handler_core > 0 {
283                                match melin_app::affinity::pin_to_core(handler_core) {
284                                    Ok(c) => tracing::info!(
285                                        core = c,
286                                        slot = slot_idx,
287                                        "replication handler pinned to core"
288                                    ),
289                                    Err(e) => tracing::warn!(
290                                        error = e,
291                                        slot = slot_idx,
292                                        "failed to pin handler"
293                                    ),
294                                }
295                            } else if let Err(e) = melin_app::affinity::clear_affinity() {
296                                tracing::warn!(error = e, "failed to clear handler affinity");
297                            }
298                            // Safety: shutdown and replica_ready outlive this thread
299                            // (they're on the parent's stack, which blocks on join
300                            // during shutdown).
301                            let shutdown_ref = unsafe { &*(shutdown_flag as *const AtomicBool) };
302                            let ready_ref = unsafe { &*(ready_flag as *const AtomicBool) };
303                            let ctx = SlotContext {
304                                replication_cursor: &cursor,
305                                fastest_replica_cursor: &fastest_cursor,
306                                this_slot_acked: &this_slot_acked,
307                                other_slot_acked: &other_slot_acked,
308                                genesis_entry: &genesis,
309                                journal_path: &jpath,
310                                authorized_keys: &auth_keys,
311                                shutdown: shutdown_ref,
312                                replica_ready: ready_ref,
313                                active_flag: &slot_active,
314                                evict_flag: &slot_evict,
315                                metrics: &slot_metrics,
316                                slot_idx,
317                                batch_size,
318                                heartbeat_secs,
319                                busy_spin,
320                            };
321                            run_replica_slot::<A>(stream, consumer, &ctx)
322                        })
323                        .expect("spawn replica handler thread");
324                    slots[slot_idx].handle = Some(handle);
325                }
326                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
327                    // No pending connection.
328                }
329                Err(e) => {
330                    error!(error = %e, "replication accept error");
331                }
332            }
333        }
334
335        // No idle drain needed — the journal stage only publishes to
336        // rings where active_flag is true (set by handler on live loop
337        // entry, cleared on disconnect). Idle consumers stay empty.
338
339        std::thread::sleep(std::time::Duration::from_millis(50));
340    }
341}
342
343/// Per-slot state shared across the replica handler call chain
344/// (`run_replica_slot` → `handle_replica_connection` → `live_stream_uring`).
345struct SlotContext<'a> {
346    replication_cursor: &'a Arc<AtomicU64>,
347    fastest_replica_cursor: &'a Arc<AtomicU64>,
348    this_slot_acked: &'a Arc<AtomicU64>,
349    other_slot_acked: &'a Arc<AtomicU64>,
350    genesis_entry: &'a [u8],
351    journal_path: &'a std::path::Path,
352    authorized_keys: &'a melin_app::auth::AuthorizedKeys,
353    shutdown: &'a AtomicBool,
354    replica_ready: &'a AtomicBool,
355    active_flag: &'a AtomicBool,
356    evict_flag: &'a AtomicBool,
357    metrics: &'a ReplicationMetrics,
358    slot_idx: usize,
359    batch_size: usize,
360    heartbeat_secs: u64,
361    busy_spin: bool,
362}
363
364/// Handle a single replica connection on a dedicated thread.
365/// Returns the consumer when the connection ends (for slot reuse).
366fn run_replica_slot<A: Application>(
367    stream: TcpStream,
368    mut consumer: ReplicationConsumer,
369    ctx: &SlotContext<'_>,
370) -> ReplicationConsumer {
371    match handle_replica_connection::<A>(stream, &mut consumer, ctx) {
372        Ok(()) => info!("replica disconnected cleanly"),
373        Err(e) => warn!(error = %e, "replica connection error"),
374    }
375    consumer
376}
377
378fn handle_replica_connection<A: Application>(
379    stream: TcpStream,
380    repl_consumer: &mut ReplicationConsumer,
381    ctx: &SlotContext<'_>,
382) -> io::Result<()> {
383    let SlotContext {
384        replication_cursor,
385        fastest_replica_cursor,
386        this_slot_acked,
387        other_slot_acked,
388        genesis_entry,
389        journal_path,
390        authorized_keys,
391        shutdown,
392        replica_ready,
393        active_flag,
394        evict_flag: _,
395        metrics,
396        slot_idx,
397        batch_size: _,
398        heartbeat_secs,
399        busy_spin: _,
400    } = ctx;
401    let slot_idx = *slot_idx;
402    let heartbeat_secs = *heartbeat_secs;
403
404    let mut reader = stream.try_clone()?;
405    let mut writer = stream;
406
407    // Set a read timeout for the handshake and auth.
408    reader.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
409
410    // Authenticate before any data exchange.
411    authenticate_replica(&mut reader, &mut writer, authorized_keys)?;
412    info!("replica authenticated");
413
414    // Read handshake.
415    let handshake_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
416    let handshake = match decode_replica_message(&handshake_frame)? {
417        ReplicaMessage::Handshake(h) => h,
418        ReplicaMessage::Ack(_) => {
419            return Err(io::Error::other("expected Handshake, got Ack"));
420        }
421    };
422
423    info!(
424        last_sequence = handshake.last_sequence,
425        "replica handshake received"
426    );
427
428    // Mark this slot as catching up. Cleared when entering the live loop.
429    metrics.catching_up[slot_idx].store(true, Ordering::Relaxed);
430
431    let mut send_buf = Vec::with_capacity(128);
432
433    // Probe whether journal catch-up is possible before committing to
434    // a protocol path. This avoids sending StreamStart only to discover
435    // the journals are too old.
436    let can_catch_up = can_catch_up_from_journal(journal_path, handshake.last_sequence)?;
437
438    let mut publish = |buf: &[u8]| -> io::Result<()> {
439        writer.write_all(buf)?;
440        writer.flush()
441    };
442
443    let catchup_end = if can_catch_up {
444        encode_stream_start(handshake.last_sequence, genesis_entry, &mut send_buf);
445        publish(&send_buf)?;
446        send_buf.clear();
447
448        match catch_up_from_journal::<A::Event>(
449            journal_path,
450            handshake.last_sequence,
451            &mut writer,
452            shutdown,
453        )? {
454            CatchUpResult::Ok(end) => end,
455            CatchUpResult::NeedSnapshot => {
456                return Err(io::Error::other("catch-up failed unexpectedly after probe"));
457            }
458        }
459    } else {
460        match snapshot_transfer_with::<A::Event>(
461            journal_path,
462            genesis_entry,
463            &mut publish,
464            shutdown,
465        )? {
466            CatchUpResult::Ok(end) => end,
467            CatchUpResult::NeedSnapshot => {
468                return Err(io::Error::other(
469                    "catch-up failed even after snapshot transfer",
470                ));
471            }
472        }
473    };
474
475    // Drain overlapping ring entries — the ring may contain entries that
476    // were already sent during catch-up. Only discard entries whose
477    // end_sequence is fully covered by the catch-up. Entries beyond
478    // catch-up are left in the ring for the live streaming loop. Ring
479    // chunks are wire-ready `InputBatch` frames; forward as-is.
480    if catchup_end > 0 {
481        while let Some((meta, data)) = repl_consumer.try_read() {
482            if meta.end_sequence > catchup_end {
483                writer.write_all(data)?;
484                writer.flush()?;
485                repl_consumer.commit();
486                break;
487            }
488            repl_consumer.commit();
489        }
490    }
491
492    // Engage both replication cursors. Set this slot's per-slot acked
493    // position and recompute the shared min/max cursors.
494    let initial_acked = handshake.last_sequence + 1;
495    this_slot_acked.store(initial_acked, Ordering::Release);
496    let other = other_slot_acked.load(Ordering::Acquire);
497    update_dual_replication_cursor(
498        initial_acked,
499        other,
500        replication_cursor,
501        fastest_replica_cursor,
502    );
503
504    // Catch-up complete — replica is entering the live streaming loop.
505    metrics.catching_up[slot_idx].store(false, Ordering::Relaxed);
506
507    // Seed the per-slot metrics cursors that the response gate's
508    // `evaluate_durability` reads. Must happen BEFORE the active_flag
509    // Release so any reader that observes `active=true` also observes
510    // a non-zero cursor pair — otherwise a 1-replica deployment running
511    // degraded freezes the gate at 0 for the first live-ack RTT after
512    // a reconnect (the disconnect-cleanup zeroed these atomics, and
513    // without seeding the gate would include a `[0, 0]` row in the
514    // policy view). The active_flag Release below publishes these
515    // Relaxed stores together.
516    metrics.acked_sequence[slot_idx].store(handshake.last_sequence, Ordering::Relaxed);
517    metrics.in_memory_sequence[slot_idx].store(handshake.last_sequence, Ordering::Relaxed);
518
519    // Mark this ring as active — the journal stage will start publishing
520    // to it. Must happen BEFORE replica_ready so the seed drain can wait
521    // on this ring's consumer cursor.
522    active_flag.store(true, Ordering::Release);
523
524    // Signal that this replica is ready to consume from the replication
525    // ring. The main thread waits on this before seeding test data.
526    // Must happen AFTER catch-up and overlap drain complete — otherwise
527    // seeding fills the replication ring faster than we can drain it,
528    // deadlocking the journal stage.
529    replica_ready.store(true, Ordering::Release);
530
531    let heartbeat_interval = std::time::Duration::from_secs(heartbeat_secs);
532    let mut last_send = std::time::Instant::now();
533    let mut last_sequence = handshake.last_sequence;
534
535    live_stream_uring(
536        writer,
537        repl_consumer,
538        ctx,
539        heartbeat_interval,
540        &mut send_buf,
541        &mut last_send,
542        &mut last_sequence,
543    )
544}
545
546/// io_uring live streaming loop for the primary replication handler.
547///
548/// Live streaming loop using async RECV/SEND via io_uring. A single RECV is always
549/// in-flight for ack frames; SEND is submitted when the replication ring
550/// has data. Both complete via the memory-mapped CQ with zero syscalls
551/// in the hot path.
552fn live_stream_uring(
553    writer: TcpStream,
554    repl_consumer: &mut ReplicationConsumer,
555    ctx: &SlotContext<'_>,
556    heartbeat_interval: std::time::Duration,
557    send_buf: &mut Vec<u8>,
558    last_send: &mut std::time::Instant,
559    last_sequence: &mut u64,
560) -> io::Result<()> {
561    let SlotContext {
562        replication_cursor,
563        fastest_replica_cursor,
564        this_slot_acked,
565        other_slot_acked,
566        shutdown,
567        evict_flag,
568        metrics,
569        slot_idx,
570        batch_size,
571        busy_spin,
572        // Only used during handshake/catch-up (handle_replica_connection).
573        genesis_entry: _,
574        journal_path: _,
575        authorized_keys: _,
576        replica_ready: _,
577        active_flag: _,
578        heartbeat_secs: _,
579    } = ctx;
580    let slot_idx = *slot_idx;
581    let batch_size = *batch_size;
582    let busy_spin = *busy_spin;
583
584    use io_uring::{IoUring, opcode, types};
585    use std::os::unix::io::AsRawFd;
586
587    const TOKEN_RECV: u64 = 0;
588    const TOKEN_SEND: u64 = 1;
589
590    let tcp_fd = writer.as_raw_fd();
591
592    let mut ring: IoUring = IoUring::builder()
593        .setup_single_issuer()
594        .build(8)
595        .map_err(|e| io::Error::other(format!("io_uring init failed: {e}")))?;
596
597    ring.submitter()
598        .register_files(&[tcp_fd])
599        .map_err(|e| io::Error::other(format!("io_uring register_files: {e}")))?;
600
601    // Pin io-wq workers to core 0 (keep them off pipeline cores).
602    {
603        let mut cpuset: libc::cpu_set_t = unsafe { std::mem::zeroed() };
604        unsafe { libc::CPU_SET(0, &mut cpuset) };
605        let _ = ring.submitter().register_iowq_aff(&cpuset);
606    }
607
608    // RECV buffer for ack frames (13 bytes each, but kernel may
609    // coalesce multiple). 4 KiB is plenty.
610    let mut recv_buf = vec![0u8; 4096];
611    // Accumulation buffer for partial ack frame parsing.
612    let mut parse_buf: Vec<u8> = Vec::with_capacity(MAX_CONTROL_FRAME + 4);
613    // RECV is always resubmitted after CQE processing — no explicit
614    // tracking needed. The io_uring kernel guarantees ordering.
615    let mut send_in_flight = false;
616    let mut send_offset: usize = 0;
617    let mut idle_spins: u32 = 0;
618    let mut heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
619
620    // Diagnostic (RUST_LOG=debug): per-slot TCP_INFO snapshot once a
621    // second, slow-SEND detection (CQE elapsed >= threshold), and a
622    // TCP_INFO capture at the evict-exit point. Amortized so the
623    // per-iteration cost is a single `AND` + predictable branch.
624    let mut info_log_timer = melin_app::amortized_timer::AmortizedTimer::new();
625    let mut send_submit_ts: Option<std::time::Instant> = None;
626    const SLOW_SEND_THRESHOLD_MS: u128 = 5;
627
628    // Submit initial RECV.
629    let sqe = opcode::Recv::new(
630        types::Fixed(0),
631        recv_buf.as_mut_ptr(),
632        recv_buf.len() as u32,
633    )
634    .build()
635    .user_data(TOKEN_RECV);
636    // SAFETY: `recv_buf` is owned by this task and lives across the
637    // event loop until the corresponding CQE is drained. The ring is
638    // single-threaded — only this task pushes/reaps on it.
639    unsafe { ring.submission().push(&sqe).expect("SQ full") };
640
641    loop {
642        // --- Check flags ---
643        if shutdown.load(Ordering::Relaxed) {
644            return Ok(());
645        }
646        if evict_flag.load(Ordering::Relaxed) {
647            // Capture the TCP state at the moment of eviction — the
648            // critical frame for comparing an evicted slot's teardown
649            // against the still-live slot's socket state when
650            // diagnosing post-eviction regressions.
651            super::log_tcp_info(tcp_fd, "evict_exit", slot_idx);
652            info!(slot = slot_idx, "handler exiting: evicted by journal stage");
653            return Ok(());
654        }
655
656        // --- Drain replication ring into send_buf (memory, non-blocking) ---
657        //
658        // Ring chunks are wire-ready `InputBatch` frames produced by the
659        // journal stage (phase 3 of the unified-pipeline plan), so the
660        // sender is a passthrough — no decode + re-encode here.
661        if !send_in_flight {
662            let mut coalesced = 0;
663            while coalesced < batch_size {
664                if let Some((meta, data)) = repl_consumer.try_read() {
665                    send_buf.extend_from_slice(data);
666                    repl_consumer.commit();
667                    *last_sequence = meta.end_sequence;
668                    coalesced += 1;
669                } else {
670                    break;
671                }
672            }
673
674            if coalesced > 0 {
675                // Submit SEND for the coalesced buffer.
676                let sqe =
677                    opcode::Send::new(types::Fixed(0), send_buf.as_ptr(), send_buf.len() as u32)
678                        .build()
679                        .user_data(TOKEN_SEND);
680                // SAFETY: `send_buf` is owned by this task and pinned
681                // by `send_in_flight = true` below — it isn't mutated
682                // until the matching TOKEN_SEND CQE clears the flag.
683                // The ring is single-threaded.
684                unsafe { ring.submission().push(&sqe).expect("SQ full") };
685                send_in_flight = true;
686                send_offset = 0;
687                *last_send = std::time::Instant::now();
688                send_submit_ts = Some(*last_send);
689                idle_spins = 0;
690                heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
691            } else {
692                // Heartbeat check: amortized when spinning (mask keeps the
693                // clock read at ~10/s at 10M iter/s). In yield mode the loop
694                // already pays a syscall per iteration, so the clock read is
695                // free and must not be skipped — see AmortizedTimer docs.
696                let spinning = busy_spin || idle_spins < 1000;
697                if heartbeat_timer.tick(heartbeat_interval, spinning).is_some() {
698                    encode_heartbeat(*last_sequence, send_buf);
699                    let sqe = opcode::Send::new(
700                        types::Fixed(0),
701                        send_buf.as_ptr(),
702                        send_buf.len() as u32,
703                    )
704                    .build()
705                    .user_data(TOKEN_SEND);
706                    // SAFETY: same as the coalesced send above —
707                    // `send_buf` is owned and pinned by `send_in_flight`
708                    // until the TOKEN_SEND CQE clears it.
709                    unsafe { ring.submission().push(&sqe).expect("SQ full") };
710                    send_in_flight = true;
711                    send_offset = 0;
712                    *last_send = std::time::Instant::now();
713                    send_submit_ts = Some(*last_send);
714                }
715            }
716        }
717
718        // Periodic TCP_INFO dump — debug level. Amortized so the
719        // per-iteration cost is a single `AND` + predictable branch.
720        if info_log_timer
721            .tick(
722                std::time::Duration::from_secs(1),
723                busy_spin || idle_spins < 1000,
724            )
725            .is_some()
726        {
727            super::log_tcp_info(tcp_fd, "live_stream", slot_idx);
728        }
729
730        // --- Submit SQEs to kernel (non-blocking) ---
731        // Skip the syscall when no new SQEs were pushed — an empty
732        // io_uring_enter still costs ~200 ns of mode-switch overhead and
733        // showed up as 6 % of total CPU in profiles of the sender loop.
734        let pending = ring.submission().len();
735        if pending > 0 {
736            ring.submit()
737                .map_err(|e| io::Error::other(format!("io_uring submit: {e}")))?;
738        }
739
740        // --- Collect CQEs (must drain before pushing new SQEs) ---
741        // Collecting into a small stack array avoids the CQ borrow
742        // conflicting with SQ pushes during processing.
743        let mut cqes: [(u64, i32); 4] = [(0, 0); 4];
744        let mut cqe_count = 0;
745        for cqe in ring.completion() {
746            if cqe_count < cqes.len() {
747                cqes[cqe_count] = (cqe.user_data(), cqe.result());
748                cqe_count += 1;
749            }
750        }
751
752        let any_cqe = cqe_count > 0;
753        for &(token, result) in &cqes[..cqe_count] {
754            idle_spins = 0;
755            match token {
756                TOKEN_RECV => {
757                    if result <= 0 {
758                        return Err(io::Error::other(format!(
759                            "replica disconnected (recv returned {result})"
760                        )));
761                    }
762                    let n = result as usize;
763                    parse_buf.extend_from_slice(&recv_buf[..n]);
764
765                    // Extract complete ack frames from parse_buf.
766                    let mut cursor = 0;
767                    while cursor + 4 <= parse_buf.len() {
768                        let frame_len = u32::from_le_bytes(
769                            parse_buf[cursor..cursor + 4]
770                                .try_into()
771                                .expect("bounds checked: 4-byte slice"),
772                        ) as usize;
773                        if frame_len == 0 || frame_len > MAX_CONTROL_FRAME {
774                            return Err(io::Error::other(format!(
775                                "invalid ack frame length: {frame_len}"
776                            )));
777                        }
778                        if cursor + 4 + frame_len > parse_buf.len() {
779                            break; // Incomplete frame.
780                        }
781                        let payload = &parse_buf[cursor + 4..cursor + 4 + frame_len];
782                        if let Ok(ReplicaMessage::Ack(ack)) = decode_replica_message(payload) {
783                            let new_val = ack.acked_sequence + 1;
784                            this_slot_acked.store(new_val, Ordering::Release);
785                            let other = other_slot_acked.load(Ordering::Acquire);
786                            update_dual_replication_cursor(
787                                new_val,
788                                other,
789                                replication_cursor,
790                                fastest_replica_cursor,
791                            );
792                            metrics.acked_sequence[slot_idx]
793                                .store(ack.acked_sequence, Ordering::Relaxed);
794                            metrics.in_memory_sequence[slot_idx]
795                                .store(ack.in_memory_sequence, Ordering::Relaxed);
796                            metrics.ack_latency_us[slot_idx]
797                                .store(last_send.elapsed().as_micros() as u64, Ordering::Relaxed);
798                        }
799                        cursor += 4 + frame_len;
800                    }
801                    // Compact parse_buf.
802                    if cursor > 0 {
803                        let remaining = parse_buf.len() - cursor;
804                        parse_buf.copy_within(cursor.., 0);
805                        parse_buf.truncate(remaining);
806                    }
807
808                    // Resubmit RECV.
809                    let sqe = opcode::Recv::new(
810                        types::Fixed(0),
811                        recv_buf.as_mut_ptr(),
812                        recv_buf.len() as u32,
813                    )
814                    .build()
815                    .user_data(TOKEN_RECV);
816                    // SAFETY: `recv_buf` is owned by this task; only
817                    // one RECV is ever in flight (we just consumed the
818                    // previous CQE). Ring is single-threaded.
819                    unsafe { ring.submission().push(&sqe).expect("SQ full") };
820                }
821
822                TOKEN_SEND => {
823                    if result < 0 {
824                        return Err(io::Error::other(format!("send error (returned {result})")));
825                    }
826                    let sent = result as usize;
827                    send_offset += sent;
828                    if send_offset >= send_buf.len() {
829                        // Fully sent. Measure end-to-end SEND latency: a
830                        // healthy io_uring TCP SEND completes in
831                        // microseconds; > threshold implies the kernel
832                        // waited on cwnd / peer window / retransmit.
833                        if let Some(ts) = send_submit_ts.take() {
834                            let elapsed = ts.elapsed();
835                            if elapsed.as_millis() >= SLOW_SEND_THRESHOLD_MS {
836                                tracing::debug!(
837                                    slot = slot_idx,
838                                    elapsed_us = elapsed.as_micros() as u64,
839                                    bytes = send_buf.len(),
840                                    "slow SEND completion"
841                                );
842                                super::log_tcp_info(tcp_fd, "slow_send", slot_idx);
843                            }
844                        }
845                        metrics.bytes_sent[slot_idx]
846                            .fetch_add(send_buf.len() as u64, Ordering::Relaxed);
847                        send_buf.clear();
848                        send_offset = 0;
849                        send_in_flight = false;
850                    } else {
851                        // Partial send — resubmit remainder.
852                        let sqe = opcode::Send::new(
853                            types::Fixed(0),
854                            send_buf[send_offset..].as_ptr(),
855                            (send_buf.len() - send_offset) as u32,
856                        )
857                        .build()
858                        .user_data(TOKEN_SEND);
859                        // SAFETY: `send_buf` is owned and still pinned
860                        // (`send_in_flight` stays true across partial
861                        // sends); ring is single-threaded.
862                        unsafe { ring.submission().push(&sqe).expect("SQ full") };
863                    }
864                }
865
866                _ => {}
867            }
868        }
869
870        // --- Idle wait ---
871        if !any_cqe && send_buf.is_empty() {
872            if busy_spin || idle_spins < 1000 {
873                idle_spins = idle_spins.wrapping_add(1);
874                std::hint::spin_loop();
875            } else {
876                std::thread::yield_now();
877            }
878        }
879    }
880}