1use 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#[derive(thiserror::Error, Debug)]
24pub enum ScheduleEvaluatorError {
25 #[error("schedule trigger error: {0}")]
27 Trigger(#[from] ScheduleError),
28
29 #[error("schedule timer id error: {0}")]
31 TimerId(#[from] aion_core::IdError),
32
33 #[error("schedule `{schedule_id}` was not found")]
35 ScheduleNotFound {
36 schedule_id: ScheduleId,
38 },
39
40 #[error("schedule side effect failed: {reason}")]
42 SideEffect {
43 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#[async_trait]
58pub trait ScheduleTimer: Send + Sync {
59 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#[async_trait]
70pub trait ScheduleWorkflowStarter: Send + Sync {
71 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#[async_trait]
86pub trait ScheduleWorkflowCanceller: Send + Sync {
87 async fn cancel_scheduled_workflow(
89 &self,
90 execution: &ScheduleExecution,
91 reason: &str,
92 ) -> Result<(), ScheduleEvaluatorError>;
93}
94
95#[async_trait]
97pub trait ScheduleEventSink: Send + Sync {
98 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#[async_trait]
109pub trait ScheduleEventSource: Send + Sync {
110 async fn schedule_events(&self) -> Result<Vec<Event>, ScheduleEvaluatorError>;
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum TimerEvaluationOutcome {
117 Started(ScheduleExecution),
119 Skipped,
121 Buffered,
123 Inactive,
125}
126
127pub 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 #[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 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 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 #[must_use]
184 pub fn state(&self, schedule_id: &ScheduleId) -> Option<&ScheduleState> {
185 self.states.get(schedule_id)
186 }
187
188 pub fn states(&self) -> impl Iterator<Item = &ScheduleState> {
190 self.states.values()
191 }
192
193 #[must_use]
195 pub fn has_pending_tick(&self, schedule_id: &ScheduleId) -> bool {
196 self.pending_ticks.contains(schedule_id)
197 }
198
199 pub fn timer_id_for(schedule_id: &ScheduleId) -> Result<TimerId, aion_core::IdError> {
206 TimerId::named(format!("schedule:{schedule_id}"))
207 }
208
209 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 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 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 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 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
461pub 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
475pub struct StoreScheduleTimer<S: ?Sized> {
477 store: Arc<S>,
478 coordinator_workflow_id: WorkflowId,
479}
480
481impl<S: ?Sized> StoreScheduleTimer<S> {
482 #[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}