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}