Skip to main content

melin_server_runtime/
durability_gate.rs

1//! The response stages' durability gate: the ack policy in force, the
2//! cached durable position, and the wait that holds a reply until its
3//! event is durable under that policy.
4//!
5//! Shared by the io_uring stage ([`crate::response`]) and the DPDK stage
6//! (`crate::dpdk_response`). Both used to carry their own copy of this
7//! state machine — startup resolve, policy changes, the gate wait,
8//! degraded-time accrual — and a fix to one copy had to be
9//! remembered in the other. What differs between the two stages is
10//! egress (socket buffers vs. the poll thread's TX rings); what a reply
11//! waits *for* does not, so it lives here once.
12//!
13//! # Policy changes
14//!
15//! The policy is part of the lineage: an `AckPolicyChange` entry is
16//! journaled in order with the events it governs, and reaches the gate as
17//! an output slot at the entry's position. One rule covers every change,
18//! with no ranking of policies: a change at wire sequence `w` takes effect
19//! for every slot after it once `w` itself is durable under the policy in
20//! force before it. Changes are applied one by one, never merged — two
21//! pending changes each wait for the policy before them.
22//!
23//! The one exception is a forced (unwitnessed) change: a gate halted
24//! under a policy no connected node can satisfy is stuck on a slot ahead
25//! of the change, so the in-order switch is never reached. The matching
26//! stage publishes such a change as it dispatches it, and the wait
27//! applies it as soon as its own entry is durable on this node's disk,
28//! then ignores every in-order change at or below it.
29//!
30//! # A new tenure
31//!
32//! Whether the lineage's newest change took effect before a crash, or
33//! before the primary a replica followed died, is not on disk: the node
34//! may have stopped between journaling it and the nodes the policy before
35//! it relied on holding it. So a primary's gate does not start under the
36//! newest change. It starts under the policy before it, with the change
37//! pending ([`GateStart`]), and applies it by the same rule as above: once
38//! the change's entry is durable under that policy, or, for an
39//! unwitnessed change, once it is on this node's disk. A change that had
40//! taken effect is re-confirmed as soon as the replica holding it is back.
41//!
42//! # Degraded release
43//!
44//! A primary halted for want of a replica cannot meet a policy that needs
45//! one, so the replies it holds — writes it had sequenced before the
46//! halt, and queries, whose answers may reflect them — would wait for a
47//! replica to come back. Once the halt has lasted a grace period, the
48//! gate releases a held slot on a second, weaker condition: the slot's
49//! event is fsynced on the primary's own journal. Such a reply goes out
50//! with `BatchEndDegraded` ([`Backing::PrimaryOnly`]) instead of
51//! `BatchEnd`, so the client knows only the primary's disk backs it.
52//!
53//! The policy is always evaluated first, on every slot, and a slot it
54//! confirms is a full reply. A degraded release never writes the cached
55//! durable position: were it to, every later slot would pass the cached
56//! check without waiting and go out with a full `BatchEnd`, an ack the
57//! policy never gave. Nor does it apply a policy change: a change applies
58//! once durable under the policy before it, and the primary's disk alone
59//! is not that. A change slot released degraded joins the pending changes
60//! instead, applied in order by the rule above once the policy confirms
61//! it, and until then nothing after it can be confirmed in full. The
62//! release is driven by the halt (no replica streaming), never by gate
63//! lag: a connected replica that is merely slow is backpressure, and its
64//! replies wait. It is an operator switch, on by default
65//! ([`DegradedRelease`]).
66
67use std::collections::VecDeque;
68use std::sync::Arc;
69use std::sync::atomic::{AtomicBool, Ordering};
70use std::time::{Duration, Instant};
71
72use melin_app::amortized_timer::AmortizedTimer;
73use melin_journal::{AckPolicyByte, AckPolicyChange};
74use melin_pipeline::wait::WaitStrategy;
75use melin_transport_core::lineage::{ForcedPolicyReader, LineagePolicy};
76use melin_transport_core::pipeline::{OutputPayload, OutputSlot, RequestId, StageUtilization};
77use melin_transport_core::replica_presence::LastLoss;
78#[cfg(feature = "tick-to-trade")]
79use melin_transport_core::trace;
80use melin_transport_core::{DurableWireSeqCursor, WireSeq};
81use melin_wire_protocol::control::TransportResponse;
82use melin_wire_protocol::control_codec::{self, BATCH_END_FRAME_LEN};
83
84use crate::ack_policy::{AckPolicy, Blocker, Policy};
85use crate::halt::{DegradedRelease, Replication};
86use crate::replication::ReplicationMetrics;
87use crate::response::{DegradationLogger, evaluate_durability, evaluate_gate, slot_needs_gate};
88#[cfg(feature = "tick-to-trade")]
89use crate::response::{GateCrossTracker, policy_replica_cursor};
90
91/// Re-emit interval for the "still degraded" reminder.
92const DEGRADED_LOG_INTERVAL: Duration = Duration::from_secs(5);
93
94/// Cadence at which the idle path re-evaluates the policy. Bounds the lag
95/// between a connection-state change and the `/healthz` gauge / warn-log
96/// reflecting it. Cheap (a handful of atomic loads + the policy
97/// evaluator) at this rate.
98const POLICY_CHECK_INTERVAL: Duration = Duration::from_secs(1);
99
100/// Cadence at which the gate-wait spin folds elapsed time into the
101/// degraded-duration counter while the durability gate is stalled.
102/// Tighter than the idle cadence — it bounds the boundary error when a
103/// degradation begins or flips mid-wedge, which matters most for short
104/// stalls. The accrual tick is gated by this period, but the clock read
105/// behind it is gated by the `AmortizedTimer` mask, so the effective
106/// resolution is `max(this, the mask's clock-read cadence)`: one clock
107/// read per `2^16` spin iterations (`AmortizedTimer::CHECK_MASK`), and
108/// one per iteration once the waiter has fallen back to yielding.
109const GATE_ACCRUAL_INTERVAL: Duration = Duration::from_millis(10);
110
111/// How long a node must have been halted for want of a replica before
112/// the gate releases held replies on the primary's own fsync, unless the
113/// operator sets `--degraded-ack-grace-ms`.
114///
115/// Long enough that a replica dropped by a short blip, which reconnects
116/// and catches up within it, confirms the held replies in full rather
117/// than turning them into degraded ones: a replica's first reconnect
118/// attempt comes a second after it loses its primary. Short enough that
119/// clients of a primary that has really lost its replicas are answered
120/// well inside a typical client timeout. A constant of its own, not the
121/// DPDK replication liveness deadline: kernel TCP has no such deadline,
122/// and the halt itself, which starts the grace, already comes after it.
123pub(crate) const DEFAULT_DEGRADED_ACK_GRACE_MS: u64 = 2_000;
124
125/// What backs a reply the gate released, and so which terminator its
126/// request gets.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub(crate) enum Backing {
129    /// The ack policy in force confirmed the slot's event: `BatchEnd`.
130    Policy,
131    /// Released while halted past the grace period, on the primary's own
132    /// fsync alone: `BatchEndDegraded`.
133    PrimaryOnly,
134}
135
136impl Backing {
137    /// The weaker of two backings, `PrimaryOnly` being the weaker: a
138    /// reply is only as backed as the weakest wait that let it through.
139    fn min(self, other: Self) -> Self {
140        match (self, other) {
141            (Self::Policy, Self::Policy) => Self::Policy,
142            (Self::PrimaryOnly, _) | (_, Self::PrimaryOnly) => Self::PrimaryOnly,
143        }
144    }
145}
146
147/// The two pre-encoded request terminators, full wire frames (length
148/// prefix, tag and a request ID), shared by both stages. Encoded once at
149/// startup: every request ends with one, so the stage only writes the
150/// request's ID into the one it picks ([`control_codec::write_terminal_id`],
151/// one eight-byte store) rather than encode a frame per request.
152pub(crate) struct BatchEnds {
153    /// `BatchEnd`: backed as the policy requires.
154    policy: [u8; BATCH_END_FRAME_LEN],
155    /// `BatchEndDegraded`: backed by the primary's disk alone.
156    primary_only: [u8; BATCH_END_FRAME_LEN],
157}
158
159impl BatchEnds {
160    pub(crate) fn new() -> Self {
161        let encode = |response| {
162            let mut buf = [0u8; BATCH_END_FRAME_LEN];
163            let written = control_codec::encode_transport_response(&response, &mut buf)
164                .expect("a batch terminator encodes into `BATCH_END_FRAME_LEN` bytes");
165            assert_eq!(
166                written, BATCH_END_FRAME_LEN,
167                "a batch terminator fills its frame"
168            );
169            buf
170        };
171        Self {
172            policy: encode(TransportResponse::BatchEnd { id: RequestId(0) }),
173            primary_only: encode(TransportResponse::BatchEndDegraded { id: RequestId(0) }),
174        }
175    }
176
177    /// `BatchEnd` for request `id`, the terminator of a reply backed as
178    /// the policy requires.
179    #[inline]
180    fn policy(&mut self, id: RequestId) -> &[u8] {
181        control_codec::write_terminal_id(&mut self.policy, id);
182        &self.policy
183    }
184
185    /// `BatchEndDegraded` for request `id`.
186    #[inline]
187    fn primary_only(&mut self, id: RequestId) -> &[u8] {
188        control_codec::write_terminal_id(&mut self.primary_only, id);
189        &self.primary_only
190    }
191}
192
193/// How a durability wait ended.
194#[must_use]
195enum GateOutcome {
196    /// The slot's reply may be sent, backed as given.
197    Open(Backing),
198    /// Shutdown was requested while the gate was closed. The reply the
199    /// policy never confirmed must not be sent; the caller goes straight
200    /// back to its shutdown branch.
201    Shutdown,
202}
203
204/// What a response stage does while the gate holds one of its slots: the
205/// work that must not wait for the slot's event to become durable.
206///
207/// The kernel stage flushes the replies it has buffered before it waits
208/// (they have cleared their own gates already), and both stages send early
209/// replies during the wait if the held slot is the first of its event (see
210/// [`crate::batch_boundary`]): a refusal must not wait for a write the ack
211/// policy holds.
212pub(crate) trait GateWait {
213    /// Called once per [`DurabilityGate::admit`], and only if the slot has
214    /// to wait, before the wait begins.
215    fn before_wait(&mut self);
216
217    /// Called on each idle turn of the gate's waiter while the slot waits,
218    /// after [`before_wait`](Self::before_wait), never on a slot that does
219    /// not wait. Runs while the stage spins on the gate, so it should cost
220    /// little when there is nothing to do.
221    fn while_waiting(&mut self);
222}
223
224/// A [`GateWait`] hook for one admit, and whether its `before_wait` has
225/// run: the one place that guarantees it runs at most once however many
226/// waits the admit takes (pending changes, then the slot).
227struct Hook<'h, H: GateWait> {
228    hook: &'h mut H,
229    started: bool,
230}
231
232impl<'h, H: GateWait> Hook<'h, H> {
233    fn new(hook: &'h mut H) -> Self {
234        Self {
235            hook,
236            started: false,
237        }
238    }
239
240    /// The slot is about to wait: run `before_wait` if no earlier wait of
241    /// this admit has.
242    #[inline]
243    fn starting(&mut self) {
244        if !self.started {
245            self.started = true;
246            self.hook.before_wait();
247        }
248    }
249}
250
251/// How [`DurabilityGate::admit`] left a slot: where its frames go, if
252/// anywhere.
253#[must_use]
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub(crate) enum Admission {
256    /// Released: the slot's frames go to this connection (0, the node's
257    /// own, for a server-originated event, which no client holds), its
258    /// request ending as `backing` says.
259    Reply {
260        connection_id: u64,
261        backing: Backing,
262    },
263    /// An ack-policy change, now applied (or stale): the gate's own slot,
264    /// whose frames go to no connection. A response stage looks a
265    /// connection up only for [`Self::Reply`], so a change slot cannot
266    /// reach a client whatever it is addressed to.
267    GateOnly,
268    /// Shutdown was requested while the gate was closed: see
269    /// [`GateOutcome::Shutdown`].
270    Shutdown,
271}
272
273/// Where the gate's policy comes from: the one it starts under, with the
274/// lineage's change it must still apply, and the forced changes that may
275/// overtake the in-order ones. Every other change reaches the gate as an
276/// output slot.
277pub struct GatePolicy {
278    /// How the gate starts (a primary resolves it with the crate's
279    /// `initial_gate_policy`).
280    pub start: GateStart,
281    /// Forced changes the matching stage has dispatched — see
282    /// [`ForcedPolicyReader`] and the module docs.
283    pub forced: ForcedPolicyReader,
284}
285
286/// The policy a gate starts under, and the change it must apply before
287/// it admits any slot, if there is one (see the module docs, "A new
288/// tenure").
289///
290/// The pending change is part of the start rather than something a
291/// caller may add: `initial_gate_policy` returns the two together and
292/// the gate takes the start whole and consumes the change, so the
293/// runtime cannot hand the gate the lineage's policy and drop the
294/// transition to it on the way. The fields are crate-private for the
295/// same reason: outside the crate a start can only be
296/// [`settled`](Self::settled), the start of a gate with no lineage
297/// change to apply (a harness driving the response stage by hand), so
298/// no caller can build one that names the lineage's newest policy as in
299/// force.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct GateStart {
302    /// The policy in force from the start.
303    pub(crate) in_force: AckPolicy,
304    /// The lineage's newest change, not yet applied.
305    pub(crate) pending: Option<PendingChange>,
306}
307
308impl GateStart {
309    /// Start under `policy`, nothing pending: a gate whose lineage holds
310    /// no change, or one serving without replication (the standalone
311    /// rule, see `initial_gate_policy`).
312    pub fn settled(policy: AckPolicy) -> Self {
313        Self {
314            in_force: policy,
315            pending: None,
316        }
317    }
318}
319
320/// A journaled ack-policy change the gate applies before any slot it
321/// admits: the lineage's newest, from a previous tenure.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub(crate) struct PendingChange {
324    /// The change entry's wire sequence.
325    pub(crate) wire_seq: WireSeq,
326    /// The policy it puts in force.
327    pub(crate) policy: AckPolicy,
328    /// Journaled unwitnessed: applied once its entry is on this node's
329    /// disk, as a forced change is, rather than once it is durable under
330    /// the policy before it.
331    pub(crate) unwitnessed: bool,
332}
333
334/// How a primary's gate starts (see the module docs, "A new tenure").
335///
336/// With replication enabled: under the policy in force before the
337/// lineage's newest change, with that change pending; under the newest
338/// policy if it is the lineage's first change (journaled as the policy
339/// the gate then started under, so there is no transition to apply
340/// again); under `requested` (the configured or staged one) while the
341/// lineage holds no change. A requested policy different from the
342/// lineage's takes effect through the change the primary journals as it
343/// starts, in order, after the pending one.
344///
345/// With replication disabled, `requested` regardless, nothing pending: no
346/// replica can ever witness the start's change, so a lineage last run
347/// under a policy that needs one (restarted standalone under `disk`)
348/// would hold every reply forever. The change is journaled unwitnessed
349/// all the same, as the record of the switch, and so is `requested` when
350/// the lineage already names it through a change that may still be
351/// waiting for its witness (`policy_control::boot_entry`). A requested
352/// policy that needs a replica can only be the configured one there (the
353/// mailbox refuses to stage one, `AckPolicyControl::request`): a replica
354/// promoted without `--replication-bind` under its `--ack-policy
355/// disk+ram`, say. Its gate holds every reply until the operator's
356/// `ACK-POLICY disk`, which, unwitnessed, takes effect once on disk: the
357/// documented failover workflow, so it is served rather than refused.
358///
359/// A lineage byte this build does not know is refused rather than
360/// replaced: the events it governs were acknowledged under a policy this
361/// node cannot evaluate, so it must neither start nor promote on it. The
362/// newest byte is checked in every case; the previous one only with
363/// replication enabled, since a standalone start never reads it.
364///
365/// `replication` is the halt gate's (`HaltGate::replication`), so the
366/// gate starts on the same answer to "standalone?" that the producer
367/// decides its entries on.
368pub(crate) fn initial_gate_policy(
369    lineage: Option<LineagePolicy>,
370    requested: AckPolicy,
371    replication: Replication,
372) -> Result<GateStart, String> {
373    let Some(lineage) = lineage else {
374        return Ok(GateStart::settled(requested));
375    };
376    let newest = known_lineage_policy(lineage.policy())?;
377    match replication {
378        Replication::Disabled => return Ok(GateStart::settled(requested)),
379        Replication::Enabled => {}
380    }
381    let Some(previous) = lineage.previous else {
382        return Ok(GateStart::settled(newest));
383    };
384    Ok(GateStart {
385        in_force: known_lineage_policy(previous)?,
386        pending: Some(PendingChange {
387            wire_seq: lineage.wire_seq,
388            policy: newest,
389            unwitnessed: lineage.change.unwitnessed,
390        }),
391    })
392}
393
394/// A lineage policy byte this build knows, or the refusal to serve on it.
395fn known_lineage_policy(byte: AckPolicyByte) -> Result<AckPolicy, String> {
396    AckPolicy::from_byte(byte).ok_or_else(|| {
397        format!(
398            "the journal's ack policy (byte {}) is unknown to this build; \
399             refusing to serve under it",
400            byte.0
401        )
402    })
403}
404
405/// The cursors and shared state the gate reads. Grouped so the two
406/// stages hand them over in one place instead of threading five
407/// arguments through every call.
408pub(crate) struct GateInputs {
409    /// Highest wire seq durably persisted on this node's journal — see
410    /// `response::Response::journal_persisted_wire_seq`.
411    pub journal_persisted_wire_seq: DurableWireSeqCursor,
412    /// The replicas' acked (persisted) and in-memory cursors, written by
413    /// the replication handlers. `None` on a standalone node, where only
414    /// the local journal can satisfy a policy.
415    pub replication_metrics: Option<Arc<ReplicationMetrics>>,
416    /// Per-replica-slot "connected and streaming" flags; an inactive
417    /// slot's cursors are left out of the evaluation. A fixed `[_; 2]`
418    /// rather than a `Vec` because a cluster has at most two replicas
419    /// beside the primary and the array is indexed in step with
420    /// `ReplicationMetrics`' per-slot cursors, with no allocation on the
421    /// gate path. `None` on a standalone node, like the metrics.
422    pub replica_active: Option<[Arc<AtomicBool>; 2]>,
423    /// The stage's health gauges: the gate writes `policy_degraded`, the
424    /// degraded-time counter and the journal/replication attribution
425    /// counters read by `/healthz` and the metrics endpoint.
426    pub utilization: Arc<StageUtilization>,
427    /// How the gate waits on the journal-disk and replication threads.
428    pub wait: WaitStrategy,
429    /// Degraded release while halted; `None` when off (or without
430    /// replication).
431    pub degraded_release: Option<DegradedRelease>,
432}
433
434/// The durability gate and the ack-policy state it evaluates against.
435///
436/// Single-threaded: owned by the response stage's thread. Every change of
437/// policy goes through [`Self::switch_to`].
438pub(crate) struct DurabilityGate {
439    inputs: GateInputs,
440    /// Which stage owns this gate, for the switch / unknown-policy logs.
441    stage: &'static str,
442    /// See [`GatePolicy::forced`].
443    forced: ForcedPolicyReader,
444    /// Wire seq of the newest journaled change the gate has applied, in
445    /// order or forced; `None` before any. A change at or below it is
446    /// stale: a forced change already superseded it. A change naming an
447    /// unknown policy is not recorded here (see [`Self::refuse_unknown`]).
448    applied_through: Option<WireSeq>,
449    /// Wire seq of the change last refused for naming an unknown policy,
450    /// so one seen again (a forced change is re-read on every spin of a
451    /// wait, then reaches the gate as its own slot) is logged once. An
452    /// `Option<WireSeq>` rather than a set: the repeats come one after the
453    /// other, and a bug that slips one more log line past it is still
454    /// logged rather than hidden.
455    last_refused: Option<WireSeq>,
456    /// Changes still to apply, oldest first, each once durable under the
457    /// policy before it: the lineage's newest (see [`GateStart`]), and
458    /// any change slot a degraded release let through (see the module
459    /// docs). Popped once applied, before any slot after it is admitted
460    /// in full. A `VecDeque` because they are applied in order from the
461    /// front and joined at the back; it allocates only when a change is
462    /// released degraded, a halted node's operator swap.
463    pending: VecDeque<PendingChange>,
464    active_policy: AckPolicy,
465    /// `active_policy.needs_replica()`, kept with it: asked on every spin
466    /// of a halted gate, where building the policy to answer would
467    /// allocate.
468    needs_replica: bool,
469    policy: Policy,
470    /// Highest wire seq known durable under `policy`, cached so a slot
471    /// whose event is already durable passes without touching an atomic.
472    /// `u64` because it is compared against `OutputSlot::wire_seq`.
473    cached_durable_pos: u64,
474    degraded_logger: DegradationLogger,
475    /// Last idle-path policy evaluation; bumped by [`Self::after_batch`]
476    /// so the logger is not double-ticked when traffic stops.
477    last_policy_check: Instant,
478    /// Paces accrual ticks inside the gate-wait spin so the degraded-
479    /// duration counter keeps advancing during a hard stall. Held across
480    /// gate entries (not per entry) so the normal gated path — entered
481    /// briefly whenever durability lags by a few µs — pays no extra
482    /// `Instant::now()`; the amortized mask only reads the clock once per
483    /// ~65 k cumulative spin iterations.
484    accrual_timer: AmortizedTimer,
485    /// When the current halt began: the latest loss's stamp, or the
486    /// earliest sighting when the stamp is missing (see [`halt_start`]);
487    /// `None` once a replica is seen streaming.
488    halt_started: Option<Instant>,
489    /// The degraded release is under way: the halt has outlasted the
490    /// grace period. Kept for the `warn!` on entering and leaving it.
491    releasing_degraded: bool,
492}
493
494impl DurabilityGate {
495    /// Start under `policy.start` and evaluate it once, so the cached
496    /// durable position and the `/healthz` gauge reflect the cluster's
497    /// startup shape before the first batch — an unsatisfiable policy
498    /// (e.g. a primary that just lost both replicas while running
499    /// `disk+ram`) is visible immediately. A pending change that the
500    /// evaluation already finds durable is applied here.
501    pub(crate) fn new(inputs: GateInputs, policy: GatePolicy, stage: &'static str) -> Self {
502        let GatePolicy {
503            start:
504                GateStart {
505                    in_force: active_policy,
506                    pending,
507                },
508            forced,
509        } = policy;
510        inputs
511            .utilization
512            .ack_policy_in_force
513            .store(Some(active_policy.to_byte()));
514        let policy = active_policy.to_policy();
515        let now = Instant::now();
516        let journal_pos = inputs.journal_persisted_wire_seq.load();
517        let status = evaluate_durability(
518            &policy,
519            journal_pos,
520            inputs.replication_metrics.as_deref(),
521            inputs.replica_active.as_ref(),
522        );
523        inputs
524            .utilization
525            .policy_degraded
526            .store(status.degraded, Ordering::Relaxed);
527        let degraded_logger = if status.degraded {
528            DegradationLogger::new_starting_degraded(now, &policy)
529        } else {
530            DegradationLogger::new(now)
531        };
532        if let Some(pending) = pending {
533            tracing::info!(
534                stage,
535                in_force = active_policy.as_str(),
536                pending = pending.policy.as_str(),
537                wire_seq = pending.wire_seq.get(),
538                unwitnessed = pending.unwitnessed,
539                "the lineage's newest ack-policy change is applied again before any reply"
540            );
541        }
542        let mut gate = Self {
543            inputs,
544            stage,
545            forced,
546            applied_through: None,
547            last_refused: None,
548            pending: pending.into_iter().collect(),
549            active_policy,
550            needs_replica: active_policy.needs_replica(),
551            policy,
552            cached_durable_pos: status.durable_pos,
553            degraded_logger,
554            last_policy_check: now,
555            accrual_timer: AmortizedTimer::new(),
556            halt_started: None,
557            releasing_degraded: false,
558        };
559        gate.settle_pending_if_durable(journal_pos);
560        gate
561    }
562
563    /// Apply the pending changes, oldest first, each once it is durable:
564    /// under the policy in force (the cached durable position, which every
565    /// caller has just refreshed or knows to cover it), or, for an
566    /// unwitnessed one, on this node's disk (`journal_pos`). Never waits.
567    /// Stops at the first change not yet durable: applying one switches
568    /// the policy and resets the cached position, so the next is judged
569    /// under its own predecessor once re-evaluated. The one place pending
570    /// changes are settled: at the start, in the idle recheck, inside a
571    /// wait (with the forced changes) and before a slot is admitted in
572    /// full. Each is applied in order before every slot after it, or
573    /// skipped by [`Self::apply_change`] if a forced change of this tenure
574    /// has overtaken it.
575    fn settle_pending_if_durable(&mut self, journal_pos: WireSeq) {
576        while let Some(&pending) = self.pending.front() {
577            if self.cached_durable_pos < pending.wire_seq.get()
578                && !(pending.unwitnessed && journal_pos >= pending.wire_seq)
579            {
580                return;
581            }
582            self.apply_front_pending();
583        }
584    }
585
586    /// Pop the front pending change and apply it: its caller has found it
587    /// durable under the policy before it (or, unwitnessed, on disk), or
588    /// overtaken by a forced change, which [`Self::apply_change`] skips.
589    fn apply_front_pending(&mut self) {
590        let Some(pending) = self.pending.pop_front() else {
591            return;
592        };
593        let cause = if pending.unwitnessed {
594            "pending unwitnessed change, on disk"
595        } else {
596            "pending change, durable under the policy before it"
597        };
598        self.apply_change(pending.wire_seq, pending.policy.to_byte(), cause);
599    }
600
601    /// The terminator for request `id`, which `backing` closes, from
602    /// `ends`. Counts each degraded one for `melin_degraded_acks_total`:
603    /// called once per request (on its last slot), by both stages.
604    #[inline]
605    pub(crate) fn batch_end<'e>(
606        &self,
607        backing: Backing,
608        ends: &'e mut BatchEnds,
609        id: RequestId,
610    ) -> &'e [u8] {
611        match backing {
612            Backing::Policy => ends.policy(id),
613            Backing::PrimaryOnly => {
614                self.inputs
615                    .utilization
616                    .degraded_acks
617                    .fetch_add(1, Ordering::Relaxed);
618                ends.primary_only(id)
619            }
620        }
621    }
622
623    /// Whether the degraded release is open now: degraded release on, no
624    /// replica streaming, a policy that needs one, and the halt older than
625    /// the grace period. Also tracks the halt's start and logs entering
626    /// and leaving the release.
627    ///
628    /// One acquire load while a replica is connected: `wait_durable` calls
629    /// this on every spin in which the local journal holds the slot but
630    /// the policy has not confirmed it, the normal `disk+ram`/`two-disks`
631    /// path while a replica acks. The clock is read only while none is
632    /// streaming.
633    fn degraded_release_open(&mut self) -> bool {
634        let Some(release) = self.inputs.degraded_release.as_ref() else {
635            return false;
636        };
637        let grace = release.grace;
638        let Some(loss) = release.presence.last_loss() else {
639            self.halt_started = None;
640            if self.releasing_degraded {
641                self.log_degraded_release(false, true, grace);
642            }
643            return false;
644        };
645        let now = Instant::now();
646        let started = halt_start(loss, self.halt_started, now);
647        self.halt_started = Some(started);
648        // Under a policy the primary's own disk satisfies (`disk`), the
649        // policy itself confirms on the same cursor: nothing is weaker
650        // than it, and there is nothing to release.
651        let open = self.needs_replica && now.saturating_duration_since(started) >= grace;
652        if open != self.releasing_degraded {
653            self.log_degraded_release(open, false, grace);
654        }
655        open
656    }
657
658    /// Record and log entering (`open`) or leaving the degraded release.
659    #[cold]
660    fn log_degraded_release(&mut self, open: bool, replica_connected: bool, grace: Duration) {
661        self.releasing_degraded = open;
662        if open {
663            tracing::warn!(
664                stage = self.stage,
665                policy = self.active_policy.as_str(),
666                grace_ms = grace.as_millis() as u64,
667                "no replica for the grace period: replies held for the ack policy are now \
668                 released once this node's own journal holds them, marked as backed by the \
669                 primary alone (BatchEndDegraded)"
670            );
671        } else {
672            tracing::warn!(
673                stage = self.stage,
674                policy = self.active_policy.as_str(),
675                replica_connected,
676                "degraded release stopped: held replies wait for the ack policy again"
677            );
678        }
679    }
680
681    /// The one place the policy in force changes.
682    ///
683    /// Rebuilds the local policy and resets the cached durable position so
684    /// the next gate evaluation starts from a clean slate — the fresh
685    /// policy may evaluate degraded/undegraded differently against the
686    /// same cluster shape. The logger is re-seeded so a transition under
687    /// the new policy surfaces immediately rather than waiting out the
688    /// sustained-state hold; accrual is flushed first so degraded time
689    /// before the switch is not dropped.
690    ///
691    /// A change naming the policy already in force (a boot or standalone
692    /// entry repeating it, an operator re-sending it) changes nothing, so
693    /// it neither logs nor resets anything; the caller still records it
694    /// as applied.
695    fn switch_to(&mut self, next: AckPolicy, cause: &'static str) {
696        if next == self.active_policy {
697            return;
698        }
699        tracing::info!(
700            stage = self.stage,
701            prev = self.active_policy.as_str(),
702            next = next.as_str(),
703            cause,
704            "ack policy switched"
705        );
706        self.active_policy = next;
707        self.needs_replica = next.needs_replica();
708        self.policy = next.to_policy();
709        self.cached_durable_pos = 0;
710        self.degraded_logger
711            .reseed(&self.inputs.utilization, Instant::now());
712        self.inputs
713            .utilization
714            .ack_policy_in_force
715            .store(Some(next.to_byte()));
716    }
717
718    /// Apply the journaled change at `wire_seq`, unless a change at or
719    /// after it has been applied already (a forced one overtook it).
720    ///
721    /// A byte this build does not know is refused ([`Self::refuse_unknown`]).
722    fn apply_change(&mut self, wire_seq: WireSeq, policy: AckPolicyByte, cause: &'static str) {
723        if self.is_stale(wire_seq) {
724            return;
725        }
726        let Some(next) = AckPolicy::from_byte(policy) else {
727            self.refuse_unknown(wire_seq, policy);
728            return;
729        };
730        self.applied_through = Some(wire_seq);
731        self.switch_to(next, cause);
732    }
733
734    /// A change at `wire_seq` is stale: one at or after it has been
735    /// applied already (a forced one overtook it).
736    fn is_stale(&self, wire_seq: WireSeq) -> bool {
737        self.applied_through
738            .is_some_and(|through| wire_seq <= through)
739    }
740
741    /// Refuse the change at `wire_seq`, which names a policy this build
742    /// does not know: the prior policy stays, loudly. This node journaled
743    /// the entry itself, from a policy it named, so it is a bug, and
744    /// refusing every later reply would turn it into an outage.
745    ///
746    /// Not recorded as applied, whether it came in order, forced or
747    /// released degraded: that would make a valid change before it, still
748    /// pending, stale, and skip it.
749    #[cold]
750    fn refuse_unknown(&mut self, wire_seq: WireSeq, policy: AckPolicyByte) {
751        if self.last_refused == Some(wire_seq) {
752            return;
753        }
754        self.last_refused = Some(wire_seq);
755        tracing::error!(
756            stage = self.stage,
757            byte = policy.0,
758            wire_seq = wire_seq.get(),
759            "journaled ack-policy change names an unknown policy; retaining prior policy"
760        );
761    }
762
763    /// Apply the newest forced change once its own entry is durable on
764    /// this node's disk (`journal_pos`), so the record of the swap exists
765    /// before any reply relies on it. One already applied is skipped by
766    /// [`Self::apply_change`]; one naming an unknown policy is refused on
767    /// every spin, and logged once.
768    ///
769    /// The lineage's pending change is settled first, if durable (an
770    /// unwitnessed one once on this node's disk, as a forced change is):
771    /// it comes before any forced change of this tenure.
772    #[inline]
773    fn observe_forced(&mut self, journal_pos: WireSeq) {
774        self.settle_pending_if_durable(journal_pos);
775        if let Some(forced) = self.forced.load()
776            && journal_pos >= forced.wire_seq
777        {
778            self.apply_change(forced.wire_seq, forced.policy, "forced change");
779        }
780    }
781
782    /// Admit one output slot: hold it until its event is durable under
783    /// the policy in force, then, for an ack-policy change, switch to the
784    /// policy it names for every slot after it. Both response stages call
785    /// this for every slot, in ring order.
786    ///
787    /// `hook`'s `before_wait` runs once, only when the slot has to wait —
788    /// the kernel stage flushes its buffered replies there — and its
789    /// `while_waiting` on every idle turn of the wait (see [`GateWait`]).
790    ///
791    /// A change a forced one has overtaken is stale, and not applied
792    /// ([`Self::apply_change`]). Waiting on it holds nothing back: the
793    /// forced change was applied only once its own entry, which comes after
794    /// the stale one, was on this node's disk, so a forced `disk` finds the
795    /// stale slot durable at once, and any other forced policy (a node
796    /// without replication) holds the next reply as long.
797    ///
798    /// The first slot of a tenure whose lineage left a change pending
799    /// waits for that change first, as it would have waited for the
800    /// change's own slot (see [`GateStart`]).
801    ///
802    /// Returns where the slot's frames go ([`Admission`]): a change slot
803    /// is the gate's alone.
804    ///
805    /// While the degraded release is open, a wait can let a slot through
806    /// on the primary's own disk ([`Backing::PrimaryOnly`]). A pending
807    /// change let through that way stays pending, so the slot behind it
808    /// goes out marked too; a change slot let through that way is not
809    /// applied but joins the pending changes (see the module docs).
810    #[inline]
811    pub(crate) fn admit<R: Copy, Q: Copy>(
812        &mut self,
813        slot: &OutputSlot<R, Q>,
814        shutdown: &AtomicBool,
815        #[cfg(feature = "tick-to-trade")] tracker: &mut GateCrossTracker,
816        hook: &mut impl GateWait,
817    ) -> Admission {
818        let mut hook = Hook::new(hook);
819        match self.admit_pending(
820            shutdown,
821            #[cfg(feature = "tick-to-trade")]
822            tracker,
823            &mut hook,
824        ) {
825            GateOutcome::Shutdown => Admission::Shutdown,
826            GateOutcome::Open(backing) => self.admit_slot(
827                slot,
828                backing,
829                shutdown,
830                #[cfg(feature = "tick-to-trade")]
831                tracker,
832                &mut hook,
833            ),
834        }
835    }
836
837    /// The first half of [`Self::admit`]: wait for the pending changes,
838    /// applying each, oldest first, once durable under the policy before
839    /// it. Returns how the slot behind them is backed so far: by the
840    /// policy once every one is applied, by the primary alone if a wait
841    /// let one through degraded, which leaves it pending, and every change
842    /// behind it.
843    #[inline]
844    fn admit_pending(
845        &mut self,
846        shutdown: &AtomicBool,
847        #[cfg(feature = "tick-to-trade")] tracker: &mut GateCrossTracker,
848        hook: &mut Hook<'_, impl GateWait>,
849    ) -> GateOutcome {
850        while let Some(&pending) = self.pending.front() {
851            if pending.wire_seq.get() > self.cached_durable_pos {
852                hook.starting();
853                match self.wait_durable(
854                    pending.wire_seq.get(),
855                    shutdown,
856                    #[cfg(feature = "tick-to-trade")]
857                    tracker,
858                    hook.hook,
859                ) {
860                    GateOutcome::Shutdown => return GateOutcome::Shutdown,
861                    GateOutcome::Open(Backing::PrimaryOnly) => {
862                        return GateOutcome::Open(Backing::PrimaryOnly);
863                    }
864                    GateOutcome::Open(Backing::Policy) => {}
865                }
866                // The wait settled it already (an unwitnessed change once
867                // on disk), and maybe more: judge the new front afresh.
868                if self.pending.front() != Some(&pending) {
869                    continue;
870                }
871            }
872            // The policy in force covers the front change: the cached
873            // position already did, or a full wait on it returned, which
874            // it does only once that position reaches it. That policy is
875            // the one before the change, every change ahead of it applied,
876            // unless a forced change overtook it, which makes it stale.
877            self.apply_front_pending();
878        }
879        GateOutcome::Open(Backing::Policy)
880    }
881
882    /// The second half of [`Self::admit`]: hold `slot` until durable
883    /// under the policy in force, its backing no stronger than `backing`
884    /// (how the pending changes ahead of it were let through), then apply
885    /// or defer it if it is a change.
886    #[inline]
887    fn admit_slot<R: Copy, Q: Copy>(
888        &mut self,
889        slot: &OutputSlot<R, Q>,
890        mut backing: Backing,
891        shutdown: &AtomicBool,
892        #[cfg(feature = "tick-to-trade")] tracker: &mut GateCrossTracker,
893        hook: &mut Hook<'_, impl GateWait>,
894    ) -> Admission {
895        let change = match slot.payload {
896            OutputPayload::AckPolicyChange(change) => Some(change),
897            OutputPayload::Report(_)
898            | OutputPayload::QueryResponse(_)
899            | OutputPayload::BatchEnd => None,
900        };
901        if slot_needs_gate(slot, self.cached_durable_pos) {
902            hook.starting();
903            match self.wait_durable(
904                slot.wire_seq,
905                shutdown,
906                #[cfg(feature = "tick-to-trade")]
907                tracker,
908                hook.hook,
909            ) {
910                GateOutcome::Shutdown => return Admission::Shutdown,
911                GateOutcome::Open(released) => backing = backing.min(released),
912            }
913        }
914        let Some(change) = change else {
915            return Admission::Reply {
916                connection_id: slot.connection_id,
917                backing,
918            };
919        };
920        let wire_seq = WireSeq::new(slot.wire_seq);
921        match backing {
922            Backing::Policy => {
923                let cause = if change.unwitnessed {
924                    "unwitnessed change, in order"
925                } else {
926                    "journaled change"
927                };
928                self.apply_change(wire_seq, change.policy, cause);
929            }
930            Backing::PrimaryOnly => self.defer_change(wire_seq, change),
931        }
932        Admission::GateOnly
933    }
934
935    /// A change slot a degraded release let through: only the primary's
936    /// disk holds it, which applies no change, so it joins the pending
937    /// changes, applied in order once durable under the policy before it
938    /// (an unwitnessed one once on disk, which it already is). One a
939    /// forced change has overtaken is stale, as in [`Self::apply_change`].
940    fn defer_change(&mut self, wire_seq: WireSeq, change: AckPolicyChange) {
941        if self.is_stale(wire_seq) {
942            return;
943        }
944        let Some(policy) = AckPolicy::from_byte(change.policy) else {
945            self.refuse_unknown(wire_seq, change.policy);
946            return;
947        };
948        tracing::info!(
949            stage = self.stage,
950            policy = policy.as_str(),
951            wire_seq = wire_seq.get(),
952            "ack-policy change released on this node's disk alone: applied once durable under \
953             the policy before it"
954        );
955        self.pending.push_back(PendingChange {
956            wire_seq,
957            policy,
958            unwitnessed: change.unwitnessed,
959        });
960    }
961
962    /// Wait until wire seq `needed` is durable under the policy in force,
963    /// or shutdown is requested.
964    ///
965    /// The gate waits on the journal-disk thread and the replication
966    /// handlers — the exact threads a small box co-schedules with the
967    /// response stage, so it waits the way every other wait does. A fresh
968    /// waiter per gate entry: the spin budget is meant to cover one
969    /// durability lag, not to carry over from the idle loop.
970    ///
971    /// A forced change is observed inside the wait. Without it, a slot
972    /// whose gate became structurally unsatisfiable (every replica gone
973    /// under `disk+ram`) would wedge the stage forever, even after an
974    /// operator sent the remediating `ACK-POLICY disk` — its slot sits
975    /// behind the stuck one.
976    ///
977    /// Shutdown is observed for the same reason: a gate that cannot open
978    /// would otherwise hold this thread until a replica returned, and the
979    /// shutdown sequence joins it without a timeout — an operator
980    /// restarting the degraded node, or a fence (which co-sets
981    /// `shutdown`), would hang the process.
982    ///
983    /// `hook`'s `while_waiting` runs on each idle turn, just before the
984    /// waiter idles.
985    #[inline]
986    fn wait_durable(
987        &mut self,
988        needed: u64,
989        shutdown: &AtomicBool,
990        #[cfg(feature = "tick-to-trade")] tracker: &mut GateCrossTracker,
991        hook: &mut impl GateWait,
992    ) -> GateOutcome {
993        let mut gate_waiter = self.inputs.wait.waiter();
994        loop {
995            if shutdown.load(Ordering::Relaxed) {
996                return GateOutcome::Shutdown;
997            }
998
999            let journal_pos = self.inputs.journal_persisted_wire_seq.load();
1000            self.observe_forced(journal_pos);
1001            let metrics_ref = self.inputs.replication_metrics.as_deref();
1002            let active_ref = self.inputs.replica_active.as_ref();
1003
1004            // The cross-tracker (traced builds only) samples the replica
1005            // cursor itself rather than sharing the evaluation's read
1006            // below: computing a standalone replica cursor
1007            // unconditionally spent four Acquire loads per spin iteration
1008            // on `ReplicationMetrics`, the same cache line the
1009            // replication sender writes on every ack and every completed
1010            // SEND. Gate attribution does not re-read at all — it comes
1011            // out of `evaluate_gate`, from the same snapshot that opens
1012            // the gate.
1013            #[cfg(feature = "tick-to-trade")]
1014            tracker.observe(
1015                journal_pos.get(),
1016                // The level the *active policy* gates replicas on —
1017                // in-memory under `disk+ram`, persisted under
1018                // `two-disks`. `None` when no clause is replica-supplied
1019                // (`disk`) and, transiently, when the binding replica
1020                // drops out of the cursor view mid-wait. Passed through
1021                // as-is so the tracker can tell "no replica wait to
1022                // measure" from "the replica caught up".
1023                policy_replica_cursor(&self.policy, journal_pos, metrics_ref, active_ref),
1024                trace::mono_trace_ns(),
1025            );
1026
1027            let (status, blocker) =
1028                evaluate_gate(&self.policy, needed, journal_pos, metrics_ref, active_ref);
1029            self.cached_durable_pos = status.durable_pos;
1030            self.inputs
1031                .utilization
1032                .policy_degraded
1033                .store(status.degraded, Ordering::Relaxed);
1034
1035            // Accrue degraded time while wedged. The post-batch tick
1036            // attributes the whole wait to a single state, so without
1037            // this a healthy→degraded flip during the wedge would be
1038            // mis-charged. While the gate waiter spins the clock read
1039            // behind the tick is mask-gated, landing only every ~65 k
1040            // iterations (`CHECK_MASK = 2^16`) regardless of the period;
1041            // once it yields, each iteration already pays a syscall and
1042            // the read is unmasked.
1043            if self
1044                .accrual_timer
1045                .tick(GATE_ACCRUAL_INTERVAL, gate_waiter.spinning())
1046                .is_some()
1047            {
1048                self.degraded_logger.tick(
1049                    &self.policy,
1050                    &self.inputs.utilization,
1051                    status.degraded,
1052                    Instant::now(),
1053                    DEGRADED_LOG_INTERVAL,
1054                );
1055            }
1056
1057            if self.cached_durable_pos >= needed {
1058                // Attribution: which subsystem supplied the binding
1059                // cursor, from the same snapshot that opened the gate and
1060                // against the policy actually in force. Relaxed is fine —
1061                // health reads are infrequent.
1062                //
1063                // `None` is unreachable here: `needed >= 1` inside this
1064                // loop, and a degraded evaluation pins `durable_pos` to
1065                // 0, so an open gate implies the policy was satisfiable
1066                // and attribution has a verdict. The no-op arm keeps a
1067                // metrics-only path from ever panicking regardless.
1068                match blocker {
1069                    Some(Blocker::Journal) => {
1070                        self.inputs
1071                            .utilization
1072                            .gate_journal
1073                            .fetch_add(1, Ordering::Relaxed);
1074                    }
1075                    Some(Blocker::Replication) => {
1076                        self.inputs
1077                            .utilization
1078                            .gate_replication
1079                            .fetch_add(1, Ordering::Relaxed);
1080                    }
1081                    None => {}
1082                }
1083                return GateOutcome::Open(Backing::Policy);
1084            }
1085
1086            // The policy has not confirmed the slot. While halted past
1087            // the grace period, the primary's own fsync releases it,
1088            // marked. `cached_durable_pos` keeps what the policy
1089            // confirmed, so the next slot is evaluated against the policy
1090            // again rather than waved through on this weaker condition.
1091            if journal_pos.get() >= needed && self.degraded_release_open() {
1092                return GateOutcome::Open(Backing::PrimaryOnly);
1093            }
1094            hook.while_waiting();
1095            gate_waiter.idle();
1096        }
1097    }
1098
1099    /// Re-evaluate the policy on a slow timer while no batches flow, so
1100    /// the `policy_degraded` flag and the periodic warn track the
1101    /// cluster's real state even on a quiet node, and the next batch's
1102    /// gate starts from a fresh cached position rather than spinning from
1103    /// a stale one. A no-op until [`POLICY_CHECK_INTERVAL`] has passed
1104    /// since the last check (or the last batch).
1105    ///
1106    /// A pending change the evaluation finds durable is applied here too,
1107    /// so a quiet node reports the policy in force once the change is
1108    /// re-confirmed rather than at its next write.
1109    #[inline]
1110    pub(crate) fn idle_recheck(&mut self, now: Instant) {
1111        if now.duration_since(self.last_policy_check) < POLICY_CHECK_INTERVAL {
1112            return;
1113        }
1114        self.last_policy_check = now;
1115        let journal_pos = self.inputs.journal_persisted_wire_seq.load();
1116        let status = evaluate_durability(
1117            &self.policy,
1118            journal_pos,
1119            self.inputs.replication_metrics.as_deref(),
1120            self.inputs.replica_active.as_ref(),
1121        );
1122        self.degraded_logger.tick(
1123            &self.policy,
1124            &self.inputs.utilization,
1125            status.degraded,
1126            now,
1127            DEGRADED_LOG_INTERVAL,
1128        );
1129        self.cached_durable_pos = status.durable_pos;
1130        self.settle_pending_if_durable(journal_pos);
1131        // Track the halt on a quiet node too, so entering and leaving the
1132        // degraded release is logged when it happens rather than at the
1133        // next held reply. Whether it is open matters only to a slot
1134        // being held, and none is.
1135        self.degraded_release_open();
1136    }
1137
1138    /// Log degradation transitions / re-emit the reminder after a batch.
1139    /// Transitions are gated on a sustained-state hold so sub-second flap
1140    /// doesn't spam.
1141    ///
1142    /// Off a fresh clock read: with the gate evaluated per slot a batch
1143    /// can span several waits, and a timestamp taken at the start of the
1144    /// batch predates all of them. The accrual inside each wait already
1145    /// charges degraded time as it elapses; this tick decides
1146    /// transitions, so it wants the state and the timestamp as of the end
1147    /// of the batch. Bumps the idle path's check timestamp so the logger
1148    /// is not double-ticked when traffic stops.
1149    #[inline]
1150    pub(crate) fn after_batch(&mut self) {
1151        let ticked_at = Instant::now();
1152        let degraded_now = self
1153            .inputs
1154            .utilization
1155            .policy_degraded
1156            .load(Ordering::Relaxed);
1157        self.degraded_logger.tick(
1158            &self.policy,
1159            &self.inputs.utilization,
1160            degraded_now,
1161            ticked_at,
1162            DEGRADED_LOG_INTERVAL,
1163        );
1164        self.last_policy_check = ticked_at;
1165    }
1166}
1167
1168/// When the current halt began, given the presence's `loss` and the
1169/// start the gate last recorded for it (`seen`, `None` if it has not seen
1170/// this halt before). A stamped loss is authoritative: it is the *latest*
1171/// one, so a replica that joined and left again between two of the gate's
1172/// looks (unseen while idle, checked once a second) restarts the grace
1173/// period. A missing stamp falls back to the earliest sighting of the
1174/// halt, `now` on the first, so the halt still ages from it.
1175fn halt_start(loss: LastLoss, seen: Option<Instant>, now: Instant) -> Instant {
1176    match loss {
1177        LastLoss::At(at) => at,
1178        LastLoss::Unknown => seen.unwrap_or(now),
1179    }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use std::sync::mpsc;
1185
1186    use melin_transport_core::WireSeq;
1187    use melin_transport_core::lineage::{ForcedPolicy, ForcedPolicyWriter, forced_policy_cell};
1188    use melin_transport_core::replica_presence::ReplicaPresence;
1189
1190    use super::*;
1191    use crate::policy_control::test_support::lineage;
1192
1193    /// A byte no `AckPolicy` maps to: a policy from a newer build, or a
1194    /// bug.
1195    const CORRUPT: u8 = 0xEE;
1196
1197    /// A gate starting under `initial`, nothing pending, and the writer of
1198    /// its forced cell.
1199    fn build(
1200        initial: AckPolicy,
1201        journal_pos: u64,
1202        replication_metrics: Option<Arc<ReplicationMetrics>>,
1203        replica_active: Option<[Arc<AtomicBool>; 2]>,
1204    ) -> (DurabilityGate, ForcedPolicyWriter) {
1205        build_from(
1206            GateStart::settled(initial),
1207            journal_pos,
1208            replication_metrics,
1209            replica_active,
1210        )
1211    }
1212
1213    /// A gate starting as `start`, and the writer of its forced cell.
1214    fn build_from(
1215        start: GateStart,
1216        journal_pos: u64,
1217        replication_metrics: Option<Arc<ReplicationMetrics>>,
1218        replica_active: Option<[Arc<AtomicBool>; 2]>,
1219    ) -> (DurabilityGate, ForcedPolicyWriter) {
1220        let (forced_writer, forced) = forced_policy_cell();
1221        let inputs = GateInputs {
1222            journal_persisted_wire_seq: DurableWireSeqCursor::detached(WireSeq::new(journal_pos)),
1223            replication_metrics,
1224            replica_active,
1225            utilization: Arc::new(StageUtilization::new()),
1226            wait: WaitStrategy::SpinThenYield,
1227            degraded_release: None,
1228        };
1229        let policy = GatePolicy { start, forced };
1230        (DurabilityGate::new(inputs, policy, "test"), forced_writer)
1231    }
1232
1233    /// A start under `in_force`, with the lineage's change to `policy` at
1234    /// `wire_seq` pending.
1235    fn pending_start(
1236        in_force: AckPolicy,
1237        wire_seq: u64,
1238        policy: AckPolicy,
1239        unwitnessed: bool,
1240    ) -> GateStart {
1241        GateStart {
1242            in_force,
1243            pending: Some(PendingChange {
1244                wire_seq: WireSeq::new(wire_seq),
1245                policy,
1246                unwitnessed,
1247            }),
1248        }
1249    }
1250
1251    /// The forced change at `wire_seq` to `policy`.
1252    fn forced_change(wire_seq: u64, policy: AckPolicyByte) -> ForcedPolicy {
1253        ForcedPolicy {
1254            wire_seq: WireSeq::new(wire_seq),
1255            policy,
1256        }
1257    }
1258
1259    /// The gate has applied every change up to the one at `wire_seq`.
1260    #[track_caller]
1261    fn assert_applied_through(gate: &DurabilityGate, wire_seq: u64) {
1262        assert_eq!(gate.applied_through, Some(WireSeq::new(wire_seq)));
1263    }
1264
1265    /// A standalone node's gate (no replicas), so `disk` is satisfied by
1266    /// the local journal alone and `disk+ram` is structurally
1267    /// unsatisfiable — degraded from the start.
1268    fn gate(policy: AckPolicy, journal_pos: u64) -> DurabilityGate {
1269        build(policy, journal_pos, None, None).0
1270    }
1271
1272    /// A primary's gate with both replicas connected and caught up to the
1273    /// journal, so every policy opens at `pos`.
1274    fn gate_with_replicas(policy: AckPolicy, pos: u64) -> DurabilityGate {
1275        let metrics = ReplicationMetrics::default();
1276        for slot in 0..2 {
1277            metrics.acked_sequence[slot].store(pos, Ordering::Relaxed);
1278            metrics.in_memory_sequence[slot].store(pos, Ordering::Relaxed);
1279        }
1280        build(
1281            policy,
1282            pos,
1283            Some(Arc::new(metrics)),
1284            Some([
1285                Arc::new(AtomicBool::new(true)),
1286                Arc::new(AtomicBool::new(true)),
1287            ]),
1288        )
1289        .0
1290    }
1291
1292    /// A primary's gate with one replica connected, holding nothing yet
1293    /// (both its cursors at 0): `disk` opens on the journal alone, and
1294    /// `disk+ram` waits for the replica's in-memory cursor, which the
1295    /// test moves through the returned metrics.
1296    fn gate_with_one_replica(
1297        initial: AckPolicy,
1298        journal_pos: u64,
1299    ) -> (DurabilityGate, Arc<ReplicationMetrics>, ForcedPolicyWriter) {
1300        gate_with_one_replica_from(GateStart::settled(initial), journal_pos)
1301    }
1302
1303    /// [`gate_with_one_replica`], starting as `start`.
1304    fn gate_with_one_replica_from(
1305        start: GateStart,
1306        journal_pos: u64,
1307    ) -> (DurabilityGate, Arc<ReplicationMetrics>, ForcedPolicyWriter) {
1308        let metrics = Arc::new(ReplicationMetrics::default());
1309        let (gate, forced) = build_from(
1310            start,
1311            journal_pos,
1312            Some(Arc::clone(&metrics)),
1313            Some([
1314                Arc::new(AtomicBool::new(true)),
1315                Arc::new(AtomicBool::new(false)),
1316            ]),
1317        );
1318        (gate, metrics, forced)
1319    }
1320
1321    /// The replica of [`gate_with_one_replica`] now holds every event up
1322    /// to `pos` in memory.
1323    fn replica_holds(metrics: &ReplicationMetrics, pos: u64) {
1324        metrics.in_memory_sequence[0].store(pos, Ordering::Release);
1325    }
1326
1327    /// The replica of [`gate_with_one_replica`] has journaled every event
1328    /// up to `pos` (and so holds it in memory too).
1329    fn replica_persists(metrics: &ReplicationMetrics, pos: u64) {
1330        replica_holds(metrics, pos);
1331        metrics.acked_sequence[0].store(pos, Ordering::Release);
1332    }
1333
1334    /// An application slot (no policy change) for the event at `wire_seq`.
1335    fn reply(wire_seq: u64) -> OutputSlot<u8, u8> {
1336        OutputSlot {
1337            wire_seq,
1338            payload: OutputPayload::Report(0),
1339            is_last_in_request: true,
1340            ..OutputSlot::default()
1341        }
1342    }
1343
1344    /// The output slot of a policy change journaled at `wire_seq`.
1345    fn change(wire_seq: u64, policy: AckPolicy, unwitnessed: bool) -> OutputSlot<u8, u8> {
1346        change_byte(wire_seq, policy.to_byte(), unwitnessed)
1347    }
1348
1349    fn change_byte(wire_seq: u64, policy: AckPolicyByte, unwitnessed: bool) -> OutputSlot<u8, u8> {
1350        OutputSlot {
1351            wire_seq,
1352            payload: OutputPayload::AckPolicyChange(melin_journal::AckPolicyChange {
1353                policy,
1354                unwitnessed,
1355            }),
1356            ..OutputSlot::default()
1357        }
1358    }
1359
1360    /// A gate hook that does nothing.
1361    struct NoHook;
1362
1363    impl GateWait for NoHook {
1364        fn before_wait(&mut self) {}
1365        fn while_waiting(&mut self) {}
1366    }
1367
1368    /// A gate hook that records its calls, in order, and runs `during` on
1369    /// each turn of a wait.
1370    struct Recording<F: FnMut()> {
1371        calls: Vec<&'static str>,
1372        during: F,
1373    }
1374
1375    impl<F: FnMut()> Recording<F> {
1376        fn new(during: F) -> Self {
1377            Self {
1378                calls: Vec::new(),
1379                during,
1380            }
1381        }
1382    }
1383
1384    impl<F: FnMut()> GateWait for Recording<F> {
1385        fn before_wait(&mut self) {
1386            self.calls.push("before");
1387        }
1388        fn while_waiting(&mut self) {
1389            self.calls.push("during");
1390            (self.during)();
1391        }
1392    }
1393
1394    fn admit(
1395        gate: &mut DurabilityGate,
1396        slot: &OutputSlot<u8, u8>,
1397        shutdown: &AtomicBool,
1398    ) -> Admission {
1399        #[cfg(feature = "tick-to-trade")]
1400        let mut tracker = GateCrossTracker::new(slot.wire_seq);
1401        gate.admit(
1402            slot,
1403            shutdown,
1404            #[cfg(feature = "tick-to-trade")]
1405            &mut tracker,
1406            &mut NoHook,
1407        )
1408    }
1409
1410    /// How long a held slot is watched before it is released: long enough
1411    /// that a gate wrongly open would have returned.
1412    const HOLD: Duration = Duration::from_millis(50);
1413
1414    /// How long a slot may take to open once nothing holds it.
1415    const OPEN_WITHIN: Duration = Duration::from_secs(10);
1416
1417    /// Admit `slot` on a helper thread; after `hold` (if any) record
1418    /// whether it is still waiting, run `release`, and give it
1419    /// [`OPEN_WITHIN`] to open. Shutdown stops a gate that never opens, so
1420    /// a regression fails the test instead of hanging it. Returns whether
1421    /// it was held, and whether it then opened.
1422    fn admit_watched(
1423        gate: &mut DurabilityGate,
1424        slot: &OutputSlot<u8, u8>,
1425        hold: Option<Duration>,
1426        release: impl FnOnce(),
1427    ) -> (bool, bool) {
1428        let shutdown = AtomicBool::new(false);
1429        std::thread::scope(|s| {
1430            let admitting = s.spawn(|| admit(gate, slot, &shutdown));
1431            let held = hold.is_some_and(|hold| {
1432                std::thread::sleep(hold);
1433                !admitting.is_finished()
1434            });
1435            release();
1436            let deadline = Instant::now() + OPEN_WITHIN;
1437            while !admitting.is_finished() && Instant::now() < deadline {
1438                std::thread::sleep(Duration::from_millis(1));
1439            }
1440            shutdown.store(true, Ordering::Relaxed);
1441            let outcome = admitting.join().expect("the admitting thread panicked");
1442            (held, outcome != Admission::Shutdown)
1443        })
1444    }
1445
1446    /// Admit `slot`: it must open on the state as it is.
1447    #[track_caller]
1448    fn admit_now(gate: &mut DurabilityGate, slot: &OutputSlot<u8, u8>) {
1449        let (_, opened) = admit_watched(gate, slot, None, || {});
1450        assert!(opened, "slot {} did not open", slot.wire_seq);
1451    }
1452
1453    /// Admit `slot`: it must still be held after [`HOLD`], and open once
1454    /// `release` has run.
1455    #[track_caller]
1456    fn assert_held_until(
1457        gate: &mut DurabilityGate,
1458        slot: &OutputSlot<u8, u8>,
1459        release: impl FnOnce(),
1460    ) {
1461        let (held, opened) = admit_watched(gate, slot, Some(HOLD), release);
1462        assert!(
1463            held,
1464            "slot {} was admitted before its release",
1465            slot.wire_seq
1466        );
1467        assert!(
1468            opened,
1469            "slot {} stayed held after its release",
1470            slot.wire_seq
1471        );
1472    }
1473
1474    /// The policy the health gauge reports in force.
1475    fn in_force(gate: &DurabilityGate) -> Option<AckPolicy> {
1476        gate.inputs
1477            .utilization
1478            .ack_policy_in_force
1479            .load()
1480            .map(|byte| AckPolicy::from_byte(byte).expect("a known policy"))
1481    }
1482
1483    fn wait(gate: &mut DurabilityGate, needed: u64, shutdown: &AtomicBool) -> GateOutcome {
1484        #[cfg(feature = "tick-to-trade")]
1485        let mut tracker = GateCrossTracker::new(needed);
1486        gate.wait_durable(
1487            needed,
1488            shutdown,
1489            #[cfg(feature = "tick-to-trade")]
1490            &mut tracker,
1491            &mut NoHook,
1492        )
1493    }
1494
1495    fn degraded_nanos(gate: &DurabilityGate) -> u64 {
1496        gate.inputs
1497            .utilization
1498            .policy_degraded_nanos
1499            .load(Ordering::Relaxed)
1500    }
1501
1502    fn attributions(gate: &DurabilityGate) -> (u64, u64) {
1503        let u = &gate.inputs.utilization;
1504        (
1505            u.gate_journal.load(Ordering::Relaxed),
1506            u.gate_replication.load(Ordering::Relaxed),
1507        )
1508    }
1509
1510    /// Let measurable time pass so a degraded-time flush is non-zero.
1511    fn let_time_pass() {
1512        std::thread::sleep(Duration::from_millis(2));
1513    }
1514
1515    #[test]
1516    fn startup_resolves_the_policy_and_its_durable_position() {
1517        let g = gate(AckPolicy::Disk, 5);
1518        assert_eq!(g.active_policy, AckPolicy::Disk);
1519        assert_eq!(g.cached_durable_pos, 5);
1520        assert!(!g.inputs.utilization.policy_degraded.load(Ordering::Relaxed));
1521
1522        let g = gate(AckPolicy::DiskAndRam, 5);
1523        assert_eq!(g.cached_durable_pos, 0, "an unsatisfiable policy pins 0");
1524        assert!(g.inputs.utilization.policy_degraded.load(Ordering::Relaxed));
1525    }
1526
1527    /// A swap rebuilds the policy, drops the cached position, and
1528    /// re-seeds the logger — flushing the degraded time accrued under
1529    /// the old policy, which is how the re-seed shows from outside.
1530    #[test]
1531    fn a_switch_resets_the_cached_position_and_reseeds_the_logger() {
1532        let mut g = gate(AckPolicy::Disk, 5);
1533        g.apply_change(WireSeq::new(1), AckPolicy::TwoDisks.to_byte(), "test");
1534        assert_eq!(g.active_policy, AckPolicy::TwoDisks);
1535        assert_eq!(g.policy, AckPolicy::TwoDisks.to_policy());
1536        assert_eq!(g.cached_durable_pos, 0);
1537        assert_eq!(in_force(&g), Some(AckPolicy::TwoDisks));
1538
1539        let mut g = gate(AckPolicy::DiskAndRam, 5);
1540        let_time_pass();
1541        g.apply_change(WireSeq::new(1), AckPolicy::Disk.to_byte(), "test");
1542        assert_eq!(g.active_policy, AckPolicy::Disk);
1543        assert!(
1544            degraded_nanos(&g) > 0,
1545            "the re-seed flushes the degraded interval before the switch"
1546        );
1547    }
1548
1549    /// A change repeating the policy in force is applied (a later change at
1550    /// or below it is stale) but switches nothing: the cached position is
1551    /// kept and the logger is not re-seeded.
1552    #[test]
1553    fn a_change_to_the_policy_in_force_is_applied_without_a_switch() {
1554        let mut g = gate(AckPolicy::Disk, 5);
1555        g.apply_change(WireSeq::new(3), AckPolicy::Disk.to_byte(), "test");
1556        assert_eq!(g.active_policy, AckPolicy::Disk);
1557        assert_eq!(g.cached_durable_pos, 5, "no switch: cached position kept");
1558        assert_applied_through(&g, 3);
1559        g.apply_change(WireSeq::new(2), AckPolicy::TwoDisks.to_byte(), "test");
1560        assert_eq!(g.active_policy, AckPolicy::Disk, "stale behind it");
1561
1562        let mut g = gate(AckPolicy::DiskAndRam, 5);
1563        let_time_pass();
1564        g.apply_change(WireSeq::new(1), AckPolicy::DiskAndRam.to_byte(), "test");
1565        assert_eq!(degraded_nanos(&g), 0, "no re-seed, so nothing flushed");
1566    }
1567
1568    /// While replication is on, the gate starts under the policy before
1569    /// the lineage's newest change, with the change pending (unwitnessed
1570    /// as journaled); under the newest one when it is the lineage's first
1571    /// change. A lineage with none takes the requested policy; with
1572    /// replication disabled the requested one wins, nothing pending (the
1573    /// standalone rule); an unknown lineage byte, newest or previous, is
1574    /// refused.
1575    #[test]
1576    fn the_initial_policy_is_the_lineages_with_its_newest_change_pending() {
1577        let disk = AckPolicy::Disk.to_byte();
1578        let two_disks = AckPolicy::TwoDisks.to_byte();
1579        let corrupt = AckPolicyByte(CORRUPT);
1580        use Replication::{Disabled, Enabled};
1581        for replication in [Enabled, Disabled] {
1582            assert_eq!(
1583                initial_gate_policy(None, AckPolicy::Disk, replication),
1584                Ok(GateStart::settled(AckPolicy::Disk))
1585            );
1586            let refused = initial_gate_policy(
1587                lineage(corrupt, false, 3, None),
1588                AckPolicy::Disk,
1589                replication,
1590            )
1591            .expect_err("an unknown byte is refused");
1592            assert!(refused.contains("238"), "names the byte: {refused}");
1593            assert_eq!(
1594                initial_gate_policy(
1595                    lineage(disk, true, 9, Some(two_disks)),
1596                    AckPolicy::Ram,
1597                    replication,
1598                ),
1599                Ok(match replication {
1600                    Enabled => pending_start(AckPolicy::TwoDisks, 9, AckPolicy::Disk, true),
1601                    Disabled => GateStart::settled(AckPolicy::Ram),
1602                }),
1603                "replication {replication:?}"
1604            );
1605        }
1606        assert_eq!(
1607            initial_gate_policy(lineage(two_disks, false, 1, None), AckPolicy::Disk, Enabled),
1608            Ok(GateStart::settled(AckPolicy::TwoDisks)),
1609            "the lineage's first change: nothing to apply again"
1610        );
1611        assert_eq!(
1612            initial_gate_policy(
1613                lineage(disk, false, 4, Some(two_disks)),
1614                AckPolicy::Disk,
1615                Enabled
1616            ),
1617            Ok(pending_start(
1618                AckPolicy::TwoDisks,
1619                4,
1620                AckPolicy::Disk,
1621                false
1622            )),
1623            "requested equal to the newest: still pending"
1624        );
1625        let refused = initial_gate_policy(
1626            lineage(disk, false, 4, Some(corrupt)),
1627            AckPolicy::Disk,
1628            Enabled,
1629        )
1630        .expect_err("an unknown previous byte is refused");
1631        assert!(refused.contains("238"), "names the byte: {refused}");
1632    }
1633
1634    /// The restart the lineage's pending change exists for: under
1635    /// `disk+ram`, the change to `disk` at 5 was journaled but the
1636    /// replica may never have held it. The gate starts under `disk+ram`
1637    /// (and says so), holds the first slot after the change, though it is
1638    /// on disk, until the replica holds the change, then applies it in
1639    /// order: that slot and the next open on the disk alone.
1640    #[test]
1641    fn a_pending_change_is_held_until_durable_under_the_policy_before_it() {
1642        let (mut g, metrics, _forced) = gate_with_one_replica_from(
1643            pending_start(AckPolicy::DiskAndRam, 5, AckPolicy::Disk, false),
1644            10,
1645        );
1646        assert_eq!(in_force(&g), Some(AckPolicy::DiskAndRam));
1647        replica_holds(&metrics, 4);
1648        assert_held_until(&mut g, &reply(8), || replica_holds(&metrics, 5));
1649        assert_eq!(g.active_policy, AckPolicy::Disk);
1650        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
1651        assert!(g.pending.is_empty(), "consumed");
1652        assert_applied_through(&g, 5);
1653        admit_now(&mut g, &reply(9));
1654    }
1655
1656    /// An unwitnessed pending change (a forced swap, or a standalone run)
1657    /// is applied once its entry is on this node's disk, as it was when
1658    /// journaled: at once on a restart, where it already is, and after
1659    /// the local fsync on a promoted replica still writing it.
1660    #[test]
1661    fn an_unwitnessed_pending_change_is_applied_once_on_disk() {
1662        // On disk already: applied as the gate starts.
1663        let (g, _) = build_from(
1664            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Disk, true),
1665            4,
1666            None,
1667            None,
1668        );
1669        assert_eq!(g.active_policy, AckPolicy::Disk);
1670        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
1671        assert!(g.pending.is_empty());
1672
1673        // Not yet on disk, and no replica: `disk+ram` cannot open, so the
1674        // slot is held until the change's entry is durable locally.
1675        let (mut g, _) = build_from(
1676            pending_start(AckPolicy::DiskAndRam, 6, AckPolicy::Disk, true),
1677            4,
1678            None,
1679            None,
1680        );
1681        assert_eq!(in_force(&g), Some(AckPolicy::DiskAndRam));
1682        let journal = g.inputs.journal_persisted_wire_seq.clone();
1683        assert_held_until(&mut g, &reply(7), || journal.store(WireSeq::new(7)));
1684        assert_eq!(g.active_policy, AckPolicy::Disk);
1685        assert_applied_through(&g, 6);
1686    }
1687
1688    /// A pending change the cluster already confirms is applied as the
1689    /// gate starts, before any slot: both replicas hold it.
1690    #[test]
1691    fn a_pending_change_already_durable_is_applied_at_the_start() {
1692        let metrics = ReplicationMetrics::default();
1693        for slot in 0..2 {
1694            metrics.in_memory_sequence[slot].store(6, Ordering::Relaxed);
1695        }
1696        let (g, _) = build_from(
1697            pending_start(AckPolicy::Ram, 3, AckPolicy::Disk, false),
1698            6,
1699            Some(Arc::new(metrics)),
1700            Some([
1701                Arc::new(AtomicBool::new(true)),
1702                Arc::new(AtomicBool::new(true)),
1703            ]),
1704        );
1705        assert_eq!(g.active_policy, AckPolicy::Disk);
1706        assert!(g.pending.is_empty());
1707    }
1708
1709    /// A restart under a different requested policy journals a boot entry
1710    /// after the pending change: the pending change is applied first,
1711    /// under the policy before it, then the boot entry in order under the
1712    /// pending change's policy. Neither is merged into the other.
1713    #[test]
1714    fn a_boot_change_after_a_pending_one_is_applied_after_it() {
1715        let (mut g, metrics, _forced) = gate_with_one_replica_from(
1716            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Disk, false),
1717            10,
1718        );
1719        assert_held_until(&mut g, &change(4, AckPolicy::TwoDisks, false), || {
1720            replica_holds(&metrics, 3)
1721        });
1722        assert_eq!(g.active_policy, AckPolicy::TwoDisks);
1723        // `two-disks` wants the replica's journal, not its memory.
1724        replica_holds(&metrics, 5);
1725        assert_held_until(&mut g, &reply(5), || replica_persists(&metrics, 5));
1726    }
1727
1728    /// The idle path applies a pending change the cluster has confirmed,
1729    /// so a quiet node reports it in force; before that it keeps it
1730    /// pending.
1731    #[test]
1732    fn the_idle_recheck_applies_a_confirmed_pending_change() {
1733        let (mut g, metrics, _forced) = gate_with_one_replica_from(
1734            pending_start(AckPolicy::DiskAndRam, 5, AckPolicy::Disk, false),
1735            10,
1736        );
1737        let checked = g.last_policy_check;
1738        replica_holds(&metrics, 4);
1739        g.idle_recheck(checked + POLICY_CHECK_INTERVAL);
1740        assert_eq!(in_force(&g), Some(AckPolicy::DiskAndRam), "not yet held");
1741        assert!(!g.pending.is_empty());
1742
1743        replica_holds(&metrics, 5);
1744        g.idle_recheck(checked + 2 * POLICY_CHECK_INTERVAL);
1745        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
1746        assert!(g.pending.is_empty());
1747    }
1748
1749    /// A forced change of this tenure overtakes a pending one the old
1750    /// policy cannot confirm (no replica): the slot opens once the forced
1751    /// entry is on disk, and the pending change, below it, is stale.
1752    #[test]
1753    fn a_forced_change_overtakes_a_pending_one() {
1754        let (mut g, mut forced) = build_from(
1755            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Ram, false),
1756            4,
1757            None,
1758            None,
1759        );
1760        let journal = g.inputs.journal_persisted_wire_seq.clone();
1761        forced.store(forced_change(5, AckPolicy::Disk.to_byte()));
1762        assert_held_until(&mut g, &reply(6), || journal.store(WireSeq::new(6)));
1763        assert_eq!(
1764            g.active_policy,
1765            AckPolicy::Disk,
1766            "not the stale pending `ram`"
1767        );
1768        assert_applied_through(&g, 5);
1769        assert!(g.pending.is_empty());
1770    }
1771
1772    /// Shutdown stops a wait on the pending change, which stays pending.
1773    #[test]
1774    fn shutdown_releases_a_wait_on_the_pending_change() {
1775        let (mut g, _) = build_from(
1776            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Disk, false),
1777            4,
1778            None,
1779            None,
1780        );
1781        let shutdown = AtomicBool::new(true);
1782        assert_eq!(admit(&mut g, &reply(4), &shutdown), Admission::Shutdown);
1783        assert!(!g.pending.is_empty());
1784        assert_eq!(g.active_policy, AckPolicy::DiskAndRam);
1785    }
1786
1787    #[test]
1788    fn an_open_gate_attributes_exactly_once() {
1789        let mut g = gate(AckPolicy::Disk, 5);
1790        let shutdown = AtomicBool::new(false);
1791        assert!(matches!(
1792            wait(&mut g, 3, &shutdown),
1793            GateOutcome::Open(Backing::Policy)
1794        ));
1795        assert_eq!(attributions(&g), (1, 0), "disk opens on the journal");
1796        assert_eq!(g.cached_durable_pos, 5);
1797    }
1798
1799    /// A wedged gate (every replica gone under `disk+ram`) still honours
1800    /// shutdown, and the reply it never confirmed is not attributed.
1801    /// Run on a helper thread so a regression fails the test instead of
1802    /// hanging it.
1803    #[test]
1804    fn shutdown_releases_a_wedged_gate_without_attribution() {
1805        let (tx, rx) = mpsc::channel();
1806        std::thread::spawn(move || {
1807            let mut g = gate(AckPolicy::DiskAndRam, 5);
1808            let shutdown = AtomicBool::new(true);
1809            let outcome = wait(&mut g, 3, &shutdown);
1810            // The receiver only disappears once the test has failed on
1811            // its timeout; nothing is left to report to.
1812            let _ = tx.send((matches!(outcome, GateOutcome::Shutdown), attributions(&g)));
1813        });
1814        let (was_shutdown, attributed) = rx
1815            .recv_timeout(Duration::from_secs(10))
1816            .expect("the wedged gate ignored shutdown");
1817        assert!(was_shutdown);
1818        assert_eq!(attributed, (0, 0));
1819    }
1820
1821    /// Shutdown is checked before the gate is evaluated: a gate that
1822    /// would open on this pass still reports `Shutdown`, and attributes
1823    /// nothing, so a reply is never released during teardown.
1824    #[test]
1825    fn shutdown_wins_over_a_gate_ready_to_open() {
1826        let mut g = gate_with_replicas(AckPolicy::Disk, 5);
1827        let shutdown = AtomicBool::new(true);
1828        assert!(matches!(wait(&mut g, 3, &shutdown), GateOutcome::Shutdown));
1829        assert_eq!(attributions(&g), (0, 0));
1830    }
1831
1832    /// A change takes effect at its slot: the slot before it is gated
1833    /// under the old policy, the slot after it under the new one, and the
1834    /// health gauge follows.
1835    #[test]
1836    fn a_change_applies_from_its_own_slot_on() {
1837        let (mut g, metrics, _forced) = gate_with_one_replica(AckPolicy::Disk, 10);
1838        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
1839        admit_now(&mut g, &reply(4));
1840        admit_now(&mut g, &change(5, AckPolicy::DiskAndRam, false));
1841        assert_eq!(g.active_policy, AckPolicy::DiskAndRam);
1842        assert_eq!(in_force(&g), Some(AckPolicy::DiskAndRam));
1843        // On disk already, but `disk+ram` also wants the replica.
1844        assert_held_until(&mut g, &reply(6), || replica_holds(&metrics, 6));
1845    }
1846
1847    /// A reply goes to its own connection; a change slot to none, even one
1848    /// addressed to a client, applied or stale alike.
1849    #[test]
1850    fn a_change_slot_goes_to_no_connection() {
1851        let mut g = gate(AckPolicy::Disk, 10);
1852        let shutdown = AtomicBool::new(false);
1853        let to = |connection_id, slot: OutputSlot<u8, u8>| OutputSlot {
1854            connection_id,
1855            ..slot
1856        };
1857        assert_eq!(
1858            admit(&mut g, &to(9, reply(3)), &shutdown),
1859            Admission::Reply {
1860                connection_id: 9,
1861                backing: Backing::Policy
1862            }
1863        );
1864        assert_eq!(
1865            admit(&mut g, &to(9, change(4, AckPolicy::Disk, true)), &shutdown),
1866            Admission::GateOnly
1867        );
1868        assert_applied_through(&g, 4);
1869        assert_eq!(
1870            admit(&mut g, &to(9, change(4, AckPolicy::Ram, false)), &shutdown),
1871            Admission::GateOnly,
1872            "stale, and still the gate's"
1873        );
1874        assert_eq!(g.active_policy, AckPolicy::Disk, "the stale one ignored");
1875    }
1876
1877    /// A change waits until the policy before it has cleared its entry:
1878    /// under `disk+ram`, a switch to `disk` holds until the replica has
1879    /// the entry, and only the slots after it then open on the journal
1880    /// alone.
1881    #[test]
1882    fn a_change_is_held_until_the_old_policy_clears_its_entry() {
1883        let (mut g, metrics, _forced) = gate_with_one_replica(AckPolicy::DiskAndRam, 10);
1884        assert_held_until(&mut g, &change(5, AckPolicy::Disk, false), || {
1885            replica_holds(&metrics, 5)
1886        });
1887        assert_eq!(g.active_policy, AckPolicy::Disk);
1888        admit_now(&mut g, &reply(8));
1889    }
1890
1891    /// Two pending changes are applied one by one, never merged: between
1892    /// `disk` → `disk+ram` at 3 and back to `disk` at 6, the slots are
1893    /// gated under `disk+ram`, and the second change itself waits for it.
1894    #[test]
1895    fn two_pending_changes_are_never_merged() {
1896        let (mut g, metrics, _forced) = gate_with_one_replica(AckPolicy::Disk, 10);
1897        admit_now(&mut g, &change(3, AckPolicy::DiskAndRam, false));
1898        assert_held_until(&mut g, &reply(4), || replica_holds(&metrics, 4));
1899        assert_held_until(&mut g, &change(6, AckPolicy::Disk, false), || {
1900            replica_holds(&metrics, 6)
1901        });
1902        assert_eq!(g.active_policy, AckPolicy::Disk);
1903        admit_now(&mut g, &reply(7));
1904    }
1905
1906    /// A replicated primary's gate with degraded release, and the levers a
1907    /// test pulls on it: the journal cursor, one replica slot's cursors and
1908    /// active flag, the replica presence the halt is judged on, and the
1909    /// forced cell's writer.
1910    struct Primary {
1911        gate: DurabilityGate,
1912        journal: DurableWireSeqCursor,
1913        metrics: Arc<ReplicationMetrics>,
1914        active: Arc<AtomicBool>,
1915        presence: Arc<ReplicaPresence>,
1916        forced: ForcedPolicyWriter,
1917    }
1918
1919    impl Primary {
1920        /// One replica slot, streaming and caught up to `pos` when
1921        /// `connected`, else gone (the node halted since it started).
1922        /// `grace` is the degraded release's, `None` for the switch off.
1923        fn new(policy: AckPolicy, pos: u64, connected: bool, grace: Option<Duration>) -> Self {
1924            Self::starting(GateStart::settled(policy), pos, connected, grace)
1925        }
1926
1927        /// [`Primary::new`], starting as `start`.
1928        fn starting(start: GateStart, pos: u64, connected: bool, grace: Option<Duration>) -> Self {
1929            let metrics = Arc::new(ReplicationMetrics::default());
1930            let active = Arc::new(AtomicBool::new(false));
1931            let presence = Arc::new(ReplicaPresence::new());
1932            let journal = DurableWireSeqCursor::detached(WireSeq::new(pos));
1933            let (forced_writer, forced) = forced_policy_cell();
1934            let inputs = GateInputs {
1935                journal_persisted_wire_seq: journal.clone(),
1936                replication_metrics: Some(Arc::clone(&metrics)),
1937                replica_active: Some([Arc::clone(&active), Arc::new(AtomicBool::new(false))]),
1938                utilization: Arc::new(StageUtilization::new()),
1939                wait: WaitStrategy::SpinThenYield,
1940                degraded_release: grace.map(|grace| DegradedRelease {
1941                    presence: Arc::clone(&presence),
1942                    grace,
1943                }),
1944            };
1945            let p = Self {
1946                gate: DurabilityGate::new(inputs, GatePolicy { start, forced }, "test"),
1947                journal,
1948                metrics,
1949                active,
1950                presence,
1951                forced: forced_writer,
1952            };
1953            if connected {
1954                p.replica_streams(pos);
1955            }
1956            p
1957        }
1958
1959        /// The replica leaves, as a sender records it.
1960        fn replica_leaves(&self) {
1961            self.active.store(false, Ordering::Release);
1962            self.presence.lower();
1963        }
1964
1965        /// A replica is counted and streams, holding everything up to
1966        /// `pos` on disk.
1967        fn replica_streams(&self, pos: u64) {
1968            drop(self.presence.lift());
1969            replica_persists(&self.metrics, pos);
1970            self.active.store(true, Ordering::Release);
1971        }
1972
1973        fn journal_at(&self, pos: u64) {
1974            self.journal.store(WireSeq::new(pos));
1975        }
1976
1977        fn degraded_acks(&self) -> u64 {
1978            self.gate
1979                .inputs
1980                .utilization
1981                .degraded_acks
1982                .load(Ordering::Relaxed)
1983        }
1984
1985        /// Admit `slot`, giving up after `patience`: a timer raises the
1986        /// shutdown flag, since a gate that holds the slot never returns
1987        /// on its own. `None` is "still held when patience ran out".
1988        fn admit_within(
1989            &mut self,
1990            slot: &OutputSlot<u8, u8>,
1991            patience: Duration,
1992        ) -> Option<Admission> {
1993            let shutdown = AtomicBool::new(false);
1994            std::thread::scope(|s| {
1995                let gate = &mut self.gate;
1996                let admitting = s.spawn(|| admit(gate, slot, &shutdown));
1997                let deadline = Instant::now() + patience;
1998                while !admitting.is_finished() && Instant::now() < deadline {
1999                    std::thread::sleep(Duration::from_millis(1));
2000                }
2001                shutdown.store(true, Ordering::Relaxed);
2002                match admitting.join().expect("the admitting thread panicked") {
2003                    Admission::Shutdown => None,
2004                    admitted => Some(admitted),
2005                }
2006            })
2007        }
2008
2009        /// How a reply slot at `wire_seq` is released within `patience`,
2010        /// or `None` if it is still held.
2011        fn release(&mut self, wire_seq: u64, patience: Duration) -> Option<Backing> {
2012            self.release_slot(&reply(wire_seq), patience)
2013        }
2014
2015        /// How `slot`, which answers a client, is released within
2016        /// `patience`, or `None` if it is still held.
2017        fn release_slot(
2018            &mut self,
2019            slot: &OutputSlot<u8, u8>,
2020            patience: Duration,
2021        ) -> Option<Backing> {
2022            match self.admit_within(slot, patience)? {
2023                Admission::Reply { backing, .. } => Some(backing),
2024                other => panic!("a reply slot admitted as {other:?}"),
2025            }
2026        }
2027    }
2028
2029    /// Long enough for a gate that releases to have done so; short
2030    /// enough that a held slot costs the suite little.
2031    const PATIENCE: Duration = Duration::from_millis(300);
2032
2033    /// A grace period that has long passed by the first check: the node
2034    /// halted as it started.
2035    const NO_GRACE: Option<Duration> = Some(Duration::ZERO);
2036
2037    #[test]
2038    fn no_degraded_release_before_the_grace_period() {
2039        let mut p = Primary::new(
2040            AckPolicy::DiskAndRam,
2041            5,
2042            false,
2043            Some(Duration::from_secs(60)),
2044        );
2045        assert_eq!(p.release(5, PATIENCE), None, "held for the grace period");
2046        assert_eq!(p.degraded_acks(), 0);
2047    }
2048
2049    #[test]
2050    fn a_halted_node_past_the_grace_releases_on_its_own_fsync_marked() {
2051        for policy in [AckPolicy::Ram, AckPolicy::DiskAndRam, AckPolicy::TwoDisks] {
2052            let mut p = Primary::new(policy, 5, false, NO_GRACE);
2053            assert_eq!(
2054                p.release(5, PATIENCE),
2055                Some(Backing::PrimaryOnly),
2056                "{policy}"
2057            );
2058            assert_eq!(p.gate.cached_durable_pos, 0, "the policy confirmed nothing");
2059        }
2060    }
2061
2062    #[test]
2063    fn the_grace_period_runs_from_the_loss() {
2064        let mut p = Primary::new(
2065            AckPolicy::DiskAndRam,
2066            5,
2067            true,
2068            Some(Duration::from_millis(150)),
2069        );
2070        p.journal_at(6);
2071        p.replica_leaves();
2072        let started = Instant::now();
2073        assert_eq!(
2074            p.release(6, Duration::from_secs(5)),
2075            Some(Backing::PrimaryOnly)
2076        );
2077        assert!(
2078            started.elapsed() >= Duration::from_millis(150),
2079            "released before the grace ran out: {:?}",
2080            started.elapsed()
2081        );
2082    }
2083
2084    /// A replica that joins and leaves again between two of the gate's
2085    /// looks, so the gate never sees the halt end, still restarts the
2086    /// grace period: it runs from the latest loss, not the first.
2087    #[test]
2088    fn an_unseen_rejoin_restarts_the_grace_period() {
2089        // Wide enough that a stalled runner between the second loss and
2090        // the first check still lands well inside the grace from it.
2091        let grace = Duration::from_millis(1_000);
2092        let mut p = Primary::new(AckPolicy::DiskAndRam, 5, true, Some(grace));
2093        p.replica_leaves();
2094        assert!(!p.gate.degraded_release_open(), "the halt has just begun");
2095        std::thread::sleep(Duration::from_millis(700));
2096        // The blip, between two looks.
2097        p.replica_streams(5);
2098        p.replica_leaves();
2099        let second_loss = Instant::now();
2100        std::thread::sleep(Duration::from_millis(400));
2101        // Past the grace from the first loss, not from the second.
2102        assert!(
2103            !p.gate.degraded_release_open(),
2104            "aged from the first loss: {:?} since the second",
2105            second_loss.elapsed()
2106        );
2107        std::thread::sleep(grace.saturating_sub(second_loss.elapsed()));
2108        assert!(
2109            p.gate.degraded_release_open(),
2110            "past the grace from the second loss"
2111        );
2112    }
2113
2114    /// A stamped loss is the halt's start, even over an earlier sighting
2115    /// (the unseen-rejoin case above); a missing stamp ages from the
2116    /// earliest sighting of the halt, or from now on the first.
2117    #[test]
2118    fn a_missing_loss_stamp_ages_from_the_first_sighting() {
2119        let seen = Instant::now();
2120        let now = seen + Duration::from_secs(10);
2121        let lost = seen + Duration::from_secs(3);
2122        assert_eq!(
2123            halt_start(LastLoss::Unknown, None, now),
2124            now,
2125            "first sighting"
2126        );
2127        assert_eq!(halt_start(LastLoss::Unknown, Some(seen), now), seen);
2128        assert_eq!(halt_start(LastLoss::At(lost), Some(seen), now), lost);
2129        assert_eq!(halt_start(LastLoss::At(lost), None, now), lost);
2130    }
2131
2132    /// The gate forgets a halt once it sees a replica streaming, and keeps
2133    /// the stamped start while halted.
2134    #[test]
2135    fn the_halt_start_is_tracked_while_halted_and_dropped_on_a_replica() {
2136        let mut p = Primary::new(AckPolicy::DiskAndRam, 5, true, NO_GRACE);
2137        assert!(!p.gate.degraded_release_open());
2138        assert_eq!(p.gate.halt_started, None);
2139
2140        let before = Instant::now();
2141        p.replica_leaves();
2142        assert!(p.gate.degraded_release_open());
2143        let started = p.gate.halt_started.expect("halted");
2144        assert!(started >= before, "the loss's own stamp");
2145        assert!(p.gate.degraded_release_open());
2146        assert_eq!(p.gate.halt_started, Some(started), "the same halt");
2147
2148        p.replica_streams(5);
2149        assert!(!p.gate.degraded_release_open());
2150        assert_eq!(p.gate.halt_started, None);
2151        assert!(!p.gate.releasing_degraded);
2152    }
2153
2154    /// Whether the policy needs a replica follows every switch, both ways.
2155    #[test]
2156    fn the_release_follows_a_switch_to_and_from_a_policy_needing_a_replica() {
2157        let mut p = Primary::new(AckPolicy::Disk, 5, false, NO_GRACE);
2158        assert!(!p.gate.degraded_release_open(), "disk needs no replica");
2159        p.gate.switch_to(AckPolicy::TwoDisks, "test");
2160        assert!(p.gate.degraded_release_open());
2161        assert!(p.gate.releasing_degraded);
2162        p.gate.switch_to(AckPolicy::Disk, "test");
2163        assert!(!p.gate.degraded_release_open());
2164        assert!(!p.gate.releasing_degraded);
2165    }
2166
2167    /// `disk` is met by the same cursor: every reply is a full one, halted
2168    /// or not, and nothing is counted as degraded.
2169    #[test]
2170    fn no_degraded_release_under_disk() {
2171        let mut p = Primary::new(AckPolicy::Disk, 5, false, NO_GRACE);
2172        assert_eq!(p.release(5, PATIENCE), Some(Backing::Policy));
2173        assert_eq!(p.release(6, PATIENCE), None, "not yet on the journal");
2174        assert!(!p.gate.releasing_degraded);
2175    }
2176
2177    #[test]
2178    fn no_degraded_release_with_the_switch_off() {
2179        let mut p = Primary::new(AckPolicy::DiskAndRam, 5, false, None);
2180        assert_eq!(p.release(5, PATIENCE), None, "the stall, unchanged");
2181        assert_eq!(p.degraded_acks(), 0);
2182    }
2183
2184    /// A replica that is connected but behind is backpressure, not a halt:
2185    /// its replies wait however long it lags.
2186    #[test]
2187    fn no_degraded_release_while_a_replica_is_connected_but_behind() {
2188        let mut p = Primary::new(AckPolicy::TwoDisks, 5, true, NO_GRACE);
2189        p.journal_at(9);
2190        assert_eq!(p.release(9, PATIENCE), None, "held for the replica");
2191    }
2192
2193    /// Under `ram` an event may be only in the primary's memory: the
2194    /// degraded release waits for the primary's fsync like any other.
2195    #[test]
2196    fn an_unfsynced_slot_under_ram_is_held_until_the_primary_fsyncs_it() {
2197        let mut p = Primary::new(AckPolicy::Ram, 5, false, NO_GRACE);
2198        assert_eq!(
2199            p.release(6, PATIENCE),
2200            None,
2201            "the primary's disk does not hold it yet"
2202        );
2203        p.journal_at(6);
2204        assert_eq!(p.release(6, PATIENCE), Some(Backing::PrimaryOnly));
2205    }
2206
2207    #[test]
2208    fn a_full_ack_resumes_once_a_replica_streams_again() {
2209        let mut p = Primary::new(AckPolicy::DiskAndRam, 5, false, NO_GRACE);
2210        assert_eq!(p.release(5, PATIENCE), Some(Backing::PrimaryOnly));
2211        assert!(p.gate.releasing_degraded);
2212
2213        p.journal_at(7);
2214        p.replica_streams(7);
2215        assert_eq!(
2216            p.release(6, PATIENCE),
2217            Some(Backing::Policy),
2218            "the policy is met again"
2219        );
2220        assert_eq!(p.release(7, PATIENCE), Some(Backing::Policy));
2221        assert_eq!(p.gate.cached_durable_pos, 7);
2222
2223        // A connected replica that falls behind holds replies again: the
2224        // degraded release stopped with the halt.
2225        p.journal_at(9);
2226        assert_eq!(p.release(9, PATIENCE), None);
2227        assert!(!p.gate.releasing_degraded);
2228    }
2229
2230    /// Chosen behaviour, pinned: a query answered during a halt over state
2231    /// every replica had confirmed before it left is marked `PrimaryOnly`
2232    /// (it ends in `BatchEndDegraded`) and counted in
2233    /// `melin_degraded_acks_total`, like any other reply the halted node
2234    /// releases on its own disk.
2235    ///
2236    /// The gate keeps no record of what the policy confirmed before the
2237    /// loss. Once it evaluates the policy with no replica streaming — on
2238    /// the idle timer here, as on a quiet halted node whose writes are
2239    /// refused at ingress, or in any held slot's wait — the durable
2240    /// position is pinned to 0, so a slot at a position the policy
2241    /// confirmed long ago is judged unconfirmed, and past the grace goes
2242    /// out on the primary's own fsync, marked. A change here changes what
2243    /// clients and the metric are told; it is a decision, not a fix.
2244    #[test]
2245    fn a_query_over_state_confirmed_before_the_loss_is_answered_primary_only() {
2246        let grace = Duration::from_millis(100);
2247        let mut p = Primary::new(AckPolicy::DiskAndRam, 5, true, Some(grace));
2248        assert_eq!(
2249            p.release(5, PATIENCE),
2250            Some(Backing::Policy),
2251            "before the loss the policy confirms everything through 5"
2252        );
2253        assert_eq!(p.gate.cached_durable_pos, 5);
2254
2255        p.replica_leaves();
2256        std::thread::sleep(grace);
2257        let due = p.gate.last_policy_check + POLICY_CHECK_INTERVAL;
2258        p.gate.idle_recheck(due);
2259        assert_eq!(
2260            p.gate.cached_durable_pos, 0,
2261            "with no replica streaming the evaluation pins the durable position to 0"
2262        );
2263
2264        let query = OutputSlot {
2265            wire_seq: 5,
2266            payload: OutputPayload::QueryResponse(0),
2267            is_last_in_request: true,
2268            ..OutputSlot::default()
2269        };
2270        let backing = p.release_slot(&query, PATIENCE);
2271        assert_eq!(
2272            backing,
2273            Some(Backing::PrimaryOnly),
2274            "marked, though every replica confirmed position 5 before it left"
2275        );
2276        let mut ends = BatchEnds::new();
2277        assert_eq!(
2278            p.gate
2279                .batch_end(Backing::PrimaryOnly, &mut ends, RequestId(5)),
2280            encoded(TransportResponse::BatchEndDegraded { id: RequestId(5) })
2281        );
2282        assert_eq!(p.degraded_acks(), 1, "counted in melin_degraded_acks_total");
2283    }
2284
2285    /// `response`'s frame, as the control codec encodes it.
2286    fn encoded(response: TransportResponse) -> Vec<u8> {
2287        let mut buf = [0u8; BATCH_END_FRAME_LEN];
2288        let n = control_codec::encode_transport_response(&response, &mut buf).unwrap();
2289        buf[..n].to_vec()
2290    }
2291
2292    /// The cache trap: a degraded release must not advance the position
2293    /// the policy confirmed, or the next slot would pass the cached check
2294    /// and go out with a full `BatchEnd`.
2295    #[test]
2296    fn after_a_degraded_release_the_next_slot_is_still_evaluated_and_still_degraded() {
2297        let mut p = Primary::new(AckPolicy::DiskAndRam, 9, false, NO_GRACE);
2298        assert_eq!(p.release(5, PATIENCE), Some(Backing::PrimaryOnly));
2299        assert!(
2300            slot_needs_gate(&reply(6), p.gate.cached_durable_pos),
2301            "the next slot is not waved through on the degraded release"
2302        );
2303        assert_eq!(
2304            p.release(6, PATIENCE),
2305            Some(Backing::PrimaryOnly),
2306            "never a full BatchEnd"
2307        );
2308        assert_eq!(p.gate.cached_durable_pos, 0);
2309    }
2310
2311    /// A change slot let through on the primary's disk alone is not
2312    /// applied: the slots after it are still judged under the policy
2313    /// before it, and go out marked. Once a replica confirms the change
2314    /// under that policy, it applies, and the next slot is judged under
2315    /// the new one.
2316    #[test]
2317    fn a_change_released_degraded_is_applied_only_once_the_policy_before_it_confirms_it() {
2318        let mut p = Primary::new(AckPolicy::DiskAndRam, 6, false, NO_GRACE);
2319        assert_eq!(
2320            p.admit_within(&change(4, AckPolicy::TwoDisks, false), PATIENCE),
2321            Some(Admission::GateOnly)
2322        );
2323        assert_eq!(p.gate.active_policy, AckPolicy::DiskAndRam, "not applied");
2324        assert_eq!(p.gate.applied_through, None);
2325        assert_eq!(p.gate.pending.len(), 1, "deferred");
2326        assert_eq!(p.release(5, PATIENCE), Some(Backing::PrimaryOnly));
2327
2328        // A replica back, holding the change in memory only: `disk+ram`
2329        // confirms the change, which applies; `two-disks` then needs the
2330        // replica's disk for the slot after it.
2331        drop(p.presence.lift());
2332        replica_holds(&p.metrics, 6);
2333        p.active.store(true, Ordering::Release);
2334        assert_eq!(
2335            p.release(6, PATIENCE),
2336            None,
2337            "judged under two-disks, which the replica's disk does not meet yet"
2338        );
2339        assert_eq!(p.gate.active_policy, AckPolicy::TwoDisks);
2340        assert!(p.gate.pending.is_empty());
2341        replica_persists(&p.metrics, 6);
2342        assert_eq!(p.release(6, PATIENCE), Some(Backing::Policy));
2343    }
2344
2345    /// The lineage's pending change of a restarted primary that has no
2346    /// replica: the degraded release lets the slots after it out marked,
2347    /// leaving the change pending, rather than holding every reply.
2348    #[test]
2349    fn a_pending_change_released_degraded_stays_pending_and_marks_what_follows() {
2350        let mut p = Primary::starting(
2351            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Ram, false),
2352            5,
2353            false,
2354            NO_GRACE,
2355        );
2356        assert_eq!(p.release(4, PATIENCE), Some(Backing::PrimaryOnly));
2357        assert_eq!(p.gate.active_policy, AckPolicy::DiskAndRam);
2358        assert_eq!(p.gate.pending.len(), 1);
2359
2360        p.replica_streams(5);
2361        assert_eq!(p.release(5, PATIENCE), Some(Backing::Policy));
2362        assert_eq!(p.gate.active_policy, AckPolicy::Ram, "applied in order");
2363        assert!(p.gate.pending.is_empty());
2364    }
2365
2366    /// Two changes let through degraded are each applied in order, under
2367    /// the policy before it, never merged.
2368    #[test]
2369    fn changes_released_degraded_are_applied_one_by_one() {
2370        let mut p = Primary::new(AckPolicy::TwoDisks, 8, false, NO_GRACE);
2371        for (wire_seq, policy) in [(3, AckPolicy::DiskAndRam), (5, AckPolicy::Disk)] {
2372            assert_eq!(
2373                p.admit_within(&change(wire_seq, policy, false), PATIENCE),
2374                Some(Admission::GateOnly)
2375            );
2376        }
2377        assert_eq!(p.gate.pending.len(), 2);
2378
2379        // A replica holding 3 on disk and 5 in memory: `two-disks`
2380        // confirms the first change, `disk+ram` then the second.
2381        drop(p.presence.lift());
2382        p.metrics.acked_sequence[0].store(3, Ordering::Release);
2383        replica_holds(&p.metrics, 5);
2384        p.active.store(true, Ordering::Release);
2385        assert_eq!(p.release(8, PATIENCE), Some(Backing::Policy));
2386        assert_eq!(p.gate.active_policy, AckPolicy::Disk);
2387        assert_applied_through(&p.gate, 5);
2388    }
2389
2390    /// An unwitnessed change let through degraded needs no witness: it is
2391    /// on this node's disk, and applies at once, in order.
2392    #[test]
2393    fn an_unwitnessed_change_released_degraded_applies_once_on_disk() {
2394        let mut p = Primary::new(AckPolicy::DiskAndRam, 6, false, NO_GRACE);
2395        assert_eq!(
2396            p.admit_within(&change(4, AckPolicy::Disk, true), PATIENCE),
2397            Some(Admission::GateOnly)
2398        );
2399        assert_eq!(p.release(5, PATIENCE), Some(Backing::Policy));
2400        assert_eq!(p.gate.active_policy, AckPolicy::Disk);
2401    }
2402
2403    /// A pending change let through degraded marks the slot behind it
2404    /// even when a replica returns before the slot's own wait, which then
2405    /// opens under the policy: the change stays pending until the next
2406    /// admit applies it. The two halves of `admit` are driven one by one
2407    /// so the replica can return between them.
2408    #[test]
2409    fn a_pending_change_released_degraded_marks_a_slot_the_policy_then_confirms() {
2410        let mut p = Primary::starting(
2411            pending_start(AckPolicy::DiskAndRam, 3, AckPolicy::Ram, false),
2412            5,
2413            false,
2414            NO_GRACE,
2415        );
2416        let shutdown = AtomicBool::new(false);
2417        let mut recording = Recording::new(|| {});
2418        let mut hook = Hook::new(&mut recording);
2419        #[cfg(feature = "tick-to-trade")]
2420        let mut tracker = GateCrossTracker::new(5);
2421        let backing = match p.gate.admit_pending(
2422            &shutdown,
2423            #[cfg(feature = "tick-to-trade")]
2424            &mut tracker,
2425            &mut hook,
2426        ) {
2427            GateOutcome::Open(backing) => backing,
2428            GateOutcome::Shutdown => panic!("no shutdown was requested"),
2429        };
2430        assert_eq!(backing, Backing::PrimaryOnly);
2431        assert_eq!(p.gate.pending.len(), 1, "left pending");
2432
2433        p.replica_streams(5);
2434        let admitted = p.gate.admit_slot(
2435            &reply(5),
2436            backing,
2437            &shutdown,
2438            #[cfg(feature = "tick-to-trade")]
2439            &mut tracker,
2440            &mut hook,
2441        );
2442        assert_eq!(
2443            admitted,
2444            Admission::Reply {
2445                connection_id: 0,
2446                backing: Backing::PrimaryOnly
2447            },
2448            "never a full BatchEnd behind an unapplied change"
2449        );
2450        assert_eq!(
2451            p.gate.cached_durable_pos, 5,
2452            "the slot's wait opened in full"
2453        );
2454        assert_eq!(p.gate.active_policy, AckPolicy::DiskAndRam, "not applied");
2455        assert_eq!(p.gate.pending.len(), 1);
2456        assert_eq!(
2457            recording.calls.iter().filter(|&&c| c == "before").count(),
2458            1,
2459            "the flush hook ran once, before the first wait"
2460        );
2461        assert_eq!(recording.calls.first(), Some(&"before"));
2462
2463        p.journal_at(6);
2464        p.replica_streams(6);
2465        assert_eq!(p.release(6, PATIENCE), Some(Backing::Policy));
2466        assert_eq!(p.gate.active_policy, AckPolicy::Ram, "applied in order");
2467        assert_applied_through(&p.gate, 3);
2468        assert!(p.gate.pending.is_empty());
2469    }
2470
2471    /// A change slot let through degraded after a forced change of this
2472    /// tenure overtook it is stale: not deferred, so never applied.
2473    #[test]
2474    fn a_change_released_degraded_behind_a_forced_one_is_stale() {
2475        let mut p = Primary::new(AckPolicy::DiskAndRam, 6, false, NO_GRACE);
2476        // `ram` needs a replica too, so the halt still releases degraded.
2477        p.forced.store(forced_change(5, AckPolicy::Ram.to_byte()));
2478        assert_eq!(p.release(6, PATIENCE), Some(Backing::PrimaryOnly));
2479        assert_eq!(p.gate.active_policy, AckPolicy::Ram);
2480        assert_eq!(
2481            p.admit_within(&change(4, AckPolicy::TwoDisks, false), PATIENCE),
2482            Some(Admission::GateOnly)
2483        );
2484        assert!(p.gate.pending.is_empty(), "stale, not deferred");
2485        assert_applied_through(&p.gate, 5);
2486        assert_eq!(p.gate.active_policy, AckPolicy::Ram);
2487    }
2488
2489    /// A change naming an unknown policy, released degraded or forced,
2490    /// keeps the policy in force and is not recorded as applied, so a
2491    /// valid change deferred before it still applies in order.
2492    #[test]
2493    fn an_unknown_policy_leaves_an_earlier_deferred_change_pending() {
2494        let mut p = Primary::new(AckPolicy::DiskAndRam, 8, false, NO_GRACE);
2495        assert_eq!(
2496            p.admit_within(&change(3, AckPolicy::TwoDisks, false), PATIENCE),
2497            Some(Admission::GateOnly)
2498        );
2499        // Released degraded: through `defer_change`.
2500        assert_eq!(
2501            p.admit_within(&change_byte(5, AckPolicyByte(CORRUPT), false), PATIENCE),
2502            Some(Admission::GateOnly)
2503        );
2504        assert_eq!(p.gate.pending.len(), 1, "the unknown one is not deferred");
2505        assert_eq!(p.gate.last_refused, Some(WireSeq::new(5)));
2506        // Forced: through `apply_change`, inside the pending change's wait.
2507        p.forced.store(forced_change(6, AckPolicyByte(CORRUPT)));
2508        assert_eq!(p.release(7, PATIENCE), Some(Backing::PrimaryOnly));
2509        assert_eq!(p.gate.last_refused, Some(WireSeq::new(6)));
2510        assert_eq!(p.gate.active_policy, AckPolicy::DiskAndRam);
2511        assert_eq!(p.gate.applied_through, None, "neither recorded as applied");
2512        assert_eq!(p.gate.pending.len(), 1);
2513
2514        p.replica_streams(8);
2515        assert_eq!(p.release(8, PATIENCE), Some(Backing::Policy));
2516        assert_eq!(p.gate.active_policy, AckPolicy::TwoDisks, "not made stale");
2517        assert_applied_through(&p.gate, 3);
2518        assert!(p.gate.pending.is_empty());
2519    }
2520
2521    /// A forced change of this tenure overtakes a change a degraded
2522    /// release deferred: the slot opens under the forced policy, and the
2523    /// deferred change, below it, is skipped as stale.
2524    #[test]
2525    fn a_forced_change_overtakes_a_deferred_one() {
2526        let mut p = Primary::new(AckPolicy::DiskAndRam, 6, false, NO_GRACE);
2527        assert_eq!(
2528            p.admit_within(&change(4, AckPolicy::Ram, false), PATIENCE),
2529            Some(Admission::GateOnly)
2530        );
2531        assert_eq!(p.gate.pending.len(), 1, "deferred");
2532        p.forced.store(forced_change(5, AckPolicy::Disk.to_byte()));
2533        assert_eq!(p.release(6, PATIENCE), Some(Backing::Policy));
2534        assert_eq!(
2535            p.gate.active_policy,
2536            AckPolicy::Disk,
2537            "not the stale deferred `ram`"
2538        );
2539        assert_applied_through(&p.gate, 5);
2540        assert!(p.gate.pending.is_empty());
2541    }
2542
2543    /// The terminator follows the backing, and only degraded ones count.
2544    #[test]
2545    fn the_terminator_follows_the_backing_and_counts_degraded_acks() {
2546        let p = Primary::new(AckPolicy::DiskAndRam, 5, false, NO_GRACE);
2547        let mut ends = BatchEnds::new();
2548        // Each request's terminator names it, whatever the template's
2549        // previous request was.
2550        for id in [RequestId(1), RequestId(u64::MAX), RequestId(0)] {
2551            assert_eq!(
2552                p.gate.batch_end(Backing::Policy, &mut ends, id),
2553                encoded(TransportResponse::BatchEnd { id })
2554            );
2555        }
2556        assert_eq!(p.degraded_acks(), 0);
2557        assert_eq!(
2558            p.gate
2559                .batch_end(Backing::PrimaryOnly, &mut ends, RequestId(7)),
2560            encoded(TransportResponse::BatchEndDegraded { id: RequestId(7) })
2561        );
2562        assert_eq!(p.degraded_acks(), 1);
2563        assert_eq!(
2564            ends.policy(RequestId(3)),
2565            encoded(TransportResponse::BatchEnd { id: RequestId(3) })
2566        );
2567    }
2568
2569    /// A forced swap frees a slot stuck ahead of it, but only once its own
2570    /// entry is on this node's disk; the wedged interval counts as
2571    /// degraded, and the opened slot is attributed once, to the journal. Later
2572    /// in-order changes at or below it — its own slot included — are stale
2573    /// and ignored, and a change after it applies again.
2574    #[test]
2575    fn a_forced_swap_frees_an_earlier_stuck_slot_once_its_entry_is_on_disk() {
2576        // No replica connected: `disk+ram` cannot open at all.
2577        let (mut g, mut forced) = build(AckPolicy::DiskAndRam, 4, None, None);
2578        let journal = g.inputs.journal_persisted_wire_seq.clone();
2579        forced.store(forced_change(6, AckPolicy::Disk.to_byte()));
2580        // The stuck slot is on disk already; the forced entry is not.
2581        assert_held_until(&mut g, &reply(3), || journal.store(WireSeq::new(6)));
2582        assert_eq!(g.active_policy, AckPolicy::Disk);
2583        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
2584        // Some degraded time is on record across the forced unwedge. The
2585        // switch's re-seed flushes the interval since construction too, so
2586        // this does not tell the wait's own ticks from that flush
2587        // (`a_switch_resets_the_cached_position_and_reseeds_the_logger`).
2588        assert!(
2589            degraded_nanos(&g) > 0,
2590            "degraded time accrued during the wedge"
2591        );
2592        assert_eq!(attributions(&g), (1, 0), "opened on the journal, once");
2593
2594        let cached = g.cached_durable_pos;
2595        admit_now(&mut g, &change(5, AckPolicy::TwoDisks, false));
2596        admit_now(&mut g, &change(6, AckPolicy::Disk, true));
2597        assert_eq!(g.active_policy, AckPolicy::Disk, "stale changes ignored");
2598        assert_eq!(g.cached_durable_pos, cached, "no switch, no reset");
2599
2600        journal.store(WireSeq::new(7));
2601        admit_now(&mut g, &change(7, AckPolicy::Ram, false));
2602        assert_eq!(g.active_policy, AckPolicy::Ram);
2603    }
2604
2605    /// A forced change the gate reached in order is not applied a second
2606    /// time by a later wait: the change after it stays in force, and the
2607    /// slot it gates stays held.
2608    #[test]
2609    fn a_forced_change_applied_in_order_is_not_reapplied() {
2610        let (mut g, metrics, mut forced) = gate_with_one_replica(AckPolicy::DiskAndRam, 10);
2611        replica_holds(&metrics, 3);
2612        forced.store(forced_change(2, AckPolicy::Disk.to_byte()));
2613        admit_now(&mut g, &change(2, AckPolicy::Disk, true));
2614        admit_now(&mut g, &change(3, AckPolicy::DiskAndRam, false));
2615        assert_held_until(&mut g, &reply(4), || replica_holds(&metrics, 4));
2616        assert_eq!(g.active_policy, AckPolicy::DiskAndRam);
2617    }
2618
2619    /// A change naming a policy this build does not know keeps the prior
2620    /// policy untouched, in order and forced alike: no switch, so no
2621    /// re-seed and the cached durable position kept. Neither is recorded
2622    /// as applied; each is logged once.
2623    #[test]
2624    fn an_unknown_policy_in_a_change_keeps_the_prior_policy() {
2625        let (mut g, mut forced) = build(AckPolicy::Disk, 4, None, None);
2626        let journal = g.inputs.journal_persisted_wire_seq.clone();
2627        admit_now(&mut g, &change_byte(4, AckPolicyByte(CORRUPT), false));
2628        assert_eq!(g.active_policy, AckPolicy::Disk);
2629        assert_eq!(g.policy, AckPolicy::Disk.to_policy());
2630        assert_eq!(in_force(&g), Some(AckPolicy::Disk));
2631        assert_eq!(g.cached_durable_pos, 4, "no switch: cached position kept");
2632        assert_eq!(g.applied_through, None, "not recorded as applied");
2633        assert_eq!(g.last_refused, Some(WireSeq::new(4)));
2634
2635        // The next slot waits; once the forced entry is on disk the wait
2636        // reads it, keeps the prior policy, and opens.
2637        forced.store(forced_change(6, AckPolicyByte(CORRUPT)));
2638        assert_held_until(&mut g, &reply(5), || journal.store(WireSeq::new(6)));
2639        assert_eq!(g.active_policy, AckPolicy::Disk);
2640        assert_eq!(g.applied_through, None, "not recorded as applied");
2641        // Refused on every spin, logged on the first.
2642        assert_eq!(g.last_refused, Some(WireSeq::new(6)));
2643
2644        // A re-seed would flush the degraded time accrued under `disk+ram`.
2645        let mut g = gate(AckPolicy::DiskAndRam, 5);
2646        let_time_pass();
2647        g.apply_change(WireSeq::new(1), AckPolicyByte(CORRUPT), "test");
2648        assert_eq!(g.active_policy, AckPolicy::DiskAndRam);
2649        assert_eq!(degraded_nanos(&g), 0, "no re-seed on an unknown byte");
2650    }
2651
2652    /// Admit `reply(wire_seq)` through a [`Recording`] hook whose
2653    /// `while_waiting` runs `during`, and return the hook's calls.
2654    fn admit_recorded(
2655        g: &mut DurabilityGate,
2656        wire_seq: u64,
2657        during: impl FnMut(),
2658    ) -> Vec<&'static str> {
2659        let shutdown = AtomicBool::new(false);
2660        let mut hook = Recording::new(during);
2661        #[cfg(feature = "tick-to-trade")]
2662        let mut tracker = GateCrossTracker::new(wire_seq);
2663        let outcome = g.admit(
2664            &reply(wire_seq),
2665            &shutdown,
2666            #[cfg(feature = "tick-to-trade")]
2667            &mut tracker,
2668            &mut hook,
2669        );
2670        assert_eq!(
2671            outcome,
2672            Admission::Reply {
2673                connection_id: 0,
2674                backing: Backing::Policy
2675            }
2676        );
2677        hook.calls
2678    }
2679
2680    /// The flush hook runs only for a slot that has to wait, once, and
2681    /// before anything else the hook is asked to do.
2682    #[test]
2683    fn before_wait_runs_only_when_the_slot_waits() {
2684        let mut g = gate(AckPolicy::Disk, 5);
2685        assert!(
2686            admit_recorded(&mut g, 5, || {}).is_empty(),
2687            "slot 5 is durable: no hook at all"
2688        );
2689        // Durable while the hook runs, so the wait opens at once, before
2690        // any idle turn.
2691        g.inputs.journal_persisted_wire_seq.store(WireSeq::new(6));
2692        assert_eq!(admit_recorded(&mut g, 6, || {}), ["before"]);
2693    }
2694
2695    /// `while_waiting` runs on the wait's idle turns, after `before_wait`,
2696    /// until the slot is durable, and never for a slot that does not wait.
2697    #[test]
2698    fn while_waiting_runs_during_a_wait_and_never_on_a_slot_that_does_not_wait() {
2699        let mut g = gate(AckPolicy::Disk, 5);
2700        let journal = g.inputs.journal_persisted_wire_seq.clone();
2701        let mut turns = 0;
2702        // The journal reaches the slot on the third turn of the wait.
2703        let calls = admit_recorded(&mut g, 6, || {
2704            turns += 1;
2705            if turns == 3 {
2706                journal.store(WireSeq::new(6));
2707            }
2708        });
2709        assert_eq!(calls, ["before", "during", "during", "during"]);
2710
2711        assert!(
2712            admit_recorded(&mut g, 6, || panic!("slot 6 is durable now")).is_empty(),
2713            "a slot that does not wait runs no hook"
2714        );
2715    }
2716
2717    #[test]
2718    fn idle_recheck_waits_out_the_check_interval() {
2719        let mut g = gate(AckPolicy::Disk, 5);
2720        let checked = g.last_policy_check;
2721        g.inputs.journal_persisted_wire_seq.store(WireSeq::new(9));
2722
2723        g.idle_recheck(checked + POLICY_CHECK_INTERVAL - Duration::from_millis(1));
2724        assert_eq!(g.cached_durable_pos, 5, "too early: no re-evaluation");
2725        assert_eq!(g.last_policy_check, checked);
2726
2727        let due = checked + POLICY_CHECK_INTERVAL;
2728        g.idle_recheck(due);
2729        assert_eq!(g.cached_durable_pos, 9);
2730        assert_eq!(g.last_policy_check, due);
2731    }
2732
2733    #[test]
2734    fn a_batch_defers_the_next_idle_recheck() {
2735        let mut g = gate(AckPolicy::Disk, 5);
2736        // An idle check long overdue, as after a quiet spell.
2737        g.last_policy_check = Instant::now()
2738            .checked_sub(2 * POLICY_CHECK_INTERVAL)
2739            .expect("the monotonic clock is past two check intervals");
2740        g.inputs.journal_persisted_wire_seq.store(WireSeq::new(9));
2741
2742        g.after_batch();
2743        g.idle_recheck(Instant::now());
2744        assert_eq!(
2745            g.cached_durable_pos, 5,
2746            "the batch counted as the check, so the idle path waits"
2747        );
2748    }
2749}