1use std::cmp::Reverse;
2use std::collections::HashMap;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Deserializer, Serialize};
8use tokio::fs;
9use tokio::sync::{Mutex, RwLock};
10use uuid::Uuid;
11
12use super::trigger_engine::{default_trigger_engine, TriggerEngine};
13use bamboo_domain::{
14 MisFirePolicy, OverlapPolicy, ScheduleRunConfig, ScheduleRunRecord, ScheduleRunStatus,
15 ScheduleSpec, ScheduleState, ScheduleTrigger, ScheduleWindow,
16};
17
18fn other_io_error(message: impl Into<String>) -> io::Error {
19 io::Error::other(message.into())
20}
21
22async fn atomic_write_json(path: &Path, bytes: Vec<u8>) -> io::Result<()> {
23 let tmp = path.with_extension(format!("json.tmp.{}", Uuid::new_v4()));
24
25 {
27 let mut file = fs::File::create(&tmp).await?;
28 tokio::io::AsyncWriteExt::write_all(&mut file, &bytes).await?;
29 file.sync_all().await?;
30 }
31
32 fs::rename(&tmp, path).await?;
33
34 if let Some(parent) = path.parent() {
36 if let Ok(dir) = fs::File::open(parent).await {
37 let _ = dir.sync_all().await;
38 }
39 }
40
41 Ok(())
42}
43
44async fn cleanup_stale_tmp_files(dir: &Path, prefix: &str) {
46 let mut entries = match fs::read_dir(dir).await {
47 Ok(e) => e,
48 Err(_) => return,
49 };
50 while let Ok(Some(entry)) = entries.next_entry().await {
51 if let Some(name) = entry.file_name().to_str() {
52 if name.starts_with(prefix) {
53 tracing::info!("Removing stale temp file: {}", entry.path().display());
54 let _ = fs::remove_file(entry.path()).await;
55 }
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ScheduleEntry {
62 pub id: String,
63 pub name: String,
64 pub enabled: bool,
65 pub trigger: ScheduleTrigger,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub timezone: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub start_at: Option<DateTime<Utc>>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub end_at: Option<DateTime<Utc>>,
72 #[serde(default)]
73 pub misfire_policy: MisFirePolicy,
74 #[serde(default)]
75 pub overlap_policy: OverlapPolicy,
76 pub created_at: DateTime<Utc>,
77 pub updated_at: DateTime<Utc>,
78 #[serde(default)]
79 pub state: ScheduleState,
80 #[serde(default)]
81 pub run_config: ScheduleRunConfig,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct ScheduleEntryCompat {
86 pub id: String,
87 pub name: String,
88 #[serde(default)]
89 pub enabled: bool,
90 #[serde(default)]
91 pub interval_seconds: Option<u64>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub trigger: Option<ScheduleTrigger>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub timezone: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub start_at: Option<DateTime<Utc>>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub end_at: Option<DateTime<Utc>>,
100 #[serde(default)]
101 pub misfire_policy: MisFirePolicy,
102 #[serde(default)]
103 pub overlap_policy: OverlapPolicy,
104 pub created_at: DateTime<Utc>,
105 pub updated_at: DateTime<Utc>,
106 #[serde(default)]
107 pub state: Option<ScheduleState>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub last_run_at: Option<DateTime<Utc>>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub next_run_at: Option<DateTime<Utc>>,
112 #[serde(default)]
113 pub queued_run_count: u32,
114 #[serde(default)]
115 pub running_run_count: u32,
116 #[serde(default)]
117 pub run_config: ScheduleRunConfig,
118}
119
120impl ScheduleEntry {
121 fn from_compat(raw: ScheduleEntryCompat) -> Result<Self, String> {
122 let trigger = match raw.trigger.clone() {
123 Some(ScheduleTrigger::Interval {
124 every_seconds,
125 anchor_at,
126 }) => {
127 let every_seconds = raw.interval_seconds.unwrap_or(every_seconds);
128 ScheduleTrigger::Interval {
129 every_seconds,
130 anchor_at: anchor_at
131 .or_else(|| derive_legacy_anchor_at(&raw, every_seconds))
132 .or(Some(raw.created_at)),
133 }
134 }
135 Some(other) => other,
136 None => {
137 let every_seconds = raw.interval_seconds.ok_or_else(|| {
138 format!("schedule entry {} missing trigger definition", raw.id)
139 })?;
140 ScheduleTrigger::legacy_interval(
141 every_seconds,
142 derive_legacy_anchor_at(&raw, every_seconds).or(Some(raw.created_at)),
143 )
144 }
145 };
146
147 let mut state = raw.state.unwrap_or_else(|| ScheduleState {
148 next_fire_at: raw.next_run_at,
149 last_scheduled_at: raw.last_run_at,
150 queued_run_count: raw.queued_run_count,
151 running_run_count: raw.running_run_count,
152 ..Default::default()
153 });
154 if state.next_fire_at.is_none() {
155 state.next_fire_at = raw.next_run_at;
156 }
157 if state.last_scheduled_at.is_none() {
158 state.last_scheduled_at = raw.last_run_at;
159 }
160
161 Ok(Self {
162 id: raw.id,
163 name: raw.name,
164 enabled: raw.enabled,
165 trigger,
166 timezone: raw.timezone,
167 start_at: raw.start_at,
168 end_at: raw.end_at,
169 misfire_policy: raw.misfire_policy,
170 overlap_policy: raw.overlap_policy,
171 created_at: raw.created_at,
172 updated_at: raw.updated_at,
173 state,
174 run_config: raw.run_config,
175 })
176 }
177
178 pub fn derived_anchor_at(&self) -> Option<DateTime<Utc>> {
179 let every_seconds = interval_seconds_from_trigger(&self.trigger)?;
180 if let ScheduleTrigger::Interval {
181 anchor_at: Some(anchor_at),
182 ..
183 } = &self.trigger
184 {
185 return Some(*anchor_at);
186 }
187 self.state
188 .last_scheduled_at
189 .or_else(|| {
190 self.state
191 .next_fire_at
192 .map(|next| next - Duration::seconds(every_seconds as i64))
193 })
194 .or(Some(self.created_at))
195 }
196
197 pub fn to_schedule_spec(&self) -> ScheduleSpec {
198 ScheduleSpec {
199 id: self.id.clone(),
200 name: self.name.clone(),
201 enabled: self.enabled,
202 trigger: self.trigger.clone(),
203 timezone: self.timezone.clone(),
204 start_at: self.start_at,
205 end_at: self.end_at,
206 misfire_policy: self.misfire_policy,
207 overlap_policy: self.overlap_policy,
208 run_config: self.run_config.clone(),
209 created_at: self.created_at,
210 updated_at: self.updated_at,
211 }
212 }
213
214 pub fn to_schedule_state(&self) -> ScheduleState {
215 self.state.clone()
216 }
217}
218
219impl<'de> Deserialize<'de> for ScheduleEntry {
220 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
221 where
222 D: Deserializer<'de>,
223 {
224 let raw = ScheduleEntryCompat::deserialize(deserializer)?;
225 ScheduleEntry::from_compat(raw).map_err(serde::de::Error::custom)
226 }
227}
228
229fn derive_legacy_anchor_at(raw: &ScheduleEntryCompat, every_seconds: u64) -> Option<DateTime<Utc>> {
230 if let Some(ScheduleTrigger::Interval {
231 anchor_at: Some(anchor_at),
232 ..
233 }) = raw.trigger.as_ref()
234 {
235 return Some(*anchor_at);
236 }
237
238 raw.state
239 .as_ref()
240 .and_then(|state| state.last_scheduled_at)
241 .or(raw.last_run_at)
242 .or_else(|| {
243 raw.state
244 .as_ref()
245 .and_then(|state| state.next_fire_at)
246 .or(raw.next_run_at)
247 .map(|next| next - Duration::seconds(every_seconds as i64))
248 })
249}
250
251fn interval_seconds_from_trigger(trigger: &ScheduleTrigger) -> Option<u64> {
252 match trigger {
253 ScheduleTrigger::Interval { every_seconds, .. } => Some(*every_seconds),
254 _ => None,
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259struct SchedulesIndex {
260 version: u32,
261 updated_at: DateTime<Utc>,
262 schedules: HashMap<String, ScheduleEntry>,
263 #[serde(default)]
264 run_records: HashMap<String, ScheduleRunRecord>,
265}
266
267impl SchedulesIndex {
268 fn empty() -> Self {
269 Self {
270 version: 4,
271 updated_at: Utc::now(),
272 schedules: HashMap::new(),
273 run_records: HashMap::new(),
274 }
275 }
276}
277
278const MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE: usize = 200;
284
285fn prune_run_records(run_records: &mut HashMap<String, ScheduleRunRecord>) {
291 let mut evict: Vec<String> = Vec::new();
293 {
294 let mut terminal_by_schedule: HashMap<&str, Vec<&ScheduleRunRecord>> = HashMap::new();
295 for record in run_records.values() {
296 if record.status.is_terminal() {
297 terminal_by_schedule
298 .entry(record.schedule_id.as_str())
299 .or_default()
300 .push(record);
301 }
302 }
303 for records in terminal_by_schedule.values_mut() {
304 if records.len() <= MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE {
305 continue;
306 }
307 records.sort_by_key(|r| std::cmp::Reverse(r.completed_at.unwrap_or(r.scheduled_for)));
310 for stale in records.iter().skip(MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE) {
311 evict.push(stale.run_id.clone());
312 }
313 }
314 }
315 for run_id in evict {
316 run_records.remove(&run_id);
317 }
318}
319
320#[derive(Debug, Clone, Default)]
321pub struct ScheduleDefinitionChanges {
322 pub trigger: Option<ScheduleTrigger>,
323 pub timezone: Option<String>,
324 pub start_at: Option<DateTime<Utc>>,
325 pub end_at: Option<DateTime<Utc>>,
326 pub misfire_policy: Option<MisFirePolicy>,
327 pub overlap_policy: Option<OverlapPolicy>,
328}
329
330fn normalize_optional_string(value: Option<String>) -> Option<String> {
331 value
332 .map(|value| value.trim().to_string())
333 .filter(|value| !value.is_empty())
334}
335
336fn definition_window(definition: &ScheduleDefinitionChanges) -> ScheduleWindow {
337 ScheduleWindow {
338 start_at: definition.start_at,
339 end_at: definition.end_at,
340 }
341}
342
343fn compute_initial_next_run_at(
344 trigger: &ScheduleTrigger,
345 timezone: Option<&str>,
346 window: &ScheduleWindow,
347 now: DateTime<Utc>,
348) -> io::Result<DateTime<Utc>> {
349 let engine = default_trigger_engine();
350 let runtime_timezone = match trigger {
351 ScheduleTrigger::Interval { .. } | ScheduleTrigger::Once { .. } => None,
354 _ => timezone,
355 };
356 engine
357 .next_after(trigger, runtime_timezone, now, window)
358 .map_err(|error| other_io_error(format!("failed to compute initial next run: {error}")))?
359 .ok_or_else(|| other_io_error("schedule has no next run within configured window"))
360}
361
362fn normalize_trigger_for_storage(
363 trigger: ScheduleTrigger,
364 anchor_at: DateTime<Utc>,
365) -> ScheduleTrigger {
366 match trigger {
367 ScheduleTrigger::Interval {
368 every_seconds,
369 anchor_at: existing_anchor,
370 } => ScheduleTrigger::Interval {
371 every_seconds,
372 anchor_at: existing_anchor.or(Some(anchor_at)),
373 },
374 other => other,
375 }
376}
377
378fn normalize_loaded_index(mut index: SchedulesIndex) -> (SchedulesIndex, bool) {
379 let mut changed = false;
380 if index.version < 4 {
381 index.version = 4;
382 changed = true;
383 }
384 for entry in index.schedules.values_mut() {
385 changed |= normalize_loaded_schedule_entry(entry);
386 }
387 if changed {
388 index.updated_at = Utc::now();
389 }
390 (index, changed)
391}
392
393fn normalize_loaded_schedule_entry(entry: &mut ScheduleEntry) -> bool {
394 let mut changed = false;
395
396 let normalized_timezone = normalize_optional_string(entry.timezone.clone());
397 if entry.timezone != normalized_timezone {
398 entry.timezone = normalized_timezone;
399 changed = true;
400 }
401
402 let derived_anchor_at = entry.derived_anchor_at();
403 if let ScheduleTrigger::Interval {
404 every_seconds,
405 anchor_at,
406 } = &mut entry.trigger
407 {
408 if anchor_at.is_none() {
409 if let Some(derived_anchor_at) = derived_anchor_at {
410 *anchor_at = Some(derived_anchor_at);
411 changed = true;
412 }
413 }
414 if *every_seconds == 0 {
415 *every_seconds = 1;
416 changed = true;
417 }
418 }
419
420 if entry.state.next_fire_at.is_none() && !matches!(entry.trigger, ScheduleTrigger::Once { .. })
424 {
425 entry.state.next_fire_at = Some(entry.created_at);
426 changed = true;
427 }
428
429 changed
430}
431
432fn current_due_at(entry: &ScheduleEntry) -> Option<DateTime<Utc>> {
433 let mut due_at = entry.state.next_fire_at?;
434 if let Some(start_at) = entry.start_at {
435 if due_at < start_at {
436 due_at = start_at;
437 }
438 }
439 if let Some(end_at) = entry.end_at {
440 if due_at > end_at {
441 return None;
442 }
443 }
444 Some(due_at)
445}
446
447fn overlap_blocks_dispatch(entry: &ScheduleEntry) -> bool {
448 match entry.overlap_policy {
449 OverlapPolicy::Allow => false,
450 OverlapPolicy::Skip => entry.state.running_run_count > 0,
451 OverlapPolicy::QueueOne => entry.state.queued_run_count > 0,
452 }
453}
454
455fn apply_overlap_dispatch_limit(entry: &ScheduleEntry, dispatch_count: u32) -> u32 {
456 match entry.overlap_policy {
457 OverlapPolicy::Allow | OverlapPolicy::Skip => dispatch_count,
458 OverlapPolicy::QueueOne => dispatch_count.min(1),
459 }
460}
461
462fn compute_misfire_dispatch_count(entry: &ScheduleEntry, now: DateTime<Utc>) -> u32 {
463 let Some(next_fire_at) = entry.state.next_fire_at else {
464 return 0;
465 };
466 let lateness_seconds = now.signed_duration_since(next_fire_at).num_seconds().max(0) as u64;
467 let interval_seconds = interval_seconds_from_trigger(&entry.trigger).unwrap_or(0);
468
469 match entry.misfire_policy {
470 MisFirePolicy::RunOnce => 1,
471 MisFirePolicy::Skip => 0,
472 MisFirePolicy::CatchUpAll => lateness_seconds
473 .checked_div(interval_seconds)
474 .map(|q| q.saturating_add(1) as u32)
475 .unwrap_or(1),
476 MisFirePolicy::CatchUpWindow {
477 max_catch_up_runs,
478 max_lateness_seconds,
479 } => {
480 if lateness_seconds > max_lateness_seconds {
481 0
482 } else {
483 lateness_seconds
484 .checked_div(interval_seconds)
485 .map(|q| (q.saturating_add(1) as u32).min(max_catch_up_runs.max(1)))
486 .unwrap_or(1)
487 }
488 }
489 }
490}
491
492fn runtime_trigger(entry: &ScheduleEntry) -> ScheduleTrigger {
493 match &entry.trigger {
494 ScheduleTrigger::Interval { every_seconds, .. } => {
495 ScheduleTrigger::legacy_interval(*every_seconds, None)
496 }
497 other => other.clone(),
498 }
499}
500
501fn runtime_timezone<'a>(entry: &'a ScheduleEntry, trigger: &ScheduleTrigger) -> Option<&'a str> {
502 match trigger {
503 ScheduleTrigger::Interval { .. } | ScheduleTrigger::Once { .. } => None,
506 _ => entry.timezone.as_deref(),
507 }
508}
509
510fn compute_next_run_at_with_engine(
511 entry: &ScheduleEntry,
512 now: DateTime<Utc>,
513 engine: &dyn TriggerEngine,
514) -> io::Result<Option<DateTime<Utc>>> {
515 let trigger = runtime_trigger(entry);
516 let window = ScheduleWindow {
517 start_at: entry.start_at,
518 end_at: entry.end_at,
519 };
520 engine
521 .next_after(&trigger, runtime_timezone(entry, &trigger), now, &window)
522 .map_err(|error| {
523 other_io_error(format!(
524 "failed to compute next fire for schedule {}: {}",
525 entry.id, error
526 ))
527 })
528}
529
530fn record_missed_occurrence(entry: &mut ScheduleEntry) {
531 entry.state.total_missed_count = entry.state.total_missed_count.saturating_add(1);
532}
533
534fn non_negative_duration_ms(from: DateTime<Utc>, to: DateTime<Utc>) -> u64 {
535 to.signed_duration_since(from).num_milliseconds().max(0) as u64
536}
537
538fn make_queued_run_record(
539 schedule_id: &str,
540 scheduled_for: DateTime<Utc>,
541 claimed_at: DateTime<Utc>,
542 was_catch_up: bool,
543) -> ScheduleRunRecord {
544 ScheduleRunRecord {
545 run_id: Uuid::new_v4().to_string(),
546 schedule_id: schedule_id.to_string(),
547 scheduled_for,
548 claimed_at,
549 started_at: None,
550 completed_at: None,
551 status: ScheduleRunStatus::Queued,
552 outcome_reason: None,
553 session_id: None,
554 dispatch_lag_ms: None,
555 execution_duration_ms: None,
556 was_catch_up,
557 }
558}
559
560fn make_fallback_run_record(
561 run_id: &str,
562 schedule_id: &str,
563 now: DateTime<Utc>,
564 status: ScheduleRunStatus,
565) -> ScheduleRunRecord {
566 ScheduleRunRecord {
567 run_id: run_id.to_string(),
568 schedule_id: schedule_id.to_string(),
569 scheduled_for: now,
570 claimed_at: now,
571 started_at: matches!(status, ScheduleRunStatus::Running).then_some(now),
572 completed_at: matches!(
573 status,
574 ScheduleRunStatus::Success
575 | ScheduleRunStatus::Failed
576 | ScheduleRunStatus::Skipped
577 | ScheduleRunStatus::Missed
578 | ScheduleRunStatus::Cancelled
579 )
580 .then_some(now),
581 status,
582 outcome_reason: None,
583 session_id: None,
584 dispatch_lag_ms: None,
585 execution_duration_ms: None,
586 was_catch_up: false,
587 }
588}
589
590fn scheduled_for_dispatch(
591 entry: &ScheduleEntry,
592 due_at: DateTime<Utc>,
593 dispatch_index: u32,
594) -> DateTime<Utc> {
595 match interval_seconds_from_trigger(&entry.trigger) {
596 Some(interval_seconds) if dispatch_index > 0 => {
597 due_at + Duration::seconds(interval_seconds as i64 * dispatch_index as i64)
598 }
599 _ => due_at,
600 }
601}
602
603fn update_run_record_started(
604 record: &mut ScheduleRunRecord,
605 started_at: DateTime<Utc>,
606 session_id: Option<&str>,
607) {
608 record.status = ScheduleRunStatus::Running;
609 record.started_at = Some(started_at);
610 record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, started_at));
611 if let Some(session_id) = session_id {
612 record.session_id = Some(session_id.to_string());
613 }
614}
615
616fn update_run_record_terminal(
617 record: &mut ScheduleRunRecord,
618 status: ScheduleRunStatus,
619 completed_at: DateTime<Utc>,
620 outcome_reason: Option<String>,
621 session_id: Option<&str>,
622) {
623 record.status = status;
624 record.completed_at = Some(completed_at);
625 if record.dispatch_lag_ms.is_none() {
626 record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, completed_at));
627 }
628 if let Some(started_at) = record.started_at {
629 record.execution_duration_ms = Some(non_negative_duration_ms(started_at, completed_at));
630 }
631 if let Some(outcome_reason) = normalize_optional_string(outcome_reason) {
632 record.outcome_reason = Some(outcome_reason);
633 }
634 if let Some(session_id) = session_id {
635 record.session_id = Some(session_id.to_string());
636 }
637}
638
639fn apply_terminal_run_status(
640 entry: &mut ScheduleEntry,
641 status: ScheduleRunStatus,
642 finished_at: DateTime<Utc>,
643) -> io::Result<()> {
644 entry.state.running_run_count = entry.state.running_run_count.saturating_sub(1);
645 entry.state.last_finished_at = Some(finished_at);
646
647 match status {
648 ScheduleRunStatus::Success => {
649 entry.state.last_success_at = Some(finished_at);
650 entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
651 entry.state.total_success_count = entry.state.total_success_count.saturating_add(1);
652 entry.state.consecutive_failures = 0;
653 Ok(())
654 }
655 ScheduleRunStatus::Failed | ScheduleRunStatus::Cancelled => {
656 entry.state.last_failure_at = Some(finished_at);
657 entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
658 entry.state.total_failure_count = entry.state.total_failure_count.saturating_add(1);
659 entry.state.consecutive_failures = entry.state.consecutive_failures.saturating_add(1);
660 Ok(())
661 }
662 ScheduleRunStatus::Skipped => Ok(()),
663 ScheduleRunStatus::Missed | ScheduleRunStatus::Queued | ScheduleRunStatus::Running => {
664 Err(other_io_error(format!(
665 "non-terminal or unsupported run status for lifecycle accounting: {:?}",
666 status
667 )))
668 }
669 }
670}
671
672#[derive(Debug, Clone)]
673pub struct ClaimedScheduleRun {
674 pub run_id: String,
675 pub schedule_id: String,
676 pub schedule_name: String,
677 pub run_config: ScheduleRunConfig,
678 pub scheduled_for: DateTime<Utc>,
679 pub claimed_at: DateTime<Utc>,
680 pub was_catch_up: bool,
681}
682
683#[derive(Debug)]
684pub struct ScheduleStore {
685 index_path: PathBuf,
686 index: RwLock<SchedulesIndex>,
687 write_lock: Mutex<()>,
688}
689
690impl ScheduleStore {
691 pub async fn new(bamboo_home_dir: PathBuf) -> io::Result<Self> {
692 let index_path = bamboo_home_dir.join("schedules.json");
693
694 let (index, needs_backfill_write) = if index_path.exists() {
695 let raw = fs::read_to_string(&index_path).await?;
696 match serde_json::from_str::<SchedulesIndex>(&raw) {
697 Ok(parsed) => normalize_loaded_index(parsed),
698 Err(e) => {
699 let backup_path =
702 index_path.with_extension(format!("json.corrupted.{}", Uuid::new_v4()));
703 tracing::error!(
704 "schedules.json is corrupted ({}). Backing up to {} and resetting.",
705 e,
706 backup_path.display()
707 );
708 if let Err(rename_err) = fs::rename(&index_path, &backup_path).await {
709 tracing::warn!(
710 "Failed to back up corrupted schedules.json: {}",
711 rename_err
712 );
713 }
714 let fresh = SchedulesIndex::empty();
715 atomic_write_json(
716 &index_path,
717 serde_json::to_vec_pretty(&fresh)
718 .map_err(|e| other_io_error(e.to_string()))?,
719 )
720 .await?;
721 (fresh, false)
722 }
723 }
724 } else {
725 let index = SchedulesIndex::empty();
726 atomic_write_json(
727 &index_path,
728 serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
729 )
730 .await?;
731 (index, false)
732 };
733
734 if needs_backfill_write {
735 atomic_write_json(
736 &index_path,
737 serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
738 )
739 .await?;
740 }
741
742 cleanup_stale_tmp_files(&bamboo_home_dir, "schedules.json.tmp.").await;
744
745 Ok(Self {
746 index_path,
747 index: RwLock::new(index),
748 write_lock: Mutex::new(()),
749 })
750 }
751
752 pub fn index_path(&self) -> &Path {
753 &self.index_path
754 }
755
756 async fn update_index<F, T>(&self, f: F) -> io::Result<T>
757 where
758 F: FnOnce(&mut SchedulesIndex) -> io::Result<T>,
759 {
760 let _guard = self.write_lock.lock().await;
761 let mut index = self.index.write().await;
762 let out = f(&mut index)?;
763 index.updated_at = Utc::now();
764 atomic_write_json(
765 &self.index_path,
766 serde_json::to_vec_pretty(&*index).map_err(|e| other_io_error(e.to_string()))?,
767 )
768 .await?;
769 Ok(out)
770 }
771
772 pub async fn list_schedules(&self) -> Vec<ScheduleEntry> {
773 let index = self.index.read().await;
774 let mut items: Vec<_> = index.schedules.values().cloned().collect();
775 items.sort_by_key(|e| Reverse(e.updated_at));
776 items
777 }
778
779 pub async fn get_schedule(&self, id: &str) -> Option<ScheduleEntry> {
780 let index = self.index.read().await;
781 index.schedules.get(id).cloned()
782 }
783
784 pub async fn get_run_record(&self, run_id: &str) -> Option<ScheduleRunRecord> {
785 let index = self.index.read().await;
786 index.run_records.get(run_id).cloned()
787 }
788
789 pub async fn list_run_records_for_schedule(&self, schedule_id: &str) -> Vec<ScheduleRunRecord> {
790 let index = self.index.read().await;
791 let mut items = index
792 .run_records
793 .values()
794 .filter(|record| record.schedule_id == schedule_id)
795 .cloned()
796 .collect::<Vec<_>>();
797 items.sort_by_key(|r| Reverse(r.claimed_at));
798 items
799 }
800
801 pub async fn create_schedule(
802 &self,
803 name: String,
804 trigger: ScheduleTrigger,
805 enabled: bool,
806 run_config: ScheduleRunConfig,
807 ) -> io::Result<ScheduleEntry> {
808 self.create_schedule_with_definition(
809 name,
810 enabled,
811 run_config,
812 ScheduleDefinitionChanges {
813 trigger: Some(trigger),
814 ..Default::default()
815 },
816 )
817 .await
818 }
819
820 pub async fn create_schedule_with_definition(
821 &self,
822 name: String,
823 enabled: bool,
824 run_config: ScheduleRunConfig,
825 definition: ScheduleDefinitionChanges,
826 ) -> io::Result<ScheduleEntry> {
827 let now = Utc::now();
828 let id = Uuid::new_v4().to_string();
829 let window = definition_window(&definition);
830 let trigger = normalize_trigger_for_storage(
831 definition
832 .trigger
833 .ok_or_else(|| other_io_error("schedule trigger is required"))?,
834 now,
835 );
836 let timezone = normalize_optional_string(definition.timezone);
837 let next_fire_at =
838 compute_initial_next_run_at(&trigger, timezone.as_deref(), &window, now)?;
839 let entry = ScheduleEntry {
840 id: id.clone(),
841 name,
842 enabled,
843 trigger,
844 timezone,
845 start_at: definition.start_at,
846 end_at: definition.end_at,
847 misfire_policy: definition.misfire_policy.unwrap_or_default(),
848 overlap_policy: definition.overlap_policy.unwrap_or_default(),
849 created_at: now,
850 updated_at: now,
851 state: ScheduleState {
852 next_fire_at: Some(next_fire_at),
853 ..Default::default()
854 },
855 run_config,
856 };
857
858 self.update_index(|index| {
859 index.schedules.insert(id.clone(), entry.clone());
860 Ok(entry.clone())
861 })
862 .await
863 }
864
865 pub async fn patch_schedule(
866 &self,
867 id: &str,
868 name: Option<String>,
869 enabled: Option<bool>,
870 trigger: Option<ScheduleTrigger>,
871 run_config: Option<ScheduleRunConfig>,
872 ) -> io::Result<Option<ScheduleEntry>> {
873 self.patch_schedule_with_definition(
874 id,
875 name,
876 enabled,
877 run_config,
878 ScheduleDefinitionChanges {
879 trigger,
880 ..Default::default()
881 },
882 )
883 .await
884 }
885
886 pub async fn patch_schedule_with_definition(
887 &self,
888 id: &str,
889 name: Option<String>,
890 enabled: Option<bool>,
891 run_config: Option<ScheduleRunConfig>,
892 definition: ScheduleDefinitionChanges,
893 ) -> io::Result<Option<ScheduleEntry>> {
894 self.update_index(|index| {
895 let Some(existing) = index.schedules.get_mut(id) else {
896 return Ok(None);
897 };
898 let now = Utc::now();
899 if let Some(name) = name {
900 existing.name = name;
901 }
902 if let Some(enabled) = enabled {
903 existing.enabled = enabled;
904 }
905
906 let trigger = normalize_trigger_for_storage(
907 definition
908 .trigger
909 .clone()
910 .unwrap_or_else(|| existing.trigger.clone()),
911 existing.derived_anchor_at().unwrap_or(now),
912 );
913 existing.trigger = trigger.clone();
914
915 if let Some(timezone) = definition.timezone {
916 existing.timezone = normalize_optional_string(Some(timezone));
917 }
918 if let Some(start_at) = definition.start_at {
919 existing.start_at = Some(start_at);
920 }
921 if let Some(end_at) = definition.end_at {
922 existing.end_at = Some(end_at);
923 }
924 if let Some(misfire_policy) = definition.misfire_policy {
925 existing.misfire_policy = misfire_policy;
926 }
927 if let Some(overlap_policy) = definition.overlap_policy {
928 existing.overlap_policy = overlap_policy;
929 }
930 if let Some(run_config) = run_config {
931 existing.run_config = run_config;
932 }
933
934 let window = ScheduleWindow {
935 start_at: existing.start_at,
936 end_at: existing.end_at,
937 };
938 existing.state.next_fire_at = Some(compute_initial_next_run_at(
939 &trigger,
940 existing.timezone.as_deref(),
941 &window,
942 now,
943 )?);
944 existing.updated_at = now;
945 Ok(Some(existing.clone()))
946 })
947 .await
948 }
949
950 pub async fn delete_schedule(&self, id: &str) -> io::Result<bool> {
951 self.update_index(|index| {
952 let deleted = index.schedules.remove(id).is_some();
953 if deleted {
954 index
955 .run_records
956 .retain(|_, record| record.schedule_id != id);
957 }
958 Ok(deleted)
959 })
960 .await
961 }
962
963 pub async fn claim_due_runs(&self, now: DateTime<Utc>) -> io::Result<Vec<ClaimedScheduleRun>> {
971 let engine = default_trigger_engine();
972 self.claim_due_runs_with_engine(now, engine.as_ref()).await
973 }
974
975 pub async fn claim_due_runs_with_engine(
976 &self,
977 now: DateTime<Utc>,
978 engine: &dyn TriggerEngine,
979 ) -> io::Result<Vec<ClaimedScheduleRun>> {
980 {
981 let index = self.index.read().await;
982 let any_due = index.schedules.values().any(|entry| {
983 entry.enabled && current_due_at(entry).is_some_and(|due_at| due_at <= now)
984 });
985 if !any_due {
986 return Ok(Vec::new());
987 }
988 }
989
990 self.update_index(|index| {
991 let mut out = Vec::new();
992 let (schedules, run_records) = (&mut index.schedules, &mut index.run_records);
993 for entry in schedules.values_mut() {
994 if !entry.enabled {
995 continue;
996 }
997 let Some(due_at) = current_due_at(entry) else {
998 continue;
999 };
1000 if due_at > now {
1001 continue;
1002 }
1003
1004 let dispatch_count = compute_misfire_dispatch_count(entry, now);
1005 if dispatch_count == 0 {
1006 entry.state.last_scheduled_at = Some(now);
1007 record_missed_occurrence(entry);
1008 match compute_next_run_at_with_engine(entry, now, engine) {
1009 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1010 Ok(None) => {
1011 entry.state.next_fire_at = None;
1012 entry.enabled = false;
1013 }
1014 Err(error) => {
1015 tracing::warn!(
1016 "failed to compute next scheduled fire for {} after misfire skip: {}. falling back to legacy interval semantics",
1017 entry.id,
1018 error
1019 );
1020 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1021 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1022 }
1023 }
1024 entry.updated_at = now;
1025 continue;
1026 }
1027
1028 let blocked = overlap_blocks_dispatch(entry);
1029 match entry.overlap_policy {
1030 OverlapPolicy::Skip if blocked => {
1031 entry.state.last_scheduled_at = Some(now);
1032 record_missed_occurrence(entry);
1033 match compute_next_run_at_with_engine(entry, now, engine) {
1034 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1035 Ok(None) => {
1036 entry.state.next_fire_at = None;
1037 entry.enabled = false;
1038 }
1039 Err(error) => {
1040 tracing::warn!(
1041 "failed to compute next scheduled fire for {} after overlap skip: {}. falling back to legacy interval semantics",
1042 entry.id,
1043 error
1044 );
1045 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1046 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1047 }
1048 }
1049 entry.updated_at = now;
1050 continue;
1051 }
1052 OverlapPolicy::QueueOne if blocked => {
1053 continue;
1054 }
1055 _ => {}
1056 }
1057
1058 let dispatch_count = apply_overlap_dispatch_limit(entry, dispatch_count);
1059 entry.state.last_scheduled_at = Some(now);
1060 match compute_next_run_at_with_engine(entry, now, engine) {
1061 Ok(Some(next_fire_at)) => {
1062 entry.state.next_fire_at = Some(next_fire_at);
1063 }
1064 Ok(None) => {
1065 entry.state.next_fire_at = None;
1066 entry.enabled = false;
1067 }
1068 Err(error) => {
1069 tracing::warn!(
1070 "failed to compute next scheduled fire for {}: {}. falling back to legacy interval semantics",
1071 entry.id,
1072 error
1073 );
1074 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1075 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1076 }
1077 }
1078 entry.state.queued_run_count = entry.state.queued_run_count.saturating_add(dispatch_count);
1079 entry.updated_at = now;
1080 for dispatch_index in 0..dispatch_count {
1081 let scheduled_for = scheduled_for_dispatch(entry, due_at, dispatch_index);
1082 let was_catch_up = scheduled_for < now;
1083 let record = make_queued_run_record(&entry.id, scheduled_for, now, was_catch_up);
1084 let run_id = record.run_id.clone();
1085 run_records.insert(run_id.clone(), record);
1086 out.push(ClaimedScheduleRun {
1087 run_id,
1088 schedule_id: entry.id.clone(),
1089 schedule_name: entry.name.clone(),
1090 run_config: entry.run_config.clone(),
1091 scheduled_for,
1092 claimed_at: now,
1093 was_catch_up,
1094 });
1095 }
1096 }
1097 prune_run_records(run_records);
1098 Ok(out)
1099 })
1100 .await
1101 }
1102
1103 pub async fn mark_run_started(&self, schedule_id: &str, run_id: &str) -> io::Result<()> {
1104 self.update_index(|index| {
1105 let now = Utc::now();
1106 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1107 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1108 entry.state.running_run_count = entry.state.running_run_count.saturating_add(1);
1109 entry.state.last_started_at = Some(now);
1110 entry.updated_at = now;
1111 }
1112 let record = index
1113 .run_records
1114 .entry(run_id.to_string())
1115 .or_insert_with(|| {
1116 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1117 });
1118 update_run_record_started(record, now, None);
1119 Ok(())
1120 })
1121 .await
1122 }
1123
1124 pub async fn bind_run_session(
1125 &self,
1126 schedule_id: &str,
1127 run_id: &str,
1128 session_id: &str,
1129 ) -> io::Result<()> {
1130 self.update_index(|index| {
1131 let now = Utc::now();
1132 let record = index
1133 .run_records
1134 .entry(run_id.to_string())
1135 .or_insert_with(|| {
1136 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Running)
1137 });
1138 record.session_id = Some(session_id.to_string());
1139 Ok(())
1140 })
1141 .await
1142 }
1143
1144 pub async fn mark_run_terminal(
1145 &self,
1146 schedule_id: &str,
1147 run_id: &str,
1148 status: ScheduleRunStatus,
1149 outcome_reason: Option<String>,
1150 ) -> io::Result<()> {
1151 self.update_index(|index| {
1152 let now = Utc::now();
1153 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1154 apply_terminal_run_status(entry, status, now)?;
1155 entry.updated_at = now;
1156 }
1157 let record = index
1158 .run_records
1159 .entry(run_id.to_string())
1160 .or_insert_with(|| make_fallback_run_record(run_id, schedule_id, now, status));
1161 update_run_record_terminal(record, status, now, outcome_reason, None);
1162 Ok(())
1163 })
1164 .await
1165 }
1166
1167 pub async fn mark_run_dequeued_without_start(
1168 &self,
1169 schedule_id: &str,
1170 run_id: &str,
1171 outcome_reason: Option<String>,
1172 ) -> io::Result<()> {
1173 self.update_index(|index| {
1174 let now = Utc::now();
1175 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1176 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1177 record_missed_occurrence(entry);
1178 entry.updated_at = now;
1179 }
1180 let record = index
1181 .run_records
1182 .entry(run_id.to_string())
1183 .or_insert_with(|| {
1184 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1185 });
1186 update_run_record_terminal(
1187 record,
1188 ScheduleRunStatus::Missed,
1189 now,
1190 outcome_reason,
1191 None,
1192 );
1193 Ok(())
1194 })
1195 .await
1196 }
1197
1198 pub async fn create_run_now(&self, id: &str) -> io::Result<Option<ClaimedScheduleRun>> {
1200 self.update_index(|index| {
1201 let Some(entry) = index.schedules.get(id).cloned() else {
1202 return Ok(None);
1203 };
1204 let now = Utc::now();
1205 let record = make_queued_run_record(&entry.id, now, now, false);
1206 let run_id = record.run_id.clone();
1207 index.run_records.insert(run_id.clone(), record);
1208 prune_run_records(&mut index.run_records);
1209 Ok(Some(ClaimedScheduleRun {
1210 run_id,
1211 schedule_id: entry.id,
1212 schedule_name: entry.name,
1213 run_config: entry.run_config,
1214 scheduled_for: now,
1215 claimed_at: now,
1216 was_catch_up: false,
1217 }))
1218 })
1219 .await
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226 use tempfile::tempdir;
1227
1228 fn run_record(
1229 run_id: &str,
1230 schedule_id: &str,
1231 status: ScheduleRunStatus,
1232 completed_at: Option<DateTime<Utc>>,
1233 ) -> ScheduleRunRecord {
1234 let base = Utc::now();
1235 ScheduleRunRecord {
1236 run_id: run_id.to_string(),
1237 schedule_id: schedule_id.to_string(),
1238 scheduled_for: base,
1239 claimed_at: base,
1240 started_at: None,
1241 completed_at,
1242 status,
1243 outcome_reason: None,
1244 session_id: None,
1245 dispatch_lag_ms: None,
1246 execution_duration_ms: None,
1247 was_catch_up: false,
1248 }
1249 }
1250
1251 #[test]
1252 fn prune_caps_terminal_records_keeps_newest_and_all_in_flight() {
1253 let mut records: HashMap<String, ScheduleRunRecord> = HashMap::new();
1254 let base = Utc::now();
1255
1256 let total_terminal = MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE + 50;
1258 for i in 0..total_terminal {
1259 let id = format!("s1-term-{i}");
1260 let completed = base + Duration::seconds(i as i64);
1261 records.insert(
1262 id.clone(),
1263 run_record(&id, "s1", ScheduleRunStatus::Success, Some(completed)),
1264 );
1265 }
1266 records.insert(
1268 "s1-queued".into(),
1269 run_record("s1-queued", "s1", ScheduleRunStatus::Queued, None),
1270 );
1271 records.insert(
1272 "s1-running".into(),
1273 run_record("s1-running", "s1", ScheduleRunStatus::Running, None),
1274 );
1275 records.insert(
1277 "s2-term-0".into(),
1278 run_record("s2-term-0", "s2", ScheduleRunStatus::Failed, Some(base)),
1279 );
1280
1281 prune_run_records(&mut records);
1282
1283 let s1_terminal = records
1284 .values()
1285 .filter(|r| r.schedule_id == "s1" && r.status.is_terminal())
1286 .count();
1287 assert_eq!(s1_terminal, MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE);
1288 assert!(records.contains_key("s1-queued"));
1290 assert!(records.contains_key("s1-running"));
1291 assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1293 assert!(!records.contains_key("s1-term-0"));
1294 assert!(records.contains_key("s2-term-0"));
1296 }
1297
1298 #[tokio::test]
1299 async fn store_backfills_legacy_interval_trigger_on_load() {
1300 let dir = tempdir().unwrap();
1301 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1302 .unwrap()
1303 .with_timezone(&Utc);
1304 let next_run_at = DateTime::parse_from_rfc3339("2026-04-04T11:00:00Z")
1305 .unwrap()
1306 .with_timezone(&Utc);
1307
1308 let raw = serde_json::json!({
1309 "version": 1,
1310 "updated_at": now,
1311 "schedules": {
1312 "legacy-1": {
1313 "id": "legacy-1",
1314 "name": "legacy",
1315 "enabled": true,
1316 "interval_seconds": 3600,
1317 "created_at": now,
1318 "updated_at": now,
1319 "last_run_at": null,
1320 "next_run_at": next_run_at,
1321 "run_config": { "auto_execute": false }
1322 }
1323 }
1324 });
1325 tokio::fs::write(
1326 dir.path().join("schedules.json"),
1327 serde_json::to_vec_pretty(&raw).unwrap(),
1328 )
1329 .await
1330 .unwrap();
1331
1332 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1333 let schedule = store.get_schedule("legacy-1").await.unwrap();
1334 assert!(matches!(
1335 schedule.trigger,
1336 ScheduleTrigger::Interval {
1337 every_seconds: 3600,
1338 ..
1339 }
1340 ));
1341 }
1342
1343 #[tokio::test]
1344 async fn create_schedule_with_definition_persists_interval_trigger_metadata() {
1345 let dir = tempdir().unwrap();
1346 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1347
1348 let created = store
1349 .create_schedule_with_definition(
1350 "interval".to_string(),
1351 true,
1352 ScheduleRunConfig::default(),
1353 ScheduleDefinitionChanges {
1354 trigger: Some(ScheduleTrigger::Interval {
1355 every_seconds: 300,
1356 anchor_at: None,
1357 }),
1358 timezone: Some("Asia/Shanghai".to_string()),
1359 misfire_policy: Some(MisFirePolicy::RunOnce),
1360 overlap_policy: Some(OverlapPolicy::QueueOne),
1361 ..Default::default()
1362 },
1363 )
1364 .await
1365 .unwrap();
1366
1367 assert!(matches!(
1368 created.trigger,
1369 ScheduleTrigger::Interval {
1370 every_seconds: 300,
1371 ..
1372 }
1373 ));
1374 assert_eq!(created.timezone.as_deref(), Some("Asia/Shanghai"));
1375 assert_eq!(created.misfire_policy, MisFirePolicy::RunOnce);
1376 assert_eq!(created.overlap_policy, OverlapPolicy::QueueOne);
1377 }
1378
1379 #[tokio::test]
1380 async fn patch_schedule_with_definition_updates_interval_trigger_metadata() {
1381 let dir = tempdir().unwrap();
1382 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1383
1384 let created = store
1385 .create_schedule(
1386 "interval".to_string(),
1387 ScheduleTrigger::Interval {
1388 every_seconds: 300,
1389 anchor_at: None,
1390 },
1391 true,
1392 ScheduleRunConfig::default(),
1393 )
1394 .await
1395 .unwrap();
1396
1397 let patched = store
1398 .patch_schedule_with_definition(
1399 &created.id,
1400 None,
1401 None,
1402 None,
1403 ScheduleDefinitionChanges {
1404 trigger: Some(ScheduleTrigger::Interval {
1405 every_seconds: 600,
1406 anchor_at: None,
1407 }),
1408 timezone: Some("UTC".to_string()),
1409 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1410 overlap_policy: Some(OverlapPolicy::Skip),
1411 ..Default::default()
1412 },
1413 )
1414 .await
1415 .unwrap()
1416 .unwrap();
1417
1418 assert!(matches!(
1419 patched.trigger,
1420 ScheduleTrigger::Interval {
1421 every_seconds: 600,
1422 ..
1423 }
1424 ));
1425 assert_eq!(patched.timezone.as_deref(), Some("UTC"));
1426 assert_eq!(patched.misfire_policy, MisFirePolicy::CatchUpAll);
1427 assert_eq!(patched.overlap_policy, OverlapPolicy::Skip);
1428 }
1429
1430 #[tokio::test]
1431 async fn claim_due_runs_with_engine_uses_runtime_adapter_for_interval_trigger() {
1432 let dir = tempdir().unwrap();
1433 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1434
1435 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1436 .unwrap()
1437 .with_timezone(&Utc);
1438
1439 let created = store
1440 .create_schedule_with_definition(
1441 "interval".to_string(),
1442 true,
1443 ScheduleRunConfig::default(),
1444 ScheduleDefinitionChanges {
1445 trigger: Some(ScheduleTrigger::Interval {
1446 every_seconds: 300,
1447 anchor_at: Some(now - Duration::seconds(300)),
1448 }),
1449 ..Default::default()
1450 },
1451 )
1452 .await
1453 .unwrap();
1454
1455 store
1456 .update_index(|index| {
1457 let entry = index.schedules.get_mut(&created.id).unwrap();
1458 entry.state.next_fire_at = Some(now);
1459 Ok(())
1460 })
1461 .await
1462 .unwrap();
1463
1464 let engine = default_trigger_engine();
1465 let claimed = store
1466 .claim_due_runs_with_engine(now, engine.as_ref())
1467 .await
1468 .unwrap();
1469 assert_eq!(claimed.len(), 1);
1470
1471 let updated = store.get_schedule(&created.id).await.unwrap();
1472 assert_eq!(updated.state.last_scheduled_at, Some(now));
1473 assert_eq!(
1474 updated.state.next_fire_at,
1475 Some(now + Duration::seconds(300))
1476 );
1477 }
1478
1479 #[tokio::test]
1480 async fn create_schedule_with_definition_initializes_monthly_next_run() {
1481 let dir = tempdir().unwrap();
1482 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1483
1484 let created = store
1485 .create_schedule_with_definition(
1486 "monthly".to_string(),
1487 true,
1488 ScheduleRunConfig::default(),
1489 ScheduleDefinitionChanges {
1490 trigger: Some(ScheduleTrigger::Monthly {
1491 days: vec![1, 15],
1492 hour: 9,
1493 minute: 0,
1494 second: 0,
1495 }),
1496 timezone: Some("UTC".to_string()),
1497 ..Default::default()
1498 },
1499 )
1500 .await
1501 .unwrap();
1502
1503 assert!(matches!(
1504 created.trigger,
1505 ScheduleTrigger::Monthly {
1506 days,
1507 hour: 9,
1508 minute: 0,
1509 second: 0
1510 } if days == vec![1, 15]
1511 ));
1512 assert!(created
1513 .state
1514 .next_fire_at
1515 .is_some_and(|next| next > created.created_at));
1516 }
1517
1518 #[tokio::test]
1519 async fn create_schedule_with_definition_initializes_once_next_run_at_trigger_instant() {
1520 let dir = tempdir().unwrap();
1521 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1522
1523 let at = Utc::now() + Duration::seconds(600);
1524 let created = store
1525 .create_schedule_with_definition(
1526 "once".to_string(),
1527 true,
1528 ScheduleRunConfig::default(),
1529 ScheduleDefinitionChanges {
1530 trigger: Some(ScheduleTrigger::Once { at }),
1531 ..Default::default()
1532 },
1533 )
1534 .await
1535 .unwrap();
1536
1537 assert!(matches!(created.trigger, ScheduleTrigger::Once { at: t } if t == at));
1538 assert_eq!(created.state.next_fire_at, Some(at));
1539 }
1540
1541 #[tokio::test]
1542 async fn claimed_once_run_does_not_produce_a_second_due_run() {
1543 let dir = tempdir().unwrap();
1544 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1545
1546 let at = Utc::now() + Duration::seconds(600);
1547 let created = store
1548 .create_schedule_with_definition(
1549 "once".to_string(),
1550 true,
1551 ScheduleRunConfig::default(),
1552 ScheduleDefinitionChanges {
1553 trigger: Some(ScheduleTrigger::Once { at }),
1554 ..Default::default()
1555 },
1556 )
1557 .await
1558 .unwrap();
1559 assert_eq!(created.state.next_fire_at, Some(at));
1560
1561 let engine = default_trigger_engine();
1562
1563 let claimed = store
1565 .claim_due_runs_with_engine(at - Duration::seconds(1), engine.as_ref())
1566 .await
1567 .unwrap();
1568 assert!(claimed.is_empty());
1569
1570 let claimed = store
1573 .claim_due_runs_with_engine(at, engine.as_ref())
1574 .await
1575 .unwrap();
1576 assert_eq!(claimed.len(), 1);
1577 assert_eq!(claimed[0].scheduled_for, at);
1578
1579 let updated = store.get_schedule(&created.id).await.unwrap();
1580 assert_eq!(updated.state.next_fire_at, None);
1581 assert!(!updated.enabled);
1582
1583 let claimed = store
1585 .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1586 .await
1587 .unwrap();
1588 assert!(claimed.is_empty());
1589 let claimed = store
1590 .claim_due_runs_with_engine(at + Duration::seconds(3600), engine.as_ref())
1591 .await
1592 .unwrap();
1593 assert!(claimed.is_empty());
1594 }
1595
1596 #[tokio::test]
1597 async fn fired_once_schedule_is_not_rearmed_on_reload() {
1598 let dir = tempdir().unwrap();
1599 let at = Utc::now() + Duration::seconds(600);
1600 let created_id;
1601 {
1602 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1603 let created = store
1604 .create_schedule_with_definition(
1605 "once".to_string(),
1606 true,
1607 ScheduleRunConfig::default(),
1608 ScheduleDefinitionChanges {
1609 trigger: Some(ScheduleTrigger::Once { at }),
1610 ..Default::default()
1611 },
1612 )
1613 .await
1614 .unwrap();
1615 created_id = created.id.clone();
1616
1617 let engine = default_trigger_engine();
1618 let claimed = store
1619 .claim_due_runs_with_engine(at, engine.as_ref())
1620 .await
1621 .unwrap();
1622 assert_eq!(claimed.len(), 1);
1623 }
1624
1625 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1628 let reloaded = store.get_schedule(&created_id).await.unwrap();
1629 assert_eq!(reloaded.state.next_fire_at, None);
1630 assert!(!reloaded.enabled);
1631
1632 let engine = default_trigger_engine();
1633 let claimed = store
1634 .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1635 .await
1636 .unwrap();
1637 assert!(claimed.is_empty());
1638 }
1639
1640 #[tokio::test]
1641 async fn misfire_skip_does_not_dispatch_run() {
1642 let dir = tempdir().unwrap();
1643 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1644 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1645 .unwrap()
1646 .with_timezone(&Utc);
1647
1648 let created = store
1649 .create_schedule_with_definition(
1650 "skip".to_string(),
1651 true,
1652 ScheduleRunConfig::default(),
1653 ScheduleDefinitionChanges {
1654 trigger: Some(ScheduleTrigger::Interval {
1655 every_seconds: 300,
1656 anchor_at: None,
1657 }),
1658 misfire_policy: Some(MisFirePolicy::Skip),
1659 ..Default::default()
1660 },
1661 )
1662 .await
1663 .unwrap();
1664
1665 store
1666 .update_index(|index| {
1667 let entry = index.schedules.get_mut(&created.id).unwrap();
1668 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1669 Ok(())
1670 })
1671 .await
1672 .unwrap();
1673
1674 let engine = default_trigger_engine();
1675 let claimed = store
1676 .claim_due_runs_with_engine(now, engine.as_ref())
1677 .await
1678 .unwrap();
1679 assert!(claimed.is_empty());
1680
1681 let updated = store.get_schedule(&created.id).await.unwrap();
1682 assert_eq!(updated.state.last_scheduled_at, Some(now));
1683 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1684 assert_eq!(updated.state.queued_run_count, 0);
1685 }
1686
1687 #[tokio::test]
1688 async fn overlap_skip_does_not_dispatch_when_running() {
1689 let dir = tempdir().unwrap();
1690 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1691 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1692 .unwrap()
1693 .with_timezone(&Utc);
1694
1695 let created = store
1696 .create_schedule_with_definition(
1697 "skip-overlap".to_string(),
1698 true,
1699 ScheduleRunConfig::default(),
1700 ScheduleDefinitionChanges {
1701 trigger: Some(ScheduleTrigger::Interval {
1702 every_seconds: 300,
1703 anchor_at: None,
1704 }),
1705 overlap_policy: Some(OverlapPolicy::Skip),
1706 ..Default::default()
1707 },
1708 )
1709 .await
1710 .unwrap();
1711
1712 store
1713 .update_index(|index| {
1714 let entry = index.schedules.get_mut(&created.id).unwrap();
1715 entry.state.next_fire_at = Some(now);
1716 entry.state.running_run_count = 1;
1717 Ok(())
1718 })
1719 .await
1720 .unwrap();
1721
1722 let engine = default_trigger_engine();
1723 let claimed = store
1724 .claim_due_runs_with_engine(now, engine.as_ref())
1725 .await
1726 .unwrap();
1727 assert!(claimed.is_empty());
1728
1729 let updated = store.get_schedule(&created.id).await.unwrap();
1730 assert_eq!(updated.state.running_run_count, 1);
1731 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1732 assert_eq!(updated.state.queued_run_count, 0);
1733 }
1734
1735 #[tokio::test]
1736 async fn overlap_queue_one_does_not_add_more_than_one_pending_run() {
1737 let dir = tempdir().unwrap();
1738 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1739 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1740 .unwrap()
1741 .with_timezone(&Utc);
1742
1743 let created = store
1744 .create_schedule_with_definition(
1745 "queue-one".to_string(),
1746 true,
1747 ScheduleRunConfig::default(),
1748 ScheduleDefinitionChanges {
1749 trigger: Some(ScheduleTrigger::Interval {
1750 every_seconds: 300,
1751 anchor_at: None,
1752 }),
1753 overlap_policy: Some(OverlapPolicy::QueueOne),
1754 ..Default::default()
1755 },
1756 )
1757 .await
1758 .unwrap();
1759
1760 store
1761 .update_index(|index| {
1762 let entry = index.schedules.get_mut(&created.id).unwrap();
1763 entry.state.next_fire_at = Some(now);
1764 entry.state.queued_run_count = 1;
1765 Ok(())
1766 })
1767 .await
1768 .unwrap();
1769
1770 let engine = default_trigger_engine();
1771 let claimed = store
1772 .claim_due_runs_with_engine(now, engine.as_ref())
1773 .await
1774 .unwrap();
1775 assert!(claimed.is_empty());
1776
1777 let updated = store.get_schedule(&created.id).await.unwrap();
1778 assert_eq!(updated.state.queued_run_count, 1);
1779 }
1780
1781 #[tokio::test]
1782 async fn overlap_queue_one_limits_catch_up_to_single_pending_run() {
1783 let dir = tempdir().unwrap();
1784 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1785 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1786 .unwrap()
1787 .with_timezone(&Utc);
1788
1789 let created = store
1790 .create_schedule_with_definition(
1791 "queue-one-catchup".to_string(),
1792 true,
1793 ScheduleRunConfig::default(),
1794 ScheduleDefinitionChanges {
1795 trigger: Some(ScheduleTrigger::Interval {
1796 every_seconds: 300,
1797 anchor_at: None,
1798 }),
1799 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1800 overlap_policy: Some(OverlapPolicy::QueueOne),
1801 ..Default::default()
1802 },
1803 )
1804 .await
1805 .unwrap();
1806
1807 store
1808 .update_index(|index| {
1809 let entry = index.schedules.get_mut(&created.id).unwrap();
1810 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1811 Ok(())
1812 })
1813 .await
1814 .unwrap();
1815
1816 let engine = default_trigger_engine();
1817 let claimed = store
1818 .claim_due_runs_with_engine(now, engine.as_ref())
1819 .await
1820 .unwrap();
1821 assert_eq!(claimed.len(), 1);
1822
1823 let updated = store.get_schedule(&created.id).await.unwrap();
1824 assert_eq!(updated.state.queued_run_count, 1);
1825 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1826 }
1827
1828 #[tokio::test]
1829 async fn mark_run_terminal_records_success_accounting() {
1830 let dir = tempdir().unwrap();
1831 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1832
1833 let created = store
1834 .create_schedule(
1835 "success".to_string(),
1836 ScheduleTrigger::Interval {
1837 every_seconds: 60,
1838 anchor_at: None,
1839 },
1840 true,
1841 ScheduleRunConfig::default(),
1842 )
1843 .await
1844 .unwrap();
1845
1846 store
1847 .update_index(|index| {
1848 let entry = index.schedules.get_mut(&created.id).unwrap();
1849 entry.state.running_run_count = 1;
1850 entry.state.consecutive_failures = 2;
1851 Ok(())
1852 })
1853 .await
1854 .unwrap();
1855
1856 store
1857 .mark_run_terminal(&created.id, "run-success", ScheduleRunStatus::Success, None)
1858 .await
1859 .unwrap();
1860
1861 let updated = store.get_schedule(&created.id).await.unwrap();
1862 assert_eq!(updated.state.running_run_count, 0);
1863 assert!(updated.state.last_finished_at.is_some());
1864 assert!(updated.state.last_success_at.is_some());
1865 assert_eq!(updated.state.total_run_count, 1);
1866 assert_eq!(updated.state.total_success_count, 1);
1867 assert_eq!(updated.state.total_failure_count, 0);
1868 assert_eq!(updated.state.consecutive_failures, 0);
1869 }
1870
1871 #[tokio::test]
1872 async fn mark_run_terminal_records_failure_accounting() {
1873 let dir = tempdir().unwrap();
1874 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1875
1876 let created = store
1877 .create_schedule(
1878 "failure".to_string(),
1879 ScheduleTrigger::Interval {
1880 every_seconds: 60,
1881 anchor_at: None,
1882 },
1883 true,
1884 ScheduleRunConfig::default(),
1885 )
1886 .await
1887 .unwrap();
1888
1889 store
1890 .update_index(|index| {
1891 let entry = index.schedules.get_mut(&created.id).unwrap();
1892 entry.state.running_run_count = 1;
1893 Ok(())
1894 })
1895 .await
1896 .unwrap();
1897
1898 store
1899 .mark_run_terminal(&created.id, "run-failed", ScheduleRunStatus::Failed, None)
1900 .await
1901 .unwrap();
1902 store
1903 .update_index(|index| {
1904 let entry = index.schedules.get_mut(&created.id).unwrap();
1905 entry.state.running_run_count = 1;
1906 Ok(())
1907 })
1908 .await
1909 .unwrap();
1910 store
1911 .mark_run_terminal(
1912 &created.id,
1913 "run-cancelled",
1914 ScheduleRunStatus::Cancelled,
1915 None,
1916 )
1917 .await
1918 .unwrap();
1919
1920 let updated = store.get_schedule(&created.id).await.unwrap();
1921 assert_eq!(updated.state.running_run_count, 0);
1922 assert!(updated.state.last_finished_at.is_some());
1923 assert!(updated.state.last_failure_at.is_some());
1924 assert_eq!(updated.state.total_run_count, 2);
1925 assert_eq!(updated.state.total_success_count, 0);
1926 assert_eq!(updated.state.total_failure_count, 2);
1927 assert_eq!(updated.state.consecutive_failures, 2);
1928 }
1929
1930 #[tokio::test]
1931 async fn mark_run_dequeued_without_start_counts_missed_occurrence() {
1932 let dir = tempdir().unwrap();
1933 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1934
1935 let created = store
1936 .create_schedule(
1937 "missed".to_string(),
1938 ScheduleTrigger::Interval {
1939 every_seconds: 60,
1940 anchor_at: None,
1941 },
1942 true,
1943 ScheduleRunConfig::default(),
1944 )
1945 .await
1946 .unwrap();
1947
1948 store
1949 .update_index(|index| {
1950 let entry = index.schedules.get_mut(&created.id).unwrap();
1951 entry.state.queued_run_count = 1;
1952 Ok(())
1953 })
1954 .await
1955 .unwrap();
1956
1957 store
1958 .mark_run_dequeued_without_start(&created.id, "run-missed", None)
1959 .await
1960 .unwrap();
1961
1962 let updated = store.get_schedule(&created.id).await.unwrap();
1963 assert_eq!(updated.state.queued_run_count, 0);
1964 assert_eq!(updated.state.total_missed_count, 1);
1965 let record = store
1966 .get_run_record("run-missed")
1967 .await
1968 .expect("run record should be created for missed dequeue");
1969 assert_eq!(record.status, ScheduleRunStatus::Missed);
1970 assert!(record.completed_at.is_some());
1971 }
1972
1973 #[tokio::test]
1974 async fn create_run_now_persists_queued_run_record() {
1975 let dir = tempdir().unwrap();
1976 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1977
1978 let created = store
1979 .create_schedule(
1980 "run-now".to_string(),
1981 ScheduleTrigger::Interval {
1982 every_seconds: 60,
1983 anchor_at: None,
1984 },
1985 true,
1986 ScheduleRunConfig::default(),
1987 )
1988 .await
1989 .unwrap();
1990
1991 let claimed = store
1992 .create_run_now(&created.id)
1993 .await
1994 .unwrap()
1995 .expect("run descriptor should be created");
1996
1997 let record = store
1998 .get_run_record(&claimed.run_id)
1999 .await
2000 .expect("queued run record should exist");
2001 assert_eq!(record.schedule_id, created.id);
2002 assert_eq!(record.status, ScheduleRunStatus::Queued);
2003 assert_eq!(record.claimed_at, claimed.claimed_at);
2004 assert_eq!(record.scheduled_for, claimed.scheduled_for);
2005 assert!(!claimed.was_catch_up);
2006 }
2007
2008 #[tokio::test]
2009 async fn mark_run_started_and_terminal_updates_run_record_fields() {
2010 let dir = tempdir().unwrap();
2011 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2012
2013 let created = store
2014 .create_schedule(
2015 "run-record-lifecycle".to_string(),
2016 ScheduleTrigger::Interval {
2017 every_seconds: 60,
2018 anchor_at: None,
2019 },
2020 true,
2021 ScheduleRunConfig::default(),
2022 )
2023 .await
2024 .unwrap();
2025
2026 let claimed = store
2027 .create_run_now(&created.id)
2028 .await
2029 .unwrap()
2030 .expect("run descriptor should be created");
2031
2032 store
2033 .mark_run_started(&created.id, &claimed.run_id)
2034 .await
2035 .unwrap();
2036 store
2037 .bind_run_session(&created.id, &claimed.run_id, "session-1")
2038 .await
2039 .unwrap();
2040 store
2041 .mark_run_terminal(
2042 &created.id,
2043 &claimed.run_id,
2044 ScheduleRunStatus::Success,
2045 Some("ok".to_string()),
2046 )
2047 .await
2048 .unwrap();
2049
2050 let record = store
2051 .get_run_record(&claimed.run_id)
2052 .await
2053 .expect("run record should exist");
2054 assert_eq!(record.status, ScheduleRunStatus::Success);
2055 assert!(record.started_at.is_some());
2056 assert!(record.completed_at.is_some());
2057 assert_eq!(record.session_id.as_deref(), Some("session-1"));
2058 assert!(record.dispatch_lag_ms.is_some());
2059 assert!(record.execution_duration_ms.is_some());
2060 assert_eq!(record.outcome_reason.as_deref(), Some("ok"));
2061 }
2062
2063 #[tokio::test]
2064 async fn delete_schedule_removes_associated_run_records() {
2065 let dir = tempdir().unwrap();
2066 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2067
2068 let created = store
2069 .create_schedule(
2070 "cleanup-history".to_string(),
2071 ScheduleTrigger::Interval {
2072 every_seconds: 60,
2073 anchor_at: None,
2074 },
2075 true,
2076 ScheduleRunConfig::default(),
2077 )
2078 .await
2079 .unwrap();
2080
2081 let claimed = store
2082 .create_run_now(&created.id)
2083 .await
2084 .unwrap()
2085 .expect("run descriptor should be created");
2086 assert!(store.get_run_record(&claimed.run_id).await.is_some());
2087
2088 let deleted = store.delete_schedule(&created.id).await.unwrap();
2089 assert!(deleted);
2090 assert!(store.get_run_record(&claimed.run_id).await.is_none());
2091 }
2092}