Skip to main content

melin_server_runtime/
response.rs

1//! io_uring-based response stage — routes matching output to connections via
2//! `IORING_OP_SEND`.
3//!
4//! Replaces the blocking `write(2)` + `BufWriter` flush path with batched
5//! io_uring sends. Instead of N `write(2)` syscalls (one per dirty connection
6//! on flush), we submit N SEND SQEs in a single `io_uring_enter` call.
7//!
8//! Same SPSC consumption and journal cursor gating as `response.rs`.
9//! Runs on a dedicated OS thread.
10
11use std::collections::{HashMap, HashSet};
12use std::os::unix::io::RawFd;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::mpsc;
16use std::time::{Duration, Instant};
17
18use io_uring::{IoUring, opcode, types};
19use tracing::{debug, error};
20
21use melin_pipeline::ring;
22
23use crate::durability_policy::{
24    Blocker, CursorView, DurabilityMode, EvalStatus, MAX_CLUSTER_SIZE, Policy,
25};
26use crate::replication::ReplicationMetrics;
27use melin_app::Application;
28use melin_app::amortized_timer::AmortizedTimer;
29use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
30#[cfg(feature = "latency-trace")]
31use melin_transport_core::trace;
32use melin_transport_core::{DurableWireSeqCursor, WireSeq};
33
34use melin_wire_protocol::control::TransportResponse;
35use melin_wire_protocol::control_codec;
36
37/// Maximum number of output slots consumed per batch.
38const MAX_BATCH: usize = 1024;
39
40/// Maximum encoded response size. PositionSnapshot is the largest variant
41/// at up to 330 bytes (length(4) + tag(1) + account(4) + count(1) +
42/// 16*(currency(4)+free(8)+reserved(8))). 512 bytes covers all variants.
43const MAX_RESPONSE_BUF: usize = 512;
44
45/// io_uring submission queue depth for sends. Must be ≥ max concurrent
46/// connections to avoid SQ overflow when all connections are dirty.
47/// Power of 2 for io_uring alignment. 4096 supports 1024+ client
48/// benchmarks where all connections flush simultaneously.
49const RING_SIZE: u32 = 4096;
50
51/// Maximum accumulated send buffer per connection (64 KiB). If a client
52/// falls behind and the buffer exceeds this, the connection is dropped.
53/// 64 KiB holds ~500 response frames — well beyond any reasonable lag.
54const MAX_SEND_BUF: usize = 64 * 1024;
55
56pub use crate::ControlEvent;
57
58/// Encoder type alias: response encoder bound to the application's
59/// `Report` / `QueryResponse` types. Hides the long
60/// `dyn ResponseEncoder<Report = ..., Query = ...>` at call sites.
61pub type ResponseEncoderArc<A> = Arc<
62    dyn melin_app::encoder::ResponseEncoder<
63            Report = <A as Application>::Report,
64            Query = <A as Application>::QueryResponse,
65        >,
66>;
67
68/// Configuration and shared state for the response stage.
69pub struct Response<A: Application> {
70    /// Highest wire seq durably persisted on the primary's journal.
71    /// In the same sequence space as `OutputSlot.wire_seq` and the
72    /// replica metrics (`metrics.in_memory_sequence` /
73    /// `metrics.acked_sequence`), so the durability gate can compare
74    /// these values numerically and the comparison is meaningful
75    /// regardless of `starting_sequence` (fresh vs recovered primary).
76    /// Typed [`DurableWireSeqCursor`] so the gate cannot be wired to a
77    /// ring-space counter — the pre-v14 bug class. Updated by the
78    /// journal stage after every fsync batch via `set_last_seq_publisher`.
79    pub journal_persisted_wire_seq: DurableWireSeqCursor,
80    /// Operator-selected durability mode, published through a shared
81    /// [`AtomicU8`] so the admin `DURABILITY` command can swap it at
82    /// runtime without restarting the node. The response stage reads
83    /// this once per gate iteration with a relaxed load (cheaper than a
84    /// `Mutex` or refcounted `Arc<Policy>` snapshot) and rebuilds its
85    /// local [`Policy`] when the byte changes. See
86    /// [`crate::durability_policy::DurabilityMode::as_u8`] for the
87    /// encoding.
88    pub durability_mode: Arc<std::sync::atomic::AtomicU8>,
89    /// Per-slot replica cursors. `None` for standalone deployments
90    /// (no replication wiring) — the policy then evaluates against the
91    /// primary alone.
92    pub replication_metrics: Option<Arc<ReplicationMetrics>>,
93    /// Per-slot replica active flags. Only "true" slots are included in
94    /// the cursor view fed to `Policy::evaluate`, so disconnected slots
95    /// don't pollute the view with stale zero cursors. When the
96    /// resulting view is too small to satisfy a clause, the policy
97    /// reports degraded and the gate stalls.
98    /// Mirrors `replication_metrics` — `None` in standalone.
99    pub replica_active: Option<[Arc<AtomicBool>; 2]>,
100    pub heartbeat_interval: Option<Duration>,
101    pub busy_spin: bool,
102    pub utilization: Arc<StageUtilization>,
103    /// Wire encoder for application-shaped payloads. Constructed
104    /// once at boot (`Arc::new(ExchangeResponseEncoder)`) and shared
105    /// with the DPDK response stage.
106    pub encoder: ResponseEncoderArc<A>,
107    /// Node fencing state. When latched (a higher epoch was observed), the
108    /// stage exits *without* the best-effort flush — in-flight responses
109    /// for orders on a superseded epoch must not be acknowledged. Fencing
110    /// co-sets `shutdown`, so the latch is only consulted on the shutdown
111    /// path (zero steady-state cost). See `crate::fence`.
112    pub fence_state: Arc<melin_transport_core::fence::FenceState>,
113}
114
115/// Per-connection state for batched io_uring sends.
116struct ConnectionEntry {
117    fd: RawFd,
118    /// Owns the write half of the socket to keep the fd alive.
119    _owner: Box<dyn Send>,
120    /// Accumulates encoded response frames between flushes.
121    /// The full wire frame (length prefix + payload) is appended here.
122    /// Vec's internal data pointer is heap-stable, so io_uring SEND SQEs
123    /// referencing `as_ptr()` remain valid even if the HashMap relocates
124    /// this struct — as long as we don't reallocate the Vec during in-flight sends.
125    send_buf: Vec<u8>,
126    /// Last time data was sent to this connection. Used for heartbeat scheduling.
127    last_send: Instant,
128}
129
130/// Run the io_uring response stage loop. Blocks the calling thread until shutdown.
131///
132/// Consumes from the output SPSC, waits for durability confirmation, and
133/// sends responses via io_uring SEND.
134///
135/// Durability gating: every gate iteration reads the journal cursor
136/// (primary persisted) plus per-slot replica cursors (in-memory and
137/// persisted) from `replication_metrics` and feeds them through the
138/// configured [`Policy`]. See [`evaluate_durability`].
139pub fn run<A: Application>(
140    mut consumer: ring::Consumer<OutputSlot<A::Report, A::QueryResponse>>,
141    control_rx: mpsc::Receiver<ControlEvent>,
142    config: Response<A>,
143    shutdown: &AtomicBool,
144) {
145    let Response {
146        journal_persisted_wire_seq,
147        durability_mode,
148        replication_metrics,
149        replica_active,
150        heartbeat_interval,
151        busy_spin,
152        utilization,
153        encoder,
154        fence_state,
155    } = config;
156    // Resolve the starting mode from the shared atomic and derive the
157    // local Policy. The atomic is the single source of truth across the
158    // process lifetime; the response thread keeps a thread-local copy
159    // for cheap per-iteration use and rebuilds it when an admin
160    // `DURABILITY` command swaps the atomic. Initialise as Hybrid (the
161    // default mode) if the atomic ever holds a corrupted byte — better
162    // than panicking on a degraded process and matches the default
163    // operators see at boot.
164    let mut active_mode =
165        DurabilityMode::from_u8(durability_mode.load(std::sync::atomic::Ordering::Relaxed))
166            .unwrap_or_else(|| {
167                tracing::error!(
168                    "durability_mode atomic held a corrupted byte at startup; defaulting to hybrid"
169                );
170                DurabilityMode::Hybrid
171            });
172    let mut policy = active_mode.to_policy();
173    let mut ring =
174        IoUring::new(RING_SIZE).expect("failed to create io_uring instance for response stage");
175
176    // Connection table: maps connection IDs to their state.
177    // HashMap for O(1) lookup. Pre-sized for a reasonable number of concurrent clients.
178    let mut connections: HashMap<u64, ConnectionEntry> = HashMap::with_capacity(256);
179
180    let mut batch = [OutputSlot::<A::Report, A::QueryResponse>::default(); MAX_BATCH];
181    let mut encode_buf = [0u8; MAX_RESPONSE_BUF];
182
183    // Cached durability position to avoid atomic reads on every slot.
184    // Initialised below from the policy's startup evaluation; updated
185    // via `evaluate_durability` on every gate iteration.
186    let mut cached_durable_pos: u64;
187
188    // Degradation logger. Tracks transitions, suppresses sub-second
189    // flap noise, and drives the `/healthz` `policy_degraded` gauge.
190    // See `DegradationLogger` for the full state machine. Initialised
191    // below from the policy's startup evaluation so an unsatisfiable
192    // policy (e.g. a primary that just lost both replicas while
193    // running `hybrid` or `durably-replicated`) is visible immediately
194    // on `/healthz` and in the journal.
195    let startup_now = Instant::now();
196    let mut last_policy_check = startup_now;
197    /// Re-emit interval for the "still degraded" reminder.
198    const DEGRADED_LOG_INTERVAL: Duration = Duration::from_secs(5);
199    /// Cadence at which the idle path re-evaluates the policy. Bounds
200    /// the lag between a connection-state change and the `/healthz`
201    /// gauge / warn-log reflecting it. Cheap (a handful of atomic
202    /// loads + the policy evaluator) at this rate.
203    const POLICY_CHECK_INTERVAL: Duration = Duration::from_secs(1);
204    /// Cadence at which the gate-wait spin folds elapsed time into the
205    /// degraded-duration counter while the durability gate is stalled.
206    /// Tighter than the idle cadence — it bounds the boundary error when
207    /// a degradation begins or flips mid-wedge, which matters most for
208    /// short stalls. The accrual tick is gated by this period, but the
209    /// clock read behind it is gated by the `AmortizedTimer` mask, so the
210    /// effective resolution is `max(this, the mask's clock-read cadence)`.
211    /// The mask reads the clock ~every 6.5 ms at this spin's rate
212    /// (`AmortizedTimer::CHECK_MASK` is `2^16`), finer than the 10 ms
213    /// here, so this cadence is the binding one and is realized in full.
214    const GATE_ACCRUAL_INTERVAL: Duration = Duration::from_millis(10);
215
216    // Initial evaluation so the cached durable position and the
217    // `/healthz` gauge reflect the cluster's startup shape before
218    // the first batch arrives.
219    let mut degraded_logger;
220    {
221        let journal_pos = journal_persisted_wire_seq.load();
222        let metrics_ref = replication_metrics.as_deref();
223        let active_ref = replica_active.as_ref();
224        let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
225        cached_durable_pos = status.durable_pos;
226        utilization
227            .policy_degraded
228            .store(status.degraded, Ordering::Relaxed);
229        degraded_logger = if status.degraded {
230            DegradationLogger::new_starting_degraded(startup_now, &policy)
231        } else {
232            DegradationLogger::new(startup_now)
233        };
234    }
235
236    // Stage histograms registered with the global registry — see
237    // `melin_transport_core::trace`. The four breakdown stages
238    // (journal-wait, replica-wait, encode, egress) feed the bench's
239    // tick-to-trade decomposition; spsc/dispatch/server-e2e are kept
240    // alongside as overall sanity checks.
241    #[cfg(feature = "latency-trace")]
242    let mut spsc_rec =
243        trace::register_stage("response: SPSC wakeup (matching publish → response consume)");
244    #[cfg(feature = "latency-trace")]
245    let mut dispatch_rec = trace::register_stage("response: dispatch (consume → socket write)");
246    #[cfg(feature = "latency-trace")]
247    let mut server_e2e_rec = trace::register_stage("server e2e (reader recv → response flush)");
248    // Tick-to-trade breakdown: per-slot wait observed for each
249    // durability path (recorded only when the gate actually held us
250    // up — cache-hit paths skip to avoid inflating the metric with
251    // crossings that happened before we noticed). Encode is wall-time
252    // around `encode_transport_response`. Egress wraps a `flush_sends`
253    // call (one sample per io_uring flush, batching many slots).
254    // Gated on `tick-to-trade`, not `latency-trace`, because these
255    // stages roughly double the hot-path mutex traffic vs the lighter
256    // 4-stage mode.
257    #[cfg(feature = "tick-to-trade")]
258    let mut journal_wait_rec =
259        trace::register_stage("response: journal-wait (match_complete → journal cursor crossed)");
260    #[cfg(feature = "tick-to-trade")]
261    let mut replica_wait_rec = trace::register_stage(
262        "response: replica-wait (match_complete → replication cursor crossed)",
263    );
264    #[cfg(feature = "tick-to-trade")]
265    let mut encode_rec = trace::register_stage("response: encode (per-kind wire encoding)");
266    #[cfg(feature = "tick-to-trade")]
267    let mut egress_rec = trace::register_stage("response: egress (flush_sends elapsed)");
268
269    // Track connections with buffered (unflushed) writes across batches.
270    let mut dirty_connections: HashSet<u64> = HashSet::new();
271
272    // Connections to remove after flush (send errors).
273    let mut to_remove: Vec<u64> = Vec::new();
274
275    // Pre-allocated CQE collection buffer. Must collect CQEs before
276    // processing because the CQ borrow must end before mutating connections.
277    // Pre-sized to RING_SIZE to avoid per-iteration heap allocation.
278    let mut cqes: Vec<(u64, i32)> = Vec::with_capacity(RING_SIZE as usize);
279
280    // Pre-encode the heartbeat response frame once. Full wire frame
281    // (length prefix + tag) for direct append to send_buf.
282    let heartbeat_wire_frame = {
283        let mut buf = [0u8; 8];
284        let written =
285            control_codec::encode_transport_response(&TransportResponse::Heartbeat, &mut buf)
286                .expect("heartbeat encodes");
287        buf[..written].to_vec()
288    };
289
290    // Coarse timestamp for heartbeat scan — avoids Instant::now() on every spin.
291    let mut last_heartbeat_scan = Instant::now();
292
293    // Adaptive spin: spin first (fast wakeup), yield after threshold.
294    let mut idle_spins: u32 = 0;
295
296    let mut busy_count: u64 = 0;
297    let mut idle_count: u64 = 0;
298
299    // Paces accrual ticks inside the durability gate-wait spin so the
300    // degraded-duration counter keeps advancing during a hard stall.
301    // Declared once at function scope (not per gate entry) so the normal
302    // gated path — entered briefly whenever durability lags by a few µs —
303    // pays no extra `Instant::now()`; the amortized mask only reads the
304    // clock once per ~1 M cumulative spin iterations.
305    let mut gate_accrual_timer = AmortizedTimer::new();
306
307    loop {
308        // Observe runtime mode swaps from the admin `DURABILITY`
309        // command. Relaxed load (single writer is the admin handler,
310        // single reader is this thread). When the byte changes,
311        // rebuild the local Policy and reset the cached durable
312        // position so the next gate evaluation starts from a clean
313        // slate under the new shape; log the transition for the audit
314        // trail. An unknown byte is treated as memory corruption: we
315        // log and keep the prior mode rather than silently downgrading.
316        let observed_byte = durability_mode.load(Ordering::Relaxed);
317        if observed_byte != active_mode.as_u8() {
318            match DurabilityMode::from_u8(observed_byte) {
319                Some(next) => {
320                    tracing::info!(
321                        prev = active_mode.as_str(),
322                        next = next.as_str(),
323                        "durability mode swapped at runtime"
324                    );
325                    active_mode = next;
326                    policy = active_mode.to_policy();
327                    // The fresh policy may evaluate degraded/undegraded
328                    // differently against the same cluster shape; let
329                    // the next gate evaluation re-derive.
330                    cached_durable_pos = 0;
331                    // Re-seed the degradation logger so a transition
332                    // out of (or into) degraded under the new policy
333                    // surfaces immediately rather than waiting for the
334                    // sustained-state hold to roll over. Flushes accrual
335                    // first so pre-swap degraded time isn't dropped.
336                    degraded_logger.reseed(&utilization, Instant::now());
337                }
338                None => {
339                    tracing::error!(
340                        byte = observed_byte,
341                        "durability_mode atomic held a corrupted byte; retaining prior mode"
342                    );
343                }
344            }
345        }
346
347        if shutdown.load(Ordering::Relaxed) {
348            // Fence: a superseded ex-primary must not acknowledge any
349            // further in-flight work — skip the best-effort flush so
350            // responses buffered for orders on the old epoch are dropped
351            // (the client sees a connection reset and reconciles on
352            // reconnect). Checked only here, not per iteration: fencing
353            // always co-sets `shutdown` (`FenceState::fence_if_superseded`
354            // owns that invariant), so this branch is the first one a
355            // fenced node reaches and the steady-state loop pays nothing.
356            let flush = !fence_state.is_fenced();
357            // Best-effort flush before shutdown.
358            if flush && !dirty_connections.is_empty() {
359                flush_sends(
360                    &mut ring,
361                    &mut connections,
362                    &dirty_connections,
363                    &mut to_remove,
364                    &mut cqes,
365                );
366                dirty_connections.clear();
367            }
368            utilization.busy.store(busy_count, Ordering::Relaxed);
369            utilization.idle.store(idle_count, Ordering::Relaxed);
370            #[cfg(feature = "pipeline-stats")]
371            print_utilization("response", busy_count, idle_count);
372            return;
373        }
374
375        // Poll control channel (non-blocking) for connect/disconnect.
376        while let Ok(event) = control_rx.try_recv() {
377            match event {
378                ControlEvent::Connected {
379                    connection_id,
380                    fd,
381                    writer,
382                } => {
383                    // The writer keeps the fd alive — store it as the owner.
384                    let owner: Box<dyn Send> = Box::new(writer);
385                    connections.insert(
386                        connection_id,
387                        ConnectionEntry {
388                            fd,
389                            _owner: owner,
390                            send_buf: Vec::with_capacity(4096),
391                            last_send: Instant::now(),
392                        },
393                    );
394                }
395                ControlEvent::Disconnected { connection_id } => {
396                    connections.remove(&connection_id);
397                    dirty_connections.remove(&connection_id);
398                }
399            }
400        }
401
402        // Consume output slots from matching stage.
403        let count = consumer.consume_batch(&mut batch, MAX_BATCH);
404        if count == 0 {
405            // SPSC is empty — flush all dirty connections via io_uring.
406            // This is the response-data egress path; heartbeat flushes
407            // below aren't sampled because they're admin traffic, not
408            // on the client RTT path.
409            if !dirty_connections.is_empty() {
410                #[cfg(feature = "tick-to-trade")]
411                let egress_start = trace::mono_trace_ns();
412                flush_sends(
413                    &mut ring,
414                    &mut connections,
415                    &dirty_connections,
416                    &mut to_remove,
417                    &mut cqes,
418                );
419                #[cfg(feature = "tick-to-trade")]
420                egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
421                for conn_id in to_remove.drain(..) {
422                    connections.remove(&conn_id);
423                }
424                dirty_connections.clear();
425            }
426
427            // Send heartbeats to idle connections. Only checked during
428            // idle periods (SPSC empty) to avoid overhead on the hot path.
429            if let Some(interval) = heartbeat_interval {
430                let now = Instant::now();
431                // Coarse gate: only scan at most once per second.
432                if now.duration_since(last_heartbeat_scan) >= Duration::from_secs(1) {
433                    last_heartbeat_scan = now;
434                    for (&conn_id, entry) in connections.iter_mut() {
435                        if now.duration_since(entry.last_send) >= interval {
436                            entry.send_buf.extend_from_slice(&heartbeat_wire_frame);
437                            dirty_connections.insert(conn_id);
438                            entry.last_send = now;
439                        }
440                    }
441                    // Flush the heartbeat sends immediately.
442                    if !dirty_connections.is_empty() {
443                        flush_sends(
444                            &mut ring,
445                            &mut connections,
446                            &dirty_connections,
447                            &mut to_remove,
448                            &mut cqes,
449                        );
450                        for conn_id in to_remove.drain(..) {
451                            connections.remove(&conn_id);
452                        }
453                        dirty_connections.clear();
454                    }
455                }
456            }
457
458            // Re-evaluate the durability policy on a slow timer so the
459            // `policy_degraded` flag and the periodic warn track the
460            // cluster's real state even on idle / quiet venues. The
461            // gate-open block also calls `update_degraded_state` after
462            // each consumed batch; this is the equivalent for the
463            // no-batch path.
464            {
465                let now_ts = Instant::now();
466                if now_ts.duration_since(last_policy_check) >= POLICY_CHECK_INTERVAL {
467                    last_policy_check = now_ts;
468                    let journal_pos = journal_persisted_wire_seq.load();
469                    let metrics_ref = replication_metrics.as_deref();
470                    let active_ref = replica_active.as_ref();
471                    let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
472                    degraded_logger.tick(
473                        &policy,
474                        &utilization,
475                        status.degraded,
476                        now_ts,
477                        DEGRADED_LOG_INTERVAL,
478                    );
479                    // Cache the position so the next batch's gate sees a
480                    // fresh value rather than spinning from a stale cache.
481                    cached_durable_pos = status.durable_pos;
482                }
483            }
484
485            idle_count += 1;
486            if idle_count.is_multiple_of(1024) {
487                utilization.busy.store(busy_count, Ordering::Relaxed);
488                utilization.idle.store(idle_count, Ordering::Relaxed);
489            }
490            if busy_spin || idle_spins < 1000 {
491                idle_spins = idle_spins.wrapping_add(1);
492                std::hint::spin_loop();
493            } else {
494                std::thread::yield_now();
495            }
496            continue;
497        }
498        idle_spins = 0;
499        busy_count += 1;
500
501        #[cfg(feature = "latency-trace")]
502        let consume_ts = trace::mono_trace_ns();
503
504        // Wait for durability confirmation before sending responses.
505        //
506        // Each iteration: read the primary journal cursor + per-slot
507        // replica cursors (both in-memory and persisted), build a
508        // `CursorView`, and evaluate the configured policy. Spin until
509        // the durable position catches up to the batch's max input_seq.
510        //
511        // Per-slot journal-wait / replica-wait tracker. See
512        // `GateCrossTracker` for the rationale (only records cursors
513        // that were actually on the critical path). Both the tracker's
514        // replica cursor and the attribution counters at the bottom are
515        // derived from the policy in force rather than a fixed level,
516        // so "which subsystem to optimize" answers for the deployment
517        // the operator actually configured.
518        #[cfg(feature = "tick-to-trade")]
519        let mut gate_tracker;
520        {
521            // Gate on `wire_seq`, not `input_seq`. `input_seq` is in
522            // local-consumer space (the matching cursor on the input
523            // ring, starts at 0 in this process) while replica metrics
524            // and the primary's `journal_persisted_wire_seq` live in
525            // wire-seq space (allocated by the journal stage starting
526            // at `starting_sequence`). A `needed` derived from
527            // `input_seq` and compared against wire-seq cursors only
528            // works when `starting_sequence == 1`; a recovered primary
529            // (or any process whose journal already has prior content
530            // pushing `starting_sequence` above 1) would silently open
531            // the gate ahead of the replica's actual replicated state.
532            //
533            // Every cursor in the policy view (`journal_persisted_wire_seq`,
534            // `metrics.in_memory_sequence`, `metrics.acked_sequence`)
535            // carries "highest wire seq known to be in that state on
536            // node X". A batch's `needed` is therefore the *exact*
537            // wire seq the gate must see — not `+1` — for the latest
538            // event in the batch to be considered durable. The legacy
539            // `+1` was load-bearing only because `input_seq` was off
540            // by `starting_sequence - 1` from wire seq; with the
541            // wire-seq stamp it would over-shoot by one event and
542            // make the gate stall an extra round-trip per response.
543            let needed = batch[..count]
544                .iter()
545                .map(|s| s.wire_seq)
546                .max()
547                .expect("non-empty batch");
548            #[cfg(feature = "tick-to-trade")]
549            {
550                gate_tracker = GateCrossTracker::new(needed);
551            }
552            // Durability-gate carve-out for halt-state output. Slots
553            // tagged `durability_bypass = true` at emission carry no
554            // engine state worth replicating before delivery — see
555            // `OutputSlot::durability_bypass` for the correctness
556            // argument. When every slot in the batch carries the flag
557            // the gate is skipped entirely, so clients receive the halt
558            // reason immediately rather than blocking on a structurally
559            // unsatisfiable policy (e.g. `Hybrid` with all replicas
560            // disconnected, which would otherwise stall the gate until
561            // peers return). If even one normal slot is present, gate
562            // the whole batch as usual — the bypass slots ride along
563            // behind the gated one, which is safe (no ordering
564            // inversion vs. a strict-gate world).
565            let needs_gate = batch[..count].iter().any(|s| !s.durability_bypass);
566            if needs_gate && cached_durable_pos < needed {
567                loop {
568                    // Inside the gate-wait spin loop, also observe a
569                    // mode swap. Without this, a batch whose gate
570                    // becomes structurally unsatisfiable (e.g. all
571                    // replicas die while a non-bypass slot is in
572                    // flight under `Hybrid`) would wedge the response
573                    // stage forever, even if an operator sends the
574                    // remediating `DURABILITY local` — the outer loop
575                    // observation never gets a chance to run. The
576                    // relaxed load is ~1 cycle on x86; cheaper than
577                    // the `spin_loop` hint below.
578                    let observed_byte = durability_mode.load(Ordering::Relaxed);
579                    if observed_byte != active_mode.as_u8()
580                        && let Some(next) = DurabilityMode::from_u8(observed_byte)
581                    {
582                        tracing::info!(
583                            prev = active_mode.as_str(),
584                            next = next.as_str(),
585                            "durability mode swapped during gate wait"
586                        );
587                        active_mode = next;
588                        policy = active_mode.to_policy();
589                        // Flush accrual before re-seeding so the wedged-
590                        // degraded interval up to the swap isn't dropped.
591                        degraded_logger.reseed(&utilization, Instant::now());
592                    }
593
594                    let journal_pos = journal_persisted_wire_seq.load();
595                    let metrics_ref = replication_metrics.as_deref();
596                    let active_ref = replica_active.as_ref();
597
598                    // The cross-tracker (traced builds only) samples the
599                    // replica cursor itself rather than sharing the
600                    // evaluation's read below: computing a standalone
601                    // replica cursor unconditionally spent four Acquire
602                    // loads per spin iteration on `ReplicationMetrics`,
603                    // the same cache line the replication sender writes
604                    // on every ack and every completed SEND. Gate
605                    // attribution does not re-read at all — it comes out
606                    // of `evaluate_gate`, from the same snapshot that
607                    // opens the gate.
608                    #[cfg(feature = "tick-to-trade")]
609                    gate_tracker.observe(
610                        journal_pos.get(),
611                        // The level the *active policy* gates replicas
612                        // on — in-memory under `hybrid`, persisted under
613                        // `durably-replicated`. `None` when no clause is
614                        // replica-supplied (`local`) and, transiently,
615                        // when the binding replica drops out of the
616                        // cursor view mid-wait. Passed through as-is so
617                        // the tracker can tell "no replica wait to
618                        // measure" from "the replica caught up".
619                        policy_replica_cursor(&policy, journal_pos, metrics_ref, active_ref),
620                        trace::mono_trace_ns(),
621                    );
622
623                    let (status, blocker) =
624                        evaluate_gate(&policy, needed, journal_pos, metrics_ref, active_ref);
625                    cached_durable_pos = status.durable_pos;
626                    utilization
627                        .policy_degraded
628                        .store(status.degraded, Ordering::Relaxed);
629
630                    // Accrue degraded time while wedged. The post-gate
631                    // tick attributes the whole wait to a single state, so
632                    // without this a healthy→degraded flip during the
633                    // wedge would be mis-charged. `spinning = true`: this
634                    // loop always spins (never yields), so the clock read
635                    // behind the tick is mask-gated, landing only every
636                    // ~65 k iterations (`CHECK_MASK = 2^16`) regardless of
637                    // the period below.
638                    if gate_accrual_timer
639                        .tick(GATE_ACCRUAL_INTERVAL, true)
640                        .is_some()
641                    {
642                        degraded_logger.tick(
643                            &policy,
644                            &utilization,
645                            status.degraded,
646                            Instant::now(),
647                            DEGRADED_LOG_INTERVAL,
648                        );
649                    }
650
651                    if cached_durable_pos >= needed {
652                        // Attribution: which subsystem supplied the
653                        // binding cursor, from the same snapshot that
654                        // opened the gate and against the policy
655                        // actually in force. Relaxed is fine — health
656                        // reads are infrequent.
657                        //
658                        // `None` is unreachable here: `needed >= 1`
659                        // inside this loop, and a degraded evaluation
660                        // pins `durable_pos` to 0, so an open gate
661                        // implies the policy was satisfiable and
662                        // attribution has a verdict. The no-op arm
663                        // keeps a metrics-only path from ever
664                        // panicking regardless.
665                        match blocker {
666                            Some(Blocker::Journal) => {
667                                utilization.gate_journal.fetch_add(1, Ordering::Relaxed);
668                            }
669                            Some(Blocker::Replication) => {
670                                utilization.gate_replication.fetch_add(1, Ordering::Relaxed);
671                            }
672                            None => {}
673                        }
674                        break;
675                    }
676                    std::hint::spin_loop();
677                }
678            }
679        }
680
681        let batch_now = Instant::now();
682
683        // Log degradation transitions / re-emit the reminder. Same
684        // logger the idle path uses; transitions are gated on a
685        // sustained-state hold so sub-second flap doesn't spam.
686        let degraded_now = utilization.policy_degraded.load(Ordering::Relaxed);
687        degraded_logger.tick(
688            &policy,
689            &utilization,
690            degraded_now,
691            batch_now,
692            DEGRADED_LOG_INTERVAL,
693        );
694        // Bump the idle-path's check timestamp so we don't double-
695        // tick the logger when traffic stops.
696        last_policy_check = batch_now;
697
698        for slot in &batch[..count] {
699            #[cfg(feature = "latency-trace")]
700            spsc_rec.record_elapsed(slot.match_complete_ts, consume_ts);
701
702            // Per-slot durability-gate breakdown. Recorded only when
703            // the gate actually held us up (the tracker captured a
704            // cross). Note: the cross timestamp is for the *batch's*
705            // `needed` — for slots earlier in the batch, the cursor
706            // may have crossed their individual `input_seq+1` earlier,
707            // so this systematically overestimates wait for non-last
708            // slots by up to the batch's matching span. Acceptable
709            // noise for the operator-facing breakdown.
710            #[cfg(feature = "tick-to-trade")]
711            if let Some(ts) = gate_tracker.journal_crossed() {
712                journal_wait_rec.record_elapsed(slot.match_complete_ts, ts);
713            }
714            #[cfg(feature = "tick-to-trade")]
715            if let Some(ts) = gate_tracker.replica_crossed() {
716                replica_wait_rec.record_elapsed(slot.match_complete_ts, ts);
717            }
718
719            // Each slot expands to at most two wire frames: the
720            // application payload (Report / Query / EngineError) and
721            // an optional trailing `BatchEnd` when
722            // `is_last_in_request` is set. Application-shaped
723            // payloads go through the encoder; transport-shaped
724            // frames (EngineError, BatchEnd) are encoded by the
725            // runtime directly.
726            if let Some(entry) = connections.get_mut(&slot.connection_id) {
727                // Frame 1: application payload (if any). BatchEnd
728                // payloads carry no body — the terminator below
729                // handles them via `is_last_in_request`.
730                let payload_result: Option<Result<usize, &'static str>> = match slot.payload {
731                    OutputPayload::Report(ref report) => {
732                        Some(encoder.encode_report(report, &mut encode_buf))
733                    }
734                    OutputPayload::QueryResponse(ref q) => {
735                        Some(encoder.encode_query(q, &mut encode_buf))
736                    }
737                    OutputPayload::EngineError => Some(
738                        control_codec::encode_transport_response(
739                            &TransportResponse::EngineError,
740                            &mut encode_buf,
741                        )
742                        .map_err(|_| "encode error"),
743                    ),
744                    OutputPayload::BatchEnd => None,
745                };
746
747                let payload_handled = if let Some(result) = payload_result {
748                    #[cfg(feature = "tick-to-trade")]
749                    let encode_start = trace::mono_trace_ns();
750                    let outcome = append_frame(
751                        result,
752                        slot.connection_id,
753                        entry,
754                        &encode_buf,
755                        batch_now,
756                        &mut dirty_connections,
757                        &mut to_remove,
758                    );
759                    #[cfg(feature = "tick-to-trade")]
760                    encode_rec.record_elapsed(encode_start, trace::mono_trace_ns());
761                    outcome
762                } else {
763                    AppendOutcome::Continue
764                };
765
766                // Frame 2: BatchEnd terminator. Skipped if the payload
767                // append dropped the connection.
768                if matches!(payload_handled, AppendOutcome::Continue) && slot.is_last_in_request {
769                    let result = control_codec::encode_transport_response(
770                        &TransportResponse::BatchEnd,
771                        &mut encode_buf,
772                    )
773                    .map_err(|_| "encode error");
774                    let outcome = append_frame(
775                        result,
776                        slot.connection_id,
777                        entry,
778                        &encode_buf,
779                        batch_now,
780                        &mut dirty_connections,
781                        &mut to_remove,
782                    );
783                    // Record server-side end-to-end: reader recv ->
784                    // response flush. Only the BatchEnd frame carries
785                    // this measurement; tracked here after the append
786                    // so a dropped connection doesn't skew the metric.
787                    #[cfg(feature = "latency-trace")]
788                    if matches!(outcome, AppendOutcome::Continue) {
789                        server_e2e_rec.record_elapsed(slot.recv_ts, trace::mono_trace_ns());
790                    }
791                    let _ = outcome;
792                }
793            }
794        }
795
796        // Remove connections that exceeded the send buffer limit.
797        for conn_id in to_remove.drain(..) {
798            connections.remove(&conn_id);
799            dirty_connections.remove(&conn_id);
800        }
801
802        #[cfg(feature = "latency-trace")]
803        dispatch_rec.record_elapsed(consume_ts, trace::mono_trace_ns());
804    }
805}
806
807/// Submit io_uring SEND SQEs for all dirty connections and wait for completions.
808///
809/// Outcome of a single per-frame append. `Continue` means the
810/// caller may proceed to the next frame for this slot;
811/// `ConnectionDropped` means the connection's send buffer overflowed
812/// or the encode failed, and the connection has been queued for
813/// removal — no further frames should be appended for this slot.
814#[derive(Clone, Copy)]
815enum AppendOutcome {
816    Continue,
817    ConnectionDropped,
818}
819
820/// Copy an encoded frame into the connection's send buffer with
821/// overflow checking. Splits the responsibilities the inline encode
822/// loop used to have: the caller passes in the encode result (so it
823/// can come from the `ResponseEncoder` trait for application
824/// payloads, or `encode_transport_response` for transport-shaped
825/// frames), and this helper handles size accounting + dirty
826/// tracking uniformly.
827#[allow(clippy::too_many_arguments)]
828fn append_frame(
829    result: Result<usize, &'static str>,
830    connection_id: u64,
831    entry: &mut ConnectionEntry,
832    encode_buf: &[u8],
833    batch_now: Instant,
834    dirty_connections: &mut HashSet<u64>,
835    to_remove: &mut Vec<u64>,
836) -> AppendOutcome {
837    let written = match result {
838        Ok(n) => n,
839        Err(reason) => {
840            tracing::error!(connection_id, reason, "encode error");
841            return AppendOutcome::Continue;
842        }
843    };
844
845    // Drop slow clients whose send buffer has grown too large. This
846    // prevents unbounded memory growth from a single laggy connection
847    // causing allocator pressure and tail latency spikes.
848    if entry.send_buf.len() + written > MAX_SEND_BUF {
849        debug!(
850            connection_id,
851            send_buf_len = entry.send_buf.len(),
852            "send buffer exceeded limit, dropping connection"
853        );
854        to_remove.push(connection_id);
855        return AppendOutcome::ConnectionDropped;
856    }
857
858    // Append the full wire frame to the connection's send buffer.
859    // The encoder writes [length(4) | payload], which is the complete
860    // wire format — no extra framing needed.
861    entry.send_buf.extend_from_slice(&encode_buf[..written]);
862    entry.last_send = batch_now;
863    dirty_connections.insert(connection_id);
864    AppendOutcome::Continue
865}
866
867/// Each dirty connection's accumulated send buffer is sent in a single SEND
868/// operation. Partial sends are retried until all bytes are delivered.
869/// Failed connections are collected in `to_remove` for the caller to clean up.
870fn flush_sends(
871    ring: &mut IoUring,
872    connections: &mut HashMap<u64, ConnectionEntry>,
873    dirty: &HashSet<u64>,
874    to_remove: &mut Vec<u64>,
875    cqes: &mut Vec<(u64, i32)>,
876) {
877    // Submit SEND SQEs for all dirty connections.
878    let mut pending: usize = 0;
879    for &conn_id in dirty {
880        if let Some(entry) = connections.get(&conn_id) {
881            if entry.send_buf.is_empty() {
882                continue;
883            }
884            let sqe = opcode::Send::new(
885                types::Fd(entry.fd),
886                entry.send_buf.as_ptr(),
887                entry.send_buf.len() as u32,
888            )
889            .build()
890            .user_data(conn_id);
891
892            unsafe {
893                ring.submission()
894                    .push(&sqe)
895                    .expect("io_uring SQ full — increase RING_SIZE");
896            }
897            pending += 1;
898        }
899    }
900
901    if pending == 0 {
902        return;
903    }
904
905    // Submit and wait for all completions.
906    if let Err(e) = ring.submit_and_wait(pending) {
907        error!(error = %e, "io_uring submit_and_wait failed in response stage");
908        return;
909    }
910
911    // Drain completions into pre-allocated buffer. Must collect to
912    // release CQ borrow before mutating connections.
913    cqes.clear();
914    cqes.extend(ring.completion().map(|cqe| (cqe.user_data(), cqe.result())));
915
916    for &(conn_id, result) in cqes.iter() {
917        if result < 0 {
918            debug!(
919                connection_id = conn_id,
920                error = result,
921                "send error, dropping connection"
922            );
923            to_remove.push(conn_id);
924            continue;
925        }
926
927        let sent = result as usize;
928        if let Some(entry) = connections.get_mut(&conn_id) {
929            if sent >= entry.send_buf.len() {
930                entry.send_buf.clear();
931            } else {
932                // Partial send — drain sent bytes, retry remainder.
933                // Rare for small response frames over TCP/UDS but must
934                // be handled for correctness (e.g., send buffer pressure).
935                entry.send_buf.drain(..sent);
936                retry_send(ring, entry, conn_id, to_remove);
937            }
938        }
939    }
940}
941
942/// Retry sending remaining bytes after a partial send. Loops until the
943/// entire buffer is delivered or an error occurs.
944fn retry_send(
945    ring: &mut IoUring,
946    entry: &mut ConnectionEntry,
947    conn_id: u64,
948    to_remove: &mut Vec<u64>,
949) {
950    while !entry.send_buf.is_empty() {
951        let sqe = opcode::Send::new(
952            types::Fd(entry.fd),
953            entry.send_buf.as_ptr(),
954            entry.send_buf.len() as u32,
955        )
956        .build()
957        .user_data(conn_id);
958
959        unsafe {
960            ring.submission()
961                .push(&sqe)
962                .expect("io_uring SQ full during send retry");
963        }
964
965        if let Err(e) = ring.submit_and_wait(1) {
966            debug!(connection_id = conn_id, error = %e, "send retry failed");
967            to_remove.push(conn_id);
968            return;
969        }
970
971        if let Some(cqe) = ring.completion().next() {
972            let result = cqe.result();
973            if result <= 0 {
974                debug!(
975                    connection_id = conn_id,
976                    error = result,
977                    "send retry error, dropping connection"
978                );
979                to_remove.push(conn_id);
980                return;
981            }
982            let sent = result as usize;
983            if sent >= entry.send_buf.len() {
984                entry.send_buf.clear();
985            } else {
986                entry.send_buf.drain(..sent);
987            }
988        }
989    }
990}
991
992/// Read the live gate cursors and lend them to `f` as a [`CursorView`].
993///
994/// The view contains the primary followed by every *currently connected*
995/// replica slot. Disconnected slots are omitted rather than entered with
996/// zero cursors, so `CursorView::len` reflects how many nodes are
997/// actually available to satisfy a clause — too few and the policy
998/// reports degraded and the gate stalls. Node 0 is always the primary;
999/// [`Policy::attribute_blocker`] relies on that index convention to tell
1000/// journal from replication.
1001///
1002/// The primary's in-memory cursor is modeled as `u64::MAX` because the
1003/// response stage only gates events the matching engine has already
1004/// processed — those are trivially in-memory on the primary.
1005///
1006/// Scoped-borrow shape rather than a returned view: `CursorView` holds a
1007/// slice, so it cannot outlive the array behind it. Lending it to a
1008/// closure keeps that array on this frame and off every caller's
1009/// signature.
1010#[inline]
1011fn with_cursor_view<R>(
1012    journal_pos: WireSeq,
1013    metrics: Option<&ReplicationMetrics>,
1014    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1015    f: impl FnOnce(&CursorView<'_>) -> R,
1016) -> R {
1017    // Fixed-size array rather than a `Vec`: the cluster is capped at
1018    // MAX_CLUSTER_SIZE and this runs on the gate path, so a heap
1019    // allocation would be pure overhead. Raw `u64` in wire-seq space —
1020    // `journal_pos` leaves the type system here, alongside the replica
1021    // metrics gauges (the `Ack` frame's wire-seq fields verbatim).
1022    let mut nodes = [[0u64; 2]; MAX_CLUSTER_SIZE as usize];
1023    nodes[0] = [u64::MAX, journal_pos.get()];
1024    let mut len = 1;
1025    if let (Some(m), Some(active)) = (metrics, replica_active) {
1026        for (i, slot_active) in active.iter().enumerate() {
1027            // Skip inactive slots up-front.
1028            if !slot_active.load(Ordering::Acquire) {
1029                continue;
1030            }
1031            let in_mem = m.in_memory_sequence[i].load(Ordering::Acquire);
1032            let persisted = m.acked_sequence[i].load(Ordering::Acquire);
1033            nodes[len] = [in_mem, persisted];
1034            len += 1;
1035        }
1036    }
1037    f(&CursorView::new(&nodes[..len]))
1038}
1039
1040/// Highest sequence at which the policy is satisfied by the live
1041/// cursors, plus whether the cluster shape can satisfy it at all.
1042#[inline]
1043pub(crate) fn evaluate_durability(
1044    policy: &Policy,
1045    journal_pos: WireSeq,
1046    metrics: Option<&ReplicationMetrics>,
1047    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1048) -> EvalStatus {
1049    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1050        policy.evaluate_with_status(view)
1051    })
1052}
1053
1054/// One-snapshot gate evaluation: the policy's durable position plus,
1055/// when that position opens the gate (`>= needed`), which subsystem
1056/// supplied the binding cursor.
1057///
1058/// Both answers come from the *same* [`CursorView`]. Re-reading the
1059/// replica cursors for attribution after the evaluation that opened the
1060/// gate would rank a later snapshot — the replica side can advance
1061/// between the two reads and flip the verdict to a subsystem that was
1062/// not the one the gate actually opened on.
1063///
1064/// The blocker is `None` while the gate stays closed (attribution is
1065/// only read at the open, and ranking the clauses for it on every spin
1066/// iteration would be wasted work). At an open with `needed >= 1` it is
1067/// always `Some`: a policy left unsatisfiable by the cluster shape pins
1068/// `durable_pos` to 0, which cannot reach `needed`.
1069///
1070/// The attribution itself replaces the old "compare the journal cursor
1071/// against the minimum replica *persisted* cursor" heuristic, which
1072/// reported replication as the blocker under `local` — where replicas
1073/// cannot bind the gate at all — and read the persisted level under
1074/// `hybrid`, which gates on in-memory.
1075#[inline]
1076pub(crate) fn evaluate_gate(
1077    policy: &Policy,
1078    needed: u64,
1079    journal_pos: WireSeq,
1080    metrics: Option<&ReplicationMetrics>,
1081    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1082) -> (EvalStatus, Option<Blocker>) {
1083    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1084        let status = policy.evaluate_with_status(view);
1085        let blocker = if status.durable_pos >= needed {
1086            policy.attribute_blocker(view)
1087        } else {
1088            None
1089        };
1090        (status, blocker)
1091    })
1092}
1093
1094/// The replica-side cursor the active policy is waiting on, for the
1095/// `tick-to-trade` replica-wait histogram. `None` when no clause is
1096/// replica-supplied (e.g. `local`).
1097#[cfg(feature = "tick-to-trade")]
1098#[inline]
1099pub(crate) fn policy_replica_cursor(
1100    policy: &Policy,
1101    journal_pos: WireSeq,
1102    metrics: Option<&ReplicationMetrics>,
1103    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1104) -> Option<u64> {
1105    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1106        policy.replica_gate_cursor(view)
1107    })
1108}
1109
1110/// Hold-time before a state transition is committed to the log.
1111/// Suppresses log spam when a replica flaps faster than this — only
1112/// transitions that hold for at least this long emit warn/info
1113/// entries. The `/healthz` gauge updates immediately regardless,
1114/// so dashboards and alerts still see real-time state.
1115const DEGRADED_FLAP_HOLD: Duration = Duration::from_secs(1);
1116
1117/// Tracks degradation state across calls and emits warn/info logs
1118/// with sustained-state gating + a periodic heartbeat re-emit.
1119///
1120/// The hot path calls [`Self::tick`] every gate iteration / idle
1121/// poll with the current `degraded` value and the wall clock. The
1122/// logger handles:
1123///
1124/// - Updating the `policy_degraded` health gauge immediately.
1125/// - Suppressing log lines for transitions that don't hold for at
1126///   least [`DEGRADED_FLAP_HOLD`] — a replica flapping at sub-second
1127///   cadence produces no log noise, only a quietly-updating gauge.
1128/// - Emitting a warn at the moment a sustained degraded state
1129///   crosses the hold threshold, plus a periodic re-emit every
1130///   `heartbeat_interval` while it persists.
1131/// - Emitting an info when a sustained healthy state crosses the
1132///   hold threshold (the cluster is back to its target shape and
1133///   stayed there long enough that we trust the recovery).
1134pub(crate) struct DegradationLogger {
1135    /// Last value passed to `tick`; what we'd log about if it stayed
1136    /// at this value past the hold threshold.
1137    pending_state: bool,
1138    /// When `pending_state` first appeared. Reset on every flip.
1139    pending_since: Instant,
1140    /// Whether the current pending state has been logged yet. Only
1141    /// the *first* log per sustained streak crosses; subsequent
1142    /// re-emits while degraded are heartbeat warns.
1143    pending_logged: bool,
1144    /// When the last warn fired. Drives the periodic re-emit while
1145    /// degraded.
1146    last_log: Option<Instant>,
1147    /// Wall clock of the previous accrual. The interval since this
1148    /// instant is attributed to the state observed then (`pending_state`)
1149    /// and accumulated into the `policy_degraded_nanos` counter. Advanced
1150    /// by [`Self::accrue`] — reached on every `tick` (post-gate, idle, and
1151    /// the gate-wait pacing tick) and on `reseed`.
1152    last_tick: Instant,
1153}
1154
1155impl DegradationLogger {
1156    pub(crate) fn new(now: Instant) -> Self {
1157        Self {
1158            pending_state: false,
1159            pending_since: now,
1160            pending_logged: true, // healthy is the assumed initial state; nothing to log
1161            last_log: None,
1162            last_tick: now,
1163        }
1164    }
1165
1166    /// Use when the policy is known to start in a degraded state
1167    /// (e.g. a primary in `hybrid` mode with no replica yet
1168    /// connected). Logs a startup warn immediately and treats the
1169    /// state as already-logged so the next tick doesn't re-emit.
1170    pub(crate) fn new_starting_degraded(now: Instant, policy: &Policy) -> Self {
1171        tracing::warn!(
1172            policy = %policy,
1173            "durability policy starts in degraded mode — fewer connected nodes than the target count"
1174        );
1175        Self {
1176            pending_state: true,
1177            pending_since: now,
1178            pending_logged: true,
1179            last_log: Some(now),
1180            last_tick: now,
1181        }
1182    }
1183
1184    /// Charge the interval `[last_tick, now]` to the degraded-duration
1185    /// counter when `prev_degraded` (the state observed at the previous
1186    /// tick) was degraded, then advance `last_tick`. Drives the
1187    /// `_seconds_total` counter so `rate()` reflects time-in-degraded
1188    /// continuously, even mid-incident, rather than only stepping on
1189    /// recovery. Shared by [`Self::tick`] and [`Self::reseed`] so accrual
1190    /// is never skipped on a logger lifecycle boundary.
1191    #[inline]
1192    fn accrue(&mut self, utilization: &StageUtilization, prev_degraded: bool, now: Instant) {
1193        if prev_degraded {
1194            // `duration_since` saturates to zero when `now < last_tick`;
1195            // `now` is always >= `last_tick` here (single-thread monotonic
1196            // clock), matching the plain `duration_since` used elsewhere
1197            // in this file.
1198            let elapsed = now.duration_since(self.last_tick);
1199            // `as_nanos()` is u128; one inter-tick interval is sub-second
1200            // to a few seconds, far below the u64 nanos ceiling (~584
1201            // years), so the cast can't truncate. See the field doc on
1202            // `StageUtilization::policy_degraded_nanos`.
1203            utilization
1204                .policy_degraded_nanos
1205                .fetch_add(elapsed.as_nanos() as u64, Ordering::Relaxed);
1206        }
1207        self.last_tick = now;
1208    }
1209
1210    /// Flush in-flight degraded accrual up to `now`, then reset to a
1211    /// fresh healthy-start logger. Called on a runtime durability-mode
1212    /// swap, which is a logger lifecycle boundary: without flushing
1213    /// first, the pre-swap degraded interval — on the gate-wait path the
1214    /// entire wedge — would be silently dropped. The new policy's actual
1215    /// degraded state is re-derived by the next `tick`, so the brief
1216    /// swap-to-next-tick window is attributed healthy; that residual is
1217    /// bounded by one tick interval, the counter's inherent granularity.
1218    pub(crate) fn reseed(&mut self, utilization: &StageUtilization, now: Instant) {
1219        let prev_state = self.pending_state;
1220        self.accrue(utilization, prev_state, now);
1221        *self = Self::new(now);
1222    }
1223
1224    /// Update the gauge + emit transition/heartbeat logs as needed.
1225    /// Cheap on the hot path: one atomic store, a few branches, one
1226    /// `Instant::duration_since`.
1227    pub(crate) fn tick(
1228        &mut self,
1229        policy: &Policy,
1230        utilization: &StageUtilization,
1231        degraded_now: bool,
1232        now: Instant,
1233        heartbeat_interval: Duration,
1234    ) {
1235        // Charge the just-elapsed interval to the state observed at the
1236        // *previous* tick. Snapshot it up front, before the flap-hold
1237        // bookkeeping below mutates `pending_state`, so accrual stays
1238        // correct even if that block is later reordered.
1239        let prev_state = self.pending_state;
1240        self.accrue(utilization, prev_state, now);
1241
1242        utilization
1243            .policy_degraded
1244            .store(degraded_now, Ordering::Relaxed);
1245
1246        if degraded_now != self.pending_state {
1247            // State changed — start a new hold window. Don't log
1248            // until / unless this new state stays long enough.
1249            self.pending_state = degraded_now;
1250            self.pending_since = now;
1251            self.pending_logged = false;
1252            return;
1253        }
1254
1255        // State held. If we haven't yet logged this streak's onset,
1256        // and it's been pending for at least the flap-hold time,
1257        // emit the transition message and mark logged.
1258        if !self.pending_logged && now.duration_since(self.pending_since) >= DEGRADED_FLAP_HOLD {
1259            if degraded_now {
1260                tracing::warn!(
1261                    policy = %policy,
1262                    "durability policy operating in degraded mode — fewer connected nodes than the target count, response gate stalled until the cluster recovers or the mode is swapped"
1263                );
1264            } else {
1265                tracing::info!(
1266                    policy = %policy,
1267                    "durability policy returned to target shape"
1268                );
1269            }
1270            self.pending_logged = true;
1271            self.last_log = Some(now);
1272            return;
1273        }
1274
1275        // Heartbeat re-emit while a degraded state persists.
1276        if degraded_now
1277            && self.pending_logged
1278            && self
1279                .last_log
1280                .is_none_or(|t| now.duration_since(t) >= heartbeat_interval)
1281        {
1282            tracing::warn!(
1283                policy = %policy,
1284                "durability policy still degraded — fewer connected nodes than the target count"
1285            );
1286            self.last_log = Some(now);
1287        }
1288    }
1289}
1290
1291/// Tracks per-cursor "first observed transition from below to >= needed"
1292/// inside the durability gate loop, to drive the journal-wait /
1293/// replica-wait histograms in the bench's tick-to-trade decomposition.
1294///
1295/// A sample is recorded only for cursors that were strictly below
1296/// `needed` at the loop's first observation. Cursors already past at
1297/// entry were not on the critical path for this batch, so attributing
1298/// "wait time" to them would inflate the metric with cursor-poll
1299/// observation timestamps that have nothing to do with how long the
1300/// stage actually held us up.
1301///
1302/// `now_ns` is taken as a parameter rather than read internally so
1303/// tests can supply deterministic timestamps. The caller's hot path
1304/// reads `trace::mono_trace_ns()` once per gate iteration and feeds it in.
1305#[cfg(feature = "tick-to-trade")]
1306pub(crate) struct GateCrossTracker {
1307    needed: u64,
1308    journal_crossed_ts: Option<trace::MonoTraceInstant>,
1309    replica_crossed_ts: Option<trace::MonoTraceInstant>,
1310    journal_was_below: bool,
1311    replica_was_below: bool,
1312    first: bool,
1313}
1314
1315#[cfg(feature = "tick-to-trade")]
1316impl GateCrossTracker {
1317    pub(crate) fn new(needed: u64) -> Self {
1318        Self {
1319            needed,
1320            journal_crossed_ts: None,
1321            replica_crossed_ts: None,
1322            journal_was_below: false,
1323            replica_was_below: false,
1324            first: true,
1325        }
1326    }
1327
1328    /// `replica_pos` is the replica-side cursor the *active policy*
1329    /// gates on — see [`policy_replica_cursor`]. `None` means no replica
1330    /// currently supplies a binding cursor, which happens permanently
1331    /// under `local` and transiently when the binding replica drops out
1332    /// of the cursor view part-way through a wait.
1333    ///
1334    /// Both cases must leave `replica_crossed_ts` alone. Treating
1335    /// `None` as an infinite cursor would satisfy the crossing test and
1336    /// latch the timestamp at the *disconnect*, reporting a replica
1337    /// wait that ended when the link died rather than when the replica
1338    /// caught up — understating exactly the failover runs the histogram
1339    /// exists to measure. On the first observe it additionally leaves
1340    /// `replica_was_below` false, so a policy that never waits on a
1341    /// replica records no sample at all.
1342    pub(crate) fn observe(
1343        &mut self,
1344        journal_pos: u64,
1345        replica_pos: Option<u64>,
1346        now_ns: trace::MonoTraceInstant,
1347    ) {
1348        if self.first {
1349            self.journal_was_below = journal_pos < self.needed;
1350            self.replica_was_below = replica_pos.is_some_and(|p| p < self.needed);
1351            self.first = false;
1352        }
1353        if self.journal_was_below && self.journal_crossed_ts.is_none() && journal_pos >= self.needed
1354        {
1355            self.journal_crossed_ts = Some(now_ns);
1356        }
1357        if self.replica_was_below
1358            && self.replica_crossed_ts.is_none()
1359            && replica_pos.is_some_and(|p| p >= self.needed)
1360        {
1361            self.replica_crossed_ts = Some(now_ns);
1362        }
1363    }
1364
1365    pub(crate) fn journal_crossed(&self) -> Option<trace::MonoTraceInstant> {
1366        self.journal_crossed_ts
1367    }
1368
1369    pub(crate) fn replica_crossed(&self) -> Option<trace::MonoTraceInstant> {
1370        self.replica_crossed_ts
1371    }
1372}
1373
1374/// Print busy/idle utilization for a pipeline stage on shutdown.
1375#[cfg(feature = "pipeline-stats")]
1376fn print_utilization(stage: &str, busy: u64, idle: u64) {
1377    let total = busy + idle;
1378    if total == 0 {
1379        tracing::info!(stage, "no iterations recorded");
1380        return;
1381    }
1382    let pct = (busy as f64 / total as f64) * 100.0;
1383    tracing::info!(
1384        stage,
1385        pct_busy = format_args!("{pct:.2}%"),
1386        busy,
1387        idle,
1388        total,
1389        "pipeline utilization",
1390    );
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395    #[cfg(feature = "tick-to-trade")]
1396    use super::GateCrossTracker;
1397    use super::{Blocker, DegradationLogger, WireSeq, evaluate_durability, evaluate_gate};
1398    use crate::durability_policy::{Clause, Level, Policy};
1399    use crate::replication::ReplicationMetrics;
1400
1401    /// Build a [`Policy`] from a mini DSL: one or more
1402    /// `"<level>>=<count>"` clauses joined with `&&`. Test-only
1403    /// ergonomics — production builds policies via
1404    /// [`DurabilityMode::to_policy`].
1405    fn parse(s: &str) -> Result<Policy, String> {
1406        let mut clauses = Vec::new();
1407        for raw in s.split("&&") {
1408            let token = raw.trim();
1409            let (lvl, rhs) = token
1410                .split_once(">=")
1411                .ok_or_else(|| format!("clause `{token}` missing `>=`"))?;
1412            let level = match lvl.trim() {
1413                "persisted" => Level::Persisted,
1414                "in_memory" => Level::InMemory,
1415                other => return Err(format!("unknown level `{other}`")),
1416            };
1417            let count: u8 = rhs.trim().parse().map_err(|e| format!("bad count: {e}"))?;
1418            clauses.push(Clause { count, level });
1419        }
1420        Policy::new(clauses).map_err(|e| e.to_string())
1421    }
1422    use melin_transport_core::pipeline::StageUtilization;
1423    use std::sync::Arc;
1424    use std::sync::atomic::{AtomicBool, Ordering};
1425    use std::time::{Duration, Instant};
1426
1427    /// Build a `ReplicationMetrics` with both slots populated. Tests
1428    /// that need to simulate a disconnected slot use [`flags`] to mark
1429    /// it inactive — its cursors are then ignored regardless of value.
1430    fn metrics(slot0: (u64, u64), slot1: (u64, u64)) -> Arc<ReplicationMetrics> {
1431        let m = Arc::new(ReplicationMetrics::default());
1432        m.in_memory_sequence[0].store(slot0.0, Ordering::Relaxed);
1433        m.acked_sequence[0].store(slot0.1, Ordering::Relaxed);
1434        m.in_memory_sequence[1].store(slot1.0, Ordering::Relaxed);
1435        m.acked_sequence[1].store(slot1.1, Ordering::Relaxed);
1436        m
1437    }
1438
1439    /// Build a `[active; 2]` flags array.
1440    fn flags(slot0_active: bool, slot1_active: bool) -> [Arc<AtomicBool>; 2] {
1441        [
1442            Arc::new(AtomicBool::new(slot0_active)),
1443            Arc::new(AtomicBool::new(slot1_active)),
1444        ]
1445    }
1446
1447    /// Both replicas active — the common healthy-cluster case.
1448    fn both_active() -> [Arc<AtomicBool>; 2] {
1449        flags(true, true)
1450    }
1451
1452    // --- Standalone (no replicas wired) ---
1453
1454    #[test]
1455    fn standalone_persisted_one_gates_on_journal() {
1456        // No metrics → only the primary is in the view. `persisted>=1`
1457        // is satisfied by the primary alone at journal_pos.
1458        let p = parse("persisted>=1").unwrap();
1459        assert_eq!(
1460            evaluate_durability(&p, WireSeq::new(500), None, None).durable_pos,
1461            500
1462        );
1463    }
1464
1465    #[test]
1466    fn standalone_strict_persisted_two_never_opens() {
1467        // `persisted>=2` on a standalone primary stays at 0: the
1468        // operator asked for two copies and there is only one. The
1469        // policy surfaces as degraded so the operator sees the gate
1470        // is stalled because the cluster can't meet the policy.
1471        let p = parse("persisted>=2").unwrap();
1472        let r = evaluate_durability(&p, WireSeq::new(500), None, None);
1473        assert_eq!(r.durable_pos, 0);
1474        assert!(
1475            r.degraded,
1476            "policy structurally unsatisfiable on this shape → degraded",
1477        );
1478    }
1479
1480    // --- 2 replicas connected ---
1481
1482    #[test]
1483    fn quorum_both_replicas_ahead_of_journal() {
1484        // Both replicas persisted past journal. `persisted>=2` returns
1485        // the 2nd-largest persisted across {primary, slot0, slot1}.
1486        let p = parse("persisted>=2").unwrap();
1487        let m = metrics((100, 100), (120, 120));
1488        let a = both_active();
1489        assert_eq!(
1490            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
1491            100
1492        );
1493    }
1494
1495    #[test]
1496    fn quorum_journal_ahead_of_both_replicas() {
1497        // Journal at 500, replicas at 100/120. 2nd-largest persisted = 120.
1498        let p = parse("persisted>=2").unwrap();
1499        let m = metrics((100, 100), (120, 120));
1500        let a = both_active();
1501        assert_eq!(
1502            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
1503            120
1504        );
1505    }
1506
1507    #[test]
1508    fn quorum_journal_between_slow_and_fast_replica() {
1509        // {primary=150, slot0_persisted=50, slot1_persisted=200}.
1510        // 2nd-largest = 150 (primary itself).
1511        let p = parse("persisted>=2").unwrap();
1512        let m = metrics((50, 50), (200, 200));
1513        let a = both_active();
1514        assert_eq!(
1515            evaluate_durability(&p, WireSeq::new(150), Some(&m), Some(&a)).durable_pos,
1516            150
1517        );
1518    }
1519
1520    // --- Single replica connected ---
1521
1522    #[test]
1523    fn single_replica_strict_persisted_two_requires_both_survivors() {
1524        // Slot 0 connected, slot 1 disconnected. View = {primary, slot0}.
1525        // Strict `persisted>=2`: 2nd-largest of the 2-row view =
1526        // min(primary, slot0). Strictly stronger than legacy auto-
1527        // degrade-to-1-node in the same shape.
1528        let p = parse("persisted>=2").unwrap();
1529        let m = metrics((100, 100), (999, 999)); // slot 1 cursors ignored
1530        let a = flags(true, false);
1531        assert_eq!(
1532            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
1533            50
1534        );
1535        assert_eq!(
1536            evaluate_durability(&p, WireSeq::new(200), Some(&m), Some(&a)).durable_pos,
1537            100
1538        );
1539    }
1540
1541    #[test]
1542    fn single_replica_persisted_two_requires_both_survivors() {
1543        // 2-node view (primary + surviving replica). `persisted>=2` is
1544        // satisfiable; the gate opens at the slower of the two and the
1545        // policy is not degraded.
1546        let p = parse("persisted>=2").unwrap();
1547        let m = metrics((100, 100), (999, 999));
1548        let a = flags(true, false);
1549        let r = evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a));
1550        assert_eq!(r.durable_pos, 50);
1551        assert!(!r.degraded);
1552    }
1553
1554    #[test]
1555    fn both_replicas_disconnected_strict_stalls() {
1556        // View has only the primary. Strict `persisted>=2` cannot be
1557        // satisfied — operator opted out of degrade.
1558        let p = parse("persisted>=2").unwrap();
1559        let m = metrics((999, 999), (999, 999));
1560        let a = flags(false, false);
1561        assert_eq!(
1562            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
1563            0
1564        );
1565    }
1566
1567    #[test]
1568    fn both_replicas_disconnected_strict_stalls_and_flags_degraded() {
1569        // With `persisted>=2` and both replicas down, the cursor view
1570        // collapses to {primary}: the clause's count (=2) exceeds the
1571        // view size, so the gate stays at 0 and the policy flags
1572        // degraded. Note the matching stage's separate halt at
1573        // `replicas_connected==0` rejects new orders before they reach
1574        // the gate; this verifies the gate semantics in isolation.
1575        let p = parse("persisted>=2").unwrap();
1576        let m = metrics((999, 999), (999, 999));
1577        let a = flags(false, false);
1578        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
1579        assert_eq!(r.durable_pos, 0);
1580        assert!(r.degraded);
1581    }
1582
1583    // --- Mixed-level policies ---
1584
1585    #[test]
1586    fn persisted_one_and_in_memory_two() {
1587        // "Leader persists, plus one other node has it in memory" —
1588        // the cheap-but-non-zero durability target. Slot 0 has it in
1589        // memory, slot 1 disconnected.
1590        let p = parse("persisted>=1 && in_memory>=2").unwrap();
1591        // primary persisted=50, slot0 in_mem=80 / persisted=20.
1592        // persisted>=1: max(50, 20, 0) = 50.
1593        // in_memory>=2: primary in_mem=u64::MAX (always), slot0_eff=max(80, 20)=80,
1594        //               slot1=0. 2nd-largest = 80.
1595        // min(50, 80) = 50.
1596        let m = metrics((80, 20), (999, 999));
1597        let a = flags(true, false);
1598        assert_eq!(
1599            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
1600            50
1601        );
1602    }
1603
1604    // --- Edge: journal at 0 ---
1605
1606    #[test]
1607    fn journal_at_zero_with_replicas_persisted_one() {
1608        // Journal hasn't fsynced anything; both replicas have. With
1609        // `persisted>=1` the gate opens at the fastest replica.
1610        let p = parse("persisted>=1").unwrap();
1611        let m = metrics((100, 100), (200, 200));
1612        let a = both_active();
1613        assert_eq!(
1614            evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a)).durable_pos,
1615            200
1616        );
1617    }
1618
1619    // --- gate-blocker attribution ---
1620    //
1621    // The attribution must follow the policy actually in force. The
1622    // previous heuristic compared the journal cursor against the
1623    // minimum replica *persisted* cursor unconditionally, which
1624    // reported replication as the blocker under `local` (where
1625    // replicas cannot bind the gate) and read the wrong level under
1626    // `hybrid` (which gates replicas on in-memory).
1627    //
1628    // Attribution comes out of `evaluate_gate` alongside the durable
1629    // position, computed from the same cursor snapshot, and is `None`
1630    // while the gate stays closed — so each test passes a `needed` at
1631    // or below the scenario's durable position to model the iteration
1632    // on which the gate opens.
1633
1634    #[test]
1635    fn local_mode_never_blames_replication() {
1636        // `persisted>=1` is satisfied by the highest persisted cursor
1637        // in the cluster — the primary's. A connected replica lagging
1638        // far behind is irrelevant to the gate, so it must not be
1639        // credited as the blocker. This is the case the old heuristic
1640        // got flatly wrong: journal_pos (500) > repl_min (10) sent it
1641        // down the `else` branch every single time.
1642        let p = parse("persisted>=1").unwrap();
1643        let m = metrics((10, 10), (10, 10));
1644        let a = both_active();
1645        assert_eq!(
1646            evaluate_gate(&p, 500, WireSeq::new(500), Some(&m), Some(&a)).1,
1647            Some(Blocker::Journal)
1648        );
1649    }
1650
1651    #[test]
1652    fn hybrid_reads_replica_in_memory_not_persisted() {
1653        // `persisted>=1 && in_memory>=2`. The replica has the event in
1654        // memory (400) well ahead of its own fsync (100), and the
1655        // primary's journal is behind that in-memory cursor (300).
1656        // The binding clause is therefore the primary's persisted
1657        // cursor → journal. Comparing against the replica's *persisted*
1658        // cursor (100) would have said replication.
1659        let p = parse("persisted>=1 && in_memory>=2").unwrap();
1660        let m = metrics((400, 100), (0, 0));
1661        let a = flags(true, false);
1662        assert_eq!(
1663            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
1664            Some(Blocker::Journal)
1665        );
1666
1667        // Same policy, replica in-memory now the laggard → replication.
1668        // The gate opens at the replica's in-memory cursor (200).
1669        let m = metrics((200, 100), (0, 0));
1670        assert_eq!(
1671            evaluate_gate(&p, 200, WireSeq::new(300), Some(&m), Some(&a)).1,
1672            Some(Blocker::Replication)
1673        );
1674    }
1675
1676    #[test]
1677    fn hybrid_is_satisfied_by_the_faster_replica() {
1678        // `in_memory>=2` needs the primary plus *one* replica, so the
1679        // best replica binds, not the worst. Replica 0 holds the event
1680        // in memory at 400 with its own fsync trailing at 250 (behind
1681        // the primary, as the physical ordering requires); replica 1
1682        // lags badly at 10. The gate is not held up by replication —
1683        // the primary's persisted cursor (300) is the binding term.
1684        // Taking the min across replicas would have blamed the slow
1685        // slot.
1686        let p = parse("persisted>=1 && in_memory>=2").unwrap();
1687        let m = metrics((400, 250), (10, 10));
1688        let a = both_active();
1689        assert_eq!(
1690            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
1691            Some(Blocker::Journal)
1692        );
1693    }
1694
1695    #[test]
1696    fn durably_replicated_uses_second_largest_persisted() {
1697        // `persisted>=2` is met by the primary plus the *best* replica's
1698        // persisted cursor. With the primary ahead at 900 and replicas
1699        // at 400 and 10, the clause resolves at 400 — the fast replica —
1700        // not at the minimum of 10.
1701        let p = parse("persisted>=2").unwrap();
1702        let m = metrics((400, 400), (10, 10));
1703        let a = both_active();
1704        assert_eq!(
1705            evaluate_durability(&p, WireSeq::new(900), Some(&m), Some(&a)).durable_pos,
1706            400,
1707            "clause takes the second-largest persisted cursor, not the minimum"
1708        );
1709        // Replication binds, which is the norm for this mode: the
1710        // primary persists before it ships, so it is normally ahead of
1711        // every replica.
1712        assert_eq!(
1713            evaluate_gate(&p, 400, WireSeq::new(900), Some(&m), Some(&a)).1,
1714            Some(Blocker::Replication)
1715        );
1716    }
1717
1718    #[test]
1719    fn standalone_credits_the_journal() {
1720        // No replication wired: the primary is the only node, so the
1721        // journal is the only thing the gate can be waiting on.
1722        let p = parse("persisted>=1").unwrap();
1723        assert_eq!(
1724            evaluate_gate(&p, 500, WireSeq::new(500), None, None).1,
1725            Some(Blocker::Journal)
1726        );
1727    }
1728
1729    #[test]
1730    fn unsatisfiable_shape_never_opens_so_never_attributes() {
1731        // `persisted>=2` with no replica connected: the shape pins
1732        // `durable_pos` to 0, so the gate cannot open and no blocker is
1733        // ever produced — the stall is a missing node, not either
1734        // subsystem's progress. `policy_degraded` is the metric for it.
1735        let p = parse("persisted>=2").unwrap();
1736        let (status, blocker) = evaluate_gate(&p, 1, WireSeq::new(500), None, None);
1737        assert!(status.degraded);
1738        assert_eq!(status.durable_pos, 0);
1739        assert_eq!(blocker, None);
1740    }
1741
1742    #[test]
1743    fn closed_gate_yields_no_attribution() {
1744        // The policy is satisfiable and healthy, but the batch needs a
1745        // sequence the cursors haven't reached — the gate stays closed
1746        // and `evaluate_gate` must not spend a ranking pass (nor name a
1747        // blocker) on an iteration that keeps spinning.
1748        let p = parse("persisted>=1").unwrap();
1749        let (status, blocker) = evaluate_gate(&p, 501, WireSeq::new(500), None, None);
1750        assert!(!status.degraded);
1751        assert_eq!(status.durable_pos, 500);
1752        assert_eq!(blocker, None);
1753    }
1754
1755    #[test]
1756    fn attribution_skips_disconnected_slots() {
1757        // Slot 1 is disconnected, so its (very advanced) cursors must
1758        // not satisfy `in_memory>=2`; only slot 0 counts, and it lags.
1759        // The gate opens at slot 0's in-memory cursor (100).
1760        let p = parse("persisted>=1 && in_memory>=2").unwrap();
1761        let m = metrics((100, 100), (999, 999));
1762        let a = flags(true, false);
1763        assert_eq!(
1764            evaluate_gate(&p, 100, WireSeq::new(500), Some(&m), Some(&a)).1,
1765            Some(Blocker::Replication)
1766        );
1767    }
1768
1769    /// Fresh-cluster catch-up: a replica that handshakes at sequence
1770    /// 0 (the legitimate genesis case, not a stale-flag race) must be
1771    /// included in the cursor view with its zero cursors so the policy
1772    /// behaves the same way it would for a 1-replica deployment that
1773    /// has just produced its first batch. The disconnect-race
1774    /// mitigations (B1 seed-on-connect + B2 reorder) keep this from
1775    /// being conflated with the stale-flag-paired-with-zero-cursor
1776    /// case under normal cluster lifecycles.
1777    #[test]
1778    fn fresh_cluster_zero_cursors_included_in_view() {
1779        let p = parse("persisted>=2").unwrap();
1780        // Both replicas just handshook at seq 0, primary also at 0
1781        // (fresh cluster, no events yet). View = 3 nodes; the clause's
1782        // count (=2) is met by the view size, so the policy is not
1783        // degraded and the gate sits at the 2nd-largest persisted = 0.
1784        let m = metrics((0, 0), (0, 0));
1785        let a = both_active();
1786        let r = evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a));
1787        assert_eq!(r.durable_pos, 0);
1788        assert!(
1789            !r.degraded,
1790            "all 3 nodes present, view meets clause target — should not flag degraded"
1791        );
1792    }
1793
1794    // -- Race-window regression tests --
1795    //
1796    // The replication senders fix two memory-ordering issues at the
1797    // active-flag transition points:
1798    //
1799    //   B1 (`a84540a`): seed `metrics.{acked,in_memory}_sequence[i]`
1800    //   to `handshake.last_sequence` BEFORE setting active_flag=true
1801    //   on reconnect. Without this, the gate would observe (active=
1802    //   true, cursor=0) for ~1 RTT after a replica catch-up completed,
1803    //   pinning any multi-node clause — and thus the gate — to 0.
1804    //
1805    //   B2 (`8888732`): zero `metrics.{acked,in_memory}_sequence[i]`
1806    //   BEFORE setting active_flag=false on disconnect. Without this,
1807    //   a weak-memory reader could observe (active=true, cursor=0)
1808    //   for one iteration during the disconnect window.
1809    //
1810    // Both fixes are in the senders, but the gate's *behaviour* under
1811    // the race-window inputs is tested here. The intent is to lock in
1812    // the invariant: even under a hypothetical (active=true,cursor=0)
1813    // observation, the gate must not produce a spuriously-open answer
1814    // that would cause a client to be told "your event is durable"
1815    // when it isn't. Stalling-briefly is safe; opening-spuriously is
1816    // not.
1817
1818    #[test]
1819    fn race_b1_post_seed_gate_doesnt_freeze_on_reconnect() {
1820        // Post-B1-fix state: replica reconnected, cursors seeded to
1821        // `handshake.last_sequence` (480) before active flipped to
1822        // true. Primary kept moving and is at 500. The gate's view
1823        // is now [primary=500, slot=480]; the durable position dips
1824        // from 500 (primary alone, degraded) to 480 (both nodes).
1825        //
1826        // The dip is correct, not a bug: once a 2nd node is
1827        // connected, durability is bounded by the slower of the two.
1828        // Events 481-500 were already served as durable on primary
1829        // alone — they aren't unsent. New responses for seq>500 wait
1830        // until slot acks; we just don't freeze at 0.
1831        let p = parse("persisted>=2").unwrap();
1832        let m = metrics((480, 480), (999, 999));
1833        let a = flags(true, false);
1834        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
1835        assert_eq!(
1836            r.durable_pos, 480,
1837            "post-seed reconnect should produce a coherent gate position equal to the slower node, not freeze at 0"
1838        );
1839    }
1840
1841    #[test]
1842    fn race_b1_pre_seed_freeze_is_what_the_fix_avoids() {
1843        // Pre-B1-fix state: cursors at 0, active=true. The gate sees
1844        // [primary=500, slot=[0,0]] and 2nd-largest persisted = 0.
1845        // The gate WOULD freeze at 0. This test documents the bug
1846        // the seeding fix is designed to avoid; the senders ensure
1847        // this state is never observed in production.
1848        let p = parse("persisted>=2").unwrap();
1849        let m = metrics((0, 0), (999, 999));
1850        let a = flags(true, false);
1851        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
1852        assert_eq!(
1853            r.durable_pos, 0,
1854            "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"
1855        );
1856    }
1857
1858    #[test]
1859    fn race_b2_disconnect_window_doesnt_open_gate_spuriously() {
1860        // Simulates the B2 race window: a weak-memory reader observes
1861        // (active=true, cursor=0) for one iteration during the
1862        // disconnect transition. The slot legitimately has cursor=0
1863        // because the disconnect handler just zeroed the metrics.
1864        //
1865        // Critical invariant: the gate must NOT produce a higher
1866        // durable_pos than it would with the slot correctly excluded.
1867        // Specifically: with primary at 500, slot stale-zero-included,
1868        // the gate must not "see" the primary alone and open at 500
1869        // — that would let a client be told a seq is durable when
1870        // only the primary has it under a `persisted>=2` policy that
1871        // demands 2 nodes.
1872        let p = parse("persisted>=2").unwrap();
1873        let m = metrics((0, 0), (999, 999));
1874        let a = flags(true, false);
1875        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
1876        // 2nd-largest persisted across {primary=500, slot=0} = 0.
1877        // Gate stalls. ✓
1878        assert_eq!(r.durable_pos, 0);
1879
1880        // Post-disconnect (active=false): view shrinks to {primary}.
1881        // `persisted>=2` is structurally unsatisfiable on a 1-node
1882        // view, so the gate stays at 0 AND surfaces degraded. The
1883        // matching stage's `replicas_connected==0` halt is what stops
1884        // accepting new orders; the gate side's job is just to keep
1885        // the existing in-flight orders stalled and the alert lit.
1886        let a_disconnected = flags(false, false);
1887        let r_after = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a_disconnected));
1888        assert_eq!(r_after.durable_pos, 0);
1889        assert!(
1890            r_after.degraded,
1891            "post-disconnect view of size 1 cannot meet persisted>=2 → degraded"
1892        );
1893    }
1894
1895    #[test]
1896    fn race_invariant_zero_cursor_never_opens_gate_above_slower_node() {
1897        // Property under both B1 and B2 race windows: for any slot
1898        // observed at cursor=0 with active=true, the gate cannot
1899        // produce a durable_pos that exceeds what an honest 2-node
1900        // evaluation would give. Spot-check a handful of primary
1901        // positions to lock the invariant.
1902        let p = parse("persisted>=2").unwrap();
1903        let m = metrics((0, 0), (999, 999));
1904        let a = flags(true, false);
1905        for primary_pos in [0, 1, 100, 500, 1_000_000_000_u64] {
1906            let r = evaluate_durability(&p, WireSeq::new(primary_pos), Some(&m), Some(&a));
1907            // 2nd-largest of {primary_pos, 0} = 0 for any primary > 0.
1908            // For primary_pos = 0, also 0. So always 0.
1909            assert_eq!(
1910                r.durable_pos, 0,
1911                "race-window observation must not open the gate above 0 for any primary position (got {} for primary_pos={primary_pos})",
1912                r.durable_pos
1913            );
1914        }
1915    }
1916
1917    // ------------------------------------------------------------------
1918    // GateCrossTracker — per-cursor "first transition from below to
1919    // crossed" inside the gate loop, used by the journal-wait /
1920    // replica-wait histograms.
1921    // ------------------------------------------------------------------
1922
1923    #[cfg(feature = "tick-to-trade")]
1924    #[test]
1925    fn gate_cross_tracker_records_journal_when_strictly_below() {
1926        // Journal starts at 5 (< 10), repl_min already at 100.
1927        // Journal crosses on the second observation. Replica was already
1928        // past at entry, so no replica sample.
1929        let mut t = GateCrossTracker::new(10);
1930        t.observe(5, Some(100), 1_000);
1931        t.observe(15, Some(100), 2_000);
1932        assert_eq!(t.journal_crossed(), Some(2_000));
1933        assert_eq!(t.replica_crossed(), None);
1934    }
1935
1936    #[cfg(feature = "tick-to-trade")]
1937    #[test]
1938    fn gate_cross_tracker_records_replica_when_strictly_below() {
1939        // Mirror image: journal already past, replica below at entry.
1940        let mut t = GateCrossTracker::new(10);
1941        t.observe(50, Some(5), 1_000);
1942        t.observe(50, Some(12), 2_000);
1943        assert_eq!(t.journal_crossed(), None);
1944        assert_eq!(t.replica_crossed(), Some(2_000));
1945    }
1946
1947    #[cfg(feature = "tick-to-trade")]
1948    #[test]
1949    fn gate_cross_tracker_records_both_when_both_below() {
1950        // Both below at entry, both cross independently.
1951        let mut t = GateCrossTracker::new(100);
1952        t.observe(50, Some(60), 1_000); // both below
1953        t.observe(105, Some(60), 2_000); // journal crosses
1954        t.observe(105, Some(110), 3_000); // replica crosses
1955        assert_eq!(t.journal_crossed(), Some(2_000));
1956        assert_eq!(t.replica_crossed(), Some(3_000));
1957    }
1958
1959    #[cfg(feature = "tick-to-trade")]
1960    #[test]
1961    fn gate_cross_tracker_skips_cursor_already_past_at_entry() {
1962        // Both cursors already >= needed at first observation —
1963        // neither was on the critical path. No samples.
1964        let mut t = GateCrossTracker::new(10);
1965        t.observe(50, Some(100), 1_000);
1966        // Even later observations don't backfill: was_below is sticky.
1967        t.observe(60, Some(110), 2_000);
1968        assert_eq!(t.journal_crossed(), None);
1969        assert_eq!(t.replica_crossed(), None);
1970    }
1971
1972    #[cfg(feature = "tick-to-trade")]
1973    #[test]
1974    fn gate_cross_tracker_first_observation_only_for_cross_decision() {
1975        // A cursor that goes back below `needed` after first iteration
1976        // (impossible in practice — cursors are monotonic — but we
1977        // verify the first-iteration snapshot is what gates the
1978        // sample). Journal: 50 < 10 false → was_below=false → no sample.
1979        let mut t = GateCrossTracker::new(10);
1980        t.observe(50, Some(5), 1_000); // journal already past, replica below
1981        t.observe(20, Some(12), 2_000); // both >= needed now
1982        // Journal: was_below=false at entry → still no sample.
1983        assert_eq!(t.journal_crossed(), None);
1984        // Replica: was_below=true at entry, crosses on iter 2 → sample.
1985        assert_eq!(t.replica_crossed(), Some(2_000));
1986    }
1987
1988    #[cfg(feature = "tick-to-trade")]
1989    #[test]
1990    fn gate_cross_tracker_holds_first_cross_only() {
1991        // Once a cross is recorded, later observations don't
1992        // overwrite — the metric is "when did it first cross", not
1993        // "when was it last below".
1994        let mut t = GateCrossTracker::new(10);
1995        t.observe(5, Some(100), 1_000);
1996        t.observe(15, Some(100), 2_000); // first cross
1997        t.observe(25, Some(100), 3_000); // would otherwise re-record
1998        assert_eq!(t.journal_crossed(), Some(2_000));
1999    }
2000
2001    #[cfg(feature = "tick-to-trade")]
2002    #[test]
2003    fn gate_cross_tracker_ignores_replica_dropping_out_mid_wait() {
2004        // The regression this signature exists for. The replica is
2005        // behind at entry (was_below latches true), then disconnects
2006        // part-way through the wait so no replica supplies a binding
2007        // cursor. That must not be read as "caught up" — the histogram
2008        // would otherwise report a wait that ended at the disconnect.
2009        let mut t = GateCrossTracker::new(100);
2010        t.observe(50, Some(60), 1_000); // replica behind
2011        t.observe(105, None, 2_000); // replica drops out mid-wait
2012        t.observe(105, None, 3_000); // still gone
2013        assert_eq!(t.journal_crossed(), Some(2_000));
2014        assert_eq!(t.replica_crossed(), None);
2015        // Reconnecting past `needed` still records the real crossing.
2016        t.observe(105, Some(150), 4_000);
2017        assert_eq!(t.replica_crossed(), Some(4_000));
2018    }
2019
2020    #[cfg(feature = "tick-to-trade")]
2021    #[test]
2022    fn gate_cross_tracker_records_nothing_when_no_replica_clause() {
2023        // `local` never binds on a replica, so `policy_replica_cursor`
2024        // returns `None` for the whole wait: was_below stays false and
2025        // no replica sample is produced.
2026        let mut t = GateCrossTracker::new(100);
2027        t.observe(50, None, 1_000);
2028        t.observe(105, None, 2_000);
2029        assert_eq!(t.journal_crossed(), Some(2_000));
2030        assert_eq!(t.replica_crossed(), None);
2031    }
2032
2033    // -- DegradationLogger flap-suppression --
2034    //
2035    // The logger gates transition logs on a sustained-state hold so a
2036    // replica flapping at sub-second cadence doesn't spam the journal.
2037    // These tests don't observe the logs themselves (tracing is
2038    // process-global and brittle to capture in unit tests); they
2039    // assert the underlying state machine via the `policy_degraded`
2040    // gauge, which the logger updates on every tick regardless of
2041    // log emission.
2042
2043    fn logger_test_policy() -> crate::durability_policy::Policy {
2044        parse("persisted>=2").unwrap()
2045    }
2046
2047    /// Tick the logger N times at `step` intervals, alternating
2048    /// `degraded` per call. Returns the gauge value after the last
2049    /// tick — useful for asserting that flap cycles don't leak the
2050    /// AtomicBool into a wrong terminal state.
2051    fn drive_logger(
2052        logger: &mut DegradationLogger,
2053        utilization: &StageUtilization,
2054        policy: &crate::durability_policy::Policy,
2055        start: Instant,
2056        states: &[bool],
2057        step: Duration,
2058    ) -> bool {
2059        for (i, &state) in states.iter().enumerate() {
2060            let now = start + step.checked_mul(i as u32).unwrap_or(Duration::ZERO);
2061            logger.tick(policy, utilization, state, now, Duration::from_secs(5));
2062        }
2063        utilization.policy_degraded.load(Ordering::Relaxed)
2064    }
2065
2066    #[test]
2067    fn logger_gauge_tracks_state_immediately() {
2068        // The /healthz gauge reflects the *latest* state on every
2069        // tick — this is what dashboards / alerts read. Sustained-
2070        // state gating only affects the warn/info log emission.
2071        let p = logger_test_policy();
2072        let utilization = StageUtilization::new();
2073        let now = Instant::now();
2074        let mut logger = DegradationLogger::new(now);
2075
2076        logger.tick(&p, &utilization, true, now, Duration::from_secs(5));
2077        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
2078
2079        logger.tick(
2080            &p,
2081            &utilization,
2082            false,
2083            now + Duration::from_millis(50),
2084            Duration::from_secs(5),
2085        );
2086        assert!(!utilization.policy_degraded.load(Ordering::Relaxed));
2087    }
2088
2089    #[test]
2090    fn logger_starting_degraded_marks_initial_state_logged() {
2091        // `new_starting_degraded` emits the startup warn and treats
2092        // the state as already-logged so the next tick at the same
2093        // cluster shape doesn't re-emit instantly. The gauge starts
2094        // at 1.
2095        let p = logger_test_policy();
2096        let utilization = StageUtilization::new();
2097        let now = Instant::now();
2098        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
2099        // The logger doesn't write the gauge from the constructor —
2100        // first tick does. Tick at the same state to settle the
2101        // gauge. No new log line should fire (state hasn't changed).
2102        logger.tick(
2103            &p,
2104            &utilization,
2105            true,
2106            now + Duration::from_millis(10),
2107            Duration::from_secs(5),
2108        );
2109        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
2110    }
2111
2112    #[test]
2113    fn logger_accumulates_degraded_seconds_only_while_degraded() {
2114        // The `policy_degraded_nanos` counter must advance by exactly
2115        // the wall-clock time spent in the degraded state and stay flat
2116        // while healthy. Each tick attributes the interval since the
2117        // previous tick to the state observed at that previous tick.
2118        let p = logger_test_policy();
2119        let utilization = StageUtilization::new();
2120        let start = Instant::now();
2121        let mut logger = DegradationLogger::new(start);
2122        let sec = Duration::from_secs(1);
2123
2124        // t0: healthy -> degraded. The zero-length interval before the
2125        // transition is attributed to the healthy start: no accrual.
2126        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
2127        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
2128
2129        // t0..t1 spent degraded -> +1s.
2130        logger.tick(&p, &utilization, true, start + sec, Duration::from_secs(5));
2131        // t1..t2 still degraded, then flips healthy at the tick -> +1s.
2132        logger.tick(
2133            &p,
2134            &utilization,
2135            false,
2136            start + 2 * sec,
2137            Duration::from_secs(5),
2138        );
2139        assert_eq!(
2140            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2141            2 * sec.as_nanos() as u64
2142        );
2143
2144        // t2..t3 spent healthy -> counter holds flat.
2145        logger.tick(
2146            &p,
2147            &utilization,
2148            false,
2149            start + 3 * sec,
2150            Duration::from_secs(5),
2151        );
2152        assert_eq!(
2153            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2154            2 * sec.as_nanos() as u64
2155        );
2156    }
2157
2158    #[test]
2159    fn logger_starting_degraded_accrues_from_construction() {
2160        // A primary that boots already-degraded (e.g. `hybrid` with no
2161        // replica yet) must accrue from construction, not from the first
2162        // observed transition — `new_starting_degraded` seeds the
2163        // degraded state so the first tick's interval counts.
2164        let p = logger_test_policy();
2165        let utilization = StageUtilization::new();
2166        let start = Instant::now();
2167        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
2168
2169        logger.tick(
2170            &p,
2171            &utilization,
2172            true,
2173            start + Duration::from_secs(1),
2174            Duration::from_secs(5),
2175        );
2176        assert_eq!(
2177            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2178            Duration::from_secs(1).as_nanos() as u64
2179        );
2180    }
2181
2182    #[test]
2183    fn logger_reseed_flushes_in_flight_degraded_accrual() {
2184        // A runtime mode swap re-seeds the logger. On the gate-wait path
2185        // the swap can land after many seconds of wedged-degraded time
2186        // with no intervening tick, so `reseed` must flush that interval
2187        // before resetting — otherwise the whole wedge is silently lost,
2188        // which is exactly the incident the metric exists to measure.
2189        let p = logger_test_policy();
2190        let utilization = StageUtilization::new();
2191        let start = Instant::now();
2192        let sec = Duration::from_secs(1);
2193
2194        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
2195        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
2196        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
2197
2198        // 30s wedged-degraded with no tick (frozen spin), then swap.
2199        logger.reseed(&utilization, start + 30 * sec);
2200        assert_eq!(
2201            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2202            30 * sec.as_nanos() as u64
2203        );
2204
2205        // Post-reseed the logger is healthy-start; a healthy interval
2206        // does not accrue, and `last_tick` was rebased to the swap so
2207        // the next interval isn't double-counted.
2208        logger.tick(
2209            &p,
2210            &utilization,
2211            false,
2212            start + 31 * sec,
2213            Duration::from_secs(5),
2214        );
2215        assert_eq!(
2216            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2217            30 * sec.as_nanos() as u64
2218        );
2219    }
2220
2221    #[test]
2222    fn logger_starting_degraded_then_first_tick_healthy_counts_construction_interval() {
2223        // `new_starting_degraded` seeds the degraded state, so the
2224        // construction→first-tick interval is attributed degraded even
2225        // when the first observed state is already healthy (the cluster
2226        // recovered before the first tick). Locks this intentional edge.
2227        let p = logger_test_policy();
2228        let utilization = StageUtilization::new();
2229        let start = Instant::now();
2230        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
2231
2232        logger.tick(
2233            &p,
2234            &utilization,
2235            false,
2236            start + Duration::from_secs(2),
2237            Duration::from_secs(5),
2238        );
2239        assert_eq!(
2240            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2241            Duration::from_secs(2).as_nanos() as u64
2242        );
2243    }
2244
2245    #[test]
2246    fn logger_counter_matches_total_degraded_time_across_flaps() {
2247        // The cumulative counter must equal exactly the wall time spent
2248        // in degraded states across a flap sequence — the guarantee
2249        // `rate(...degraded_seconds_total)` dashboards depend on.
2250        let p = logger_test_policy();
2251        let utilization = StageUtilization::new();
2252        let start = Instant::now();
2253        let step = Duration::from_millis(500);
2254        let mut logger = DegradationLogger::new(start);
2255
2256        // State observed at tick i (fired at start + i*step). The state
2257        // set at tick i holds over [tick i, tick i+1], so degraded wall
2258        // time = the spans where the *prior* tick's state was true:
2259        // [t1,t2], [t2,t3], [t4,t5] = 3 * step.
2260        let states = [false, true, true, false, true, false];
2261        for (i, &s) in states.iter().enumerate() {
2262            logger.tick(
2263                &p,
2264                &utilization,
2265                s,
2266                start + step * i as u32,
2267                Duration::from_secs(5),
2268            );
2269        }
2270
2271        assert_eq!(
2272            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
2273            (3 * step).as_nanos() as u64
2274        );
2275    }
2276
2277    #[test]
2278    fn logger_handles_rapid_flap_without_panic() {
2279        // Drive the logger through 100 alternating flips at 100ms
2280        // each (faster than the 1s flap-hold). The state machine
2281        // must remain coherent — no panics, gauge tracks final
2282        // state, and `pending_logged` doesn't get stuck.
2283        let p = logger_test_policy();
2284        let utilization = StageUtilization::new();
2285        let now = Instant::now();
2286        let mut logger = DegradationLogger::new(now);
2287        let states: Vec<bool> = (0..100u32).map(|i| i.is_multiple_of(2)).collect();
2288        let final_state = drive_logger(
2289            &mut logger,
2290            &utilization,
2291            &p,
2292            now,
2293            &states,
2294            Duration::from_millis(100),
2295        );
2296        // 100 states starting at i=0 → final state is i=99 → odd → false.
2297        assert!(!final_state);
2298    }
2299
2300    #[test]
2301    fn logger_sustained_degraded_eventually_settles() {
2302        // After a sustained-true state, the logger should be in the
2303        // "logged the onset" mode. Drive 5 ticks of degraded=true at
2304        // 500ms intervals — total 2s, well past the 1s flap-hold.
2305        // Last tick should leave gauge=1 and the heartbeat re-emit
2306        // window primed (last_log set).
2307        let p = logger_test_policy();
2308        let utilization = StageUtilization::new();
2309        let now = Instant::now();
2310        let mut logger = DegradationLogger::new(now);
2311        let final_state = drive_logger(
2312            &mut logger,
2313            &utilization,
2314            &p,
2315            now,
2316            &[true; 5],
2317            Duration::from_millis(500),
2318        );
2319        assert!(final_state);
2320    }
2321
2322    #[test]
2323    fn logger_recovery_to_healthy_settles_gauge() {
2324        // Sustained degraded → sustained healthy. Gauge should end at 0.
2325        let p = logger_test_policy();
2326        let utilization = StageUtilization::new();
2327        let now = Instant::now();
2328        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
2329        let mut states = vec![true; 5]; // 2.5s degraded
2330        states.extend(vec![false; 5]); // 2.5s healthy
2331        let final_state = drive_logger(
2332            &mut logger,
2333            &utilization,
2334            &p,
2335            now,
2336            &states,
2337            Duration::from_millis(500),
2338        );
2339        assert!(!final_state);
2340    }
2341}