Skip to main content

bamboo_server/schedule_app/
store.rs

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    // Write + fsync to ensure data is on disk before rename.
26    {
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    // fsync parent directory to persist the rename metadata.
35    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
44/// Remove leftover `.tmp.*` files from a previous interrupted atomic write.
45async 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
278/// Per-schedule cap on retained *terminal* run records. In-flight runs
279/// (Queued/Running) are always kept; only completed history is bounded. Chosen
280/// so a 60s schedule retains ~3.3h of history while keeping the persisted
281/// `schedules.json` (and every rewrite of it) bounded rather than O(total
282/// history) — see issue #233.
283const MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE: usize = 200;
284
285/// Drop the oldest terminal run records once a schedule exceeds
286/// [`MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE`], keeping the most recent by
287/// completion time. Non-terminal runs are never pruned. O(n) in the current
288/// record count; called only at run-insert points, where the count is already
289/// bounded by this same cap.
290fn prune_run_records(run_records: &mut HashMap<String, ScheduleRunRecord>) {
291    // Collect the run_ids to evict without holding a borrow across the removal.
292    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            // Newest first by completion time (falling back to the scheduled
308            // time for records without a completed_at).
309            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                    // Corrupted file (e.g. partial write before crash).
692                    // Back up the broken file and start fresh so the app can boot.
693                    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        // Clean up stale temp files left behind by interrupted atomic writes.
735        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    /// Claim all due schedules and advance their `next_run_at`.
956    ///
957    /// Returns a list of run descriptors to execute out-of-band.
958    ///
959    /// **Important**: only writes to disk when at least one schedule is actually
960    /// due.  The ticker calls this every few seconds, so avoiding unnecessary
961    /// writes is critical for disk health and crash-safety.
962    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    /// Create a run descriptor immediately (does not change the schedule cadence).
1191    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        // MAX + 50 terminal records for schedule "s1", completion times ordered.
1249        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        // In-flight runs must survive regardless of count.
1259        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        // A different schedule under the cap is untouched.
1268        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        // Non-terminal runs kept.
1281        assert!(records.contains_key("s1-queued"));
1282        assert!(records.contains_key("s1-running"));
1283        // The newest terminal record is retained; the oldest is evicted.
1284        assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1285        assert!(!records.contains_key("s1-term-0"));
1286        // Other schedules untouched.
1287        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}