Skip to main content

aster_forge_runtime/
lease.rs

1//! Runtime lease supervision for multi-instance services.
2//!
3//! A runtime lease protects process-level singleton groups such as schedulers,
4//! cleanup loops, outbox dispatchers, and other background producers that must
5//! run on only one service instance at a time. It is intentionally separate
6//! from task-row processing leases: task leases protect one persisted work item,
7//! while runtime leases decide which process is allowed to start a whole worker
8//! group.
9//!
10//! The storage backend is abstracted by [`RuntimeLeaseStore`]. Database-backed
11//! services can use the store provided by `aster_forge_db`; tests and other
12//! deployments can provide their own implementation. The supervisor remains
13//! conservative: if renewal fails or ownership is lost, it cancels the leased
14//! workload before trying to acquire the lease again.
15
16use std::fmt::Display;
17use std::future::Future;
18use std::time::Duration;
19
20use chrono::{DateTime, Utc};
21use tokio_util::sync::CancellationToken;
22
23/// Minimum lease TTL used when a caller provides a zero duration.
24pub const DEFAULT_RUNTIME_LEASE_TTL: Duration = Duration::from_secs(30);
25/// Minimum retry interval used when a caller provides a zero duration.
26pub const DEFAULT_RUNTIME_LEASE_RETRY_INTERVAL: Duration = Duration::from_secs(5);
27
28/// Generates a process-unique runtime lease owner ID.
29///
30/// The owner ID identifies this running process instance, not a configured
31/// deployment node. A fresh value on every process start prevents an old stuck
32/// process and a newly restarted process from being treated as the same lease
33/// owner.
34pub fn new_runtime_lease_owner_id() -> String {
35    aster_forge_utils::id::new_runtime_id()
36}
37
38/// Runtime lease settings for one singleton worker group.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RuntimeLeaseConfig {
41    /// Stable lease key shared by all service instances.
42    pub lease_id: String,
43    /// Process-unique identifier for the current runtime owner.
44    pub owner_id: String,
45    /// Time after which another owner may take over if renewals stop.
46    pub ttl: Duration,
47    /// Interval used by the current owner to renew the lease.
48    pub renew_interval: Duration,
49    /// Interval used by standby instances before attempting acquisition again.
50    pub standby_retry_interval: Duration,
51}
52
53impl RuntimeLeaseConfig {
54    /// Creates runtime lease settings with conservative default intervals.
55    pub fn new(lease_id: impl Into<String>, owner_id: impl Into<String>) -> Self {
56        Self {
57            lease_id: lease_id.into(),
58            owner_id: owner_id.into(),
59            ttl: DEFAULT_RUNTIME_LEASE_TTL,
60            renew_interval: Duration::from_secs(10),
61            standby_retry_interval: DEFAULT_RUNTIME_LEASE_RETRY_INTERVAL,
62        }
63    }
64
65    /// Sets the lease TTL.
66    pub const fn ttl(mut self, ttl: Duration) -> Self {
67        self.ttl = ttl;
68        self
69    }
70
71    /// Sets the owner renewal interval.
72    pub const fn renew_interval(mut self, renew_interval: Duration) -> Self {
73        self.renew_interval = renew_interval;
74        self
75    }
76
77    /// Sets the standby acquisition retry interval.
78    pub const fn standby_retry_interval(mut self, standby_retry_interval: Duration) -> Self {
79        self.standby_retry_interval = standby_retry_interval;
80        self
81    }
82
83    fn effective_ttl(&self) -> Duration {
84        if self.ttl.is_zero() {
85            DEFAULT_RUNTIME_LEASE_TTL
86        } else {
87            self.ttl
88        }
89    }
90
91    fn effective_renew_interval(&self) -> Duration {
92        let ttl = self.effective_ttl();
93        if self.renew_interval.is_zero() {
94            return duration_third(ttl).max(Duration::from_secs(1));
95        }
96        if self.renew_interval >= ttl {
97            return duration_half(ttl).max(Duration::from_secs(1));
98        }
99        self.renew_interval
100    }
101
102    fn effective_standby_retry_interval(&self) -> Duration {
103        if self.standby_retry_interval.is_zero() {
104            DEFAULT_RUNTIME_LEASE_RETRY_INTERVAL
105        } else {
106            self.standby_retry_interval
107        }
108    }
109
110    fn expires_at(&self, now: DateTime<Utc>) -> DateTime<Utc> {
111        let ttl = chrono::Duration::from_std(self.effective_ttl()).unwrap_or(chrono::Duration::MAX);
112        // `now + ttl` would panic inside chrono's Add impl when the sum overflows
113        // DateTime's representable range (operator panics bypass clippy::panic).
114        // A lease expiring at the end of time is the correct saturated semantics
115        // for absurd TTLs.
116        now.checked_add_signed(ttl)
117            .unwrap_or(DateTime::<Utc>::MAX_UTC)
118    }
119}
120
121/// Acquisition request passed to a runtime lease store.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct RuntimeLeaseClaim<'a> {
124    /// Stable lease key shared by all service instances.
125    pub lease_id: &'a str,
126    /// Process-unique identifier for the current runtime owner.
127    pub owner_id: &'a str,
128    /// Current timestamp chosen by the caller.
129    pub now: DateTime<Utc>,
130    /// Expiry timestamp to persist when acquisition succeeds.
131    pub expires_at: DateTime<Utc>,
132}
133
134/// Current owner observed when a lease is held by another process.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct RuntimeLeaseOwner {
137    /// Owner identifier stored by the active process.
138    pub owner_id: String,
139    /// Current expiry timestamp for that owner.
140    pub expires_at: DateTime<Utc>,
141}
142
143/// Result of one acquisition attempt.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum RuntimeLeaseAcquire {
146    /// The caller owns the lease and may start the singleton worker group.
147    Acquired,
148    /// Another owner still holds the lease.
149    Standby {
150        /// Current owner details when available.
151        owner: Option<RuntimeLeaseOwner>,
152    },
153}
154
155impl RuntimeLeaseAcquire {
156    /// Returns whether the caller acquired ownership.
157    pub const fn acquired(&self) -> bool {
158        matches!(self, Self::Acquired)
159    }
160}
161
162/// Store contract used by runtime lease supervisors.
163#[async_trait::async_trait]
164pub trait RuntimeLeaseStore: Send + Sync + 'static {
165    /// Store error type.
166    type Error: Display + Send + Sync + 'static;
167
168    /// Attempts to acquire the lease for the caller.
169    async fn try_acquire(
170        &self,
171        claim: RuntimeLeaseClaim<'_>,
172    ) -> Result<RuntimeLeaseAcquire, Self::Error>;
173
174    /// Renews an owned lease and returns whether ownership was still held.
175    async fn renew(
176        &self,
177        lease_id: &str,
178        owner_id: &str,
179        now: DateTime<Utc>,
180        expires_at: DateTime<Utc>,
181    ) -> Result<bool, Self::Error>;
182
183    /// Releases an owned lease during cooperative shutdown.
184    async fn release(&self, lease_id: &str, owner_id: &str) -> Result<(), Self::Error>;
185}
186
187/// Runs one singleton worker group behind a runtime lease.
188///
189/// The supervisor stays alive until `shutdown_token` is cancelled. Standby
190/// instances retry acquisition periodically. The active owner renews its lease;
191/// if renewal fails or reports lost ownership, the workload is cancelled and
192/// stopped before the supervisor returns to standby mode.
193pub async fn run_runtime_lease_supervisor<Store, StartFn, Workload, StopFn, StopFut>(
194    store: Store,
195    config: RuntimeLeaseConfig,
196    shutdown_token: CancellationToken,
197    mut start_workload: StartFn,
198    mut stop_workload: StopFn,
199) where
200    Store: RuntimeLeaseStore,
201    StartFn: FnMut(CancellationToken) -> Workload + Send,
202    StopFn: FnMut(Workload) -> StopFut + Send,
203    StopFut: Future<Output = ()> + Send,
204{
205    while !shutdown_token.is_cancelled() {
206        let now = Utc::now();
207        let claim = RuntimeLeaseClaim {
208            lease_id: &config.lease_id,
209            owner_id: &config.owner_id,
210            now,
211            expires_at: config.expires_at(now),
212        };
213
214        match store.try_acquire(claim).await {
215            Ok(RuntimeLeaseAcquire::Acquired) => {
216                tracing::info!(
217                    lease_id = %config.lease_id,
218                    owner_id = %config.owner_id,
219                    "runtime lease acquired"
220                );
221                run_owned_runtime_lease(
222                    &store,
223                    &config,
224                    shutdown_token.clone(),
225                    &mut start_workload,
226                    &mut stop_workload,
227                )
228                .await;
229            }
230            Ok(RuntimeLeaseAcquire::Standby { owner }) => {
231                if let Some(owner) = owner {
232                    tracing::debug!(
233                        lease_id = %config.lease_id,
234                        owner_id = %config.owner_id,
235                        active_owner_id = %owner.owner_id,
236                        active_expires_at = %owner.expires_at,
237                        "runtime lease held by another owner"
238                    );
239                }
240                sleep_or_shutdown(config.effective_standby_retry_interval(), &shutdown_token).await;
241            }
242            Err(error) => {
243                tracing::warn!(
244                    lease_id = %config.lease_id,
245                    owner_id = %config.owner_id,
246                    error = %error,
247                    "runtime lease acquisition failed"
248                );
249                sleep_or_shutdown(config.effective_standby_retry_interval(), &shutdown_token).await;
250            }
251        }
252    }
253}
254
255async fn run_owned_runtime_lease<Store, StartFn, Workload, StopFn, StopFut>(
256    store: &Store,
257    config: &RuntimeLeaseConfig,
258    shutdown_token: CancellationToken,
259    start_workload: &mut StartFn,
260    stop_workload: &mut StopFn,
261) where
262    Store: RuntimeLeaseStore,
263    StartFn: FnMut(CancellationToken) -> Workload + Send,
264    StopFn: FnMut(Workload) -> StopFut + Send,
265    StopFut: Future<Output = ()> + Send,
266{
267    let workload_token = CancellationToken::new();
268    let workload = start_workload(workload_token.clone());
269    let renew_interval = config.effective_renew_interval();
270
271    loop {
272        tokio::select! {
273            biased;
274            _ = shutdown_token.cancelled() => {
275                workload_token.cancel();
276                stop_workload(workload).await;
277                if let Err(error) = store.release(&config.lease_id, &config.owner_id).await {
278                    tracing::warn!(
279                        lease_id = %config.lease_id,
280                        owner_id = %config.owner_id,
281                        error = %error,
282                        "failed to release runtime lease during shutdown"
283                    );
284                }
285                return;
286            }
287            _ = tokio::time::sleep(renew_interval) => {}
288        }
289
290        let now = Utc::now();
291        match store
292            .renew(
293                &config.lease_id,
294                &config.owner_id,
295                now,
296                config.expires_at(now),
297            )
298            .await
299        {
300            Ok(true) => {
301                tracing::trace!(
302                    lease_id = %config.lease_id,
303                    owner_id = %config.owner_id,
304                    "runtime lease renewed"
305                );
306            }
307            Ok(false) => {
308                tracing::warn!(
309                    lease_id = %config.lease_id,
310                    owner_id = %config.owner_id,
311                    "runtime lease ownership lost"
312                );
313                workload_token.cancel();
314                stop_workload(workload).await;
315                return;
316            }
317            Err(error) => {
318                tracing::warn!(
319                    lease_id = %config.lease_id,
320                    owner_id = %config.owner_id,
321                    error = %error,
322                    "runtime lease renewal failed"
323                );
324                workload_token.cancel();
325                stop_workload(workload).await;
326                return;
327            }
328        }
329    }
330}
331
332async fn sleep_or_shutdown(duration: Duration, shutdown_token: &CancellationToken) {
333    tokio::select! {
334        biased;
335        _ = shutdown_token.cancelled() => {}
336        _ = tokio::time::sleep(duration) => {}
337    }
338}
339
340fn duration_half(duration: Duration) -> Duration {
341    Duration::from_secs_f64(duration.as_secs_f64() / 2.0)
342}
343
344fn duration_third(duration: Duration) -> Duration {
345    Duration::from_secs_f64(duration.as_secs_f64() / 3.0)
346}
347
348#[cfg(test)]
349mod tests {
350    use std::collections::HashMap;
351    use std::fmt;
352    use std::sync::{
353        Arc,
354        atomic::{AtomicUsize, Ordering},
355    };
356    use std::time::Duration;
357
358    use chrono::{DateTime, Utc};
359    use tokio::sync::Mutex;
360    use tokio_util::sync::CancellationToken;
361
362    use super::{
363        RuntimeLeaseAcquire, RuntimeLeaseClaim, RuntimeLeaseConfig, RuntimeLeaseOwner,
364        RuntimeLeaseStore, new_runtime_lease_owner_id, run_runtime_lease_supervisor,
365    };
366
367    #[test]
368    fn expires_at_saturates_instead_of_panicking_on_absurd_ttl() {
369        let now = Utc::now();
370
371        // Beyond chrono::Duration's range: from_std fails and Duration::MAX is used.
372        let config =
373            RuntimeLeaseConfig::new("test.background", "node-a").ttl(Duration::from_secs(u64::MAX));
374        assert_eq!(config.expires_at(now), DateTime::<Utc>::MAX_UTC);
375
376        // Inside chrono::Duration's range but beyond DateTime's representable range:
377        // must saturate, not panic inside chrono's Add impl.
378        let config = RuntimeLeaseConfig::new("test.background", "node-a")
379            .ttl(Duration::from_secs(10_u64.pow(15)));
380        assert_eq!(config.expires_at(now), DateTime::<Utc>::MAX_UTC);
381
382        // Normal TTLs still add exactly.
383        let config =
384            RuntimeLeaseConfig::new("test.background", "node-a").ttl(Duration::from_secs(60));
385        assert_eq!(config.expires_at(now), now + chrono::Duration::seconds(60));
386    }
387
388    #[derive(Debug, Clone)]
389    struct TestLeaseError;
390    impl fmt::Display for TestLeaseError {
391        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
392            formatter.write_str("test lease error")
393        }
394    }
395
396    #[derive(Debug, Clone)]
397    struct TestLeaseRow {
398        owner_id: String,
399        expires_at: DateTime<Utc>,
400    }
401
402    #[derive(Default)]
403    struct TestLeaseStore {
404        rows: Mutex<HashMap<String, TestLeaseRow>>,
405        renew_results: Mutex<Vec<Result<bool, TestLeaseError>>>,
406        releases: AtomicUsize,
407    }
408
409    impl TestLeaseStore {
410        async fn hold(&self, lease_id: &str, owner_id: &str, expires_at: DateTime<Utc>) {
411            self.rows.lock().await.insert(
412                lease_id.to_string(),
413                TestLeaseRow {
414                    owner_id: owner_id.to_string(),
415                    expires_at,
416                },
417            );
418        }
419
420        async fn push_renew_result(&self, result: Result<bool, TestLeaseError>) {
421            self.renew_results.lock().await.push(result);
422        }
423    }
424
425    #[async_trait::async_trait]
426    impl RuntimeLeaseStore for Arc<TestLeaseStore> {
427        type Error = TestLeaseError;
428
429        async fn try_acquire(
430            &self,
431            claim: RuntimeLeaseClaim<'_>,
432        ) -> Result<RuntimeLeaseAcquire, Self::Error> {
433            let mut rows = self.rows.lock().await;
434            match rows.get(claim.lease_id) {
435                Some(row) if row.owner_id != claim.owner_id && row.expires_at > claim.now => {
436                    Ok(RuntimeLeaseAcquire::Standby {
437                        owner: Some(RuntimeLeaseOwner {
438                            owner_id: row.owner_id.clone(),
439                            expires_at: row.expires_at,
440                        }),
441                    })
442                }
443                _ => {
444                    rows.insert(
445                        claim.lease_id.to_string(),
446                        TestLeaseRow {
447                            owner_id: claim.owner_id.to_string(),
448                            expires_at: claim.expires_at,
449                        },
450                    );
451                    Ok(RuntimeLeaseAcquire::Acquired)
452                }
453            }
454        }
455
456        async fn renew(
457            &self,
458            lease_id: &str,
459            owner_id: &str,
460            _now: DateTime<Utc>,
461            expires_at: DateTime<Utc>,
462        ) -> Result<bool, Self::Error> {
463            if let Some(result) = self.renew_results.lock().await.pop() {
464                return result;
465            }
466
467            let mut rows = self.rows.lock().await;
468            let Some(row) = rows.get_mut(lease_id) else {
469                return Ok(false);
470            };
471            if row.owner_id != owner_id {
472                return Ok(false);
473            }
474            row.expires_at = expires_at;
475            Ok(true)
476        }
477
478        async fn release(&self, lease_id: &str, owner_id: &str) -> Result<(), Self::Error> {
479            let mut rows = self.rows.lock().await;
480            if rows
481                .get(lease_id)
482                .is_some_and(|row| row.owner_id == owner_id)
483            {
484                rows.remove(lease_id);
485                self.releases.fetch_add(1, Ordering::SeqCst);
486            }
487            Ok(())
488        }
489    }
490
491    #[tokio::test]
492    async fn runtime_lease_owner_id_is_process_unique_shape() {
493        let owner_id = new_runtime_lease_owner_id();
494        assert!(owner_id.starts_with("runtime-"));
495        assert_eq!(owner_id.len(), "runtime-".len() + 32);
496    }
497
498    #[tokio::test]
499    async fn supervisor_starts_workload_after_acquire_and_releases_on_shutdown() {
500        let store = Arc::new(TestLeaseStore::default());
501        let started = Arc::new(AtomicUsize::new(0));
502        let stopped = Arc::new(AtomicUsize::new(0));
503        let shutdown = CancellationToken::new();
504
505        let handle = tokio::spawn(run_runtime_lease_supervisor(
506            store.clone(),
507            RuntimeLeaseConfig::new("test.background", "node-a")
508                .ttl(Duration::from_secs(5))
509                .renew_interval(Duration::from_millis(20)),
510            shutdown.clone(),
511            {
512                let started = started.clone();
513                move |_token| {
514                    started.fetch_add(1, Ordering::SeqCst);
515                }
516            },
517            {
518                let stopped = stopped.clone();
519                move |()| {
520                    stopped.fetch_add(1, Ordering::SeqCst);
521                    async {}
522                }
523            },
524        ));
525
526        tokio::time::sleep(Duration::from_millis(30)).await;
527        shutdown.cancel();
528        handle.await.expect("supervisor should join");
529
530        assert_eq!(started.load(Ordering::SeqCst), 1);
531        assert_eq!(stopped.load(Ordering::SeqCst), 1);
532        assert_eq!(store.releases.load(Ordering::SeqCst), 1);
533    }
534
535    #[tokio::test]
536    async fn supervisor_stays_standby_when_another_owner_holds_the_lease() {
537        let store = Arc::new(TestLeaseStore::default());
538        store
539            .hold(
540                "test.background",
541                "node-b",
542                Utc::now() + chrono::Duration::seconds(60),
543            )
544            .await;
545        let started = Arc::new(AtomicUsize::new(0));
546        let shutdown = CancellationToken::new();
547
548        let handle = tokio::spawn(run_runtime_lease_supervisor(
549            store,
550            RuntimeLeaseConfig::new("test.background", "node-a")
551                .standby_retry_interval(Duration::from_millis(50)),
552            shutdown.clone(),
553            {
554                let started = started.clone();
555                move |_token| {
556                    started.fetch_add(1, Ordering::SeqCst);
557                }
558            },
559            |()| async {},
560        ));
561
562        tokio::time::sleep(Duration::from_millis(20)).await;
563        shutdown.cancel();
564        handle.await.expect("supervisor should join");
565
566        assert_eq!(started.load(Ordering::SeqCst), 0);
567    }
568
569    #[tokio::test]
570    async fn supervisor_stops_workload_when_renewal_loses_ownership() {
571        let store = Arc::new(TestLeaseStore::default());
572        store.push_renew_result(Ok(false)).await;
573        let started = Arc::new(AtomicUsize::new(0));
574        let stopped = Arc::new(AtomicUsize::new(0));
575        let shutdown = CancellationToken::new();
576
577        let handle = tokio::spawn(run_runtime_lease_supervisor(
578            store,
579            RuntimeLeaseConfig::new("test.background", "node-a")
580                .ttl(Duration::from_secs(5))
581                .renew_interval(Duration::from_millis(10))
582                .standby_retry_interval(Duration::from_secs(60)),
583            shutdown.clone(),
584            {
585                let started = started.clone();
586                move |_token| {
587                    started.fetch_add(1, Ordering::SeqCst);
588                }
589            },
590            {
591                let stopped = stopped.clone();
592                let shutdown = shutdown.clone();
593                move |()| {
594                    stopped.fetch_add(1, Ordering::SeqCst);
595                    shutdown.cancel();
596                    async {}
597                }
598            },
599        ));
600
601        tokio::time::sleep(Duration::from_millis(40)).await;
602        handle.await.expect("supervisor should join");
603
604        assert_eq!(started.load(Ordering::SeqCst), 1);
605        assert_eq!(stopped.load(Ordering::SeqCst), 1);
606    }
607}