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 { .. } => None,
352 _ => timezone,
353 };
354 engine
355 .next_after(trigger, runtime_timezone, now, window)
356 .map_err(|error| other_io_error(format!("failed to compute initial next run: {error}")))?
357 .ok_or_else(|| other_io_error("schedule has no next run within configured window"))
358}
359
360fn normalize_trigger_for_storage(
361 trigger: ScheduleTrigger,
362 anchor_at: DateTime<Utc>,
363) -> ScheduleTrigger {
364 match trigger {
365 ScheduleTrigger::Interval {
366 every_seconds,
367 anchor_at: existing_anchor,
368 } => ScheduleTrigger::Interval {
369 every_seconds,
370 anchor_at: existing_anchor.or(Some(anchor_at)),
371 },
372 other => other,
373 }
374}
375
376fn normalize_loaded_index(mut index: SchedulesIndex) -> (SchedulesIndex, bool) {
377 let mut changed = false;
378 if index.version < 4 {
379 index.version = 4;
380 changed = true;
381 }
382 for entry in index.schedules.values_mut() {
383 changed |= normalize_loaded_schedule_entry(entry);
384 }
385 if changed {
386 index.updated_at = Utc::now();
387 }
388 (index, changed)
389}
390
391fn normalize_loaded_schedule_entry(entry: &mut ScheduleEntry) -> bool {
392 let mut changed = false;
393
394 let normalized_timezone = normalize_optional_string(entry.timezone.clone());
395 if entry.timezone != normalized_timezone {
396 entry.timezone = normalized_timezone;
397 changed = true;
398 }
399
400 let derived_anchor_at = entry.derived_anchor_at();
401 if let ScheduleTrigger::Interval {
402 every_seconds,
403 anchor_at,
404 } = &mut entry.trigger
405 {
406 if anchor_at.is_none() {
407 if let Some(derived_anchor_at) = derived_anchor_at {
408 *anchor_at = Some(derived_anchor_at);
409 changed = true;
410 }
411 }
412 if *every_seconds == 0 {
413 *every_seconds = 1;
414 changed = true;
415 }
416 }
417
418 if entry.state.next_fire_at.is_none() {
419 entry.state.next_fire_at = Some(entry.created_at);
420 changed = true;
421 }
422
423 changed
424}
425
426fn current_due_at(entry: &ScheduleEntry) -> Option<DateTime<Utc>> {
427 let mut due_at = entry.state.next_fire_at?;
428 if let Some(start_at) = entry.start_at {
429 if due_at < start_at {
430 due_at = start_at;
431 }
432 }
433 if let Some(end_at) = entry.end_at {
434 if due_at > end_at {
435 return None;
436 }
437 }
438 Some(due_at)
439}
440
441fn overlap_blocks_dispatch(entry: &ScheduleEntry) -> bool {
442 match entry.overlap_policy {
443 OverlapPolicy::Allow => false,
444 OverlapPolicy::Skip => entry.state.running_run_count > 0,
445 OverlapPolicy::QueueOne => entry.state.queued_run_count > 0,
446 }
447}
448
449fn apply_overlap_dispatch_limit(entry: &ScheduleEntry, dispatch_count: u32) -> u32 {
450 match entry.overlap_policy {
451 OverlapPolicy::Allow | OverlapPolicy::Skip => dispatch_count,
452 OverlapPolicy::QueueOne => dispatch_count.min(1),
453 }
454}
455
456fn compute_misfire_dispatch_count(entry: &ScheduleEntry, now: DateTime<Utc>) -> u32 {
457 let Some(next_fire_at) = entry.state.next_fire_at else {
458 return 0;
459 };
460 let lateness_seconds = now.signed_duration_since(next_fire_at).num_seconds().max(0) as u64;
461 let interval_seconds = interval_seconds_from_trigger(&entry.trigger).unwrap_or(0);
462
463 match entry.misfire_policy {
464 MisFirePolicy::RunOnce => 1,
465 MisFirePolicy::Skip => 0,
466 MisFirePolicy::CatchUpAll => lateness_seconds
467 .checked_div(interval_seconds)
468 .map(|q| q.saturating_add(1) as u32)
469 .unwrap_or(1),
470 MisFirePolicy::CatchUpWindow {
471 max_catch_up_runs,
472 max_lateness_seconds,
473 } => {
474 if lateness_seconds > max_lateness_seconds {
475 0
476 } else {
477 lateness_seconds
478 .checked_div(interval_seconds)
479 .map(|q| (q.saturating_add(1) as u32).min(max_catch_up_runs.max(1)))
480 .unwrap_or(1)
481 }
482 }
483 }
484}
485
486fn runtime_trigger(entry: &ScheduleEntry) -> ScheduleTrigger {
487 match &entry.trigger {
488 ScheduleTrigger::Interval { every_seconds, .. } => {
489 ScheduleTrigger::legacy_interval(*every_seconds, None)
490 }
491 other => other.clone(),
492 }
493}
494
495fn runtime_timezone<'a>(entry: &'a ScheduleEntry, trigger: &ScheduleTrigger) -> Option<&'a str> {
496 match trigger {
497 ScheduleTrigger::Interval { .. } => None,
498 _ => entry.timezone.as_deref(),
499 }
500}
501
502fn compute_next_run_at_with_engine(
503 entry: &ScheduleEntry,
504 now: DateTime<Utc>,
505 engine: &dyn TriggerEngine,
506) -> io::Result<Option<DateTime<Utc>>> {
507 let trigger = runtime_trigger(entry);
508 let window = ScheduleWindow {
509 start_at: entry.start_at,
510 end_at: entry.end_at,
511 };
512 engine
513 .next_after(&trigger, runtime_timezone(entry, &trigger), now, &window)
514 .map_err(|error| {
515 other_io_error(format!(
516 "failed to compute next fire for schedule {}: {}",
517 entry.id, error
518 ))
519 })
520}
521
522fn record_missed_occurrence(entry: &mut ScheduleEntry) {
523 entry.state.total_missed_count = entry.state.total_missed_count.saturating_add(1);
524}
525
526fn non_negative_duration_ms(from: DateTime<Utc>, to: DateTime<Utc>) -> u64 {
527 to.signed_duration_since(from).num_milliseconds().max(0) as u64
528}
529
530fn make_queued_run_record(
531 schedule_id: &str,
532 scheduled_for: DateTime<Utc>,
533 claimed_at: DateTime<Utc>,
534 was_catch_up: bool,
535) -> ScheduleRunRecord {
536 ScheduleRunRecord {
537 run_id: Uuid::new_v4().to_string(),
538 schedule_id: schedule_id.to_string(),
539 scheduled_for,
540 claimed_at,
541 started_at: None,
542 completed_at: None,
543 status: ScheduleRunStatus::Queued,
544 outcome_reason: None,
545 session_id: None,
546 dispatch_lag_ms: None,
547 execution_duration_ms: None,
548 was_catch_up,
549 }
550}
551
552fn make_fallback_run_record(
553 run_id: &str,
554 schedule_id: &str,
555 now: DateTime<Utc>,
556 status: ScheduleRunStatus,
557) -> ScheduleRunRecord {
558 ScheduleRunRecord {
559 run_id: run_id.to_string(),
560 schedule_id: schedule_id.to_string(),
561 scheduled_for: now,
562 claimed_at: now,
563 started_at: matches!(status, ScheduleRunStatus::Running).then_some(now),
564 completed_at: matches!(
565 status,
566 ScheduleRunStatus::Success
567 | ScheduleRunStatus::Failed
568 | ScheduleRunStatus::Skipped
569 | ScheduleRunStatus::Missed
570 | ScheduleRunStatus::Cancelled
571 )
572 .then_some(now),
573 status,
574 outcome_reason: None,
575 session_id: None,
576 dispatch_lag_ms: None,
577 execution_duration_ms: None,
578 was_catch_up: false,
579 }
580}
581
582fn scheduled_for_dispatch(
583 entry: &ScheduleEntry,
584 due_at: DateTime<Utc>,
585 dispatch_index: u32,
586) -> DateTime<Utc> {
587 match interval_seconds_from_trigger(&entry.trigger) {
588 Some(interval_seconds) if dispatch_index > 0 => {
589 due_at + Duration::seconds(interval_seconds as i64 * dispatch_index as i64)
590 }
591 _ => due_at,
592 }
593}
594
595fn update_run_record_started(
596 record: &mut ScheduleRunRecord,
597 started_at: DateTime<Utc>,
598 session_id: Option<&str>,
599) {
600 record.status = ScheduleRunStatus::Running;
601 record.started_at = Some(started_at);
602 record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, started_at));
603 if let Some(session_id) = session_id {
604 record.session_id = Some(session_id.to_string());
605 }
606}
607
608fn update_run_record_terminal(
609 record: &mut ScheduleRunRecord,
610 status: ScheduleRunStatus,
611 completed_at: DateTime<Utc>,
612 outcome_reason: Option<String>,
613 session_id: Option<&str>,
614) {
615 record.status = status;
616 record.completed_at = Some(completed_at);
617 if record.dispatch_lag_ms.is_none() {
618 record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, completed_at));
619 }
620 if let Some(started_at) = record.started_at {
621 record.execution_duration_ms = Some(non_negative_duration_ms(started_at, completed_at));
622 }
623 if let Some(outcome_reason) = normalize_optional_string(outcome_reason) {
624 record.outcome_reason = Some(outcome_reason);
625 }
626 if let Some(session_id) = session_id {
627 record.session_id = Some(session_id.to_string());
628 }
629}
630
631fn apply_terminal_run_status(
632 entry: &mut ScheduleEntry,
633 status: ScheduleRunStatus,
634 finished_at: DateTime<Utc>,
635) -> io::Result<()> {
636 entry.state.running_run_count = entry.state.running_run_count.saturating_sub(1);
637 entry.state.last_finished_at = Some(finished_at);
638
639 match status {
640 ScheduleRunStatus::Success => {
641 entry.state.last_success_at = Some(finished_at);
642 entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
643 entry.state.total_success_count = entry.state.total_success_count.saturating_add(1);
644 entry.state.consecutive_failures = 0;
645 Ok(())
646 }
647 ScheduleRunStatus::Failed | ScheduleRunStatus::Cancelled => {
648 entry.state.last_failure_at = Some(finished_at);
649 entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
650 entry.state.total_failure_count = entry.state.total_failure_count.saturating_add(1);
651 entry.state.consecutive_failures = entry.state.consecutive_failures.saturating_add(1);
652 Ok(())
653 }
654 ScheduleRunStatus::Skipped => Ok(()),
655 ScheduleRunStatus::Missed | ScheduleRunStatus::Queued | ScheduleRunStatus::Running => {
656 Err(other_io_error(format!(
657 "non-terminal or unsupported run status for lifecycle accounting: {:?}",
658 status
659 )))
660 }
661 }
662}
663
664#[derive(Debug, Clone)]
665pub struct ClaimedScheduleRun {
666 pub run_id: String,
667 pub schedule_id: String,
668 pub schedule_name: String,
669 pub run_config: ScheduleRunConfig,
670 pub scheduled_for: DateTime<Utc>,
671 pub claimed_at: DateTime<Utc>,
672 pub was_catch_up: bool,
673}
674
675#[derive(Debug)]
676pub struct ScheduleStore {
677 index_path: PathBuf,
678 index: RwLock<SchedulesIndex>,
679 write_lock: Mutex<()>,
680}
681
682impl ScheduleStore {
683 pub async fn new(bamboo_home_dir: PathBuf) -> io::Result<Self> {
684 let index_path = bamboo_home_dir.join("schedules.json");
685
686 let (index, needs_backfill_write) = if index_path.exists() {
687 let raw = fs::read_to_string(&index_path).await?;
688 match serde_json::from_str::<SchedulesIndex>(&raw) {
689 Ok(parsed) => normalize_loaded_index(parsed),
690 Err(e) => {
691 let backup_path =
694 index_path.with_extension(format!("json.corrupted.{}", Uuid::new_v4()));
695 tracing::error!(
696 "schedules.json is corrupted ({}). Backing up to {} and resetting.",
697 e,
698 backup_path.display()
699 );
700 if let Err(rename_err) = fs::rename(&index_path, &backup_path).await {
701 tracing::warn!(
702 "Failed to back up corrupted schedules.json: {}",
703 rename_err
704 );
705 }
706 let fresh = SchedulesIndex::empty();
707 atomic_write_json(
708 &index_path,
709 serde_json::to_vec_pretty(&fresh)
710 .map_err(|e| other_io_error(e.to_string()))?,
711 )
712 .await?;
713 (fresh, false)
714 }
715 }
716 } else {
717 let index = SchedulesIndex::empty();
718 atomic_write_json(
719 &index_path,
720 serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
721 )
722 .await?;
723 (index, false)
724 };
725
726 if needs_backfill_write {
727 atomic_write_json(
728 &index_path,
729 serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
730 )
731 .await?;
732 }
733
734 cleanup_stale_tmp_files(&bamboo_home_dir, "schedules.json.tmp.").await;
736
737 Ok(Self {
738 index_path,
739 index: RwLock::new(index),
740 write_lock: Mutex::new(()),
741 })
742 }
743
744 pub fn index_path(&self) -> &Path {
745 &self.index_path
746 }
747
748 async fn update_index<F, T>(&self, f: F) -> io::Result<T>
749 where
750 F: FnOnce(&mut SchedulesIndex) -> io::Result<T>,
751 {
752 let _guard = self.write_lock.lock().await;
753 let mut index = self.index.write().await;
754 let out = f(&mut index)?;
755 index.updated_at = Utc::now();
756 atomic_write_json(
757 &self.index_path,
758 serde_json::to_vec_pretty(&*index).map_err(|e| other_io_error(e.to_string()))?,
759 )
760 .await?;
761 Ok(out)
762 }
763
764 pub async fn list_schedules(&self) -> Vec<ScheduleEntry> {
765 let index = self.index.read().await;
766 let mut items: Vec<_> = index.schedules.values().cloned().collect();
767 items.sort_by_key(|e| Reverse(e.updated_at));
768 items
769 }
770
771 pub async fn get_schedule(&self, id: &str) -> Option<ScheduleEntry> {
772 let index = self.index.read().await;
773 index.schedules.get(id).cloned()
774 }
775
776 pub async fn get_run_record(&self, run_id: &str) -> Option<ScheduleRunRecord> {
777 let index = self.index.read().await;
778 index.run_records.get(run_id).cloned()
779 }
780
781 pub async fn list_run_records_for_schedule(&self, schedule_id: &str) -> Vec<ScheduleRunRecord> {
782 let index = self.index.read().await;
783 let mut items = index
784 .run_records
785 .values()
786 .filter(|record| record.schedule_id == schedule_id)
787 .cloned()
788 .collect::<Vec<_>>();
789 items.sort_by_key(|r| Reverse(r.claimed_at));
790 items
791 }
792
793 pub async fn create_schedule(
794 &self,
795 name: String,
796 trigger: ScheduleTrigger,
797 enabled: bool,
798 run_config: ScheduleRunConfig,
799 ) -> io::Result<ScheduleEntry> {
800 self.create_schedule_with_definition(
801 name,
802 enabled,
803 run_config,
804 ScheduleDefinitionChanges {
805 trigger: Some(trigger),
806 ..Default::default()
807 },
808 )
809 .await
810 }
811
812 pub async fn create_schedule_with_definition(
813 &self,
814 name: String,
815 enabled: bool,
816 run_config: ScheduleRunConfig,
817 definition: ScheduleDefinitionChanges,
818 ) -> io::Result<ScheduleEntry> {
819 let now = Utc::now();
820 let id = Uuid::new_v4().to_string();
821 let window = definition_window(&definition);
822 let trigger = normalize_trigger_for_storage(
823 definition
824 .trigger
825 .ok_or_else(|| other_io_error("schedule trigger is required"))?,
826 now,
827 );
828 let timezone = normalize_optional_string(definition.timezone);
829 let next_fire_at =
830 compute_initial_next_run_at(&trigger, timezone.as_deref(), &window, now)?;
831 let entry = ScheduleEntry {
832 id: id.clone(),
833 name,
834 enabled,
835 trigger,
836 timezone,
837 start_at: definition.start_at,
838 end_at: definition.end_at,
839 misfire_policy: definition.misfire_policy.unwrap_or_default(),
840 overlap_policy: definition.overlap_policy.unwrap_or_default(),
841 created_at: now,
842 updated_at: now,
843 state: ScheduleState {
844 next_fire_at: Some(next_fire_at),
845 ..Default::default()
846 },
847 run_config,
848 };
849
850 self.update_index(|index| {
851 index.schedules.insert(id.clone(), entry.clone());
852 Ok(entry.clone())
853 })
854 .await
855 }
856
857 pub async fn patch_schedule(
858 &self,
859 id: &str,
860 name: Option<String>,
861 enabled: Option<bool>,
862 trigger: Option<ScheduleTrigger>,
863 run_config: Option<ScheduleRunConfig>,
864 ) -> io::Result<Option<ScheduleEntry>> {
865 self.patch_schedule_with_definition(
866 id,
867 name,
868 enabled,
869 run_config,
870 ScheduleDefinitionChanges {
871 trigger,
872 ..Default::default()
873 },
874 )
875 .await
876 }
877
878 pub async fn patch_schedule_with_definition(
879 &self,
880 id: &str,
881 name: Option<String>,
882 enabled: Option<bool>,
883 run_config: Option<ScheduleRunConfig>,
884 definition: ScheduleDefinitionChanges,
885 ) -> io::Result<Option<ScheduleEntry>> {
886 self.update_index(|index| {
887 let Some(existing) = index.schedules.get_mut(id) else {
888 return Ok(None);
889 };
890 let now = Utc::now();
891 if let Some(name) = name {
892 existing.name = name;
893 }
894 if let Some(enabled) = enabled {
895 existing.enabled = enabled;
896 }
897
898 let trigger = normalize_trigger_for_storage(
899 definition
900 .trigger
901 .clone()
902 .unwrap_or_else(|| existing.trigger.clone()),
903 existing.derived_anchor_at().unwrap_or(now),
904 );
905 existing.trigger = trigger.clone();
906
907 if let Some(timezone) = definition.timezone {
908 existing.timezone = normalize_optional_string(Some(timezone));
909 }
910 if let Some(start_at) = definition.start_at {
911 existing.start_at = Some(start_at);
912 }
913 if let Some(end_at) = definition.end_at {
914 existing.end_at = Some(end_at);
915 }
916 if let Some(misfire_policy) = definition.misfire_policy {
917 existing.misfire_policy = misfire_policy;
918 }
919 if let Some(overlap_policy) = definition.overlap_policy {
920 existing.overlap_policy = overlap_policy;
921 }
922 if let Some(run_config) = run_config {
923 existing.run_config = run_config;
924 }
925
926 let window = ScheduleWindow {
927 start_at: existing.start_at,
928 end_at: existing.end_at,
929 };
930 existing.state.next_fire_at = Some(compute_initial_next_run_at(
931 &trigger,
932 existing.timezone.as_deref(),
933 &window,
934 now,
935 )?);
936 existing.updated_at = now;
937 Ok(Some(existing.clone()))
938 })
939 .await
940 }
941
942 pub async fn delete_schedule(&self, id: &str) -> io::Result<bool> {
943 self.update_index(|index| {
944 let deleted = index.schedules.remove(id).is_some();
945 if deleted {
946 index
947 .run_records
948 .retain(|_, record| record.schedule_id != id);
949 }
950 Ok(deleted)
951 })
952 .await
953 }
954
955 pub async fn claim_due_runs(&self, now: DateTime<Utc>) -> io::Result<Vec<ClaimedScheduleRun>> {
963 let engine = default_trigger_engine();
964 self.claim_due_runs_with_engine(now, engine.as_ref()).await
965 }
966
967 pub async fn claim_due_runs_with_engine(
968 &self,
969 now: DateTime<Utc>,
970 engine: &dyn TriggerEngine,
971 ) -> io::Result<Vec<ClaimedScheduleRun>> {
972 {
973 let index = self.index.read().await;
974 let any_due = index.schedules.values().any(|entry| {
975 entry.enabled && current_due_at(entry).is_some_and(|due_at| due_at <= now)
976 });
977 if !any_due {
978 return Ok(Vec::new());
979 }
980 }
981
982 self.update_index(|index| {
983 let mut out = Vec::new();
984 let (schedules, run_records) = (&mut index.schedules, &mut index.run_records);
985 for entry in schedules.values_mut() {
986 if !entry.enabled {
987 continue;
988 }
989 let Some(due_at) = current_due_at(entry) else {
990 continue;
991 };
992 if due_at > now {
993 continue;
994 }
995
996 let dispatch_count = compute_misfire_dispatch_count(entry, now);
997 if dispatch_count == 0 {
998 entry.state.last_scheduled_at = Some(now);
999 record_missed_occurrence(entry);
1000 match compute_next_run_at_with_engine(entry, now, engine) {
1001 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1002 Ok(None) => {
1003 entry.state.next_fire_at = None;
1004 entry.enabled = false;
1005 }
1006 Err(error) => {
1007 tracing::warn!(
1008 "failed to compute next scheduled fire for {} after misfire skip: {}. falling back to legacy interval semantics",
1009 entry.id,
1010 error
1011 );
1012 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1013 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1014 }
1015 }
1016 entry.updated_at = now;
1017 continue;
1018 }
1019
1020 let blocked = overlap_blocks_dispatch(entry);
1021 match entry.overlap_policy {
1022 OverlapPolicy::Skip if blocked => {
1023 entry.state.last_scheduled_at = Some(now);
1024 record_missed_occurrence(entry);
1025 match compute_next_run_at_with_engine(entry, now, engine) {
1026 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1027 Ok(None) => {
1028 entry.state.next_fire_at = None;
1029 entry.enabled = false;
1030 }
1031 Err(error) => {
1032 tracing::warn!(
1033 "failed to compute next scheduled fire for {} after overlap skip: {}. falling back to legacy interval semantics",
1034 entry.id,
1035 error
1036 );
1037 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1038 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1039 }
1040 }
1041 entry.updated_at = now;
1042 continue;
1043 }
1044 OverlapPolicy::QueueOne if blocked => {
1045 continue;
1046 }
1047 _ => {}
1048 }
1049
1050 let dispatch_count = apply_overlap_dispatch_limit(entry, dispatch_count);
1051 entry.state.last_scheduled_at = Some(now);
1052 match compute_next_run_at_with_engine(entry, now, engine) {
1053 Ok(Some(next_fire_at)) => {
1054 entry.state.next_fire_at = Some(next_fire_at);
1055 }
1056 Ok(None) => {
1057 entry.state.next_fire_at = None;
1058 entry.enabled = false;
1059 }
1060 Err(error) => {
1061 tracing::warn!(
1062 "failed to compute next scheduled fire for {}: {}. falling back to legacy interval semantics",
1063 entry.id,
1064 error
1065 );
1066 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1067 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1068 }
1069 }
1070 entry.state.queued_run_count = entry.state.queued_run_count.saturating_add(dispatch_count);
1071 entry.updated_at = now;
1072 for dispatch_index in 0..dispatch_count {
1073 let scheduled_for = scheduled_for_dispatch(entry, due_at, dispatch_index);
1074 let was_catch_up = scheduled_for < now;
1075 let record = make_queued_run_record(&entry.id, scheduled_for, now, was_catch_up);
1076 let run_id = record.run_id.clone();
1077 run_records.insert(run_id.clone(), record);
1078 out.push(ClaimedScheduleRun {
1079 run_id,
1080 schedule_id: entry.id.clone(),
1081 schedule_name: entry.name.clone(),
1082 run_config: entry.run_config.clone(),
1083 scheduled_for,
1084 claimed_at: now,
1085 was_catch_up,
1086 });
1087 }
1088 }
1089 prune_run_records(run_records);
1090 Ok(out)
1091 })
1092 .await
1093 }
1094
1095 pub async fn mark_run_started(&self, schedule_id: &str, run_id: &str) -> io::Result<()> {
1096 self.update_index(|index| {
1097 let now = Utc::now();
1098 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1099 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1100 entry.state.running_run_count = entry.state.running_run_count.saturating_add(1);
1101 entry.state.last_started_at = Some(now);
1102 entry.updated_at = now;
1103 }
1104 let record = index
1105 .run_records
1106 .entry(run_id.to_string())
1107 .or_insert_with(|| {
1108 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1109 });
1110 update_run_record_started(record, now, None);
1111 Ok(())
1112 })
1113 .await
1114 }
1115
1116 pub async fn bind_run_session(
1117 &self,
1118 schedule_id: &str,
1119 run_id: &str,
1120 session_id: &str,
1121 ) -> io::Result<()> {
1122 self.update_index(|index| {
1123 let now = Utc::now();
1124 let record = index
1125 .run_records
1126 .entry(run_id.to_string())
1127 .or_insert_with(|| {
1128 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Running)
1129 });
1130 record.session_id = Some(session_id.to_string());
1131 Ok(())
1132 })
1133 .await
1134 }
1135
1136 pub async fn mark_run_terminal(
1137 &self,
1138 schedule_id: &str,
1139 run_id: &str,
1140 status: ScheduleRunStatus,
1141 outcome_reason: Option<String>,
1142 ) -> io::Result<()> {
1143 self.update_index(|index| {
1144 let now = Utc::now();
1145 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1146 apply_terminal_run_status(entry, status, now)?;
1147 entry.updated_at = now;
1148 }
1149 let record = index
1150 .run_records
1151 .entry(run_id.to_string())
1152 .or_insert_with(|| make_fallback_run_record(run_id, schedule_id, now, status));
1153 update_run_record_terminal(record, status, now, outcome_reason, None);
1154 Ok(())
1155 })
1156 .await
1157 }
1158
1159 pub async fn mark_run_dequeued_without_start(
1160 &self,
1161 schedule_id: &str,
1162 run_id: &str,
1163 outcome_reason: Option<String>,
1164 ) -> io::Result<()> {
1165 self.update_index(|index| {
1166 let now = Utc::now();
1167 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1168 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1169 record_missed_occurrence(entry);
1170 entry.updated_at = now;
1171 }
1172 let record = index
1173 .run_records
1174 .entry(run_id.to_string())
1175 .or_insert_with(|| {
1176 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1177 });
1178 update_run_record_terminal(
1179 record,
1180 ScheduleRunStatus::Missed,
1181 now,
1182 outcome_reason,
1183 None,
1184 );
1185 Ok(())
1186 })
1187 .await
1188 }
1189
1190 pub async fn create_run_now(&self, id: &str) -> io::Result<Option<ClaimedScheduleRun>> {
1192 self.update_index(|index| {
1193 let Some(entry) = index.schedules.get(id).cloned() else {
1194 return Ok(None);
1195 };
1196 let now = Utc::now();
1197 let record = make_queued_run_record(&entry.id, now, now, false);
1198 let run_id = record.run_id.clone();
1199 index.run_records.insert(run_id.clone(), record);
1200 prune_run_records(&mut index.run_records);
1201 Ok(Some(ClaimedScheduleRun {
1202 run_id,
1203 schedule_id: entry.id,
1204 schedule_name: entry.name,
1205 run_config: entry.run_config,
1206 scheduled_for: now,
1207 claimed_at: now,
1208 was_catch_up: false,
1209 }))
1210 })
1211 .await
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218 use tempfile::tempdir;
1219
1220 fn run_record(
1221 run_id: &str,
1222 schedule_id: &str,
1223 status: ScheduleRunStatus,
1224 completed_at: Option<DateTime<Utc>>,
1225 ) -> ScheduleRunRecord {
1226 let base = Utc::now();
1227 ScheduleRunRecord {
1228 run_id: run_id.to_string(),
1229 schedule_id: schedule_id.to_string(),
1230 scheduled_for: base,
1231 claimed_at: base,
1232 started_at: None,
1233 completed_at,
1234 status,
1235 outcome_reason: None,
1236 session_id: None,
1237 dispatch_lag_ms: None,
1238 execution_duration_ms: None,
1239 was_catch_up: false,
1240 }
1241 }
1242
1243 #[test]
1244 fn prune_caps_terminal_records_keeps_newest_and_all_in_flight() {
1245 let mut records: HashMap<String, ScheduleRunRecord> = HashMap::new();
1246 let base = Utc::now();
1247
1248 let total_terminal = MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE + 50;
1250 for i in 0..total_terminal {
1251 let id = format!("s1-term-{i}");
1252 let completed = base + Duration::seconds(i as i64);
1253 records.insert(
1254 id.clone(),
1255 run_record(&id, "s1", ScheduleRunStatus::Success, Some(completed)),
1256 );
1257 }
1258 records.insert(
1260 "s1-queued".into(),
1261 run_record("s1-queued", "s1", ScheduleRunStatus::Queued, None),
1262 );
1263 records.insert(
1264 "s1-running".into(),
1265 run_record("s1-running", "s1", ScheduleRunStatus::Running, None),
1266 );
1267 records.insert(
1269 "s2-term-0".into(),
1270 run_record("s2-term-0", "s2", ScheduleRunStatus::Failed, Some(base)),
1271 );
1272
1273 prune_run_records(&mut records);
1274
1275 let s1_terminal = records
1276 .values()
1277 .filter(|r| r.schedule_id == "s1" && r.status.is_terminal())
1278 .count();
1279 assert_eq!(s1_terminal, MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE);
1280 assert!(records.contains_key("s1-queued"));
1282 assert!(records.contains_key("s1-running"));
1283 assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1285 assert!(!records.contains_key("s1-term-0"));
1286 assert!(records.contains_key("s2-term-0"));
1288 }
1289
1290 #[tokio::test]
1291 async fn store_backfills_legacy_interval_trigger_on_load() {
1292 let dir = tempdir().unwrap();
1293 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1294 .unwrap()
1295 .with_timezone(&Utc);
1296 let next_run_at = DateTime::parse_from_rfc3339("2026-04-04T11:00:00Z")
1297 .unwrap()
1298 .with_timezone(&Utc);
1299
1300 let raw = serde_json::json!({
1301 "version": 1,
1302 "updated_at": now,
1303 "schedules": {
1304 "legacy-1": {
1305 "id": "legacy-1",
1306 "name": "legacy",
1307 "enabled": true,
1308 "interval_seconds": 3600,
1309 "created_at": now,
1310 "updated_at": now,
1311 "last_run_at": null,
1312 "next_run_at": next_run_at,
1313 "run_config": { "auto_execute": false }
1314 }
1315 }
1316 });
1317 tokio::fs::write(
1318 dir.path().join("schedules.json"),
1319 serde_json::to_vec_pretty(&raw).unwrap(),
1320 )
1321 .await
1322 .unwrap();
1323
1324 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1325 let schedule = store.get_schedule("legacy-1").await.unwrap();
1326 assert!(matches!(
1327 schedule.trigger,
1328 ScheduleTrigger::Interval {
1329 every_seconds: 3600,
1330 ..
1331 }
1332 ));
1333 }
1334
1335 #[tokio::test]
1336 async fn create_schedule_with_definition_persists_interval_trigger_metadata() {
1337 let dir = tempdir().unwrap();
1338 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1339
1340 let created = store
1341 .create_schedule_with_definition(
1342 "interval".to_string(),
1343 true,
1344 ScheduleRunConfig::default(),
1345 ScheduleDefinitionChanges {
1346 trigger: Some(ScheduleTrigger::Interval {
1347 every_seconds: 300,
1348 anchor_at: None,
1349 }),
1350 timezone: Some("Asia/Shanghai".to_string()),
1351 misfire_policy: Some(MisFirePolicy::RunOnce),
1352 overlap_policy: Some(OverlapPolicy::QueueOne),
1353 ..Default::default()
1354 },
1355 )
1356 .await
1357 .unwrap();
1358
1359 assert!(matches!(
1360 created.trigger,
1361 ScheduleTrigger::Interval {
1362 every_seconds: 300,
1363 ..
1364 }
1365 ));
1366 assert_eq!(created.timezone.as_deref(), Some("Asia/Shanghai"));
1367 assert_eq!(created.misfire_policy, MisFirePolicy::RunOnce);
1368 assert_eq!(created.overlap_policy, OverlapPolicy::QueueOne);
1369 }
1370
1371 #[tokio::test]
1372 async fn patch_schedule_with_definition_updates_interval_trigger_metadata() {
1373 let dir = tempdir().unwrap();
1374 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1375
1376 let created = store
1377 .create_schedule(
1378 "interval".to_string(),
1379 ScheduleTrigger::Interval {
1380 every_seconds: 300,
1381 anchor_at: None,
1382 },
1383 true,
1384 ScheduleRunConfig::default(),
1385 )
1386 .await
1387 .unwrap();
1388
1389 let patched = store
1390 .patch_schedule_with_definition(
1391 &created.id,
1392 None,
1393 None,
1394 None,
1395 ScheduleDefinitionChanges {
1396 trigger: Some(ScheduleTrigger::Interval {
1397 every_seconds: 600,
1398 anchor_at: None,
1399 }),
1400 timezone: Some("UTC".to_string()),
1401 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1402 overlap_policy: Some(OverlapPolicy::Skip),
1403 ..Default::default()
1404 },
1405 )
1406 .await
1407 .unwrap()
1408 .unwrap();
1409
1410 assert!(matches!(
1411 patched.trigger,
1412 ScheduleTrigger::Interval {
1413 every_seconds: 600,
1414 ..
1415 }
1416 ));
1417 assert_eq!(patched.timezone.as_deref(), Some("UTC"));
1418 assert_eq!(patched.misfire_policy, MisFirePolicy::CatchUpAll);
1419 assert_eq!(patched.overlap_policy, OverlapPolicy::Skip);
1420 }
1421
1422 #[tokio::test]
1423 async fn claim_due_runs_with_engine_uses_runtime_adapter_for_interval_trigger() {
1424 let dir = tempdir().unwrap();
1425 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1426
1427 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1428 .unwrap()
1429 .with_timezone(&Utc);
1430
1431 let created = store
1432 .create_schedule_with_definition(
1433 "interval".to_string(),
1434 true,
1435 ScheduleRunConfig::default(),
1436 ScheduleDefinitionChanges {
1437 trigger: Some(ScheduleTrigger::Interval {
1438 every_seconds: 300,
1439 anchor_at: Some(now - Duration::seconds(300)),
1440 }),
1441 ..Default::default()
1442 },
1443 )
1444 .await
1445 .unwrap();
1446
1447 store
1448 .update_index(|index| {
1449 let entry = index.schedules.get_mut(&created.id).unwrap();
1450 entry.state.next_fire_at = Some(now);
1451 Ok(())
1452 })
1453 .await
1454 .unwrap();
1455
1456 let engine = default_trigger_engine();
1457 let claimed = store
1458 .claim_due_runs_with_engine(now, engine.as_ref())
1459 .await
1460 .unwrap();
1461 assert_eq!(claimed.len(), 1);
1462
1463 let updated = store.get_schedule(&created.id).await.unwrap();
1464 assert_eq!(updated.state.last_scheduled_at, Some(now));
1465 assert_eq!(
1466 updated.state.next_fire_at,
1467 Some(now + Duration::seconds(300))
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn create_schedule_with_definition_initializes_monthly_next_run() {
1473 let dir = tempdir().unwrap();
1474 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1475
1476 let created = store
1477 .create_schedule_with_definition(
1478 "monthly".to_string(),
1479 true,
1480 ScheduleRunConfig::default(),
1481 ScheduleDefinitionChanges {
1482 trigger: Some(ScheduleTrigger::Monthly {
1483 days: vec![1, 15],
1484 hour: 9,
1485 minute: 0,
1486 second: 0,
1487 }),
1488 timezone: Some("UTC".to_string()),
1489 ..Default::default()
1490 },
1491 )
1492 .await
1493 .unwrap();
1494
1495 assert!(matches!(
1496 created.trigger,
1497 ScheduleTrigger::Monthly {
1498 days,
1499 hour: 9,
1500 minute: 0,
1501 second: 0
1502 } if days == vec![1, 15]
1503 ));
1504 assert!(created
1505 .state
1506 .next_fire_at
1507 .is_some_and(|next| next > created.created_at));
1508 }
1509
1510 #[tokio::test]
1511 async fn misfire_skip_does_not_dispatch_run() {
1512 let dir = tempdir().unwrap();
1513 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1514 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1515 .unwrap()
1516 .with_timezone(&Utc);
1517
1518 let created = store
1519 .create_schedule_with_definition(
1520 "skip".to_string(),
1521 true,
1522 ScheduleRunConfig::default(),
1523 ScheduleDefinitionChanges {
1524 trigger: Some(ScheduleTrigger::Interval {
1525 every_seconds: 300,
1526 anchor_at: None,
1527 }),
1528 misfire_policy: Some(MisFirePolicy::Skip),
1529 ..Default::default()
1530 },
1531 )
1532 .await
1533 .unwrap();
1534
1535 store
1536 .update_index(|index| {
1537 let entry = index.schedules.get_mut(&created.id).unwrap();
1538 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1539 Ok(())
1540 })
1541 .await
1542 .unwrap();
1543
1544 let engine = default_trigger_engine();
1545 let claimed = store
1546 .claim_due_runs_with_engine(now, engine.as_ref())
1547 .await
1548 .unwrap();
1549 assert!(claimed.is_empty());
1550
1551 let updated = store.get_schedule(&created.id).await.unwrap();
1552 assert_eq!(updated.state.last_scheduled_at, Some(now));
1553 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1554 assert_eq!(updated.state.queued_run_count, 0);
1555 }
1556
1557 #[tokio::test]
1558 async fn overlap_skip_does_not_dispatch_when_running() {
1559 let dir = tempdir().unwrap();
1560 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1561 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1562 .unwrap()
1563 .with_timezone(&Utc);
1564
1565 let created = store
1566 .create_schedule_with_definition(
1567 "skip-overlap".to_string(),
1568 true,
1569 ScheduleRunConfig::default(),
1570 ScheduleDefinitionChanges {
1571 trigger: Some(ScheduleTrigger::Interval {
1572 every_seconds: 300,
1573 anchor_at: None,
1574 }),
1575 overlap_policy: Some(OverlapPolicy::Skip),
1576 ..Default::default()
1577 },
1578 )
1579 .await
1580 .unwrap();
1581
1582 store
1583 .update_index(|index| {
1584 let entry = index.schedules.get_mut(&created.id).unwrap();
1585 entry.state.next_fire_at = Some(now);
1586 entry.state.running_run_count = 1;
1587 Ok(())
1588 })
1589 .await
1590 .unwrap();
1591
1592 let engine = default_trigger_engine();
1593 let claimed = store
1594 .claim_due_runs_with_engine(now, engine.as_ref())
1595 .await
1596 .unwrap();
1597 assert!(claimed.is_empty());
1598
1599 let updated = store.get_schedule(&created.id).await.unwrap();
1600 assert_eq!(updated.state.running_run_count, 1);
1601 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1602 assert_eq!(updated.state.queued_run_count, 0);
1603 }
1604
1605 #[tokio::test]
1606 async fn overlap_queue_one_does_not_add_more_than_one_pending_run() {
1607 let dir = tempdir().unwrap();
1608 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1609 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1610 .unwrap()
1611 .with_timezone(&Utc);
1612
1613 let created = store
1614 .create_schedule_with_definition(
1615 "queue-one".to_string(),
1616 true,
1617 ScheduleRunConfig::default(),
1618 ScheduleDefinitionChanges {
1619 trigger: Some(ScheduleTrigger::Interval {
1620 every_seconds: 300,
1621 anchor_at: None,
1622 }),
1623 overlap_policy: Some(OverlapPolicy::QueueOne),
1624 ..Default::default()
1625 },
1626 )
1627 .await
1628 .unwrap();
1629
1630 store
1631 .update_index(|index| {
1632 let entry = index.schedules.get_mut(&created.id).unwrap();
1633 entry.state.next_fire_at = Some(now);
1634 entry.state.queued_run_count = 1;
1635 Ok(())
1636 })
1637 .await
1638 .unwrap();
1639
1640 let engine = default_trigger_engine();
1641 let claimed = store
1642 .claim_due_runs_with_engine(now, engine.as_ref())
1643 .await
1644 .unwrap();
1645 assert!(claimed.is_empty());
1646
1647 let updated = store.get_schedule(&created.id).await.unwrap();
1648 assert_eq!(updated.state.queued_run_count, 1);
1649 }
1650
1651 #[tokio::test]
1652 async fn overlap_queue_one_limits_catch_up_to_single_pending_run() {
1653 let dir = tempdir().unwrap();
1654 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1655 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1656 .unwrap()
1657 .with_timezone(&Utc);
1658
1659 let created = store
1660 .create_schedule_with_definition(
1661 "queue-one-catchup".to_string(),
1662 true,
1663 ScheduleRunConfig::default(),
1664 ScheduleDefinitionChanges {
1665 trigger: Some(ScheduleTrigger::Interval {
1666 every_seconds: 300,
1667 anchor_at: None,
1668 }),
1669 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1670 overlap_policy: Some(OverlapPolicy::QueueOne),
1671 ..Default::default()
1672 },
1673 )
1674 .await
1675 .unwrap();
1676
1677 store
1678 .update_index(|index| {
1679 let entry = index.schedules.get_mut(&created.id).unwrap();
1680 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1681 Ok(())
1682 })
1683 .await
1684 .unwrap();
1685
1686 let engine = default_trigger_engine();
1687 let claimed = store
1688 .claim_due_runs_with_engine(now, engine.as_ref())
1689 .await
1690 .unwrap();
1691 assert_eq!(claimed.len(), 1);
1692
1693 let updated = store.get_schedule(&created.id).await.unwrap();
1694 assert_eq!(updated.state.queued_run_count, 1);
1695 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1696 }
1697
1698 #[tokio::test]
1699 async fn mark_run_terminal_records_success_accounting() {
1700 let dir = tempdir().unwrap();
1701 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1702
1703 let created = store
1704 .create_schedule(
1705 "success".to_string(),
1706 ScheduleTrigger::Interval {
1707 every_seconds: 60,
1708 anchor_at: None,
1709 },
1710 true,
1711 ScheduleRunConfig::default(),
1712 )
1713 .await
1714 .unwrap();
1715
1716 store
1717 .update_index(|index| {
1718 let entry = index.schedules.get_mut(&created.id).unwrap();
1719 entry.state.running_run_count = 1;
1720 entry.state.consecutive_failures = 2;
1721 Ok(())
1722 })
1723 .await
1724 .unwrap();
1725
1726 store
1727 .mark_run_terminal(&created.id, "run-success", ScheduleRunStatus::Success, None)
1728 .await
1729 .unwrap();
1730
1731 let updated = store.get_schedule(&created.id).await.unwrap();
1732 assert_eq!(updated.state.running_run_count, 0);
1733 assert!(updated.state.last_finished_at.is_some());
1734 assert!(updated.state.last_success_at.is_some());
1735 assert_eq!(updated.state.total_run_count, 1);
1736 assert_eq!(updated.state.total_success_count, 1);
1737 assert_eq!(updated.state.total_failure_count, 0);
1738 assert_eq!(updated.state.consecutive_failures, 0);
1739 }
1740
1741 #[tokio::test]
1742 async fn mark_run_terminal_records_failure_accounting() {
1743 let dir = tempdir().unwrap();
1744 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1745
1746 let created = store
1747 .create_schedule(
1748 "failure".to_string(),
1749 ScheduleTrigger::Interval {
1750 every_seconds: 60,
1751 anchor_at: None,
1752 },
1753 true,
1754 ScheduleRunConfig::default(),
1755 )
1756 .await
1757 .unwrap();
1758
1759 store
1760 .update_index(|index| {
1761 let entry = index.schedules.get_mut(&created.id).unwrap();
1762 entry.state.running_run_count = 1;
1763 Ok(())
1764 })
1765 .await
1766 .unwrap();
1767
1768 store
1769 .mark_run_terminal(&created.id, "run-failed", ScheduleRunStatus::Failed, None)
1770 .await
1771 .unwrap();
1772 store
1773 .update_index(|index| {
1774 let entry = index.schedules.get_mut(&created.id).unwrap();
1775 entry.state.running_run_count = 1;
1776 Ok(())
1777 })
1778 .await
1779 .unwrap();
1780 store
1781 .mark_run_terminal(
1782 &created.id,
1783 "run-cancelled",
1784 ScheduleRunStatus::Cancelled,
1785 None,
1786 )
1787 .await
1788 .unwrap();
1789
1790 let updated = store.get_schedule(&created.id).await.unwrap();
1791 assert_eq!(updated.state.running_run_count, 0);
1792 assert!(updated.state.last_finished_at.is_some());
1793 assert!(updated.state.last_failure_at.is_some());
1794 assert_eq!(updated.state.total_run_count, 2);
1795 assert_eq!(updated.state.total_success_count, 0);
1796 assert_eq!(updated.state.total_failure_count, 2);
1797 assert_eq!(updated.state.consecutive_failures, 2);
1798 }
1799
1800 #[tokio::test]
1801 async fn mark_run_dequeued_without_start_counts_missed_occurrence() {
1802 let dir = tempdir().unwrap();
1803 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1804
1805 let created = store
1806 .create_schedule(
1807 "missed".to_string(),
1808 ScheduleTrigger::Interval {
1809 every_seconds: 60,
1810 anchor_at: None,
1811 },
1812 true,
1813 ScheduleRunConfig::default(),
1814 )
1815 .await
1816 .unwrap();
1817
1818 store
1819 .update_index(|index| {
1820 let entry = index.schedules.get_mut(&created.id).unwrap();
1821 entry.state.queued_run_count = 1;
1822 Ok(())
1823 })
1824 .await
1825 .unwrap();
1826
1827 store
1828 .mark_run_dequeued_without_start(&created.id, "run-missed", None)
1829 .await
1830 .unwrap();
1831
1832 let updated = store.get_schedule(&created.id).await.unwrap();
1833 assert_eq!(updated.state.queued_run_count, 0);
1834 assert_eq!(updated.state.total_missed_count, 1);
1835 let record = store
1836 .get_run_record("run-missed")
1837 .await
1838 .expect("run record should be created for missed dequeue");
1839 assert_eq!(record.status, ScheduleRunStatus::Missed);
1840 assert!(record.completed_at.is_some());
1841 }
1842
1843 #[tokio::test]
1844 async fn create_run_now_persists_queued_run_record() {
1845 let dir = tempdir().unwrap();
1846 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1847
1848 let created = store
1849 .create_schedule(
1850 "run-now".to_string(),
1851 ScheduleTrigger::Interval {
1852 every_seconds: 60,
1853 anchor_at: None,
1854 },
1855 true,
1856 ScheduleRunConfig::default(),
1857 )
1858 .await
1859 .unwrap();
1860
1861 let claimed = store
1862 .create_run_now(&created.id)
1863 .await
1864 .unwrap()
1865 .expect("run descriptor should be created");
1866
1867 let record = store
1868 .get_run_record(&claimed.run_id)
1869 .await
1870 .expect("queued run record should exist");
1871 assert_eq!(record.schedule_id, created.id);
1872 assert_eq!(record.status, ScheduleRunStatus::Queued);
1873 assert_eq!(record.claimed_at, claimed.claimed_at);
1874 assert_eq!(record.scheduled_for, claimed.scheduled_for);
1875 assert!(!claimed.was_catch_up);
1876 }
1877
1878 #[tokio::test]
1879 async fn mark_run_started_and_terminal_updates_run_record_fields() {
1880 let dir = tempdir().unwrap();
1881 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1882
1883 let created = store
1884 .create_schedule(
1885 "run-record-lifecycle".to_string(),
1886 ScheduleTrigger::Interval {
1887 every_seconds: 60,
1888 anchor_at: None,
1889 },
1890 true,
1891 ScheduleRunConfig::default(),
1892 )
1893 .await
1894 .unwrap();
1895
1896 let claimed = store
1897 .create_run_now(&created.id)
1898 .await
1899 .unwrap()
1900 .expect("run descriptor should be created");
1901
1902 store
1903 .mark_run_started(&created.id, &claimed.run_id)
1904 .await
1905 .unwrap();
1906 store
1907 .bind_run_session(&created.id, &claimed.run_id, "session-1")
1908 .await
1909 .unwrap();
1910 store
1911 .mark_run_terminal(
1912 &created.id,
1913 &claimed.run_id,
1914 ScheduleRunStatus::Success,
1915 Some("ok".to_string()),
1916 )
1917 .await
1918 .unwrap();
1919
1920 let record = store
1921 .get_run_record(&claimed.run_id)
1922 .await
1923 .expect("run record should exist");
1924 assert_eq!(record.status, ScheduleRunStatus::Success);
1925 assert!(record.started_at.is_some());
1926 assert!(record.completed_at.is_some());
1927 assert_eq!(record.session_id.as_deref(), Some("session-1"));
1928 assert!(record.dispatch_lag_ms.is_some());
1929 assert!(record.execution_duration_ms.is_some());
1930 assert_eq!(record.outcome_reason.as_deref(), Some("ok"));
1931 }
1932
1933 #[tokio::test]
1934 async fn delete_schedule_removes_associated_run_records() {
1935 let dir = tempdir().unwrap();
1936 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1937
1938 let created = store
1939 .create_schedule(
1940 "cleanup-history".to_string(),
1941 ScheduleTrigger::Interval {
1942 every_seconds: 60,
1943 anchor_at: None,
1944 },
1945 true,
1946 ScheduleRunConfig::default(),
1947 )
1948 .await
1949 .unwrap();
1950
1951 let claimed = store
1952 .create_run_now(&created.id)
1953 .await
1954 .unwrap()
1955 .expect("run descriptor should be created");
1956 assert!(store.get_run_record(&claimed.run_id).await.is_some());
1957
1958 let deleted = store.delete_schedule(&created.id).await.unwrap();
1959 assert!(deleted);
1960 assert!(store.get_run_record(&claimed.run_id).await.is_none());
1961 }
1962}