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