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
28pub fn new_runtime_lease_owner_id() -> String {
35 aster_forge_utils::id::new_runtime_id()
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RuntimeLeaseConfig {
41 pub lease_id: String,
43 pub owner_id: String,
45 pub ttl: Duration,
47 pub renew_interval: Duration,
49 pub standby_retry_interval: Duration,
51}
52
53impl RuntimeLeaseConfig {
54 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 pub const fn ttl(mut self, ttl: Duration) -> Self {
67 self.ttl = ttl;
68 self
69 }
70
71 pub const fn renew_interval(mut self, renew_interval: Duration) -> Self {
73 self.renew_interval = renew_interval;
74 self
75 }
76
77 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.checked_add_signed(ttl)
117 .unwrap_or(DateTime::<Utc>::MAX_UTC)
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct RuntimeLeaseClaim<'a> {
124 pub lease_id: &'a str,
126 pub owner_id: &'a str,
128 pub now: DateTime<Utc>,
130 pub expires_at: DateTime<Utc>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct RuntimeLeaseOwner {
137 pub owner_id: String,
139 pub expires_at: DateTime<Utc>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum RuntimeLeaseAcquire {
146 Acquired,
148 Standby {
150 owner: Option<RuntimeLeaseOwner>,
152 },
153}
154
155impl RuntimeLeaseAcquire {
156 pub const fn acquired(&self) -> bool {
158 matches!(self, Self::Acquired)
159 }
160}
161
162#[async_trait::async_trait]
164pub trait RuntimeLeaseStore: Send + Sync + 'static {
165 type Error: Display + Send + Sync + 'static;
167
168 async fn try_acquire(
170 &self,
171 claim: RuntimeLeaseClaim<'_>,
172 ) -> Result<RuntimeLeaseAcquire, Self::Error>;
173
174 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 async fn release(&self, lease_id: &str, owner_id: &str) -> Result<(), Self::Error>;
185}
186
187pub 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 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 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 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}