Skip to main content

laminar_core/cluster/control/controller/
authority.rs

1//! Leader and process-lease authority, fencing, and durable assignment decisions.
2
3use super::*;
4
5impl ClusterController {
6    /// This instance's ID.
7    #[must_use]
8    pub fn instance_id(&self) -> NodeId {
9        self.instance_id
10    }
11
12    /// The cluster gossip KV, for advertising/discovering per-stream state.
13    #[must_use]
14    pub fn kv(&self) -> &Arc<dyn ClusterKv> {
15        &self.kv
16    }
17
18    /// Current leader: the lowest available active certified participant. A locally certified
19    /// incumbent may retain candidacy while it drains, but a remote draining process is never
20    /// nominated. When no certified participant is available, only a controller wired to durable
21    /// leader fencing may nominate the lowest idle worker so it can repair assignment authority.
22    #[must_use]
23    pub fn current_leader(&self) -> Option<NodeId> {
24        let members = self.members_rx.borrow();
25        let participants = self.leadership_participants.read();
26        let unresponsive = self.unresponsive.lock();
27        let is_participant = |node: NodeId| {
28            participants
29                .as_ref()
30                .is_none_or(|(_, roster)| roster.binary_search(&node.0).is_ok())
31        };
32        let mut eligible: Vec<NodeId> = members
33            .iter()
34            .filter(|m| {
35                m.id != self.instance_id
36                    && matches!(m.state, NodeState::Active)
37                    && !unresponsive.contains_key(&m.id.0)
38            })
39            .map(|m| m.id)
40            .collect();
41        if self.leader_eligible.load(Ordering::SeqCst)
42            && self.process_lease_is_live()
43            && !unresponsive.contains_key(&self.instance_id.0)
44        {
45            eligible.push(self.instance_id);
46        }
47        let certified = eligible
48            .iter()
49            .copied()
50            .filter(|node| is_participant(*node))
51            .collect::<Vec<_>>();
52        if let Some(leader) = leader_of(&certified) {
53            return Some(leader);
54        }
55        #[cfg(feature = "cluster")]
56        if self.has_leader_lease_fencing() {
57            return leader_of(&eligible);
58        }
59        None
60    }
61
62    /// True if this node is the gossip-elected candidate (lowest active id), ignoring the
63    /// lease. The lease manager acquires only while this holds.
64    #[must_use]
65    pub fn is_gossip_leader(&self) -> bool {
66        self.current_leader() == Some(self.instance_id)
67    }
68
69    /// True if this node may act as leader — the gate all leader-gated work inherits. With a
70    /// lease wired, also requires this exact process's live local-monotonic grant.
71    #[must_use]
72    pub fn is_leader(&self) -> bool {
73        if !self.process_lease_is_live() {
74            return false;
75        }
76        if !self.is_gossip_leader() {
77            return false;
78        }
79        #[cfg(feature = "cluster")]
80        if let Some(gate) = self.leader_lease.get() {
81            let lease = gate.lease.borrow();
82            let deadline = gate.deadline.borrow().clone();
83            return super::super::lease_grants_leadership(&lease, &gate.owner, &deadline);
84        }
85        true
86    }
87
88    /// Whether this controller is wired to a durable leader lease. Gossip-only leadership is
89    /// adequate for best-effort coordination but cannot certify exactly-once cluster decisions.
90    #[must_use]
91    #[cfg(feature = "cluster")]
92    pub fn has_leader_lease_fencing(&self) -> bool {
93        self.leader_lease.get().is_some()
94    }
95
96    /// Current live fencing token when this node owns the durable lease.
97    ///
98    /// This is an observation for detecting that an operation crossed leadership terms. Reading
99    /// it does **not** fence a later object-store write: a durable mutation is fenced only when
100    /// the storage operation atomically validates the token. Callers must not stamp this value
101    /// into an otherwise unconditional write and treat that as a correctness boundary.
102    #[must_use]
103    #[cfg(feature = "cluster")]
104    pub fn leader_fencing_token(&self) -> Option<u64> {
105        self.capture_leader_proof().map(|proof| proof.fencing_token)
106    }
107
108    /// Capture the exact durable leader term currently authorized by local monotonic leases.
109    ///
110    /// Gossip determines candidacy only. A proof is available solely when this process is the
111    /// candidate, both process and leader deadlines are live, and the observed durable grant has
112    /// this process's exact owner identity.
113    #[must_use]
114    #[cfg(feature = "cluster")]
115    pub fn capture_leader_proof(&self) -> Option<super::super::LeaderProof> {
116        if !self.process_lease_proof_is_live() || !self.is_gossip_leader() {
117            return None;
118        }
119        let gate = self.leader_lease.get()?;
120        let lease = gate.lease.borrow();
121        let deadline = gate.deadline.borrow().clone();
122        if !super::super::lease_grants_leadership(&lease, &gate.owner, &deadline) {
123            return None;
124        }
125        lease.as_ref().map(super::super::LeaderLease::proof)
126    }
127
128    /// Whether a captured proof remains the exact current locally authorized leader term.
129    #[must_use]
130    #[cfg(feature = "cluster")]
131    pub fn proof_is_live(&self, proof: &super::super::LeaderProof) -> bool {
132        if !self.process_lease_proof_is_live() || !self.is_gossip_leader() {
133            return false;
134        }
135        let Some(gate) = self.leader_lease.get() else {
136            return false;
137        };
138        let lease = gate.lease.borrow();
139        let deadline = gate.deadline.borrow().clone();
140        super::super::lease_grants_proof(&lease, &gate.owner, &deadline, proof)
141    }
142
143    /// Capture the durable leader grant while forming a cluster with no active member.
144    ///
145    /// Every joining process may contend in a cold start, but the shared lease store admits only
146    /// one exact owner. As soon as an active member is visible, a joining process ceases to be a
147    /// candidate and cannot use its observation to publish catalog state.
148    #[must_use]
149    #[cfg(feature = "cluster")]
150    pub fn capture_catalog_bootstrap_proof(&self) -> Option<super::super::LeaderProof> {
151        if !self.process_lease_proof_is_live() || !self.is_leader_lease_candidate() {
152            return None;
153        }
154        let gate = self.leader_lease.get()?;
155        let lease = gate.lease.borrow();
156        let deadline = gate.deadline.borrow().clone();
157        if !super::super::lease_grants_leadership(&lease, &gate.owner, &deadline) {
158            return None;
159        }
160        lease.as_ref().map(super::super::LeaderLease::proof)
161    }
162
163    /// Whether a catalog-bootstrap proof remains owned by this cold-start candidate.
164    #[must_use]
165    #[cfg(feature = "cluster")]
166    pub fn catalog_bootstrap_proof_is_live(&self, proof: &super::super::LeaderProof) -> bool {
167        if !self.process_lease_proof_is_live() || !self.is_leader_lease_candidate() {
168            return false;
169        }
170        let Some(gate) = self.leader_lease.get() else {
171            return false;
172        };
173        let lease = gate.lease.borrow();
174        let deadline = gate.deadline.borrow().clone();
175        super::super::lease_grants_proof(&lease, &gate.owner, &deadline, proof)
176    }
177
178    /// Subscribe to leader-grant changes for evented proof cancellation.
179    ///
180    /// After notification, callers must use [`Self::proof_is_live`]. Candidacy changes are
181    /// available separately through [`Self::leader_candidacy_watch`]. The lease manager publishes
182    /// `None` when its monotonic deadline expires, so no polling loop is required.
183    #[must_use]
184    #[cfg(feature = "cluster")]
185    pub fn leader_grant_watch(&self) -> Option<watch::Receiver<Option<super::super::LeaderLease>>> {
186        self.leader_lease.get().map(|gate| gate.lease.clone())
187    }
188
189    #[cfg(feature = "cluster")]
190    pub(super) fn process_lease_proof_is_live(&self) -> bool {
191        self.process_lease_live.load(Ordering::Acquire)
192            && self
193                .process_lease_deadline
194                .get()
195                .is_some_and(|deadline| deadline.is_live())
196    }
197
198    /// Wire the exact owner and one fixed local deadline used to fence leader work.
199    ///
200    /// This compatibility seam is intended for fixed-generation tests and externally managed
201    /// grants. A running [`super::super::LeaderLeaseManager`] must use
202    /// [`Self::set_leader_lease_runtime_watches`] so deadline rotation remains visible.
203    ///
204    /// # Errors
205    /// Rejects an owner for another node or duplicate installation.
206    #[cfg(feature = "cluster")]
207    pub fn set_leader_lease_watch(
208        &self,
209        lease: watch::Receiver<Option<super::super::LeaderLease>>,
210        owner: super::super::LeaderLeaseOwner,
211        deadline: Arc<super::super::LeaseDeadline>,
212    ) -> Result<(), String> {
213        let (_deadline_tx, deadline) = watch::channel(deadline);
214        self.set_leader_lease_runtime_watches(lease, owner, deadline)
215    }
216
217    /// Wire the exact owner and generation-scoped local deadlines used to fence leader work.
218    ///
219    /// Readers hold the lease observation while sampling its deadline generation. The manager
220    /// fences the old deadline, withdraws the old lease, then publishes the next deadline, so a
221    /// stale lease can never be paired with a later live generation.
222    ///
223    /// # Errors
224    /// Rejects an owner for another node or duplicate installation.
225    #[cfg(feature = "cluster")]
226    pub fn set_leader_lease_runtime_watches(
227        &self,
228        lease: watch::Receiver<Option<super::super::LeaderLease>>,
229        owner: super::super::LeaderLeaseOwner,
230        deadline: watch::Receiver<Arc<super::super::LeaseDeadline>>,
231    ) -> Result<(), String> {
232        if owner.node != self.instance_id || owner.boot.is_nil() || owner.process_term == 0 {
233            return Err("leader lease owner does not match this process".into());
234        }
235        self.leader_lease
236            .set(LeaderLeaseGate {
237                lease,
238                owner,
239                deadline,
240            })
241            .map_err(|_| "leader lease fencing is already installed".into())
242    }
243
244    pub(super) fn notify_leader_eligibility_change(&self) {
245        let eligible = self.is_leader_lease_candidate();
246        self.leader_candidacy.send_if_modified(|published| {
247            let next = published
248                .transition(eligible)
249                .unwrap_or_else(super::super::LeaderCandidacy::terminal);
250            if next == *published {
251                false
252            } else {
253                *published = next;
254                true
255            }
256        });
257    }
258
259    pub(super) fn is_leader_lease_candidate(&self) -> bool {
260        if self.is_gossip_leader() {
261            return true;
262        }
263        !self.active.load(Ordering::Acquire)
264            && !self.is_draining()
265            && self.process_lease_is_live()
266            && !self
267                .members_rx
268                .borrow()
269                .iter()
270                .any(|member| matches!(member.state, NodeState::Active))
271    }
272
273    /// Evented lease candidacy, including cold cluster formation and normal gossip leadership.
274    #[cfg(feature = "cluster")]
275    #[must_use]
276    pub fn leader_candidacy_watch(
277        self: &Arc<Self>,
278    ) -> watch::Receiver<super::super::LeaderCandidacy> {
279        let mut members = self.members_rx.clone();
280        members.borrow_and_update();
281        self.notify_leader_eligibility_change();
282        let candidacy = self.leader_candidacy.clone();
283        let candidate_rx = candidacy.subscribe();
284        let controller = Arc::downgrade(self);
285        tokio::spawn(async move {
286            loop {
287                tokio::select! {
288                    biased;
289                    () = candidacy.closed() => return,
290                    changed = members.changed() => {
291                        if changed.is_err() {
292                            candidacy.send_replace(super::super::LeaderCandidacy::terminal());
293                            return;
294                        }
295                    }
296                }
297                let Some(controller) = controller.upgrade() else {
298                    return;
299                };
300                controller.notify_leader_eligibility_change();
301            }
302        });
303        candidate_rx
304    }
305
306    /// Mark this node's active status.
307    pub fn set_active(&self, active: bool) {
308        let _transition = self.process_authority_transition.lock();
309        let active = active && self.process_lease_is_live();
310        self.active.store(active, Ordering::SeqCst);
311        let eligible = active && !self.is_draining();
312        if self.leader_eligible.swap(eligible, Ordering::SeqCst) != eligible {
313            self.notify_leader_eligibility_change();
314        }
315    }
316
317    /// Permanently fence controller mutations after stable-node lease loss.
318    pub fn fence_process_lease(&self) {
319        let _transition = self.process_authority_transition.lock();
320        self.process_lease_live.store(false, Ordering::SeqCst);
321        if let Some(deadline) = self.process_lease_deadline.get() {
322            deadline.fence();
323        }
324        self.active.store(false, Ordering::SeqCst);
325        self.recovering.store(true, Ordering::SeqCst);
326        let roster_changed = self.leadership_participants.write().take().is_some();
327        if self.leader_eligible.swap(false, Ordering::SeqCst) || roster_changed {
328            self.notify_leader_eligibility_change();
329        }
330        self.checkpoint_assignment_fence.send_replace(None);
331        self.checkpoint_drain_transition.send_replace(None);
332    }
333
334    /// Whether this process still owns its stable node identity.
335    #[must_use]
336    pub fn process_lease_is_live(&self) -> bool {
337        self.process_lease_live.load(Ordering::Acquire)
338            && self
339                .process_lease_deadline
340                .get()
341                .is_none_or(|deadline| deadline.is_live())
342    }
343
344    pub(super) fn recovery_process_lease_is_live(&self) -> bool {
345        self.process_lease_live.load(Ordering::Acquire)
346            && self
347                .process_lease_deadline
348                .get()
349                .is_some_and(|deadline| deadline.is_live())
350    }
351
352    /// Wait until the process-local lease deadline expires or is explicitly fenced.
353    pub async fn wait_for_process_lease_loss(&self) {
354        if !self.process_lease_live.load(Ordering::Acquire) {
355            return;
356        }
357        let Some(deadline) = self.process_lease_deadline.get() else {
358            std::future::pending::<()>().await;
359            return;
360        };
361        deadline.wait_until_expired().await;
362    }
363
364    /// Install the stable-node lease deadline before the runtime starts.
365    ///
366    /// Reinstalling the same shared deadline is idempotent. A different deadline, or installation
367    /// after terminal process fencing, is rejected so controller and data-plane gates cannot
368    /// silently follow different lease clocks.
369    ///
370    /// # Errors
371    /// Rejects installation after fencing or replacement with a different deadline.
372    pub fn set_process_lease_deadline(
373        &self,
374        deadline: Arc<super::super::LeaseDeadline>,
375    ) -> Result<(), String> {
376        let _transition = self.process_authority_transition.lock();
377        if !self.process_lease_live.load(Ordering::Acquire) {
378            return Err("process lease authority is already terminally fenced".into());
379        }
380        if let Some(current) = self.process_lease_deadline.get() {
381            if !Arc::ptr_eq(current, &deadline) {
382                return Err("process lease deadline is already installed".into());
383            }
384            #[cfg(feature = "cluster")]
385            self.barrier.install_process_lease_deadline(deadline)?;
386            return Ok(());
387        }
388        #[cfg(feature = "cluster")]
389        self.barrier
390            .install_process_lease_deadline(Arc::clone(&deadline))?;
391        self.process_lease_deadline
392            .set(deadline)
393            .map_err(|_| "process lease deadline is already installed".to_string())
394    }
395
396    /// Shared process-local lease deadline, when cluster runtime wiring is complete.
397    #[must_use]
398    pub fn process_lease_deadline(&self) -> Option<Arc<super::super::LeaseDeadline>> {
399        self.process_lease_deadline.get().cloned()
400    }
401
402    /// Install the shared stable-node fencing authority before recovery starts.
403    ///
404    /// # Errors
405    /// Rejects replacing an already-installed authority.
406    pub fn set_process_lease_authority(
407        &self,
408        authority: Arc<super::super::process_lease::ProcessLeaseAuthority>,
409    ) -> Result<(), String> {
410        self.process_lease_authority
411            .set(authority)
412            .map_err(|_| "process lease authority is already installed".to_string())
413    }
414
415    /// Prove that an exact assignment participant was durably superseded within `deadline`.
416    ///
417    /// # Errors
418    /// Fails closed when no shared authority is installed or fencing cannot be proven in time.
419    pub async fn fence_process_incarnation(
420        &self,
421        participant: CheckpointParticipant,
422        deadline: tokio::time::Instant,
423    ) -> Result<super::super::process_lease::ProcessLeaseFence, String> {
424        self.process_lease_authority
425            .get()
426            .ok_or_else(|| "process lease authority is not installed".to_string())?
427            .fence_incarnation(participant, deadline)
428            .await
429            .map_err(|error| error.to_string())
430    }
431
432    /// Bound process fencing by the authority TTL plus the caller's existing control-plane I/O
433    /// budget, avoiding a second configurable timeout dimension.
434    ///
435    /// # Errors
436    /// Fails when no shared authority is installed or the deadline cannot be represented.
437    pub fn process_fencing_deadline(
438        &self,
439        io_budget: Duration,
440    ) -> Result<tokio::time::Instant, String> {
441        self.process_lease_authority
442            .get()
443            .ok_or_else(|| "process lease authority is not installed".to_string())?
444            .fencing_deadline(io_budget)
445            .map_err(|error| error.to_string())
446    }
447
448    /// Verify one successor roster identity against the current durable process-lease head.
449    ///
450    /// # Errors
451    /// Fails when no authority is installed or the bounded durable read is uncertain.
452    pub async fn verify_current_process_incarnation(
453        &self,
454        participant: CheckpointParticipant,
455        deadline: tokio::time::Instant,
456    ) -> Result<bool, String> {
457        self.process_lease_authority
458            .get()
459            .ok_or_else(|| "process lease authority is not installed".to_string())?
460            .verify_current_participant(participant, deadline)
461            .await
462            .map_err(|error| error.to_string())
463    }
464
465    /// Re-read the exact durable records behind a process fence within `deadline`.
466    ///
467    /// # Errors
468    /// Fails closed when no shared authority is installed or verification cannot finish in time.
469    pub async fn verify_process_lease_fence(
470        &self,
471        fence: &super::super::process_lease::ProcessLeaseFence,
472        deadline: tokio::time::Instant,
473    ) -> Result<bool, String> {
474        self.process_lease_authority
475            .get()
476            .ok_or_else(|| "process lease authority is not installed".to_string())?
477            .verify_fence(fence, deadline)
478            .await
479            .map_err(|error| error.to_string())
480    }
481
482    /// Admit a recovery assignment only after re-verifying every durable process revocation.
483    ///
484    /// Keeping this operation on the controller binds the process-lease authority and leader
485    /// authority installed for this runtime. Callers cannot bypass revocation verification by
486    /// writing a structurally valid recovery decision directly to the leader store.
487    ///
488    /// # Errors
489    /// Fails closed when either authority is absent, a process fence is not durable, the deadline
490    /// expires, leadership changes, the decision conflicts, or storage is unavailable.
491    pub async fn record_assignment_recovery_decision(
492        &self,
493        proof: &super::super::LeaderProof,
494        decision: super::super::AssignmentRecoveryDecision,
495        deadline: tokio::time::Instant,
496    ) -> Result<super::super::RecordAssignmentRecoveryDecisionResult, String> {
497        let process_authority = Arc::clone(
498            self.process_lease_authority
499                .get()
500                .ok_or_else(|| "process lease authority is not installed".to_string())?,
501        );
502        let checks = futures::stream::iter(decision.removed_process_fences.iter().cloned())
503            .map(|fence| {
504                let process_authority = Arc::clone(&process_authority);
505                async move { process_authority.verify_fence(&fence, deadline).await }
506            })
507            .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
508            .collect::<Vec<_>>()
509            .await;
510        for check in checks {
511            match check {
512                Ok(true) => {}
513                Ok(false) => {
514                    return Err("assignment recovery contains an unverified process fence".into());
515                }
516                Err(error) => {
517                    return Err(format!(
518                        "assignment recovery process fence verification failed: {error}"
519                    ));
520                }
521            }
522        }
523        if tokio::time::Instant::now() >= deadline {
524            return Err("assignment recovery authority admission exceeded its deadline".into());
525        }
526        let authority = self
527            .checkpoint_authority()
528            .map_err(|error| error.to_string())?;
529        Box::pin(tokio::time::timeout_at(
530            deadline,
531            authority.record_assignment_recovery_decision(proof, decision),
532        ))
533        .await
534        .map_err(|_| "assignment recovery authority admission exceeded its deadline".to_string())?
535        .map_err(|error| error.to_string())
536    }
537}