Skip to main content

laminar_core/cluster/control/leader_lease/
manager.rs

1//! Process-local renewal loop that converges the durable leader lease to gossip candidacy.
2
3use super::*;
4
5/// Acquires and renews leadership while candidacy remains true.
6pub struct LeaderLeaseManager {
7    store: Arc<LeaderLeaseStore>,
8    owner: LeaderLeaseOwner,
9    config: LeaderLeaseConfig,
10    lease_tx: watch::Sender<Option<LeaderLease>>,
11    deadline_tx: watch::Sender<Arc<LeaseDeadline>>,
12}
13
14#[cfg(feature = "cluster")]
15enum LeaseOperationEvent {
16    Shutdown,
17    Candidacy(Result<(), watch::error::RecvError>),
18    Deadline,
19    Completed {
20        result: Result<LeaseOutcome, LeaseError>,
21        valid_until: tokio::time::Instant,
22    },
23}
24
25#[cfg(feature = "cluster")]
26struct LeaderLeaseExitGuard {
27    shutdown: tokio_util::sync::CancellationToken,
28    unexpected_exit: tokio_util::sync::CancellationToken,
29    lease_tx: watch::Sender<Option<LeaderLease>>,
30    deadline_tx: watch::Sender<Arc<LeaseDeadline>>,
31}
32
33#[cfg(feature = "cluster")]
34impl Drop for LeaderLeaseExitGuard {
35    fn drop(&mut self) {
36        self.deadline_tx.borrow().fence();
37        self.lease_tx.send_replace(None);
38        if !self.shutdown.is_cancelled() {
39            tracing::error!(
40                "leader lease manager exited without intentional shutdown; fencing process authority"
41            );
42            self.unexpected_exit.cancel();
43        }
44    }
45}
46
47#[cfg(feature = "cluster")]
48async fn wait_for_deadline(deadline: Option<tokio::time::Instant>) {
49    if let Some(deadline) = deadline {
50        tokio::time::sleep_until(deadline).await;
51    } else {
52        std::future::pending::<()>().await;
53    }
54}
55
56impl std::fmt::Debug for LeaderLeaseManager {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter
59            .debug_struct("LeaderLeaseManager")
60            .field("owner", &self.owner)
61            .field("config", &self.config)
62            .finish_non_exhaustive()
63    }
64}
65
66impl LeaderLeaseManager {
67    /// Construct a manager bound to this boot's acquired stable-node process lease.
68    ///
69    /// # Errors
70    /// Rejects invalid ownership or inconsistent renewal timings.
71    pub fn new(
72        store: Arc<LeaderLeaseStore>,
73        process_lease: &ProcessLease,
74        config: LeaderLeaseConfig,
75    ) -> Result<Self, LeaseError> {
76        let owner = LeaderLeaseOwner::from_process_lease(process_lease)?;
77        let ttl_ms = i64::try_from(config.ttl.as_millis())
78            .map_err(|_| LeaseError::Invalid("lease TTL exceeds diagnostic range".into()))?;
79        let exact_ttl = u64::try_from(ttl_ms)
80            .ok()
81            .map(Duration::from_millis)
82            .is_some_and(|ttl| ttl == config.ttl);
83        if ttl_ms <= 0
84            || !exact_ttl
85            || config.renew_interval.is_zero()
86            || config.renew_interval >= config.ttl
87            || ttl_ms != store.ttl_ms
88        {
89            return Err(LeaseError::Invalid(
90                "manager requires a renewal interval below the store's matching TTL".into(),
91            ));
92        }
93        let (lease_tx, _lease_rx) = watch::channel(None);
94        let (deadline_tx, _deadline_rx) = watch::channel(Arc::new(LeaseDeadline::uninitialized()));
95        Ok(Self {
96            store,
97            owner,
98            config,
99            lease_tx,
100            deadline_tx,
101        })
102    }
103
104    /// Exact process incarnation this manager may publish.
105    #[must_use]
106    pub fn owner(&self) -> &LeaderLeaseOwner {
107        &self.owner
108    }
109
110    /// Subscribe to the locally authorized leader record.
111    #[must_use]
112    pub fn lease_watch(&self) -> watch::Receiver<Option<LeaderLease>> {
113        self.lease_tx.subscribe()
114    }
115
116    /// Snapshot the current local-monotonic deadline generation.
117    ///
118    /// This is useful before the manager starts and in fixed-generation tests. A running manager
119    /// may replace it after any discontinuous grant; runtime consumers must subscribe through
120    /// [`Self::deadline_watch`].
121    #[must_use]
122    pub fn deadline(&self) -> Arc<LeaseDeadline> {
123        self.deadline_tx.borrow().clone()
124    }
125
126    /// Subscribe to generation-scoped local leader deadlines.
127    ///
128    /// A discontinuous grant permanently fences the previous deadline and publishes a fresh
129    /// inactive one before another durable fencing term can become locally authoritative.
130    #[must_use]
131    pub fn deadline_watch(&self) -> watch::Receiver<Arc<LeaseDeadline>> {
132        self.deadline_tx.subscribe()
133    }
134
135    #[cfg(feature = "cluster")]
136    pub(super) fn withdraw(&self) -> bool {
137        let deadline = self.deadline_tx.borrow().clone();
138        let had_grant = self.lease_tx.borrow().is_some() || deadline.is_live();
139        // Always rotate the generation. An acquisition may have crossed its deadline after the
140        // durable response but before local publication, leaving an expired, never-published
141        // deadline that `is_live()` cannot distinguish from a fresh inactive one.
142        deadline.fence();
143        self.lease_tx.send_replace(None);
144        self.deadline_tx
145            .send_replace(Arc::new(LeaseDeadline::uninitialized()));
146        had_grant
147    }
148
149    #[cfg(feature = "cluster")]
150    fn fence(&self) {
151        self.deadline_tx.borrow().fence();
152        self.lease_tx.send_replace(None);
153    }
154
155    #[cfg(feature = "cluster")]
156    fn withdraw_for_new_term(
157        &self,
158        ticker: &mut tokio::time::Interval,
159        valid_until: &mut Option<tokio::time::Instant>,
160        observation: &mut Option<LeaderLeaseObservation>,
161        held_token: &mut Option<u64>,
162        rotate_not_before: &mut Option<tokio::time::Instant>,
163    ) {
164        // Permanently fence the process-local grant before forgetting its fencing token. A later
165        // acquisition runs through `begin_new_term` and a fresh deadline generation, so neither
166        // half of the expired proof can become live again.
167        let had_grant = self.withdraw();
168        *valid_until = None;
169        *observation = None;
170        *held_token = None;
171        let now = tokio::time::Instant::now();
172        if had_grant {
173            let retry_at = now
174                .checked_add(self.config.ttl)
175                .unwrap_or_else(tokio::time::Instant::now);
176            // An old-proof checkpoint tail is still allowed to win the authority CAS while no
177            // newer term exists. Preserve one full TTL for that bounded settlement before the
178            // same process rotates the durable token.
179            if rotate_not_before.is_none_or(|current| retry_at > current) {
180                *rotate_not_before = Some(retry_at);
181            }
182        }
183        if let Some(retry_at) = *rotate_not_before {
184            if retry_at > now {
185                ticker.reset_at(retry_at);
186            } else {
187                *rotate_not_before = None;
188                ticker.reset_immediately();
189            }
190        } else {
191            ticker.reset_immediately();
192        }
193    }
194
195    #[cfg(feature = "cluster")]
196    async fn attempt_lease(
197        &self,
198        shutdown: &tokio_util::sync::CancellationToken,
199        candidate: &mut watch::Receiver<LeaderCandidacy>,
200        valid_until: Option<tokio::time::Instant>,
201        observation: Option<&LeaderLeaseObservation>,
202        held_token: Option<u64>,
203    ) -> LeaseOperationEvent {
204        let Some(attempt_valid_until) = tokio::time::Instant::now().checked_add(self.config.ttl)
205        else {
206            return LeaseOperationEvent::Deadline;
207        };
208        let operation_deadline = valid_until.map_or(attempt_valid_until, |current| {
209            current.min(attempt_valid_until)
210        });
211        let operation = async {
212            if let Some(observation) = observation {
213                Box::pin(
214                    self.store
215                        .try_takeover(&self.owner, observation, now_millis()),
216                )
217                .await
218            } else if let Some(token) = held_token {
219                Box::pin(self.store.renew_exact(&self.owner, token, now_millis())).await
220            } else {
221                Box::pin(self.store.begin_new_term(&self.owner, now_millis())).await
222            }
223        };
224        tokio::select! {
225            biased;
226            () = shutdown.cancelled() => LeaseOperationEvent::Shutdown,
227            changed = candidate.changed() => LeaseOperationEvent::Candidacy(changed),
228            () = wait_for_deadline(Some(operation_deadline)) => LeaseOperationEvent::Deadline,
229            result = operation => LeaseOperationEvent::Completed {
230                result,
231                valid_until: attempt_valid_until,
232            },
233        }
234    }
235
236    /// Publish that this process gave up the term it just withdrew from, then wait for candidacy
237    /// to change. The local grant is already fenced, so a rival may take the released term over
238    /// at once rather than observe it for a full TTL; after one TTL the release is moot.
239    #[cfg(feature = "cluster")]
240    async fn release_and_await_candidacy(
241        &self,
242        shutdown: &tokio_util::sync::CancellationToken,
243        candidate: &mut watch::Receiver<LeaderCandidacy>,
244        withdrawn_term: Option<u64>,
245    ) -> bool {
246        if let Some(token) = withdrawn_term {
247            let release = tokio::time::timeout(
248                self.config.ttl,
249                self.store.publish_release(&self.owner, token),
250            );
251            tokio::select! {
252                biased;
253                () = shutdown.cancelled() => {
254                    self.fence();
255                    return false;
256                }
257                released = release => match released {
258                    Ok(Ok(())) => {
259                        tracing::info!(owner = ?self.owner, token, "released the leader lease term");
260                    }
261                    Ok(Err(error)) => tracing::warn!(%error, "leader lease release failed"),
262                    Err(_) => tracing::warn!("leader lease release timed out"),
263                },
264            }
265        }
266        tokio::select! {
267            biased;
268            () = shutdown.cancelled() => {
269                self.fence();
270                false
271            }
272            changed = candidate.changed() => {
273                if changed.is_err() {
274                    self.fence();
275                    return false;
276                }
277                true
278            }
279        }
280    }
281
282    /// Keep an observation that still names the rival's liveness identity; restart it otherwise.
283    #[cfg(feature = "cluster")]
284    fn observe_held_rival(
285        &self,
286        observation: Option<LeaderLeaseObservation>,
287        rival: &LeaderLease,
288    ) -> Option<LeaderLeaseObservation> {
289        if observation
290            .as_ref()
291            .is_some_and(|observed| observed.lease.has_same_liveness_identity(rival))
292        {
293            return observation;
294        }
295        match self.store.observe_rival(&self.owner, rival) {
296            Ok(observation) => Some(observation),
297            Err(error) => {
298                tracing::warn!(%error, "leader lease observation rejected");
299                None
300            }
301        }
302    }
303
304    #[cfg(feature = "cluster")]
305    async fn run(
306        self,
307        shutdown: tokio_util::sync::CancellationToken,
308        mut candidate: watch::Receiver<LeaderCandidacy>,
309    ) {
310        let mut ticker = tokio::time::interval(self.config.renew_interval);
311        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
312        let mut valid_until: Option<tokio::time::Instant> = None;
313        let mut observation: Option<LeaderLeaseObservation> = None;
314        // A newly constructed manager and every locally withdrawn grant need a new durable
315        // fencing token. Only uninterrupted renewals may preserve the current token.
316        let mut held_token = None;
317        let mut rotate_not_before = None;
318        let mut candidacy_generation = candidate.borrow().generation;
319
320        loop {
321            let candidacy = *candidate.borrow_and_update();
322            let withdrawn_term = held_token;
323            if candidacy.generation != candidacy_generation {
324                self.withdraw_for_new_term(
325                    &mut ticker,
326                    &mut valid_until,
327                    &mut observation,
328                    &mut held_token,
329                    &mut rotate_not_before,
330                );
331                candidacy_generation = candidacy.generation;
332            }
333            if !candidacy.eligible {
334                self.withdraw_for_new_term(
335                    &mut ticker,
336                    &mut valid_until,
337                    &mut observation,
338                    &mut held_token,
339                    &mut rotate_not_before,
340                );
341                if !self
342                    .release_and_await_candidacy(&shutdown, &mut candidate, withdrawn_term)
343                    .await
344                {
345                    return;
346                }
347                continue;
348            }
349
350            tokio::select! {
351                biased;
352                () = shutdown.cancelled() => {
353                    self.fence();
354                    return;
355                }
356                changed = candidate.changed() => {
357                    if changed.is_err() {
358                        self.fence();
359                        return;
360                    }
361                    continue;
362                }
363                () = wait_for_deadline(valid_until) => {
364                    tracing::warn!(
365                        owner = ?self.owner,
366                        "leader lease local deadline expired; withdrawing and rotating the fencing token"
367                    );
368                    self.withdraw_for_new_term(
369                        &mut ticker,
370                        &mut valid_until,
371                        &mut observation,
372                        &mut held_token,
373                        &mut rotate_not_before,
374                    );
375                    continue;
376                }
377                _ = ticker.tick() => {}
378            }
379
380            let (result, attempt_valid_until) = match Box::pin(self.attempt_lease(
381                &shutdown,
382                &mut candidate,
383                valid_until,
384                observation.as_ref(),
385                held_token,
386            ))
387            .await
388            {
389                LeaseOperationEvent::Shutdown => {
390                    self.fence();
391                    return;
392                }
393                LeaseOperationEvent::Deadline => {
394                    tracing::warn!(
395                        owner = ?self.owner,
396                        "leader lease operation exceeded its local deadline; withdrawing and rotating the fencing token"
397                    );
398                    self.withdraw_for_new_term(
399                        &mut ticker,
400                        &mut valid_until,
401                        &mut observation,
402                        &mut held_token,
403                        &mut rotate_not_before,
404                    );
405                    continue;
406                }
407                LeaseOperationEvent::Candidacy(changed) => {
408                    if changed.is_err() {
409                        self.fence();
410                        return;
411                    }
412                    continue;
413                }
414                LeaseOperationEvent::Completed {
415                    result,
416                    valid_until: attempt_valid_until,
417                } => {
418                    let response_at = tokio::time::Instant::now();
419                    if response_at >= attempt_valid_until
420                        || valid_until.is_some_and(|current| response_at >= current)
421                    {
422                        tracing::warn!(
423                            owner = ?self.owner,
424                            "leader lease response arrived after its local deadline; withdrawing and rotating the fencing token"
425                        );
426                        self.withdraw_for_new_term(
427                            &mut ticker,
428                            &mut valid_until,
429                            &mut observation,
430                            &mut held_token,
431                            &mut rotate_not_before,
432                        );
433                        continue;
434                    }
435                    (result, attempt_valid_until)
436                }
437            };
438
439            match result {
440                Ok(LeaseOutcome::Acquired(lease)) if lease.owner == self.owner => {
441                    let publication_at = tokio::time::Instant::now();
442                    if publication_at >= attempt_valid_until
443                        || valid_until.is_some_and(|current| publication_at >= current)
444                    {
445                        tracing::warn!(
446                            owner = ?self.owner,
447                            "leader lease publication crossed its local deadline; withdrawing and rotating the fencing token"
448                        );
449                        self.withdraw_for_new_term(
450                            &mut ticker,
451                            &mut valid_until,
452                            &mut observation,
453                            &mut held_token,
454                            &mut rotate_not_before,
455                        );
456                        continue;
457                    }
458                    observation = None;
459                    valid_until = Some(attempt_valid_until);
460                    held_token = Some(lease.token);
461                    rotate_not_before = None;
462                    let deadline = self.deadline_tx.borrow().clone();
463                    deadline.extend_until(attempt_valid_until.into_std());
464                    if !deadline.is_live() {
465                        tracing::warn!(
466                            owner = ?self.owner,
467                            "leader lease deadline expired during local publication; withdrawing and rotating the fencing token"
468                        );
469                        self.withdraw_for_new_term(
470                            &mut ticker,
471                            &mut valid_until,
472                            &mut observation,
473                            &mut held_token,
474                            &mut rotate_not_before,
475                        );
476                        continue;
477                    }
478                    self.lease_tx.send_replace(Some(lease));
479                }
480                Ok(LeaseOutcome::Acquired(_)) => {
481                    self.fence();
482                    return;
483                }
484                Ok(LeaseOutcome::Held(rival)) => {
485                    self.withdraw();
486                    valid_until = None;
487                    held_token = None;
488                    observation = self.observe_held_rival(observation.take(), &rival);
489                }
490                Err(LeaseError::Fenced(error)) => {
491                    tracing::warn!(%error, "leader lease renewal was fenced");
492                    self.fence();
493                    return;
494                }
495                Err(error) => {
496                    tracing::warn!(%error, "leader lease operation failed");
497                }
498            }
499        }
500    }
501
502    /// Spawn the renewal loop. Loss of candidacy withdraws the current local grant so this
503    /// manager can contend again later. A missed local deadline withdraws the grant and rotates
504    /// its fencing token before reacquisition. Shutdown or an invalid lease outcome terminally
505    /// fences the manager.
506    #[cfg(feature = "cluster")]
507    #[must_use]
508    pub fn spawn(
509        self,
510        shutdown: tokio_util::sync::CancellationToken,
511        candidate: watch::Receiver<LeaderCandidacy>,
512    ) -> tokio::task::JoinHandle<()> {
513        tokio::spawn(self.run(shutdown, candidate))
514    }
515
516    /// Spawn the renewal loop and signal any exit not preceded by intentional shutdown.
517    ///
518    /// The guard lives inside the same task as the manager future, so panic and forced task abort
519    /// cannot detach a still-running inner lease writer.
520    #[cfg(feature = "cluster")]
521    #[must_use]
522    pub fn spawn_supervised(
523        self,
524        shutdown: tokio_util::sync::CancellationToken,
525        candidate: watch::Receiver<LeaderCandidacy>,
526        unexpected_exit: tokio_util::sync::CancellationToken,
527    ) -> tokio::task::JoinHandle<()> {
528        tokio::spawn(async move {
529            let run_shutdown = shutdown.clone();
530            let _exit_guard = LeaderLeaseExitGuard {
531                shutdown,
532                unexpected_exit,
533                lease_tx: self.lease_tx.clone(),
534                deadline_tx: self.deadline_tx.clone(),
535            };
536            self.run(run_shutdown, candidate).await;
537        })
538    }
539}