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