Skip to main content

laminar_core/cluster/control/controller/
assignment.rs

1//! Assignable membership, drain acknowledgements, assignment adoption, and leader audit.
2
3use super::*;
4
5impl ClusterController {
6    /// Node ids eligible to own vnodes: `Active` peers, plus self unless draining. Unlike
7    /// [`Self::live_instances`], non-`Active` peers are filtered so they never receive vnodes.
8    #[must_use]
9    pub fn assignable_instances(&self) -> Vec<NodeId> {
10        let mut ids = assignable_node_ids(&self.members_rx.borrow());
11        ids.retain(|id| *id != self.instance_id);
12        if self.active.load(Ordering::SeqCst)
13            && !self.is_draining()
14            && !self.instance_id.is_unassigned()
15        {
16            ids.push(self.instance_id);
17        }
18        ids.sort_unstable();
19        ids.dedup();
20        ids
21    }
22
23    /// Record this node's own locality. Call once at startup.
24    pub fn set_self_locality(&self, locality: Locality) {
25        *self.self_locality.write() = locality;
26    }
27
28    /// [`Self::assignable_instances`] paired with each node's [`Locality`]
29    /// (peers' from `members_rx`, self's from [`Self::set_self_locality`]).
30    #[must_use]
31    pub fn assignable_with_locality(&self) -> Vec<(NodeId, Locality)> {
32        let members = self.members_rx.borrow();
33        self.assignable_instances()
34            .into_iter()
35            .map(|id| {
36                let locality = if id == self.instance_id {
37                    self.self_locality.read().clone()
38                } else {
39                    members
40                        .iter()
41                        .find(|m| m.id == id)
42                        .and_then(|m| m.metadata.failure_domain.as_deref())
43                        .map(Locality::parse)
44                        .unwrap_or_default()
45                };
46                (id, locality)
47            })
48            .collect()
49    }
50
51    /// Cloneable membership watch for reacting to join/leave events without polling.
52    #[must_use]
53    pub fn members_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
54        self.members_rx.clone()
55    }
56
57    pub(super) async fn drain_transition_authority_is_current(
58        &self,
59        transition: &AssignmentDrainTransition,
60    ) -> Result<bool, String> {
61        #[cfg(feature = "cluster")]
62        {
63            let authority = self
64                .checkpoint_authority()
65                .map_err(|error| error.to_string())?;
66            let Some(lease) = authority.load().await.map_err(|error| error.to_string())? else {
67                return Ok(false);
68            };
69            Ok(lease.matches_proof(&transition.leader))
70        }
71        #[cfg(not(feature = "cluster"))]
72        {
73            let _ = transition;
74            Err("assignment drain authority requires the cluster feature".into())
75        }
76    }
77
78    /// Publish and exactly read back proof that every local source reached one exact external
79    /// input cut for `transition`.
80    ///
81    /// # Errors
82    /// Rejects a nonparticipant, stale boot, superseded process, malformed certificate, or failed
83    /// durable write/read-back.
84    pub async fn announce_drain_ack(
85        &self,
86        transition: &AssignmentDrainTransition,
87    ) -> Result<(), String> {
88        if !transition.is_canonical() {
89            return Err("drain acknowledgement requires an exact assignment transition".into());
90        }
91        let participant = CheckpointParticipant {
92            node_id: self.instance_id.0,
93            boot_incarnation: self.recovery_incarnation,
94        };
95        if transition
96            .predecessor
97            .participant_incarnation(participant.node_id)
98            != Some(participant.boot_incarnation)
99        {
100            return Err("drain acknowledgement does not bind the local process".into());
101        }
102        if self.checkpoint_drain_transition.borrow().as_ref() != Some(transition) {
103            return Err("drain acknowledgement is not the locally audited transition".into());
104        }
105        if !self.process_lease_is_live()
106            || !self
107                .drain_transition_authority_is_current(transition)
108                .await?
109        {
110            return Err("drain acknowledgement authority is no longer live".into());
111        }
112        if !self.recovery_incarnation_is_current().await? {
113            return Err("drain acknowledgement came from a superseded local process".into());
114        }
115        self.write_recovery_value_exact(
116            DRAIN_ACK_KEY,
117            encode_drain_ack(&DrainAck::for_transition(participant, transition))?,
118        )
119        .await
120    }
121
122    /// Whether every process in the exact frozen assignment roster durably acknowledged the same
123    /// certificate and the transition remains the exact unfinalized durable assignment head.
124    ///
125    /// Records from nodes outside `fence` are ignored because durable per-node slots can outlive
126    /// membership. A missing or different-version record never contributes to quorum.
127    ///
128    /// # Errors
129    /// Fails closed when the certificate, current boot roster, assignment head, terminal decision,
130    /// durable scan, or an expected participant's record is malformed or unavailable. A
131    /// controller without a configured assignment store returns `Ok(false)` while a drain is
132    /// active.
133    pub async fn drain_ack_quorum_reached(
134        &self,
135        transition: &AssignmentDrainTransition,
136    ) -> Result<bool, String> {
137        if !transition.is_canonical() {
138            return Err("drain quorum requires a canonical assignment transition".into());
139        }
140        let locally_audited =
141            self.checkpoint_drain_transition.borrow().as_ref() == Some(transition);
142        if !locally_audited
143            || !self
144                .drain_transition_authority_is_current(transition)
145                .await?
146        {
147            return Ok(false);
148        }
149        if self
150            .recovery_participant_incarnations(&transition.predecessor.participant_ids())
151            .await?
152            != transition.predecessor.participants
153        {
154            return Ok(false);
155        }
156
157        let expected: std::collections::BTreeSet<NodeId> = transition
158            .required_participants()
159            .iter()
160            .map(|participant| NodeId(participant.node_id))
161            .collect();
162        let mut seen = std::collections::BTreeSet::new();
163        let mut matching = std::collections::BTreeSet::new();
164        for (publisher, raw) in self.scan_recovery_values(DRAIN_ACK_KEY).await? {
165            if !expected.contains(&publisher) {
166                continue;
167            }
168            if !seen.insert(publisher) {
169                return Err(format!(
170                    "duplicate drain acknowledgement from expected participant {publisher}"
171                ));
172            }
173            let ack = parse_drain_ack(&raw, publisher)?;
174            if ack.matches_transition(transition) {
175                matching.insert(publisher);
176            }
177        }
178        if matching != expected {
179            return Ok(false);
180        }
181        // A complete receipt roster is not itself permission to publish a handoff checkpoint.
182        // The drain may already have been terminalized (or a newer assignment may have become
183        // the durable head) while those immutable per-process receipts remain visible.
184        let Some(snapshot_store) = self.snapshot.as_ref() else {
185            return Ok(false);
186        };
187        let Some(head) = snapshot_store
188            .load()
189            .await
190            .map_err(|error| error.to_string())?
191        else {
192            return Ok(false);
193        };
194        if !head.draining
195            || head.drain_transition.as_ref() != Some(transition)
196            || head.assignment_fence().map_err(|error| error.to_string())? != transition.target
197        {
198            return Ok(false);
199        }
200        // Close the read/read race: a process can restart after the first incarnation scan but
201        // before its old acknowledgement is observed. The drain head can likewise be finalized
202        // during the durable reads. Revalidate all process-local authority after the exact cut;
203        // callers retain the transition and perform their own post-I/O check before publication.
204        if self
205            .recovery_participant_incarnations(&transition.predecessor.participant_ids())
206            .await?
207            != transition.predecessor.participants
208            || !self
209                .drain_transition_authority_is_current(transition)
210                .await?
211        {
212            return Ok(false);
213        }
214        let authority = self
215            .checkpoint_authority()
216            .map_err(|error| error.to_string())?;
217        if authority
218            .assignment_drain_decision(transition.target.assignment_version)
219            .await
220            .map_err(|error| error.to_string())?
221            .is_some()
222        {
223            return Ok(false);
224        }
225        Ok(
226            self.checkpoint_drain_transition.borrow().as_ref() == Some(transition)
227                && self.process_lease_is_live(),
228        )
229    }
230
231    /// Publish and exactly read back this process's adopted assignment map.
232    ///
233    /// # Errors
234    /// Fails closed for a malformed/stale process report or unavailable control storage.
235    pub async fn announce_adopted_assignment(
236        &self,
237        adoption: &CheckpointAssignmentAdoption,
238    ) -> Result<(), String> {
239        if !adoption.is_canonical()
240            || adoption.participant.node_id != self.instance_id.0
241            || adoption.participant.boot_incarnation != self.recovery_incarnation
242            || !self.recovery_incarnation_is_current().await?
243        {
244            return Err("adopted assignment report does not bind the current process".into());
245        }
246        let encoded = serde_json::to_string(adoption)
247            .map_err(|error| format!("could not encode adopted assignment: {error}"))?;
248        self.write_recovery_value_exact(ADOPTED_ASSIGNMENT_KEY, encoded)
249            .await
250    }
251
252    /// Each visible process's exact adopted assignment identity.
253    ///
254    /// # Errors
255    /// Fails closed if any visible report is malformed or claims another publisher.
256    pub async fn read_adopted_assignments(
257        &self,
258    ) -> Result<Vec<(NodeId, CheckpointAssignmentAdoption)>, String> {
259        self.scan_recovery_values(ADOPTED_ASSIGNMENT_KEY)
260            .await?
261            .into_iter()
262            .map(|(node, raw)| {
263                let adoption: CheckpointAssignmentAdoption = serde_json::from_str(&raw)
264                    .map_err(|error| format!("invalid adopted assignment from {node}: {error}"))?;
265                if !adoption.is_canonical() || adoption.participant.node_id != node.0 {
266                    return Err(format!(
267                        "adopted assignment from {node} has a non-canonical publisher"
268                    ));
269                }
270                Ok((node, adoption))
271            })
272            .collect()
273    }
274
275    /// Publish this node's version-bound checkpoint fence. Called off the hot path by the snapshot
276    /// watcher; admission and recovery revalidate it locally against current membership.
277    pub fn publish_checkpoint_assignment_fence(&self, fence: Option<CheckpointAssignmentFence>) {
278        let _transition = self.process_authority_transition.lock();
279        let fence = if self.process_lease_is_live() {
280            fence
281        } else {
282            None
283        };
284        if let Some(fence) = fence.as_ref().filter(|fence| fence.is_canonical()) {
285            let participants = fence.participant_ids();
286            let changed = {
287                let mut installed = self.leadership_participants.write();
288                match installed.as_ref() {
289                    Some((version, _)) if *version >= fence.assignment_version => false,
290                    _ => {
291                        *installed = Some((fence.assignment_version, participants));
292                        true
293                    }
294                }
295            };
296            if changed {
297                self.notify_leader_eligibility_change();
298            }
299        }
300        self.checkpoint_assignment_fence.send_replace(fence);
301    }
302
303    /// Publish the exact locally audited drain transition, or clear it after commit/abort.
304    pub fn publish_checkpoint_drain_transition(
305        &self,
306        transition: Option<AssignmentDrainTransition>,
307    ) {
308        let _transition = self.process_authority_transition.lock();
309        let transition = if self.process_lease_is_live() {
310            transition
311        } else {
312            None
313        };
314        self.checkpoint_drain_transition.send_replace(transition);
315    }
316
317    /// Clear the locally audited drain transition only when it still equals `expected`.
318    ///
319    /// The comparison and clear share the process-authority transition lock with publication and
320    /// terminal process fencing, so a newer transition cannot be erased by stale reconciliation.
321    #[must_use]
322    pub fn clear_checkpoint_drain_transition_if_matches(
323        &self,
324        expected: &AssignmentDrainTransition,
325    ) -> bool {
326        let _transition = self.process_authority_transition.lock();
327        let matches = self.checkpoint_drain_transition.borrow().as_ref() == Some(expected);
328        if matches {
329            self.checkpoint_drain_transition.send_replace(None);
330        }
331        matches
332    }
333
334    /// Current locally audited drain transition.
335    #[must_use]
336    pub fn checkpoint_drain_transition(&self) -> Option<AssignmentDrainTransition> {
337        self.checkpoint_drain_transition.borrow().clone()
338    }
339
340    /// Subscribe to local assignment-fence changes so a blocking durability probe can be
341    /// interrupted when its checkpoint cut becomes stale.
342    #[must_use]
343    pub fn checkpoint_assignment_watch(
344        &self,
345    ) -> watch::Receiver<Option<CheckpointAssignmentFence>> {
346        self.checkpoint_assignment_fence.subscribe()
347    }
348
349    /// Return the exact locally certified assignment cut while every participant remains
350    /// checkpoint-capable. Active workers that own no vnode do not expand the checkpoint quorum.
351    /// The clone is retained by the admitted attempt and propagated to followers.
352    #[must_use]
353    pub fn checkpoint_assignment_fence(
354        &self,
355        assignment_version: u64,
356    ) -> Option<CheckpointAssignmentFence> {
357        let checkpoint_capable: Vec<u64> = self
358            .checkpoint_instances()
359            .into_iter()
360            .map(|node| node.0)
361            .collect();
362        self.checkpoint_assignment_fence
363            .borrow()
364            .as_ref()
365            .filter(|fence| {
366                fence.is_canonical()
367                    && fence.assignment_version == assignment_version
368                    && fence
369                        .participant_incarnation(self.instance_id.0)
370                        .is_none_or(|incarnation| incarnation == self.recovery_incarnation)
371                    && fence.participants.iter().all(|participant| {
372                        checkpoint_capable
373                            .binary_search(&participant.node_id)
374                            .is_ok()
375                    })
376            })
377            .cloned()
378    }
379
380    /// Return the locally certified assignment only when the announcing leader proof exactly
381    /// matches the current durable authority. A predecessor retained during an in-progress drain
382    /// additionally remains bound to the leader term that opened that drain.
383    #[cfg(feature = "cluster")]
384    pub async fn checkpoint_assignment_fence_for_leader(
385        &self,
386        assignment_version: u64,
387        leader: &crate::checkpoint::LeaderProof,
388    ) -> Option<CheckpointAssignmentFence> {
389        if !leader.is_canonical() {
390            return None;
391        }
392        let fence = self.checkpoint_assignment_fence(assignment_version)?;
393        let authority = self.checkpoint_authority().ok()?;
394        let lease = authority.load().await.ok().flatten()?;
395        if !lease.matches_proof(leader) {
396            return None;
397        }
398        self.checkpoint_assignment_fence_after_authority_validation(fence, leader)
399    }
400
401    #[cfg(feature = "cluster")]
402    pub(super) fn checkpoint_assignment_fence_after_authority_validation(
403        &self,
404        fence: CheckpointAssignmentFence,
405        leader: &crate::checkpoint::LeaderProof,
406    ) -> Option<CheckpointAssignmentFence> {
407        let transition = self.checkpoint_drain_transition.borrow();
408        if transition
409            .as_ref()
410            .is_some_and(|transition| transition.predecessor == fence)
411            && transition.as_ref().map(|transition| &transition.leader) != Some(leader)
412        {
413            return None;
414        }
415        Some(fence)
416    }
417
418    /// Start the direct gRPC barrier sync server.
419    ///
420    /// # Errors
421    /// Propagates [`BarrierCoordinator::start_server`] errors.
422    #[cfg(feature = "cluster")]
423    pub async fn start_barrier_server(
424        &self,
425        bind_addr: std::net::SocketAddr,
426        advertise_host: Option<String>,
427    ) -> Result<std::net::SocketAddr, String> {
428        self.barrier.start_server(bind_addr, advertise_host).await
429    }
430
431    /// Start a cluster control endpoint whose first advertisement is bound to an acquired
432    /// stable-node process lease.
433    ///
434    /// # Errors
435    /// Rejects a lease for another node or boot, a conflicting prior identity, or server start.
436    #[cfg(feature = "cluster")]
437    pub async fn start_leased_barrier_server(
438        &self,
439        bind_addr: std::net::SocketAddr,
440        advertise_host: Option<String>,
441        process_lease: &super::super::ProcessLease,
442    ) -> Result<std::net::SocketAddr, String> {
443        process_lease
444            .validate(self.instance_id)
445            .map_err(|error| error.to_string())?;
446        if process_lease.owner != self.recovery_incarnation {
447            return Err("control endpoint lease does not bind this process incarnation".into());
448        }
449        if !self.recovery_process_lease_is_live() {
450            return Err("live process lease deadline is not installed".into());
451        }
452        self.barrier.install_local_process_lease(process_lease)?;
453        self.barrier.start_server(bind_addr, advertise_host).await
454    }
455
456    /// Confirm that one exact remote process still holds a proof read from durable authority.
457    ///
458    /// The RPC returns only a challenge acknowledgement and never returns authority material.
459    ///
460    /// # Errors
461    /// Fails closed when the control RPC is unavailable, malformed, or misses `deadline`.
462    #[cfg(feature = "cluster")]
463    pub async fn confirm_remote_leader_proof(
464        &self,
465        proof: &LeaderProof,
466        deadline: tokio::time::Instant,
467    ) -> Result<bool, String> {
468        self.barrier
469            .confirm_remote_leader_proof(proof, deadline)
470            .await
471    }
472
473    #[cfg(feature = "cluster")]
474    pub(super) async fn confirm_assignment_leader_proof(
475        &self,
476        leader: NodeId,
477        proof: &LeaderProof,
478        deadline: tokio::time::Instant,
479    ) -> Result<(), String> {
480        if tokio::time::Instant::now() >= deadline {
481            return Err("assignment leader authority audit deadline expired".into());
482        }
483        if proof.owner.node_id != leader.0 {
484            return Err("assignment leader proof does not match the current leader".into());
485        }
486        let confirmed = if leader == self.instance_id {
487            self.capture_leader_proof().as_ref() == Some(proof)
488        } else {
489            self.confirm_remote_leader_proof(proof, deadline).await?
490        };
491        if !confirmed {
492            return Err(format!(
493                "assignment leader process {} has no live exact grant",
494                leader.0
495            ));
496        }
497        Ok(())
498    }
499
500    /// Audit one exact assignment leader against durable and process-local authority.
501    ///
502    /// This is a single bounded attempt intended for a caller that already holds its assignment
503    /// and execution locks. `expected_proof` binds predecessor execution during an active drain;
504    /// a stable assignment passes `None` and adopts the current durable grant. The audit never
505    /// waits for convergence and never mutates assignment, lease, or source-gate state.
506    ///
507    /// # Errors
508    /// Fails closed when the installed assignment, elected participant, leader authority, live
509    /// local/remote grant, or durable process term changes or cannot be verified before `deadline`.
510    #[cfg(feature = "cluster")]
511    pub async fn audit_assignment_leader_authority(
512        &self,
513        fence: &CheckpointAssignmentFence,
514        expected_proof: Option<&LeaderProof>,
515        deadline: tokio::time::Instant,
516    ) -> Result<LeaderProof, String> {
517        if tokio::time::Instant::now() >= deadline {
518            return Err("assignment leader authority audit deadline expired".into());
519        }
520        if !fence.is_canonical()
521            || self
522                .checkpoint_assignment_fence(fence.assignment_version)
523                .as_ref()
524                != Some(fence)
525        {
526            return Err(
527                "assignment leader authority audit requires the exact installed fence".into(),
528            );
529        }
530        if expected_proof.is_some_and(|proof| !proof.is_canonical()) {
531            return Err("assignment leader authority audit expected proof is not canonical".into());
532        }
533
534        let leader = self
535            .current_leader()
536            .ok_or_else(|| "assignment leader authority audit has no current leader".to_string())?;
537        let participant = fence
538            .participants
539            .iter()
540            .find(|participant| participant.node_id == leader.0)
541            .copied()
542            .ok_or_else(|| {
543                "current leader is not a participant in the exact assignment fence".to_string()
544            })?;
545        let authority = self
546            .checkpoint_authority()
547            .map_err(|error| format!("assignment leader authority is unavailable: {error}"))?;
548        let initial = tokio::time::timeout_at(deadline, authority.load())
549            .await
550            .map_err(|_| "assignment leader authority initial read timed out".to_string())?
551            .map_err(|error| format!("assignment leader authority initial read failed: {error}"))?
552            .ok_or_else(|| "assignment leader authority has no durable grant".to_string())?;
553        if initial.owner.node != leader || initial.owner.boot != participant.boot_incarnation {
554            return Err(
555                "durable leader grant does not match the elected assignment participant".into(),
556            );
557        }
558        let proof = match expected_proof {
559            Some(expected) if initial.matches_proof(expected) => expected.clone(),
560            Some(_) => {
561                return Err(
562                    "durable leader grant does not match the drain-bound expected proof".into(),
563                );
564            }
565            None => initial.proof(),
566        };
567
568        self.confirm_assignment_leader_proof(leader, &proof, deadline)
569            .await?;
570        let process_authority = self
571            .process_lease_authority
572            .get()
573            .ok_or_else(|| "process lease authority is not installed".to_string())?;
574        let process_current = process_authority
575            .verify_current_participant_term(participant, proof.owner.process_term, deadline)
576            .await
577            .map_err(|error| {
578                format!("assignment leader process-term verification failed: {error}")
579            })?;
580        if !process_current {
581            return Err("assignment leader durable process term is no longer current".to_string());
582        }
583        self.confirm_assignment_leader_proof(leader, &proof, deadline)
584            .await?;
585
586        let final_grant = tokio::time::timeout_at(deadline, authority.load())
587            .await
588            .map_err(|_| "assignment leader authority final read timed out".to_string())?
589            .map_err(|error| format!("assignment leader authority final read failed: {error}"))?
590            .ok_or_else(|| "assignment leader authority grant disappeared".to_string())?;
591        if final_grant.owner != initial.owner
592            || final_grant.token != initial.token
593            || final_grant.seq < initial.seq
594            || !final_grant.matches_proof(&proof)
595        {
596            return Err("assignment leader durable grant changed during the audit".into());
597        }
598        if self.current_leader() != Some(leader)
599            || self
600                .checkpoint_assignment_fence(fence.assignment_version)
601                .as_ref()
602                != Some(fence)
603        {
604            return Err("assignment leader or exact fence changed during the audit".into());
605        }
606        if tokio::time::Instant::now() >= deadline {
607            return Err("assignment leader authority audit deadline expired".into());
608        }
609        Ok(proof)
610    }
611}