Skip to main content

reddb_server/cluster/
supervisor.rs

1//! Member health scoring and automatic range failover (issue #998, PRD #987,
2//! ADR 0037).
3//!
4//! The **Cluster Supervisor** is the control-plane component that watches the
5//! authorized members ([`MembershipCatalog`]), decides when an owner has failed,
6//! and drives a *safe, fenced* range failover through the one sanctioned path —
7//! the ownership transition state machine ([`super::ownership_transition`]). It
8//! never edits ownership directly: every promotion it proposes is a
9//! [`TransitionRequest`] that the transition machine re-validates (three-part
10//! CAS + commit-watermark safety gate) before it touches the catalog.
11//!
12//! ## Health scoring, not a single short timeout
13//!
14//! A naive supervisor declares a member dead the instant one heartbeat is late.
15//! That is brittle: a single dropped packet, a GC pause, or a brief network
16//! hiccup triggers a needless, disruptive failover (and, under load, a *storm*
17//! of them). Instead the supervisor combines four signals into a
18//! [`HealthScore`]:
19//!
20//! * **Liveness** — time since the last heartbeat. The dominant signal, but not
21//!   the only one.
22//! * **Replication lag** — how far behind the range commit watermark the member
23//!   is. A live-but-far-behind owner is a poor owner.
24//! * **Recent errors** — observed failures in the recent window.
25//! * **Grace period** — how long the member has been continuously below the
26//!   failover threshold. This is the flapping damper: a member that dips and
27//!   recovers inside the grace window is never failed over.
28//!
29//! The first three combine into a 0..=100 score (weighted, liveness-heavy);
30//! the score classifies the member as [`Healthy`](HealthClass::Healthy),
31//! [`Degraded`](HealthClass::Degraded), or [`Failed`](HealthClass::Failed). The
32//! grace period then gates the *action*: only a `Failed` owner that has stayed
33//! failed for at least the grace period is eligible for automatic failover.
34//! Together the score and the grace period damp false positives and flapping
35//! (acceptance criteria 1 and 4).
36//!
37//! ## Safe candidate selection
38//!
39//! When an owner is eligible for failover, the supervisor considers **only**
40//! candidates that are (a) current replicas of the range, (b) still authorized
41//! data members, and (c) backed by catch-up evidence that covers the range
42//! commit watermark — exactly the bar the transition machine enforces. An
43//! arbitrary node, a witness, or a replica that has not caught up is never a
44//! promotion target (acceptance criterion 2). Among the safe candidates the
45//! supervisor prefers the healthiest, breaking ties by stable identity order so
46//! the plan is deterministic.
47//!
48//! The selected promotion is a [`TransitionKind::Promote`] request; activating
49//! it bumps the ownership epoch, which fences the failed owner — any write it
50//! still attempts under the old epoch is rejected by
51//! [`admit_public_write`](super::ownership::ShardOwnershipCatalog::admit_public_write)
52//! (acceptance criterion 3).
53//!
54//! ## Purity
55//!
56//! All state the supervisor needs from the running cluster — heartbeat,
57//! lag, error counts, grace tracking, per-range commit watermarks, and
58//! per-candidate catch-up progress — is read through the [`ClusterSignals`]
59//! trait, injected by the caller. The supervisor itself is a pure policy over
60//! the membership and ownership catalogs plus those signals, so the whole
61//! scoring/selection/fencing story is exercised deterministically with a
62//! scripted fake — no clock, no network, no engine.
63
64use std::collections::BTreeMap;
65use std::time::Duration;
66
67use crate::replication::{
68    LivenessObservation, LivenessStatus, MemberHealthInput, ReceivedSignal, SignalPlaneMessage,
69};
70
71use super::identity::NodeIdentity;
72use super::identity::NodeIdentityError;
73use super::membership::MembershipCatalog;
74use super::ownership::{CollectionId, RangeId, ShardOwnershipCatalog};
75use super::ownership_transition::{
76    run_transition, CatchUpEvidence, CommitWatermark, TransitionError, TransitionKind,
77    TransitionOutcome, TransitionRequest,
78};
79
80/// Raw, point-in-time health signals for one member, read from the running
81/// cluster through [`ClusterSignals::member_signals`]. The supervisor turns
82/// these into a [`HealthScore`]; it owns no clock or counters itself.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct MemberSignals {
85    /// Time since the member's last heartbeat was received (liveness).
86    pub since_last_heartbeat: Duration,
87    /// How many WAL LSNs the member trails the range commit watermark by, as an
88    /// aggregate liveness-of-replication signal. Zero means fully caught up.
89    pub replication_lag_lsn: u64,
90    /// Observed errors from the member in the recent observation window.
91    pub recent_errors: u32,
92    /// How long the member has been *continuously* below the failover
93    /// threshold. Zero for a healthy member; the caller resets it the moment the
94    /// member recovers. This is the grace-period input that damps flapping —
95    /// the supervisor refuses to fail over until it reaches the policy's grace
96    /// period.
97    pub unhealthy_for: Duration,
98}
99
100impl MemberSignals {
101    /// A perfectly healthy member: fresh heartbeat, no lag, no errors, never
102    /// unhealthy. Handy as a test/observation baseline.
103    pub fn healthy() -> Self {
104        Self {
105            since_last_heartbeat: Duration::ZERO,
106            replication_lag_lsn: 0,
107            recent_errors: 0,
108            unhealthy_for: Duration::ZERO,
109        }
110    }
111}
112
113/// Gossip-derived member health inputs keyed by the same [`NodeIdentity`] the
114/// supervisor already scores.
115///
116/// The tracker is deliberately just a transport adapter: it folds
117/// signal-plane liveness and health-input messages into [`MemberSignals`], then
118/// the existing [`HealthPolicy`] keeps doing all scoring and grace gating.
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct GossipMemberSignals {
121    members: BTreeMap<NodeIdentity, GossipMemberState>,
122}
123
124impl GossipMemberSignals {
125    pub fn record_received(
126        &mut self,
127        observed_at: Duration,
128        signal: &ReceivedSignal,
129    ) -> Result<(), NodeIdentityError> {
130        self.record_message(observed_at, &signal.message)
131    }
132
133    pub fn record_message(
134        &mut self,
135        observed_at: Duration,
136        message: &SignalPlaneMessage,
137    ) -> Result<(), NodeIdentityError> {
138        match message {
139            SignalPlaneMessage::LivenessObservation(observation) => {
140                let member = NodeIdentity::from_certificate_subject(&observation.observed)?;
141                self.members
142                    .entry(member)
143                    .or_default()
144                    .record_liveness(observed_at, observation.status);
145            }
146            SignalPlaneMessage::MemberHealthInput(input) => {
147                let member = NodeIdentity::from_certificate_subject(&input.member)?;
148                self.members.entry(member).or_default().record_health(input);
149            }
150            SignalPlaneMessage::LoadMetricSample(_)
151            | SignalPlaneMessage::CatalogVersionHint(_)
152            | SignalPlaneMessage::TopologyHint(_) => {}
153        }
154        Ok(())
155    }
156
157    pub fn member_signals_at(
158        &self,
159        member: &NodeIdentity,
160        now: Duration,
161        policy: &HealthPolicy,
162    ) -> MemberSignals {
163        self.members
164            .get(member)
165            .map(|state| state.to_member_signals(now, policy))
166            .unwrap_or_else(MemberSignals::healthy)
167    }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171struct GossipMemberState {
172    liveness: LivenessStatus,
173    liveness_since: Duration,
174    replication_lag_lsn: u64,
175    recent_errors: u32,
176}
177
178impl Default for GossipMemberState {
179    fn default() -> Self {
180        Self {
181            liveness: LivenessStatus::Alive,
182            liveness_since: Duration::ZERO,
183            replication_lag_lsn: 0,
184            recent_errors: 0,
185        }
186    }
187}
188
189impl GossipMemberState {
190    fn record_liveness(&mut self, observed_at: Duration, liveness: LivenessStatus) {
191        if self.liveness != liveness {
192            self.liveness = liveness;
193            self.liveness_since = observed_at;
194        }
195    }
196
197    fn record_health(&mut self, input: &MemberHealthInput) {
198        self.replication_lag_lsn = input.replication_lag_records;
199        self.recent_errors = if input.self_fenced {
200            u32::MAX
201        } else {
202            input.error_count.saturating_add(u32::from(input.read_only))
203        };
204    }
205
206    fn to_member_signals(&self, now: Duration, policy: &HealthPolicy) -> MemberSignals {
207        let unhealthy_for = now.saturating_sub(self.liveness_since);
208        let (since_last_heartbeat, unhealthy_for) = match self.liveness {
209            LivenessStatus::Alive => (Duration::ZERO, Duration::ZERO),
210            LivenessStatus::Suspect => (half_duration(policy.heartbeat_timeout), Duration::ZERO),
211            LivenessStatus::Unreachable => (policy.heartbeat_timeout, unhealthy_for),
212        };
213
214        MemberSignals {
215            since_last_heartbeat,
216            replication_lag_lsn: self.replication_lag_lsn,
217            recent_errors: self.recent_errors,
218            unhealthy_for,
219        }
220    }
221}
222
223fn half_duration(duration: Duration) -> Duration {
224    Duration::from_secs_f64(duration.as_secs_f64() / 2.0)
225}
226
227/// [`ClusterSignals`] view that gets member health from gossip observations and
228/// delegates watermarks/catch-up evidence to the existing control-plane source.
229pub struct GossipDerivedSignals<'a, S: ClusterSignals + ?Sized> {
230    gossip: &'a GossipMemberSignals,
231    delegate: &'a S,
232    now: Duration,
233    policy: HealthPolicy,
234}
235
236impl<'a, S: ClusterSignals + ?Sized> GossipDerivedSignals<'a, S> {
237    pub fn new(
238        gossip: &'a GossipMemberSignals,
239        delegate: &'a S,
240        now: Duration,
241        policy: HealthPolicy,
242    ) -> Self {
243        Self {
244            gossip,
245            delegate,
246            now,
247            policy,
248        }
249    }
250}
251
252/// How a member's [`HealthScore`] classifies against the policy thresholds.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum HealthClass {
255    /// Above the degraded threshold — a fully serving member.
256    Healthy,
257    /// Below the degraded threshold but above the failover threshold — observed
258    /// as impaired, but **not** failed over. Surfacing this is what lets an
259    /// operator see trouble building before it becomes an outage.
260    Degraded,
261    /// At or below the failover threshold — a failover candidate *once the grace
262    /// period has elapsed*.
263    Failed,
264}
265
266/// A member's combined health, with the per-axis sub-scores kept visible so an
267/// operator (or a test) can see *why* a member scored as it did rather than
268/// just the verdict.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct HealthScore {
271    /// Combined 0..=100 score (higher is healthier).
272    pub overall: u8,
273    /// Liveness sub-score from the heartbeat age (0..=100).
274    pub liveness: u8,
275    /// Replication-lag sub-score (0..=100).
276    pub lag: u8,
277    /// Recent-error sub-score (0..=100).
278    pub errors: u8,
279    /// The classification the combined score falls into.
280    pub class: HealthClass,
281}
282
283impl HealthScore {
284    pub fn is_healthy(&self) -> bool {
285        self.class == HealthClass::Healthy
286    }
287
288    pub fn is_failed(&self) -> bool {
289        self.class == HealthClass::Failed
290    }
291}
292
293/// The tunables that turn raw signals into a [`HealthScore`] and gate failover.
294///
295/// The defaults ([`HealthPolicy::default`]) are deliberately conservative:
296/// generous enough that an ordinary hiccup does not trip a failover, with a
297/// grace period long enough to ride out a transient blip.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct HealthPolicy {
300    /// Heartbeat age at or beyond which the liveness sub-score bottoms out at 0.
301    pub heartbeat_timeout: Duration,
302    /// Replication lag (LSNs) at or beyond which the lag sub-score bottoms out.
303    pub max_replication_lag: u64,
304    /// Recent-error count at or beyond which the error sub-score bottoms out.
305    pub max_recent_errors: u32,
306    /// Score (inclusive) at or below which a member is [`Failed`](HealthClass::Failed).
307    pub failover_threshold: u8,
308    /// Score (inclusive) at or below which a member is at least
309    /// [`Degraded`](HealthClass::Degraded). Must be `>= failover_threshold`.
310    pub degraded_threshold: u8,
311    /// How long a member must stay continuously `Failed` before the supervisor
312    /// will fail it over. The flapping damper: a shorter blip never triggers a
313    /// transition.
314    pub grace_period: Duration,
315}
316
317impl Default for HealthPolicy {
318    fn default() -> Self {
319        Self {
320            heartbeat_timeout: Duration::from_secs(10),
321            max_replication_lag: 10_000,
322            max_recent_errors: 20,
323            failover_threshold: 30,
324            degraded_threshold: 70,
325            grace_period: Duration::from_secs(30),
326        }
327    }
328}
329
330/// Linear sub-score in 0..=100: full marks at `0`, zero at/after `limit`.
331fn ramp_down(value: f64, limit: f64) -> u8 {
332    if limit <= 0.0 {
333        // No tolerance configured: anything non-zero is a total failure on this
334        // axis, zero is perfect.
335        return if value <= 0.0 { 100 } else { 0 };
336    }
337    let clamped = value.min(limit);
338    (100.0 * (1.0 - clamped / limit)).round() as u8
339}
340
341impl HealthPolicy {
342    /// Combine raw `signals` into a [`HealthScore`] under this policy.
343    ///
344    /// The three serving signals fold into the overall score with a
345    /// liveness-heavy weighting (liveness 70%, lag 20%, errors 10%): a member
346    /// whose heartbeat has fully lapsed must be able to reach the failover
347    /// threshold on liveness alone — a crashed node stops heartbeating but its
348    /// *last-known* lag and error counts may still look fine, so trusting them
349    /// would wedge failover shut. At the same time a live owner that is far
350    /// behind or erroring is penalised rather than trusted blindly, and a
351    /// *brief* heartbeat gap with good lag/errors stays out of the failover band
352    /// (which a single short fixed timeout could not express). The grace-period
353    /// signal (`unhealthy_for`) is *not* part of the score — it gates the
354    /// failover action in [`failover_eligible`](Self::failover_eligible).
355    pub fn evaluate(&self, signals: &MemberSignals) -> HealthScore {
356        let liveness = ramp_down(
357            signals.since_last_heartbeat.as_secs_f64(),
358            self.heartbeat_timeout.as_secs_f64(),
359        );
360        let lag = ramp_down(
361            signals.replication_lag_lsn as f64,
362            self.max_replication_lag as f64,
363        );
364        let errors = ramp_down(signals.recent_errors as f64, self.max_recent_errors as f64);
365
366        let overall =
367            (liveness as f64 * 0.7 + lag as f64 * 0.2 + errors as f64 * 0.1).round() as u8;
368        let class = if overall <= self.failover_threshold {
369            HealthClass::Failed
370        } else if overall <= self.degraded_threshold {
371            HealthClass::Degraded
372        } else {
373            HealthClass::Healthy
374        };
375
376        HealthScore {
377            overall,
378            liveness,
379            lag,
380            errors,
381            class,
382        }
383    }
384
385    /// Whether a member with this `score` and these `signals` is eligible for
386    /// automatic failover: it must be [`Failed`](HealthClass::Failed) **and**
387    /// have stayed unhealthy for at least the grace period. A `Failed` member
388    /// still inside the grace window is held back — the flapping damper.
389    pub fn failover_eligible(&self, score: &HealthScore, signals: &MemberSignals) -> bool {
390        score.is_failed() && signals.unhealthy_for >= self.grace_period
391    }
392}
393
394/// The cluster state the supervisor reads but does not own: per-member health
395/// signals, per-range commit watermarks, and per-candidate catch-up evidence.
396///
397/// Production backs this onto the heartbeat tracker, the replica registry, and
398/// the per-range stream progress (issue #992); tests back it onto a scripted
399/// fake. Keeping it behind a trait is what makes the supervisor a pure policy.
400pub trait ClusterSignals {
401    /// Current raw health signals for `member`.
402    fn member_signals(&self, member: &NodeIdentity) -> MemberSignals;
403
404    /// The range commit watermark a promotion candidate must cover for
405    /// `(collection, range_id)` — the highest `(term, lsn)` known durable under
406    /// the range's commit policy.
407    fn commit_watermark(&self, collection: &CollectionId, range_id: RangeId) -> CommitWatermark;
408
409    /// The catch-up evidence the supervisor has for `candidate` on the range, or
410    /// `None` if the candidate's progress is unknown (in which case it cannot be
411    /// promoted — fail closed).
412    fn catch_up(
413        &self,
414        collection: &CollectionId,
415        range_id: RangeId,
416        candidate: &NodeIdentity,
417    ) -> Option<CatchUpEvidence>;
418}
419
420impl<S: ClusterSignals + ?Sized> ClusterSignals for GossipDerivedSignals<'_, S> {
421    fn member_signals(&self, member: &NodeIdentity) -> MemberSignals {
422        self.gossip
423            .member_signals_at(member, self.now, &self.policy)
424    }
425
426    fn commit_watermark(&self, collection: &CollectionId, range_id: RangeId) -> CommitWatermark {
427        self.delegate.commit_watermark(collection, range_id)
428    }
429
430    fn catch_up(
431        &self,
432        collection: &CollectionId,
433        range_id: RangeId,
434        candidate: &NodeIdentity,
435    ) -> Option<CatchUpEvidence> {
436        self.delegate.catch_up(collection, range_id, candidate)
437    }
438}
439
440/// A safe, validated promotion the supervisor proposes for one range: the failed
441/// owner, the chosen caught-up candidate, the owner's health at decision time,
442/// and the [`TransitionRequest`] (already carrying the three-part CAS,
443/// watermark, and catch-up evidence) to run through the transition machine.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct PlannedPromotion {
446    pub collection: CollectionId,
447    pub range_id: RangeId,
448    pub failed_owner: NodeIdentity,
449    pub candidate: NodeIdentity,
450    pub candidate_score: HealthScore,
451    pub owner_score: HealthScore,
452    pub request: TransitionRequest,
453}
454
455/// Why a failing owner's range could **not** be failed over. Surfaced rather
456/// than silently skipped, so an operator can see a range that needs attention.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum BlockedReason {
459    /// The owner is failing but no replica is a safe candidate — none is an
460    /// authorized data member with catch-up evidence covering the commit
461    /// watermark. Failing over here could lose committed writes.
462    NoSafeCandidate,
463}
464
465/// A failing owner's range with no safe failover target.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub struct BlockedFailover {
468    pub collection: CollectionId,
469    pub range_id: RangeId,
470    pub failed_owner: NodeIdentity,
471    pub owner_score: HealthScore,
472    pub reason: BlockedReason,
473}
474
475/// The supervisor's decision for one scan: the safe promotions to run and the
476/// failing ranges with no safe target. A cluster with all owners healthy yields
477/// an empty plan ([`is_empty`](Self::is_empty)) — the healthy no-op.
478#[derive(Debug, Clone, Default, PartialEq, Eq)]
479pub struct FailoverPlan {
480    /// Safe, ready-to-run promotions, in `(collection, range_id)` order.
481    pub promotions: Vec<PlannedPromotion>,
482    /// Failing ranges that have no safe candidate.
483    pub blocked: Vec<BlockedFailover>,
484}
485
486impl FailoverPlan {
487    /// Nothing to do — every owner is healthy (or degraded-but-not-failed, or
488    /// within its grace period). The healthy no-op the supervisor must produce
489    /// for a stable cluster.
490    pub fn is_empty(&self) -> bool {
491        self.promotions.is_empty() && self.blocked.is_empty()
492    }
493}
494
495/// The cluster supervisor: health scoring + automatic range failover planning.
496///
497/// Holds only the [`HealthPolicy`]; all live state is read through
498/// [`ClusterSignals`] at scan time, so one supervisor instance serves the whole
499/// cluster lifetime.
500#[derive(Debug, Clone, Default)]
501pub struct ClusterSupervisor {
502    policy: HealthPolicy,
503}
504
505impl ClusterSupervisor {
506    /// A supervisor with the given health policy.
507    pub fn new(policy: HealthPolicy) -> Self {
508        Self { policy }
509    }
510
511    pub fn policy(&self) -> &HealthPolicy {
512        &self.policy
513    }
514
515    /// Score a single member's health under the policy. The building block of
516    /// degraded-member detection: an operator surface calls this for every
517    /// authorized member to render a health view.
518    pub fn assess(&self, signals: &MemberSignals) -> HealthScore {
519        self.policy.evaluate(signals)
520    }
521
522    /// Score every authorized member of `membership`, in stable identity order.
523    /// Includes healthy, degraded, and failed members alike — the input to a
524    /// cluster health dashboard.
525    pub fn assess_members(
526        &self,
527        membership: &MembershipCatalog,
528        signals: &impl ClusterSignals,
529    ) -> BTreeMap<NodeIdentity, HealthScore> {
530        membership
531            .members()
532            .map(|m| {
533                let id = m.identity().clone();
534                let score = self.policy.evaluate(&signals.member_signals(&id));
535                (id, score)
536            })
537            .collect()
538    }
539
540    /// Plan automatic failovers across the whole ownership catalog **without**
541    /// mutating it. For each range whose owner is failover-eligible (Failed and
542    /// past the grace period), pick the safest caught-up replica candidate and
543    /// produce a [`PlannedPromotion`]; if no replica is safe, record a
544    /// [`BlockedFailover`]. Owners that are healthy, merely degraded, or still
545    /// inside their grace period produce nothing.
546    pub fn plan_failovers(
547        &self,
548        membership: &MembershipCatalog,
549        ownership: &ShardOwnershipCatalog,
550        signals: &impl ClusterSignals,
551    ) -> FailoverPlan {
552        // entries() yields ranges in (collection, range_id) order, so the plan
553        // is deterministic.
554        let mut plan = FailoverPlan::default();
555
556        for range in ownership.entries() {
557            let owner = range.owner().clone();
558            let owner_signals = signals.member_signals(&owner);
559            let owner_score = self.policy.evaluate(&owner_signals);
560
561            // Healthy or degraded-but-not-failed owners are left alone; a failed
562            // owner still inside its grace period is held back (flapping damper).
563            if !self.policy.failover_eligible(&owner_score, &owner_signals) {
564                continue;
565            }
566
567            let collection = range.collection().clone();
568            let range_id = range.range_id();
569            let watermark = signals.commit_watermark(&collection, range_id);
570
571            // Consider only safe candidates: a current replica, still an
572            // authorized data member, not itself failed, with catch-up evidence
573            // covering the commit watermark.
574            let mut best: Option<(HealthScore, CatchUpEvidence, NodeIdentity)> = None;
575            for candidate in range.replicas() {
576                if !membership
577                    .member(candidate)
578                    .is_some_and(|m| m.kind().holds_data())
579                {
580                    continue;
581                }
582                let cand_score = self.policy.evaluate(&signals.member_signals(candidate));
583                if cand_score.is_failed() {
584                    // Promoting a failed replica just moves the outage; skip it.
585                    continue;
586                }
587                let Some(evidence) = signals.catch_up(&collection, range_id, candidate) else {
588                    continue;
589                };
590                if !evidence.covers(watermark) {
591                    // Replica is a copy of the range but has not caught up to the
592                    // commit watermark — promoting it could lose committed
593                    // writes. This is the unsafe-candidate rejection.
594                    continue;
595                }
596
597                // Prefer the healthiest candidate; break ties by stable identity
598                // order for determinism.
599                let better = match &best {
600                    None => true,
601                    Some((best_score, _, best_id)) => {
602                        cand_score.overall > best_score.overall
603                            || (cand_score.overall == best_score.overall && candidate < best_id)
604                    }
605                };
606                if better {
607                    best = Some((cand_score, evidence, candidate.clone()));
608                }
609            }
610
611            match best {
612                Some((candidate_score, evidence, candidate)) => {
613                    let request = TransitionRequest::new(
614                        TransitionKind::Promote,
615                        collection.clone(),
616                        range_id,
617                        owner.clone(),
618                        range.epoch(),
619                        range.version(),
620                        candidate.clone(),
621                        watermark,
622                    )
623                    .with_evidence(evidence)
624                    .with_replicas(remaining_replicas(range.replicas(), &candidate));
625                    plan.promotions.push(PlannedPromotion {
626                        collection,
627                        range_id,
628                        failed_owner: owner,
629                        candidate,
630                        candidate_score,
631                        owner_score,
632                        request,
633                    });
634                }
635                None => plan.blocked.push(BlockedFailover {
636                    collection,
637                    range_id,
638                    failed_owner: owner,
639                    owner_score,
640                    reason: BlockedReason::NoSafeCandidate,
641                }),
642            }
643        }
644
645        plan
646    }
647
648    /// Plan failovers and immediately run the safe promotions through the
649    /// ownership transition machine, fencing each failed owner via the epoch
650    /// bump. Returns the activated [`TransitionOutcome`]s and the surviving
651    /// [`FailoverPlan`] (whose `blocked` entries still need attention; its
652    /// `promotions` are the requests that were run).
653    ///
654    /// Each promotion is an independent catalog entry, so running them in
655    /// sequence never invalidates another's CAS. A promotion whose CAS lost a
656    /// race (the catalog moved between planning and activation) surfaces as a
657    /// [`TransitionError`] in the returned vector rather than aborting the rest.
658    pub fn run_failovers(
659        &self,
660        membership: &MembershipCatalog,
661        ownership: &mut ShardOwnershipCatalog,
662        signals: &impl ClusterSignals,
663    ) -> (
664        Vec<Result<TransitionOutcome, TransitionError>>,
665        FailoverPlan,
666    ) {
667        let plan = self.plan_failovers(membership, ownership, signals);
668        let outcomes = plan
669            .promotions
670            .iter()
671            .map(|p| run_transition(ownership, &p.request))
672            .collect();
673        (outcomes, plan)
674    }
675}
676
677/// The replica set the new owner carries after promotion: the old replica set
678/// minus the promoted candidate (it becomes owner, not its own replica). The
679/// failed owner is intentionally *not* added back as a replica — it is fenced
680/// and presumed down; the rebalancer re-replicates once it returns or is
681/// replaced.
682fn remaining_replicas(replicas: &[NodeIdentity], promoted: &NodeIdentity) -> Vec<NodeIdentity> {
683    replicas
684        .iter()
685        .filter(|r| *r != promoted)
686        .cloned()
687        .collect()
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::cluster::membership::{ClusterId, ClusterMember, MemberKind};
694    use crate::cluster::ownership::{CatalogVersion, OwnershipEpoch};
695    use crate::cluster::ownership::{
696        PlacementMetadata, RangeBounds, RangeOwnership, RangeRole, RangeWriteReject, ShardKeyMode,
697    };
698    use std::collections::HashMap;
699
700    fn ident(cn: &str) -> NodeIdentity {
701        NodeIdentity::from_certificate_subject(cn).unwrap()
702    }
703
704    fn collection(name: &str) -> CollectionId {
705        CollectionId::new(name).unwrap()
706    }
707
708    fn data_member(cn: &str) -> ClusterMember {
709        ClusterMember::joined_empty(ident(cn), MemberKind::Data)
710    }
711
712    fn membership(members: &[&str]) -> MembershipCatalog {
713        MembershipCatalog::new(
714            ClusterId::new("cluster-x").unwrap(),
715            members.iter().map(|m| data_member(m)),
716        )
717    }
718
719    /// A catalog with one full-keyspace range `orders/1` owned by `owner` with
720    /// `replicas`, at the initial epoch/version.
721    fn catalog_with(owner: &str, replicas: &[&str]) -> (ShardOwnershipCatalog, CollectionId) {
722        let orders = collection("orders");
723        let mut catalog = ShardOwnershipCatalog::new();
724        catalog
725            .apply_update(RangeOwnership::establish(
726                orders.clone(),
727                RangeId::new(1),
728                ShardKeyMode::Hash,
729                RangeBounds::full(),
730                ident(owner),
731                replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
732                PlacementMetadata::with_replication_factor(3),
733            ))
734            .unwrap();
735        (catalog, orders)
736    }
737
738    /// A scripted [`ClusterSignals`]: per-member signals, one shared watermark,
739    /// and per-(range,candidate) catch-up evidence keyed by candidate CN.
740    struct FakeSignals {
741        members: HashMap<NodeIdentity, MemberSignals>,
742        watermark: CommitWatermark,
743        catch_up: HashMap<NodeIdentity, CatchUpEvidence>,
744    }
745
746    impl FakeSignals {
747        fn new(watermark: CommitWatermark) -> Self {
748            Self {
749                members: HashMap::new(),
750                watermark,
751                catch_up: HashMap::new(),
752            }
753        }
754
755        fn with_member(mut self, cn: &str, signals: MemberSignals) -> Self {
756            self.members.insert(ident(cn), signals);
757            self
758        }
759
760        fn with_catch_up(mut self, cn: &str, applied_term: u64, applied_lsn: u64) -> Self {
761            self.catch_up.insert(
762                ident(cn),
763                CatchUpEvidence::new(ident(cn), applied_term, applied_lsn),
764            );
765            self
766        }
767    }
768
769    impl ClusterSignals for FakeSignals {
770        fn member_signals(&self, member: &NodeIdentity) -> MemberSignals {
771            self.members
772                .get(member)
773                .copied()
774                .unwrap_or_else(MemberSignals::healthy)
775        }
776
777        fn commit_watermark(
778            &self,
779            _collection: &CollectionId,
780            _range_id: RangeId,
781        ) -> CommitWatermark {
782            self.watermark
783        }
784
785        fn catch_up(
786            &self,
787            _collection: &CollectionId,
788            _range_id: RangeId,
789            candidate: &NodeIdentity,
790        ) -> Option<CatchUpEvidence> {
791            self.catch_up.get(candidate).cloned()
792        }
793    }
794
795    /// Signals for a failed-and-past-grace owner: no heartbeat for a long time,
796    /// well over the default grace period.
797    fn failed_signals() -> MemberSignals {
798        MemberSignals {
799            since_last_heartbeat: Duration::from_secs(60),
800            replication_lag_lsn: 50_000,
801            recent_errors: 100,
802            unhealthy_for: Duration::from_secs(60),
803        }
804    }
805
806    // --- health scoring ---------------------------------------------------
807
808    #[test]
809    fn fresh_member_scores_perfectly_healthy() {
810        let policy = HealthPolicy::default();
811        let score = policy.evaluate(&MemberSignals::healthy());
812        assert_eq!(score.overall, 100);
813        assert_eq!(score.class, HealthClass::Healthy);
814    }
815
816    #[test]
817    fn score_combines_signals_not_just_a_timeout() {
818        // A member with a *brief* heartbeat gap but good lag and no errors should
819        // not be treated as dead the way a short fixed timeout would. Its liveness
820        // sub-score dips, but lag/errors keep the overall in the Healthy band.
821        let policy = HealthPolicy::default();
822        let signals = MemberSignals {
823            since_last_heartbeat: Duration::from_secs(2), // 1/5 of the 10s timeout
824            replication_lag_lsn: 0,
825            recent_errors: 0,
826            unhealthy_for: Duration::ZERO,
827        };
828        let score = policy.evaluate(&signals);
829        assert_eq!(score.liveness, 80, "heartbeat at 1/5 of the timeout");
830        assert_eq!(score.lag, 100);
831        assert_eq!(score.errors, 100);
832        // overall = 0.7*80 + 0.2*100 + 0.1*100 = 56 + 20 + 10 = 86 -> Healthy.
833        // A 2s fixed timeout would have declared this member dead.
834        assert_eq!(score.overall, 86);
835        assert_eq!(score.class, HealthClass::Healthy);
836    }
837
838    #[test]
839    fn lag_and_errors_pull_a_live_member_into_degraded() {
840        // A member that is heartbeating fine but far behind and erroring is
841        // penalised — a single timeout would have called it perfectly healthy.
842        let policy = HealthPolicy::default();
843        let signals = MemberSignals {
844            since_last_heartbeat: Duration::ZERO,
845            replication_lag_lsn: 10_000, // at the cap -> lag sub-score 0
846            recent_errors: 20,           // at the cap -> error sub-score 0
847            unhealthy_for: Duration::ZERO,
848        };
849        let score = policy.evaluate(&signals);
850        // overall = 0.7*100 + 0.2*0 + 0.1*0 = 70 -> Degraded (<= degraded threshold).
851        assert_eq!(score.overall, 70);
852        assert_eq!(score.class, HealthClass::Degraded);
853    }
854
855    #[test]
856    fn dead_heartbeat_alone_reaches_failed() {
857        // A crashed node stops heartbeating; even if its last-known lag/errors
858        // look perfect, liveness alone must carry it to the failover band — else
859        // the most common failure (a clean crash) would never fail over.
860        let policy = HealthPolicy::default();
861        let signals = MemberSignals {
862            since_last_heartbeat: Duration::from_secs(30),
863            replication_lag_lsn: 0,
864            recent_errors: 0,
865            unhealthy_for: Duration::from_secs(30),
866        };
867        let score = policy.evaluate(&signals);
868        assert_eq!(score.liveness, 0);
869        // overall = 0.7*0 + 0.2*100 + 0.1*100 = 30 -> Failed (<= failover threshold).
870        assert_eq!(score.overall, 30);
871        assert_eq!(score.class, HealthClass::Failed);
872    }
873
874    #[test]
875    fn totally_unreachable_member_is_failed() {
876        // A member we cannot reach reports a dead heartbeat *and* growing lag and
877        // errors — every axis bottoms out, so it lands well under the failover
878        // threshold.
879        let policy = HealthPolicy::default();
880        let signals = MemberSignals {
881            since_last_heartbeat: Duration::from_secs(30),
882            replication_lag_lsn: 50_000,
883            recent_errors: 100,
884            unhealthy_for: Duration::from_secs(30),
885        };
886        let score = policy.evaluate(&signals);
887        assert_eq!(score.overall, 0);
888        assert_eq!(score.class, HealthClass::Failed);
889    }
890
891    // --- failover planning: the five acceptance scenarios -----------------
892
893    #[test]
894    fn healthy_cluster_is_a_no_op() {
895        let supervisor = ClusterSupervisor::default();
896        let members = membership(&["CN=node-a", "CN=node-b", "CN=node-c"]);
897        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
898        // All members healthy by default.
899        let signals = FakeSignals::new(CommitWatermark::new(1, 10));
900
901        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
902        assert!(plan.is_empty(), "no failover when every owner is healthy");
903    }
904
905    #[test]
906    fn degraded_owner_is_detected_but_not_failed_over() {
907        // node-a is degraded (live, but lagging+erroring) — observable, but the
908        // supervisor must not move ownership for a merely-degraded owner.
909        let supervisor = ClusterSupervisor::default();
910        let members = membership(&["CN=node-a", "CN=node-b"]);
911        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b"]);
912        let signals = FakeSignals::new(CommitWatermark::new(1, 10)).with_member(
913            "CN=node-a",
914            MemberSignals {
915                since_last_heartbeat: Duration::ZERO,
916                replication_lag_lsn: 10_000,
917                recent_errors: 20,
918                unhealthy_for: Duration::ZERO,
919            },
920        );
921
922        let score = supervisor.assess(&signals.member_signals(&ident("CN=node-a")));
923        assert_eq!(score.class, HealthClass::Degraded, "detected as degraded");
924
925        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
926        assert!(plan.is_empty(), "a degraded owner is not failed over");
927    }
928
929    #[test]
930    fn safe_candidate_is_promoted_and_old_owner_is_fenced() {
931        let supervisor = ClusterSupervisor::default();
932        let members = membership(&["CN=node-a", "CN=node-b", "CN=node-c"]);
933        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
934        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
935            .with_member("CN=node-a", failed_signals())
936            // node-b is healthy and fully caught up; node-c is caught up too but
937            // we expect node-b chosen on identity tie-break.
938            .with_catch_up("CN=node-b", 1, 10)
939            .with_catch_up("CN=node-c", 1, 10);
940
941        let (outcomes, plan) = supervisor.run_failovers(&members, &mut catalog, &signals);
942        assert_eq!(plan.promotions.len(), 1);
943        assert!(plan.blocked.is_empty());
944        let promotion = &plan.promotions[0];
945        assert_eq!(promotion.failed_owner, ident("CN=node-a"));
946        assert_eq!(
947            promotion.candidate,
948            ident("CN=node-b"),
949            "healthiest, tie -> lowest id"
950        );
951
952        let outcome = outcomes[0].as_ref().expect("promotion should activate");
953        assert_eq!(outcome.kind, TransitionKind::Promote);
954        assert!(
955            outcome.fenced_old_owner(),
956            "epoch bumped to fence old owner"
957        );
958        assert_eq!(outcome.new_owner, ident("CN=node-b"));
959
960        // The catalog now makes node-b the owner at the bumped epoch, and the old
961        // owner node-a is fenced from public writes under the old epoch.
962        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
963        assert_eq!(range.owner(), &ident("CN=node-b"));
964        assert_eq!(range.role_of(&ident("CN=node-b")), RangeRole::Owner);
965        let err = catalog
966            .admit_public_write(
967                &ident("CN=node-a"),
968                &orders,
969                b"k",
970                OwnershipEpoch::initial(),
971            )
972            .unwrap_err();
973        assert!(matches!(
974            err,
975            RangeWriteReject::NotOwner { .. } | RangeWriteReject::StaleEpoch { .. }
976        ));
977    }
978
979    #[test]
980    fn unsafe_candidate_behind_watermark_is_rejected() {
981        // node-a failed; its only replica node-b is a copy of the range but has
982        // NOT caught up to the commit watermark (term 2 lsn 50 vs applied 2/49).
983        // Promoting it could lose committed writes, so failover is blocked and
984        // the catalog is untouched.
985        let supervisor = ClusterSupervisor::default();
986        let members = membership(&["CN=node-a", "CN=node-b"]);
987        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
988        let signals = FakeSignals::new(CommitWatermark::new(2, 50))
989            .with_member("CN=node-a", failed_signals())
990            .with_catch_up("CN=node-b", 2, 49); // one LSN short
991
992        let (outcomes, plan) = supervisor.run_failovers(&members, &mut catalog, &signals);
993        assert!(plan.promotions.is_empty(), "no safe promotion");
994        assert!(outcomes.is_empty());
995        assert_eq!(plan.blocked.len(), 1);
996        assert_eq!(plan.blocked[0].reason, BlockedReason::NoSafeCandidate);
997        assert_eq!(plan.blocked[0].failed_owner, ident("CN=node-a"));
998
999        // Catalog is unchanged — node-a still owner at the initial epoch.
1000        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
1001        assert_eq!(range.owner(), &ident("CN=node-a"));
1002        assert_eq!(range.epoch(), OwnershipEpoch::initial());
1003        assert_eq!(range.version(), CatalogVersion::initial());
1004    }
1005
1006    #[test]
1007    fn flapping_owner_within_grace_period_is_not_failed_over() {
1008        // node-a's score is Failed, but it has only been unhealthy for 2s — well
1009        // inside the default 30s grace period. A flap must not move ownership.
1010        let supervisor = ClusterSupervisor::default();
1011        let members = membership(&["CN=node-a", "CN=node-b"]);
1012        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1013        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1014            .with_member(
1015                "CN=node-a",
1016                MemberSignals {
1017                    since_last_heartbeat: Duration::from_secs(30),
1018                    replication_lag_lsn: 50_000,
1019                    recent_errors: 100,
1020                    unhealthy_for: Duration::from_secs(2), // inside grace
1021                },
1022            )
1023            .with_catch_up("CN=node-b", 1, 10);
1024
1025        // The owner *is* scored Failed...
1026        let score = supervisor.assess(&signals.member_signals(&ident("CN=node-a")));
1027        assert_eq!(score.class, HealthClass::Failed);
1028        // ...but the grace period holds the failover back.
1029        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
1030        assert!(plan.is_empty(), "flap inside grace period is damped");
1031    }
1032
1033    #[test]
1034    fn unknown_candidate_progress_blocks_failover() {
1035        // node-a failed; node-b is a replica and a member, but the supervisor has
1036        // no catch-up evidence for it. Fail closed: blocked, not promoted.
1037        let supervisor = ClusterSupervisor::default();
1038        let members = membership(&["CN=node-a", "CN=node-b"]);
1039        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1040        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1041            .with_member("CN=node-a", failed_signals());
1042
1043        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
1044        assert_eq!(plan.blocked.len(), 1);
1045        assert_eq!(plan.blocked[0].reason, BlockedReason::NoSafeCandidate);
1046    }
1047
1048    #[test]
1049    fn non_replica_node_is_never_a_candidate() {
1050        // node-a failed and has NO replicas for the range. node-c is a healthy,
1051        // caught-up member — but it is not a replica, so it is never considered.
1052        let supervisor = ClusterSupervisor::default();
1053        let members = membership(&["CN=node-a", "CN=node-c"]);
1054        let (catalog, _orders) = catalog_with("CN=node-a", &[]);
1055        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1056            .with_member("CN=node-a", failed_signals())
1057            .with_catch_up("CN=node-c", 9, 999);
1058
1059        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
1060        assert_eq!(plan.blocked.len(), 1, "no replica -> no safe candidate");
1061        assert!(plan.promotions.is_empty());
1062    }
1063
1064    #[test]
1065    fn failed_replica_is_not_promoted() {
1066        // node-a failed; node-b is a caught-up replica but is itself failed.
1067        // Promoting it would just move the outage, so it is not selected.
1068        let supervisor = ClusterSupervisor::default();
1069        let members = membership(&["CN=node-a", "CN=node-b"]);
1070        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1071        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1072            .with_member("CN=node-a", failed_signals())
1073            .with_member("CN=node-b", failed_signals())
1074            .with_catch_up("CN=node-b", 1, 10);
1075
1076        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
1077        assert_eq!(plan.blocked.len(), 1);
1078        assert_eq!(plan.blocked[0].reason, BlockedReason::NoSafeCandidate);
1079    }
1080
1081    #[test]
1082    fn healthiest_caught_up_candidate_is_preferred() {
1083        // Both replicas are caught up, but node-c is healthier than node-b, so it
1084        // wins despite node-b sorting first by identity.
1085        let supervisor = ClusterSupervisor::default();
1086        let members = membership(&["CN=node-a", "CN=node-b", "CN=node-c"]);
1087        let (catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
1088        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1089            .with_member("CN=node-a", failed_signals())
1090            .with_member(
1091                "CN=node-b",
1092                MemberSignals {
1093                    since_last_heartbeat: Duration::from_secs(4),
1094                    replication_lag_lsn: 0,
1095                    recent_errors: 0,
1096                    unhealthy_for: Duration::ZERO,
1097                },
1098            ) // degraded-ish liveness, lower score
1099            .with_member("CN=node-c", MemberSignals::healthy())
1100            .with_catch_up("CN=node-b", 1, 10)
1101            .with_catch_up("CN=node-c", 1, 10);
1102
1103        let plan = supervisor.plan_failovers(&members, &catalog, &signals);
1104        assert_eq!(plan.promotions.len(), 1);
1105        assert_eq!(
1106            plan.promotions[0].candidate,
1107            ident("CN=node-c"),
1108            "healthier candidate preferred over identity tie-break",
1109        );
1110    }
1111
1112    #[test]
1113    fn promoted_owner_drops_itself_from_the_replica_set() {
1114        let supervisor = ClusterSupervisor::default();
1115        let members = membership(&["CN=node-a", "CN=node-b", "CN=node-c"]);
1116        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
1117        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1118            .with_member("CN=node-a", failed_signals())
1119            .with_catch_up("CN=node-b", 1, 10)
1120            .with_catch_up("CN=node-c", 1, 10);
1121
1122        supervisor.run_failovers(&members, &mut catalog, &signals);
1123        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
1124        assert_eq!(range.owner(), &ident("CN=node-b"));
1125        // node-b is no longer in its own replica set; node-c remains; the fenced
1126        // old owner node-a is not re-added.
1127        assert!(!range.replicas().contains(&ident("CN=node-b")));
1128        assert!(range.replicas().contains(&ident("CN=node-c")));
1129        assert!(!range.replicas().contains(&ident("CN=node-a")));
1130    }
1131
1132    #[test]
1133    fn assess_members_scores_every_authorized_member() {
1134        let supervisor = ClusterSupervisor::default();
1135        let members = membership(&["CN=node-a", "CN=node-b"]);
1136        let signals = FakeSignals::new(CommitWatermark::new(1, 10))
1137            .with_member("CN=node-a", failed_signals());
1138
1139        let scores = supervisor.assess_members(&members, &signals);
1140        assert_eq!(scores.len(), 2);
1141        assert_eq!(scores[&ident("CN=node-a")].class, HealthClass::Failed);
1142        assert_eq!(scores[&ident("CN=node-b")].class, HealthClass::Healthy);
1143    }
1144
1145    #[test]
1146    fn gossip_alive_observation_scores_member_healthy_without_direct_heartbeat() {
1147        let policy = HealthPolicy::default();
1148        let member = ident("CN=node-a");
1149        let mut gossip = GossipMemberSignals::default();
1150        gossip
1151            .record_message(
1152                Duration::ZERO,
1153                &SignalPlaneMessage::LivenessObservation(LivenessObservation {
1154                    observer: "CN=node-c".to_string(),
1155                    observed: "CN=node-a".to_string(),
1156                    incarnation: 1,
1157                    status: LivenessStatus::Alive,
1158                }),
1159            )
1160            .unwrap();
1161        gossip
1162            .record_message(
1163                Duration::ZERO,
1164                &SignalPlaneMessage::MemberHealthInput(MemberHealthInput {
1165                    member: "CN=node-a".to_string(),
1166                    error_count: 0,
1167                    replication_lag_records: 0,
1168                    read_only: false,
1169                    self_fenced: false,
1170                }),
1171            )
1172            .unwrap();
1173
1174        let signals = gossip.member_signals_at(&member, Duration::from_secs(60), &policy);
1175        let score = policy.evaluate(&signals);
1176
1177        assert_eq!(signals, MemberSignals::healthy());
1178        assert_eq!(score.class, HealthClass::Healthy);
1179    }
1180
1181    #[test]
1182    fn gossip_unreachable_observation_crosses_grace_and_triggers_failover() {
1183        let policy = HealthPolicy {
1184            grace_period: Duration::from_secs(5),
1185            ..HealthPolicy::default()
1186        };
1187        let supervisor = ClusterSupervisor::new(policy);
1188        let members = membership(&["CN=node-a", "CN=node-b"]);
1189        let (mut catalog, _orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1190        let base_signals =
1191            FakeSignals::new(CommitWatermark::new(1, 10)).with_catch_up("CN=node-b", 1, 10);
1192        let mut gossip = GossipMemberSignals::default();
1193        gossip
1194            .record_message(
1195                Duration::ZERO,
1196                &SignalPlaneMessage::LivenessObservation(LivenessObservation {
1197                    observer: "CN=node-b".to_string(),
1198                    observed: "CN=node-a".to_string(),
1199                    incarnation: 2,
1200                    status: LivenessStatus::Unreachable,
1201                }),
1202            )
1203            .unwrap();
1204        gossip
1205            .record_message(
1206                Duration::ZERO,
1207                &SignalPlaneMessage::MemberHealthInput(MemberHealthInput {
1208                    member: "CN=node-a".to_string(),
1209                    error_count: 100,
1210                    replication_lag_records: 50_000,
1211                    read_only: false,
1212                    self_fenced: false,
1213                }),
1214            )
1215            .unwrap();
1216        let signals =
1217            GossipDerivedSignals::new(&gossip, &base_signals, policy.grace_period, policy);
1218
1219        let (outcomes, plan) = supervisor.run_failovers(&members, &mut catalog, &signals);
1220
1221        assert_eq!(plan.promotions.len(), 1);
1222        assert_eq!(plan.promotions[0].failed_owner, ident("CN=node-a"));
1223        assert_eq!(plan.promotions[0].candidate, ident("CN=node-b"));
1224        assert!(outcomes[0].as_ref().unwrap().fenced_old_owner());
1225    }
1226}