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