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