Skip to main content

laminar_core/cluster/control/controller/
membership.rs

1//! Live membership, responsiveness, draining, and recovery mode.
2
3use super::*;
4
5impl ClusterController {
6    /// Live instance IDs: `Active` peers plus self.
7    #[must_use]
8    pub fn live_instances(&self) -> Vec<NodeId> {
9        let mut ids: Vec<NodeId> = self
10            .members_rx
11            .borrow()
12            .iter()
13            .filter(|m| m.id != self.instance_id && matches!(m.state, NodeState::Active))
14            .map(|m| m.id)
15            .collect();
16        if self.active.load(Ordering::SeqCst) && self.process_lease_is_live() {
17            ids.push(self.instance_id);
18        }
19        ids
20    }
21
22    /// Nodes that must participate in a checkpoint for the current assignment.
23    ///
24    /// Draining owners remain responsible for their old vnodes through the handoff checkpoint,
25    /// while joining, suspected, left, and missing nodes cannot contribute to a restorable cut.
26    /// This is deliberately distinct from [`Self::assignable_instances`], which excludes draining
27    /// nodes so they never receive new ownership.
28    #[must_use]
29    pub fn checkpoint_instances(&self) -> Vec<NodeId> {
30        let mut ids: Vec<NodeId> = self
31            .members_rx
32            .borrow()
33            .iter()
34            .filter(|member| {
35                member.id != self.instance_id
36                    && matches!(member.state, NodeState::Active | NodeState::Draining)
37            })
38            .map(|member| member.id)
39            .collect();
40        if self.active.load(Ordering::SeqCst) && self.process_lease_is_live() {
41            ids.push(self.instance_id);
42        }
43        ids.sort_unstable();
44        ids.dedup();
45        ids
46    }
47
48    /// Record peers that failed to ack a capture quorum in time.
49    pub fn note_unresponsive(&self, peers: &[NodeId]) {
50        let fence = self.checkpoint_assignment_fence.borrow().clone();
51        let mut map = self.unresponsive.lock();
52        let mut changed = false;
53        for p in peers {
54            let incarnation = fence
55                .as_ref()
56                .and_then(|certificate| certificate.participant_incarnation(p.0));
57            changed |= map.insert(p.0, incarnation) != Some(incarnation);
58        }
59        drop(map);
60        if changed {
61            self.notify_leader_eligibility_change();
62        }
63    }
64
65    /// Clear peers that acked (they are demonstrably alive).
66    pub fn note_responsive(&self, peers: &[NodeId]) {
67        let mut map = self.unresponsive.lock();
68        let mut changed = false;
69        for p in peers {
70            changed |= map.remove(&p.0).is_some();
71        }
72        drop(map);
73        if changed {
74            self.notify_leader_eligibility_change();
75        }
76    }
77
78    /// Clear capture-quorum quarantine only for exact process boots that acknowledged a validated
79    /// recovery stop round. Entries without a recorded boot, or for a different boot, remain
80    /// quarantined. The caller must supply publishers from one complete, exact stopped quorum.
81    pub fn note_recovery_responsive(&self, participants: &[CheckpointParticipant]) {
82        let mut map = self.unresponsive.lock();
83        let mut changed = false;
84        for participant in participants {
85            let exact = matches!(
86                map.get(&participant.node_id),
87                Some(Some(failed_boot)) if *failed_boot == participant.boot_incarnation
88            );
89            if exact {
90                changed |= map.remove(&participant.node_id).is_some();
91            }
92        }
93        drop(map);
94        if changed {
95            self.notify_leader_eligibility_change();
96        }
97    }
98
99    /// Whether `peer` has an unresolved capture-quorum failure.
100    #[must_use]
101    pub fn is_unresponsive(&self, peer: NodeId) -> bool {
102        self.unresponsive.lock().contains_key(&peer.0)
103    }
104
105    /// Admit a placement candidate only when it was never quarantined or it is a different boot
106    /// incarnation. Callers must obtain `participant` from the lease-validated durable control KV.
107    pub fn admit_successor_process(&self, participant: CheckpointParticipant) -> bool {
108        let mut unresponsive = self.unresponsive.lock();
109        let admitted = match unresponsive.get(&participant.node_id).copied() {
110            None => true,
111            Some(Some(failed_boot)) if failed_boot != participant.boot_incarnation => {
112                unresponsive.remove(&participant.node_id);
113                drop(unresponsive);
114                self.notify_leader_eligibility_change();
115                return true;
116            }
117            Some(Some(_) | None) => false,
118        };
119        drop(unresponsive);
120        admitted
121    }
122
123    /// Mark this node as draining. Returns whether this exact certified leader must retain its
124    /// lease long enough to coordinate the predecessor cut. Idempotent.
125    pub fn begin_drain(&self) -> bool {
126        #[cfg(feature = "cluster")]
127        let has_durable_leadership = self.has_leader_lease_fencing();
128        #[cfg(not(feature = "cluster"))]
129        let has_durable_leadership = false;
130        let retain_leadership = has_durable_leadership
131            && self.is_leader()
132            && self
133                .checkpoint_assignment_fence
134                .borrow()
135                .as_ref()
136                .is_some_and(|fence| {
137                    fence.is_canonical()
138                        && fence.participant_incarnation(self.instance_id.0)
139                            == Some(self.recovery_incarnation)
140                });
141        self.draining.store(true, Ordering::SeqCst);
142        if !retain_leadership && self.leader_eligible.swap(false, Ordering::SeqCst) {
143            self.notify_leader_eligibility_change();
144        }
145        retain_leadership
146    }
147
148    /// Whether this node is draining.
149    #[must_use]
150    pub fn is_draining(&self) -> bool {
151        self.draining.load(Ordering::SeqCst)
152    }
153
154    /// Set or clear the coordinated-recovery fence.
155    pub fn set_recovering(&self, recovering: bool) {
156        let _transition = self.process_authority_transition.lock();
157        let recovering = recovering || !self.process_lease_is_live();
158        self.recovering.store(recovering, Ordering::SeqCst);
159    }
160
161    /// Whether a coordinated restart is in flight on this node.
162    #[must_use]
163    pub fn is_recovering(&self) -> bool {
164        self.recovering.load(Ordering::SeqCst)
165    }
166}