Skip to main content

melin_server_runtime/
halt.rs

1//! Refusing client writes while a node cannot honour durability.
2//!
3//! A node halts when replication is configured and no replica is
4//! streaming (authenticated and its handshake accepted: catching up or
5//! live): it can no longer promise what the ack policy promises, so it
6//! takes no new writes. Each is answered with the application's rejection
7//! for [`RejectReason::ReplicaDisconnected`]; queries still run. A node a
8//! newer primary has fenced is stopping and answers nothing: a connection
9//! that sends anything while it winds down is closed, and the client
10//! reconnects to the new primary.
11//!
12//! # Lifted by the operator's consent
13//!
14//! The halt applies under every ack policy, `disk` included: it is what
15//! stops a primary a partition has cut off from its replicas, which
16//! fencing reaches only on contact. An operator who knows the node has no
17//! replica can consent to serving on its disk alone with `ACK-POLICY
18//! disk`: sent while no replica is streaming, the change is journaled as
19//! forced, and journaling it lifts the halt
20//! (`HaltGate::record_journaled`). The override lasts until a replica
21//! streams and acknowledges an entry, or a policy that needs one is
22//! journaled; the next loss of the last replica then halts the node again.
23//! A replica streamed to that leaves without acking (one refusing the
24//! stream, retrying) leaves it standing. A swap to `disk` made while a
25//! replica is still streaming lifts nothing when it leaves: consent counts
26//! only once the operator knows the node has none. The input ring's
27//! producer, which journals the change, is also the reader that enforces
28//! the halt, so the first write after the change's entry is taken. The
29//! override and the count live together in
30//! [`ReplicaPresence`], which the health endpoint reads too.
31//!
32//! # Refused at ingress
33//!
34//! The readers decide, before a request is published. A refused write
35//! never enters the input ring, so the journal never records it, replicas
36//! never see it, and replay agrees with the live engine. Refusing any later
37//! cannot have that property: the journal stage reads the input ring in
38//! parallel with the matching stage, so a write the matching stage refuses
39//! is already on its way to disk, and replay would apply what the live
40//! engine turned down.
41//!
42//! A write published just before the halt was observed is applied like any
43//! other, and its reply waits on the ack policy — sent once the policy is
44//! met again, never if the node is superseded or stopped first.
45//!
46//! Only client frames pass the gate. Events the runtime publishes itself
47//! — the seed set, a promotion marker — go straight to the input ring and
48//! are applied whether or not a replica is attached: they have no client
49//! to answer, and a primary that seeds before its first replica connects
50//! (the DPDK start-up order) must not lose its seeds to the halt. A
51//! request refused for the halt never reached the application, so a
52//! client may resend it as it was once the node takes writes again.
53//!
54//! A halt refusal is an early reply: an answer the reader decides on
55//! without publishing the request, sent between other requests' replies
56//! and counted on `/metrics` ([`crate::early_reply`]). The refusals are
57//! counted through [`HaltGate::record_refused`] as
58//! `melin_writes_refused_total`.
59
60use std::sync::Arc;
61use std::sync::atomic::{AtomicU64, Ordering};
62use std::time::Duration;
63
64use melin_app::RejectReason;
65use melin_journal::AckPolicyChange;
66use melin_transport_core::fence::FenceState;
67use melin_transport_core::replica_presence::{Observed, ReplicaPresence};
68
69use crate::ack_policy::AckPolicy;
70use crate::policy_control::{Origin, is_unwitnessed};
71
72/// What the response stage needs to answer a halted node's held replies
73/// once its own journal holds them: the replies to writes it sequenced
74/// before the halt, and queries, which no replica can confirm while none
75/// is connected. Each goes out terminated by `BatchEndDegraded`, which
76/// tells the client only the primary's disk backs it.
77///
78/// `None` where a stage takes one is the operator switch off
79/// (`--no-degraded-acks`): every held reply, and every reply queued behind
80/// one, waits for a replica or a policy swap (a refusal does not: it is an
81/// early reply, see [`crate::early_reply`]). A standalone
82/// node never halts and takes `None` too.
83pub struct DegradedRelease {
84    /// The node's replica presence: whether a replica is streaming, and
85    /// since when none has been.
86    pub presence: Arc<ReplicaPresence>,
87    /// How long the halt must last before the release starts
88    /// (`--degraded-ack-grace-ms`).
89    pub grace: Duration,
90}
91
92/// What a reader does with a client write right now.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum Verdict {
95    /// Publish it.
96    Take,
97    /// Refuse it with this reason; the connection stays open.
98    Refuse(RejectReason),
99    /// Close the connection: the node is superseded and stopping.
100    Close,
101}
102
103/// A reader's view of whether this node takes client writes.
104pub struct HaltGate {
105    /// Replicas currently streaming, maintained by the replication
106    /// senders, and the operator's halt override. `None` in standalone
107    /// mode, which never halts for want of a replica.
108    replicas_connected: Option<Arc<ReplicaPresence>>,
109    /// Latched once a newer primary is observed.
110    fence_state: Arc<FenceState>,
111    /// Writes refused so far; shared with the health endpoint.
112    refused: Arc<AtomicU64>,
113}
114
115/// Whether a node replicates: the one shape of "standalone or replicated"
116/// every ack-policy decision reads. Its source is
117/// `ServerConfig::replication` (whether `--replication-bind` is set): the
118/// ack-policy mailbox is built from it directly, and a primary pipeline
119/// gives its halt gate a replica presence exactly when it is `Enabled`, so
120/// the gate's start and the producer's entries, which read it back through
121/// [`HaltGate::replication`], cannot disagree with the mailbox's refusal.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub(crate) enum Replication {
124    /// Replication is configured: replicas may stream, and witness changes.
125    Enabled,
126    /// Standalone: nothing can witness a change.
127    Disabled,
128}
129
130impl Replication {
131    /// Whether `policy` can ever be met on a node of this shape. A policy
132    /// that needs a replica cannot on a node that accepts none: no replica
133    /// will ever attach to supply the second copy, so its gate would hold
134    /// every reply. The admin `ACK-POLICY` command refuses such a request
135    /// (`AckPolicyControl::request`). An exhaustive match, so a new shape
136    /// has to decide.
137    pub(crate) fn can_meet(self, policy: AckPolicy) -> bool {
138        match self {
139            Replication::Enabled => true,
140            Replication::Disabled => !policy.needs_replica(),
141        }
142    }
143}
144
145/// An ack-policy change decided on one look at who was streaming, taken
146/// just before the input ring's producer journals it: the policy, whether
147/// its entry is unwitnessed, and what was seen. Built only by
148/// [`HaltGate::decide`] and consumed only by [`HaltGate::record_journaled`],
149/// both private to [`HaltGate::journal`], so the entry the producer
150/// publishes and the policy and replica count the halt acts on cannot
151/// disagree, and a published entry is recorded exactly once. Neither
152/// `Copy` nor `Clone`, for the same reason (and the observation it holds
153/// is neither).
154#[derive(Debug)]
155struct Decision {
156    policy: AckPolicy,
157    /// Whether the entry is unwitnessed ([`is_unwitnessed`]).
158    unwitnessed: bool,
159    /// Who was streaming; `None` in standalone mode.
160    seen: Option<Observed>,
161}
162
163impl Decision {
164    /// The entry that journals the change: the policy's byte, derived
165    /// here rather than stored beside the policy, so the two cannot
166    /// disagree.
167    fn change(&self) -> AckPolicyChange {
168        AckPolicyChange {
169            policy: self.policy.to_byte(),
170            unwitnessed: self.unwitnessed,
171        }
172    }
173
174    /// Whether the change is a forced swap: unwitnessed for want of a
175    /// connected replica, rather than because replication is disabled.
176    /// Only an explicit runtime `disk` is (see [`is_unwitnessed`]). It
177    /// warns, and lifts the halt ([`HaltGate::record_journaled`]).
178    fn is_forced(&self) -> bool {
179        self.unwitnessed && matches!(self.seen, Some(Observed::NoneConnected(_)))
180    }
181}
182
183/// An ack-policy change the input ring's producer has journaled, and the
184/// halt has recorded ([`HaltGate::journal`]): what the caller logs.
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub(crate) struct Journaled {
187    policy: AckPolicy,
188    unwitnessed: bool,
189    forced: bool,
190    /// The input-ring sequence the entry was published at: what the
191    /// producer's publish returned, so a change is recorded only with
192    /// the index of the entry that journals it.
193    input_seq: u64,
194}
195
196impl Journaled {
197    /// The policy changed to.
198    pub(crate) fn policy(self) -> AckPolicy {
199        self.policy
200    }
201
202    /// Whether the entry was journaled unwitnessed.
203    pub(crate) fn unwitnessed(self) -> bool {
204        self.unwitnessed
205    }
206
207    /// Whether it was a forced swap, which lifted the halt (see
208    /// [`HaltGate::record_journaled`]).
209    pub(crate) fn is_forced(self) -> bool {
210        self.forced
211    }
212
213    /// The input-ring sequence the entry was published at.
214    pub(crate) fn input_seq(self) -> u64 {
215        self.input_seq
216    }
217}
218
219impl HaltGate {
220    /// A gate over the node's replica presence (`None` in standalone mode)
221    /// and its fence latch — the same two the health endpoint folds into
222    /// its status word — that counts what it refuses into `refused`.
223    pub fn new(
224        replicas_connected: Option<Arc<ReplicaPresence>>,
225        fence_state: Arc<FenceState>,
226        refused: Arc<AtomicU64>,
227    ) -> Self {
228        Self {
229            replicas_connected,
230            fence_state,
231            refused,
232        }
233    }
234
235    /// Whether this node replicates: whether the gate watches a replica
236    /// presence at all.
237    pub(crate) fn replication(&self) -> Replication {
238        match self.replicas_connected {
239            Some(_) => Replication::Enabled,
240            None => Replication::Disabled,
241        }
242    }
243
244    /// Journal a change to `policy` from `origin`: decide its entry on who
245    /// is streaming now, hand the entry to `publish` (the input ring's
246    /// producer putting it on the ring, returning the sequence it was
247    /// published at), and, once it is published, record it against the
248    /// halt ([`Self::record_journaled`]). An entry `publish` refuses is
249    /// recorded nowhere, and its error returned.
250    ///
251    /// The one way to journal a change, so a published entry is always
252    /// recorded, once, from the very look at who was streaming that
253    /// decided it. `publish` returns the ring's publish index, which the
254    /// record carries: recording a change without publishing its entry
255    /// takes a closure that makes an index up, not one that merely forgot
256    /// to push.
257    pub(crate) fn journal<E>(
258        &self,
259        policy: AckPolicy,
260        origin: Origin,
261        publish: impl FnOnce(AckPolicyChange) -> Result<u64, E>,
262    ) -> Result<Journaled, E> {
263        let decision = self.decide(policy, origin);
264        let input_seq = publish(decision.change())?;
265        Ok(self.record_journaled(decision, input_seq))
266    }
267
268    /// Decide the entry that journals a change to `policy` from `origin`,
269    /// on who is streaming now (two loads) — see [`is_unwitnessed`].
270    fn decide(&self, policy: AckPolicy, origin: Origin) -> Decision {
271        let seen = self.replicas_connected.as_ref().map(|p| p.observe());
272        Decision {
273            policy,
274            unwitnessed: is_unwitnessed(policy, origin, seen.as_ref()),
275            seen,
276        }
277    }
278
279    /// The input ring's producer journaled `decision`'s change, at
280    /// `input_seq`: a forced change (the operator's explicit `disk` with
281    /// no replica streaming) lifts the halt; a change to a policy that
282    /// needs a replica drops any such override, so the node halts again
283    /// while none is streaming. Any other change leaves the override as it
284    /// is: a witnessed `disk` was decided with a replica streaming, which
285    /// ends any override with its first ack (this change's entry, if no
286    /// earlier one); if it leaves without acking anything, the operator's
287    /// earlier consent stands. Standalone never halts, so there is nothing
288    /// to record. Either change to the override consumes the decision's
289    /// observation, the evidence the presence asks for.
290    fn record_journaled(&self, decision: Decision, input_seq: u64) -> Journaled {
291        let forced = decision.is_forced();
292        let Decision {
293            policy,
294            unwitnessed,
295            seen,
296        } = decision;
297        let journaled = Journaled {
298            policy,
299            unwitnessed,
300            forced,
301            input_seq,
302        };
303        let (Some(presence), Some(seen)) = (&self.replicas_connected, seen) else {
304            return journaled;
305        };
306        if policy.needs_replica() {
307            presence.clear_override(seen);
308        } else if let Observed::NoneConnected(since) = seen
309            && forced
310        {
311            presence.latch_override(since);
312        }
313        journaled
314    }
315
316    /// Count `count` refused writes. Meant for one call per receive with
317    /// the receive's tally, not one per write: a single relaxed add is the
318    /// whole cost.
319    #[inline]
320    pub fn record_refused(&self, count: u64) {
321        self.refused.fetch_add(count, Ordering::Relaxed);
322    }
323
324    /// The verdict on a client write arriving now. Fencing wins: a
325    /// superseded node is shutting down whatever its replica count.
326    ///
327    /// Two relaxed loads while a replica is connected, the override's two
328    /// more while none is. Relaxed is enough: a halt that starts between
329    /// the check and the publish is the case a write published just before
330    /// the halt already covers — see the module docs.
331    #[inline]
332    pub fn verdict(&self) -> Verdict {
333        if self.fence_state.is_fenced() {
334            Verdict::Close
335        } else if self
336            .replicas_connected
337            .as_ref()
338            .is_some_and(|presence| presence.halted())
339        {
340            Verdict::Refuse(RejectReason::ReplicaDisconnected)
341        } else {
342            Verdict::Take
343        }
344    }
345}
346
347/// Halt gates for the crate's tests.
348#[cfg(test)]
349pub(crate) mod test_support {
350    use super::*;
351
352    /// A replica presence with `connected` replicas streaming, none of
353    /// which has acked yet. (The health endpoint's tests in
354    /// `melin-transport-core` keep their own: a test helper cannot cross
355    /// the crate boundary.)
356    pub(crate) fn presence(connected: u32) -> Arc<ReplicaPresence> {
357        let presence = Arc::new(ReplicaPresence::new());
358        for _ in 0..connected {
359            drop(presence.lift());
360        }
361        presence
362    }
363
364    /// An unfenced gate over `replicas` (`None`: replication disabled)
365    /// that counts its refusals nowhere anyone reads.
366    pub(crate) fn gate_over(replicas: Option<Arc<ReplicaPresence>>) -> HaltGate {
367        HaltGate::new(
368            replicas,
369            Arc::new(FenceState::new(0)),
370            Arc::new(AtomicU64::new(0)),
371        )
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use melin_transport_core::replica_presence::Lowered;
379    use std::sync::atomic::AtomicBool;
380
381    fn gate(replicas_connected: Option<Arc<ReplicaPresence>>, fence: Arc<FenceState>) -> HaltGate {
382        HaltGate::new(replicas_connected, fence, Arc::new(AtomicU64::new(0)))
383    }
384
385    const HALTED: Verdict = Verdict::Refuse(RejectReason::ReplicaDisconnected);
386
387    /// Standalone never refuses, and journaling a change records nothing.
388    #[test]
389    fn standalone_never_refuses() {
390        let gate = gate(None, Arc::new(FenceState::new(0)));
391        assert_eq!(gate.verdict(), Verdict::Take);
392        for policy in [AckPolicy::Disk, AckPolicy::TwoDisks] {
393            journal(&gate, policy, Origin::Runtime);
394            assert_eq!(gate.verdict(), Verdict::Take);
395        }
396    }
397
398    #[test]
399    fn no_replica_refuses_with_replica_disconnected_until_one_connects() {
400        let replicas = test_support::presence(0);
401        let gate = gate(Some(Arc::clone(&replicas)), Arc::new(FenceState::new(0)));
402        assert_eq!(gate.verdict(), HALTED);
403        drop(replicas.lift());
404        assert_eq!(gate.verdict(), Verdict::Take);
405    }
406
407    /// A decision follows the replica count at the time it is made: a
408    /// runtime `disk` is unwitnessed standalone (not forced), forced with
409    /// no replica connected, and witnessed once one connects.
410    #[test]
411    fn a_decision_follows_the_replica_count() {
412        let standalone =
413            gate(None, Arc::new(FenceState::new(0))).decide(AckPolicy::Disk, Origin::Runtime);
414        assert!(standalone.unwitnessed && !standalone.is_forced());
415        let replicas = test_support::presence(0);
416        let gate = gate(Some(Arc::clone(&replicas)), Arc::new(FenceState::new(0)));
417        let none = gate.decide(AckPolicy::Disk, Origin::Runtime);
418        assert!(none.unwitnessed && none.is_forced());
419        drop(replicas.lift());
420        drop(replicas.lift());
421        let connected = gate.decide(AckPolicy::Disk, Origin::Runtime);
422        assert!(!connected.unwitnessed && !connected.is_forced());
423    }
424
425    /// Journal `policy` as the producer would, onto a ring that always
426    /// takes the entry (at a sequence nothing here reads).
427    fn journal(gate: &HaltGate, policy: AckPolicy, origin: Origin) -> Journaled {
428        let Ok(journaled) = gate.journal(policy, origin, |_| Ok::<_, std::convert::Infallible>(0));
429        journaled
430    }
431
432    /// The entry a journaled change carries is the policy's byte, and it
433    /// is the one handed to the producer, recorded at the sequence the
434    /// producer published it at; standalone, it is unwitnessed (nothing
435    /// can witness it) but not forced, whatever the policy and origin. The
436    /// rule with replication on is `policy_control`'s `the_forced_rule`,
437    /// and the count it reads is `a_decision_follows_the_replica_count`.
438    #[test]
439    fn a_journaled_change_carries_its_policy_as_the_observation_rules() {
440        let standalone = gate(None, Arc::new(FenceState::new(0)));
441        let mut published = None;
442        let Ok(journaled) = standalone.journal(AckPolicy::Ram, Origin::Staged, |change| {
443            published = Some(change);
444            Ok::<_, std::convert::Infallible>(42)
445        });
446        assert_eq!(journaled.policy(), AckPolicy::Ram);
447        assert_eq!(
448            published,
449            Some(AckPolicyChange {
450                policy: AckPolicy::Ram.to_byte(),
451                unwitnessed: journaled.unwitnessed(),
452            }),
453            "the entry published"
454        );
455        assert_eq!(journaled.input_seq(), 42, "where it was published");
456        assert!(journaled.unwitnessed(), "nothing can witness it");
457        assert!(
458            !journaled.is_forced(),
459            "unwitnessed for want of replication"
460        );
461    }
462
463    /// An entry the producer could not publish (a full ring) is recorded
464    /// nowhere: a forced `disk` refused that way lifts no halt, and the
465    /// error comes back.
466    #[test]
467    fn an_entry_not_published_is_not_recorded() {
468        let gate = gate(
469            Some(test_support::presence(0)),
470            Arc::new(FenceState::new(0)),
471        );
472        assert_eq!(
473            gate.journal(AckPolicy::Disk, Origin::Runtime, |_| Err("full")),
474            Err("full")
475        );
476        assert_eq!(gate.verdict(), HALTED, "still halted");
477        assert!(journal(&gate, AckPolicy::Disk, Origin::Runtime).is_forced());
478        assert_eq!(gate.verdict(), Verdict::Take, "published: lifted");
479    }
480
481    /// The replication shape is the presence's: watched or not.
482    #[test]
483    fn the_replication_shape_is_the_presence() {
484        let fence = || Arc::new(FenceState::new(0));
485        assert_eq!(gate(None, fence()).replication(), Replication::Disabled);
486        assert_eq!(
487            gate(Some(test_support::presence(0)), fence()).replication(),
488            Replication::Enabled
489        );
490    }
491
492    /// A replicating node can meet every policy; one that accepts no
493    /// replica only those that need none.
494    #[test]
495    fn a_node_without_replication_meets_only_policies_that_need_no_replica() {
496        for policy in [
497            AckPolicy::Disk,
498            AckPolicy::Ram,
499            AckPolicy::DiskAndRam,
500            AckPolicy::TwoDisks,
501        ] {
502            assert!(Replication::Enabled.can_meet(policy), "{policy}");
503            assert_eq!(
504                Replication::Disabled.can_meet(policy),
505                policy == AckPolicy::Disk,
506                "{policy}"
507            );
508        }
509    }
510
511    /// Halt-plan step 0. A node under `disk` that loses its replica halts
512    /// like any other; the operator's `disk` during the halt lifts it; a
513    /// returning replica that acks voids the override, so its next
514    /// departure halts the node again.
515    #[test]
516    fn a_forced_disk_lifts_the_halt_until_a_replica_returns() {
517        let replicas = test_support::presence(1);
518        let gate = gate(Some(Arc::clone(&replicas)), Arc::new(FenceState::new(0)));
519        assert_eq!(gate.verdict(), Verdict::Take);
520        assert_eq!(replicas.lower(), Lowered::LastHalted);
521        assert_eq!(gate.verdict(), HALTED, "the halt ignores the policy");
522
523        journal(&gate, AckPolicy::Disk, Origin::Runtime);
524        assert_eq!(gate.verdict(), Verdict::Take, "consent lifts the halt");
525        assert!(
526            gate.decide(AckPolicy::Disk, Origin::Runtime).is_forced(),
527            "still no witness: a later `disk` is forced too"
528        );
529        journal(&gate, AckPolicy::Disk, Origin::Runtime);
530        assert_eq!(gate.verdict(), Verdict::Take, "re-sent, it stays lifted");
531
532        let mut first_ack = replicas.lift();
533        assert_eq!(gate.verdict(), Verdict::Take);
534        first_ack.acked(&replicas);
535        assert_eq!(replicas.lower(), Lowered::LastHalted);
536        assert_eq!(
537            gate.verdict(),
538            HALTED,
539            "the override did not outlive the replica"
540        );
541    }
542
543    /// A replica that authenticates and is then refused (a lineage it will
544    /// not follow, a primary it takes for stale) never acks: counted once
545    /// its handshake is accepted, then leaving, however often it retries,
546    /// it leaves the operator's override standing, and a decision while it
547    /// is counted is witnessed. One that acks ends it.
548    #[test]
549    fn a_replica_refused_after_authenticating_leaves_the_override() {
550        let replicas = test_support::presence(0);
551        let gate = gate(Some(Arc::clone(&replicas)), Arc::new(FenceState::new(0)));
552        journal(&gate, AckPolicy::Disk, Origin::Runtime);
553        assert_eq!(gate.verdict(), Verdict::Take);
554        for _ in 0..3 {
555            let refused = replicas.lift();
556            assert!(
557                !gate.decide(AckPolicy::Disk, Origin::Runtime).is_forced(),
558                "streaming, so witnessed"
559            );
560            drop(refused);
561            assert_eq!(replicas.lower(), Lowered::LastOverridden);
562            assert_eq!(gate.verdict(), Verdict::Take, "the override held");
563        }
564
565        replicas.lift().acked(&replicas);
566        assert_eq!(replicas.lower(), Lowered::LastHalted);
567        assert_eq!(gate.verdict(), HALTED, "a replica that acked ended it");
568    }
569
570    /// A swap back to a policy that needs a replica, with none connected,
571    /// restores the halt; each of them does.
572    #[test]
573    fn a_swap_to_a_replica_backed_policy_restores_the_halt() {
574        for policy in [AckPolicy::Ram, AckPolicy::DiskAndRam, AckPolicy::TwoDisks] {
575            let gate = gate(
576                Some(test_support::presence(0)),
577                Arc::new(FenceState::new(0)),
578            );
579            journal(&gate, AckPolicy::Disk, Origin::Runtime);
580            assert_eq!(gate.verdict(), Verdict::Take);
581            journal(&gate, policy, Origin::Runtime);
582            assert_eq!(gate.verdict(), HALTED, "{policy}");
583            journal(&gate, AckPolicy::Disk, Origin::Runtime);
584            assert_eq!(
585                gate.verdict(),
586                Verdict::Take,
587                "a forced swap again: {policy}"
588            );
589        }
590    }
591
592    /// Consent counts only once the operator knows the node has no
593    /// replica: a `disk` journaled while one is connected lifts nothing
594    /// when it leaves, and neither does a staged one (a boot or promotion
595    /// entry) with none connected.
596    #[test]
597    fn a_disk_not_forced_does_not_lift_the_halt() {
598        let replicas = test_support::presence(1);
599        let gate = gate(Some(Arc::clone(&replicas)), Arc::new(FenceState::new(0)));
600        journal(&gate, AckPolicy::Disk, Origin::Runtime);
601        assert_eq!(replicas.lower(), Lowered::LastHalted);
602        assert_eq!(gate.verdict(), HALTED, "witnessed: no consent");
603
604        journal(&gate, AckPolicy::Disk, Origin::Staged);
605        assert_eq!(gate.verdict(), HALTED, "staged: never forced");
606    }
607
608    /// The fence wins over the override: a superseded node closes.
609    #[test]
610    fn a_fenced_node_closes_despite_the_override() {
611        let fence = Arc::new(FenceState::new(0));
612        let gate = gate(Some(test_support::presence(0)), Arc::clone(&fence));
613        journal(&gate, AckPolicy::Disk, Origin::Runtime);
614        fence.fence();
615        assert_eq!(gate.verdict(), Verdict::Close);
616    }
617
618    #[test]
619    fn a_fenced_node_closes_whatever_its_replica_count() {
620        let fence = Arc::new(FenceState::new(1));
621        let gate = gate(Some(test_support::presence(1)), Arc::clone(&fence));
622        assert_eq!(gate.verdict(), Verdict::Take);
623        assert_eq!(
624            fence.fence_if_superseded(2, &AtomicBool::new(false)),
625            Some(true)
626        );
627        assert_eq!(gate.verdict(), Verdict::Close);
628    }
629
630    #[test]
631    fn refusals_are_counted() {
632        let refused = Arc::new(AtomicU64::new(0));
633        let gate = HaltGate::new(None, Arc::new(FenceState::new(0)), Arc::clone(&refused));
634        gate.record_refused(3);
635        gate.record_refused(2);
636        assert_eq!(refused.load(Ordering::Relaxed), 5);
637    }
638}