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        // Interval and Once are defined on absolute UTC instants; the engine
352        // rejects a timezone for them, so never pass one through.
353        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    // A `next_fire_at: None` on a one-shot trigger is terminal state (the
421    // single occurrence was already claimed), not missing data — re-arming it
422    // here would make a fired Once schedule fire a second time on reload.
423    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        // Interval and Once are defined on absolute UTC instants; the engine
504        // rejects a timezone for them, so never pass one through.
505        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                    // Corrupted file (e.g. partial write before crash).
700                    // Back up the broken file and start fresh so the app can boot.
701                    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        // Clean up stale temp files left behind by interrupted atomic writes.
743        cleanup_stale_tmp_files(&bamboo_home_dir, "schedules.json.tmp.").await;
744
745        Ok(Self {
746            index_path,
747            index: RwLock::new(index),
748            write_lock: Mutex::new(()),
749        })
750    }
751
752    pub fn index_path(&self) -> &Path {
753        &self.index_path
754    }
755
756    async fn update_index<F, T>(&self, f: F) -> io::Result<T>
757    where
758        F: FnOnce(&mut SchedulesIndex) -> io::Result<T>,
759    {
760        let _guard = self.write_lock.lock().await;
761        let mut index = self.index.write().await;
762        let out = f(&mut index)?;
763        index.updated_at = Utc::now();
764        atomic_write_json(
765            &self.index_path,
766            serde_json::to_vec_pretty(&*index).map_err(|e| other_io_error(e.to_string()))?,
767        )
768        .await?;
769        Ok(out)
770    }
771
772    pub async fn list_schedules(&self) -> Vec<ScheduleEntry> {
773        let index = self.index.read().await;
774        let mut items: Vec<_> = index.schedules.values().cloned().collect();
775        items.sort_by_key(|e| Reverse(e.updated_at));
776        items
777    }
778
779    pub async fn get_schedule(&self, id: &str) -> Option<ScheduleEntry> {
780        let index = self.index.read().await;
781        index.schedules.get(id).cloned()
782    }
783
784    pub async fn get_run_record(&self, run_id: &str) -> Option<ScheduleRunRecord> {
785        let index = self.index.read().await;
786        index.run_records.get(run_id).cloned()
787    }
788
789    pub async fn list_run_records_for_schedule(&self, schedule_id: &str) -> Vec<ScheduleRunRecord> {
790        let index = self.index.read().await;
791        let mut items = index
792            .run_records
793            .values()
794            .filter(|record| record.schedule_id == schedule_id)
795            .cloned()
796            .collect::<Vec<_>>();
797        items.sort_by_key(|r| Reverse(r.claimed_at));
798        items
799    }
800
801    pub async fn create_schedule(
802        &self,
803        name: String,
804        trigger: ScheduleTrigger,
805        enabled: bool,
806        run_config: ScheduleRunConfig,
807    ) -> io::Result<ScheduleEntry> {
808        self.create_schedule_with_definition(
809            name,
810            enabled,
811            run_config,
812            ScheduleDefinitionChanges {
813                trigger: Some(trigger),
814                ..Default::default()
815            },
816        )
817        .await
818    }
819
820    pub async fn create_schedule_with_definition(
821        &self,
822        name: String,
823        enabled: bool,
824        run_config: ScheduleRunConfig,
825        definition: ScheduleDefinitionChanges,
826    ) -> io::Result<ScheduleEntry> {
827        let now = Utc::now();
828        let id = Uuid::new_v4().to_string();
829        let window = definition_window(&definition);
830        let trigger = normalize_trigger_for_storage(
831            definition
832                .trigger
833                .ok_or_else(|| other_io_error("schedule trigger is required"))?,
834            now,
835        );
836        let timezone = normalize_optional_string(definition.timezone);
837        let next_fire_at =
838            compute_initial_next_run_at(&trigger, timezone.as_deref(), &window, now)?;
839        let entry = ScheduleEntry {
840            id: id.clone(),
841            name,
842            enabled,
843            trigger,
844            timezone,
845            start_at: definition.start_at,
846            end_at: definition.end_at,
847            misfire_policy: definition.misfire_policy.unwrap_or_default(),
848            overlap_policy: definition.overlap_policy.unwrap_or_default(),
849            created_at: now,
850            updated_at: now,
851            state: ScheduleState {
852                next_fire_at: Some(next_fire_at),
853                ..Default::default()
854            },
855            run_config,
856        };
857
858        self.update_index(|index| {
859            index.schedules.insert(id.clone(), entry.clone());
860            Ok(entry.clone())
861        })
862        .await
863    }
864
865    pub async fn patch_schedule(
866        &self,
867        id: &str,
868        name: Option<String>,
869        enabled: Option<bool>,
870        trigger: Option<ScheduleTrigger>,
871        run_config: Option<ScheduleRunConfig>,
872    ) -> io::Result<Option<ScheduleEntry>> {
873        self.patch_schedule_with_definition(
874            id,
875            name,
876            enabled,
877            run_config,
878            ScheduleDefinitionChanges {
879                trigger,
880                ..Default::default()
881            },
882        )
883        .await
884    }
885
886    pub async fn patch_schedule_with_definition(
887        &self,
888        id: &str,
889        name: Option<String>,
890        enabled: Option<bool>,
891        run_config: Option<ScheduleRunConfig>,
892        definition: ScheduleDefinitionChanges,
893    ) -> io::Result<Option<ScheduleEntry>> {
894        self.update_index(|index| {
895            let Some(existing) = index.schedules.get_mut(id) else {
896                return Ok(None);
897            };
898            let now = Utc::now();
899            if let Some(name) = name {
900                existing.name = name;
901            }
902            if let Some(enabled) = enabled {
903                existing.enabled = enabled;
904            }
905
906            let trigger = normalize_trigger_for_storage(
907                definition
908                    .trigger
909                    .clone()
910                    .unwrap_or_else(|| existing.trigger.clone()),
911                existing.derived_anchor_at().unwrap_or(now),
912            );
913            existing.trigger = trigger.clone();
914
915            if let Some(timezone) = definition.timezone {
916                existing.timezone = normalize_optional_string(Some(timezone));
917            }
918            if let Some(start_at) = definition.start_at {
919                existing.start_at = Some(start_at);
920            }
921            if let Some(end_at) = definition.end_at {
922                existing.end_at = Some(end_at);
923            }
924            if let Some(misfire_policy) = definition.misfire_policy {
925                existing.misfire_policy = misfire_policy;
926            }
927            if let Some(overlap_policy) = definition.overlap_policy {
928                existing.overlap_policy = overlap_policy;
929            }
930            if let Some(run_config) = run_config {
931                existing.run_config = run_config;
932            }
933
934            let window = ScheduleWindow {
935                start_at: existing.start_at,
936                end_at: existing.end_at,
937            };
938            existing.state.next_fire_at = Some(compute_initial_next_run_at(
939                &trigger,
940                existing.timezone.as_deref(),
941                &window,
942                now,
943            )?);
944            existing.updated_at = now;
945            Ok(Some(existing.clone()))
946        })
947        .await
948    }
949
950    pub async fn delete_schedule(&self, id: &str) -> io::Result<bool> {
951        self.update_index(|index| {
952            let deleted = index.schedules.remove(id).is_some();
953            if deleted {
954                index
955                    .run_records
956                    .retain(|_, record| record.schedule_id != id);
957            }
958            Ok(deleted)
959        })
960        .await
961    }
962
963    /// Claim all due schedules and advance their `next_run_at`.
964    ///
965    /// Returns a list of run descriptors to execute out-of-band.
966    ///
967    /// **Important**: only writes to disk when at least one schedule is actually
968    /// due.  The ticker calls this every few seconds, so avoiding unnecessary
969    /// writes is critical for disk health and crash-safety.
970    pub async fn claim_due_runs(&self, now: DateTime<Utc>) -> io::Result<Vec<ClaimedScheduleRun>> {
971        let engine = default_trigger_engine();
972        self.claim_due_runs_with_engine(now, engine.as_ref()).await
973    }
974
975    pub async fn claim_due_runs_with_engine(
976        &self,
977        now: DateTime<Utc>,
978        engine: &dyn TriggerEngine,
979    ) -> io::Result<Vec<ClaimedScheduleRun>> {
980        {
981            let index = self.index.read().await;
982            let any_due = index.schedules.values().any(|entry| {
983                entry.enabled && current_due_at(entry).is_some_and(|due_at| due_at <= now)
984            });
985            if !any_due {
986                return Ok(Vec::new());
987            }
988        }
989
990        self.update_index(|index| {
991            let mut out = Vec::new();
992            let (schedules, run_records) = (&mut index.schedules, &mut index.run_records);
993            for entry in schedules.values_mut() {
994                if !entry.enabled {
995                    continue;
996                }
997                let Some(due_at) = current_due_at(entry) else {
998                    continue;
999                };
1000                if due_at > now {
1001                    continue;
1002                }
1003
1004                let dispatch_count = compute_misfire_dispatch_count(entry, now);
1005                if dispatch_count == 0 {
1006                    entry.state.last_scheduled_at = Some(now);
1007                    record_missed_occurrence(entry);
1008                    match compute_next_run_at_with_engine(entry, now, engine) {
1009                        Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1010                        Ok(None) => {
1011                            entry.state.next_fire_at = None;
1012                            entry.enabled = false;
1013                        }
1014                        Err(error) => {
1015                            tracing::warn!(
1016                                "failed to compute next scheduled fire for {} after misfire skip: {}. falling back to legacy interval semantics",
1017                                entry.id,
1018                                error
1019                            );
1020                            let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1021                            entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1022                        }
1023                    }
1024                    entry.updated_at = now;
1025                    continue;
1026                }
1027
1028                let blocked = overlap_blocks_dispatch(entry);
1029                match entry.overlap_policy {
1030                    OverlapPolicy::Skip if blocked => {
1031                        entry.state.last_scheduled_at = Some(now);
1032                        record_missed_occurrence(entry);
1033                        match compute_next_run_at_with_engine(entry, now, engine) {
1034                            Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1035                            Ok(None) => {
1036                                entry.state.next_fire_at = None;
1037                                entry.enabled = false;
1038                            }
1039                            Err(error) => {
1040                                tracing::warn!(
1041                                    "failed to compute next scheduled fire for {} after overlap skip: {}. falling back to legacy interval semantics",
1042                                    entry.id,
1043                                    error
1044                                );
1045                                let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1046                                entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1047                            }
1048                        }
1049                        entry.updated_at = now;
1050                        continue;
1051                    }
1052                    OverlapPolicy::QueueOne if blocked => {
1053                        continue;
1054                    }
1055                    _ => {}
1056                }
1057
1058                let dispatch_count = apply_overlap_dispatch_limit(entry, dispatch_count);
1059                entry.state.last_scheduled_at = Some(now);
1060                match compute_next_run_at_with_engine(entry, now, engine) {
1061                    Ok(Some(next_fire_at)) => {
1062                        entry.state.next_fire_at = Some(next_fire_at);
1063                    }
1064                    Ok(None) => {
1065                        entry.state.next_fire_at = None;
1066                        entry.enabled = false;
1067                    }
1068                    Err(error) => {
1069                        tracing::warn!(
1070                            "failed to compute next scheduled fire for {}: {}. falling back to legacy interval semantics",
1071                            entry.id,
1072                            error
1073                        );
1074                        let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1075                        entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1076                    }
1077                }
1078                entry.state.queued_run_count = entry.state.queued_run_count.saturating_add(dispatch_count);
1079                entry.updated_at = now;
1080                for dispatch_index in 0..dispatch_count {
1081                    let scheduled_for = scheduled_for_dispatch(entry, due_at, dispatch_index);
1082                    let was_catch_up = scheduled_for < now;
1083                    let record = make_queued_run_record(&entry.id, scheduled_for, now, was_catch_up);
1084                    let run_id = record.run_id.clone();
1085                    run_records.insert(run_id.clone(), record);
1086                    out.push(ClaimedScheduleRun {
1087                        run_id,
1088                        schedule_id: entry.id.clone(),
1089                        schedule_name: entry.name.clone(),
1090                        run_config: entry.run_config.clone(),
1091                        scheduled_for,
1092                        claimed_at: now,
1093                        was_catch_up,
1094                    });
1095                }
1096            }
1097            prune_run_records(run_records);
1098            Ok(out)
1099        })
1100        .await
1101    }
1102
1103    pub async fn mark_run_started(&self, schedule_id: &str, run_id: &str) -> io::Result<()> {
1104        self.update_index(|index| {
1105            let now = Utc::now();
1106            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1107                entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1108                entry.state.running_run_count = entry.state.running_run_count.saturating_add(1);
1109                entry.state.last_started_at = Some(now);
1110                entry.updated_at = now;
1111            }
1112            let record = index
1113                .run_records
1114                .entry(run_id.to_string())
1115                .or_insert_with(|| {
1116                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1117                });
1118            update_run_record_started(record, now, None);
1119            Ok(())
1120        })
1121        .await
1122    }
1123
1124    pub async fn bind_run_session(
1125        &self,
1126        schedule_id: &str,
1127        run_id: &str,
1128        session_id: &str,
1129    ) -> io::Result<()> {
1130        self.update_index(|index| {
1131            let now = Utc::now();
1132            let record = index
1133                .run_records
1134                .entry(run_id.to_string())
1135                .or_insert_with(|| {
1136                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Running)
1137                });
1138            record.session_id = Some(session_id.to_string());
1139            Ok(())
1140        })
1141        .await
1142    }
1143
1144    pub async fn mark_run_terminal(
1145        &self,
1146        schedule_id: &str,
1147        run_id: &str,
1148        status: ScheduleRunStatus,
1149        outcome_reason: Option<String>,
1150    ) -> io::Result<()> {
1151        self.update_index(|index| {
1152            let now = Utc::now();
1153            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1154                apply_terminal_run_status(entry, status, now)?;
1155                entry.updated_at = now;
1156            }
1157            let record = index
1158                .run_records
1159                .entry(run_id.to_string())
1160                .or_insert_with(|| make_fallback_run_record(run_id, schedule_id, now, status));
1161            update_run_record_terminal(record, status, now, outcome_reason, None);
1162            Ok(())
1163        })
1164        .await
1165    }
1166
1167    pub async fn mark_run_dequeued_without_start(
1168        &self,
1169        schedule_id: &str,
1170        run_id: &str,
1171        outcome_reason: Option<String>,
1172    ) -> io::Result<()> {
1173        self.update_index(|index| {
1174            let now = Utc::now();
1175            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1176                entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1177                record_missed_occurrence(entry);
1178                entry.updated_at = now;
1179            }
1180            let record = index
1181                .run_records
1182                .entry(run_id.to_string())
1183                .or_insert_with(|| {
1184                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1185                });
1186            update_run_record_terminal(
1187                record,
1188                ScheduleRunStatus::Missed,
1189                now,
1190                outcome_reason,
1191                None,
1192            );
1193            Ok(())
1194        })
1195        .await
1196    }
1197
1198    /// Create a run descriptor immediately (does not change the schedule cadence).
1199    pub async fn create_run_now(&self, id: &str) -> io::Result<Option<ClaimedScheduleRun>> {
1200        self.update_index(|index| {
1201            let Some(entry) = index.schedules.get(id).cloned() else {
1202                return Ok(None);
1203            };
1204            let now = Utc::now();
1205            let record = make_queued_run_record(&entry.id, now, now, false);
1206            let run_id = record.run_id.clone();
1207            index.run_records.insert(run_id.clone(), record);
1208            prune_run_records(&mut index.run_records);
1209            Ok(Some(ClaimedScheduleRun {
1210                run_id,
1211                schedule_id: entry.id,
1212                schedule_name: entry.name,
1213                run_config: entry.run_config,
1214                scheduled_for: now,
1215                claimed_at: now,
1216                was_catch_up: false,
1217            }))
1218        })
1219        .await
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226    use tempfile::tempdir;
1227
1228    fn run_record(
1229        run_id: &str,
1230        schedule_id: &str,
1231        status: ScheduleRunStatus,
1232        completed_at: Option<DateTime<Utc>>,
1233    ) -> ScheduleRunRecord {
1234        let base = Utc::now();
1235        ScheduleRunRecord {
1236            run_id: run_id.to_string(),
1237            schedule_id: schedule_id.to_string(),
1238            scheduled_for: base,
1239            claimed_at: base,
1240            started_at: None,
1241            completed_at,
1242            status,
1243            outcome_reason: None,
1244            session_id: None,
1245            dispatch_lag_ms: None,
1246            execution_duration_ms: None,
1247            was_catch_up: false,
1248        }
1249    }
1250
1251    #[test]
1252    fn prune_caps_terminal_records_keeps_newest_and_all_in_flight() {
1253        let mut records: HashMap<String, ScheduleRunRecord> = HashMap::new();
1254        let base = Utc::now();
1255
1256        // MAX + 50 terminal records for schedule "s1", completion times ordered.
1257        let total_terminal = MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE + 50;
1258        for i in 0..total_terminal {
1259            let id = format!("s1-term-{i}");
1260            let completed = base + Duration::seconds(i as i64);
1261            records.insert(
1262                id.clone(),
1263                run_record(&id, "s1", ScheduleRunStatus::Success, Some(completed)),
1264            );
1265        }
1266        // In-flight runs must survive regardless of count.
1267        records.insert(
1268            "s1-queued".into(),
1269            run_record("s1-queued", "s1", ScheduleRunStatus::Queued, None),
1270        );
1271        records.insert(
1272            "s1-running".into(),
1273            run_record("s1-running", "s1", ScheduleRunStatus::Running, None),
1274        );
1275        // A different schedule under the cap is untouched.
1276        records.insert(
1277            "s2-term-0".into(),
1278            run_record("s2-term-0", "s2", ScheduleRunStatus::Failed, Some(base)),
1279        );
1280
1281        prune_run_records(&mut records);
1282
1283        let s1_terminal = records
1284            .values()
1285            .filter(|r| r.schedule_id == "s1" && r.status.is_terminal())
1286            .count();
1287        assert_eq!(s1_terminal, MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE);
1288        // Non-terminal runs kept.
1289        assert!(records.contains_key("s1-queued"));
1290        assert!(records.contains_key("s1-running"));
1291        // The newest terminal record is retained; the oldest is evicted.
1292        assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1293        assert!(!records.contains_key("s1-term-0"));
1294        // Other schedules untouched.
1295        assert!(records.contains_key("s2-term-0"));
1296    }
1297
1298    #[tokio::test]
1299    async fn store_backfills_legacy_interval_trigger_on_load() {
1300        let dir = tempdir().unwrap();
1301        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1302            .unwrap()
1303            .with_timezone(&Utc);
1304        let next_run_at = DateTime::parse_from_rfc3339("2026-04-04T11:00:00Z")
1305            .unwrap()
1306            .with_timezone(&Utc);
1307
1308        let raw = serde_json::json!({
1309            "version": 1,
1310            "updated_at": now,
1311            "schedules": {
1312                "legacy-1": {
1313                    "id": "legacy-1",
1314                    "name": "legacy",
1315                    "enabled": true,
1316                    "interval_seconds": 3600,
1317                    "created_at": now,
1318                    "updated_at": now,
1319                    "last_run_at": null,
1320                    "next_run_at": next_run_at,
1321                    "run_config": { "auto_execute": false }
1322                }
1323            }
1324        });
1325        tokio::fs::write(
1326            dir.path().join("schedules.json"),
1327            serde_json::to_vec_pretty(&raw).unwrap(),
1328        )
1329        .await
1330        .unwrap();
1331
1332        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1333        let schedule = store.get_schedule("legacy-1").await.unwrap();
1334        assert!(matches!(
1335            schedule.trigger,
1336            ScheduleTrigger::Interval {
1337                every_seconds: 3600,
1338                ..
1339            }
1340        ));
1341    }
1342
1343    #[tokio::test]
1344    async fn create_schedule_with_definition_persists_interval_trigger_metadata() {
1345        let dir = tempdir().unwrap();
1346        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1347
1348        let created = store
1349            .create_schedule_with_definition(
1350                "interval".to_string(),
1351                true,
1352                ScheduleRunConfig::default(),
1353                ScheduleDefinitionChanges {
1354                    trigger: Some(ScheduleTrigger::Interval {
1355                        every_seconds: 300,
1356                        anchor_at: None,
1357                    }),
1358                    timezone: Some("Asia/Shanghai".to_string()),
1359                    misfire_policy: Some(MisFirePolicy::RunOnce),
1360                    overlap_policy: Some(OverlapPolicy::QueueOne),
1361                    ..Default::default()
1362                },
1363            )
1364            .await
1365            .unwrap();
1366
1367        assert!(matches!(
1368            created.trigger,
1369            ScheduleTrigger::Interval {
1370                every_seconds: 300,
1371                ..
1372            }
1373        ));
1374        assert_eq!(created.timezone.as_deref(), Some("Asia/Shanghai"));
1375        assert_eq!(created.misfire_policy, MisFirePolicy::RunOnce);
1376        assert_eq!(created.overlap_policy, OverlapPolicy::QueueOne);
1377    }
1378
1379    #[tokio::test]
1380    async fn patch_schedule_with_definition_updates_interval_trigger_metadata() {
1381        let dir = tempdir().unwrap();
1382        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1383
1384        let created = store
1385            .create_schedule(
1386                "interval".to_string(),
1387                ScheduleTrigger::Interval {
1388                    every_seconds: 300,
1389                    anchor_at: None,
1390                },
1391                true,
1392                ScheduleRunConfig::default(),
1393            )
1394            .await
1395            .unwrap();
1396
1397        let patched = store
1398            .patch_schedule_with_definition(
1399                &created.id,
1400                None,
1401                None,
1402                None,
1403                ScheduleDefinitionChanges {
1404                    trigger: Some(ScheduleTrigger::Interval {
1405                        every_seconds: 600,
1406                        anchor_at: None,
1407                    }),
1408                    timezone: Some("UTC".to_string()),
1409                    misfire_policy: Some(MisFirePolicy::CatchUpAll),
1410                    overlap_policy: Some(OverlapPolicy::Skip),
1411                    ..Default::default()
1412                },
1413            )
1414            .await
1415            .unwrap()
1416            .unwrap();
1417
1418        assert!(matches!(
1419            patched.trigger,
1420            ScheduleTrigger::Interval {
1421                every_seconds: 600,
1422                ..
1423            }
1424        ));
1425        assert_eq!(patched.timezone.as_deref(), Some("UTC"));
1426        assert_eq!(patched.misfire_policy, MisFirePolicy::CatchUpAll);
1427        assert_eq!(patched.overlap_policy, OverlapPolicy::Skip);
1428    }
1429
1430    #[tokio::test]
1431    async fn claim_due_runs_with_engine_uses_runtime_adapter_for_interval_trigger() {
1432        let dir = tempdir().unwrap();
1433        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1434
1435        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1436            .unwrap()
1437            .with_timezone(&Utc);
1438
1439        let created = store
1440            .create_schedule_with_definition(
1441                "interval".to_string(),
1442                true,
1443                ScheduleRunConfig::default(),
1444                ScheduleDefinitionChanges {
1445                    trigger: Some(ScheduleTrigger::Interval {
1446                        every_seconds: 300,
1447                        anchor_at: Some(now - Duration::seconds(300)),
1448                    }),
1449                    ..Default::default()
1450                },
1451            )
1452            .await
1453            .unwrap();
1454
1455        store
1456            .update_index(|index| {
1457                let entry = index.schedules.get_mut(&created.id).unwrap();
1458                entry.state.next_fire_at = Some(now);
1459                Ok(())
1460            })
1461            .await
1462            .unwrap();
1463
1464        let engine = default_trigger_engine();
1465        let claimed = store
1466            .claim_due_runs_with_engine(now, engine.as_ref())
1467            .await
1468            .unwrap();
1469        assert_eq!(claimed.len(), 1);
1470
1471        let updated = store.get_schedule(&created.id).await.unwrap();
1472        assert_eq!(updated.state.last_scheduled_at, Some(now));
1473        assert_eq!(
1474            updated.state.next_fire_at,
1475            Some(now + Duration::seconds(300))
1476        );
1477    }
1478
1479    #[tokio::test]
1480    async fn create_schedule_with_definition_initializes_monthly_next_run() {
1481        let dir = tempdir().unwrap();
1482        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1483
1484        let created = store
1485            .create_schedule_with_definition(
1486                "monthly".to_string(),
1487                true,
1488                ScheduleRunConfig::default(),
1489                ScheduleDefinitionChanges {
1490                    trigger: Some(ScheduleTrigger::Monthly {
1491                        days: vec![1, 15],
1492                        hour: 9,
1493                        minute: 0,
1494                        second: 0,
1495                    }),
1496                    timezone: Some("UTC".to_string()),
1497                    ..Default::default()
1498                },
1499            )
1500            .await
1501            .unwrap();
1502
1503        assert!(matches!(
1504            created.trigger,
1505            ScheduleTrigger::Monthly {
1506                days,
1507                hour: 9,
1508                minute: 0,
1509                second: 0
1510            } if days == vec![1, 15]
1511        ));
1512        assert!(created
1513            .state
1514            .next_fire_at
1515            .is_some_and(|next| next > created.created_at));
1516    }
1517
1518    #[tokio::test]
1519    async fn create_schedule_with_definition_initializes_once_next_run_at_trigger_instant() {
1520        let dir = tempdir().unwrap();
1521        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1522
1523        let at = Utc::now() + Duration::seconds(600);
1524        let created = store
1525            .create_schedule_with_definition(
1526                "once".to_string(),
1527                true,
1528                ScheduleRunConfig::default(),
1529                ScheduleDefinitionChanges {
1530                    trigger: Some(ScheduleTrigger::Once { at }),
1531                    ..Default::default()
1532                },
1533            )
1534            .await
1535            .unwrap();
1536
1537        assert!(matches!(created.trigger, ScheduleTrigger::Once { at: t } if t == at));
1538        assert_eq!(created.state.next_fire_at, Some(at));
1539    }
1540
1541    #[tokio::test]
1542    async fn claimed_once_run_does_not_produce_a_second_due_run() {
1543        let dir = tempdir().unwrap();
1544        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1545
1546        let at = Utc::now() + Duration::seconds(600);
1547        let created = store
1548            .create_schedule_with_definition(
1549                "once".to_string(),
1550                true,
1551                ScheduleRunConfig::default(),
1552                ScheduleDefinitionChanges {
1553                    trigger: Some(ScheduleTrigger::Once { at }),
1554                    ..Default::default()
1555                },
1556            )
1557            .await
1558            .unwrap();
1559        assert_eq!(created.state.next_fire_at, Some(at));
1560
1561        let engine = default_trigger_engine();
1562
1563        // Not due yet: nothing is claimed before `at`.
1564        let claimed = store
1565            .claim_due_runs_with_engine(at - Duration::seconds(1), engine.as_ref())
1566            .await
1567            .unwrap();
1568        assert!(claimed.is_empty());
1569
1570        // Due: the single occurrence is claimed exactly once, and the
1571        // schedule terminates (no next fire, disabled).
1572        let claimed = store
1573            .claim_due_runs_with_engine(at, engine.as_ref())
1574            .await
1575            .unwrap();
1576        assert_eq!(claimed.len(), 1);
1577        assert_eq!(claimed[0].scheduled_for, at);
1578
1579        let updated = store.get_schedule(&created.id).await.unwrap();
1580        assert_eq!(updated.state.next_fire_at, None);
1581        assert!(!updated.enabled);
1582
1583        // Subsequent ticks never claim the fired one-shot again.
1584        let claimed = store
1585            .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1586            .await
1587            .unwrap();
1588        assert!(claimed.is_empty());
1589        let claimed = store
1590            .claim_due_runs_with_engine(at + Duration::seconds(3600), engine.as_ref())
1591            .await
1592            .unwrap();
1593        assert!(claimed.is_empty());
1594    }
1595
1596    #[tokio::test]
1597    async fn fired_once_schedule_is_not_rearmed_on_reload() {
1598        let dir = tempdir().unwrap();
1599        let at = Utc::now() + Duration::seconds(600);
1600        let created_id;
1601        {
1602            let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1603            let created = store
1604                .create_schedule_with_definition(
1605                    "once".to_string(),
1606                    true,
1607                    ScheduleRunConfig::default(),
1608                    ScheduleDefinitionChanges {
1609                        trigger: Some(ScheduleTrigger::Once { at }),
1610                        ..Default::default()
1611                    },
1612                )
1613                .await
1614                .unwrap();
1615            created_id = created.id.clone();
1616
1617            let engine = default_trigger_engine();
1618            let claimed = store
1619                .claim_due_runs_with_engine(at, engine.as_ref())
1620                .await
1621                .unwrap();
1622            assert_eq!(claimed.len(), 1);
1623        }
1624
1625        // Reload from disk: the load-time backfill must not re-arm the fired
1626        // one-shot (`next_fire_at: None` is its terminal state).
1627        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1628        let reloaded = store.get_schedule(&created_id).await.unwrap();
1629        assert_eq!(reloaded.state.next_fire_at, None);
1630        assert!(!reloaded.enabled);
1631
1632        let engine = default_trigger_engine();
1633        let claimed = store
1634            .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1635            .await
1636            .unwrap();
1637        assert!(claimed.is_empty());
1638    }
1639
1640    #[tokio::test]
1641    async fn misfire_skip_does_not_dispatch_run() {
1642        let dir = tempdir().unwrap();
1643        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1644        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1645            .unwrap()
1646            .with_timezone(&Utc);
1647
1648        let created = store
1649            .create_schedule_with_definition(
1650                "skip".to_string(),
1651                true,
1652                ScheduleRunConfig::default(),
1653                ScheduleDefinitionChanges {
1654                    trigger: Some(ScheduleTrigger::Interval {
1655                        every_seconds: 300,
1656                        anchor_at: None,
1657                    }),
1658                    misfire_policy: Some(MisFirePolicy::Skip),
1659                    ..Default::default()
1660                },
1661            )
1662            .await
1663            .unwrap();
1664
1665        store
1666            .update_index(|index| {
1667                let entry = index.schedules.get_mut(&created.id).unwrap();
1668                entry.state.next_fire_at = Some(now - Duration::seconds(900));
1669                Ok(())
1670            })
1671            .await
1672            .unwrap();
1673
1674        let engine = default_trigger_engine();
1675        let claimed = store
1676            .claim_due_runs_with_engine(now, engine.as_ref())
1677            .await
1678            .unwrap();
1679        assert!(claimed.is_empty());
1680
1681        let updated = store.get_schedule(&created.id).await.unwrap();
1682        assert_eq!(updated.state.last_scheduled_at, Some(now));
1683        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1684        assert_eq!(updated.state.queued_run_count, 0);
1685    }
1686
1687    #[tokio::test]
1688    async fn overlap_skip_does_not_dispatch_when_running() {
1689        let dir = tempdir().unwrap();
1690        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1691        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1692            .unwrap()
1693            .with_timezone(&Utc);
1694
1695        let created = store
1696            .create_schedule_with_definition(
1697                "skip-overlap".to_string(),
1698                true,
1699                ScheduleRunConfig::default(),
1700                ScheduleDefinitionChanges {
1701                    trigger: Some(ScheduleTrigger::Interval {
1702                        every_seconds: 300,
1703                        anchor_at: None,
1704                    }),
1705                    overlap_policy: Some(OverlapPolicy::Skip),
1706                    ..Default::default()
1707                },
1708            )
1709            .await
1710            .unwrap();
1711
1712        store
1713            .update_index(|index| {
1714                let entry = index.schedules.get_mut(&created.id).unwrap();
1715                entry.state.next_fire_at = Some(now);
1716                entry.state.running_run_count = 1;
1717                Ok(())
1718            })
1719            .await
1720            .unwrap();
1721
1722        let engine = default_trigger_engine();
1723        let claimed = store
1724            .claim_due_runs_with_engine(now, engine.as_ref())
1725            .await
1726            .unwrap();
1727        assert!(claimed.is_empty());
1728
1729        let updated = store.get_schedule(&created.id).await.unwrap();
1730        assert_eq!(updated.state.running_run_count, 1);
1731        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1732        assert_eq!(updated.state.queued_run_count, 0);
1733    }
1734
1735    #[tokio::test]
1736    async fn overlap_queue_one_does_not_add_more_than_one_pending_run() {
1737        let dir = tempdir().unwrap();
1738        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1739        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1740            .unwrap()
1741            .with_timezone(&Utc);
1742
1743        let created = store
1744            .create_schedule_with_definition(
1745                "queue-one".to_string(),
1746                true,
1747                ScheduleRunConfig::default(),
1748                ScheduleDefinitionChanges {
1749                    trigger: Some(ScheduleTrigger::Interval {
1750                        every_seconds: 300,
1751                        anchor_at: None,
1752                    }),
1753                    overlap_policy: Some(OverlapPolicy::QueueOne),
1754                    ..Default::default()
1755                },
1756            )
1757            .await
1758            .unwrap();
1759
1760        store
1761            .update_index(|index| {
1762                let entry = index.schedules.get_mut(&created.id).unwrap();
1763                entry.state.next_fire_at = Some(now);
1764                entry.state.queued_run_count = 1;
1765                Ok(())
1766            })
1767            .await
1768            .unwrap();
1769
1770        let engine = default_trigger_engine();
1771        let claimed = store
1772            .claim_due_runs_with_engine(now, engine.as_ref())
1773            .await
1774            .unwrap();
1775        assert!(claimed.is_empty());
1776
1777        let updated = store.get_schedule(&created.id).await.unwrap();
1778        assert_eq!(updated.state.queued_run_count, 1);
1779    }
1780
1781    #[tokio::test]
1782    async fn overlap_queue_one_limits_catch_up_to_single_pending_run() {
1783        let dir = tempdir().unwrap();
1784        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1785        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1786            .unwrap()
1787            .with_timezone(&Utc);
1788
1789        let created = store
1790            .create_schedule_with_definition(
1791                "queue-one-catchup".to_string(),
1792                true,
1793                ScheduleRunConfig::default(),
1794                ScheduleDefinitionChanges {
1795                    trigger: Some(ScheduleTrigger::Interval {
1796                        every_seconds: 300,
1797                        anchor_at: None,
1798                    }),
1799                    misfire_policy: Some(MisFirePolicy::CatchUpAll),
1800                    overlap_policy: Some(OverlapPolicy::QueueOne),
1801                    ..Default::default()
1802                },
1803            )
1804            .await
1805            .unwrap();
1806
1807        store
1808            .update_index(|index| {
1809                let entry = index.schedules.get_mut(&created.id).unwrap();
1810                entry.state.next_fire_at = Some(now - Duration::seconds(900));
1811                Ok(())
1812            })
1813            .await
1814            .unwrap();
1815
1816        let engine = default_trigger_engine();
1817        let claimed = store
1818            .claim_due_runs_with_engine(now, engine.as_ref())
1819            .await
1820            .unwrap();
1821        assert_eq!(claimed.len(), 1);
1822
1823        let updated = store.get_schedule(&created.id).await.unwrap();
1824        assert_eq!(updated.state.queued_run_count, 1);
1825        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1826    }
1827
1828    #[tokio::test]
1829    async fn mark_run_terminal_records_success_accounting() {
1830        let dir = tempdir().unwrap();
1831        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1832
1833        let created = store
1834            .create_schedule(
1835                "success".to_string(),
1836                ScheduleTrigger::Interval {
1837                    every_seconds: 60,
1838                    anchor_at: None,
1839                },
1840                true,
1841                ScheduleRunConfig::default(),
1842            )
1843            .await
1844            .unwrap();
1845
1846        store
1847            .update_index(|index| {
1848                let entry = index.schedules.get_mut(&created.id).unwrap();
1849                entry.state.running_run_count = 1;
1850                entry.state.consecutive_failures = 2;
1851                Ok(())
1852            })
1853            .await
1854            .unwrap();
1855
1856        store
1857            .mark_run_terminal(&created.id, "run-success", ScheduleRunStatus::Success, None)
1858            .await
1859            .unwrap();
1860
1861        let updated = store.get_schedule(&created.id).await.unwrap();
1862        assert_eq!(updated.state.running_run_count, 0);
1863        assert!(updated.state.last_finished_at.is_some());
1864        assert!(updated.state.last_success_at.is_some());
1865        assert_eq!(updated.state.total_run_count, 1);
1866        assert_eq!(updated.state.total_success_count, 1);
1867        assert_eq!(updated.state.total_failure_count, 0);
1868        assert_eq!(updated.state.consecutive_failures, 0);
1869    }
1870
1871    #[tokio::test]
1872    async fn mark_run_terminal_records_failure_accounting() {
1873        let dir = tempdir().unwrap();
1874        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1875
1876        let created = store
1877            .create_schedule(
1878                "failure".to_string(),
1879                ScheduleTrigger::Interval {
1880                    every_seconds: 60,
1881                    anchor_at: None,
1882                },
1883                true,
1884                ScheduleRunConfig::default(),
1885            )
1886            .await
1887            .unwrap();
1888
1889        store
1890            .update_index(|index| {
1891                let entry = index.schedules.get_mut(&created.id).unwrap();
1892                entry.state.running_run_count = 1;
1893                Ok(())
1894            })
1895            .await
1896            .unwrap();
1897
1898        store
1899            .mark_run_terminal(&created.id, "run-failed", ScheduleRunStatus::Failed, None)
1900            .await
1901            .unwrap();
1902        store
1903            .update_index(|index| {
1904                let entry = index.schedules.get_mut(&created.id).unwrap();
1905                entry.state.running_run_count = 1;
1906                Ok(())
1907            })
1908            .await
1909            .unwrap();
1910        store
1911            .mark_run_terminal(
1912                &created.id,
1913                "run-cancelled",
1914                ScheduleRunStatus::Cancelled,
1915                None,
1916            )
1917            .await
1918            .unwrap();
1919
1920        let updated = store.get_schedule(&created.id).await.unwrap();
1921        assert_eq!(updated.state.running_run_count, 0);
1922        assert!(updated.state.last_finished_at.is_some());
1923        assert!(updated.state.last_failure_at.is_some());
1924        assert_eq!(updated.state.total_run_count, 2);
1925        assert_eq!(updated.state.total_success_count, 0);
1926        assert_eq!(updated.state.total_failure_count, 2);
1927        assert_eq!(updated.state.consecutive_failures, 2);
1928    }
1929
1930    #[tokio::test]
1931    async fn mark_run_dequeued_without_start_counts_missed_occurrence() {
1932        let dir = tempdir().unwrap();
1933        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1934
1935        let created = store
1936            .create_schedule(
1937                "missed".to_string(),
1938                ScheduleTrigger::Interval {
1939                    every_seconds: 60,
1940                    anchor_at: None,
1941                },
1942                true,
1943                ScheduleRunConfig::default(),
1944            )
1945            .await
1946            .unwrap();
1947
1948        store
1949            .update_index(|index| {
1950                let entry = index.schedules.get_mut(&created.id).unwrap();
1951                entry.state.queued_run_count = 1;
1952                Ok(())
1953            })
1954            .await
1955            .unwrap();
1956
1957        store
1958            .mark_run_dequeued_without_start(&created.id, "run-missed", None)
1959            .await
1960            .unwrap();
1961
1962        let updated = store.get_schedule(&created.id).await.unwrap();
1963        assert_eq!(updated.state.queued_run_count, 0);
1964        assert_eq!(updated.state.total_missed_count, 1);
1965        let record = store
1966            .get_run_record("run-missed")
1967            .await
1968            .expect("run record should be created for missed dequeue");
1969        assert_eq!(record.status, ScheduleRunStatus::Missed);
1970        assert!(record.completed_at.is_some());
1971    }
1972
1973    #[tokio::test]
1974    async fn create_run_now_persists_queued_run_record() {
1975        let dir = tempdir().unwrap();
1976        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1977
1978        let created = store
1979            .create_schedule(
1980                "run-now".to_string(),
1981                ScheduleTrigger::Interval {
1982                    every_seconds: 60,
1983                    anchor_at: None,
1984                },
1985                true,
1986                ScheduleRunConfig::default(),
1987            )
1988            .await
1989            .unwrap();
1990
1991        let claimed = store
1992            .create_run_now(&created.id)
1993            .await
1994            .unwrap()
1995            .expect("run descriptor should be created");
1996
1997        let record = store
1998            .get_run_record(&claimed.run_id)
1999            .await
2000            .expect("queued run record should exist");
2001        assert_eq!(record.schedule_id, created.id);
2002        assert_eq!(record.status, ScheduleRunStatus::Queued);
2003        assert_eq!(record.claimed_at, claimed.claimed_at);
2004        assert_eq!(record.scheduled_for, claimed.scheduled_for);
2005        assert!(!claimed.was_catch_up);
2006    }
2007
2008    #[tokio::test]
2009    async fn mark_run_started_and_terminal_updates_run_record_fields() {
2010        let dir = tempdir().unwrap();
2011        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2012
2013        let created = store
2014            .create_schedule(
2015                "run-record-lifecycle".to_string(),
2016                ScheduleTrigger::Interval {
2017                    every_seconds: 60,
2018                    anchor_at: None,
2019                },
2020                true,
2021                ScheduleRunConfig::default(),
2022            )
2023            .await
2024            .unwrap();
2025
2026        let claimed = store
2027            .create_run_now(&created.id)
2028            .await
2029            .unwrap()
2030            .expect("run descriptor should be created");
2031
2032        store
2033            .mark_run_started(&created.id, &claimed.run_id)
2034            .await
2035            .unwrap();
2036        store
2037            .bind_run_session(&created.id, &claimed.run_id, "session-1")
2038            .await
2039            .unwrap();
2040        store
2041            .mark_run_terminal(
2042                &created.id,
2043                &claimed.run_id,
2044                ScheduleRunStatus::Success,
2045                Some("ok".to_string()),
2046            )
2047            .await
2048            .unwrap();
2049
2050        let record = store
2051            .get_run_record(&claimed.run_id)
2052            .await
2053            .expect("run record should exist");
2054        assert_eq!(record.status, ScheduleRunStatus::Success);
2055        assert!(record.started_at.is_some());
2056        assert!(record.completed_at.is_some());
2057        assert_eq!(record.session_id.as_deref(), Some("session-1"));
2058        assert!(record.dispatch_lag_ms.is_some());
2059        assert!(record.execution_duration_ms.is_some());
2060        assert_eq!(record.outcome_reason.as_deref(), Some("ok"));
2061    }
2062
2063    #[tokio::test]
2064    async fn delete_schedule_removes_associated_run_records() {
2065        let dir = tempdir().unwrap();
2066        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2067
2068        let created = store
2069            .create_schedule(
2070                "cleanup-history".to_string(),
2071                ScheduleTrigger::Interval {
2072                    every_seconds: 60,
2073                    anchor_at: None,
2074                },
2075                true,
2076                ScheduleRunConfig::default(),
2077            )
2078            .await
2079            .unwrap();
2080
2081        let claimed = store
2082            .create_run_now(&created.id)
2083            .await
2084            .unwrap()
2085            .expect("run descriptor should be created");
2086        assert!(store.get_run_record(&claimed.run_id).await.is_some());
2087
2088        let deleted = store.delete_schedule(&created.id).await.unwrap();
2089        assert!(deleted);
2090        assert!(store.get_run_record(&claimed.run_id).await.is_none());
2091    }
2092}