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, ProjectId, ScheduleRunConfig, ScheduleRunRecord,
15 ScheduleRunStatus, 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.patch_schedule_with_definition_inner(id, name, enabled, run_config, definition, None)
895 .await
896 }
897
898 pub async fn patch_schedule_with_definition_in_project(
902 &self,
903 id: &str,
904 name: Option<String>,
905 enabled: Option<bool>,
906 run_config: Option<ScheduleRunConfig>,
907 definition: ScheduleDefinitionChanges,
908 expected_project_id: Option<&ProjectId>,
909 ) -> io::Result<Option<ScheduleEntry>> {
910 self.patch_schedule_with_definition_inner(
911 id,
912 name,
913 enabled,
914 run_config,
915 definition,
916 Some(expected_project_id.cloned()),
917 )
918 .await
919 }
920
921 async fn patch_schedule_with_definition_inner(
922 &self,
923 id: &str,
924 name: Option<String>,
925 enabled: Option<bool>,
926 run_config: Option<ScheduleRunConfig>,
927 definition: ScheduleDefinitionChanges,
928 expected_project_id: Option<Option<ProjectId>>,
929 ) -> io::Result<Option<ScheduleEntry>> {
930 self.update_index(|index| {
931 let Some(existing) = index.schedules.get_mut(id) else {
932 return Ok(None);
933 };
934 if expected_project_id.as_ref().is_some_and(|expected| {
935 existing.run_config.project_id.as_ref() != expected.as_ref()
936 }) {
937 return Ok(None);
938 }
939 let now = Utc::now();
940 if let Some(name) = name {
941 existing.name = name;
942 }
943 if let Some(enabled) = enabled {
944 existing.enabled = enabled;
945 }
946
947 let trigger = normalize_trigger_for_storage(
948 definition
949 .trigger
950 .clone()
951 .unwrap_or_else(|| existing.trigger.clone()),
952 existing.derived_anchor_at().unwrap_or(now),
953 );
954 existing.trigger = trigger.clone();
955
956 if let Some(timezone) = definition.timezone {
957 existing.timezone = normalize_optional_string(Some(timezone));
958 }
959 if let Some(start_at) = definition.start_at {
960 existing.start_at = Some(start_at);
961 }
962 if let Some(end_at) = definition.end_at {
963 existing.end_at = Some(end_at);
964 }
965 if let Some(misfire_policy) = definition.misfire_policy {
966 existing.misfire_policy = misfire_policy;
967 }
968 if let Some(overlap_policy) = definition.overlap_policy {
969 existing.overlap_policy = overlap_policy;
970 }
971 if let Some(run_config) = run_config {
972 existing.run_config = run_config;
973 }
974
975 let window = ScheduleWindow {
976 start_at: existing.start_at,
977 end_at: existing.end_at,
978 };
979 existing.state.next_fire_at = Some(compute_initial_next_run_at(
980 &trigger,
981 existing.timezone.as_deref(),
982 &window,
983 now,
984 )?);
985 existing.updated_at = now;
986 Ok(Some(existing.clone()))
987 })
988 .await
989 }
990
991 pub async fn delete_schedule(&self, id: &str) -> io::Result<bool> {
992 self.delete_schedule_inner(id, None).await
993 }
994
995 pub async fn delete_schedule_in_project(
998 &self,
999 id: &str,
1000 expected_project_id: Option<&ProjectId>,
1001 ) -> io::Result<bool> {
1002 self.delete_schedule_inner(id, Some(expected_project_id.cloned()))
1003 .await
1004 }
1005
1006 async fn delete_schedule_inner(
1007 &self,
1008 id: &str,
1009 expected_project_id: Option<Option<ProjectId>>,
1010 ) -> io::Result<bool> {
1011 self.update_index(|index| {
1012 if expected_project_id.as_ref().is_some_and(|expected| {
1013 index
1014 .schedules
1015 .get(id)
1016 .is_none_or(|entry| entry.run_config.project_id.as_ref() != expected.as_ref())
1017 }) {
1018 return Ok(false);
1019 }
1020 let deleted = index.schedules.remove(id).is_some();
1021 if deleted {
1022 index
1023 .run_records
1024 .retain(|_, record| record.schedule_id != id);
1025 }
1026 Ok(deleted)
1027 })
1028 .await
1029 }
1030
1031 pub async fn claim_due_runs(&self, now: DateTime<Utc>) -> io::Result<Vec<ClaimedScheduleRun>> {
1039 let engine = default_trigger_engine();
1040 self.claim_due_runs_with_engine(now, engine.as_ref()).await
1041 }
1042
1043 pub async fn claim_due_runs_with_engine(
1044 &self,
1045 now: DateTime<Utc>,
1046 engine: &dyn TriggerEngine,
1047 ) -> io::Result<Vec<ClaimedScheduleRun>> {
1048 {
1049 let index = self.index.read().await;
1050 let any_due = index.schedules.values().any(|entry| {
1051 entry.enabled && current_due_at(entry).is_some_and(|due_at| due_at <= now)
1052 });
1053 if !any_due {
1054 return Ok(Vec::new());
1055 }
1056 }
1057
1058 self.update_index(|index| {
1059 let mut out = Vec::new();
1060 let (schedules, run_records) = (&mut index.schedules, &mut index.run_records);
1061 for entry in schedules.values_mut() {
1062 if !entry.enabled {
1063 continue;
1064 }
1065 let Some(due_at) = current_due_at(entry) else {
1066 continue;
1067 };
1068 if due_at > now {
1069 continue;
1070 }
1071
1072 let dispatch_count = compute_misfire_dispatch_count(entry, now);
1073 if dispatch_count == 0 {
1074 entry.state.last_scheduled_at = Some(now);
1075 record_missed_occurrence(entry);
1076 match compute_next_run_at_with_engine(entry, now, engine) {
1077 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1078 Ok(None) => {
1079 entry.state.next_fire_at = None;
1080 entry.enabled = false;
1081 }
1082 Err(error) => {
1083 tracing::warn!(
1084 "failed to compute next scheduled fire for {} after misfire skip: {}. falling back to legacy interval semantics",
1085 entry.id,
1086 error
1087 );
1088 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1089 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1090 }
1091 }
1092 entry.updated_at = now;
1093 continue;
1094 }
1095
1096 let blocked = overlap_blocks_dispatch(entry);
1097 match entry.overlap_policy {
1098 OverlapPolicy::Skip if blocked => {
1099 entry.state.last_scheduled_at = Some(now);
1100 record_missed_occurrence(entry);
1101 match compute_next_run_at_with_engine(entry, now, engine) {
1102 Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1103 Ok(None) => {
1104 entry.state.next_fire_at = None;
1105 entry.enabled = false;
1106 }
1107 Err(error) => {
1108 tracing::warn!(
1109 "failed to compute next scheduled fire for {} after overlap skip: {}. falling back to legacy interval semantics",
1110 entry.id,
1111 error
1112 );
1113 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1114 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1115 }
1116 }
1117 entry.updated_at = now;
1118 continue;
1119 }
1120 OverlapPolicy::QueueOne if blocked => {
1121 continue;
1122 }
1123 _ => {}
1124 }
1125
1126 let dispatch_count = apply_overlap_dispatch_limit(entry, dispatch_count);
1127 entry.state.last_scheduled_at = Some(now);
1128 match compute_next_run_at_with_engine(entry, now, engine) {
1129 Ok(Some(next_fire_at)) => {
1130 entry.state.next_fire_at = Some(next_fire_at);
1131 }
1132 Ok(None) => {
1133 entry.state.next_fire_at = None;
1134 entry.enabled = false;
1135 }
1136 Err(error) => {
1137 tracing::warn!(
1138 "failed to compute next scheduled fire for {}: {}. falling back to legacy interval semantics",
1139 entry.id,
1140 error
1141 );
1142 let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1143 entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1144 }
1145 }
1146 entry.state.queued_run_count = entry.state.queued_run_count.saturating_add(dispatch_count);
1147 entry.updated_at = now;
1148 for dispatch_index in 0..dispatch_count {
1149 let scheduled_for = scheduled_for_dispatch(entry, due_at, dispatch_index);
1150 let was_catch_up = scheduled_for < now;
1151 let record = make_queued_run_record(&entry.id, scheduled_for, now, was_catch_up);
1152 let run_id = record.run_id.clone();
1153 run_records.insert(run_id.clone(), record);
1154 out.push(ClaimedScheduleRun {
1155 run_id,
1156 schedule_id: entry.id.clone(),
1157 schedule_name: entry.name.clone(),
1158 run_config: entry.run_config.clone(),
1159 scheduled_for,
1160 claimed_at: now,
1161 was_catch_up,
1162 });
1163 }
1164 }
1165 prune_run_records(run_records);
1166 Ok(out)
1167 })
1168 .await
1169 }
1170
1171 pub async fn mark_run_started(&self, schedule_id: &str, run_id: &str) -> io::Result<()> {
1172 self.update_index(|index| {
1173 let now = Utc::now();
1174 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1175 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1176 entry.state.running_run_count = entry.state.running_run_count.saturating_add(1);
1177 entry.state.last_started_at = Some(now);
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_started(record, now, None);
1187 Ok(())
1188 })
1189 .await
1190 }
1191
1192 pub async fn bind_run_session(
1193 &self,
1194 schedule_id: &str,
1195 run_id: &str,
1196 session_id: &str,
1197 ) -> io::Result<()> {
1198 self.update_index(|index| {
1199 let now = Utc::now();
1200 let record = index
1201 .run_records
1202 .entry(run_id.to_string())
1203 .or_insert_with(|| {
1204 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Running)
1205 });
1206 record.session_id = Some(session_id.to_string());
1207 Ok(())
1208 })
1209 .await
1210 }
1211
1212 pub async fn mark_run_terminal(
1213 &self,
1214 schedule_id: &str,
1215 run_id: &str,
1216 status: ScheduleRunStatus,
1217 outcome_reason: Option<String>,
1218 ) -> io::Result<()> {
1219 self.update_index(|index| {
1220 let now = Utc::now();
1221 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1222 apply_terminal_run_status(entry, status, now)?;
1223 entry.updated_at = now;
1224 }
1225 let record = index
1226 .run_records
1227 .entry(run_id.to_string())
1228 .or_insert_with(|| make_fallback_run_record(run_id, schedule_id, now, status));
1229 update_run_record_terminal(record, status, now, outcome_reason, None);
1230 Ok(())
1231 })
1232 .await
1233 }
1234
1235 pub async fn mark_run_dequeued_without_start(
1236 &self,
1237 schedule_id: &str,
1238 run_id: &str,
1239 outcome_reason: Option<String>,
1240 ) -> io::Result<()> {
1241 self.update_index(|index| {
1242 let now = Utc::now();
1243 if let Some(entry) = index.schedules.get_mut(schedule_id) {
1244 entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1245 record_missed_occurrence(entry);
1246 entry.updated_at = now;
1247 }
1248 let record = index
1249 .run_records
1250 .entry(run_id.to_string())
1251 .or_insert_with(|| {
1252 make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1253 });
1254 update_run_record_terminal(
1255 record,
1256 ScheduleRunStatus::Missed,
1257 now,
1258 outcome_reason,
1259 None,
1260 );
1261 Ok(())
1262 })
1263 .await
1264 }
1265
1266 pub async fn create_run_now(&self, id: &str) -> io::Result<Option<ClaimedScheduleRun>> {
1268 self.create_run_now_inner(id, None, None).await
1269 }
1270
1271 pub async fn create_run_now_in_project(
1274 &self,
1275 id: &str,
1276 expected_project_id: Option<&ProjectId>,
1277 ) -> io::Result<Option<ClaimedScheduleRun>> {
1278 self.create_run_now_inner(id, Some(expected_project_id.cloned()), None)
1279 .await
1280 }
1281
1282 pub async fn create_run_now_if_config(
1288 &self,
1289 id: &str,
1290 expected_run_config: &ScheduleRunConfig,
1291 ) -> io::Result<Option<ClaimedScheduleRun>> {
1292 self.create_run_now_inner(id, None, Some(expected_run_config.clone()))
1293 .await
1294 }
1295
1296 async fn create_run_now_inner(
1297 &self,
1298 id: &str,
1299 expected_project_id: Option<Option<ProjectId>>,
1300 expected_run_config: Option<ScheduleRunConfig>,
1301 ) -> io::Result<Option<ClaimedScheduleRun>> {
1302 self.update_index(|index| {
1303 let Some(entry) = index.schedules.get(id).cloned() else {
1304 return Ok(None);
1305 };
1306 if expected_project_id
1307 .as_ref()
1308 .is_some_and(|expected| entry.run_config.project_id.as_ref() != expected.as_ref())
1309 || expected_run_config
1310 .as_ref()
1311 .is_some_and(|expected| &entry.run_config != expected)
1312 {
1313 return Ok(None);
1314 }
1315 let now = Utc::now();
1316 let record = make_queued_run_record(&entry.id, now, now, false);
1317 let run_id = record.run_id.clone();
1318 index.run_records.insert(run_id.clone(), record);
1319 prune_run_records(&mut index.run_records);
1320 Ok(Some(ClaimedScheduleRun {
1321 run_id,
1322 schedule_id: entry.id,
1323 schedule_name: entry.name,
1324 run_config: entry.run_config,
1325 scheduled_for: now,
1326 claimed_at: now,
1327 was_catch_up: false,
1328 }))
1329 })
1330 .await
1331 }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336 use super::*;
1337 use tempfile::tempdir;
1338
1339 fn run_record(
1340 run_id: &str,
1341 schedule_id: &str,
1342 status: ScheduleRunStatus,
1343 completed_at: Option<DateTime<Utc>>,
1344 ) -> ScheduleRunRecord {
1345 let base = Utc::now();
1346 ScheduleRunRecord {
1347 run_id: run_id.to_string(),
1348 schedule_id: schedule_id.to_string(),
1349 scheduled_for: base,
1350 claimed_at: base,
1351 started_at: None,
1352 completed_at,
1353 status,
1354 outcome_reason: None,
1355 session_id: None,
1356 dispatch_lag_ms: None,
1357 execution_duration_ms: None,
1358 was_catch_up: false,
1359 }
1360 }
1361
1362 #[test]
1363 fn prune_caps_terminal_records_keeps_newest_and_all_in_flight() {
1364 let mut records: HashMap<String, ScheduleRunRecord> = HashMap::new();
1365 let base = Utc::now();
1366
1367 let total_terminal = MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE + 50;
1369 for i in 0..total_terminal {
1370 let id = format!("s1-term-{i}");
1371 let completed = base + Duration::seconds(i as i64);
1372 records.insert(
1373 id.clone(),
1374 run_record(&id, "s1", ScheduleRunStatus::Success, Some(completed)),
1375 );
1376 }
1377 records.insert(
1379 "s1-queued".into(),
1380 run_record("s1-queued", "s1", ScheduleRunStatus::Queued, None),
1381 );
1382 records.insert(
1383 "s1-running".into(),
1384 run_record("s1-running", "s1", ScheduleRunStatus::Running, None),
1385 );
1386 records.insert(
1388 "s2-term-0".into(),
1389 run_record("s2-term-0", "s2", ScheduleRunStatus::Failed, Some(base)),
1390 );
1391
1392 prune_run_records(&mut records);
1393
1394 let s1_terminal = records
1395 .values()
1396 .filter(|r| r.schedule_id == "s1" && r.status.is_terminal())
1397 .count();
1398 assert_eq!(s1_terminal, MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE);
1399 assert!(records.contains_key("s1-queued"));
1401 assert!(records.contains_key("s1-running"));
1402 assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1404 assert!(!records.contains_key("s1-term-0"));
1405 assert!(records.contains_key("s2-term-0"));
1407 }
1408
1409 #[tokio::test]
1410 async fn store_backfills_legacy_interval_trigger_on_load() {
1411 let dir = tempdir().unwrap();
1412 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1413 .unwrap()
1414 .with_timezone(&Utc);
1415 let next_run_at = DateTime::parse_from_rfc3339("2026-04-04T11:00:00Z")
1416 .unwrap()
1417 .with_timezone(&Utc);
1418
1419 let raw = serde_json::json!({
1420 "version": 1,
1421 "updated_at": now,
1422 "schedules": {
1423 "legacy-1": {
1424 "id": "legacy-1",
1425 "name": "legacy",
1426 "enabled": true,
1427 "interval_seconds": 3600,
1428 "created_at": now,
1429 "updated_at": now,
1430 "last_run_at": null,
1431 "next_run_at": next_run_at,
1432 "run_config": { "auto_execute": false }
1433 }
1434 }
1435 });
1436 tokio::fs::write(
1437 dir.path().join("schedules.json"),
1438 serde_json::to_vec_pretty(&raw).unwrap(),
1439 )
1440 .await
1441 .unwrap();
1442
1443 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1444 let schedule = store.get_schedule("legacy-1").await.unwrap();
1445 assert!(matches!(
1446 schedule.trigger,
1447 ScheduleTrigger::Interval {
1448 every_seconds: 3600,
1449 ..
1450 }
1451 ));
1452 }
1453
1454 #[tokio::test]
1455 async fn create_schedule_with_definition_persists_interval_trigger_metadata() {
1456 let dir = tempdir().unwrap();
1457 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1458
1459 let created = store
1460 .create_schedule_with_definition(
1461 "interval".to_string(),
1462 true,
1463 ScheduleRunConfig::default(),
1464 ScheduleDefinitionChanges {
1465 trigger: Some(ScheduleTrigger::Interval {
1466 every_seconds: 300,
1467 anchor_at: None,
1468 }),
1469 timezone: Some("Asia/Shanghai".to_string()),
1470 misfire_policy: Some(MisFirePolicy::RunOnce),
1471 overlap_policy: Some(OverlapPolicy::QueueOne),
1472 ..Default::default()
1473 },
1474 )
1475 .await
1476 .unwrap();
1477
1478 assert!(matches!(
1479 created.trigger,
1480 ScheduleTrigger::Interval {
1481 every_seconds: 300,
1482 ..
1483 }
1484 ));
1485 assert_eq!(created.timezone.as_deref(), Some("Asia/Shanghai"));
1486 assert_eq!(created.misfire_policy, MisFirePolicy::RunOnce);
1487 assert_eq!(created.overlap_policy, OverlapPolicy::QueueOne);
1488 }
1489
1490 #[tokio::test]
1491 async fn schedule_project_identity_survives_store_restart() {
1492 let dir = tempdir().unwrap();
1493 let project_id: bamboo_domain::ProjectId = "project-scheduled".parse().unwrap();
1494 let schedule_id = {
1495 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1496 store
1497 .create_schedule(
1498 "project schedule".to_string(),
1499 ScheduleTrigger::Interval {
1500 every_seconds: 300,
1501 anchor_at: None,
1502 },
1503 true,
1504 ScheduleRunConfig {
1505 project_id: Some(project_id.clone()),
1506 ..ScheduleRunConfig::default()
1507 },
1508 )
1509 .await
1510 .unwrap()
1511 .id
1512 };
1513
1514 let reopened = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1515 let persisted = reopened.get_schedule(&schedule_id).await.unwrap();
1516 assert_eq!(persisted.run_config.project_id, Some(project_id));
1517 }
1518
1519 #[tokio::test]
1520 async fn patch_schedule_with_definition_updates_interval_trigger_metadata() {
1521 let dir = tempdir().unwrap();
1522 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1523
1524 let created = store
1525 .create_schedule(
1526 "interval".to_string(),
1527 ScheduleTrigger::Interval {
1528 every_seconds: 300,
1529 anchor_at: None,
1530 },
1531 true,
1532 ScheduleRunConfig::default(),
1533 )
1534 .await
1535 .unwrap();
1536
1537 let patched = store
1538 .patch_schedule_with_definition(
1539 &created.id,
1540 None,
1541 None,
1542 None,
1543 ScheduleDefinitionChanges {
1544 trigger: Some(ScheduleTrigger::Interval {
1545 every_seconds: 600,
1546 anchor_at: None,
1547 }),
1548 timezone: Some("UTC".to_string()),
1549 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1550 overlap_policy: Some(OverlapPolicy::Skip),
1551 ..Default::default()
1552 },
1553 )
1554 .await
1555 .unwrap()
1556 .unwrap();
1557
1558 assert!(matches!(
1559 patched.trigger,
1560 ScheduleTrigger::Interval {
1561 every_seconds: 600,
1562 ..
1563 }
1564 ));
1565 assert_eq!(patched.timezone.as_deref(), Some("UTC"));
1566 assert_eq!(patched.misfire_policy, MisFirePolicy::CatchUpAll);
1567 assert_eq!(patched.overlap_policy, OverlapPolicy::Skip);
1568 }
1569
1570 #[tokio::test]
1571 async fn claim_due_runs_with_engine_uses_runtime_adapter_for_interval_trigger() {
1572 let dir = tempdir().unwrap();
1573 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1574
1575 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1576 .unwrap()
1577 .with_timezone(&Utc);
1578
1579 let created = store
1580 .create_schedule_with_definition(
1581 "interval".to_string(),
1582 true,
1583 ScheduleRunConfig::default(),
1584 ScheduleDefinitionChanges {
1585 trigger: Some(ScheduleTrigger::Interval {
1586 every_seconds: 300,
1587 anchor_at: Some(now - Duration::seconds(300)),
1588 }),
1589 ..Default::default()
1590 },
1591 )
1592 .await
1593 .unwrap();
1594
1595 store
1596 .update_index(|index| {
1597 let entry = index.schedules.get_mut(&created.id).unwrap();
1598 entry.state.next_fire_at = Some(now);
1599 Ok(())
1600 })
1601 .await
1602 .unwrap();
1603
1604 let engine = default_trigger_engine();
1605 let claimed = store
1606 .claim_due_runs_with_engine(now, engine.as_ref())
1607 .await
1608 .unwrap();
1609 assert_eq!(claimed.len(), 1);
1610
1611 let updated = store.get_schedule(&created.id).await.unwrap();
1612 assert_eq!(updated.state.last_scheduled_at, Some(now));
1613 assert_eq!(
1614 updated.state.next_fire_at,
1615 Some(now + Duration::seconds(300))
1616 );
1617 }
1618
1619 #[tokio::test]
1620 async fn create_schedule_with_definition_initializes_monthly_next_run() {
1621 let dir = tempdir().unwrap();
1622 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1623
1624 let created = store
1625 .create_schedule_with_definition(
1626 "monthly".to_string(),
1627 true,
1628 ScheduleRunConfig::default(),
1629 ScheduleDefinitionChanges {
1630 trigger: Some(ScheduleTrigger::Monthly {
1631 days: vec![1, 15],
1632 hour: 9,
1633 minute: 0,
1634 second: 0,
1635 }),
1636 timezone: Some("UTC".to_string()),
1637 ..Default::default()
1638 },
1639 )
1640 .await
1641 .unwrap();
1642
1643 assert!(matches!(
1644 created.trigger,
1645 ScheduleTrigger::Monthly {
1646 days,
1647 hour: 9,
1648 minute: 0,
1649 second: 0
1650 } if days == vec![1, 15]
1651 ));
1652 assert!(created
1653 .state
1654 .next_fire_at
1655 .is_some_and(|next| next > created.created_at));
1656 }
1657
1658 #[tokio::test]
1659 async fn create_schedule_with_definition_initializes_once_next_run_at_trigger_instant() {
1660 let dir = tempdir().unwrap();
1661 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1662
1663 let at = Utc::now() + Duration::seconds(600);
1664 let created = store
1665 .create_schedule_with_definition(
1666 "once".to_string(),
1667 true,
1668 ScheduleRunConfig::default(),
1669 ScheduleDefinitionChanges {
1670 trigger: Some(ScheduleTrigger::Once { at }),
1671 ..Default::default()
1672 },
1673 )
1674 .await
1675 .unwrap();
1676
1677 assert!(matches!(created.trigger, ScheduleTrigger::Once { at: t } if t == at));
1678 assert_eq!(created.state.next_fire_at, Some(at));
1679 }
1680
1681 #[tokio::test]
1682 async fn claimed_once_run_does_not_produce_a_second_due_run() {
1683 let dir = tempdir().unwrap();
1684 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1685
1686 let at = Utc::now() + Duration::seconds(600);
1687 let created = store
1688 .create_schedule_with_definition(
1689 "once".to_string(),
1690 true,
1691 ScheduleRunConfig::default(),
1692 ScheduleDefinitionChanges {
1693 trigger: Some(ScheduleTrigger::Once { at }),
1694 ..Default::default()
1695 },
1696 )
1697 .await
1698 .unwrap();
1699 assert_eq!(created.state.next_fire_at, Some(at));
1700
1701 let engine = default_trigger_engine();
1702
1703 let claimed = store
1705 .claim_due_runs_with_engine(at - Duration::seconds(1), engine.as_ref())
1706 .await
1707 .unwrap();
1708 assert!(claimed.is_empty());
1709
1710 let claimed = store
1713 .claim_due_runs_with_engine(at, engine.as_ref())
1714 .await
1715 .unwrap();
1716 assert_eq!(claimed.len(), 1);
1717 assert_eq!(claimed[0].scheduled_for, at);
1718
1719 let updated = store.get_schedule(&created.id).await.unwrap();
1720 assert_eq!(updated.state.next_fire_at, None);
1721 assert!(!updated.enabled);
1722
1723 let claimed = store
1725 .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1726 .await
1727 .unwrap();
1728 assert!(claimed.is_empty());
1729 let claimed = store
1730 .claim_due_runs_with_engine(at + Duration::seconds(3600), engine.as_ref())
1731 .await
1732 .unwrap();
1733 assert!(claimed.is_empty());
1734 }
1735
1736 #[tokio::test]
1737 async fn fired_once_schedule_is_not_rearmed_on_reload() {
1738 let dir = tempdir().unwrap();
1739 let at = Utc::now() + Duration::seconds(600);
1740 let created_id;
1741 {
1742 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1743 let created = store
1744 .create_schedule_with_definition(
1745 "once".to_string(),
1746 true,
1747 ScheduleRunConfig::default(),
1748 ScheduleDefinitionChanges {
1749 trigger: Some(ScheduleTrigger::Once { at }),
1750 ..Default::default()
1751 },
1752 )
1753 .await
1754 .unwrap();
1755 created_id = created.id.clone();
1756
1757 let engine = default_trigger_engine();
1758 let claimed = store
1759 .claim_due_runs_with_engine(at, engine.as_ref())
1760 .await
1761 .unwrap();
1762 assert_eq!(claimed.len(), 1);
1763 }
1764
1765 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1768 let reloaded = store.get_schedule(&created_id).await.unwrap();
1769 assert_eq!(reloaded.state.next_fire_at, None);
1770 assert!(!reloaded.enabled);
1771
1772 let engine = default_trigger_engine();
1773 let claimed = store
1774 .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1775 .await
1776 .unwrap();
1777 assert!(claimed.is_empty());
1778 }
1779
1780 #[tokio::test]
1781 async fn misfire_skip_does_not_dispatch_run() {
1782 let dir = tempdir().unwrap();
1783 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1784 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1785 .unwrap()
1786 .with_timezone(&Utc);
1787
1788 let created = store
1789 .create_schedule_with_definition(
1790 "skip".to_string(),
1791 true,
1792 ScheduleRunConfig::default(),
1793 ScheduleDefinitionChanges {
1794 trigger: Some(ScheduleTrigger::Interval {
1795 every_seconds: 300,
1796 anchor_at: None,
1797 }),
1798 misfire_policy: Some(MisFirePolicy::Skip),
1799 ..Default::default()
1800 },
1801 )
1802 .await
1803 .unwrap();
1804
1805 store
1806 .update_index(|index| {
1807 let entry = index.schedules.get_mut(&created.id).unwrap();
1808 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1809 Ok(())
1810 })
1811 .await
1812 .unwrap();
1813
1814 let engine = default_trigger_engine();
1815 let claimed = store
1816 .claim_due_runs_with_engine(now, engine.as_ref())
1817 .await
1818 .unwrap();
1819 assert!(claimed.is_empty());
1820
1821 let updated = store.get_schedule(&created.id).await.unwrap();
1822 assert_eq!(updated.state.last_scheduled_at, Some(now));
1823 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1824 assert_eq!(updated.state.queued_run_count, 0);
1825 }
1826
1827 #[tokio::test]
1828 async fn overlap_skip_does_not_dispatch_when_running() {
1829 let dir = tempdir().unwrap();
1830 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1831 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1832 .unwrap()
1833 .with_timezone(&Utc);
1834
1835 let created = store
1836 .create_schedule_with_definition(
1837 "skip-overlap".to_string(),
1838 true,
1839 ScheduleRunConfig::default(),
1840 ScheduleDefinitionChanges {
1841 trigger: Some(ScheduleTrigger::Interval {
1842 every_seconds: 300,
1843 anchor_at: None,
1844 }),
1845 overlap_policy: Some(OverlapPolicy::Skip),
1846 ..Default::default()
1847 },
1848 )
1849 .await
1850 .unwrap();
1851
1852 store
1853 .update_index(|index| {
1854 let entry = index.schedules.get_mut(&created.id).unwrap();
1855 entry.state.next_fire_at = Some(now);
1856 entry.state.running_run_count = 1;
1857 Ok(())
1858 })
1859 .await
1860 .unwrap();
1861
1862 let engine = default_trigger_engine();
1863 let claimed = store
1864 .claim_due_runs_with_engine(now, engine.as_ref())
1865 .await
1866 .unwrap();
1867 assert!(claimed.is_empty());
1868
1869 let updated = store.get_schedule(&created.id).await.unwrap();
1870 assert_eq!(updated.state.running_run_count, 1);
1871 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1872 assert_eq!(updated.state.queued_run_count, 0);
1873 }
1874
1875 #[tokio::test]
1876 async fn overlap_queue_one_does_not_add_more_than_one_pending_run() {
1877 let dir = tempdir().unwrap();
1878 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1879 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1880 .unwrap()
1881 .with_timezone(&Utc);
1882
1883 let created = store
1884 .create_schedule_with_definition(
1885 "queue-one".to_string(),
1886 true,
1887 ScheduleRunConfig::default(),
1888 ScheduleDefinitionChanges {
1889 trigger: Some(ScheduleTrigger::Interval {
1890 every_seconds: 300,
1891 anchor_at: None,
1892 }),
1893 overlap_policy: Some(OverlapPolicy::QueueOne),
1894 ..Default::default()
1895 },
1896 )
1897 .await
1898 .unwrap();
1899
1900 store
1901 .update_index(|index| {
1902 let entry = index.schedules.get_mut(&created.id).unwrap();
1903 entry.state.next_fire_at = Some(now);
1904 entry.state.queued_run_count = 1;
1905 Ok(())
1906 })
1907 .await
1908 .unwrap();
1909
1910 let engine = default_trigger_engine();
1911 let claimed = store
1912 .claim_due_runs_with_engine(now, engine.as_ref())
1913 .await
1914 .unwrap();
1915 assert!(claimed.is_empty());
1916
1917 let updated = store.get_schedule(&created.id).await.unwrap();
1918 assert_eq!(updated.state.queued_run_count, 1);
1919 }
1920
1921 #[tokio::test]
1922 async fn overlap_queue_one_limits_catch_up_to_single_pending_run() {
1923 let dir = tempdir().unwrap();
1924 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1925 let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1926 .unwrap()
1927 .with_timezone(&Utc);
1928
1929 let created = store
1930 .create_schedule_with_definition(
1931 "queue-one-catchup".to_string(),
1932 true,
1933 ScheduleRunConfig::default(),
1934 ScheduleDefinitionChanges {
1935 trigger: Some(ScheduleTrigger::Interval {
1936 every_seconds: 300,
1937 anchor_at: None,
1938 }),
1939 misfire_policy: Some(MisFirePolicy::CatchUpAll),
1940 overlap_policy: Some(OverlapPolicy::QueueOne),
1941 ..Default::default()
1942 },
1943 )
1944 .await
1945 .unwrap();
1946
1947 store
1948 .update_index(|index| {
1949 let entry = index.schedules.get_mut(&created.id).unwrap();
1950 entry.state.next_fire_at = Some(now - Duration::seconds(900));
1951 Ok(())
1952 })
1953 .await
1954 .unwrap();
1955
1956 let engine = default_trigger_engine();
1957 let claimed = store
1958 .claim_due_runs_with_engine(now, engine.as_ref())
1959 .await
1960 .unwrap();
1961 assert_eq!(claimed.len(), 1);
1962
1963 let updated = store.get_schedule(&created.id).await.unwrap();
1964 assert_eq!(updated.state.queued_run_count, 1);
1965 assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1966 }
1967
1968 #[tokio::test]
1969 async fn mark_run_terminal_records_success_accounting() {
1970 let dir = tempdir().unwrap();
1971 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1972
1973 let created = store
1974 .create_schedule(
1975 "success".to_string(),
1976 ScheduleTrigger::Interval {
1977 every_seconds: 60,
1978 anchor_at: None,
1979 },
1980 true,
1981 ScheduleRunConfig::default(),
1982 )
1983 .await
1984 .unwrap();
1985
1986 store
1987 .update_index(|index| {
1988 let entry = index.schedules.get_mut(&created.id).unwrap();
1989 entry.state.running_run_count = 1;
1990 entry.state.consecutive_failures = 2;
1991 Ok(())
1992 })
1993 .await
1994 .unwrap();
1995
1996 store
1997 .mark_run_terminal(&created.id, "run-success", ScheduleRunStatus::Success, None)
1998 .await
1999 .unwrap();
2000
2001 let updated = store.get_schedule(&created.id).await.unwrap();
2002 assert_eq!(updated.state.running_run_count, 0);
2003 assert!(updated.state.last_finished_at.is_some());
2004 assert!(updated.state.last_success_at.is_some());
2005 assert_eq!(updated.state.total_run_count, 1);
2006 assert_eq!(updated.state.total_success_count, 1);
2007 assert_eq!(updated.state.total_failure_count, 0);
2008 assert_eq!(updated.state.consecutive_failures, 0);
2009 }
2010
2011 #[tokio::test]
2012 async fn mark_run_terminal_records_failure_accounting() {
2013 let dir = tempdir().unwrap();
2014 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2015
2016 let created = store
2017 .create_schedule(
2018 "failure".to_string(),
2019 ScheduleTrigger::Interval {
2020 every_seconds: 60,
2021 anchor_at: None,
2022 },
2023 true,
2024 ScheduleRunConfig::default(),
2025 )
2026 .await
2027 .unwrap();
2028
2029 store
2030 .update_index(|index| {
2031 let entry = index.schedules.get_mut(&created.id).unwrap();
2032 entry.state.running_run_count = 1;
2033 Ok(())
2034 })
2035 .await
2036 .unwrap();
2037
2038 store
2039 .mark_run_terminal(&created.id, "run-failed", ScheduleRunStatus::Failed, None)
2040 .await
2041 .unwrap();
2042 store
2043 .update_index(|index| {
2044 let entry = index.schedules.get_mut(&created.id).unwrap();
2045 entry.state.running_run_count = 1;
2046 Ok(())
2047 })
2048 .await
2049 .unwrap();
2050 store
2051 .mark_run_terminal(
2052 &created.id,
2053 "run-cancelled",
2054 ScheduleRunStatus::Cancelled,
2055 None,
2056 )
2057 .await
2058 .unwrap();
2059
2060 let updated = store.get_schedule(&created.id).await.unwrap();
2061 assert_eq!(updated.state.running_run_count, 0);
2062 assert!(updated.state.last_finished_at.is_some());
2063 assert!(updated.state.last_failure_at.is_some());
2064 assert_eq!(updated.state.total_run_count, 2);
2065 assert_eq!(updated.state.total_success_count, 0);
2066 assert_eq!(updated.state.total_failure_count, 2);
2067 assert_eq!(updated.state.consecutive_failures, 2);
2068 }
2069
2070 #[tokio::test]
2071 async fn mark_run_dequeued_without_start_counts_missed_occurrence() {
2072 let dir = tempdir().unwrap();
2073 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2074
2075 let created = store
2076 .create_schedule(
2077 "missed".to_string(),
2078 ScheduleTrigger::Interval {
2079 every_seconds: 60,
2080 anchor_at: None,
2081 },
2082 true,
2083 ScheduleRunConfig::default(),
2084 )
2085 .await
2086 .unwrap();
2087
2088 store
2089 .update_index(|index| {
2090 let entry = index.schedules.get_mut(&created.id).unwrap();
2091 entry.state.queued_run_count = 1;
2092 Ok(())
2093 })
2094 .await
2095 .unwrap();
2096
2097 store
2098 .mark_run_dequeued_without_start(&created.id, "run-missed", None)
2099 .await
2100 .unwrap();
2101
2102 let updated = store.get_schedule(&created.id).await.unwrap();
2103 assert_eq!(updated.state.queued_run_count, 0);
2104 assert_eq!(updated.state.total_missed_count, 1);
2105 let record = store
2106 .get_run_record("run-missed")
2107 .await
2108 .expect("run record should be created for missed dequeue");
2109 assert_eq!(record.status, ScheduleRunStatus::Missed);
2110 assert!(record.completed_at.is_some());
2111 }
2112
2113 #[tokio::test]
2114 async fn create_run_now_persists_queued_run_record() {
2115 let dir = tempdir().unwrap();
2116 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2117
2118 let created = store
2119 .create_schedule(
2120 "run-now".to_string(),
2121 ScheduleTrigger::Interval {
2122 every_seconds: 60,
2123 anchor_at: None,
2124 },
2125 true,
2126 ScheduleRunConfig::default(),
2127 )
2128 .await
2129 .unwrap();
2130
2131 let claimed = store
2132 .create_run_now(&created.id)
2133 .await
2134 .unwrap()
2135 .expect("run descriptor should be created");
2136
2137 let record = store
2138 .get_run_record(&claimed.run_id)
2139 .await
2140 .expect("queued run record should exist");
2141 assert_eq!(record.schedule_id, created.id);
2142 assert_eq!(record.status, ScheduleRunStatus::Queued);
2143 assert_eq!(record.claimed_at, claimed.claimed_at);
2144 assert_eq!(record.scheduled_for, claimed.scheduled_for);
2145 assert!(!claimed.was_catch_up);
2146 }
2147
2148 #[tokio::test]
2149 async fn validated_run_now_rechecks_the_complete_config_under_the_write_lock() {
2150 let dir = tempdir().unwrap();
2151 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2152 let created = store
2153 .create_schedule(
2154 "run-now-config-cas".to_string(),
2155 ScheduleTrigger::Interval {
2156 every_seconds: 60,
2157 anchor_at: None,
2158 },
2159 true,
2160 ScheduleRunConfig {
2161 workspace_path: Some("/validated/workspace".to_string()),
2162 ..Default::default()
2163 },
2164 )
2165 .await
2166 .unwrap();
2167 let validated = created.run_config.clone();
2168
2169 store
2170 .patch_schedule_with_definition(
2171 &created.id,
2172 None,
2173 None,
2174 Some(ScheduleRunConfig {
2175 workspace_path: Some("/concurrently/replaced".to_string()),
2176 ..Default::default()
2177 }),
2178 ScheduleDefinitionChanges::default(),
2179 )
2180 .await
2181 .unwrap()
2182 .expect("schedule remains");
2183
2184 assert!(store
2185 .create_run_now_if_config(&created.id, &validated)
2186 .await
2187 .unwrap()
2188 .is_none());
2189 assert!(store
2190 .list_run_records_for_schedule(&created.id)
2191 .await
2192 .is_empty());
2193 }
2194
2195 #[tokio::test]
2196 async fn project_scoped_mutations_recheck_scope_under_the_write_lock() {
2197 let dir = tempdir().unwrap();
2198 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2199 let owner = ProjectId::parse("project-owner").unwrap();
2200 let foreign = ProjectId::parse("project-foreign").unwrap();
2201 let schedule = store
2202 .create_schedule(
2203 "owned".to_string(),
2204 ScheduleTrigger::Interval {
2205 every_seconds: 60,
2206 anchor_at: None,
2207 },
2208 true,
2209 ScheduleRunConfig {
2210 project_id: Some(owner.clone()),
2211 ..Default::default()
2212 },
2213 )
2214 .await
2215 .unwrap();
2216
2217 let patched = store
2218 .patch_schedule_with_definition_in_project(
2219 &schedule.id,
2220 Some("foreign mutation".to_string()),
2221 Some(false),
2222 None,
2223 ScheduleDefinitionChanges::default(),
2224 Some(&foreign),
2225 )
2226 .await
2227 .unwrap();
2228 assert!(patched.is_none());
2229 assert!(store
2230 .create_run_now_in_project(&schedule.id, Some(&foreign))
2231 .await
2232 .unwrap()
2233 .is_none());
2234 assert!(!store
2235 .delete_schedule_in_project(&schedule.id, Some(&foreign))
2236 .await
2237 .unwrap());
2238
2239 let unchanged = store.get_schedule(&schedule.id).await.unwrap();
2240 assert_eq!(unchanged.name, "owned");
2241 assert!(unchanged.enabled);
2242 assert_eq!(unchanged.run_config.project_id.as_ref(), Some(&owner));
2243 assert!(store
2244 .list_run_records_for_schedule(&schedule.id)
2245 .await
2246 .is_empty());
2247 }
2248
2249 #[tokio::test]
2250 async fn mark_run_started_and_terminal_updates_run_record_fields() {
2251 let dir = tempdir().unwrap();
2252 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2253
2254 let created = store
2255 .create_schedule(
2256 "run-record-lifecycle".to_string(),
2257 ScheduleTrigger::Interval {
2258 every_seconds: 60,
2259 anchor_at: None,
2260 },
2261 true,
2262 ScheduleRunConfig::default(),
2263 )
2264 .await
2265 .unwrap();
2266
2267 let claimed = store
2268 .create_run_now(&created.id)
2269 .await
2270 .unwrap()
2271 .expect("run descriptor should be created");
2272
2273 store
2274 .mark_run_started(&created.id, &claimed.run_id)
2275 .await
2276 .unwrap();
2277 store
2278 .bind_run_session(&created.id, &claimed.run_id, "session-1")
2279 .await
2280 .unwrap();
2281 store
2282 .mark_run_terminal(
2283 &created.id,
2284 &claimed.run_id,
2285 ScheduleRunStatus::Success,
2286 Some("ok".to_string()),
2287 )
2288 .await
2289 .unwrap();
2290
2291 let record = store
2292 .get_run_record(&claimed.run_id)
2293 .await
2294 .expect("run record should exist");
2295 assert_eq!(record.status, ScheduleRunStatus::Success);
2296 assert!(record.started_at.is_some());
2297 assert!(record.completed_at.is_some());
2298 assert_eq!(record.session_id.as_deref(), Some("session-1"));
2299 assert!(record.dispatch_lag_ms.is_some());
2300 assert!(record.execution_duration_ms.is_some());
2301 assert_eq!(record.outcome_reason.as_deref(), Some("ok"));
2302 }
2303
2304 #[tokio::test]
2305 async fn delete_schedule_removes_associated_run_records() {
2306 let dir = tempdir().unwrap();
2307 let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2308
2309 let created = store
2310 .create_schedule(
2311 "cleanup-history".to_string(),
2312 ScheduleTrigger::Interval {
2313 every_seconds: 60,
2314 anchor_at: None,
2315 },
2316 true,
2317 ScheduleRunConfig::default(),
2318 )
2319 .await
2320 .unwrap();
2321
2322 let claimed = store
2323 .create_run_now(&created.id)
2324 .await
2325 .unwrap()
2326 .expect("run descriptor should be created");
2327 assert!(store.get_run_record(&claimed.run_id).await.is_some());
2328
2329 let deleted = store.delete_schedule(&created.id).await.unwrap();
2330 assert!(deleted);
2331 assert!(store.get_run_record(&claimed.run_id).await.is_none());
2332 }
2333}