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}