Skip to main content

aion/schedule/
evaluator.rs

1//! Timer-driven schedule evaluation service.
2//!
3//! The evaluator is intentionally built around explicit seams for durable timer arming, workflow
4//! start/cancel side effects, and recovery event sourcing. Engine API wiring is left to later
5//! schedule integration work.
6
7use std::collections::{HashMap, HashSet};
8use std::sync::Arc;
9
10use aion_core::{Event, Payload, ScheduleId, SearchAttributeValue, TimerId, WorkflowId};
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13
14use crate::schedule::{
15    OverlapDecision, ScheduleError, ScheduleExecution, ScheduleState, evaluate_catch_up,
16    evaluate_overlap, next_fire_time, project_schedule_state,
17};
18
19#[cfg(test)]
20mod tests;
21
22/// Errors returned by schedule evaluation and recovery.
23#[derive(thiserror::Error, Debug)]
24pub enum ScheduleEvaluatorError {
25    /// Trigger parsing or advancement failed.
26    #[error("schedule trigger error: {0}")]
27    Trigger(#[from] ScheduleError),
28
29    /// A timer identifier could not be constructed for a schedule.
30    #[error("schedule timer id error: {0}")]
31    TimerId(#[from] aion_core::IdError),
32
33    /// No state exists for a schedule referenced by the caller.
34    #[error("schedule `{schedule_id}` was not found")]
35    ScheduleNotFound {
36        /// Schedule identifier requested by the caller.
37        schedule_id: ScheduleId,
38    },
39
40    /// A side-effect dependency failed.
41    #[error("schedule side effect failed: {reason}")]
42    SideEffect {
43        /// Human-readable failure reason.
44        reason: String,
45    },
46}
47
48impl ScheduleEvaluatorError {
49    pub(crate) fn side_effect(reason: impl Into<String>) -> Self {
50        Self::SideEffect {
51            reason: reason.into(),
52        }
53    }
54}
55
56/// Durable timer seam used by [`ScheduleEvaluator`].
57#[async_trait]
58pub trait ScheduleTimer: Send + Sync {
59    /// Arms a durable timer for the schedule's next fire time.
60    async fn arm_schedule_timer(
61        &self,
62        schedule_id: &ScheduleId,
63        timer_id: &TimerId,
64        fire_at: DateTime<Utc>,
65    ) -> Result<(), ScheduleEvaluatorError>;
66}
67
68/// Workflow-start seam used by [`ScheduleEvaluator`].
69#[async_trait]
70pub trait ScheduleWorkflowStarter: Send + Sync {
71    /// Starts a workflow for a schedule tick and returns the concrete execution identifiers.
72    ///
73    /// `search_attributes` come from the schedule's persisted configuration and
74    /// are recorded on every triggered execution so visibility metadata (such as
75    /// a server-assigned tenancy attribute) survives engine restarts.
76    async fn start_scheduled_workflow(
77        &self,
78        workflow_type: &str,
79        input: Payload,
80        search_attributes: HashMap<String, SearchAttributeValue>,
81    ) -> Result<ScheduleExecution, ScheduleEvaluatorError>;
82}
83
84/// Workflow-cancellation seam used by [`ScheduleEvaluator`] for `CancelPrevious`.
85#[async_trait]
86pub trait ScheduleWorkflowCanceller: Send + Sync {
87    /// Cancels a running schedule-started workflow execution.
88    async fn cancel_scheduled_workflow(
89        &self,
90        execution: &ScheduleExecution,
91        reason: &str,
92    ) -> Result<(), ScheduleEvaluatorError>;
93}
94
95/// Schedule event recording seam used by [`ScheduleEvaluator`].
96#[async_trait]
97pub trait ScheduleEventSink: Send + Sync {
98    /// Records that a schedule tick started a workflow execution.
99    async fn record_schedule_triggered(
100        &self,
101        schedule_id: &ScheduleId,
102        execution: &ScheduleExecution,
103        recorded_at: DateTime<Utc>,
104    ) -> Result<(), ScheduleEvaluatorError>;
105}
106
107/// Recovery source seam for reconstructing schedule state from durable events.
108#[async_trait]
109pub trait ScheduleEventSource: Send + Sync {
110    /// Returns schedule-bearing events in projection order.
111    async fn schedule_events(&self) -> Result<Vec<Event>, ScheduleEvaluatorError>;
112}
113
114/// Result returned after handling a schedule timer tick.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum TimerEvaluationOutcome {
117    /// A workflow was started and a `ScheduleTriggered` event was recorded.
118    Started(ScheduleExecution),
119    /// The tick was skipped by overlap policy.
120    Skipped,
121    /// The tick was buffered because one execution is already active.
122    Buffered,
123    /// The schedule is inactive and no timer was re-armed.
124    Inactive,
125}
126
127/// Schedule evaluator state and dependencies.
128pub struct ScheduleEvaluator {
129    states: HashMap<ScheduleId, ScheduleState>,
130    pending_ticks: HashSet<ScheduleId>,
131    timer: Arc<dyn ScheduleTimer>,
132    starter: Arc<dyn ScheduleWorkflowStarter>,
133    canceller: Arc<dyn ScheduleWorkflowCanceller>,
134    events: Arc<dyn ScheduleEventSink>,
135}
136
137impl ScheduleEvaluator {
138    /// Creates an evaluator from injected schedule side-effect seams.
139    #[must_use]
140    pub fn new(
141        timer: Arc<dyn ScheduleTimer>,
142        starter: Arc<dyn ScheduleWorkflowStarter>,
143        canceller: Arc<dyn ScheduleWorkflowCanceller>,
144        events: Arc<dyn ScheduleEventSink>,
145    ) -> Self {
146        Self {
147            states: HashMap::new(),
148            pending_ticks: HashSet::new(),
149            timer,
150            starter,
151            canceller,
152            events,
153        }
154    }
155
156    /// Replaces evaluator state with a projection from schedule events.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`ScheduleEvaluatorError`] when projection trigger evaluation fails.
161    pub fn replace_state_from_events(
162        &mut self,
163        events: &[Event],
164    ) -> Result<(), ScheduleEvaluatorError> {
165        self.states = project_schedule_state(events)?
166            .into_iter()
167            .map(|state| (state.schedule_id.clone(), state))
168            .collect();
169        self.pending_ticks.clear();
170        Ok(())
171    }
172
173    /// Inserts or replaces a schedule state. Primarily useful for tests and future Engine wiring.
174    pub fn upsert_state(&mut self, state: ScheduleState) {
175        let schedule_id = state.schedule_id.clone();
176        if !state.is_active() {
177            self.pending_ticks.remove(&schedule_id);
178        }
179        self.states.insert(schedule_id, state);
180    }
181
182    /// Returns the projected state for a schedule, when present.
183    #[must_use]
184    pub fn state(&self, schedule_id: &ScheduleId) -> Option<&ScheduleState> {
185        self.states.get(schedule_id)
186    }
187
188    /// Returns all projected schedule states.
189    pub fn states(&self) -> impl Iterator<Item = &ScheduleState> {
190        self.states.values()
191    }
192
193    /// Returns whether one buffered tick is pending for a schedule.
194    #[must_use]
195    pub fn has_pending_tick(&self, schedule_id: &ScheduleId) -> bool {
196        self.pending_ticks.contains(schedule_id)
197    }
198
199    /// Computes a durable timer id for a schedule timer.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`aion_core::IdError`] only if the generated name is empty, which cannot occur for a
204    /// valid [`ScheduleId`]. The error is still propagated instead of panicking.
205    pub fn timer_id_for(schedule_id: &ScheduleId) -> Result<TimerId, aion_core::IdError> {
206        TimerId::named(format!("schedule:{schedule_id}"))
207    }
208
209    /// Arms an active schedule at its projected next trigger time.
210    ///
211    /// Paused and deleted schedules are ignored.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`ScheduleEvaluatorError`] when timer id construction or durable arming fails.
216    pub async fn arm_active_schedule(
217        &self,
218        schedule_id: &ScheduleId,
219    ) -> Result<bool, ScheduleEvaluatorError> {
220        let Some(state) = self.states.get(schedule_id) else {
221            return Err(ScheduleEvaluatorError::ScheduleNotFound {
222                schedule_id: schedule_id.clone(),
223            });
224        };
225
226        self.arm_state_if_active(state).await
227    }
228
229    async fn arm_state_if_active(
230        &self,
231        state: &ScheduleState,
232    ) -> Result<bool, ScheduleEvaluatorError> {
233        if !state.is_active() {
234            return Ok(false);
235        }
236
237        let timer_id = Self::timer_id_for(&state.schedule_id)?;
238        self.timer
239            .arm_schedule_timer(&state.schedule_id, &timer_id, state.next_trigger_at)
240            .await?;
241        Ok(true)
242    }
243
244    /// Handles a fired schedule timer without polling.
245    ///
246    /// The timer owner calls this method when a durable schedule timer fires. The evaluator enforces
247    /// overlap policy, records `ScheduleTriggered` after successful starts, computes the next fire
248    /// time, and re-arms active schedules.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`ScheduleEvaluatorError`] when policy side effects, event recording, trigger
253    /// advancement, or durable timer arming fail.
254    pub async fn handle_timer_fired(
255        &mut self,
256        schedule_id: &ScheduleId,
257        fire_at: DateTime<Utc>,
258    ) -> Result<TimerEvaluationOutcome, ScheduleEvaluatorError> {
259        if !self
260            .states
261            .get(schedule_id)
262            .ok_or_else(|| ScheduleEvaluatorError::ScheduleNotFound {
263                schedule_id: schedule_id.clone(),
264            })?
265            .is_active()
266        {
267            return Ok(TimerEvaluationOutcome::Inactive);
268        }
269
270        let outcome = self.process_fire(schedule_id, fire_at).await?;
271        self.advance_and_arm(schedule_id, fire_at).await?;
272        Ok(outcome)
273    }
274
275    async fn process_fire(
276        &mut self,
277        schedule_id: &ScheduleId,
278        fire_at: DateTime<Utc>,
279    ) -> Result<TimerEvaluationOutcome, ScheduleEvaluatorError> {
280        let decision = {
281            let state = self.states.get(schedule_id).ok_or_else(|| {
282                ScheduleEvaluatorError::ScheduleNotFound {
283                    schedule_id: schedule_id.clone(),
284                }
285            })?;
286            evaluate_overlap(
287                &state.config.overlap_policy,
288                state.current_execution.as_ref(),
289                self.pending_ticks.contains(schedule_id),
290            )
291        };
292
293        match decision {
294            OverlapDecision::Start => self.start_and_record(schedule_id, fire_at).await,
295            OverlapDecision::Skip => Ok(TimerEvaluationOutcome::Skipped),
296            OverlapDecision::BufferPending => {
297                self.pending_ticks.insert(schedule_id.clone());
298                Ok(TimerEvaluationOutcome::Buffered)
299            }
300            OverlapDecision::CancelThenStart(execution) => {
301                self.canceller
302                    .cancel_scheduled_workflow(&execution, "schedule overlap policy CancelPrevious")
303                    .await?;
304                if let Some(state) = self.states.get_mut(schedule_id) {
305                    state.current_execution = None;
306                }
307                self.start_and_record(schedule_id, fire_at).await
308            }
309        }
310    }
311
312    async fn start_and_record(
313        &mut self,
314        schedule_id: &ScheduleId,
315        recorded_at: DateTime<Utc>,
316    ) -> Result<TimerEvaluationOutcome, ScheduleEvaluatorError> {
317        let (workflow_type, input, search_attributes) = {
318            let state = self.states.get(schedule_id).ok_or_else(|| {
319                ScheduleEvaluatorError::ScheduleNotFound {
320                    schedule_id: schedule_id.clone(),
321                }
322            })?;
323            (
324                state.config.workflow_type.clone(),
325                state.config.input.clone(),
326                state.config.search_attributes.clone(),
327            )
328        };
329
330        let execution = self
331            .starter
332            .start_scheduled_workflow(&workflow_type, input, search_attributes)
333            .await?;
334        self.events
335            .record_schedule_triggered(schedule_id, &execution, recorded_at)
336            .await?;
337
338        let state = self.states.get_mut(schedule_id).ok_or_else(|| {
339            ScheduleEvaluatorError::ScheduleNotFound {
340                schedule_id: schedule_id.clone(),
341            }
342        })?;
343        state.record_triggered(execution.clone(), recorded_at);
344        Ok(TimerEvaluationOutcome::Started(execution))
345    }
346
347    async fn advance_and_arm(
348        &mut self,
349        schedule_id: &ScheduleId,
350        after: DateTime<Utc>,
351    ) -> Result<(), ScheduleEvaluatorError> {
352        let state = self.states.get_mut(schedule_id).ok_or_else(|| {
353            ScheduleEvaluatorError::ScheduleNotFound {
354                schedule_id: schedule_id.clone(),
355            }
356        })?;
357        let next = next_fire_time(&state.config.trigger, after)?;
358        state.set_next_trigger_at(next);
359        let state_snapshot = state.clone();
360        self.arm_state_if_active(&state_snapshot).await?;
361        Ok(())
362    }
363
364    /// Reconstructs schedule state from a recovery source, applies catch-up policy, and re-arms
365    /// active schedules.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`ScheduleEvaluatorError`] when source reading, projection, catch-up starts/event
370    /// recording, trigger advancement, or durable timer arming fails.
371    pub async fn recover_on_startup(
372        &mut self,
373        source: &dyn ScheduleEventSource,
374        now: DateTime<Utc>,
375    ) -> Result<(), ScheduleEvaluatorError> {
376        let events = source.schedule_events().await?;
377        self.replace_state_from_events(&events)?;
378        self.recover_projected_state(now).await
379    }
380
381    /// Applies catch-up policy to current projected state and arms active schedules.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`ScheduleEvaluatorError`] when catch-up or timer side effects fail.
386    pub async fn recover_projected_state(
387        &mut self,
388        now: DateTime<Utc>,
389    ) -> Result<(), ScheduleEvaluatorError> {
390        let schedule_ids = self.states.keys().cloned().collect::<Vec<_>>();
391        for schedule_id in schedule_ids {
392            let Some(state) = self.states.get(&schedule_id) else {
393                continue;
394            };
395            if !state.is_active() {
396                continue;
397            }
398            if let Some(state) = self.states.get_mut(&schedule_id) {
399                state.current_execution = None;
400            }
401            let Some(state) = self.states.get(&schedule_id) else {
402                continue;
403            };
404
405            let plan = evaluate_catch_up(
406                &state.config.catch_up_policy,
407                &state.config.trigger,
408                state.next_trigger_at,
409                now,
410            )?;
411
412            for fire_time in plan.fire_times {
413                self.process_fire(&schedule_id, fire_time).await?;
414            }
415
416            if let Some(state) = self.states.get_mut(&schedule_id) {
417                state.set_next_trigger_at(plan.next_trigger_at);
418            }
419            self.arm_active_schedule(&schedule_id).await?;
420        }
421        Ok(())
422    }
423
424    /// Clears the current execution and, if `BufferOne` queued a tick, starts exactly one buffered
425    /// execution immediately.
426    ///
427    /// # Errors
428    ///
429    /// Returns [`ScheduleEvaluatorError`] when starting or event recording the buffered tick fails.
430    pub async fn complete_current_execution(
431        &mut self,
432        schedule_id: &ScheduleId,
433        completed_at: DateTime<Utc>,
434    ) -> Result<Option<ScheduleExecution>, ScheduleEvaluatorError> {
435        let state = self.states.get_mut(schedule_id).ok_or_else(|| {
436            ScheduleEvaluatorError::ScheduleNotFound {
437                schedule_id: schedule_id.clone(),
438            }
439        })?;
440        state.current_execution = None;
441        if !state.is_active() {
442            self.pending_ticks.remove(schedule_id);
443            return Ok(None);
444        }
445
446        if !self.pending_ticks.remove(schedule_id) {
447            return Ok(None);
448        }
449
450        match self.start_and_record(schedule_id, completed_at).await? {
451            TimerEvaluationOutcome::Started(execution) => Ok(Some(execution)),
452            TimerEvaluationOutcome::Skipped
453            | TimerEvaluationOutcome::Buffered
454            | TimerEvaluationOutcome::Inactive => Err(ScheduleEvaluatorError::side_effect(
455                "buffered schedule tick did not start a workflow",
456            )),
457        }
458    }
459}
460
461/// A no-op canceller for evaluators that do not need `CancelPrevious` support in a given test seam.
462pub struct NoopScheduleCanceller;
463
464#[async_trait]
465impl ScheduleWorkflowCanceller for NoopScheduleCanceller {
466    async fn cancel_scheduled_workflow(
467        &self,
468        _execution: &ScheduleExecution,
469        _reason: &str,
470    ) -> Result<(), ScheduleEvaluatorError> {
471        Ok(())
472    }
473}
474
475/// Durable timer adapter backed by the workflow timer store API with a schedule coordinator owner.
476pub struct StoreScheduleTimer<S: ?Sized> {
477    store: Arc<S>,
478    coordinator_workflow_id: WorkflowId,
479}
480
481impl<S: ?Sized> StoreScheduleTimer<S> {
482    /// Creates a store-backed schedule timer adapter.
483    #[must_use]
484    pub fn new(store: Arc<S>, coordinator_workflow_id: WorkflowId) -> Self {
485        Self {
486            store,
487            coordinator_workflow_id,
488        }
489    }
490}
491
492#[async_trait]
493impl<S> ScheduleTimer for StoreScheduleTimer<S>
494where
495    S: aion_store::EventStore + ?Sized,
496{
497    async fn arm_schedule_timer(
498        &self,
499        _schedule_id: &ScheduleId,
500        timer_id: &TimerId,
501        fire_at: DateTime<Utc>,
502    ) -> Result<(), ScheduleEvaluatorError> {
503        self.store
504            .schedule_timer(&self.coordinator_workflow_id, timer_id, fire_at)
505            .await
506            .map_err(|error| ScheduleEvaluatorError::side_effect(error.to_string()))
507    }
508}