1pub mod cron;
10pub mod dependency;
11pub mod event;
12pub mod interval;
13
14use crate::engine::WorkflowDefinition;
15use crate::error::{Result, WorkflowError};
16use chrono::{DateTime, Utc};
17use dashmap::DashMap;
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20use tokio::sync::RwLock;
21use uuid::Uuid;
22
23pub use self::cron::{CronSchedule, CronScheduler};
24pub use self::dependency::{DependencyScheduler, WorkflowDependency};
25pub use self::event::{EventScheduler, EventTrigger};
26pub use self::interval::{IntervalSchedule, IntervalScheduler};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SchedulerConfig {
31 pub max_concurrent_executions: usize,
33 pub handle_missed_executions: bool,
35 pub max_missed_executions: usize,
37 pub execution_timeout_secs: u64,
39 pub enable_persistence: bool,
41 pub persistence_path: Option<String>,
43 pub tick_interval_ms: u64,
45 pub timezone: String,
47}
48
49impl Default for SchedulerConfig {
50 fn default() -> Self {
51 Self {
52 max_concurrent_executions: 100,
53 handle_missed_executions: true,
54 max_missed_executions: 10,
55 execution_timeout_secs: 3600,
56 enable_persistence: true,
57 persistence_path: None,
58 tick_interval_ms: 100,
59 timezone: "UTC".to_string(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ScheduledWorkflow {
67 pub schedule_id: String,
69 pub workflow: WorkflowDefinition,
71 pub schedule_type: ScheduleType,
73 pub enabled: bool,
75 pub last_execution: Option<DateTime<Utc>>,
77 pub next_execution: Option<DateTime<Utc>>,
79 pub execution_history: Vec<ScheduleExecution>,
81 pub max_history: usize,
83 pub metadata: ScheduleMetadata,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(tag = "type")]
90pub enum ScheduleType {
91 Cron {
93 expression: String,
95 },
96 Interval {
98 interval_secs: u64,
100 },
101 Event {
103 event_pattern: String,
105 },
106 Manual,
108 Dependency {
110 dependencies: Vec<String>,
112 },
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ScheduleExecution {
118 pub execution_id: String,
120 pub start_time: DateTime<Utc>,
122 pub end_time: Option<DateTime<Utc>>,
124 pub status: ExecutionStatus,
126 pub error_message: Option<String>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum ExecutionStatus {
133 Pending,
135 Running,
137 Success,
139 Failed,
141 Cancelled,
143 TimedOut,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ScheduleMetadata {
150 pub created_at: DateTime<Utc>,
152 pub updated_at: DateTime<Utc>,
154 pub created_by: String,
156 pub description: Option<String>,
158 pub tags: Vec<String>,
160}
161
162pub struct Scheduler {
164 config: SchedulerConfig,
165 schedules: Arc<DashMap<String, ScheduledWorkflow>>,
166 cron_scheduler: Arc<RwLock<CronScheduler>>,
167 interval_scheduler: Arc<RwLock<IntervalScheduler>>,
168 event_scheduler: Arc<RwLock<EventScheduler>>,
169 dependency_scheduler: Arc<RwLock<DependencyScheduler>>,
170 running: Arc<RwLock<bool>>,
171}
172
173impl Scheduler {
174 pub fn new(config: SchedulerConfig) -> Self {
176 Self {
177 config: config.clone(),
178 schedules: Arc::new(DashMap::new()),
179 cron_scheduler: Arc::new(RwLock::new(CronScheduler::new(config.clone()))),
180 interval_scheduler: Arc::new(RwLock::new(IntervalScheduler::new(config.clone()))),
181 event_scheduler: Arc::new(RwLock::new(EventScheduler::new(config.clone()))),
182 dependency_scheduler: Arc::new(RwLock::new(DependencyScheduler::new(config.clone()))),
183 running: Arc::new(RwLock::new(false)),
184 }
185 }
186
187 pub fn with_defaults() -> Self {
189 Self::new(SchedulerConfig::default())
190 }
191
192 pub async fn add_schedule(
194 &self,
195 workflow: WorkflowDefinition,
196 schedule_type: ScheduleType,
197 ) -> Result<String> {
198 let schedule_id = Uuid::new_v4().to_string();
199 let now = Utc::now();
200
201 let next_execution = match &schedule_type {
202 ScheduleType::Cron { expression } => {
203 let scheduler = self.cron_scheduler.write().await;
204 scheduler.calculate_next_execution(expression, now)?
205 }
206 ScheduleType::Interval { interval_secs } => Some(
207 now + chrono::Duration::try_seconds(*interval_secs as i64)
208 .ok_or_else(|| WorkflowError::scheduling("Invalid interval"))?,
209 ),
210 ScheduleType::Event { .. } | ScheduleType::Dependency { .. } => None,
211 ScheduleType::Manual => None,
212 };
213
214 let scheduled = ScheduledWorkflow {
215 schedule_id: schedule_id.clone(),
216 workflow,
217 schedule_type,
218 enabled: true,
219 last_execution: None,
220 next_execution,
221 execution_history: Vec::new(),
222 max_history: 100,
223 metadata: ScheduleMetadata {
224 created_at: now,
225 updated_at: now,
226 created_by: "system".to_string(),
227 description: None,
228 tags: Vec::new(),
229 },
230 };
231
232 self.schedules.insert(schedule_id.clone(), scheduled);
233
234 if self.config.enable_persistence {
235 self.persist_state().await?;
236 }
237
238 Ok(schedule_id)
239 }
240
241 pub async fn remove_schedule(&self, schedule_id: &str) -> Result<()> {
243 self.schedules
244 .remove(schedule_id)
245 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
246
247 if self.config.enable_persistence {
248 self.persist_state().await?;
249 }
250
251 Ok(())
252 }
253
254 pub async fn enable_schedule(&self, schedule_id: &str) -> Result<()> {
256 let mut schedule = self
257 .schedules
258 .get_mut(schedule_id)
259 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
260 schedule.enabled = true;
261 schedule.metadata.updated_at = Utc::now();
262 Ok(())
263 }
264
265 pub async fn disable_schedule(&self, schedule_id: &str) -> Result<()> {
267 let mut schedule = self
268 .schedules
269 .get_mut(schedule_id)
270 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
271 schedule.enabled = false;
272 schedule.metadata.updated_at = Utc::now();
273 Ok(())
274 }
275
276 pub async fn start(&self) -> Result<()> {
278 let mut running = self.running.write().await;
279 if *running {
280 return Err(WorkflowError::scheduling("Scheduler already running"));
281 }
282 *running = true;
283 drop(running);
284
285 let cron_scheduler = self.cron_scheduler.clone();
287 let interval_scheduler = self.interval_scheduler.clone();
288 let event_scheduler = self.event_scheduler.clone();
289 let dependency_scheduler = self.dependency_scheduler.clone();
290
291 tokio::spawn(async move {
292 let _ = cron_scheduler.write().await;
293 });
295
296 tokio::spawn(async move {
297 let _ = interval_scheduler.write().await;
298 });
300
301 tokio::spawn(async move {
302 let _ = event_scheduler.write().await;
303 });
305
306 tokio::spawn(async move {
307 let _ = dependency_scheduler.write().await;
308 });
310
311 Ok(())
312 }
313
314 pub async fn stop(&self) -> Result<()> {
316 let mut running = self.running.write().await;
317 if !*running {
318 return Err(WorkflowError::scheduling("Scheduler not running"));
319 }
320 *running = false;
321 Ok(())
322 }
323
324 pub async fn is_running(&self) -> bool {
326 *self.running.read().await
327 }
328
329 pub fn get_schedules(&self) -> Vec<ScheduledWorkflow> {
331 self.schedules
332 .iter()
333 .map(|entry| entry.value().clone())
334 .collect()
335 }
336
337 pub fn get_schedule(&self, schedule_id: &str) -> Option<ScheduledWorkflow> {
339 self.schedules.get(schedule_id).map(|entry| entry.clone())
340 }
341
342 pub async fn trigger_manual(&self, schedule_id: &str) -> Result<String> {
344 let schedule = self
345 .schedules
346 .get(schedule_id)
347 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
348
349 if !schedule.enabled {
350 return Err(WorkflowError::scheduling("Schedule is disabled"));
351 }
352
353 let execution_id = Uuid::new_v4().to_string();
354
355 let execution = ScheduleExecution {
357 execution_id: execution_id.clone(),
358 start_time: Utc::now(),
359 end_time: None,
360 status: ExecutionStatus::Pending,
361 error_message: None,
362 };
363
364 drop(schedule);
365
366 let mut schedule_mut = self
367 .schedules
368 .get_mut(schedule_id)
369 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
370 schedule_mut.execution_history.push(execution);
371 if schedule_mut.execution_history.len() > schedule_mut.max_history {
372 schedule_mut.execution_history.remove(0);
373 }
374
375 Ok(execution_id)
376 }
377
378 pub async fn update_execution_status(
380 &self,
381 schedule_id: &str,
382 execution_id: &str,
383 status: ExecutionStatus,
384 error_message: Option<String>,
385 ) -> Result<()> {
386 let mut schedule = self
387 .schedules
388 .get_mut(schedule_id)
389 .ok_or_else(|| WorkflowError::not_found(schedule_id))?;
390
391 if let Some(execution) = schedule
392 .execution_history
393 .iter_mut()
394 .find(|e| e.execution_id == execution_id)
395 {
396 execution.status = status;
397 execution.error_message = error_message;
398 if matches!(
399 status,
400 ExecutionStatus::Success
401 | ExecutionStatus::Failed
402 | ExecutionStatus::Cancelled
403 | ExecutionStatus::TimedOut
404 ) {
405 execution.end_time = Some(Utc::now());
406 }
407 }
408
409 drop(schedule);
411
412 if self.config.enable_persistence {
413 if let Err(e) = self.persist_state().await {
414 tracing::warn!(
415 "scheduler: failed to persist state after status update: {}",
416 e
417 );
418 }
419 }
420
421 Ok(())
422 }
423
424 async fn persist_state(&self) -> Result<()> {
430 let path_str = match &self.config.persistence_path {
431 Some(p) => p.clone(),
432 None => return Ok(()),
433 };
434
435 let path = std::path::PathBuf::from(&path_str);
436
437 if let Some(parent) = path.parent() {
439 if !parent.as_os_str().is_empty() {
440 tokio::fs::create_dir_all(parent).await?;
441 }
442 }
443
444 let mut snapshot: Vec<ScheduledWorkflow> = self
446 .schedules
447 .iter()
448 .map(|entry| entry.value().clone())
449 .collect();
450 snapshot.sort_by(|a, b| a.schedule_id.cmp(&b.schedule_id));
451
452 let mut payload = String::with_capacity(snapshot.len() * 256);
454 for workflow in &snapshot {
455 let line = serde_json::to_string(workflow)?;
456 payload.push_str(&line);
457 payload.push('\n');
458 }
459
460 let tmp_path = {
462 let mut p = path.clone();
463 let ext = match p.extension() {
464 Some(e) => format!("{}.tmp", e.to_string_lossy()),
465 None => "tmp".to_string(),
466 };
467 p.set_extension(ext);
468 p
469 };
470
471 tokio::fs::write(&tmp_path, payload.as_bytes()).await?;
472 tokio::fs::rename(&tmp_path, &path).await?;
473
474 tracing::debug!(
475 "scheduler::persist_state: wrote {} schedules to {}",
476 snapshot.len(),
477 path.display()
478 );
479
480 Ok(())
481 }
482
483 pub async fn load_state(&self) -> Result<()> {
490 let path_str = match &self.config.persistence_path {
491 Some(p) => p.clone(),
492 None => return Ok(()),
493 };
494
495 let path = std::path::PathBuf::from(&path_str);
496
497 if !path.exists() {
499 return Ok(());
500 }
501
502 let content = tokio::fs::read_to_string(&path).await?;
503 let mut loaded_count = 0usize;
504 let mut failed_count = 0usize;
505
506 for (line_no, raw_line) in content.lines().enumerate() {
507 let line = raw_line.trim();
508 if line.is_empty() {
509 continue;
510 }
511
512 match serde_json::from_str::<ScheduledWorkflow>(line) {
513 Ok(workflow) => {
514 self.schedules
515 .insert(workflow.schedule_id.clone(), workflow);
516 loaded_count += 1;
517 }
518 Err(e) => {
519 tracing::warn!(
520 "scheduler::load_state: skipping corrupt line {} in {}: {}",
521 line_no + 1,
522 path.display(),
523 e
524 );
525 failed_count += 1;
526 }
527 }
528 }
529
530 if failed_count > 0 {
531 tracing::warn!(
532 "scheduler::load_state: {}/{} lines failed to parse in {}",
533 failed_count,
534 loaded_count + failed_count,
535 path.display()
536 );
537 }
538
539 tracing::debug!(
540 "scheduler::load_state: loaded {} schedules from {}",
541 loaded_count,
542 path.display()
543 );
544
545 Ok(())
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::dag::WorkflowDag;
553
554 #[tokio::test]
555 async fn test_scheduler_creation() {
556 let scheduler = Scheduler::with_defaults();
557 assert!(!scheduler.is_running().await);
558 }
559
560 #[tokio::test]
561 async fn test_add_remove_schedule() {
562 let scheduler = Scheduler::with_defaults();
563 let workflow = WorkflowDefinition {
564 id: "test-workflow".to_string(),
565 name: "Test Workflow".to_string(),
566 description: None,
567 version: "1.0.0".to_string(),
568 dag: WorkflowDag::new(),
569 };
570
571 let schedule_id = scheduler
572 .add_schedule(workflow, ScheduleType::Manual)
573 .await
574 .expect("Failed to add schedule");
575
576 assert!(scheduler.get_schedule(&schedule_id).is_some());
577
578 scheduler
579 .remove_schedule(&schedule_id)
580 .await
581 .expect("Failed to remove schedule");
582
583 assert!(scheduler.get_schedule(&schedule_id).is_none());
584 }
585
586 #[tokio::test]
587 async fn test_enable_disable_schedule() {
588 let scheduler = Scheduler::with_defaults();
589 let workflow = WorkflowDefinition {
590 id: "test-workflow".to_string(),
591 name: "Test Workflow".to_string(),
592 description: None,
593 version: "1.0.0".to_string(),
594 dag: WorkflowDag::new(),
595 };
596
597 let schedule_id = scheduler
598 .add_schedule(workflow, ScheduleType::Manual)
599 .await
600 .expect("Failed to add schedule");
601
602 scheduler
603 .disable_schedule(&schedule_id)
604 .await
605 .expect("Failed to disable");
606 assert!(
607 !scheduler
608 .get_schedule(&schedule_id)
609 .is_some_and(|s| s.enabled)
610 );
611
612 scheduler
613 .enable_schedule(&schedule_id)
614 .await
615 .expect("Failed to enable");
616 assert!(
617 scheduler
618 .get_schedule(&schedule_id)
619 .is_some_and(|s| s.enabled)
620 );
621 }
622}