1use std::future::Future;
10use std::marker::PhantomData;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use futures::FutureExt;
15use tokio_util::sync::CancellationToken;
16use tracing::Instrument;
17
18use crate::runtime::panic_payload_message;
19use crate::{
20 BackgroundTasks, RecordedTaskHooks, RegisteredRuntimeTaskKind, periodic_sleep_duration,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct ScheduledTaskCatalogEntry<'a> {
26 pub namespace: &'a str,
28 pub task_name: &'a str,
30 pub display_name: &'a str,
32 pub first_run_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct ScheduledTaskClaimRequest<'a> {
39 pub namespace: &'a str,
41 pub task_name: &'a str,
43 pub owner_id: &'a str,
45 pub now: DateTime<Utc>,
47 pub claim_ttl: Duration,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ScheduledTaskClaim {
54 pub task_id: String,
56 pub namespace: String,
58 pub task_name: String,
60 pub owner_id: String,
62 pub scheduled_at: DateTime<Utc>,
64 pub claimed_at: DateTime<Utc>,
66 pub claim_expires_at: DateTime<Utc>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ScheduledTaskCompletion {
73 pub claim: ScheduledTaskClaim,
75 pub finished_at: DateTime<Utc>,
77 pub next_run_at: DateTime<Utc>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct ScheduledTaskClaimRenewal<'a> {
88 pub claim: &'a ScheduledTaskClaim,
90 pub now: DateTime<Utc>,
92 pub claim_ttl: Duration,
94}
95
96#[async_trait::async_trait]
98pub trait ScheduledTaskStore: Clone + Send + Sync + 'static {
99 type Error: std::fmt::Display + Send + Sync + 'static;
101
102 async fn ensure_scheduled_task(
104 &self,
105 entry: ScheduledTaskCatalogEntry<'_>,
106 ) -> std::result::Result<(), Self::Error>;
107
108 async fn claim_scheduled_task(
110 &self,
111 request: ScheduledTaskClaimRequest<'_>,
112 ) -> std::result::Result<Option<ScheduledTaskClaim>, Self::Error>;
113
114 async fn renew_scheduled_task_claim(
120 &self,
121 renewal: ScheduledTaskClaimRenewal<'_>,
122 ) -> std::result::Result<bool, Self::Error>;
123
124 async fn complete_scheduled_task(
126 &self,
127 completion: ScheduledTaskCompletion,
128 ) -> std::result::Result<bool, Self::Error>;
129}
130
131pub struct ScheduledPeriodicTask<Name, State, Store, IntervalFn, TaskFn, PanicFn, RecordFn> {
133 pub name: Name,
135 pub namespace: &'static str,
137 pub task_name: &'static str,
139 pub display_name: &'static str,
141 pub owner_id: String,
143 pub claim_ttl: Duration,
145 pub interval_fn: IntervalFn,
147 pub jitter_cap: Option<Duration>,
149 pub shutdown_token: CancellationToken,
151 pub state: State,
153 pub store: Store,
155 pub hooks: RecordedTaskHooks<TaskFn, PanicFn, RecordFn>,
157}
158
159#[derive(Clone)]
167pub struct LeasedScheduledRuntimeConfig<
168 Name,
169 Outcome,
170 State,
171 LeaseStore,
172 ScheduleStore,
173 PanicFn,
174 RecordFn,
175> {
176 namespace: &'static str,
177 lease_id: String,
178 lease_store: LeaseStore,
179 schedule_store: ScheduleStore,
180 claim_ttl: Duration,
181 lease_ttl: Duration,
182 lease_renew_interval: Duration,
183 lease_standby_retry_interval: Duration,
184 state: State,
185 panic_outcome: PanicFn,
186 record_outcome: RecordFn,
187 _name: PhantomData<fn() -> Name>,
188 _outcome: PhantomData<fn() -> Outcome>,
189}
190
191impl<Name, Outcome, State, LeaseStore, ScheduleStore, PanicFn, RecordFn>
192 LeasedScheduledRuntimeConfig<Name, Outcome, State, LeaseStore, ScheduleStore, PanicFn, RecordFn>
193{
194 pub fn new<RecordFut>(
196 namespace: &'static str,
197 lease_id: impl Into<String>,
198 lease_store: LeaseStore,
199 schedule_store: ScheduleStore,
200 state: State,
201 panic_outcome: PanicFn,
202 record_outcome: RecordFn,
203 ) -> Self
204 where
205 PanicFn: Fn(String) -> Outcome,
206 RecordFn:
207 Fn(State, Name, ScheduledTaskClaim, DateTime<Utc>, DateTime<Utc>, Outcome) -> RecordFut,
208 RecordFut: Future<Output = ()> + Send + 'static,
209 {
210 Self {
211 namespace,
212 lease_id: lease_id.into(),
213 lease_store,
214 schedule_store,
215 claim_ttl: Duration::from_mins(2),
216 lease_ttl: aster_forge_runtime::DEFAULT_RUNTIME_LEASE_TTL,
217 lease_renew_interval: Duration::from_secs(10),
218 lease_standby_retry_interval: aster_forge_runtime::DEFAULT_RUNTIME_LEASE_RETRY_INTERVAL,
219 state,
220 panic_outcome,
221 record_outcome,
222 _name: PhantomData,
223 _outcome: PhantomData,
224 }
225 }
226
227 #[must_use]
229 pub const fn claim_ttl(mut self, claim_ttl: Duration) -> Self {
230 self.claim_ttl = claim_ttl;
231 self
232 }
233
234 #[must_use]
236 pub const fn lease_ttl(mut self, lease_ttl: Duration) -> Self {
237 self.lease_ttl = lease_ttl;
238 self
239 }
240
241 #[must_use]
243 pub const fn lease_renew_interval(mut self, lease_renew_interval: Duration) -> Self {
244 self.lease_renew_interval = lease_renew_interval;
245 self
246 }
247
248 #[must_use]
250 pub const fn lease_standby_retry_interval(
251 mut self,
252 lease_standby_retry_interval: Duration,
253 ) -> Self {
254 self.lease_standby_retry_interval = lease_standby_retry_interval;
255 self
256 }
257
258 pub async fn run<ConfigureFn>(self, shutdown_token: CancellationToken, configure: ConfigureFn)
264 where
265 Name: RegisteredRuntimeTaskKind + Send + Sync + 'static,
266 State: Clone + Send + Sync + 'static,
267 LeaseStore: aster_forge_runtime::RuntimeLeaseStore,
268 ScheduleStore: ScheduledTaskStore,
269 ConfigureFn: for<'a> FnMut(
270 &mut ScheduledRuntimeTaskGroup<
271 'a,
272 Name,
273 State,
274 ScheduleStore,
275 PanicFn,
276 RecordFn,
277 Outcome,
278 >,
279 ) + Send
280 + 'static,
281 PanicFn: Clone + Fn(String) -> Outcome + Send + Sync + 'static,
282 RecordFn: Clone + Send + Sync + 'static,
283 Outcome: Send + 'static,
284 {
285 run_leased_scheduled_runtime_tasks(self, shutdown_token, configure).await;
286 }
287
288 fn into_parts(
289 self,
290 ) -> LeasedScheduledRuntimeParts<State, LeaseStore, ScheduleStore, PanicFn, RecordFn> {
291 let owner_id = aster_forge_runtime::new_runtime_lease_owner_id();
292 let lease_config =
293 aster_forge_runtime::RuntimeLeaseConfig::new(self.lease_id, owner_id.clone())
294 .ttl(self.lease_ttl)
295 .renew_interval(self.lease_renew_interval)
296 .standby_retry_interval(self.lease_standby_retry_interval);
297 LeasedScheduledRuntimeParts {
298 namespace: self.namespace,
299 owner_id,
300 lease_store: self.lease_store,
301 schedule_store: self.schedule_store,
302 claim_ttl: self.claim_ttl,
303 state: self.state,
304 panic_outcome: self.panic_outcome,
305 record_outcome: self.record_outcome,
306 lease_config,
307 }
308 }
309}
310
311struct LeasedScheduledRuntimeParts<State, LeaseStore, ScheduleStore, PanicFn, RecordFn> {
312 namespace: &'static str,
313 owner_id: String,
314 lease_store: LeaseStore,
315 schedule_store: ScheduleStore,
316 claim_ttl: Duration,
317 state: State,
318 panic_outcome: PanicFn,
319 record_outcome: RecordFn,
320 lease_config: aster_forge_runtime::RuntimeLeaseConfig,
321}
322
323pub struct ScheduledRuntimeTaskGroup<'a, Name, State, Store, PanicFn, RecordFn, Outcome> {
331 tasks: &'a mut BackgroundTasks,
332 namespace: &'static str,
333 owner_id: String,
334 claim_ttl: Duration,
335 shutdown_token: CancellationToken,
336 state: State,
337 store: Store,
338 panic_outcome: PanicFn,
339 record_outcome: RecordFn,
340 _name: std::marker::PhantomData<Name>,
341 _outcome: std::marker::PhantomData<Outcome>,
342}
343
344impl<Name, State, Store, PanicFn, RecordFn, Outcome>
345 ScheduledRuntimeTaskGroup<'_, Name, State, Store, PanicFn, RecordFn, Outcome>
346where
347 State: Clone + Send + Sync + 'static,
348{
349 pub fn worker<WorkerFn, WorkerFut>(&mut self, worker: WorkerFn)
351 where
352 WorkerFn: FnOnce(CancellationToken, State) -> WorkerFut,
353 WorkerFut: Future<Output = ()> + Send + 'static,
354 {
355 self.tasks
356 .push(worker(self.shutdown_token.clone(), self.state.clone()));
357 }
358
359 pub fn shutdown_token(&self) -> CancellationToken {
361 self.shutdown_token.clone()
362 }
363
364 pub fn state(&self) -> State {
366 self.state.clone()
367 }
368}
369
370impl<Name, State, Store, PanicFn, RecordFn, Outcome>
371 ScheduledRuntimeTaskGroup<'_, Name, State, Store, PanicFn, RecordFn, Outcome>
372where
373 Name: RegisteredRuntimeTaskKind + Send + Sync + 'static,
374 State: Clone + Send + Sync + 'static,
375 Store: ScheduledTaskStore,
376 PanicFn: Clone + Fn(String) -> Outcome + Send + Sync + 'static,
377 RecordFn: Clone + Send + Sync + 'static,
378 Outcome: Send + 'static,
379{
380 pub fn scheduled<IntervalFn, TaskFn, TaskFut, RecordFut>(
382 &mut self,
383 name: Name,
384 interval_fn: IntervalFn,
385 jitter_cap: Option<Duration>,
386 task_fn: TaskFn,
387 ) where
388 IntervalFn: Fn(&State) -> Duration + Send + Sync + 'static,
389 TaskFn: Fn(State) -> TaskFut + Send + Sync + 'static,
390 TaskFut: Future<Output = Outcome> + Send + 'static,
391 RecordFn:
392 Fn(State, Name, ScheduledTaskClaim, DateTime<Utc>, DateTime<Utc>, Outcome) -> RecordFut,
393 RecordFut: Future<Output = ()> + Send + 'static,
394 {
395 self.tasks
396 .push(run_scheduled_periodic_task(ScheduledPeriodicTask {
397 name,
398 namespace: self.namespace,
399 task_name: name.as_str(),
400 display_name: name.display_name(),
401 owner_id: self.owner_id.clone(),
402 claim_ttl: self.claim_ttl,
403 interval_fn,
404 jitter_cap,
405 shutdown_token: self.shutdown_token.clone(),
406 state: self.state.clone(),
407 store: self.store.clone(),
408 hooks: RecordedTaskHooks::new(
409 task_fn,
410 self.panic_outcome.clone(),
411 self.record_outcome.clone(),
412 ),
413 }));
414 }
415}
416
417async fn run_leased_scheduled_runtime_tasks<
424 Name,
425 Outcome,
426 State,
427 LeaseStore,
428 ScheduleStore,
429 ConfigureFn,
430 PanicFn,
431 RecordFn,
432>(
433 config: LeasedScheduledRuntimeConfig<
434 Name,
435 Outcome,
436 State,
437 LeaseStore,
438 ScheduleStore,
439 PanicFn,
440 RecordFn,
441 >,
442 shutdown_token: CancellationToken,
443 mut configure: ConfigureFn,
444) where
445 Name: RegisteredRuntimeTaskKind + Send + Sync + 'static,
446 State: Clone + Send + Sync + 'static,
447 LeaseStore: aster_forge_runtime::RuntimeLeaseStore,
448 ScheduleStore: ScheduledTaskStore,
449 ConfigureFn: for<'a> FnMut(
450 &mut ScheduledRuntimeTaskGroup<
451 'a,
452 Name,
453 State,
454 ScheduleStore,
455 PanicFn,
456 RecordFn,
457 Outcome,
458 >,
459 ) + Send
460 + 'static,
461 PanicFn: Clone + Fn(String) -> Outcome + Send + Sync + 'static,
462 RecordFn: Clone + Send + Sync + 'static,
463 Outcome: Send + 'static,
464{
465 let parts = config.into_parts();
466 let LeasedScheduledRuntimeParts {
467 namespace,
468 owner_id,
469 lease_store,
470 schedule_store,
471 claim_ttl,
472 state,
473 panic_outcome,
474 record_outcome,
475 lease_config,
476 } = parts;
477
478 aster_forge_runtime::run_runtime_lease_supervisor(
479 lease_store,
480 lease_config,
481 shutdown_token,
482 move |leased_shutdown_token| {
483 let mut tasks = BackgroundTasks::with_shutdown_token(leased_shutdown_token);
484 let group_shutdown_token = tasks.shutdown_token();
485 let mut group = ScheduledRuntimeTaskGroup {
486 tasks: &mut tasks,
487 namespace,
488 owner_id: owner_id.clone(),
489 claim_ttl,
490 shutdown_token: group_shutdown_token,
491 state: state.clone(),
492 store: schedule_store.clone(),
493 panic_outcome: panic_outcome.clone(),
494 record_outcome: record_outcome.clone(),
495 _name: std::marker::PhantomData,
496 _outcome: std::marker::PhantomData,
497 };
498 configure(&mut group);
499 tasks
500 },
501 |background_tasks| async move {
502 background_tasks.shutdown().await;
503 },
504 )
505 .await;
506}
507
508pub async fn run_scheduled_periodic_task<
520 Name,
521 State,
522 Store,
523 IntervalFn,
524 TaskFn,
525 TaskFut,
526 PanicFn,
527 RecordFn,
528 RecordFut,
529 Outcome,
530>(
531 task: ScheduledPeriodicTask<Name, State, Store, IntervalFn, TaskFn, PanicFn, RecordFn>,
532) where
533 Name: Copy + Send + 'static,
534 State: Clone + Send + Sync + 'static,
535 Store: ScheduledTaskStore,
536 IntervalFn: Fn(&State) -> Duration + Send + Sync + 'static,
537 TaskFn: Fn(State) -> TaskFut + Send + Sync + 'static,
538 TaskFut: Future<Output = Outcome> + Send + 'static,
539 PanicFn: Fn(String) -> Outcome + Send + Sync + 'static,
540 RecordFn: Fn(State, Name, ScheduledTaskClaim, DateTime<Utc>, DateTime<Utc>, Outcome) -> RecordFut
541 + Send
542 + Sync
543 + 'static,
544 RecordFut: Future<Output = ()> + Send + 'static,
545 Outcome: Send + 'static,
546{
547 if task.shutdown_token.is_cancelled() {
548 return;
549 }
550
551 run_scheduled_periodic_iteration(&task)
552 .instrument(tracing::info_span!("bg_task", task.name = task.task_name))
553 .await;
554
555 loop {
556 let sleep_duration =
557 periodic_sleep_duration((task.interval_fn)(&task.state), task.jitter_cap);
558 tokio::select! {
559 biased;
560 () = task.shutdown_token.cancelled() => break,
561 () = tokio::time::sleep(sleep_duration) => {}
562 }
563
564 if task.shutdown_token.is_cancelled() {
565 break;
566 }
567
568 run_scheduled_periodic_iteration(&task)
569 .instrument(tracing::info_span!("bg_task", task.name = task.task_name))
570 .await;
571 }
572}
573
574#[must_use]
580pub fn scheduled_claim_renew_interval(claim_ttl: Duration) -> Duration {
581 (claim_ttl / 3).max(Duration::from_millis(10))
582}
583
584pub async fn run_scheduled_claim_renewal_loop<Store>(
591 store: Store,
592 claim: ScheduledTaskClaim,
593 claim_ttl: Duration,
594 interval: Duration,
595 stop_token: CancellationToken,
596) where
597 Store: ScheduledTaskStore,
598{
599 let mut ticker = tokio::time::interval(interval);
600 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
601 ticker.tick().await;
602
603 loop {
604 tokio::select! {
605 () = stop_token.cancelled() => return,
606 _ = ticker.tick() => {
607 let renewal = ScheduledTaskClaimRenewal {
608 claim: &claim,
609 now: Utc::now(),
610 claim_ttl,
611 };
612 let result = tokio::select! {
613 () = stop_token.cancelled() => return,
614 result = store.renew_scheduled_task_claim(renewal) => result,
615 };
616
617 match result {
618 Ok(true) => {}
619 Ok(false) => {
620 tracing::warn!(
621 task.name = claim.task_name,
622 "scheduled task claim lost; stopping claim renewal"
623 );
624 return;
625 }
626 Err(error) => {
627 tracing::warn!(
628 task.name = claim.task_name,
629 error = %error,
630 "scheduled task claim renewal failed; retrying next tick"
631 );
632 }
633 }
634 }
635 }
636 }
637}
638
639#[expect(
640 clippy::too_many_lines,
641 reason = "Catalog ensure, fenced claim, renewal, panic capture, recording, and completion form one scheduled iteration."
642)]
643async fn run_scheduled_periodic_iteration<
644 Name,
645 State,
646 Store,
647 IntervalFn,
648 TaskFn,
649 TaskFut,
650 PanicFn,
651 RecordFn,
652 RecordFut,
653 Outcome,
654>(
655 task: &ScheduledPeriodicTask<Name, State, Store, IntervalFn, TaskFn, PanicFn, RecordFn>,
656) where
657 Name: Copy + Send + 'static,
658 State: Clone + Send + Sync + 'static,
659 Store: ScheduledTaskStore,
660 IntervalFn: Fn(&State) -> Duration + Send + Sync + 'static,
661 TaskFn: Fn(State) -> TaskFut + Send + Sync + 'static,
662 TaskFut: Future<Output = Outcome> + Send + 'static,
663 PanicFn: Fn(String) -> Outcome + Send + Sync + 'static,
664 RecordFn: Fn(State, Name, ScheduledTaskClaim, DateTime<Utc>, DateTime<Utc>, Outcome) -> RecordFut
665 + Send
666 + Sync
667 + 'static,
668 RecordFut: Future<Output = ()> + Send + 'static,
669 Outcome: Send + 'static,
670{
671 let now = Utc::now();
672 if let Err(error) = task
673 .store
674 .ensure_scheduled_task(ScheduledTaskCatalogEntry {
675 namespace: task.namespace,
676 task_name: task.task_name,
677 display_name: task.display_name,
678 first_run_at: now,
679 })
680 .await
681 {
682 tracing::warn!(
683 task.name = task.task_name,
684 error = %error,
685 "failed to ensure scheduled task catalog row"
686 );
687 return;
688 }
689
690 let claim = match task
691 .store
692 .claim_scheduled_task(ScheduledTaskClaimRequest {
693 namespace: task.namespace,
694 task_name: task.task_name,
695 owner_id: &task.owner_id,
696 now,
697 claim_ttl: task.claim_ttl,
698 })
699 .await
700 {
701 Ok(Some(claim)) => claim,
702 Ok(None) => return,
703 Err(error) => {
704 tracing::warn!(
705 task.name = task.task_name,
706 error = %error,
707 "failed to claim scheduled task"
708 );
709 return;
710 }
711 };
712
713 let renewal_stop = task.shutdown_token.child_token();
714 let renewal_handle = tokio::spawn(run_scheduled_claim_renewal_loop(
715 task.store.clone(),
716 claim.clone(),
717 task.claim_ttl,
718 scheduled_claim_renew_interval(task.claim_ttl),
719 renewal_stop.clone(),
720 ));
721
722 let started_at = Utc::now();
723 let outcome = match std::panic::AssertUnwindSafe((task.hooks.task_fn)(task.state.clone()))
724 .catch_unwind()
725 .await
726 {
727 Ok(outcome) => outcome,
728 Err(panic) => {
729 let panic_message = panic_payload_message(&panic);
730 tracing::error!(
731 task.name = task.task_name,
732 "scheduled task panicked: {panic_message}"
733 );
734 (task.hooks.panic_outcome)(panic_message)
735 }
736 };
737 let finished_at = Utc::now();
738
739 renewal_stop.cancel();
740 if let Err(error) = renewal_handle.await {
741 tracing::warn!(
742 task.name = task.task_name,
743 error = %error,
744 "scheduled task claim renewal worker stopped unexpectedly"
745 );
746 }
747
748 let record_result = std::panic::AssertUnwindSafe((task.hooks.record_outcome)(
749 task.state.clone(),
750 task.name,
751 claim.clone(),
752 started_at,
753 finished_at,
754 outcome,
755 ))
756 .catch_unwind()
757 .await;
758 if let Err(panic) = record_result {
759 let panic_message = panic_payload_message(&panic);
760 tracing::error!(
761 task.name = task.task_name,
762 "scheduled task outcome recorder panicked: {panic_message}"
763 );
764 return;
765 }
766
767 let Some(next_run_at) = next_scheduled_run_at(finished_at, (task.interval_fn)(&task.state))
768 else {
769 tracing::warn!(
770 task.name = task.task_name,
771 "scheduled task interval overflowed while computing next run"
772 );
773 return;
774 };
775
776 match task
777 .store
778 .complete_scheduled_task(ScheduledTaskCompletion {
779 claim,
780 finished_at,
781 next_run_at,
782 })
783 .await
784 {
785 Ok(true) => {}
786 Ok(false) => {
787 tracing::warn!(
788 task.name = task.task_name,
789 "scheduled task claim was not completed because ownership changed"
790 );
791 }
792 Err(error) => {
793 tracing::warn!(
794 task.name = task.task_name,
795 error = %error,
796 "failed to complete scheduled task claim"
797 );
798 }
799 }
800}
801
802#[must_use]
804pub fn next_scheduled_run_at(
805 finished_at: DateTime<Utc>,
806 interval: Duration,
807) -> Option<DateTime<Utc>> {
808 let interval = chrono::Duration::from_std(interval).ok()?;
809 finished_at.checked_add_signed(interval)
810}
811
812#[cfg(test)]
813mod tests {
814 use std::sync::Arc;
815 use std::sync::Mutex;
816 use std::sync::atomic::{AtomicUsize, Ordering};
817
818 use async_trait::async_trait;
819 use chrono::{TimeZone, Utc};
820 use tokio_util::sync::CancellationToken;
821
822 use super::{
823 LeasedScheduledRuntimeConfig, ScheduledPeriodicTask, ScheduledRuntimeTaskGroup,
824 ScheduledTaskCatalogEntry, ScheduledTaskClaim, ScheduledTaskClaimRequest,
825 ScheduledTaskCompletion, ScheduledTaskStore, next_scheduled_run_at,
826 run_scheduled_claim_renewal_loop, run_scheduled_periodic_task,
827 scheduled_claim_renew_interval,
828 };
829 use crate::{RecordedTaskHooks, RegisteredRuntimeTaskKind};
830
831 #[derive(Clone)]
832 struct MemoryScheduleStore {
833 calls: Arc<AtomicUsize>,
834 completions: Arc<AtomicUsize>,
835 renewals: Arc<AtomicUsize>,
836 renewal_script: Arc<Mutex<std::collections::VecDeque<Result<bool, String>>>>,
837 }
838
839 #[expect(
840 clippy::trivially_copy_pass_by_ref,
841 reason = "The scheduled task interval callback receives shared state by reference; this fixture uses unit state."
842 )]
843 fn test_interval(_: &()) -> std::time::Duration {
844 std::time::Duration::from_mins(1)
845 }
846
847 #[derive(Clone)]
848 struct AlwaysAcquireLeaseStore {
849 acquired: Arc<AtomicUsize>,
850 released: Arc<AtomicUsize>,
851 }
852
853 impl AlwaysAcquireLeaseStore {
854 fn new() -> Self {
855 Self {
856 acquired: Arc::new(AtomicUsize::new(0)),
857 released: Arc::new(AtomicUsize::new(0)),
858 }
859 }
860 }
861
862 #[async_trait]
863 impl aster_forge_runtime::RuntimeLeaseStore for AlwaysAcquireLeaseStore {
864 type Error = String;
865
866 async fn try_acquire(
867 &self,
868 _claim: aster_forge_runtime::RuntimeLeaseClaim<'_>,
869 ) -> Result<aster_forge_runtime::RuntimeLeaseAcquire, Self::Error> {
870 self.acquired.fetch_add(1, Ordering::SeqCst);
871 Ok(aster_forge_runtime::RuntimeLeaseAcquire::Acquired)
872 }
873
874 async fn renew(
875 &self,
876 _lease_id: &str,
877 _owner_id: &str,
878 _now: chrono::DateTime<Utc>,
879 _expires_at: chrono::DateTime<Utc>,
880 ) -> Result<bool, Self::Error> {
881 Ok(true)
882 }
883
884 async fn release(&self, _lease_id: &str, _owner_id: &str) -> Result<(), Self::Error> {
885 self.released.fetch_add(1, Ordering::SeqCst);
886 Ok(())
887 }
888 }
889
890 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
891 enum TestRuntimeTask {
892 Cleanup,
893 }
894
895 impl RegisteredRuntimeTaskKind for TestRuntimeTask {
896 fn as_str(self) -> &'static str {
897 "cleanup"
898 }
899
900 fn display_name(self) -> &'static str {
901 "Cleanup"
902 }
903
904 fn from_wire_value(value: &str) -> Option<Self> {
905 (value == "cleanup").then_some(Self::Cleanup)
906 }
907 }
908
909 #[async_trait]
910 impl ScheduledTaskStore for MemoryScheduleStore {
911 type Error = String;
912
913 async fn ensure_scheduled_task(
914 &self,
915 entry: ScheduledTaskCatalogEntry<'_>,
916 ) -> Result<(), Self::Error> {
917 assert_eq!(entry.namespace, "aster_test");
918 assert_eq!(entry.task_name, "cleanup");
919 Ok(())
920 }
921
922 async fn claim_scheduled_task(
923 &self,
924 request: ScheduledTaskClaimRequest<'_>,
925 ) -> Result<Option<ScheduledTaskClaim>, Self::Error> {
926 if self.calls.fetch_add(1, Ordering::SeqCst) > 0 {
927 return Ok(None);
928 }
929 Ok(Some(ScheduledTaskClaim {
930 task_id: "aster_test:cleanup".to_string(),
931 namespace: request.namespace.to_string(),
932 task_name: request.task_name.to_string(),
933 owner_id: request.owner_id.to_string(),
934 scheduled_at: request.now,
935 claimed_at: request.now,
936 claim_expires_at: request.now,
937 }))
938 }
939
940 async fn renew_scheduled_task_claim(
941 &self,
942 renewal: super::ScheduledTaskClaimRenewal<'_>,
943 ) -> Result<bool, Self::Error> {
944 assert_eq!(renewal.claim.task_name, "cleanup");
945 assert!(!renewal.claim_ttl.is_zero());
946 self.renewals.fetch_add(1, Ordering::SeqCst);
947 let scripted = self
948 .renewal_script
949 .lock()
950 .expect("renewal script should lock")
951 .pop_front();
952 scripted.unwrap_or(Ok(true))
953 }
954
955 async fn complete_scheduled_task(
956 &self,
957 completion: ScheduledTaskCompletion,
958 ) -> Result<bool, Self::Error> {
959 assert_eq!(completion.claim.task_name, "cleanup");
960 assert!(completion.next_run_at >= completion.finished_at);
961 self.completions.fetch_add(1, Ordering::SeqCst);
962 Ok(true)
963 }
964 }
965
966 fn memory_store() -> MemoryScheduleStore {
967 MemoryScheduleStore {
968 calls: Arc::new(AtomicUsize::new(0)),
969 completions: Arc::new(AtomicUsize::new(0)),
970 renewals: Arc::new(AtomicUsize::new(0)),
971 renewal_script: Arc::new(Mutex::new(std::collections::VecDeque::new())),
972 }
973 }
974
975 #[tokio::test]
976 async fn scheduled_periodic_task_claims_records_and_completes_one_due_run() {
977 let shutdown = CancellationToken::new();
978 let ran = Arc::new(AtomicUsize::new(0));
979 let recorded = Arc::new(AtomicUsize::new(0));
980 let store = memory_store();
981 let completions = store.completions.clone();
982 let ran_for_task = ran.clone();
983 let recorded_for_hook = recorded.clone();
984 let shutdown_for_hook = shutdown.clone();
985
986 run_scheduled_periodic_task(ScheduledPeriodicTask {
987 name: "cleanup",
988 namespace: "aster_test",
989 task_name: "cleanup",
990 display_name: "Cleanup",
991 owner_id: "runtime-a".to_string(),
992 claim_ttl: std::time::Duration::from_secs(30),
993 interval_fn: test_interval,
994 jitter_cap: None,
995 shutdown_token: shutdown.clone(),
996 state: (),
997 store,
998 hooks: RecordedTaskHooks::new(
999 move |()| {
1000 let ran = ran_for_task.clone();
1001 async move {
1002 ran.fetch_add(1, Ordering::SeqCst);
1003 "ok"
1004 }
1005 },
1006 |_| "panic",
1007 move |(),
1008 _name: &str,
1009 claim: ScheduledTaskClaim,
1010 _started_at,
1011 _finished_at,
1012 outcome| {
1013 let recorded = recorded_for_hook.clone();
1014 let shutdown = shutdown_for_hook.clone();
1015 async move {
1016 assert_eq!(claim.task_name, "cleanup");
1017 assert_eq!(outcome, "ok");
1018 recorded.fetch_add(1, Ordering::SeqCst);
1019 shutdown.cancel();
1020 }
1021 },
1022 ),
1023 })
1024 .await;
1025
1026 assert_eq!(ran.load(Ordering::SeqCst), 1);
1027 assert_eq!(recorded.load(Ordering::SeqCst), 1);
1028 assert_eq!(completions.load(Ordering::SeqCst), 1);
1029 }
1030
1031 #[tokio::test]
1032 async fn scheduled_periodic_task_records_panic_outcome_and_completes_claim() {
1033 let shutdown = CancellationToken::new();
1034 let store = memory_store();
1035 let completions = store.completions.clone();
1036 let recorded = Arc::new(Mutex::new(Vec::new()));
1037 let recorded_for_hook = recorded.clone();
1038 let shutdown_for_hook = shutdown.clone();
1039
1040 run_scheduled_periodic_task(ScheduledPeriodicTask {
1041 name: "cleanup",
1042 namespace: "aster_test",
1043 task_name: "cleanup",
1044 display_name: "Cleanup",
1045 owner_id: "runtime-a".to_string(),
1046 claim_ttl: std::time::Duration::from_secs(30),
1047 interval_fn: test_interval,
1048 jitter_cap: None,
1049 shutdown_token: shutdown.clone(),
1050 state: (),
1051 store,
1052 hooks: RecordedTaskHooks::new(
1053 move |()| async move {
1054 panic!("scheduled body failed");
1055 #[expect(
1056 unreachable_code,
1057 reason = "The test intentionally panics to exercise scheduled-task failure handling."
1058 )]
1059 "ok".to_string()
1060 },
1061 |message| format!("panic:{message}"),
1062 move |(), _name, _claim, _started_at, _finished_at, outcome| {
1063 let recorded = recorded_for_hook.clone();
1064 let shutdown = shutdown_for_hook.clone();
1065 async move {
1066 recorded
1067 .lock()
1068 .expect("recorded outcomes should lock")
1069 .push(outcome);
1070 shutdown.cancel();
1071 }
1072 },
1073 ),
1074 })
1075 .await;
1076
1077 assert_eq!(
1078 recorded
1079 .lock()
1080 .expect("recorded outcomes should lock")
1081 .as_slice(),
1082 ["panic:scheduled body failed"]
1083 );
1084 assert_eq!(completions.load(Ordering::SeqCst), 1);
1085 }
1086
1087 #[tokio::test]
1088 async fn scheduled_periodic_task_does_not_complete_claim_when_recorder_panics() {
1089 let shutdown = CancellationToken::new();
1090 let store = memory_store();
1091 let completions = store.completions.clone();
1092
1093 run_scheduled_periodic_task(ScheduledPeriodicTask {
1094 name: "cleanup",
1095 namespace: "aster_test",
1096 task_name: "cleanup",
1097 display_name: "Cleanup",
1098 owner_id: "runtime-a".to_string(),
1099 claim_ttl: std::time::Duration::from_secs(30),
1100 interval_fn: test_interval,
1101 jitter_cap: None,
1102 shutdown_token: shutdown.clone(),
1103 state: (),
1104 store,
1105 hooks: RecordedTaskHooks::new(
1106 move |()| {
1107 let shutdown = shutdown.clone();
1108 async move {
1109 shutdown.cancel();
1110 "ok"
1111 }
1112 },
1113 |_| "panic",
1114 move |(), _name, _claim, _started_at, _finished_at, _outcome| async move {
1115 panic!("record failed");
1116 },
1117 ),
1118 })
1119 .await;
1120
1121 assert_eq!(completions.load(Ordering::SeqCst), 0);
1122 }
1123
1124 #[tokio::test]
1125 async fn leased_scheduled_runtime_group_runs_worker_and_scheduled_task() {
1126 let lease_store = AlwaysAcquireLeaseStore::new();
1127 let acquired = lease_store.acquired.clone();
1128 let released = lease_store.released.clone();
1129 let schedule_store = memory_store();
1130 let completions = schedule_store.completions.clone();
1131 let worker_runs = Arc::new(AtomicUsize::new(0));
1132 let scheduled_runs = Arc::new(AtomicUsize::new(0));
1133 let recorded_runs = Arc::new(AtomicUsize::new(0));
1134 let shutdown = CancellationToken::new();
1135 let config = LeasedScheduledRuntimeConfig::new(
1136 "aster_test",
1137 "aster_test.background",
1138 lease_store,
1139 schedule_store,
1140 (),
1141 |_| "panic",
1142 {
1143 let recorded_runs = recorded_runs.clone();
1144 let shutdown = shutdown.clone();
1145 move |(), _name, claim: ScheduledTaskClaim, _started_at, _finished_at, outcome| {
1146 let recorded_runs = recorded_runs.clone();
1147 let shutdown = shutdown.clone();
1148 async move {
1149 assert_eq!(claim.task_name, "cleanup");
1150 assert_eq!(outcome, "ok");
1151 recorded_runs.fetch_add(1, Ordering::SeqCst);
1152 shutdown.cancel();
1153 }
1154 }
1155 },
1156 )
1157 .claim_ttl(std::time::Duration::from_secs(30))
1158 .lease_ttl(std::time::Duration::from_secs(30))
1159 .lease_renew_interval(std::time::Duration::from_secs(10))
1160 .lease_standby_retry_interval(std::time::Duration::from_secs(5));
1161 let worker_runs_for_group = worker_runs.clone();
1162 let scheduled_runs_for_group = scheduled_runs.clone();
1163
1164 config
1165 .run(
1166 shutdown.clone(),
1167 move |group: &mut ScheduledRuntimeTaskGroup<
1168 '_,
1169 TestRuntimeTask,
1170 (),
1171 _,
1172 _,
1173 _,
1174 &'static str,
1175 >| {
1176 let worker_runs = worker_runs_for_group.clone();
1177 group.worker(move |shutdown_token, ()| async move {
1178 worker_runs.fetch_add(1, Ordering::SeqCst);
1179 shutdown_token.cancelled().await;
1180 });
1181 let scheduled_runs = scheduled_runs_for_group.clone();
1182 group.scheduled(TestRuntimeTask::Cleanup, test_interval, None, move |()| {
1183 let scheduled_runs = scheduled_runs.clone();
1184 async move {
1185 scheduled_runs.fetch_add(1, Ordering::SeqCst);
1186 "ok"
1187 }
1188 });
1189 },
1190 )
1191 .await;
1192
1193 assert_eq!(acquired.load(Ordering::SeqCst), 1);
1194 assert_eq!(released.load(Ordering::SeqCst), 1);
1195 assert_eq!(worker_runs.load(Ordering::SeqCst), 1);
1196 assert_eq!(scheduled_runs.load(Ordering::SeqCst), 1);
1197 assert_eq!(recorded_runs.load(Ordering::SeqCst), 1);
1198 assert_eq!(completions.load(Ordering::SeqCst), 1);
1199 }
1200
1201 #[test]
1202 fn next_scheduled_run_at_adds_interval() {
1203 let finished_at = Utc.with_ymd_and_hms(2026, 6, 26, 1, 2, 3).unwrap();
1204 assert_eq!(
1205 next_scheduled_run_at(finished_at, std::time::Duration::from_mins(1)),
1206 Some(Utc.with_ymd_and_hms(2026, 6, 26, 1, 3, 3).unwrap())
1207 );
1208 }
1209
1210 fn test_claim() -> ScheduledTaskClaim {
1211 let now = Utc.with_ymd_and_hms(2026, 6, 26, 1, 0, 0).unwrap();
1212 ScheduledTaskClaim {
1213 task_id: "aster_test:cleanup".to_string(),
1214 namespace: "aster_test".to_string(),
1215 task_name: "cleanup".to_string(),
1216 owner_id: "runtime-a".to_string(),
1217 scheduled_at: now,
1218 claimed_at: now,
1219 claim_expires_at: now,
1220 }
1221 }
1222
1223 #[test]
1224 fn claim_renew_interval_is_one_third_of_ttl_with_floor() {
1225 assert_eq!(
1226 scheduled_claim_renew_interval(std::time::Duration::from_mins(2)),
1227 std::time::Duration::from_secs(40)
1228 );
1229 assert_eq!(
1230 scheduled_claim_renew_interval(std::time::Duration::from_millis(30)),
1231 std::time::Duration::from_millis(10)
1232 );
1233 assert_eq!(
1235 scheduled_claim_renew_interval(std::time::Duration::ZERO),
1236 std::time::Duration::from_millis(10)
1237 );
1238 }
1239
1240 #[tokio::test]
1241 async fn claim_renewal_loop_renews_until_stopped() {
1242 let store = memory_store();
1243 let renewals = store.renewals.clone();
1244 let stop = CancellationToken::new();
1245
1246 let handle = tokio::spawn(run_scheduled_claim_renewal_loop(
1247 store,
1248 test_claim(),
1249 std::time::Duration::from_secs(30),
1250 std::time::Duration::from_millis(1),
1251 stop.clone(),
1252 ));
1253
1254 while renewals.load(Ordering::SeqCst) == 0 {
1255 tokio::task::yield_now().await;
1256 }
1257 stop.cancel();
1258 handle.await.expect("renewal loop should stop cleanly");
1259 assert!(renewals.load(Ordering::SeqCst) >= 1);
1260 }
1261
1262 #[tokio::test]
1263 async fn claim_renewal_loop_stops_when_claim_is_lost() {
1264 let store = memory_store();
1265 store
1266 .renewal_script
1267 .lock()
1268 .expect("renewal script should lock")
1269 .push_back(Ok(false));
1270 let renewals = store.renewals.clone();
1271
1272 run_scheduled_claim_renewal_loop(
1275 store,
1276 test_claim(),
1277 std::time::Duration::from_secs(30),
1278 std::time::Duration::from_millis(1),
1279 CancellationToken::new(),
1280 )
1281 .await;
1282
1283 assert_eq!(renewals.load(Ordering::SeqCst), 1);
1284 }
1285
1286 #[tokio::test]
1287 async fn claim_renewal_loop_retries_after_transient_errors() {
1288 let store = memory_store();
1289 {
1290 let mut script = store
1291 .renewal_script
1292 .lock()
1293 .expect("renewal script should lock");
1294 script.push_back(Err("database temporarily unavailable".to_string()));
1295 script.push_back(Err("database temporarily unavailable".to_string()));
1296 }
1297 let renewals = store.renewals.clone();
1298 let stop = CancellationToken::new();
1299
1300 let handle = tokio::spawn(run_scheduled_claim_renewal_loop(
1301 store,
1302 test_claim(),
1303 std::time::Duration::from_secs(30),
1304 std::time::Duration::from_millis(1),
1305 stop.clone(),
1306 ));
1307
1308 while renewals.load(Ordering::SeqCst) < 3 {
1309 tokio::task::yield_now().await;
1310 }
1311 stop.cancel();
1312 handle.await.expect("renewal loop should stop cleanly");
1313 assert!(renewals.load(Ordering::SeqCst) >= 3);
1314 }
1315
1316 #[tokio::test]
1317 async fn scheduled_periodic_task_renews_claim_while_task_body_runs() {
1318 let shutdown = CancellationToken::new();
1319 let store = memory_store();
1320 let renewals = store.renewals.clone();
1321 let completions = store.completions.clone();
1322 let renewals_for_task = renewals.clone();
1323 let shutdown_for_hook = shutdown.clone();
1324
1325 run_scheduled_periodic_task(ScheduledPeriodicTask {
1326 name: "cleanup",
1327 namespace: "aster_test",
1328 task_name: "cleanup",
1329 display_name: "Cleanup",
1330 owner_id: "runtime-a".to_string(),
1331 claim_ttl: std::time::Duration::from_millis(30),
1334 interval_fn: test_interval,
1335 jitter_cap: None,
1336 shutdown_token: shutdown.clone(),
1337 state: (),
1338 store,
1339 hooks: RecordedTaskHooks::new(
1340 move |()| {
1341 let renewals = renewals_for_task.clone();
1342 async move {
1343 tokio::time::timeout(std::time::Duration::from_secs(5), async move {
1344 while renewals.load(Ordering::SeqCst) < 2 {
1345 tokio::task::yield_now().await;
1346 }
1347 })
1348 .await
1349 .expect("claim should be renewed while the task body runs");
1350 "ok"
1351 }
1352 },
1353 |_| "panic",
1354 move |(), _name, _claim, _started_at, _finished_at, _outcome| {
1355 let shutdown = shutdown_for_hook.clone();
1356 async move {
1357 shutdown.cancel();
1358 }
1359 },
1360 ),
1361 })
1362 .await;
1363
1364 assert_eq!(completions.load(Ordering::SeqCst), 1);
1365 let renewed = renewals.load(Ordering::SeqCst);
1366 assert!(renewed >= 2);
1367 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1368 assert_eq!(
1369 renewals.load(Ordering::SeqCst),
1370 renewed,
1371 "renewal loop must stop before the claim is completed"
1372 );
1373 }
1374}