1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct MemberSignals {
85 pub since_last_heartbeat: Duration,
87 pub replication_lag_lsn: u64,
90 pub recent_errors: u32,
92 pub unhealthy_for: Duration,
98}
99
100impl MemberSignals {
101 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#[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
227pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum HealthClass {
255 Healthy,
257 Degraded,
261 Failed,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct HealthScore {
271 pub overall: u8,
273 pub liveness: u8,
275 pub lag: u8,
277 pub errors: u8,
279 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct HealthPolicy {
300 pub heartbeat_timeout: Duration,
302 pub max_replication_lag: u64,
304 pub max_recent_errors: u32,
306 pub failover_threshold: u8,
308 pub degraded_threshold: u8,
311 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
330fn ramp_down(value: f64, limit: f64) -> u8 {
332 if limit <= 0.0 {
333 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 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 pub fn failover_eligible(&self, score: &HealthScore, signals: &MemberSignals) -> bool {
390 score.is_failed() && signals.unhealthy_for >= self.grace_period
391 }
392}
393
394pub trait ClusterSignals {
401 fn member_signals(&self, member: &NodeIdentity) -> MemberSignals;
403
404 fn commit_watermark(&self, collection: &CollectionId, range_id: RangeId) -> CommitWatermark;
408
409 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum BlockedReason {
459 NoSafeCandidate,
463}
464
465#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
479pub struct FailoverPlan {
480 pub promotions: Vec<PlannedPromotion>,
482 pub blocked: Vec<BlockedFailover>,
484}
485
486impl FailoverPlan {
487 pub fn is_empty(&self) -> bool {
491 self.promotions.is_empty() && self.blocked.is_empty()
492 }
493}
494
495#[derive(Debug, Clone, Default)]
501pub struct ClusterSupervisor {
502 policy: HealthPolicy,
503}
504
505impl ClusterSupervisor {
506 pub fn new(policy: HealthPolicy) -> Self {
508 Self { policy }
509 }
510
511 pub fn policy(&self) -> &HealthPolicy {
512 &self.policy
513 }
514
515 pub fn assess(&self, signals: &MemberSignals) -> HealthScore {
519 self.policy.evaluate(signals)
520 }
521
522 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 pub fn plan_failovers(
547 &self,
548 membership: &MembershipCatalog,
549 ownership: &ShardOwnershipCatalog,
550 signals: &impl ClusterSignals,
551 ) -> FailoverPlan {
552 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 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 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 continue;
586 }
587 let Some(evidence) = signals.catch_up(&collection, range_id, candidate) else {
588 continue;
589 };
590 if !evidence.covers(watermark) {
591 continue;
595 }
596
597 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 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
677fn 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 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 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 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 #[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 let policy = HealthPolicy::default();
822 let signals = MemberSignals {
823 since_last_heartbeat: Duration::from_secs(2), 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 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 let policy = HealthPolicy::default();
843 let signals = MemberSignals {
844 since_last_heartbeat: Duration::ZERO,
845 replication_lag_lsn: 10_000, recent_errors: 20, unhealthy_for: Duration::ZERO,
848 };
849 let score = policy.evaluate(&signals);
850 assert_eq!(score.overall, 70);
852 assert_eq!(score.class, HealthClass::Degraded);
853 }
854
855 #[test]
856 fn dead_heartbeat_alone_reaches_failed() {
857 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 assert_eq!(score.overall, 30);
871 assert_eq!(score.class, HealthClass::Failed);
872 }
873
874 #[test]
875 fn totally_unreachable_member_is_failed() {
876 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 #[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 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 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 .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 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 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); 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 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 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), },
1022 )
1023 .with_catch_up("CN=node-b", 1, 10);
1024
1025 let score = supervisor.assess(&signals.member_signals(&ident("CN=node-a")));
1027 assert_eq!(score.class, HealthClass::Failed);
1028 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 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 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 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 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 ) .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 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}