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}