1use std::fmt::Display;
17use std::future::Future;
18use std::time::Duration;
19
20use chrono::{DateTime, Utc};
21use tokio_util::sync::CancellationToken;
22
23pub const DEFAULT_RUNTIME_LEASE_TTL: Duration = Duration::from_secs(30);
25pub const DEFAULT_RUNTIME_LEASE_RETRY_INTERVAL: Duration = Duration::from_secs(5);
27
28#[must_use]
35pub fn new_runtime_lease_owner_id() -> String {
36 aster_forge_utils::id::new_runtime_id()
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RuntimeLeaseConfig {
42 pub lease_id: String,
44 pub owner_id: String,
46 pub ttl: Duration,
48 pub renew_interval: Duration,
50 pub standby_retry_interval: Duration,
52}
53
54impl RuntimeLeaseConfig {
55 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 #[must_use]
68 pub const fn ttl(mut self, ttl: Duration) -> Self {
69 self.ttl = ttl;
70 self
71 }
72
73 #[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 #[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.checked_add_signed(ttl)
121 .unwrap_or(DateTime::<Utc>::MAX_UTC)
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct RuntimeLeaseClaim<'a> {
128 pub lease_id: &'a str,
130 pub owner_id: &'a str,
132 pub now: DateTime<Utc>,
134 pub expires_at: DateTime<Utc>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct RuntimeLeaseOwner {
141 pub owner_id: String,
143 pub expires_at: DateTime<Utc>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum RuntimeLeaseAcquire {
150 Acquired,
152 Standby {
154 owner: Option<RuntimeLeaseOwner>,
156 },
157}
158
159impl RuntimeLeaseAcquire {
160 #[must_use]
162 pub const fn acquired(&self) -> bool {
163 matches!(self, Self::Acquired)
164 }
165}
166
167#[async_trait::async_trait]
169pub trait RuntimeLeaseStore: Send + Sync + 'static {
170 type Error: Display + Send + Sync + 'static;
172
173 async fn try_acquire(
175 &self,
176 claim: RuntimeLeaseClaim<'_>,
177 ) -> Result<RuntimeLeaseAcquire, Self::Error>;
178
179 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 async fn release(&self, lease_id: &str, owner_id: &str) -> Result<(), Self::Error>;
190}
191
192pub 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 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 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 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}