Skip to main content

melin_server_runtime/
response.rs

1//! io_uring-based response stage — routes matching output to connections via
2//! `IORING_OP_SEND`.
3//!
4//! Replaces the blocking `write(2)` + `BufWriter` flush path with batched
5//! io_uring sends. Instead of N `write(2)` syscalls (one per dirty connection
6//! on flush), we submit N SEND SQEs in a single `io_uring_enter` call.
7//!
8//! Same SPSC consumption and journal cursor gating as `response.rs`.
9//! Runs on a dedicated OS thread.
10
11use std::os::unix::io::RawFd;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16
17use io_uring::{IoUring, opcode, types};
18use rustc_hash::FxHashMap;
19use tracing::{debug, error};
20
21use melin_pipeline::ring;
22use melin_pipeline::wait::WaitStrategy;
23
24use crate::ack_policy::{AckPolicy, Blocker, CursorView, EvalStatus, MAX_CLUSTER_SIZE, Policy};
25use crate::replication::ReplicationMetrics;
26use melin_app::Application;
27use melin_app::amortized_timer::AmortizedTimer;
28use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
29#[cfg(feature = "latency-trace")]
30use melin_transport_core::trace;
31use melin_transport_core::{DurableWireSeqCursor, WireSeq};
32
33use melin_wire_protocol::control::TransportResponse;
34use melin_wire_protocol::control_codec;
35
36/// Maximum number of output slots consumed per batch.
37const MAX_BATCH: usize = 1024;
38
39/// Bound on one encoded response frame, length prefix included.
40///
41/// The response stage encodes every report and query response into a
42/// stack buffer of this size. An application whose `ResponseEncoder`
43/// needs more does not get a larger buffer: the encode fails, the reply
44/// is dropped, and the failure is logged at `error!` — so an application
45/// should check its widest frame against this bound at compile time
46/// rather than discover it under load. Public for that reason; see the
47/// re-export in the crate root.
48pub const MAX_RESPONSE_BUF: usize = 512;
49
50/// io_uring submission queue depth for sends. Must be ≥ max concurrent
51/// connections to avoid SQ overflow when all connections are dirty.
52/// Power of 2 for io_uring alignment. 4096 supports 1024+ client
53/// benchmarks where all connections flush simultaneously.
54const RING_SIZE: u32 = 4096;
55
56/// Maximum accumulated send buffer per connection (64 KiB). If a client
57/// falls behind and the buffer exceeds this, the connection is dropped.
58/// 64 KiB holds ~500 response frames — well beyond any reasonable lag.
59const MAX_SEND_BUF: usize = 64 * 1024;
60
61/// Minimum interval between SEND retries to a connection whose socket
62/// buffer is full (`MSG_DONTWAIT` completed with `EAGAIN`). The stage
63/// busy-spins, so without pacing a blocked connection would cost one
64/// futile `io_uring_enter` per loop iteration. 100 µs adds no meaningful
65/// delivery delay: a full socket buffer drains at the client's read
66/// cadence (millisecond scale), and healthy connections are never paced.
67const BLOCKED_RETRY_INTERVAL: Duration = Duration::from_micros(100);
68
69/// Byte-threshold flush trigger for the consumed (busy) path — the
70/// still-open half of finding 4 in
71/// `docs/internal/latency-audit-2026-07.md`. Under sustained load with
72/// an open durability gate, neither the idle-path flush (SPSC never
73/// empties) nor the gate-path flush (no wait) runs, so without this
74/// trigger responses sit in `send_buf` until `MAX_SEND_BUF` *drops* the
75/// connection instead of serving it. Roughly one TCP MSS (1460 for
76/// standard Ethernet, minus margin): once a connection has a full
77/// segment buffered, flushing early costs nothing in wire efficiency,
78/// and the bound keeps `send_buf` ~45 flushes away from the disconnect
79/// cap instead of zero.
80const FLUSH_BYTES_THRESHOLD: usize = 1400;
81
82/// Slot-count flush trigger: how many appended slots may pass between
83/// flushes before one is forced, regardless of how few bytes any single
84/// connection has buffered.
85///
86/// [`FLUSH_BYTES_THRESHOLD`] bounds a *connection's* buffered bytes but
87/// not their *age*: the interval to the next flush is the time the
88/// busiest connection needs to accumulate an MSS, which grows with
89/// client count. At 4 clients that is microseconds; at 100 clients each
90/// sending ~40 B responses at a modest rate it is milliseconds, and every
91/// connection's already-encoded responses wait that long. Counting slots
92/// instead makes the bound depend on the stage's own throughput: one
93/// extra `io_uring_enter` per 256 responses is well amortized (~0.1 % of
94/// the per-slot cost at 1 M/s) and caps the wait at the time to encode
95/// 256 responses — tens of microseconds — however the load is spread.
96const FLUSH_SLOT_INTERVAL: usize = 256;
97
98/// The consumed path's flush cadence: both triggers plus the counter
99/// they share.
100///
101/// A struct rather than loose locals because the age counter has to be
102/// restarted by *every* flush site (consumed path, pre-gate-wait, idle),
103/// and a missed reset silently shortens the bound. One `on_flush` call
104/// per site is checkable by eye.
105#[derive(Default)]
106struct FlushCadence {
107    /// Slots appended since the last flush — the age bound.
108    slots_since_flush: usize,
109    /// Latched by the byte trigger, cleared by [`Self::on_flush`].
110    due: bool,
111}
112
113impl FlushCadence {
114    /// Record one appended slot on a connection that now holds
115    /// `buffered_bytes` unflushed.
116    #[inline]
117    fn on_append(&mut self, buffered_bytes: usize) {
118        self.slots_since_flush += 1;
119        self.due |= buffered_bytes >= FLUSH_BYTES_THRESHOLD;
120    }
121
122    /// Whether a flush must run before the next slot is encoded.
123    #[inline]
124    fn is_due(&self) -> bool {
125        self.due || self.slots_since_flush >= FLUSH_SLOT_INTERVAL
126    }
127
128    /// Restart both triggers. Call immediately after any `flush_sends`.
129    #[inline]
130    fn on_flush(&mut self) {
131        self.due = false;
132        self.slots_since_flush = 0;
133    }
134}
135
136/// How long a connection's socket may accept *zero* bytes while it has
137/// undelivered data before it is dropped. Partial progress restarts the
138/// clock — a slow-but-draining client is `MAX_SEND_BUF`'s problem, not
139/// this one's. Complements `MAX_SEND_BUF`, which only catches clients
140/// that lag while new responses keep *arriving* — a client that stops
141/// reading during a quiet period accumulates almost nothing (heartbeats
142/// only) and would otherwise pin its buffered bytes and its slot
143/// indefinitely.
144const BLOCKED_SEND_TIMEOUT: Duration = Duration::from_secs(5);
145
146pub use crate::ControlEvent;
147
148/// Encoder type alias: response encoder bound to the application's
149/// `Report` / `QueryResponse` types. Hides the long
150/// `dyn ResponseEncoder<Report = ..., Query = ...>` at call sites.
151pub type ResponseEncoderArc<A> = Arc<
152    dyn melin_app::encoder::ResponseEncoder<
153            Report = <A as Application>::Report,
154            Query = <A as Application>::QueryResponse,
155        >,
156>;
157
158/// Configuration and shared state for the response stage.
159pub struct Response<A: Application> {
160    /// Highest wire seq durably persisted on this node's journal.
161    /// In the same sequence space as `OutputSlot.wire_seq` and the
162    /// replica metrics (`metrics.in_memory_sequence` /
163    /// `metrics.acked_sequence`), so the durability gate can compare
164    /// these values numerically and the comparison is meaningful
165    /// regardless of `starting_sequence` (fresh vs recovered primary).
166    /// Typed [`DurableWireSeqCursor`] so the gate cannot be wired to a
167    /// ring-space counter — the pre-v14 bug class. Updated by the
168    /// journal stage after every fsync batch via `set_last_seq_publisher`.
169    pub journal_persisted_wire_seq: DurableWireSeqCursor,
170    /// Operator-selected ack policy, published through a shared
171    /// [`AtomicU8`](std::sync::atomic::AtomicU8) so the admin `ACK-POLICY` command can swap it at
172    /// runtime without restarting the node. The response stage reads
173    /// this once per gate iteration with a relaxed load (cheaper than a
174    /// `Mutex` or refcounted `Arc<Policy>` snapshot) and rebuilds its
175    /// local [`Policy`] when the byte changes. See
176    /// [`crate::ack_policy::AckPolicy::as_u8`] for the encoding.
177    pub ack_policy: Arc<std::sync::atomic::AtomicU8>,
178    /// Per-slot replica cursors. `None` for standalone deployments
179    /// (no replication wiring) — the policy then evaluates against the
180    /// primary alone.
181    pub replication_metrics: Option<Arc<ReplicationMetrics>>,
182    /// Per-slot replica active flags. Only "true" slots are included in
183    /// the cursor view fed to `Policy::evaluate`, so disconnected slots
184    /// don't pollute the view with stale zero cursors. When the
185    /// resulting view is too small to satisfy a clause, the policy
186    /// reports degraded and the gate stalls.
187    /// Mirrors `replication_metrics` — `None` in standalone.
188    pub replica_active: Option<[Arc<AtomicBool>; 2]>,
189    pub heartbeat_interval: Option<Duration>,
190    /// How this thread waits — on an empty output ring, and inside the
191    /// durability gate on the journal-disk and replication threads.
192    pub wait: WaitStrategy,
193    pub utilization: Arc<StageUtilization>,
194    /// Wire encoder for application-shaped payloads. Constructed
195    /// once at boot (`Arc::new(ExchangeResponseEncoder)`) and shared
196    /// with the DPDK response stage.
197    pub encoder: ResponseEncoderArc<A>,
198    /// Node fencing state. When latched (a higher epoch was observed), the
199    /// stage exits *without* the best-effort flush — in-flight responses
200    /// for orders on a superseded epoch must not be acknowledged. Fencing
201    /// co-sets `shutdown`, so the latch is only consulted on the shutdown
202    /// path (zero steady-state cost). See `crate::fence`.
203    pub fence_state: Arc<melin_transport_core::fence::FenceState>,
204    /// Live-connection count shared with the accept loop's
205    /// `max_connections` gate. Incremented there after auth; decremented
206    /// here — the response stage owns the connection map, so "an entry
207    /// left the map" is the one place a connection verifiably dies
208    /// exactly once, whichever side (reader disconnect or a drop
209    /// decision here) initiated it.
210    pub active_connections: Arc<AtomicU64>,
211    /// Test seam: called once per iteration right after the control
212    /// channel has been drained, so a test can hold the stage there and
213    /// choose what lands while it waits. The drain runs after the
214    /// output ring is read, and the seam guards that order: were the
215    /// drain moved back in front of the read, the seam would sit
216    /// between the two, and a `Connected` landing there would be
217    /// missed by the read that follows — which `registration_race`
218    /// checks. `None` runs the stage as production does; the field
219    /// itself is compiled out of non-test builds, so the loop pays
220    /// nothing.
221    #[cfg(test)]
222    pub pause_after_control_drain: Option<Box<dyn FnMut() + Send>>,
223}
224
225/// Per-connection state for batched io_uring sends.
226struct ConnectionEntry {
227    fd: RawFd,
228    /// Owns the write half of the socket to keep the fd alive.
229    _owner: Box<dyn Send>,
230    /// Accumulates encoded response frames between flushes.
231    /// The full wire frame (length prefix + payload) is appended here.
232    /// Vec's internal data pointer is heap-stable, so io_uring SEND SQEs
233    /// referencing `as_ptr()` remain valid even if the HashMap relocates
234    /// this struct. No SEND outlives its `flush_sends` call (every SQE
235    /// carries `MSG_DONTWAIT`, and the flush reaps all completions before
236    /// returning), so the Vec is never mutated while the kernel holds its
237    /// pointer.
238    send_buf: Vec<u8>,
239    /// Last time data was sent to this connection. Used for heartbeat scheduling.
240    last_send: Instant,
241    /// When this connection's socket last made zero forward progress:
242    /// set on `EAGAIN`, reset to the flush timestamp on a partial send
243    /// (bytes moved), cleared when the buffer fully drains. `None` while
244    /// the socket is accepting everything. Drives retry pacing and the
245    /// [`BLOCKED_SEND_TIMEOUT`] drop.
246    blocked_since: Option<Instant>,
247    /// Last SEND attempt. Read only while blocked, to pace retries at
248    /// [`BLOCKED_RETRY_INTERVAL`].
249    last_send_attempt: Instant,
250    /// Whether this connection is on the dirty list. A flag on the entry
251    /// rather than set membership: the append path already holds the
252    /// entry (and just wrote its `send_buf`), so the dedup test is a
253    /// byte in a cache line that is hot, instead of a hash + probe on
254    /// every one of the ~2 frames per request. See `dirty_connections`.
255    dirty: bool,
256}
257
258/// Run the io_uring response stage loop. Blocks the calling thread until shutdown.
259///
260/// Consumes from the output SPSC, waits for durability confirmation, and
261/// sends responses via io_uring SEND.
262///
263/// Ack gating: every gate iteration reads the journal cursor (this
264/// node's persisted) plus per-slot replica cursors (in-memory and
265/// persisted) from `replication_metrics` and feeds them through the
266/// configured [`Policy`]. See `evaluate_durability`.
267pub fn run<A: Application>(
268    mut consumer: ring::Consumer<OutputSlot<A::Report, A::QueryResponse>>,
269    control_rx: mpsc::Receiver<ControlEvent>,
270    config: Response<A>,
271    shutdown: &AtomicBool,
272) {
273    let Response {
274        journal_persisted_wire_seq,
275        ack_policy,
276        replication_metrics,
277        replica_active,
278        heartbeat_interval,
279        wait,
280        utilization,
281        encoder,
282        fence_state,
283        active_connections,
284        #[cfg(test)]
285        mut pause_after_control_drain,
286    } = config;
287    // Resolve the starting policy from the shared atomic and derive the
288    // local Policy. The atomic is the single source of truth across the
289    // process lifetime; the response thread keeps a thread-local copy
290    // for cheap per-iteration use and rebuilds it when an admin
291    // `ACK-POLICY` command swaps the atomic. Initialise as DiskAndRam
292    // (the default policy) if the atomic ever holds a corrupted byte —
293    // better than panicking on a degraded process and matches the
294    // default operators see at boot.
295    let mut active_policy = AckPolicy::from_u8(
296        ack_policy.load(std::sync::atomic::Ordering::Relaxed),
297    )
298    .unwrap_or_else(|| {
299        tracing::error!(
300            "ack_policy atomic held a corrupted byte at startup; defaulting to disk+ram"
301        );
302        AckPolicy::DiskAndRam
303    });
304    let mut policy = active_policy.to_policy();
305    // SINGLE_ISSUER: created and submitted from this thread only — the
306    // kernel skips SQ locking and rejects cross-thread submission with
307    // EEXIST instead of racing. Matches the journal/replication rings.
308    let mut ring: IoUring = IoUring::builder()
309        .setup_single_issuer()
310        .build(RING_SIZE)
311        .expect("failed to create io_uring instance for response stage");
312
313    // Connection table: maps connection IDs to their state.
314    //
315    // `FxHashMap` rather than std's SipHash-1-3 map: this is looked up
316    // once per output slot on the hot path, and the keys are
317    // server-generated monotonic connection ids — a client cannot choose
318    // them, so the HashDoS resistance SipHash buys is worth nothing here
319    // while its ~20 ns/lookup is not. Pre-sized for a reasonable number
320    // of concurrent clients.
321    let mut connections: FxHashMap<u64, ConnectionEntry> =
322        FxHashMap::with_capacity_and_hasher(256, Default::default());
323
324    let mut encode_buf = [0u8; MAX_RESPONSE_BUF];
325
326    // Cached durability position to avoid atomic reads on every slot.
327    // Initialised below from the policy's startup evaluation; updated
328    // via `evaluate_durability` on every gate iteration.
329    let mut cached_durable_pos: u64;
330
331    // Degradation logger. Tracks transitions, suppresses sub-second
332    // flap noise, and drives the `/healthz` `policy_degraded` gauge.
333    // See `DegradationLogger` for the full state machine. Initialised
334    // below from the policy's startup evaluation so an unsatisfiable
335    // policy (e.g. a primary that just lost both replicas while
336    // running `disk+ram` or `two-disks`) is visible immediately
337    // on `/healthz` and in the journal.
338    let startup_now = Instant::now();
339    let mut last_policy_check = startup_now;
340    /// Re-emit interval for the "still degraded" reminder.
341    const DEGRADED_LOG_INTERVAL: Duration = Duration::from_secs(5);
342    /// Cadence at which the idle path re-evaluates the policy. Bounds
343    /// the lag between a connection-state change and the `/healthz`
344    /// gauge / warn-log reflecting it. Cheap (a handful of atomic
345    /// loads + the policy evaluator) at this rate.
346    const POLICY_CHECK_INTERVAL: Duration = Duration::from_secs(1);
347    /// Cadence at which the gate-wait spin folds elapsed time into the
348    /// degraded-duration counter while the durability gate is stalled.
349    /// Tighter than the idle cadence — it bounds the boundary error when
350    /// a degradation begins or flips mid-wedge, which matters most for
351    /// short stalls. The accrual tick is gated by this period, but the
352    /// clock read behind it is gated by the `AmortizedTimer` mask, so the
353    /// effective resolution is `max(this, the mask's clock-read cadence)`.
354    /// The mask reads the clock ~every 6.5 ms at this spin's rate
355    /// (`AmortizedTimer::CHECK_MASK` is `2^16`), finer than the 10 ms
356    /// here, so this cadence is the binding one and is realized in full.
357    const GATE_ACCRUAL_INTERVAL: Duration = Duration::from_millis(10);
358    /// Cadence at which the idle spin does its housekeeping *clock read*.
359    /// The heartbeat scan, the policy re-check and the trace-stats flush
360    /// each keep their own (coarser) interval; this only bounds how
361    /// often the loop asks the clock what time it is, so it must stay at
362    /// or below the finest of them (`trace::IDLE_FLUSH_INTERVAL`, 100 ms).
363    const IDLE_HOUSEKEEPING_INTERVAL: Duration = Duration::from_millis(10);
364
365    // Initial evaluation so the cached durable position and the
366    // `/healthz` gauge reflect the cluster's startup shape before
367    // the first batch arrives.
368    let mut degraded_logger;
369    {
370        let journal_pos = journal_persisted_wire_seq.load();
371        let metrics_ref = replication_metrics.as_deref();
372        let active_ref = replica_active.as_ref();
373        let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
374        cached_durable_pos = status.durable_pos;
375        utilization
376            .policy_degraded
377            .store(status.degraded, Ordering::Relaxed);
378        degraded_logger = if status.degraded {
379            DegradationLogger::new_starting_degraded(startup_now, &policy)
380        } else {
381            DegradationLogger::new(startup_now)
382        };
383    }
384
385    // Stage histograms registered with the global registry — see
386    // `melin_transport_core::trace`. The four breakdown stages
387    // (journal-wait, replica-wait, encode, egress) feed the bench's
388    // tick-to-trade decomposition; spsc/dispatch/server-e2e are kept
389    // alongside as overall sanity checks.
390    #[cfg(feature = "latency-trace")]
391    let mut spsc_rec =
392        trace::register_stage("response: SPSC wakeup (matching publish → response consume)");
393    #[cfg(feature = "latency-trace")]
394    let mut dispatch_rec = trace::register_stage("response: dispatch (consume → socket write)");
395    #[cfg(feature = "latency-trace")]
396    let mut server_e2e_rec = trace::register_stage("server e2e (reader recv → response flush)");
397    // Tick-to-trade breakdown: per-slot wait observed for each
398    // durability path (recorded only when the gate actually held us
399    // up — cache-hit paths skip to avoid inflating the metric with
400    // crossings that happened before we noticed). Encode is wall-time
401    // around `encode_transport_response`. Egress wraps a `flush_sends`
402    // call (one sample per io_uring flush, batching many slots).
403    // Gated on `tick-to-trade`, not `latency-trace`, because these
404    // stages roughly double the hot-path mutex traffic vs the lighter
405    // 4-stage mode.
406    #[cfg(feature = "tick-to-trade")]
407    let mut journal_wait_rec =
408        trace::register_stage("response: journal-wait (match_complete → journal cursor crossed)");
409    #[cfg(feature = "tick-to-trade")]
410    let mut replica_wait_rec = trace::register_stage(
411        "response: replica-wait (match_complete → replication cursor crossed)",
412    );
413    #[cfg(feature = "tick-to-trade")]
414    let mut encode_rec = trace::register_stage("response: encode (per-kind wire encoding)");
415    #[cfg(feature = "tick-to-trade")]
416    let mut egress_rec = trace::register_stage("response: egress (flush_sends elapsed)");
417    // Paces the idle-path recorder flush. Without it, every sample this
418    // thread recorded stays in its thread-local buffer once traffic
419    // stops — which is exactly when the bench scrapes /stats-dump.
420    #[cfg(feature = "latency-trace")]
421    let mut last_stats_flush = Instant::now();
422
423    // Server-side end-to-end samples awaiting a flush, tagged with the
424    // connection whose bytes they measure.
425    //
426    // The stage encodes into `send_buf` and hands the bytes to the
427    // kernel later, so closing the sample at encode time would omit
428    // every microsecond a response spends buffered — the interval this
429    // stage most needs to be honest about, since that is exactly where a
430    // deferred flush hides. Holding each frame's `recv_ts` until the
431    // flush completes measures reader-recv → kernel, which is what the
432    // stage name claims and what the DPDK path already records (it
433    // samples after `tx_producers.flush()`).
434    //
435    // The connection id is what lets a connection dropped without a
436    // flush have its samples thrown away rather than closed — see
437    // `discard_e2e_samples`. Two rules keep the queue honest, and every
438    // mutation of `dirty_connections` obeys one of them:
439    //
440    // - a clear-by-flush closes the queue (`close_e2e_samples`),
441    // - a removal-by-drop discards that connection's entries
442    //   (`discard_e2e_samples`).
443    //
444    // Together they give the invariant "queue non-empty implies some
445    // connection is dirty", which is what makes the flush sites
446    // sufficient drain points.
447    //
448    // `Vec` rather than a per-connection map: entries are appended and
449    // then walked wholesale, order is irrelevant, and the dropped-
450    // connection lists they are matched against are almost always empty.
451    // A flat append is the cheapest thing that does that. Capacity is a
452    // starting point, not a bound — under saturation the output ring
453    // never empties, so the queue grows until something forces a flush.
454    //
455    // Only compiled under `latency-trace`; production builds carry
456    // neither the buffer nor the pushes.
457    #[cfg(feature = "latency-trace")]
458    let mut pending_e2e: Vec<(u64, trace::MonoTraceInstant)> = Vec::with_capacity(MAX_BATCH);
459
460    // Connections with buffered (unflushed) writes, carried across
461    // batches.
462    //
463    // `Vec<u64>` paired with `ConnectionEntry::dirty` rather than a
464    // `HashSet`: membership is never *queried*, only iterated and
465    // rebuilt, so the set's only contribution on the hot path was
466    // deduplicating repeat appends — which the entry flag does with a
467    // branch instead of a hash. Pre-sized to the connection table.
468    let mut dirty_connections: Vec<u64> = Vec::with_capacity(256);
469
470    // Flush triggers for the consumed path. Lives across batches because
471    // the regime the age bound exists for is a ring that never empties:
472    // the byte threshold is per connection, so with many low-rate
473    // clients no single `send_buf` reaches an MSS for milliseconds while
474    // the stage encodes continuously.
475    let mut flush = FlushCadence::default();
476
477    // Connections to remove after flush (send errors).
478    let mut to_remove: Vec<u64> = Vec::new();
479
480    // Pre-allocated CQE collection buffer. Must collect CQEs before
481    // processing because the CQ borrow must end before mutating connections.
482    // Pre-sized to RING_SIZE to avoid per-iteration heap allocation.
483    let mut cqes: Vec<(u64, i32)> = Vec::with_capacity(RING_SIZE as usize);
484
485    // Pre-encode the heartbeat response frame once. Full wire frame
486    // (length prefix + tag) for direct append to send_buf.
487    let heartbeat_wire_frame = {
488        let mut buf = [0u8; 8];
489        let written =
490            control_codec::encode_transport_response(&TransportResponse::Heartbeat, &mut buf)
491                .expect("heartbeat encodes");
492        buf[..written].to_vec()
493    };
494
495    // Pre-encode the ServerBusy frame the same way. Sent on behalf of
496    // the reader (`ControlEvent::PipelineBusy`) — this stage owns all
497    // egress on a client socket, so the busy notice appends to
498    // `send_buf` like any other frame instead of racing our sends from
499    // the reader thread.
500    let server_busy_wire_frame = {
501        let mut buf = [0u8; 8];
502        let written =
503            control_codec::encode_transport_response(&TransportResponse::ServerBusy, &mut buf)
504                .expect("ServerBusy encodes");
505        buf[..written].to_vec()
506    };
507
508    // Pre-encode the BatchEnd terminator once. Unlike the two above this
509    // one is on the hot path — every request ends with it — and its
510    // bytes are a constant, so re-encoding it per request was pure
511    // overhead.
512    let batch_end_wire_frame = {
513        let mut buf = [0u8; 8];
514        let written =
515            control_codec::encode_transport_response(&TransportResponse::BatchEnd, &mut buf)
516                .expect("BatchEnd encodes");
517        buf[..written].to_vec()
518    };
519
520    // Coarse timestamp for heartbeat scan — avoids Instant::now() on every spin.
521    let mut last_heartbeat_scan = Instant::now();
522
523    // Gates the idle path's housekeeping clock read. The idle spin used
524    // to call `Instant::now()` twice per iteration (heartbeat scan gate,
525    // policy re-check) to decide it had nothing to do — the same vDSO
526    // tax the DPDK stage removed with this timer.
527    let mut idle_housekeeping_timer = AmortizedTimer::new();
528
529    let mut waiter = wait.waiter();
530
531    let mut busy_count: u64 = 0;
532    let mut idle_count: u64 = 0;
533
534    // Paces accrual ticks inside the durability gate-wait spin so the
535    // degraded-duration counter keeps advancing during a hard stall.
536    // Declared once at function scope (not per gate entry) so the normal
537    // gated path — entered briefly whenever durability lags by a few µs —
538    // pays no extra `Instant::now()`; the amortized mask only reads the
539    // clock once per ~1 M cumulative spin iterations.
540    let mut gate_accrual_timer = AmortizedTimer::new();
541
542    loop {
543        // Observe runtime policy swaps from the admin `ACK-POLICY`
544        // command. Relaxed load (single writer is the admin handler,
545        // single reader is this thread). When the byte changes,
546        // rebuild the local Policy and reset the cached durable
547        // position so the next gate evaluation starts from a clean
548        // slate under the new shape; log the transition for the audit
549        // trail. An unknown byte is treated as memory corruption: we
550        // log and keep the prior policy rather than silently downgrading.
551        let observed_byte = ack_policy.load(Ordering::Relaxed);
552        if observed_byte != active_policy.as_u8() {
553            match AckPolicy::from_u8(observed_byte) {
554                Some(next) => {
555                    tracing::info!(
556                        prev = active_policy.as_str(),
557                        next = next.as_str(),
558                        "ack policy swapped at runtime"
559                    );
560                    active_policy = next;
561                    policy = active_policy.to_policy();
562                    // The fresh policy may evaluate degraded/undegraded
563                    // differently against the same cluster shape; let
564                    // the next gate evaluation re-derive.
565                    cached_durable_pos = 0;
566                    // Re-seed the degradation logger so a transition
567                    // out of (or into) degraded under the new policy
568                    // surfaces immediately rather than waiting for the
569                    // sustained-state hold to roll over. Flushes accrual
570                    // first so pre-swap degraded time isn't dropped.
571                    degraded_logger.reseed(&utilization, Instant::now());
572                }
573                None => {
574                    tracing::error!(
575                        byte = observed_byte,
576                        "ack_policy atomic held a corrupted byte; retaining prior policy"
577                    );
578                }
579            }
580        }
581
582        if shutdown.load(Ordering::Relaxed) {
583            // Fence: a superseded ex-primary must not acknowledge any
584            // further in-flight work — skip the best-effort flush so
585            // responses buffered for orders on the old epoch are dropped
586            // (the client sees a connection reset and reconciles on
587            // reconnect). Checked only here, not per iteration: fencing
588            // always co-sets `shutdown` (`FenceState::fence_if_superseded`
589            // owns that invariant), so this branch is the first one a
590            // fenced node reaches and the steady-state loop pays nothing.
591            let flush = !fence_state.is_fenced();
592            // Best-effort flush before shutdown.
593            if flush && !dirty_connections.is_empty() {
594                flush_sends(
595                    &mut ring,
596                    &mut connections,
597                    &mut dirty_connections,
598                    &mut to_remove,
599                    &mut cqes,
600                );
601                #[cfg(feature = "latency-trace")]
602                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
603            }
604            utilization.busy.store(busy_count, Ordering::Relaxed);
605            utilization.idle.store(idle_count, Ordering::Relaxed);
606            #[cfg(feature = "pipeline-stats")]
607            print_utilization("response", busy_count, idle_count);
608            return;
609        }
610
611        // Borrow output slots from the matching stage in place.
612        //
613        // `read_contiguous` rather than `consume_batch`: the latter
614        // memcpy'd every ready slot into a stack array before the first
615        // one was touched, and `OutputSlot` embeds the application's
616        // largest query response (~330 B for the exchange), so the head
617        // slot of a deep batch paid for the whole copy before it could
618        // be encoded. Borrowing costs nothing and the loop below reads
619        // each slot exactly once anyway.
620        //
621        // The progress counter now moves *after* the batch instead of
622        // before it (`consume_batch` committed up front), so the
623        // matching stage cannot reclaim these slots until the loop —
624        // durability gate waits included — is done with them. That is
625        // at most `MAX_BATCH` of a 1 M-slot ring, and the ring fills at
626        // the same rate either way.
627        let slots = consumer.read_contiguous(MAX_BATCH);
628
629        // Poll the control channel (non-blocking) for connect and
630        // disconnect — after the ring is read, never before. A reply
631        // always trails its connection's `Connected`: the accept loop
632        // queues that event before it registers the socket with the
633        // reader, so no request can be read, let alone answered, until
634        // the event is in the channel. Every slot in `slots` was
635        // published before the read above, so its `Connected` was queued
636        // before that, and this drain sees it. Drained first, the stage
637        // could read the ring after a `Connected` it had not seen and
638        // drop the reply — which it did, now and then, on a fresh node
639        // under load. `registration_race` in the tests holds the stage
640        // right after this drain to pin the order.
641        while let Ok(event) = control_rx.try_recv() {
642            match event {
643                ControlEvent::Connected {
644                    connection_id,
645                    fd,
646                    writer,
647                } => {
648                    // The writer keeps the fd alive — store it as the owner.
649                    let owner: Box<dyn Send> = Box::new(writer);
650                    connections.insert(
651                        connection_id,
652                        ConnectionEntry {
653                            fd,
654                            _owner: owner,
655                            send_buf: Vec::with_capacity(4096),
656                            last_send: Instant::now(),
657                            blocked_since: None,
658                            last_send_attempt: Instant::now(),
659                            dirty: false,
660                        },
661                    );
662                }
663                ControlEvent::Disconnected { connection_id } => {
664                    // Reader-initiated teardown: the reader already
665                    // closed its half. Decrement only if the entry was
666                    // actually present — a response-initiated drop
667                    // already removed it (and paid the decrement), and
668                    // this event is its echo.
669                    if connections.remove(&connection_id).is_some() {
670                        active_connections.fetch_sub(1, Ordering::Relaxed);
671                    }
672                    unmark_dirty(connection_id, &mut dirty_connections);
673                    // Anything this connection had buffered goes with
674                    // it, so its queued samples measure bytes that will
675                    // never be sent.
676                    #[cfg(feature = "latency-trace")]
677                    discard_e2e_samples(&mut pending_e2e, &[connection_id]);
678                }
679                ControlEvent::PipelineBusy { connection_id } => {
680                    if let Some(entry) = connections.get_mut(&connection_id) {
681                        // Overflow discipline: a busy notice to a peer
682                        // already at its buffer cap is not worth more
683                        // memory — skip it; the cap (or the blocked
684                        // timeout) is about to drop the connection
685                        // anyway. Missing entry likewise: best-effort.
686                        if entry.send_buf.len() + server_busy_wire_frame.len() <= MAX_SEND_BUF {
687                            entry.send_buf.extend_from_slice(&server_busy_wire_frame);
688                            entry.last_send = Instant::now();
689                            mark_dirty(entry, connection_id, &mut dirty_connections);
690                        }
691                    }
692                }
693            }
694        }
695
696        // Test seam: hold the stage right after the drain — see
697        // `Response::pause_after_control_drain`.
698        #[cfg(test)]
699        if let Some(pause) = pause_after_control_drain.as_mut() {
700            pause();
701        }
702
703        if slots.is_empty() {
704            // SPSC is empty — flush all dirty connections via io_uring.
705            // This is the response-data egress path; heartbeat flushes
706            // below aren't sampled because they're admin traffic, not
707            // on the client RTT path.
708            if !dirty_connections.is_empty() {
709                #[cfg(feature = "tick-to-trade")]
710                let egress_start = trace::mono_trace_ns();
711                flush_sends(
712                    &mut ring,
713                    &mut connections,
714                    &mut dirty_connections,
715                    &mut to_remove,
716                    &mut cqes,
717                );
718                flush.on_flush();
719                #[cfg(feature = "tick-to-trade")]
720                egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
721                #[cfg(feature = "latency-trace")]
722                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
723                for conn_id in to_remove.drain(..) {
724                    if let Some(entry) = connections.remove(&conn_id) {
725                        teardown_dropped(entry, &active_connections);
726                    }
727                }
728            }
729
730            // Everything below is periodic housekeeping on
731            // sub-second-to-second cadences, and each check needs the
732            // current time only to discover it has nothing to do. One
733            // amortized tick per idle iteration replaces the two
734            // unconditional `Instant::now()` calls that decision used to
735            // cost; the inner intervals are unchanged, so cadences are
736            // the same (see `IDLE_HOUSEKEEPING_INTERVAL`).
737            //
738            // The timer's iteration mask is only engaged once the stage
739            // has been idle for a sustained stretch (the waiter resets
740            // on every consumed batch). That is deliberate: the mask
741            // trades cadence for clock reads, and it should only do so
742            // when clock reads are the loop's whole cost. A stage that
743            // is nearly saturated reaches this path a handful of times
744            // between batches — there the read is already rare, and
745            // masking it would stretch the one-second heartbeat scan
746            // into minutes. A yielding thread never masks either: the
747            // yield syscall dwarfs a vDSO read.
748            //
749            // "Busy-spinning, and has been for a while": under
750            // spin-then-yield a loop past its budget is no longer
751            // spinning, so the conjunction is only ever true under
752            // `BusySpin`.
753            if idle_housekeeping_timer
754                .tick(
755                    IDLE_HOUSEKEEPING_INTERVAL,
756                    waiter.spinning() && waiter.past_spin_budget(),
757                )
758                .is_some()
759            {
760                let now = Instant::now();
761
762                // Send heartbeats to idle connections. Only checked
763                // during idle periods (SPSC empty) to avoid overhead on
764                // the hot path.
765                //
766                // No end-to-end samples to close here: the sample
767                // queue's invariant is that a queued entry implies a
768                // dirty connection, so the flush above — which closes
769                // the queue whenever anything was dirty — leaves it
770                // empty. Anything dirtied from here on is heartbeat
771                // frames, which carry no samples.
772                if let Some(interval) = heartbeat_interval
773                    // Coarse gate: only scan at most once per second.
774                    && now.duration_since(last_heartbeat_scan) >= Duration::from_secs(1)
775                {
776                    last_heartbeat_scan = now;
777                    for (&conn_id, entry) in connections.iter_mut() {
778                        if heartbeat_due(entry, now, interval, heartbeat_wire_frame.len()) {
779                            entry.send_buf.extend_from_slice(&heartbeat_wire_frame);
780                            entry.last_send = now;
781                            mark_dirty(entry, conn_id, &mut dirty_connections);
782                        }
783                    }
784                    // Flush the heartbeat sends immediately.
785                    if !dirty_connections.is_empty() {
786                        flush_sends(
787                            &mut ring,
788                            &mut connections,
789                            &mut dirty_connections,
790                            &mut to_remove,
791                            &mut cqes,
792                        );
793                        flush.on_flush();
794                        for conn_id in to_remove.drain(..) {
795                            if let Some(entry) = connections.remove(&conn_id) {
796                                teardown_dropped(entry, &active_connections);
797                            }
798                        }
799                    }
800                }
801
802                // Re-evaluate the ack policy on a slow timer so
803                // the `policy_degraded` flag and the periodic warn track
804                // the cluster's real state even on idle / quiet venues.
805                // The gate-open block also calls `update_degraded_state`
806                // after each consumed batch; this is the equivalent for
807                // the no-batch path.
808                if now.duration_since(last_policy_check) >= POLICY_CHECK_INTERVAL {
809                    last_policy_check = now;
810                    let journal_pos = journal_persisted_wire_seq.load();
811                    let metrics_ref = replication_metrics.as_deref();
812                    let active_ref = replica_active.as_ref();
813                    let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
814                    degraded_logger.tick(
815                        &policy,
816                        &utilization,
817                        status.degraded,
818                        now,
819                        DEGRADED_LOG_INTERVAL,
820                    );
821                    // Cache the position so the next batch's gate sees a
822                    // fresh value rather than spinning from a stale cache.
823                    cached_durable_pos = status.durable_pos;
824                }
825
826                // Hand buffered latency samples to the stats registry
827                // while we have nothing better to do. Reuses the
828                // housekeeping clock read, so the spin path picks up no
829                // extra `clock_gettime`.
830                #[cfg(feature = "latency-trace")]
831                if now.duration_since(last_stats_flush) >= trace::IDLE_FLUSH_INTERVAL {
832                    last_stats_flush = now;
833                    spsc_rec.flush();
834                    dispatch_rec.flush();
835                    server_e2e_rec.flush();
836                    #[cfg(feature = "tick-to-trade")]
837                    {
838                        journal_wait_rec.flush();
839                        replica_wait_rec.flush();
840                        encode_rec.flush();
841                        egress_rec.flush();
842                    }
843                }
844            }
845
846            idle_count += 1;
847            if idle_count.is_multiple_of(1024) {
848                utilization.busy.store(busy_count, Ordering::Relaxed);
849                utilization.idle.store(idle_count, Ordering::Relaxed);
850            }
851            waiter.idle();
852            continue;
853        }
854        waiter.reset();
855        busy_count += 1;
856
857        #[cfg(feature = "latency-trace")]
858        let consume_ts = trace::mono_trace_ns();
859
860        // Wait for durability confirmation before sending responses.
861        //
862        // Gate on `wire_seq`, not `input_seq`. `input_seq` is in
863        // local-consumer space (the matching cursor on the input ring,
864        // starts at 0 in this process) while replica metrics and the
865        // primary's `journal_persisted_wire_seq` live in wire-seq space
866        // (allocated by the journal stage starting at
867        // `starting_sequence`). A `needed` derived from `input_seq` and
868        // compared against wire-seq cursors only works when
869        // `starting_sequence == 1`; a recovered primary (or any process
870        // whose journal already has prior content pushing
871        // `starting_sequence` above 1) would silently open the gate
872        // ahead of the replica's actual replicated state.
873        //
874        // Every cursor in the policy view (`journal_persisted_wire_seq`,
875        // `metrics.in_memory_sequence`, `metrics.acked_sequence`)
876        // carries "highest wire seq known to be in that state on node
877        // X". A slot's `wire_seq` is therefore the *exact* wire seq the
878        // gate must see — not `+1` — for that slot's event to be
879        // considered durable. The legacy `+1` was load-bearing only
880        // because `input_seq` was off by `starting_sequence - 1` from
881        // wire seq; with the wire-seq stamp it would over-shoot by one
882        // event and make the gate stall an extra round-trip per
883        // response.
884        //
885        // The gate is evaluated **per slot**, not once per batch against
886        // the batch's maximum `wire_seq`. Batch-max gating made every
887        // response in a batch wait for the newest event in it to become
888        // durable, so the oldest slot paid the durability latency of an
889        // event sequenced up to `MAX_BATCH` positions after it — a
890        // head-of-line block bounded only by the batch size, and one
891        // that bites hardest exactly when the stage has caught up to the
892        // durability frontier under load. Per-slot gating walks that
893        // frontier instead: each response is released as soon as its own
894        // event is durable.
895        //
896        // The extra waits are self-limiting. Slots arrive FIFO with
897        // monotonic `wire_seq`, and the journal publishes its cursor per
898        // fsync batch, so one wait typically advances the cursor past a
899        // whole run of following slots and they fall through without
900        // spinning. The number of waits (and so of flushes) tracks real
901        // durability boundaries, not the slot count. Correctness does
902        // not depend on that ordering — an unsorted batch would simply
903        // wait more often.
904        let batch_now = Instant::now();
905
906        // `flush` (see `FlushCadence`) is fed at append time and checked
907        // after every slot, so the flush runs *within* the batch.
908        // Deferring it to the end of the batch would reopen the defect
909        // the byte threshold exists to close: one MAX_BATCH batch of
910        // large frames (anything over ~64 bytes average) can push a
911        // healthy connection from empty past `MAX_SEND_BUF` before a
912        // batch-end flush ever ran, tearing it down with nothing
913        // written. A flag written at append time rather than a per-slot
914        // scan of the dirty set: the scan would cost a hash lookup per
915        // dirty connection on the hot path.
916        for slot in slots {
917            #[cfg(feature = "latency-trace")]
918            spsc_rec.record_elapsed(slot.match_complete_ts, consume_ts);
919
920            // Per-slot journal-wait / replica-wait tracker. See
921            // `GateCrossTracker` for the rationale (only records cursors
922            // that were actually on the critical path). Both the
923            // tracker's replica cursor and the attribution counters
924            // below are derived from the policy in force rather than a
925            // fixed level, so "which subsystem to optimize" answers for
926            // the deployment the operator actually configured. A slot
927            // that never waits leaves the tracker unobserved, and an
928            // unobserved tracker reports no crossing — so the fast path
929            // records nothing rather than a zero-length sample.
930            #[cfg(feature = "tick-to-trade")]
931            let mut gate_tracker = GateCrossTracker::new(slot.wire_seq);
932
933            if slot_needs_gate(slot, cached_durable_pos) {
934                let needed = slot.wire_seq;
935
936                // Drain buffered sends before blocking, never after.
937                //
938                // The steady-state flush happens on the `count == 0`
939                // path — once the output ring is empty. That batches
940                // many responses behind one `io_uring_enter`, which is
941                // the right trade on the fast path. But it means a
942                // response that has already cleared its own durability
943                // gate stays in `send_buf` while the loop blocks on a
944                // later event's gate, so a client waits out an fsync +
945                // replica round-trip for an event whose durability was
946                // confirmed before that event was even sequenced.
947                //
948                // Flushing here costs nothing in latency terms: it only
949                // fires when we are about to spin on the gate anyway, so
950                // the syscall overlaps time that would otherwise be
951                // dead. Slots whose gate is already satisfied fall
952                // straight through and keep their batching.
953                //
954                // Traced builds only: this delays the tracker's first
955                // `observe` by the flush duration, so a cursor that
956                // crosses mid-flush is attributed late (or, if it
957                // crosses past `needed`, not attributed at all). Bounded
958                // by one flush and confined to the tick-to-trade
959                // breakdown — the gate's own behaviour is unchanged.
960                if !dirty_connections.is_empty() {
961                    #[cfg(feature = "tick-to-trade")]
962                    let egress_start = trace::mono_trace_ns();
963                    flush_sends(
964                        &mut ring,
965                        &mut connections,
966                        &mut dirty_connections,
967                        &mut to_remove,
968                        &mut cqes,
969                    );
970                    flush.on_flush();
971                    #[cfg(feature = "tick-to-trade")]
972                    egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
973                    #[cfg(feature = "latency-trace")]
974                    close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
975                    // This slot and later ones addressed to a dropped
976                    // connection are skipped by the
977                    // `connections.get_mut` lookup below, so removing
978                    // here is safe.
979                    for conn_id in to_remove.drain(..) {
980                        if let Some(entry) = connections.remove(&conn_id) {
981                            teardown_dropped(entry, &active_connections);
982                        }
983                    }
984                }
985
986                // The gate waits on the journal-disk thread and the
987                // replication handlers — the exact threads a small box
988                // co-schedules with this one, so it waits the way every
989                // other wait does. A fresh waiter per gate entry: the
990                // spin budget is meant to cover one durability lag, not
991                // to carry over from the idle loop.
992                let mut gate_waiter = wait.waiter();
993                loop {
994                    // Inside the gate-wait loop, also observe a
995                    // policy swap. Without this, a batch whose gate
996                    // becomes structurally unsatisfiable (e.g. all
997                    // replicas die while a non-bypass slot is in
998                    // flight under `DiskAndRam`) would wedge the response
999                    // stage forever, even if an operator sends the
1000                    // remediating `ACK-POLICY disk` — the outer loop
1001                    // observation never gets a chance to run. The
1002                    // relaxed load is ~1 cycle on x86; cheaper than
1003                    // the wait below.
1004                    let observed_byte = ack_policy.load(Ordering::Relaxed);
1005                    if observed_byte != active_policy.as_u8()
1006                        && let Some(next) = AckPolicy::from_u8(observed_byte)
1007                    {
1008                        tracing::info!(
1009                            prev = active_policy.as_str(),
1010                            next = next.as_str(),
1011                            "ack policy swapped during gate wait"
1012                        );
1013                        active_policy = next;
1014                        policy = active_policy.to_policy();
1015                        // Flush accrual before re-seeding so the wedged-
1016                        // degraded interval up to the swap isn't dropped.
1017                        degraded_logger.reseed(&utilization, Instant::now());
1018                    }
1019
1020                    let journal_pos = journal_persisted_wire_seq.load();
1021                    let metrics_ref = replication_metrics.as_deref();
1022                    let active_ref = replica_active.as_ref();
1023
1024                    // The cross-tracker (traced builds only) samples the
1025                    // replica cursor itself rather than sharing the
1026                    // evaluation's read below: computing a standalone
1027                    // replica cursor unconditionally spent four Acquire
1028                    // loads per spin iteration on `ReplicationMetrics`,
1029                    // the same cache line the replication sender writes
1030                    // on every ack and every completed SEND. Gate
1031                    // attribution does not re-read at all — it comes out
1032                    // of `evaluate_gate`, from the same snapshot that
1033                    // opens the gate.
1034                    #[cfg(feature = "tick-to-trade")]
1035                    gate_tracker.observe(
1036                        journal_pos.get(),
1037                        // The level the *active policy* gates replicas
1038                        // on — in-memory under `disk+ram`, persisted under
1039                        // `two-disks`. `None` when no clause is
1040                        // replica-supplied (`disk`) and, transiently,
1041                        // when the binding replica drops out of the
1042                        // cursor view mid-wait. Passed through as-is so
1043                        // the tracker can tell "no replica wait to
1044                        // measure" from "the replica caught up".
1045                        policy_replica_cursor(&policy, journal_pos, metrics_ref, active_ref),
1046                        trace::mono_trace_ns(),
1047                    );
1048
1049                    let (status, blocker) =
1050                        evaluate_gate(&policy, needed, journal_pos, metrics_ref, active_ref);
1051                    cached_durable_pos = status.durable_pos;
1052                    utilization
1053                        .policy_degraded
1054                        .store(status.degraded, Ordering::Relaxed);
1055
1056                    // Accrue degraded time while wedged. The post-gate
1057                    // tick attributes the whole wait to a single state, so
1058                    // without this a healthy→degraded flip during the
1059                    // wedge would be mis-charged. While the gate waiter
1060                    // spins the clock read behind the tick is mask-gated,
1061                    // landing only every ~65 k iterations
1062                    // (`CHECK_MASK = 2^16`) regardless of the period
1063                    // below; once it yields, each iteration already pays
1064                    // a syscall and the read is unmasked.
1065                    if gate_accrual_timer
1066                        .tick(GATE_ACCRUAL_INTERVAL, gate_waiter.spinning())
1067                        .is_some()
1068                    {
1069                        degraded_logger.tick(
1070                            &policy,
1071                            &utilization,
1072                            status.degraded,
1073                            Instant::now(),
1074                            DEGRADED_LOG_INTERVAL,
1075                        );
1076                    }
1077
1078                    if cached_durable_pos >= needed {
1079                        // Attribution: which subsystem supplied the
1080                        // binding cursor, from the same snapshot that
1081                        // opened the gate and against the policy
1082                        // actually in force. Relaxed is fine — health
1083                        // reads are infrequent.
1084                        //
1085                        // `None` is unreachable here: `needed >= 1`
1086                        // inside this loop, and a degraded evaluation
1087                        // pins `durable_pos` to 0, so an open gate
1088                        // implies the policy was satisfiable and
1089                        // attribution has a verdict. The no-op arm
1090                        // keeps a metrics-only path from ever
1091                        // panicking regardless.
1092                        match blocker {
1093                            Some(Blocker::Journal) => {
1094                                utilization.gate_journal.fetch_add(1, Ordering::Relaxed);
1095                            }
1096                            Some(Blocker::Replication) => {
1097                                utilization.gate_replication.fetch_add(1, Ordering::Relaxed);
1098                            }
1099                            None => {}
1100                        }
1101                        break;
1102                    }
1103                    gate_waiter.idle();
1104                }
1105            }
1106
1107            // Per-slot durability-gate breakdown. Recorded only when the
1108            // gate actually held us up (the tracker captured a cross).
1109            // The cross timestamp is this slot's own `wire_seq`, so the
1110            // sample measures the wait that slot really paid — under
1111            // batch-max gating it was the batch maximum's crossing,
1112            // which overstated every slot but the last by up to the
1113            // batch's matching span.
1114            #[cfg(feature = "tick-to-trade")]
1115            if let Some(ts) = gate_tracker.journal_crossed() {
1116                journal_wait_rec.record_elapsed(slot.match_complete_ts, ts);
1117            }
1118            #[cfg(feature = "tick-to-trade")]
1119            if let Some(ts) = gate_tracker.replica_crossed() {
1120                replica_wait_rec.record_elapsed(slot.match_complete_ts, ts);
1121            }
1122
1123            // Each slot expands to at most two wire frames: the
1124            // application payload (Report / Query / EngineError) and
1125            // an optional trailing `BatchEnd` when
1126            // `is_last_in_request` is set. Application-shaped
1127            // payloads go through the encoder; transport-shaped
1128            // frames (EngineError, BatchEnd) are encoded by the
1129            // runtime directly.
1130            if let Some(entry) = connections.get_mut(&slot.connection_id) {
1131                // Frame 1: application payload (if any). BatchEnd
1132                // payloads carry no body — the terminator below
1133                // handles them via `is_last_in_request`.
1134                let payload_result: Option<Result<usize, &'static str>> = match slot.payload {
1135                    OutputPayload::Report(ref report) => {
1136                        Some(encoder.encode_report(report, &mut encode_buf))
1137                    }
1138                    OutputPayload::QueryResponse(ref q) => {
1139                        Some(encoder.encode_query(q, &mut encode_buf))
1140                    }
1141                    OutputPayload::EngineError => Some(
1142                        control_codec::encode_transport_response(
1143                            &TransportResponse::EngineError,
1144                            &mut encode_buf,
1145                        )
1146                        .map_err(|_| "encode error"),
1147                    ),
1148                    OutputPayload::BatchEnd => None,
1149                };
1150
1151                // Frame 2: the pre-encoded BatchEnd terminator, appended
1152                // with the payload in one call.
1153                let trailer: &[u8] = if slot.is_last_in_request {
1154                    &batch_end_wire_frame
1155                } else {
1156                    &[]
1157                };
1158
1159                #[cfg(feature = "tick-to-trade")]
1160                let encode_start = trace::mono_trace_ns();
1161                // Underscored: with both frames in one call there is no
1162                // second append to skip, so the outcome is purely
1163                // informational — `append_frames` has already queued an
1164                // overflowing connection for removal. Only the traced
1165                // build reads it, to avoid measuring bytes that were
1166                // never buffered.
1167                let _outcome = append_frames(
1168                    payload_result,
1169                    trailer,
1170                    slot.connection_id,
1171                    entry,
1172                    &encode_buf,
1173                    batch_now,
1174                    &mut dirty_connections,
1175                    &mut to_remove,
1176                );
1177                #[cfg(feature = "tick-to-trade")]
1178                encode_rec.record_elapsed(encode_start, trace::mono_trace_ns());
1179
1180                // Queue the server-side end-to-end sample: reader recv ->
1181                // response flush. Only a request's last slot carries this
1182                // measurement; queued after the append so a dropped
1183                // connection doesn't skew the metric, and closed by
1184                // whichever flush ships the bytes.
1185                #[cfg(feature = "latency-trace")]
1186                if slot.is_last_in_request && matches!(_outcome, AppendOutcome::Continue) {
1187                    pending_e2e.push((slot.connection_id, slot.recv_ts));
1188                }
1189
1190                flush.on_append(entry.send_buf.len());
1191            } else if slot.connection_id != 0 {
1192                // A reply for a connection this stage does not hold: it
1193                // was dropped earlier in this batch, or went away before
1194                // its reply was encoded. The drain above runs after the
1195                // ring is read, so a connection that is merely new is
1196                // never a miss. Logged so a lost reply leaves a trace.
1197                // Connection 0 is a server-originated event — a seed, a
1198                // tick — whose reports have no client to go to, and is
1199                // skipped without a word.
1200                debug!(
1201                    connection_id = slot.connection_id,
1202                    wire_seq = slot.wire_seq,
1203                    "reply dropped: connection not registered"
1204                );
1205            }
1206
1207            // Consumed-path flush — the still-open half of July-audit
1208            // finding 4, plus its age bound (August audit finding 3).
1209            // Under sustained load with an open gate this is the ONLY
1210            // flush that runs: the idle path needs an empty SPSC, the
1211            // gate path needs a durability wait, and heartbeats need
1212            // idleness. Without it, delivery on a busy stretch
1213            // degenerates to `MAX_SEND_BUF` evicting the very clients
1214            // being served. Trigger-gated so light traffic keeps full
1215            // batching; once a connection holds ~an MSS — or the stage
1216            // has encoded `FLUSH_SLOT_INTERVAL` responses since the last
1217            // flush, whichever comes first — the extra submit no longer
1218            // costs wire efficiency. MSG_DONTWAIT flushes cannot block,
1219            // so this adds no head-of-line exposure (the hazard that
1220            // deferred this trigger in July). Runs between slots, not
1221            // after the batch, so `MAX_SEND_BUF` can never trip on a
1222            // healthy connection within a single batch — a blocked peer
1223            // re-arms the flag on its later slots, but its paced retry
1224            // inside `flush_sends` keeps that cheap.
1225            if flush.is_due() {
1226                flush.on_flush();
1227                // Overflow-dropped connections leave first so the flush
1228                // skips them — same order as the batch-end cleanup, and
1229                // their queued samples go with them (they measure bytes
1230                // that will never be flushed). Later slots addressed to
1231                // them are skipped by the `connections.get_mut` miss
1232                // above.
1233                #[cfg(feature = "latency-trace")]
1234                discard_e2e_samples(&mut pending_e2e, &to_remove);
1235                for conn_id in to_remove.drain(..) {
1236                    if let Some(entry) = connections.remove(&conn_id) {
1237                        teardown_dropped(entry, &active_connections);
1238                    }
1239                    unmark_dirty(conn_id, &mut dirty_connections);
1240                }
1241                #[cfg(feature = "tick-to-trade")]
1242                let egress_start = trace::mono_trace_ns();
1243                flush_sends(
1244                    &mut ring,
1245                    &mut connections,
1246                    &mut dirty_connections,
1247                    &mut to_remove,
1248                    &mut cqes,
1249                );
1250                #[cfg(feature = "tick-to-trade")]
1251                egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
1252                #[cfg(feature = "latency-trace")]
1253                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
1254                for conn_id in to_remove.drain(..) {
1255                    if let Some(entry) = connections.remove(&conn_id) {
1256                        teardown_dropped(entry, &active_connections);
1257                    }
1258                }
1259            }
1260        }
1261
1262        // The borrowed slots are dead here, so the batch can be handed
1263        // back to the producer. Publishing progress is deliberately the
1264        // last thing the iteration does with the ring: every slot has
1265        // cleared its durability gate and been encoded by now.
1266        consumer.commit();
1267
1268        // Remove connections that exceeded the send buffer limit. Like
1269        // the disconnect handler, this un-dirties a connection without
1270        // flushing it, so it has to discard that connection's queued
1271        // samples too — otherwise they outlive the bytes they measure
1272        // and land on some later, unrelated flush.
1273        #[cfg(feature = "latency-trace")]
1274        discard_e2e_samples(&mut pending_e2e, &to_remove);
1275        for conn_id in to_remove.drain(..) {
1276            if let Some(entry) = connections.remove(&conn_id) {
1277                teardown_dropped(entry, &active_connections);
1278            }
1279            unmark_dirty(conn_id, &mut dirty_connections);
1280        }
1281
1282        // Log degradation transitions / re-emit the reminder. Same
1283        // logger the idle path uses; transitions are gated on a
1284        // sustained-state hold so sub-second flap doesn't spam.
1285        //
1286        // Ticked after dispatch rather than before it, and off a fresh
1287        // clock read: with the gate now evaluated per slot, a batch can
1288        // span several waits, and `batch_now` was taken before the first
1289        // of them. The accrual inside each wait already charges degraded
1290        // time as it elapses; this tick decides transitions, so it wants
1291        // the state and the timestamp as of the end of the batch.
1292        let ticked_at = Instant::now();
1293        let degraded_now = utilization.policy_degraded.load(Ordering::Relaxed);
1294        degraded_logger.tick(
1295            &policy,
1296            &utilization,
1297            degraded_now,
1298            ticked_at,
1299            DEGRADED_LOG_INTERVAL,
1300        );
1301        // Bump the idle-path's check timestamp so we don't double-
1302        // tick the logger when traffic stops.
1303        last_policy_check = ticked_at;
1304
1305        #[cfg(feature = "latency-trace")]
1306        dispatch_rec.record_elapsed(consume_ts, trace::mono_trace_ns());
1307    }
1308}
1309
1310/// Close every queued server-side end-to-end sample against the flush
1311/// that just completed. Call immediately after `flush_sends`.
1312///
1313/// Closes the whole queue unconditionally, including entries for a
1314/// connection the flush then dropped on a send error. That is
1315/// deliberate: the stage measures "reader recv → response flush", which
1316/// ends when the bytes are handed to the kernel, not when the peer
1317/// acknowledges them. A failed SEND still produces a sample whose
1318/// duration is the real one for everything this stage controls.
1319/// Contrast [`discard_e2e_samples`], where no flush happens at all.
1320///
1321/// One clock read serves the whole queue: the samples all end at the
1322/// same flush, so reading per entry would only measure the drain loop.
1323#[cfg(feature = "latency-trace")]
1324fn close_e2e_samples(
1325    pending: &mut Vec<(u64, trace::MonoTraceInstant)>,
1326    rec: &mut trace::StageRecorder,
1327) {
1328    let flushed_at = trace::mono_trace_ns();
1329    for (_, recv_ts) in pending.drain(..) {
1330        rec.record_elapsed(recv_ts, flushed_at);
1331    }
1332}
1333
1334/// Drop queued samples belonging to connections going away *without* a
1335/// flush — the disconnect handler and the send-buffer-overflow drain.
1336///
1337/// Their buffered bytes are discarded along with the connection, so
1338/// there is no flush to close the samples against and nothing to
1339/// measure. Left queued, they would instead be timed against some later,
1340/// unrelated connection's flush: a millisecond-scale phantom in a
1341/// microsecond histogram, landing squarely in the tail this stage exists
1342/// to report.
1343///
1344/// Not to be used after `flush_sends`. `dropped` there can hold a
1345/// connection queued by an earlier append overflow whose buffered bytes
1346/// the flush nonetheless shipped — discarding would lose a legitimate
1347/// sample.
1348///
1349/// Linear scan per entry, but `dropped` is empty on every iteration that
1350/// loses no connection, which is essentially all of them, and the early
1351/// return keeps that case free.
1352#[cfg(feature = "latency-trace")]
1353fn discard_e2e_samples(pending: &mut Vec<(u64, trace::MonoTraceInstant)>, dropped: &[u64]) {
1354    if dropped.is_empty() {
1355        return;
1356    }
1357    pending.retain(|(conn_id, _)| !dropped.contains(conn_id));
1358}
1359
1360/// Whether this slot must wait on the durability gate before its
1361/// response is encoded.
1362///
1363/// Two reasons to skip the wait. First, the durability-gate carve-out
1364/// for halt-state output: slots tagged `durability_bypass = true` at
1365/// emission carry no engine state worth replicating before delivery —
1366/// see [`OutputSlot::durability_bypass`] for the correctness argument —
1367/// so clients receive the halt reason immediately rather than blocking
1368/// on a structurally unsatisfiable policy (e.g. `DiskAndRam` with all
1369/// replicas disconnected, which would otherwise stall the gate until
1370/// peers return). Second, the slot's own event is already durable.
1371///
1372/// Applied per slot rather than per batch, so a bypass slot is released
1373/// as soon as the dispatch loop reaches it instead of riding behind a
1374/// gated slot earlier in the same batch. Ordering is unaffected: the
1375/// loop walks slots in ring order either way, so a bypass slot still
1376/// leaves after everything sequenced before it.
1377///
1378/// Shared by both response stages. The matching stage that sets the flag
1379/// is transport-agnostic, so the same slots reach the io_uring and DPDK
1380/// paths and the two must agree; keeping the predicate in one place is
1381/// what stops them drifting apart again.
1382#[inline]
1383pub(crate) fn slot_needs_gate<R: Copy, Q: Copy>(
1384    slot: &OutputSlot<R, Q>,
1385    cached_durable_pos: u64,
1386) -> bool {
1387    !slot.durability_bypass && cached_durable_pos < slot.wire_seq
1388}
1389
1390/// Tear down a connection the *response stage* decided to drop (send
1391/// error, `MAX_SEND_BUF` overflow, blocked-send timeout).
1392///
1393/// Removing the map entry alone only drops this stage's dup of the
1394/// socket — the reader still holds its own dup, so the socket would
1395/// stay fully open: the dropped client's requests keep flowing through
1396/// the reader (whose idle timeout never fires while the client keeps
1397/// sending) with no response ever delivered, and the accept loop's
1398/// `max_connections` permit stays pinned forever. `shutdown(2)` reaches
1399/// every dup: the reader's multishot RECV completes with 0, it tears
1400/// down its slab entry and emits `Disconnected` — which finds this
1401/// entry already gone, so the permit is released exactly once.
1402fn teardown_dropped(entry: ConnectionEntry, active_connections: &AtomicU64) {
1403    // Best-effort: ENOTCONN just means the peer tore the socket down
1404    // first — dropping `entry` below still reclaims our dup.
1405    unsafe {
1406        libc::shutdown(entry.fd, libc::SHUT_RDWR);
1407    }
1408    active_connections.fetch_sub(1, Ordering::Relaxed);
1409}
1410
1411/// Outcome of a slot's append. `Continue` means the slot's bytes are
1412/// buffered (or there were none); `ConnectionDropped` means the
1413/// connection's send buffer overflowed and the connection has been
1414/// queued for removal — nothing further should be appended for it.
1415#[derive(Clone, Copy)]
1416enum AppendOutcome {
1417    Continue,
1418    ConnectionDropped,
1419}
1420
1421/// Whether the idle-path heartbeat scan should append a frame to this
1422/// connection: idle for a full interval — and able to receive it.
1423///
1424/// The two guards close the one append path that used to bypass
1425/// `MAX_SEND_BUF`. A blocked peer's socket is full, so a heartbeat
1426/// would only sit in `send_buf` — and an idle client that trickle-read
1427/// a few bytes per interval kept resetting the blocked clock while
1428/// unchecked heartbeat appends grew its buffer without bound, pinning
1429/// its connection permit on ever-growing memory. Skipping blocked
1430/// peers (they detect liveness by draining, not by new frames) and
1431/// cap-checking the append bounds the buffer; a peer that never drains
1432/// is then `BLOCKED_SEND_TIMEOUT`'s or `MAX_SEND_BUF`'s problem, as
1433/// designed. Residual: a trickling client still holds its permit while
1434/// it drains — bounded memory, and the reader's idle timeout covers
1435/// clients that stop sending entirely.
1436/// Put a connection on the dirty list if it isn't already there.
1437/// Idempotent — the entry flag is the dedup that the old `HashSet`
1438/// membership provided.
1439#[inline]
1440fn mark_dirty(entry: &mut ConnectionEntry, connection_id: u64, dirty: &mut Vec<u64>) {
1441    if !entry.dirty {
1442        entry.dirty = true;
1443        dirty.push(connection_id);
1444    }
1445}
1446
1447/// Take a connection off the dirty list — teardown paths only. `retain`
1448/// rather than a swap-remove scan because the ordering is cheap to keep
1449/// and this runs once per dropped connection, never per frame.
1450fn unmark_dirty(connection_id: u64, dirty: &mut Vec<u64>) {
1451    dirty.retain(|&id| id != connection_id);
1452}
1453
1454fn heartbeat_due(
1455    entry: &ConnectionEntry,
1456    now: Instant,
1457    interval: Duration,
1458    frame_len: usize,
1459) -> bool {
1460    now.duration_since(entry.last_send) >= interval
1461        && entry.blocked_since.is_none()
1462        && entry.send_buf.len() + frame_len <= MAX_SEND_BUF
1463}
1464
1465/// Copy a slot's wire bytes into the connection's send buffer with
1466/// overflow checking and dirty tracking: the encoded payload (from the
1467/// `ResponseEncoder` for application shapes, or
1468/// `encode_transport_response` for transport ones) followed by an
1469/// optional pre-encoded `trailer`.
1470///
1471/// One call per slot rather than one per frame. A request's last slot
1472/// carries a `BatchEnd` terminator behind its payload, and appending
1473/// them separately paid the cap check, the `last_send` stamp and the
1474/// dirty marking twice for every request — plus a re-encode of a frame
1475/// whose bytes never change.
1476#[allow(clippy::too_many_arguments)]
1477fn append_frames(
1478    payload: Option<Result<usize, &'static str>>,
1479    trailer: &[u8],
1480    connection_id: u64,
1481    entry: &mut ConnectionEntry,
1482    encode_buf: &[u8],
1483    batch_now: Instant,
1484    dirty_connections: &mut Vec<u64>,
1485    to_remove: &mut Vec<u64>,
1486) -> AppendOutcome {
1487    // An encode failure is this server's bug, not the client's — log it
1488    // and carry on with the trailer, exactly as when the two frames were
1489    // appended by separate calls.
1490    let written = match payload {
1491        Some(Ok(n)) => n,
1492        Some(Err(reason)) => {
1493            tracing::error!(connection_id, reason, "encode error");
1494            0
1495        }
1496        None => 0,
1497    };
1498    let total = written + trailer.len();
1499    if total == 0 {
1500        return AppendOutcome::Continue;
1501    }
1502
1503    // Drop slow clients whose send buffer has grown too large. This
1504    // prevents unbounded memory growth from a single laggy connection
1505    // causing allocator pressure and tail latency spikes. Checked
1506    // against both frames together, so a request is never delivered
1507    // without its terminator.
1508    if entry.send_buf.len() + total > MAX_SEND_BUF {
1509        debug!(
1510            connection_id,
1511            send_buf_len = entry.send_buf.len(),
1512            "send buffer exceeded limit, dropping connection"
1513        );
1514        to_remove.push(connection_id);
1515        return AppendOutcome::ConnectionDropped;
1516    }
1517
1518    // Append the full wire frames to the connection's send buffer.
1519    // The encoder writes [length(4) | payload], which is the complete
1520    // wire format — no extra framing needed.
1521    entry.send_buf.extend_from_slice(&encode_buf[..written]);
1522    entry.send_buf.extend_from_slice(trailer);
1523    entry.last_send = batch_now;
1524    mark_dirty(entry, connection_id, dirty_connections);
1525    AppendOutcome::Continue
1526}
1527
1528/// Each dirty connection's accumulated send buffer is sent in a single
1529/// SEND operation, and the flush never blocks on a slow peer.
1530///
1531/// Every SEND carries `MSG_DONTWAIT`, so a full socket buffer completes
1532/// immediately with `EAGAIN` instead of parking the operation until the
1533/// peer reads. That matters because io_uring never surfaces `EAGAIN` for
1534/// a plain SEND — it arms an internal poll and withholds the CQE, which
1535/// is what let one zero-window client wedge this stage (and with it every
1536/// client's acks) behind the `submit_and_wait` below.
1537///
1538/// Undelivered bytes (EAGAIN or a partial send) stay in the connection's
1539/// `send_buf` and the connection stays in `dirty`; retries are paced by
1540/// [`BLOCKED_RETRY_INTERVAL`] and bounded by [`BLOCKED_SEND_TIMEOUT`].
1541/// `MAX_SEND_BUF` remains the growth cap while new responses accumulate.
1542///
1543/// On return, `dirty` holds exactly the connections that still have
1544/// undelivered bytes and were not queued in `to_remove` — callers must
1545/// not clear it. Failed connections are collected in `to_remove` for the
1546/// caller to purge from `connections`.
1547fn flush_sends(
1548    ring: &mut IoUring,
1549    connections: &mut FxHashMap<u64, ConnectionEntry>,
1550    dirty: &mut Vec<u64>,
1551    to_remove: &mut Vec<u64>,
1552    cqes: &mut Vec<(u64, i32)>,
1553) {
1554    // One clock read per flush, shared by the retry-pacing and
1555    // blocked-timeout decisions. Cheap next to the submit syscall below,
1556    // and flushes are already batched (one per drained SPSC batch or
1557    // idle-loop pass, not one per response).
1558    let now = Instant::now();
1559
1560    // Submit SEND SQEs for all dirty connections not in retry backoff.
1561    let mut pending: usize = 0;
1562    for &conn_id in dirty.iter() {
1563        let Some(entry) = connections.get_mut(&conn_id) else {
1564            continue;
1565        };
1566        if entry.send_buf.is_empty() {
1567            continue;
1568        }
1569        if let Some(blocked_at) = entry.blocked_since {
1570            // A peer that has refused bytes for the full timeout is not
1571            // coming back for them — reclaim the slot. Client-caused,
1572            // hence debug (see the log-level convention).
1573            if now.duration_since(blocked_at) >= BLOCKED_SEND_TIMEOUT {
1574                debug!(
1575                    connection_id = conn_id,
1576                    pending_bytes = entry.send_buf.len(),
1577                    blocked_ms = now.duration_since(blocked_at).as_millis() as u64,
1578                    "socket blocked past timeout, dropping connection"
1579                );
1580                to_remove.push(conn_id);
1581                continue;
1582            }
1583            if now.duration_since(entry.last_send_attempt) < BLOCKED_RETRY_INTERVAL {
1584                continue;
1585            }
1586        }
1587        entry.last_send_attempt = now;
1588
1589        let sqe = opcode::Send::new(
1590            types::Fd(entry.fd),
1591            entry.send_buf.as_ptr(),
1592            entry.send_buf.len() as u32,
1593        )
1594        .flags(libc::MSG_DONTWAIT)
1595        .build()
1596        .user_data(conn_id);
1597
1598        unsafe {
1599            ring.submission()
1600                .push(&sqe)
1601                .expect("io_uring SQ full — increase RING_SIZE");
1602        }
1603        pending += 1;
1604    }
1605
1606    if pending > 0 {
1607        // With MSG_DONTWAIT every SEND completes during submission
1608        // (success, partial, or EAGAIN — the flag sets the kernel's
1609        // no-wait path, so nothing is deferred to a poll retry). The
1610        // wait is bounded by op execution, never by a peer.
1611        //
1612        // EINTR must retry, not return: a signal (profilers use them
1613        // routinely) can interrupt the CQE wait *after* the submit phase
1614        // consumed the SQEs — the inline completions are already posted
1615        // by then. Returning without reaping would leave stale CQEs that
1616        // the next flush drains against *updated* buffer contents:
1617        // a delivered prefix gets re-sent and `drain(..sent)` double-
1618        // drains. The retry submits an empty SQ and finds the CQ already
1619        // holding `pending` entries, so it cannot stall.
1620        loop {
1621            match ring.submit_and_wait(pending) {
1622                Ok(_) => break,
1623                Err(ref e) if e.raw_os_error() == Some(libc::EINTR) => continue,
1624                Err(e) => {
1625                    // Submit-phase failure (ENOMEM class — EBUSY cannot
1626                    // happen: the CQ is RING_SIZE deep and fully reaped
1627                    // every flush, so at most `pending` ≤ RING_SIZE
1628                    // entries are ever outstanding). An error here means
1629                    // the SQEs were NOT consumed: continuing would leave
1630                    // them queued for the next flush's submit, by which
1631                    // time their addr fields can point at drained or
1632                    // reallocated send_bufs — garbage on client sockets,
1633                    // on the ack path. The kernel is out of resources
1634                    // and the stage cannot proceed without corruption
1635                    // risk: fail loudly and let the accept loop's
1636                    // pipeline-death detection take the server down.
1637                    error!(error = %e, "io_uring submit_and_wait failed in response stage");
1638                    panic!("response stage io_uring submit failed: {e}");
1639                }
1640            }
1641        }
1642
1643        // Drain completions into pre-allocated buffer. Must collect to
1644        // release CQ borrow before mutating connections.
1645        cqes.clear();
1646        cqes.extend(ring.completion().map(|cqe| (cqe.user_data(), cqe.result())));
1647
1648        for &(conn_id, result) in cqes.iter() {
1649            let Some(entry) = connections.get_mut(&conn_id) else {
1650                continue;
1651            };
1652            if result == -libc::EAGAIN {
1653                // Socket buffer full. Keep the bytes; the connection
1654                // stays dirty and retries at the paced cadence.
1655                if entry.blocked_since.is_none() {
1656                    entry.blocked_since = Some(now);
1657                }
1658                continue;
1659            }
1660            if result < 0 {
1661                debug!(
1662                    connection_id = conn_id,
1663                    error = result,
1664                    "send error, dropping connection"
1665                );
1666                to_remove.push(conn_id);
1667                continue;
1668            }
1669
1670            let sent = result as usize;
1671            if sent == 0 {
1672                // Unreachable for SOCK_STREAM sends of len > 0 (the
1673                // kernel reports -EAGAIN instead), but keep the
1674                // zero-progress invariant literal: 0 bytes must not
1675                // restart the blocked clock via the partial branch.
1676                if entry.blocked_since.is_none() {
1677                    entry.blocked_since = Some(now);
1678                }
1679            } else if sent >= entry.send_buf.len() {
1680                entry.send_buf.clear();
1681                entry.blocked_since = None;
1682            } else {
1683                // Partial send — the socket buffer filled mid-copy. An
1684                // immediate retry would only report EAGAIN, so keep the
1685                // remainder for the paced retry. Bytes moved, though:
1686                // restart the blocked clock from this progress, so only
1687                // a peer accepting *zero* bytes for the full timeout is
1688                // dropped — a slow-but-draining client keeps its
1689                // connection (until `MAX_SEND_BUF` says otherwise).
1690                entry.send_buf.drain(..sent);
1691                entry.blocked_since = Some(now);
1692            }
1693        }
1694    }
1695
1696    // Keep exactly the connections that still hold undelivered bytes and
1697    // survived this flush. Runs even when nothing was submitted so a
1698    // blocked-timeout drop above still leaves the dirty list consistent.
1699    // The entry flag is cleared in lockstep — it is what keeps a
1700    // connection off the list until its next append.
1701    dirty.retain(|conn_id| {
1702        let keep = !to_remove.contains(conn_id)
1703            && connections
1704                .get(conn_id)
1705                .is_some_and(|e| !e.send_buf.is_empty());
1706        if !keep && let Some(entry) = connections.get_mut(conn_id) {
1707            entry.dirty = false;
1708        }
1709        keep
1710    });
1711}
1712
1713/// Read the live gate cursors and lend them to `f` as a [`CursorView`].
1714///
1715/// The view contains the primary followed by every *currently connected*
1716/// replica slot. Disconnected slots are omitted rather than entered with
1717/// zero cursors, so `CursorView::len` reflects how many nodes are
1718/// actually available to satisfy a clause — too few and the policy
1719/// reports degraded and the gate stalls. Node 0 is always the primary;
1720/// [`Policy::attribute_blocker`] relies on that index convention to tell
1721/// journal from replication.
1722///
1723/// The primary's in-memory cursor is modeled as `u64::MAX` because the
1724/// response stage only gates events the matching engine has already
1725/// processed — those are trivially in-memory on the primary.
1726///
1727/// Scoped-borrow shape rather than a returned view: `CursorView` holds a
1728/// slice, so it cannot outlive the array behind it. Lending it to a
1729/// closure keeps that array on this frame and off every caller's
1730/// signature.
1731#[inline]
1732fn with_cursor_view<R>(
1733    journal_pos: WireSeq,
1734    metrics: Option<&ReplicationMetrics>,
1735    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1736    f: impl FnOnce(&CursorView<'_>) -> R,
1737) -> R {
1738    // Fixed-size array rather than a `Vec`: the cluster is capped at
1739    // MAX_CLUSTER_SIZE and this runs on the gate path, so a heap
1740    // allocation would be pure overhead. Raw `u64` in wire-seq space —
1741    // `journal_pos` leaves the type system here, alongside the replica
1742    // metrics gauges (the `Ack` frame's wire-seq fields verbatim).
1743    let mut nodes = [[0u64; 2]; MAX_CLUSTER_SIZE as usize];
1744    nodes[0] = [u64::MAX, journal_pos.get()];
1745    let mut len = 1;
1746    if let (Some(m), Some(active)) = (metrics, replica_active) {
1747        for (i, slot_active) in active.iter().enumerate() {
1748            // Skip inactive slots up-front.
1749            if !slot_active.load(Ordering::Acquire) {
1750                continue;
1751            }
1752            let in_mem = m.in_memory_sequence[i].load(Ordering::Acquire);
1753            let persisted = m.acked_sequence[i].load(Ordering::Acquire);
1754            nodes[len] = [in_mem, persisted];
1755            len += 1;
1756        }
1757    }
1758    f(&CursorView::new(&nodes[..len]))
1759}
1760
1761/// Highest sequence at which the policy is satisfied by the live
1762/// cursors, plus whether the cluster shape can satisfy it at all.
1763#[inline]
1764pub(crate) fn evaluate_durability(
1765    policy: &Policy,
1766    journal_pos: WireSeq,
1767    metrics: Option<&ReplicationMetrics>,
1768    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1769) -> EvalStatus {
1770    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1771        policy.evaluate_with_status(view)
1772    })
1773}
1774
1775/// One-snapshot gate evaluation: the policy's durable position plus,
1776/// when that position opens the gate (`>= needed`), which subsystem
1777/// supplied the binding cursor.
1778///
1779/// Both answers come from the *same* [`CursorView`]. Re-reading the
1780/// replica cursors for attribution after the evaluation that opened the
1781/// gate would rank a later snapshot — the replica side can advance
1782/// between the two reads and flip the verdict to a subsystem that was
1783/// not the one the gate actually opened on.
1784///
1785/// The blocker is `None` while the gate stays closed (attribution is
1786/// only read at the open, and ranking the clauses for it on every spin
1787/// iteration would be wasted work). At an open with `needed >= 1` it is
1788/// always `Some`: a policy left unsatisfiable by the cluster shape pins
1789/// `durable_pos` to 0, which cannot reach `needed`.
1790///
1791/// The attribution itself replaces the old "compare the journal cursor
1792/// against the minimum replica *persisted* cursor" heuristic, which
1793/// reported replication as the blocker under `disk` whenever a replica
1794/// merely lagged — a replica binds `persisted>=1` only when its fsync
1795/// leads the journal's — and read the persisted level under
1796/// `disk+ram`, which gates on in-memory.
1797#[inline]
1798pub(crate) fn evaluate_gate(
1799    policy: &Policy,
1800    needed: u64,
1801    journal_pos: WireSeq,
1802    metrics: Option<&ReplicationMetrics>,
1803    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1804) -> (EvalStatus, Option<Blocker>) {
1805    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1806        let status = policy.evaluate_with_status(view);
1807        let blocker = if status.durable_pos >= needed {
1808            policy.attribute_blocker(view)
1809        } else {
1810            None
1811        };
1812        (status, blocker)
1813    })
1814}
1815
1816/// The replica-side cursor the active policy is waiting on, for the
1817/// `tick-to-trade` replica-wait histogram. `None` when no clause is
1818/// replica-supplied (e.g. `disk`).
1819#[cfg(feature = "tick-to-trade")]
1820#[inline]
1821pub(crate) fn policy_replica_cursor(
1822    policy: &Policy,
1823    journal_pos: WireSeq,
1824    metrics: Option<&ReplicationMetrics>,
1825    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1826) -> Option<u64> {
1827    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1828        policy.replica_gate_cursor(view)
1829    })
1830}
1831
1832/// Hold-time before a state transition is committed to the log.
1833/// Suppresses log spam when a replica flaps faster than this — only
1834/// transitions that hold for at least this long emit warn/info
1835/// entries. The `/healthz` gauge updates immediately regardless,
1836/// so dashboards and alerts still see real-time state.
1837const DEGRADED_FLAP_HOLD: Duration = Duration::from_secs(1);
1838
1839/// Tracks degradation state across calls and emits warn/info logs
1840/// with sustained-state gating + a periodic heartbeat re-emit.
1841///
1842/// The hot path calls [`Self::tick`] every gate iteration / idle
1843/// poll with the current `degraded` value and the wall clock. The
1844/// logger handles:
1845///
1846/// - Updating the `policy_degraded` health gauge immediately.
1847/// - Suppressing log lines for transitions that don't hold for at
1848///   least [`DEGRADED_FLAP_HOLD`] — a replica flapping at sub-second
1849///   cadence produces no log noise, only a quietly-updating gauge.
1850/// - Emitting a warn at the moment a sustained degraded state
1851///   crosses the hold threshold, plus a periodic re-emit every
1852///   `heartbeat_interval` while it persists.
1853/// - Emitting an info when a sustained healthy state crosses the
1854///   hold threshold (the cluster is back to its target shape and
1855///   stayed there long enough that we trust the recovery).
1856pub(crate) struct DegradationLogger {
1857    /// Last value passed to `tick`; what we'd log about if it stayed
1858    /// at this value past the hold threshold.
1859    pending_state: bool,
1860    /// When `pending_state` first appeared. Reset on every flip.
1861    pending_since: Instant,
1862    /// Whether the current pending state has been logged yet. Only
1863    /// the *first* log per sustained streak crosses; subsequent
1864    /// re-emits while degraded are heartbeat warns.
1865    pending_logged: bool,
1866    /// When the last warn fired. Drives the periodic re-emit while
1867    /// degraded.
1868    last_log: Option<Instant>,
1869    /// Wall clock of the previous accrual. The interval since this
1870    /// instant is attributed to the state observed then (`pending_state`)
1871    /// and accumulated into the `policy_degraded_nanos` counter. Advanced
1872    /// by [`Self::accrue`] — reached on every `tick` (post-gate, idle, and
1873    /// the gate-wait pacing tick) and on `reseed`.
1874    last_tick: Instant,
1875}
1876
1877impl DegradationLogger {
1878    pub(crate) fn new(now: Instant) -> Self {
1879        Self {
1880            pending_state: false,
1881            pending_since: now,
1882            pending_logged: true, // healthy is the assumed initial state; nothing to log
1883            last_log: None,
1884            last_tick: now,
1885        }
1886    }
1887
1888    /// Use when the policy is known to start in a degraded state
1889    /// (e.g. a primary under `disk+ram` with no replica yet
1890    /// connected). Logs a startup warn immediately and treats the
1891    /// state as already-logged so the next tick doesn't re-emit.
1892    pub(crate) fn new_starting_degraded(now: Instant, policy: &Policy) -> Self {
1893        tracing::warn!(
1894            policy = %policy,
1895            "ack policy starts degraded — fewer connected nodes than the target count"
1896        );
1897        Self {
1898            pending_state: true,
1899            pending_since: now,
1900            pending_logged: true,
1901            last_log: Some(now),
1902            last_tick: now,
1903        }
1904    }
1905
1906    /// Charge the interval `[last_tick, now]` to the degraded-duration
1907    /// counter when `prev_degraded` (the state observed at the previous
1908    /// tick) was degraded, then advance `last_tick`. Drives the
1909    /// `_seconds_total` counter so `rate()` reflects time-in-degraded
1910    /// continuously, even mid-incident, rather than only stepping on
1911    /// recovery. Shared by [`Self::tick`] and [`Self::reseed`] so accrual
1912    /// is never skipped on a logger lifecycle boundary.
1913    #[inline]
1914    fn accrue(&mut self, utilization: &StageUtilization, prev_degraded: bool, now: Instant) {
1915        if prev_degraded {
1916            // `duration_since` saturates to zero when `now < last_tick`;
1917            // `now` is always >= `last_tick` here (single-thread monotonic
1918            // clock), matching the plain `duration_since` used elsewhere
1919            // in this file.
1920            let elapsed = now.duration_since(self.last_tick);
1921            // `as_nanos()` is u128; one inter-tick interval is sub-second
1922            // to a few seconds, far below the u64 nanos ceiling (~584
1923            // years), so the cast can't truncate. See the field doc on
1924            // `StageUtilization::policy_degraded_nanos`.
1925            utilization
1926                .policy_degraded_nanos
1927                .fetch_add(elapsed.as_nanos() as u64, Ordering::Relaxed);
1928        }
1929        self.last_tick = now;
1930    }
1931
1932    /// Flush in-flight degraded accrual up to `now`, then reset to a
1933    /// fresh healthy-start logger. Called on a runtime ack-policy
1934    /// swap, which is a logger lifecycle boundary: without flushing
1935    /// first, the pre-swap degraded interval — on the gate-wait path the
1936    /// entire wedge — would be silently dropped. The new policy's actual
1937    /// degraded state is re-derived by the next `tick`, so the brief
1938    /// swap-to-next-tick window is attributed healthy; that residual is
1939    /// bounded by one tick interval, the counter's inherent granularity.
1940    pub(crate) fn reseed(&mut self, utilization: &StageUtilization, now: Instant) {
1941        let prev_state = self.pending_state;
1942        self.accrue(utilization, prev_state, now);
1943        *self = Self::new(now);
1944    }
1945
1946    /// Update the gauge + emit transition/heartbeat logs as needed.
1947    /// Cheap on the hot path: one atomic store, a few branches, one
1948    /// `Instant::duration_since`.
1949    pub(crate) fn tick(
1950        &mut self,
1951        policy: &Policy,
1952        utilization: &StageUtilization,
1953        degraded_now: bool,
1954        now: Instant,
1955        heartbeat_interval: Duration,
1956    ) {
1957        // Charge the just-elapsed interval to the state observed at the
1958        // *previous* tick. Snapshot it up front, before the flap-hold
1959        // bookkeeping below mutates `pending_state`, so accrual stays
1960        // correct even if that block is later reordered.
1961        let prev_state = self.pending_state;
1962        self.accrue(utilization, prev_state, now);
1963
1964        utilization
1965            .policy_degraded
1966            .store(degraded_now, Ordering::Relaxed);
1967
1968        if degraded_now != self.pending_state {
1969            // State changed — start a new hold window. Don't log
1970            // until / unless this new state stays long enough.
1971            self.pending_state = degraded_now;
1972            self.pending_since = now;
1973            self.pending_logged = false;
1974            return;
1975        }
1976
1977        // State held. If we haven't yet logged this streak's onset,
1978        // and it's been pending for at least the flap-hold time,
1979        // emit the transition message and mark logged.
1980        if !self.pending_logged && now.duration_since(self.pending_since) >= DEGRADED_FLAP_HOLD {
1981            if degraded_now {
1982                tracing::warn!(
1983                    policy = %policy,
1984                    "ack policy operating degraded — fewer connected nodes than the target count, response gate stalled until the cluster recovers or the policy is swapped"
1985                );
1986            } else {
1987                tracing::info!(
1988                    policy = %policy,
1989                    "ack policy returned to target shape"
1990                );
1991            }
1992            self.pending_logged = true;
1993            self.last_log = Some(now);
1994            return;
1995        }
1996
1997        // Heartbeat re-emit while a degraded state persists.
1998        if degraded_now
1999            && self.pending_logged
2000            && self
2001                .last_log
2002                .is_none_or(|t| now.duration_since(t) >= heartbeat_interval)
2003        {
2004            tracing::warn!(
2005                policy = %policy,
2006                "ack policy still degraded — fewer connected nodes than the target count"
2007            );
2008            self.last_log = Some(now);
2009        }
2010    }
2011}
2012
2013/// Tracks per-cursor "first observed transition from below to >= needed"
2014/// inside the durability gate loop, to drive the journal-wait /
2015/// replica-wait histograms in the bench's tick-to-trade decomposition.
2016///
2017/// A sample is recorded only for cursors that were strictly below
2018/// `needed` at the loop's first observation. Cursors already past at
2019/// entry were not on the critical path for this batch, so attributing
2020/// "wait time" to them would inflate the metric with cursor-poll
2021/// observation timestamps that have nothing to do with how long the
2022/// stage actually held us up.
2023///
2024/// `now_ns` is taken as a parameter rather than read internally so
2025/// tests can supply deterministic timestamps. The caller's hot path
2026/// reads `trace::mono_trace_ns()` once per gate iteration and feeds it in.
2027#[cfg(feature = "tick-to-trade")]
2028pub(crate) struct GateCrossTracker {
2029    needed: u64,
2030    journal_crossed_ts: Option<trace::MonoTraceInstant>,
2031    replica_crossed_ts: Option<trace::MonoTraceInstant>,
2032    journal_was_below: bool,
2033    replica_was_below: bool,
2034    first: bool,
2035}
2036
2037#[cfg(feature = "tick-to-trade")]
2038impl GateCrossTracker {
2039    pub(crate) fn new(needed: u64) -> Self {
2040        Self {
2041            needed,
2042            journal_crossed_ts: None,
2043            replica_crossed_ts: None,
2044            journal_was_below: false,
2045            replica_was_below: false,
2046            first: true,
2047        }
2048    }
2049
2050    /// `replica_pos` is the replica-side cursor the *active policy*
2051    /// gates on — see [`policy_replica_cursor`]. `None` means no replica
2052    /// currently supplies a binding cursor, which happens permanently
2053    /// under `disk` and transiently when the binding replica drops out
2054    /// of the cursor view part-way through a wait.
2055    ///
2056    /// Both cases must leave `replica_crossed_ts` alone. Treating
2057    /// `None` as an infinite cursor would satisfy the crossing test and
2058    /// latch the timestamp at the *disconnect*, reporting a replica
2059    /// wait that ended when the link died rather than when the replica
2060    /// caught up — understating exactly the failover runs the histogram
2061    /// exists to measure. On the first observe it additionally leaves
2062    /// `replica_was_below` false, so a policy that never waits on a
2063    /// replica records no sample at all.
2064    pub(crate) fn observe(
2065        &mut self,
2066        journal_pos: u64,
2067        replica_pos: Option<u64>,
2068        now_ns: trace::MonoTraceInstant,
2069    ) {
2070        if self.first {
2071            self.journal_was_below = journal_pos < self.needed;
2072            self.replica_was_below = replica_pos.is_some_and(|p| p < self.needed);
2073            self.first = false;
2074        }
2075        if self.journal_was_below && self.journal_crossed_ts.is_none() && journal_pos >= self.needed
2076        {
2077            self.journal_crossed_ts = Some(now_ns);
2078        }
2079        if self.replica_was_below
2080            && self.replica_crossed_ts.is_none()
2081            && replica_pos.is_some_and(|p| p >= self.needed)
2082        {
2083            self.replica_crossed_ts = Some(now_ns);
2084        }
2085    }
2086
2087    pub(crate) fn journal_crossed(&self) -> Option<trace::MonoTraceInstant> {
2088        self.journal_crossed_ts
2089    }
2090
2091    pub(crate) fn replica_crossed(&self) -> Option<trace::MonoTraceInstant> {
2092        self.replica_crossed_ts
2093    }
2094}
2095
2096/// Print busy/idle utilization for a pipeline stage on shutdown.
2097#[cfg(feature = "pipeline-stats")]
2098fn print_utilization(stage: &str, busy: u64, idle: u64) {
2099    let total = busy + idle;
2100    if total == 0 {
2101        tracing::info!(stage, "no iterations recorded");
2102        return;
2103    }
2104    let pct = (busy as f64 / total as f64) * 100.0;
2105    tracing::info!(
2106        stage,
2107        pct_busy = format_args!("{pct:.2}%"),
2108        busy,
2109        idle,
2110        total,
2111        "pipeline utilization",
2112    );
2113}
2114
2115#[cfg(test)]
2116mod tests {
2117    #[cfg(feature = "tick-to-trade")]
2118    use super::GateCrossTracker;
2119    use super::{
2120        Blocker, DegradationLogger, OutputSlot, WireSeq, evaluate_durability, evaluate_gate,
2121        slot_needs_gate,
2122    };
2123    use crate::ack_policy::{AckPolicy, Clause, Level, Policy};
2124    use crate::replication::ReplicationMetrics;
2125
2126    /// Queued end-to-end samples survive a flush that dropped nothing,
2127    /// and are closed (drained) by it.
2128    #[cfg(feature = "latency-trace")]
2129    #[test]
2130    fn e2e_samples_survive_until_a_flush_closes_them() {
2131        let mut pending = vec![(1u64, 10u64), (2, 20), (1, 30)];
2132        super::discard_e2e_samples(&mut pending, &[]);
2133        assert_eq!(pending.len(), 3, "an empty drop list must lose nothing");
2134
2135        let mut rec = melin_transport_core::trace::register_stage("test::response_e2e_close");
2136        super::close_e2e_samples(&mut pending, &mut rec);
2137        assert!(pending.is_empty(), "a flush closes the whole queue");
2138    }
2139
2140    /// The defect this pairing exists to prevent: a dropped connection's
2141    /// buffered bytes are discarded with it, so its samples must not
2142    /// survive to be timed against some later connection's flush.
2143    #[cfg(feature = "latency-trace")]
2144    #[test]
2145    fn e2e_samples_for_a_dropped_connection_are_discarded() {
2146        let mut pending = vec![(1u64, 10u64), (2, 20), (1, 30), (3, 40)];
2147        super::discard_e2e_samples(&mut pending, &[1, 3]);
2148        assert_eq!(
2149            pending,
2150            vec![(2, 20)],
2151            "only the surviving connection's samples remain"
2152        );
2153
2154        // Dropping the last live connection empties the queue, which is
2155        // what restores "queue non-empty implies something is dirty" —
2156        // the invariant the flush sites rely on to be sufficient drain
2157        // points.
2158        super::discard_e2e_samples(&mut pending, &[2]);
2159        assert!(pending.is_empty());
2160    }
2161
2162    /// Output slot carrying only the fields the gate decision reads.
2163    /// `()` for the report/query types keeps the fixture independent of
2164    /// any concrete application.
2165    fn slot(wire_seq: u64, durability_bypass: bool) -> OutputSlot<(), ()> {
2166        OutputSlot {
2167            wire_seq,
2168            durability_bypass,
2169            ..Default::default()
2170        }
2171    }
2172
2173    #[test]
2174    fn bypass_slot_never_gates() {
2175        // Halt-state output must not block on a policy that may be
2176        // structurally unsatisfiable, however far behind the cursor is.
2177        assert!(!slot_needs_gate(&slot(10, true), 0));
2178        assert!(!slot_needs_gate(&slot(10, true), 9));
2179    }
2180
2181    #[test]
2182    fn normal_slot_gates_until_its_own_sequence_is_durable() {
2183        assert!(slot_needs_gate(&slot(10, false), 0));
2184        assert!(slot_needs_gate(&slot(10, false), 9));
2185    }
2186
2187    #[test]
2188    fn normal_slot_clears_at_its_own_sequence_not_the_batch_maximum() {
2189        // The cursor reaching exactly this slot's `wire_seq` is enough —
2190        // an off-by-one here would stall every response an extra
2191        // durability round-trip. A later slot in the same batch needing
2192        // more must not hold this one back.
2193        assert!(!slot_needs_gate(&slot(10, false), 10));
2194        assert!(!slot_needs_gate(&slot(10, false), 11));
2195    }
2196
2197    /// Build a [`Policy`] from a mini DSL: one or more
2198    /// `"<level>>=<count>"` clauses joined with `&&`. Test-only
2199    /// ergonomics — production builds policies via
2200    /// [`AckPolicy::to_policy`].
2201    fn parse(s: &str) -> Result<Policy, String> {
2202        let mut clauses = Vec::new();
2203        for raw in s.split("&&") {
2204            let token = raw.trim();
2205            let (lvl, rhs) = token
2206                .split_once(">=")
2207                .ok_or_else(|| format!("clause `{token}` missing `>=`"))?;
2208            let level = match lvl.trim() {
2209                "persisted" => Level::Persisted,
2210                "in_memory" => Level::InMemory,
2211                other => return Err(format!("unknown level `{other}`")),
2212            };
2213            let count: u8 = rhs.trim().parse().map_err(|e| format!("bad count: {e}"))?;
2214            clauses.push(Clause { count, level });
2215        }
2216        Policy::new(clauses).map_err(|e| e.to_string())
2217    }
2218    use melin_transport_core::pipeline::StageUtilization;
2219    use std::sync::Arc;
2220    use std::sync::atomic::{AtomicBool, Ordering};
2221    use std::time::{Duration, Instant};
2222
2223    /// Build a `ReplicationMetrics` with both slots populated. Tests
2224    /// that need to simulate a disconnected slot use [`flags`] to mark
2225    /// it inactive — its cursors are then ignored regardless of value.
2226    fn metrics(slot0: (u64, u64), slot1: (u64, u64)) -> Arc<ReplicationMetrics> {
2227        let m = Arc::new(ReplicationMetrics::default());
2228        m.in_memory_sequence[0].store(slot0.0, Ordering::Relaxed);
2229        m.acked_sequence[0].store(slot0.1, Ordering::Relaxed);
2230        m.in_memory_sequence[1].store(slot1.0, Ordering::Relaxed);
2231        m.acked_sequence[1].store(slot1.1, Ordering::Relaxed);
2232        m
2233    }
2234
2235    /// Build a `[active; 2]` flags array.
2236    fn flags(slot0_active: bool, slot1_active: bool) -> [Arc<AtomicBool>; 2] {
2237        [
2238            Arc::new(AtomicBool::new(slot0_active)),
2239            Arc::new(AtomicBool::new(slot1_active)),
2240        ]
2241    }
2242
2243    /// Both replicas active — the common healthy-cluster case.
2244    fn both_active() -> [Arc<AtomicBool>; 2] {
2245        flags(true, true)
2246    }
2247
2248    // --- Standalone (no replicas wired) ---
2249
2250    #[test]
2251    fn standalone_persisted_one_gates_on_journal() {
2252        // No metrics → only the primary is in the view. `persisted>=1`
2253        // is satisfied by the primary alone at journal_pos.
2254        let p = parse("persisted>=1").unwrap();
2255        assert_eq!(
2256            evaluate_durability(&p, WireSeq::new(500), None, None).durable_pos,
2257            500
2258        );
2259    }
2260
2261    #[test]
2262    fn standalone_strict_persisted_two_never_opens() {
2263        // `persisted>=2` on a standalone primary stays at 0: the
2264        // operator asked for two copies and there is only one. The
2265        // policy surfaces as degraded so the operator sees the gate
2266        // is stalled because the cluster can't meet the policy.
2267        let p = parse("persisted>=2").unwrap();
2268        let r = evaluate_durability(&p, WireSeq::new(500), None, None);
2269        assert_eq!(r.durable_pos, 0);
2270        assert!(
2271            r.degraded,
2272            "policy structurally unsatisfiable on this shape → degraded",
2273        );
2274    }
2275
2276    // --- 2 replicas connected ---
2277
2278    #[test]
2279    fn quorum_both_replicas_ahead_of_journal() {
2280        // Both replicas persisted past journal. `persisted>=2` returns
2281        // the 2nd-largest persisted across {primary, slot0, slot1}.
2282        let p = parse("persisted>=2").unwrap();
2283        let m = metrics((100, 100), (120, 120));
2284        let a = both_active();
2285        assert_eq!(
2286            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2287            100
2288        );
2289    }
2290
2291    #[test]
2292    fn quorum_journal_ahead_of_both_replicas() {
2293        // Journal at 500, replicas at 100/120. 2nd-largest persisted = 120.
2294        let p = parse("persisted>=2").unwrap();
2295        let m = metrics((100, 100), (120, 120));
2296        let a = both_active();
2297        assert_eq!(
2298            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
2299            120
2300        );
2301    }
2302
2303    #[test]
2304    fn quorum_journal_between_slow_and_fast_replica() {
2305        // {primary=150, slot0_persisted=50, slot1_persisted=200}.
2306        // 2nd-largest = 150 (primary itself).
2307        let p = parse("persisted>=2").unwrap();
2308        let m = metrics((50, 50), (200, 200));
2309        let a = both_active();
2310        assert_eq!(
2311            evaluate_durability(&p, WireSeq::new(150), Some(&m), Some(&a)).durable_pos,
2312            150
2313        );
2314    }
2315
2316    #[test]
2317    fn ram_quorum_gates_on_replica_memory_not_on_any_disk() {
2318        // `ram` (`in_memory>=2`): the journal position must not
2319        // bind. Journal at 0 — nothing persisted anywhere on the
2320        // primary — while the replicas hold 100/120 in memory. The
2321        // 2nd-largest in-memory across {primary=MAX, 100, 120} is 120:
2322        // the gate is exactly the fastest replica's RAM receipt.
2323        let p = AckPolicy::Ram.to_policy();
2324        let m = metrics((100, 0), (120, 0));
2325        let a = both_active();
2326        let r = evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a));
2327        assert_eq!(r.durable_pos, 120);
2328        assert!(!r.degraded);
2329    }
2330
2331    #[test]
2332    fn ram_quorum_stalls_with_no_replica_connected() {
2333        // Fail-closed: with only the primary in the view, `in_memory>=2`
2334        // is structurally unsatisfiable however far the journal is.
2335        let p = AckPolicy::Ram.to_policy();
2336        let r = evaluate_durability(&p, WireSeq::new(500), None, None);
2337        assert_eq!(r.durable_pos, 0);
2338        assert!(r.degraded);
2339    }
2340
2341    // --- Single replica connected ---
2342
2343    #[test]
2344    fn single_replica_strict_persisted_two_requires_both_survivors() {
2345        // Slot 0 connected, slot 1 disconnected. View = {primary, slot0}.
2346        // Strict `persisted>=2`: 2nd-largest of the 2-row view =
2347        // min(primary, slot0). Strictly stronger than legacy auto-
2348        // degrade-to-1-node in the same shape.
2349        let p = parse("persisted>=2").unwrap();
2350        let m = metrics((100, 100), (999, 999)); // slot 1 cursors ignored
2351        let a = flags(true, false);
2352        assert_eq!(
2353            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2354            50
2355        );
2356        assert_eq!(
2357            evaluate_durability(&p, WireSeq::new(200), Some(&m), Some(&a)).durable_pos,
2358            100
2359        );
2360    }
2361
2362    #[test]
2363    fn single_replica_persisted_two_requires_both_survivors() {
2364        // 2-node view (primary + surviving replica). `persisted>=2` is
2365        // satisfiable; the gate opens at the slower of the two and the
2366        // policy is not degraded.
2367        let p = parse("persisted>=2").unwrap();
2368        let m = metrics((100, 100), (999, 999));
2369        let a = flags(true, false);
2370        let r = evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a));
2371        assert_eq!(r.durable_pos, 50);
2372        assert!(!r.degraded);
2373    }
2374
2375    #[test]
2376    fn both_replicas_disconnected_strict_stalls() {
2377        // View has only the primary. Strict `persisted>=2` cannot be
2378        // satisfied — operator opted out of degrade.
2379        let p = parse("persisted>=2").unwrap();
2380        let m = metrics((999, 999), (999, 999));
2381        let a = flags(false, false);
2382        assert_eq!(
2383            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
2384            0
2385        );
2386    }
2387
2388    #[test]
2389    fn both_replicas_disconnected_strict_stalls_and_flags_degraded() {
2390        // With `persisted>=2` and both replicas down, the cursor view
2391        // collapses to {primary}: the clause's count (=2) exceeds the
2392        // view size, so the gate stays at 0 and the policy flags
2393        // degraded. Note the matching stage's separate halt at
2394        // `replicas_connected==0` rejects new orders before they reach
2395        // the gate; this verifies the gate semantics in isolation.
2396        let p = parse("persisted>=2").unwrap();
2397        let m = metrics((999, 999), (999, 999));
2398        let a = flags(false, false);
2399        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2400        assert_eq!(r.durable_pos, 0);
2401        assert!(r.degraded);
2402    }
2403
2404    // --- Mixed-level policies ---
2405
2406    #[test]
2407    fn persisted_one_and_in_memory_two() {
2408        // "Leader persists, plus one other node has it in memory" —
2409        // the cheap-but-non-zero durability target. Slot 0 has it in
2410        // memory, slot 1 disconnected.
2411        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2412        // primary persisted=50, slot0 in_mem=80 / persisted=20.
2413        // persisted>=1: max(50, 20, 0) = 50.
2414        // in_memory>=2: primary in_mem=u64::MAX (always), slot0_eff=max(80, 20)=80,
2415        //               slot1=0. 2nd-largest = 80.
2416        // min(50, 80) = 50.
2417        let m = metrics((80, 20), (999, 999));
2418        let a = flags(true, false);
2419        assert_eq!(
2420            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2421            50
2422        );
2423    }
2424
2425    // --- Edge: journal at 0 ---
2426
2427    #[test]
2428    fn journal_at_zero_with_replicas_persisted_one() {
2429        // Journal hasn't fsynced anything; both replicas have. With
2430        // `persisted>=1` the gate opens at the fastest replica.
2431        let p = parse("persisted>=1").unwrap();
2432        let m = metrics((100, 100), (200, 200));
2433        let a = both_active();
2434        assert_eq!(
2435            evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a)).durable_pos,
2436            200
2437        );
2438    }
2439
2440    // --- gate-blocker attribution ---
2441    //
2442    // The attribution must follow the policy actually in force. The
2443    // previous heuristic compared the journal cursor against the
2444    // minimum replica *persisted* cursor unconditionally, which
2445    // reported replication as the blocker under `disk` whenever a
2446    // replica lagged (a replica binds `persisted>=1` only when its fsync
2447    // leads) and read the wrong level under `disk+ram` (which gates
2448    // replicas on in-memory).
2449    //
2450    // Attribution comes out of `evaluate_gate` alongside the durable
2451    // position, computed from the same cursor snapshot, and is `None`
2452    // while the gate stays closed — so each test passes a `needed` at
2453    // or below the scenario's durable position to model the iteration
2454    // on which the gate opens.
2455
2456    #[test]
2457    fn disk_policy_credits_journal_when_replicas_lag() {
2458        // `persisted>=1` is satisfied by the highest persisted cursor
2459        // in the cluster — here the primary's. A connected replica lagging
2460        // far behind is irrelevant to the gate, so it must not be
2461        // credited as the blocker. This is the case the old heuristic
2462        // got flatly wrong: journal_pos (500) > repl_min (10) sent it
2463        // down the `else` branch every single time.
2464        let p = parse("persisted>=1").unwrap();
2465        let m = metrics((10, 10), (10, 10));
2466        let a = both_active();
2467        assert_eq!(
2468            evaluate_gate(&p, 500, WireSeq::new(500), Some(&m), Some(&a)).1,
2469            Some(Blocker::Journal)
2470        );
2471    }
2472
2473    #[test]
2474    fn disk_policy_credits_replication_when_a_replica_fsync_leads() {
2475        // The mirror: policies count copies, not nodes. With the
2476        // primary's disk stalled at 10 and a replica persisted through
2477        // 500, `persisted>=1` opens on the replica's copy — and the
2478        // attribution must say so, or an operator reading the gate
2479        // counters would conclude the primary's disk was keeping up.
2480        let p = parse("persisted>=1").unwrap();
2481        let m = metrics((500, 500), (10, 10));
2482        let a = both_active();
2483        assert_eq!(
2484            evaluate_gate(&p, 500, WireSeq::new(10), Some(&m), Some(&a)).1,
2485            Some(Blocker::Replication)
2486        );
2487    }
2488
2489    #[test]
2490    fn disk_and_ram_reads_replica_in_memory_not_persisted() {
2491        // `persisted>=1 && in_memory>=2`. The replica has the event in
2492        // memory (400) well ahead of its own fsync (100), and the
2493        // primary's journal is behind that in-memory cursor (300).
2494        // The binding clause is therefore the primary's persisted
2495        // cursor → journal. Comparing against the replica's *persisted*
2496        // cursor (100) would have said replication.
2497        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2498        let m = metrics((400, 100), (0, 0));
2499        let a = flags(true, false);
2500        assert_eq!(
2501            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
2502            Some(Blocker::Journal)
2503        );
2504
2505        // Same policy, replica in-memory now the laggard → replication.
2506        // The gate opens at the replica's in-memory cursor (200).
2507        let m = metrics((200, 100), (0, 0));
2508        assert_eq!(
2509            evaluate_gate(&p, 200, WireSeq::new(300), Some(&m), Some(&a)).1,
2510            Some(Blocker::Replication)
2511        );
2512    }
2513
2514    #[test]
2515    fn disk_and_ram_is_satisfied_by_the_faster_replica() {
2516        // `in_memory>=2` needs the primary plus *one* replica, so the
2517        // best replica binds, not the worst. Replica 0 holds the event
2518        // in memory at 400 with its own fsync trailing at 250 (behind
2519        // the primary, as the physical ordering requires); replica 1
2520        // lags badly at 10. The gate is not held up by replication —
2521        // the primary's persisted cursor (300) is the binding term.
2522        // Taking the min across replicas would have blamed the slow
2523        // slot.
2524        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2525        let m = metrics((400, 250), (10, 10));
2526        let a = both_active();
2527        assert_eq!(
2528            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
2529            Some(Blocker::Journal)
2530        );
2531    }
2532
2533    #[test]
2534    fn two_disks_uses_second_largest_persisted() {
2535        // `persisted>=2` is met by the primary plus the *best* replica's
2536        // persisted cursor. With the primary ahead at 900 and replicas
2537        // at 400 and 10, the clause resolves at 400 — the fast replica —
2538        // not at the minimum of 10.
2539        let p = parse("persisted>=2").unwrap();
2540        let m = metrics((400, 400), (10, 10));
2541        let a = both_active();
2542        assert_eq!(
2543            evaluate_durability(&p, WireSeq::new(900), Some(&m), Some(&a)).durable_pos,
2544            400,
2545            "clause takes the second-largest persisted cursor, not the minimum"
2546        );
2547        // Replication binds, which is the norm for this policy: the
2548        // primary persists before it ships, so it is normally ahead of
2549        // every replica.
2550        assert_eq!(
2551            evaluate_gate(&p, 400, WireSeq::new(900), Some(&m), Some(&a)).1,
2552            Some(Blocker::Replication)
2553        );
2554    }
2555
2556    #[test]
2557    fn standalone_credits_the_journal() {
2558        // No replication wired: the primary is the only node, so the
2559        // journal is the only thing the gate can be waiting on.
2560        let p = parse("persisted>=1").unwrap();
2561        assert_eq!(
2562            evaluate_gate(&p, 500, WireSeq::new(500), None, None).1,
2563            Some(Blocker::Journal)
2564        );
2565    }
2566
2567    #[test]
2568    fn unsatisfiable_shape_never_opens_so_never_attributes() {
2569        // `persisted>=2` with no replica connected: the shape pins
2570        // `durable_pos` to 0, so the gate cannot open and no blocker is
2571        // ever produced — the stall is a missing node, not either
2572        // subsystem's progress. `policy_degraded` is the metric for it.
2573        let p = parse("persisted>=2").unwrap();
2574        let (status, blocker) = evaluate_gate(&p, 1, WireSeq::new(500), None, None);
2575        assert!(status.degraded);
2576        assert_eq!(status.durable_pos, 0);
2577        assert_eq!(blocker, None);
2578    }
2579
2580    #[test]
2581    fn closed_gate_yields_no_attribution() {
2582        // The policy is satisfiable and healthy, but the batch needs a
2583        // sequence the cursors haven't reached — the gate stays closed
2584        // and `evaluate_gate` must not spend a ranking pass (nor name a
2585        // blocker) on an iteration that keeps spinning.
2586        let p = parse("persisted>=1").unwrap();
2587        let (status, blocker) = evaluate_gate(&p, 501, WireSeq::new(500), None, None);
2588        assert!(!status.degraded);
2589        assert_eq!(status.durable_pos, 500);
2590        assert_eq!(blocker, None);
2591    }
2592
2593    #[test]
2594    fn attribution_skips_disconnected_slots() {
2595        // Slot 1 is disconnected, so its (very advanced) cursors must
2596        // not satisfy `in_memory>=2`; only slot 0 counts, and it lags.
2597        // The gate opens at slot 0's in-memory cursor (100).
2598        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2599        let m = metrics((100, 100), (999, 999));
2600        let a = flags(true, false);
2601        assert_eq!(
2602            evaluate_gate(&p, 100, WireSeq::new(500), Some(&m), Some(&a)).1,
2603            Some(Blocker::Replication)
2604        );
2605    }
2606
2607    /// Fresh-cluster catch-up: a replica that handshakes at sequence
2608    /// 0 (the legitimate genesis case, not a stale-flag race) must be
2609    /// included in the cursor view with its zero cursors so the policy
2610    /// behaves the same way it would for a 1-replica deployment that
2611    /// has just produced its first batch. The disconnect-race
2612    /// mitigations (B1 seed-on-connect + B2 reorder) keep this from
2613    /// being conflated with the stale-flag-paired-with-zero-cursor
2614    /// case under normal cluster lifecycles.
2615    #[test]
2616    fn fresh_cluster_zero_cursors_included_in_view() {
2617        let p = parse("persisted>=2").unwrap();
2618        // Both replicas just handshook at seq 0, primary also at 0
2619        // (fresh cluster, no events yet). View = 3 nodes; the clause's
2620        // count (=2) is met by the view size, so the policy is not
2621        // degraded and the gate sits at the 2nd-largest persisted = 0.
2622        let m = metrics((0, 0), (0, 0));
2623        let a = both_active();
2624        let r = evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a));
2625        assert_eq!(r.durable_pos, 0);
2626        assert!(
2627            !r.degraded,
2628            "all 3 nodes present, view meets clause target — should not flag degraded"
2629        );
2630    }
2631
2632    // -- Race-window regression tests --
2633    //
2634    // The replication senders fix two memory-ordering issues at the
2635    // active-flag transition points:
2636    //
2637    //   B1 (`a84540a`): seed `metrics.{acked,in_memory}_sequence[i]`
2638    //   to `handshake.last_sequence` BEFORE setting active_flag=true
2639    //   on reconnect. Without this, the gate would observe (active=
2640    //   true, cursor=0) for ~1 RTT after a replica catch-up completed,
2641    //   pinning any multi-node clause — and thus the gate — to 0.
2642    //
2643    //   B2 (`8888732`): zero `metrics.{acked,in_memory}_sequence[i]`
2644    //   BEFORE setting active_flag=false on disconnect. Without this,
2645    //   a weak-memory reader could observe (active=true, cursor=0)
2646    //   for one iteration during the disconnect window.
2647    //
2648    // Both fixes are in the senders, but the gate's *behaviour* under
2649    // the race-window inputs is tested here. The intent is to lock in
2650    // the invariant: even under a hypothetical (active=true,cursor=0)
2651    // observation, the gate must not produce a spuriously-open answer
2652    // that would cause a client to be told "your event is durable"
2653    // when it isn't. Stalling-briefly is safe; opening-spuriously is
2654    // not.
2655
2656    #[test]
2657    fn race_b1_post_seed_gate_doesnt_freeze_on_reconnect() {
2658        // Post-B1-fix state: replica reconnected, cursors seeded to
2659        // `handshake.last_sequence` (480) before active flipped to
2660        // true. Primary kept moving and is at 500. The gate's view
2661        // is now [primary=500, slot=480]; the durable position dips
2662        // from 500 (primary alone, degraded) to 480 (both nodes).
2663        //
2664        // The dip is correct, not a bug: once a 2nd node is
2665        // connected, durability is bounded by the slower of the two.
2666        // Events 481-500 were already served as durable on primary
2667        // alone — they aren't unsent. New responses for seq>500 wait
2668        // until slot acks; we just don't freeze at 0.
2669        let p = parse("persisted>=2").unwrap();
2670        let m = metrics((480, 480), (999, 999));
2671        let a = flags(true, false);
2672        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2673        assert_eq!(
2674            r.durable_pos, 480,
2675            "post-seed reconnect should produce a coherent gate position equal to the slower node, not freeze at 0"
2676        );
2677    }
2678
2679    #[test]
2680    fn race_b1_pre_seed_freeze_is_what_the_fix_avoids() {
2681        // Pre-B1-fix state: cursors at 0, active=true. The gate sees
2682        // [primary=500, slot=[0,0]] and 2nd-largest persisted = 0.
2683        // The gate WOULD freeze at 0. This test documents the bug
2684        // the seeding fix is designed to avoid; the senders ensure
2685        // this state is never observed in production.
2686        let p = parse("persisted>=2").unwrap();
2687        let m = metrics((0, 0), (999, 999));
2688        let a = flags(true, false);
2689        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2690        assert_eq!(
2691            r.durable_pos, 0,
2692            "the gate behaviour under (active=true, cursor=0) — if the senders ever fail to seed before flipping active, this is the freeze the operator would see"
2693        );
2694    }
2695
2696    #[test]
2697    fn race_b2_disconnect_window_doesnt_open_gate_spuriously() {
2698        // Simulates the B2 race window: a weak-memory reader observes
2699        // (active=true, cursor=0) for one iteration during the
2700        // disconnect transition. The slot legitimately has cursor=0
2701        // because the disconnect handler just zeroed the metrics.
2702        //
2703        // Critical invariant: the gate must NOT produce a higher
2704        // durable_pos than it would with the slot correctly excluded.
2705        // Specifically: with primary at 500, slot stale-zero-included,
2706        // the gate must not "see" the primary alone and open at 500
2707        // — that would let a client be told a seq is durable when
2708        // only the primary has it under a `persisted>=2` policy that
2709        // demands 2 nodes.
2710        let p = parse("persisted>=2").unwrap();
2711        let m = metrics((0, 0), (999, 999));
2712        let a = flags(true, false);
2713        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2714        // 2nd-largest persisted across {primary=500, slot=0} = 0.
2715        // Gate stalls. ✓
2716        assert_eq!(r.durable_pos, 0);
2717
2718        // Post-disconnect (active=false): view shrinks to {primary}.
2719        // `persisted>=2` is structurally unsatisfiable on a 1-node
2720        // view, so the gate stays at 0 AND surfaces degraded. The
2721        // matching stage's `replicas_connected==0` halt is what stops
2722        // accepting new orders; the gate side's job is just to keep
2723        // the existing in-flight orders stalled and the alert lit.
2724        let a_disconnected = flags(false, false);
2725        let r_after = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a_disconnected));
2726        assert_eq!(r_after.durable_pos, 0);
2727        assert!(
2728            r_after.degraded,
2729            "post-disconnect view of size 1 cannot meet persisted>=2 → degraded"
2730        );
2731    }
2732
2733    #[test]
2734    fn race_invariant_zero_cursor_never_opens_gate_above_slower_node() {
2735        // Property under both B1 and B2 race windows: for any slot
2736        // observed at cursor=0 with active=true, the gate cannot
2737        // produce a durable_pos that exceeds what an honest 2-node
2738        // evaluation would give. Spot-check a handful of primary
2739        // positions to lock the invariant.
2740        let p = parse("persisted>=2").unwrap();
2741        let m = metrics((0, 0), (999, 999));
2742        let a = flags(true, false);
2743        for primary_pos in [0, 1, 100, 500, 1_000_000_000_u64] {
2744            let r = evaluate_durability(&p, WireSeq::new(primary_pos), Some(&m), Some(&a));
2745            // 2nd-largest of {primary_pos, 0} = 0 for any primary > 0.
2746            // For primary_pos = 0, also 0. So always 0.
2747            assert_eq!(
2748                r.durable_pos, 0,
2749                "race-window observation must not open the gate above 0 for any primary position (got {} for primary_pos={primary_pos})",
2750                r.durable_pos
2751            );
2752        }
2753    }
2754
2755    // ------------------------------------------------------------------
2756    // GateCrossTracker — per-cursor "first transition from below to
2757    // crossed" inside the gate loop, used by the journal-wait /
2758    // replica-wait histograms.
2759    // ------------------------------------------------------------------
2760
2761    #[cfg(feature = "tick-to-trade")]
2762    #[test]
2763    fn gate_cross_tracker_records_journal_when_strictly_below() {
2764        // Journal starts at 5 (< 10), repl_min already at 100.
2765        // Journal crosses on the second observation. Replica was already
2766        // past at entry, so no replica sample.
2767        let mut t = GateCrossTracker::new(10);
2768        t.observe(5, Some(100), 1_000);
2769        t.observe(15, Some(100), 2_000);
2770        assert_eq!(t.journal_crossed(), Some(2_000));
2771        assert_eq!(t.replica_crossed(), None);
2772    }
2773
2774    #[cfg(feature = "tick-to-trade")]
2775    #[test]
2776    fn gate_cross_tracker_records_replica_when_strictly_below() {
2777        // Mirror image: journal already past, replica below at entry.
2778        let mut t = GateCrossTracker::new(10);
2779        t.observe(50, Some(5), 1_000);
2780        t.observe(50, Some(12), 2_000);
2781        assert_eq!(t.journal_crossed(), None);
2782        assert_eq!(t.replica_crossed(), Some(2_000));
2783    }
2784
2785    #[cfg(feature = "tick-to-trade")]
2786    #[test]
2787    fn gate_cross_tracker_records_both_when_both_below() {
2788        // Both below at entry, both cross independently.
2789        let mut t = GateCrossTracker::new(100);
2790        t.observe(50, Some(60), 1_000); // both below
2791        t.observe(105, Some(60), 2_000); // journal crosses
2792        t.observe(105, Some(110), 3_000); // replica crosses
2793        assert_eq!(t.journal_crossed(), Some(2_000));
2794        assert_eq!(t.replica_crossed(), Some(3_000));
2795    }
2796
2797    #[cfg(feature = "tick-to-trade")]
2798    #[test]
2799    fn gate_cross_tracker_skips_cursor_already_past_at_entry() {
2800        // Both cursors already >= needed at first observation —
2801        // neither was on the critical path. No samples.
2802        let mut t = GateCrossTracker::new(10);
2803        t.observe(50, Some(100), 1_000);
2804        // Even later observations don't backfill: was_below is sticky.
2805        t.observe(60, Some(110), 2_000);
2806        assert_eq!(t.journal_crossed(), None);
2807        assert_eq!(t.replica_crossed(), None);
2808    }
2809
2810    #[cfg(feature = "tick-to-trade")]
2811    #[test]
2812    fn gate_cross_tracker_first_observation_only_for_cross_decision() {
2813        // A cursor that goes back below `needed` after first iteration
2814        // (impossible in practice — cursors are monotonic — but we
2815        // verify the first-iteration snapshot is what gates the
2816        // sample). Journal: 50 < 10 false → was_below=false → no sample.
2817        let mut t = GateCrossTracker::new(10);
2818        t.observe(50, Some(5), 1_000); // journal already past, replica below
2819        t.observe(20, Some(12), 2_000); // both >= needed now
2820        // Journal: was_below=false at entry → still no sample.
2821        assert_eq!(t.journal_crossed(), None);
2822        // Replica: was_below=true at entry, crosses on iter 2 → sample.
2823        assert_eq!(t.replica_crossed(), Some(2_000));
2824    }
2825
2826    #[cfg(feature = "tick-to-trade")]
2827    #[test]
2828    fn gate_cross_tracker_holds_first_cross_only() {
2829        // Once a cross is recorded, later observations don't
2830        // overwrite — the metric is "when did it first cross", not
2831        // "when was it last below".
2832        let mut t = GateCrossTracker::new(10);
2833        t.observe(5, Some(100), 1_000);
2834        t.observe(15, Some(100), 2_000); // first cross
2835        t.observe(25, Some(100), 3_000); // would otherwise re-record
2836        assert_eq!(t.journal_crossed(), Some(2_000));
2837    }
2838
2839    #[cfg(feature = "tick-to-trade")]
2840    #[test]
2841    fn gate_cross_tracker_ignores_replica_dropping_out_mid_wait() {
2842        // The regression this signature exists for. The replica is
2843        // behind at entry (was_below latches true), then disconnects
2844        // part-way through the wait so no replica supplies a binding
2845        // cursor. That must not be read as "caught up" — the histogram
2846        // would otherwise report a wait that ended at the disconnect.
2847        let mut t = GateCrossTracker::new(100);
2848        t.observe(50, Some(60), 1_000); // replica behind
2849        t.observe(105, None, 2_000); // replica drops out mid-wait
2850        t.observe(105, None, 3_000); // still gone
2851        assert_eq!(t.journal_crossed(), Some(2_000));
2852        assert_eq!(t.replica_crossed(), None);
2853        // Reconnecting past `needed` still records the real crossing.
2854        t.observe(105, Some(150), 4_000);
2855        assert_eq!(t.replica_crossed(), Some(4_000));
2856    }
2857
2858    #[cfg(feature = "tick-to-trade")]
2859    #[test]
2860    fn gate_cross_tracker_records_nothing_when_no_replica_clause() {
2861        // `local` never binds on a replica, so `policy_replica_cursor`
2862        // returns `None` for the whole wait: was_below stays false and
2863        // no replica sample is produced.
2864        let mut t = GateCrossTracker::new(100);
2865        t.observe(50, None, 1_000);
2866        t.observe(105, None, 2_000);
2867        assert_eq!(t.journal_crossed(), Some(2_000));
2868        assert_eq!(t.replica_crossed(), None);
2869    }
2870
2871    // -- DegradationLogger flap-suppression --
2872    //
2873    // The logger gates transition logs on a sustained-state hold so a
2874    // replica flapping at sub-second cadence doesn't spam the journal.
2875    // These tests don't observe the logs themselves (tracing is
2876    // process-global and brittle to capture in unit tests); they
2877    // assert the underlying state machine via the `policy_degraded`
2878    // gauge, which the logger updates on every tick regardless of
2879    // log emission.
2880
2881    fn logger_test_policy() -> crate::ack_policy::Policy {
2882        parse("persisted>=2").unwrap()
2883    }
2884
2885    /// Tick the logger N times at `step` intervals, alternating
2886    /// `degraded` per call. Returns the gauge value after the last
2887    /// tick — useful for asserting that flap cycles don't leak the
2888    /// AtomicBool into a wrong terminal state.
2889    fn drive_logger(
2890        logger: &mut DegradationLogger,
2891        utilization: &StageUtilization,
2892        policy: &crate::ack_policy::Policy,
2893        start: Instant,
2894        states: &[bool],
2895        step: Duration,
2896    ) -> bool {
2897        for (i, &state) in states.iter().enumerate() {
2898            let now = start + step.checked_mul(i as u32).unwrap_or(Duration::ZERO);
2899            logger.tick(policy, utilization, state, now, Duration::from_secs(5));
2900        }
2901        utilization.policy_degraded.load(Ordering::Relaxed)
2902    }
2903
2904    #[test]
2905    fn logger_gauge_tracks_state_immediately() {
2906        // The /healthz gauge reflects the *latest* state on every
2907        // tick — this is what dashboards / alerts read. Sustained-
2908        // state gating only affects the warn/info log emission.
2909        let p = logger_test_policy();
2910        let utilization = StageUtilization::new();
2911        let now = Instant::now();
2912        let mut logger = DegradationLogger::new(now);
2913
2914        logger.tick(&p, &utilization, true, now, Duration::from_secs(5));
2915        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
2916
2917        logger.tick(
2918            &p,
2919            &utilization,
2920            false,
2921            now + Duration::from_millis(50),
2922            Duration::from_secs(5),
2923        );
2924        assert!(!utilization.policy_degraded.load(Ordering::Relaxed));
2925    }
2926
2927    #[test]
2928    fn logger_starting_degraded_marks_initial_state_logged() {
2929        // `new_starting_degraded` emits the startup warn and treats
2930        // the state as already-logged so the next tick at the same
2931        // cluster shape doesn't re-emit instantly. The gauge starts
2932        // at 1.
2933        let p = logger_test_policy();
2934        let utilization = StageUtilization::new();
2935        let now = Instant::now();
2936        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
2937        // The logger doesn't write the gauge from the constructor —
2938        // first tick does. Tick at the same state to settle the
2939        // gauge. No new log line should fire (state hasn't changed).
2940        logger.tick(
2941            &p,
2942            &utilization,
2943            true,
2944            now + Duration::from_millis(10),
2945            Duration::from_secs(5),
2946        );
2947        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
2948    }
2949
2950    #[test]
2951    fn logger_accumulates_degraded_seconds_only_while_degraded() {
2952        // The `policy_degraded_nanos` counter must advance by exactly
2953        // the wall-clock time spent in the degraded state and stay flat
2954        // while healthy. Each tick attributes the interval since the
2955        // previous tick to the state observed at that previous tick.
2956        let p = logger_test_policy();
2957        let utilization = StageUtilization::new();
2958        let start = Instant::now();
2959        let mut logger = DegradationLogger::new(start);
2960        let sec = Duration::from_secs(1);
2961
2962        // t0: healthy -> degraded. The zero-length interval before the
2963        // transition is attributed to the healthy start: no accrual.
2964        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
2965        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
2966
2967        // t0..t1 spent degraded -> +1s.
2968        logger.tick(&p, &utilization, true, start + sec, Duration::from_secs(5));
2969        // t1..t2 still degraded, then flips healthy at the tick -> +1s.
2970        logger.tick(
2971            &p,
2972            &utilization,
2973            false,
2974            start + 2 * sec,
2975            Duration::from_secs(5),
2976        );
2977        assert_eq!(
2978            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2979            2 * sec.as_nanos() as u64
2980        );
2981
2982        // t2..t3 spent healthy -> counter holds flat.
2983        logger.tick(
2984            &p,
2985            &utilization,
2986            false,
2987            start + 3 * sec,
2988            Duration::from_secs(5),
2989        );
2990        assert_eq!(
2991            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2992            2 * sec.as_nanos() as u64
2993        );
2994    }
2995
2996    #[test]
2997    fn logger_starting_degraded_accrues_from_construction() {
2998        // A primary that boots already-degraded (e.g. `disk+ram` with no
2999        // replica yet) must accrue from construction, not from the first
3000        // observed transition — `new_starting_degraded` seeds the
3001        // degraded state so the first tick's interval counts.
3002        let p = logger_test_policy();
3003        let utilization = StageUtilization::new();
3004        let start = Instant::now();
3005        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3006
3007        logger.tick(
3008            &p,
3009            &utilization,
3010            true,
3011            start + Duration::from_secs(1),
3012            Duration::from_secs(5),
3013        );
3014        assert_eq!(
3015            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3016            Duration::from_secs(1).as_nanos() as u64
3017        );
3018    }
3019
3020    #[test]
3021    fn logger_reseed_flushes_in_flight_degraded_accrual() {
3022        // A runtime policy swap re-seeds the logger. On the gate-wait path
3023        // the swap can land after many seconds of wedged-degraded time
3024        // with no intervening tick, so `reseed` must flush that interval
3025        // before resetting — otherwise the whole wedge is silently lost,
3026        // which is exactly the incident the metric exists to measure.
3027        let p = logger_test_policy();
3028        let utilization = StageUtilization::new();
3029        let start = Instant::now();
3030        let sec = Duration::from_secs(1);
3031
3032        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3033        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
3034        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
3035
3036        // 30s wedged-degraded with no tick (frozen spin), then swap.
3037        logger.reseed(&utilization, start + 30 * sec);
3038        assert_eq!(
3039            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3040            30 * sec.as_nanos() as u64
3041        );
3042
3043        // Post-reseed the logger is healthy-start; a healthy interval
3044        // does not accrue, and `last_tick` was rebased to the swap so
3045        // the next interval isn't double-counted.
3046        logger.tick(
3047            &p,
3048            &utilization,
3049            false,
3050            start + 31 * sec,
3051            Duration::from_secs(5),
3052        );
3053        assert_eq!(
3054            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3055            30 * sec.as_nanos() as u64
3056        );
3057    }
3058
3059    #[test]
3060    fn logger_starting_degraded_then_first_tick_healthy_counts_construction_interval() {
3061        // `new_starting_degraded` seeds the degraded state, so the
3062        // construction→first-tick interval is attributed degraded even
3063        // when the first observed state is already healthy (the cluster
3064        // recovered before the first tick). Locks this intentional edge.
3065        let p = logger_test_policy();
3066        let utilization = StageUtilization::new();
3067        let start = Instant::now();
3068        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3069
3070        logger.tick(
3071            &p,
3072            &utilization,
3073            false,
3074            start + Duration::from_secs(2),
3075            Duration::from_secs(5),
3076        );
3077        assert_eq!(
3078            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3079            Duration::from_secs(2).as_nanos() as u64
3080        );
3081    }
3082
3083    #[test]
3084    fn logger_counter_matches_total_degraded_time_across_flaps() {
3085        // The cumulative counter must equal exactly the wall time spent
3086        // in degraded states across a flap sequence — the guarantee
3087        // `rate(...degraded_seconds_total)` dashboards depend on.
3088        let p = logger_test_policy();
3089        let utilization = StageUtilization::new();
3090        let start = Instant::now();
3091        let step = Duration::from_millis(500);
3092        let mut logger = DegradationLogger::new(start);
3093
3094        // State observed at tick i (fired at start + i*step). The state
3095        // set at tick i holds over [tick i, tick i+1], so degraded wall
3096        // time = the spans where the *prior* tick's state was true:
3097        // [t1,t2], [t2,t3], [t4,t5] = 3 * step.
3098        let states = [false, true, true, false, true, false];
3099        for (i, &s) in states.iter().enumerate() {
3100            logger.tick(
3101                &p,
3102                &utilization,
3103                s,
3104                start + step * i as u32,
3105                Duration::from_secs(5),
3106            );
3107        }
3108
3109        assert_eq!(
3110            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3111            (3 * step).as_nanos() as u64
3112        );
3113    }
3114
3115    #[test]
3116    fn logger_handles_rapid_flap_without_panic() {
3117        // Drive the logger through 100 alternating flips at 100ms
3118        // each (faster than the 1s flap-hold). The state machine
3119        // must remain coherent — no panics, gauge tracks final
3120        // state, and `pending_logged` doesn't get stuck.
3121        let p = logger_test_policy();
3122        let utilization = StageUtilization::new();
3123        let now = Instant::now();
3124        let mut logger = DegradationLogger::new(now);
3125        let states: Vec<bool> = (0..100u32).map(|i| i.is_multiple_of(2)).collect();
3126        let final_state = drive_logger(
3127            &mut logger,
3128            &utilization,
3129            &p,
3130            now,
3131            &states,
3132            Duration::from_millis(100),
3133        );
3134        // 100 states starting at i=0 → final state is i=99 → odd → false.
3135        assert!(!final_state);
3136    }
3137
3138    #[test]
3139    fn logger_sustained_degraded_eventually_settles() {
3140        // After a sustained-true state, the logger should be in the
3141        // "logged the onset" mode. Drive 5 ticks of degraded=true at
3142        // 500ms intervals — total 2s, well past the 1s flap-hold.
3143        // Last tick should leave gauge=1 and the heartbeat re-emit
3144        // window primed (last_log set).
3145        let p = logger_test_policy();
3146        let utilization = StageUtilization::new();
3147        let now = Instant::now();
3148        let mut logger = DegradationLogger::new(now);
3149        let final_state = drive_logger(
3150            &mut logger,
3151            &utilization,
3152            &p,
3153            now,
3154            &[true; 5],
3155            Duration::from_millis(500),
3156        );
3157        assert!(final_state);
3158    }
3159
3160    #[test]
3161    fn logger_recovery_to_healthy_settles_gauge() {
3162        // Sustained degraded → sustained healthy. Gauge should end at 0.
3163        let p = logger_test_policy();
3164        let utilization = StageUtilization::new();
3165        let now = Instant::now();
3166        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
3167        let mut states = vec![true; 5]; // 2.5s degraded
3168        states.extend(vec![false; 5]); // 2.5s healthy
3169        let final_state = drive_logger(
3170            &mut logger,
3171            &utilization,
3172            &p,
3173            now,
3174            &states,
3175            Duration::from_millis(500),
3176        );
3177        assert!(!final_state);
3178    }
3179
3180    /// Dirty tracking: an entry flag for dedup plus a flat list for
3181    /// iteration, replacing the per-append `HashSet` insert.
3182    mod dirty_tracking {
3183        use super::super::{mark_dirty, unmark_dirty};
3184        use super::flush_sends::entry_for;
3185        use std::os::unix::net::UnixStream;
3186
3187        fn entry() -> super::super::ConnectionEntry {
3188            let (tx, _rx) = UnixStream::pair().expect("socketpair");
3189            let mut e = entry_for(tx, b"payload");
3190            e.dirty = false;
3191            e
3192        }
3193
3194        #[test]
3195        fn repeat_appends_enqueue_a_connection_once() {
3196            let mut e = entry();
3197            let mut dirty = Vec::new();
3198            for _ in 0..8 {
3199                mark_dirty(&mut e, 42, &mut dirty);
3200            }
3201            assert_eq!(dirty, vec![42], "the flag is the dedup");
3202            assert!(e.dirty);
3203        }
3204
3205        #[test]
3206        fn teardown_takes_a_connection_off_the_list() {
3207            let mut a = entry();
3208            let mut b = entry();
3209            let mut dirty = Vec::new();
3210            mark_dirty(&mut a, 1, &mut dirty);
3211            mark_dirty(&mut b, 2, &mut dirty);
3212            unmark_dirty(1, &mut dirty);
3213            assert_eq!(dirty, vec![2], "order of the survivors is preserved");
3214        }
3215    }
3216
3217    /// One append per slot: the encoded payload plus the request's
3218    /// pre-encoded `BatchEnd` terminator, sharing a single cap check and
3219    /// a single dirty marking.
3220    mod append_frames {
3221        use super::super::{AppendOutcome, ConnectionEntry, MAX_SEND_BUF, append_frames};
3222        use super::flush_sends::entry_for;
3223        use std::os::unix::net::UnixStream;
3224        use std::time::Instant;
3225
3226        const TRAILER: &[u8] = &[0xEE, 0xEE];
3227
3228        fn entry(prefill: usize) -> ConnectionEntry {
3229            let (tx, _rx) = UnixStream::pair().expect("socketpair");
3230            let mut e = entry_for(tx, &vec![0u8; prefill]);
3231            e.dirty = false;
3232            e
3233        }
3234
3235        #[test]
3236        fn payload_and_terminator_land_in_one_append() {
3237            let mut e = entry(0);
3238            let mut dirty = Vec::new();
3239            let mut to_remove = Vec::new();
3240            let encoded = [1u8, 2, 3, 4, 0xFF];
3241
3242            let outcome = append_frames(
3243                Some(Ok(4)),
3244                TRAILER,
3245                7,
3246                &mut e,
3247                &encoded,
3248                Instant::now(),
3249                &mut dirty,
3250                &mut to_remove,
3251            );
3252
3253            assert!(matches!(outcome, AppendOutcome::Continue));
3254            assert_eq!(
3255                e.send_buf,
3256                vec![1, 2, 3, 4, 0xEE, 0xEE],
3257                "payload then terminator, nothing past the encoder's length"
3258            );
3259            assert_eq!(dirty, vec![7], "one dirty marking for the pair");
3260            assert!(to_remove.is_empty());
3261        }
3262
3263        #[test]
3264        fn a_slot_with_no_bytes_does_not_dirty_the_connection() {
3265            // `OutputPayload::BatchEnd` mid-request: no payload, no
3266            // terminator. Marking it dirty would queue an empty send.
3267            let mut e = entry(0);
3268            let mut dirty = Vec::new();
3269            let mut to_remove = Vec::new();
3270
3271            let outcome = append_frames(
3272                None,
3273                &[],
3274                7,
3275                &mut e,
3276                &[],
3277                Instant::now(),
3278                &mut dirty,
3279                &mut to_remove,
3280            );
3281
3282            assert!(matches!(outcome, AppendOutcome::Continue));
3283            assert!(e.send_buf.is_empty());
3284            assert!(dirty.is_empty());
3285            assert!(!e.dirty);
3286        }
3287
3288        #[test]
3289        fn the_cap_is_checked_against_both_frames_together() {
3290            // Payload alone fits; payload + terminator does not. The
3291            // connection must be dropped *before* the payload lands —
3292            // never delivered without its terminator.
3293            let mut e = entry(MAX_SEND_BUF - 5);
3294            let mut dirty = Vec::new();
3295            let mut to_remove = Vec::new();
3296            let encoded = [1u8, 2, 3, 4];
3297
3298            let outcome = append_frames(
3299                Some(Ok(4)),
3300                TRAILER,
3301                7,
3302                &mut e,
3303                &encoded,
3304                Instant::now(),
3305                &mut dirty,
3306                &mut to_remove,
3307            );
3308
3309            assert!(matches!(outcome, AppendOutcome::ConnectionDropped));
3310            assert_eq!(to_remove, vec![7]);
3311            assert_eq!(e.send_buf.len(), MAX_SEND_BUF - 5, "no partial append");
3312            assert!(dirty.is_empty(), "a dropped connection is not queued");
3313        }
3314
3315        #[test]
3316        fn an_encode_failure_still_terminates_the_request() {
3317            // The payload is this server's bug; the client still needs
3318            // the terminator or it waits forever for the batch to end.
3319            let mut e = entry(0);
3320            let mut dirty = Vec::new();
3321            let mut to_remove = Vec::new();
3322
3323            let outcome = append_frames(
3324                Some(Err("encode error")),
3325                TRAILER,
3326                7,
3327                &mut e,
3328                &[],
3329                Instant::now(),
3330                &mut dirty,
3331                &mut to_remove,
3332            );
3333
3334            assert!(matches!(outcome, AppendOutcome::Continue));
3335            assert_eq!(e.send_buf, TRAILER);
3336            assert_eq!(dirty, vec![7]);
3337        }
3338    }
3339
3340    /// The consumed path's two flush triggers. The byte threshold bounds
3341    /// a connection's buffered *bytes*; the slot interval bounds their
3342    /// *age*, which is what many-low-rate-client deployments need (2026-08
3343    /// network audit, finding 3).
3344    mod flush_cadence {
3345        use super::super::{FLUSH_BYTES_THRESHOLD, FLUSH_SLOT_INTERVAL, FlushCadence};
3346
3347        #[test]
3348        fn byte_threshold_fires_on_a_single_fat_connection() {
3349            let mut c = FlushCadence::default();
3350            c.on_append(FLUSH_BYTES_THRESHOLD - 1);
3351            assert!(!c.is_due());
3352            c.on_append(FLUSH_BYTES_THRESHOLD);
3353            assert!(c.is_due());
3354        }
3355
3356        #[test]
3357        fn slot_interval_bounds_the_age_of_tiny_buffers() {
3358            // Every connection stays far below an MSS — the byte
3359            // trigger never fires, so before the age bound these
3360            // responses sat in `send_buf` until the ring went idle.
3361            let mut c = FlushCadence::default();
3362            for _ in 0..FLUSH_SLOT_INTERVAL - 1 {
3363                c.on_append(40);
3364                assert!(!c.is_due());
3365            }
3366            c.on_append(40);
3367            assert!(
3368                c.is_due(),
3369                "age bound must fire on the {FLUSH_SLOT_INTERVAL}th slot"
3370            );
3371        }
3372
3373        #[test]
3374        fn flushing_restarts_both_triggers() {
3375            let mut c = FlushCadence::default();
3376            for _ in 0..FLUSH_SLOT_INTERVAL {
3377                c.on_append(FLUSH_BYTES_THRESHOLD);
3378            }
3379            assert!(c.is_due());
3380            c.on_flush();
3381            assert!(
3382                !c.is_due(),
3383                "a flush restarts the byte flag and the counter"
3384            );
3385            // And the age bound counts from the flush, not from start.
3386            for _ in 0..FLUSH_SLOT_INTERVAL - 1 {
3387                c.on_append(1);
3388            }
3389            assert!(!c.is_due());
3390            c.on_append(1);
3391            assert!(c.is_due());
3392        }
3393    }
3394
3395    /// Regression tests for the slow-client head-of-line block fixed in
3396    /// the 2026-08 io_uring audit (docs/internal/io-uring-audit-2026-08.md,
3397    /// finding 1): `flush_sends` used to `submit_and_wait` on SENDs
3398    /// without `MSG_DONTWAIT`, and io_uring holds such a SEND's CQE back
3399    /// until the peer reads — so one zero-window client stalled every
3400    /// client's acks. These use real sockets and a real ring: the defect
3401    /// was kernel-level behaviour a mock would not reproduce.
3402    mod heartbeat {
3403        use super::super::{MAX_SEND_BUF, heartbeat_due};
3404        use super::flush_sends::entry_for;
3405        use std::os::unix::net::UnixStream;
3406        use std::time::{Duration, Instant};
3407
3408        const INTERVAL: Duration = Duration::from_secs(1);
3409        const FRAME_LEN: usize = 13;
3410
3411        /// Backdate `last_send` a full interval so only the receive-
3412        /// ability guards decide. `checked_sub` because a young
3413        /// monotonic clock can't be backdated (same pattern as the
3414        /// flush_sends tests).
3415        fn idle_entry(payload: &[u8]) -> Option<super::super::ConnectionEntry> {
3416            let (tx, _rx) = UnixStream::pair().unwrap();
3417            let mut entry = entry_for(tx, payload);
3418            entry.last_send = Instant::now().checked_sub(INTERVAL * 2)?;
3419            Some(entry)
3420        }
3421
3422        #[test]
3423        fn idle_unblocked_connection_gets_a_heartbeat() {
3424            let Some(entry) = idle_entry(&[]) else {
3425                eprintln!("monotonic clock too young to backdate; skipping");
3426                return;
3427            };
3428            assert!(heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3429        }
3430
3431        #[test]
3432        fn recently_active_connection_is_skipped() {
3433            let (tx, _rx) = UnixStream::pair().unwrap();
3434            let entry = entry_for(tx, &[]);
3435            assert!(
3436                !heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN),
3437                "a connection inside the interval must not be pinged"
3438            );
3439        }
3440
3441        /// A blocked peer's socket is full — the frame would only grow
3442        /// `send_buf`. This is one half of the fix for the unbounded
3443        /// heartbeat growth on trickle-reading clients.
3444        #[test]
3445        fn blocked_connection_is_skipped() {
3446            let Some(mut entry) = idle_entry(&[]) else {
3447                eprintln!("monotonic clock too young to backdate; skipping");
3448                return;
3449            };
3450            entry.blocked_since = Some(Instant::now());
3451            assert!(!heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3452        }
3453
3454        /// The other half: heartbeats respect `MAX_SEND_BUF` like every
3455        /// other append — this was the one path that bypassed the cap.
3456        #[test]
3457        fn append_never_exceeds_the_send_buffer_cap() {
3458            let payload = vec![0u8; MAX_SEND_BUF - FRAME_LEN + 1];
3459            let Some(entry) = idle_entry(&payload) else {
3460                eprintln!("monotonic clock too young to backdate; skipping");
3461                return;
3462            };
3463            assert!(!heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3464
3465            let payload = vec![0u8; MAX_SEND_BUF - FRAME_LEN];
3466            let Some(entry) = idle_entry(&payload) else {
3467                eprintln!("monotonic clock too young to backdate; skipping");
3468                return;
3469            };
3470            assert!(
3471                heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN),
3472                "a frame that exactly fits is allowed"
3473            );
3474        }
3475    }
3476
3477    mod flush_sends {
3478        use super::super::{
3479            BLOCKED_RETRY_INTERVAL, BLOCKED_SEND_TIMEOUT, ConnectionEntry, flush_sends,
3480        };
3481        use io_uring::IoUring;
3482        use rustc_hash::FxHashMap;
3483        use std::io::Read as _;
3484        use std::os::unix::io::{AsRawFd, RawFd};
3485        use std::os::unix::net::UnixStream;
3486        use std::time::{Duration, Instant};
3487
3488        pub(super) fn entry_for(stream: UnixStream, payload: &[u8]) -> ConnectionEntry {
3489            ConnectionEntry {
3490                fd: stream.as_raw_fd(),
3491                _owner: Box::new(stream),
3492                send_buf: payload.to_vec(),
3493                last_send: Instant::now(),
3494                blocked_since: None,
3495                last_send_attempt: Instant::now(),
3496                // Every entry these tests build is handed to `flush_sends`
3497                // on the dirty list, so the flag has to agree with it.
3498                dirty: true,
3499            }
3500        }
3501
3502        /// Fill `fd`'s socket send buffer with non-blocking writes until
3503        /// the kernel refuses more — the state a zero-window / non-reading
3504        /// client leaves a connection in.
3505        fn fill_socket(fd: RawFd) {
3506            let junk = [0u8; 4096];
3507            loop {
3508                let n =
3509                    unsafe { libc::send(fd, junk.as_ptr().cast(), junk.len(), libc::MSG_DONTWAIT) };
3510                if n < 0 {
3511                    let err = std::io::Error::last_os_error();
3512                    assert_eq!(
3513                        err.raw_os_error(),
3514                        Some(libc::EAGAIN),
3515                        "unexpected errno while filling socket"
3516                    );
3517                    break;
3518                }
3519            }
3520        }
3521
3522        /// The defect this module exists to prevent: a peer that stops
3523        /// reading must cost the flush nothing, and healthy peers on the
3524        /// same flush must still be delivered. Against the pre-fix code
3525        /// this test never returns.
3526        #[test]
3527        fn slow_peer_does_not_block_flush_and_healthy_peer_is_delivered() {
3528            let mut ring = IoUring::new(64).unwrap();
3529            let (slow_tx, _slow_rx) = UnixStream::pair().unwrap();
3530            let (fast_tx, mut fast_rx) = UnixStream::pair().unwrap();
3531            fill_socket(slow_tx.as_raw_fd());
3532
3533            let mut connections = FxHashMap::default();
3534            connections.insert(1u64, entry_for(slow_tx, &[0xAA; 1024]));
3535            connections.insert(2u64, entry_for(fast_tx, b"hello"));
3536            let mut dirty: Vec<u64> = vec![1, 2];
3537            let mut to_remove = Vec::new();
3538            let mut cqes = Vec::new();
3539
3540            let start = Instant::now();
3541            flush_sends(
3542                &mut ring,
3543                &mut connections,
3544                &mut dirty,
3545                &mut to_remove,
3546                &mut cqes,
3547            );
3548            // Generous bound to stay unflaky under CI load — the fixed
3549            // path completes in microseconds, the broken one only when
3550            // the slow peer reads (here: never).
3551            assert!(
3552                start.elapsed() < Duration::from_secs(1),
3553                "flush blocked on a slow peer"
3554            );
3555
3556            assert!(to_remove.is_empty(), "nobody dropped on first refusal");
3557            let slow = &connections[&1];
3558            assert!(
3559                !slow.send_buf.is_empty(),
3560                "slow peer keeps its undelivered bytes"
3561            );
3562            assert!(slow.blocked_since.is_some(), "slow peer marked blocked");
3563            assert!(dirty.contains(&1), "slow peer stays dirty for retry");
3564            assert!(slow.dirty, "and its flag agrees with the list");
3565
3566            assert!(connections[&2].send_buf.is_empty(), "healthy peer drained");
3567            assert!(!dirty.contains(&2), "healthy peer leaves the dirty set");
3568            // Flag and list must clear together: a stale `true` would
3569            // keep the connection off the list forever, since `mark_dirty`
3570            // only pushes when the flag is down.
3571            assert!(!connections[&2].dirty, "and its flag clears with it");
3572            fast_rx
3573                .set_read_timeout(Some(Duration::from_secs(1)))
3574                .unwrap();
3575            let mut buf = [0u8; 5];
3576            fast_rx.read_exact(&mut buf).unwrap();
3577            assert_eq!(&buf, b"hello", "healthy peer's bytes reached the socket");
3578        }
3579
3580        /// A payload larger than the socket buffer must be partially
3581        /// delivered, the remainder kept, and the connection treated as
3582        /// blocked (an immediate retry would only report EAGAIN).
3583        #[test]
3584        fn partial_send_keeps_remainder_and_marks_blocked() {
3585            let mut ring = IoUring::new(64).unwrap();
3586            let (tx, _rx) = UnixStream::pair().unwrap();
3587            // Shrink the send buffer so the payload cannot fit in one go.
3588            let sz: libc::c_int = 8192;
3589            let rc = unsafe {
3590                libc::setsockopt(
3591                    tx.as_raw_fd(),
3592                    libc::SOL_SOCKET,
3593                    libc::SO_SNDBUF,
3594                    (&raw const sz).cast(),
3595                    size_of::<libc::c_int>() as libc::socklen_t,
3596                )
3597            };
3598            assert_eq!(rc, 0, "setsockopt(SO_SNDBUF) failed");
3599            let payload = vec![0x55u8; 512 * 1024];
3600
3601            // Enter the flush as an already-blocked peer with a stale
3602            // (but unexpired) block timestamp and an expired pacing
3603            // window, so the partial delivery below must *restart* the
3604            // blocked clock — the behaviour that keeps a slow-but-
3605            // draining client alive past BLOCKED_SEND_TIMEOUT.
3606            let Some(stale_block) = Instant::now().checked_sub(BLOCKED_SEND_TIMEOUT / 2) else {
3607                eprintln!("monotonic clock too young to backdate; skipping");
3608                return;
3609            };
3610            let mut entry = entry_for(tx, &payload);
3611            entry.blocked_since = Some(stale_block);
3612            entry.last_send_attempt = stale_block;
3613
3614            let mut connections = FxHashMap::default();
3615            connections.insert(1u64, entry);
3616            let mut dirty: Vec<u64> = vec![1];
3617            let mut to_remove = Vec::new();
3618            let mut cqes = Vec::new();
3619
3620            flush_sends(
3621                &mut ring,
3622                &mut connections,
3623                &mut dirty,
3624                &mut to_remove,
3625                &mut cqes,
3626            );
3627
3628            let entry = &connections[&1];
3629            assert!(entry.send_buf.len() < payload.len(), "some bytes were sent");
3630            assert!(!entry.send_buf.is_empty(), "remainder kept for retry");
3631            let refreshed = entry.blocked_since.expect("partial send marks blocked");
3632            assert!(
3633                refreshed > stale_block,
3634                "forward progress restarts the blocked clock"
3635            );
3636            assert!(dirty.contains(&1));
3637            assert!(to_remove.is_empty());
3638        }
3639
3640        /// A connection blocked past `BLOCKED_SEND_TIMEOUT` is dropped —
3641        /// the guard for clients that stop reading during quiet periods,
3642        /// where `MAX_SEND_BUF` never trips because nothing accumulates.
3643        #[test]
3644        fn blocked_past_timeout_is_dropped() {
3645            let mut ring = IoUring::new(64).unwrap();
3646            let (tx, _rx) = UnixStream::pair().unwrap();
3647            fill_socket(tx.as_raw_fd());
3648
3649            let mut entry = entry_for(tx, &[1u8; 64]);
3650            // `checked_sub`: a plain `-` panics when the monotonic clock
3651            // is younger than the timeout (fresh-boot CI microVMs).
3652            let Some(expired) = Instant::now().checked_sub(BLOCKED_SEND_TIMEOUT) else {
3653                eprintln!("monotonic clock too young to backdate; skipping");
3654                return;
3655            };
3656            entry.blocked_since = Some(expired);
3657            let mut connections = FxHashMap::default();
3658            connections.insert(7u64, entry);
3659            let mut dirty: Vec<u64> = vec![7];
3660            let mut to_remove = Vec::new();
3661            let mut cqes = Vec::new();
3662
3663            flush_sends(
3664                &mut ring,
3665                &mut connections,
3666                &mut dirty,
3667                &mut to_remove,
3668                &mut cqes,
3669            );
3670
3671            assert_eq!(to_remove, vec![7], "timed-out peer queued for removal");
3672            assert!(!dirty.contains(&7), "dropped peer leaves the dirty set");
3673        }
3674
3675        /// A blocked connection retried a moment ago is skipped — the
3676        /// pacing that keeps a busy-spinning stage from probing a full
3677        /// socket once per loop iteration.
3678        #[test]
3679        fn blocked_retry_is_paced() {
3680            let mut ring = IoUring::new(64).unwrap();
3681            let (tx, _rx) = UnixStream::pair().unwrap();
3682            fill_socket(tx.as_raw_fd());
3683
3684            let mut entry = entry_for(tx, &[1u8; 64]);
3685            entry.blocked_since = Some(Instant::now());
3686            // A future attempt timestamp makes the pacing skip
3687            // deterministic: `duration_since` saturates to zero, so no
3688            // CI-load preemption between here and the flush's own clock
3689            // read can open the 100 µs window and flake the assertion.
3690            entry.last_send_attempt = Instant::now() + Duration::from_secs(1);
3691            let attempted_at = entry.last_send_attempt;
3692            let mut connections = FxHashMap::default();
3693            connections.insert(3u64, entry);
3694            let mut dirty: Vec<u64> = vec![3];
3695            let mut to_remove = Vec::new();
3696            let mut cqes = Vec::new();
3697
3698            flush_sends(
3699                &mut ring,
3700                &mut connections,
3701                &mut dirty,
3702                &mut to_remove,
3703                &mut cqes,
3704            );
3705
3706            assert_eq!(
3707                connections[&3].last_send_attempt, attempted_at,
3708                "no SEND submitted inside the pacing window"
3709            );
3710            assert!(dirty.contains(&3), "paced peer stays dirty");
3711            assert!(to_remove.is_empty());
3712
3713            // Outside the pacing window the retry happens: backdate the
3714            // last attempt and verify the attempt timestamp advances.
3715            let Some(backdated) = Instant::now().checked_sub(BLOCKED_RETRY_INTERVAL * 2) else {
3716                eprintln!("monotonic clock too young to backdate; skipping");
3717                return;
3718            };
3719            connections.get_mut(&3).unwrap().last_send_attempt = backdated;
3720            flush_sends(
3721                &mut ring,
3722                &mut connections,
3723                &mut dirty,
3724                &mut to_remove,
3725                &mut cqes,
3726            );
3727            assert!(
3728                connections[&3].last_send_attempt > backdated,
3729                "retry submitted once the pacing window elapsed"
3730            );
3731        }
3732
3733        /// Review finding F1 (io-uring-audit-2026-08.md): a
3734        /// response-initiated drop must tear down the *whole* socket and
3735        /// release the `max_connections` permit. The reader holds its
3736        /// own dup of the socket, so merely dropping this stage's entry
3737        /// (closing our dup) leaves the socket open — the EOF below must
3738        /// arrive *despite* a live second dup, which only shutdown(2)
3739        /// achieves.
3740        #[test]
3741        fn teardown_shuts_the_socket_despite_other_dups_and_releases_the_permit() {
3742            use std::sync::atomic::{AtomicU64, Ordering};
3743
3744            let (tx, mut peer) = UnixStream::pair().unwrap();
3745            // Simulates the reader's half: still open across the drop.
3746            let reader_dup = tx.try_clone().unwrap();
3747            let entry = entry_for(tx, &[]);
3748            let active = AtomicU64::new(1);
3749
3750            super::super::teardown_dropped(entry, &active);
3751
3752            assert_eq!(active.load(Ordering::Relaxed), 0, "permit released");
3753            peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
3754            let mut buf = [0u8; 1];
3755            assert_eq!(
3756                peer.read(&mut buf).expect("EOF, not a timeout"),
3757                0,
3758                "peer observes EOF even though another dup is still open"
3759            );
3760            drop(reader_dup);
3761        }
3762    }
3763
3764    /// The stage learns of a connection from its control channel and of
3765    /// that connection's replies from the output ring. A reply always
3766    /// trails its `Connected`, so reading the ring before draining the
3767    /// channel is enough for every slot read to find its connection.
3768    /// Drained first, the stage could read the ring after a `Connected`
3769    /// it had not seen — the accept loop queues that event only after
3770    /// the client holds `ServerReady`, so a first request is often on
3771    /// the wire before the stage has heard of its connection — and drop
3772    /// the reply. Seen as a first reply lost on a fresh node under
3773    /// full-suite load. The seam sits right
3774    /// after the drain: with the drain after the read that is harmless,
3775    /// with the drain before it the seam is the gap, and a `Connected`
3776    /// landing there is missed by the read that follows. Holding the
3777    /// stage at the seam makes the interleaving the test's rather than
3778    /// the scheduler's.
3779    mod registration_race {
3780        use std::io::{Read, Write};
3781        use std::os::unix::io::AsRawFd;
3782        use std::os::unix::net::UnixStream;
3783        use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
3784        use std::sync::{Arc, mpsc};
3785        use std::thread;
3786        use std::time::Duration;
3787
3788        use counter_server::{Counter, CounterQuery, CounterReport, ResponseEncoder};
3789        use melin_pipeline::ring::DisruptorBuilder;
3790        use melin_pipeline::wait::WaitStrategy;
3791        use melin_transport_core::fence::FenceState;
3792        use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
3793        use melin_transport_core::{DurableWireSeqCursor, WireSeq};
3794        use melin_wire_protocol::blocking::BlockingFrameWriter;
3795
3796        use crate::ControlEvent;
3797        use crate::ack_policy::AckPolicy;
3798        use crate::response::{Response, run};
3799
3800        /// `CounterReport::Ack` on the wire: len(4) + tag(1) + value(8).
3801        const FRAME_LEN: usize = 13;
3802        /// Long enough that a genuine loss is distinguishable from
3803        /// scheduler noise on a loaded box, short enough to fail in
3804        /// bounded time.
3805        const READ_TIMEOUT: Duration = Duration::from_secs(5);
3806
3807        #[test]
3808        fn a_reply_that_lands_with_its_connection_is_delivered() {
3809            let (mut producer, mut consumers) =
3810                DisruptorBuilder::<OutputSlot<CounterReport, CounterQuery>>::new(64)
3811                    .add_consumer()
3812                    .build(WaitStrategy::SpinThenYield);
3813            let consumer = consumers.pop().expect("one consumer was requested");
3814
3815            let (server_sock, mut client_sock) = UnixStream::pair().expect("socketpair");
3816            let server_fd = server_sock.as_raw_fd();
3817            let writer = BlockingFrameWriter::new(Box::new(server_sock) as Box<dyn Write + Send>);
3818            client_sock
3819                .set_read_timeout(Some(READ_TIMEOUT))
3820                .expect("set read timeout");
3821
3822            // The stage reports reaching the gap on `parked` and waits on
3823            // `resume` — once, on its first iteration.
3824            let (parked_tx, parked_rx) = mpsc::channel::<()>();
3825            let (resume_tx, resume_rx) = mpsc::channel::<()>();
3826            let mut held = false;
3827            let pause = move || {
3828                if !held {
3829                    held = true;
3830                    parked_tx.send(()).expect("the test is waiting");
3831                    resume_rx.recv().expect("the test resumes the stage");
3832                }
3833            };
3834
3835            let (control_tx, control_rx) = mpsc::channel();
3836            let shutdown = AtomicBool::new(false);
3837            let config = Response::<Counter> {
3838                // The journal is already past the one event, so the gate
3839                // is open and delivery hinges on registration alone.
3840                journal_persisted_wire_seq: DurableWireSeqCursor::detached(WireSeq::new(1)),
3841                ack_policy: Arc::new(AtomicU8::new(AckPolicy::Disk.as_u8())),
3842                replication_metrics: None,
3843                replica_active: None,
3844                heartbeat_interval: None,
3845                wait: WaitStrategy::SpinThenYield,
3846                utilization: Arc::new(StageUtilization::default()),
3847                encoder: Arc::new(ResponseEncoder),
3848                fence_state: Arc::new(FenceState::new(0)),
3849                active_connections: Arc::new(AtomicU64::new(0)),
3850                pause_after_control_drain: Some(Box::new(pause)),
3851            };
3852
3853            thread::scope(|scope| {
3854                let stage = scope.spawn(|| run::<Counter>(consumer, control_rx, config, &shutdown));
3855
3856                // The stage has drained an empty control channel and is
3857                // held at the seam. Now both arrive: read after drain,
3858                // the next iteration finds the slot and then its
3859                // connection; drain after read, the read would find the
3860                // slot and the connection would still be in the channel.
3861                parked_rx.recv().expect("the stage reaches the seam");
3862                control_tx
3863                    .send(ControlEvent::Connected {
3864                        connection_id: 1,
3865                        fd: server_fd,
3866                        writer,
3867                    })
3868                    .expect("stage is running");
3869                producer.publish(OutputSlot {
3870                    connection_id: 1,
3871                    wire_seq: 1,
3872                    payload: OutputPayload::Report(CounterReport::Ack { new_value: 7 }),
3873                    // No trailing `BatchEnd`: one frame on the wire.
3874                    is_last_in_request: false,
3875                    ..Default::default()
3876                });
3877                resume_tx.send(()).expect("the stage is parked");
3878
3879                let mut frame = [0u8; FRAME_LEN];
3880                let delivered = client_sock.read_exact(&mut frame);
3881                shutdown.store(true, Ordering::Relaxed);
3882                stage.join().expect("stage panicked");
3883                delivered.expect(
3884                    "the reply was dropped: the control channel was drained before the ring was read",
3885                );
3886                assert_eq!(
3887                    u64::from_le_bytes(frame[5..].try_into().expect("8 bytes")),
3888                    7,
3889                    "the ack carries the counter's value"
3890                );
3891            });
3892        }
3893    }
3894}