Skip to main content

celers_beat/
task.rs

1//! Scheduled task types
2//!
3//! Contains `ScheduledTask`, `TaskOptions`, and related helper types
4//! for defining and managing scheduled tasks.
5
6use crate::alert::AlertConfig;
7use crate::config::ScheduleError;
8use crate::history::{
9    CatchupPolicy, DependencyStatus, ExecutionRecord, ExecutionResult, ExecutionState,
10    HealthCheckResult, Jitter, RetryPolicy, ScheduleHealth, ScheduleVersion,
11};
12use crate::schedule::{BusinessCalendar, HolidayCalendar, Schedule};
13use crate::wfq::{TaskWeight, WFQState};
14use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::{HashMap, HashSet};
17
18/// Scheduled task
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ScheduledTask {
21    /// Task name
22    pub name: String,
23
24    /// Task schedule
25    pub schedule: Schedule,
26
27    /// Task arguments
28    #[serde(default)]
29    pub args: Vec<serde_json::Value>,
30
31    /// Task keyword arguments
32    #[serde(default)]
33    pub kwargs: HashMap<String, serde_json::Value>,
34
35    /// Task options
36    #[serde(default)]
37    pub options: TaskOptions,
38
39    /// Last run timestamp
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub last_run_at: Option<DateTime<Utc>>,
42
43    /// Total run count
44    #[serde(default)]
45    pub total_run_count: u64,
46
47    /// Enabled flag
48    #[serde(default = "default_true")]
49    pub enabled: bool,
50
51    /// Jitter configuration
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub jitter: Option<Jitter>,
54
55    /// Catch-up policy for missed schedules
56    #[serde(default)]
57    pub catchup_policy: CatchupPolicy,
58
59    /// Task group (for organizational purposes)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub group: Option<String>,
62
63    /// Task tags (for filtering and categorization)
64    #[serde(default)]
65    pub tags: HashSet<String>,
66
67    /// Retry policy for failed executions
68    #[serde(default)]
69    pub retry_policy: RetryPolicy,
70
71    /// Current retry attempt count
72    #[serde(default)]
73    pub retry_count: u32,
74
75    /// Last failure timestamp
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub last_failure_at: Option<DateTime<Utc>>,
78
79    /// Total failure count
80    #[serde(default)]
81    pub total_failure_count: u64,
82
83    /// Execution history (last N executions)
84    #[serde(default)]
85    pub execution_history: Vec<ExecutionRecord>,
86
87    /// Maximum number of history records to keep (0 = unlimited)
88    #[serde(default)]
89    pub max_history_size: usize,
90
91    /// Version history (tracks schedule modifications)
92    #[serde(default)]
93    pub version_history: Vec<ScheduleVersion>,
94
95    /// Current version number
96    #[serde(default = "default_version")]
97    pub current_version: u32,
98
99    /// Task dependencies (task names this task depends on)
100    #[serde(default)]
101    pub dependencies: HashSet<String>,
102
103    /// Whether to wait for all dependencies before running
104    #[serde(default = "default_true")]
105    pub wait_for_dependencies: bool,
106
107    /// Cached next run time (for performance optimization)
108    #[serde(skip)]
109    pub(crate) cached_next_run: Option<DateTime<Utc>>,
110
111    /// Alert configuration for this task
112    #[serde(default)]
113    pub alert_config: AlertConfig,
114
115    /// Current execution state (for crash recovery)
116    #[serde(default)]
117    pub execution_state: ExecutionState,
118
119    /// Weighted Fair Queuing state
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub wfq_state: Option<WFQState>,
122}
123
124#[allow(dead_code)]
125fn default_true() -> bool {
126    true
127}
128
129fn default_version() -> u32 {
130    1
131}
132
133impl ScheduledTask {
134    pub fn new(name: String, schedule: Schedule) -> Self {
135        let mut task = Self {
136            name,
137            schedule: schedule.clone(),
138            args: Vec::new(),
139            kwargs: HashMap::new(),
140            options: TaskOptions::default(),
141            last_run_at: None,
142            total_run_count: 0,
143            enabled: true,
144            jitter: None,
145            catchup_policy: CatchupPolicy::default(),
146            group: None,
147            tags: HashSet::new(),
148            retry_policy: RetryPolicy::default(),
149            retry_count: 0,
150            last_failure_at: None,
151            total_failure_count: 0,
152            execution_history: Vec::new(),
153            max_history_size: 0, // 0 = unlimited
154            version_history: Vec::new(),
155            current_version: 1,
156            dependencies: HashSet::new(),
157            wait_for_dependencies: true,
158            cached_next_run: None,
159            alert_config: AlertConfig::default(),
160            execution_state: ExecutionState::default(),
161            wfq_state: None,
162        };
163
164        // Create initial version
165        let initial_version =
166            ScheduleVersion::from_task(&task, 1, Some("Initial creation".to_string()));
167        task.version_history.push(initial_version);
168
169        task
170    }
171
172    pub fn with_args(mut self, args: Vec<serde_json::Value>) -> Self {
173        self.args = args;
174        self
175    }
176
177    pub fn with_kwargs(mut self, kwargs: HashMap<String, serde_json::Value>) -> Self {
178        self.kwargs = kwargs;
179        self
180    }
181
182    pub fn disabled(mut self) -> Self {
183        self.enabled = false;
184        self
185    }
186
187    pub fn with_queue(mut self, queue: String) -> Self {
188        self.options.queue = Some(queue);
189        self
190    }
191
192    pub fn with_priority(mut self, priority: u8) -> Self {
193        self.options.priority = Some(priority);
194        self
195    }
196
197    pub fn with_expires(mut self, expires: u64) -> Self {
198        self.options.expires = Some(expires);
199        self
200    }
201
202    /// Add jitter to avoid thundering herd
203    pub fn with_jitter(mut self, jitter: Jitter) -> Self {
204        self.jitter = Some(jitter);
205        self
206    }
207
208    /// Set catch-up policy for missed schedules
209    pub fn with_catchup_policy(mut self, policy: CatchupPolicy) -> Self {
210        self.catchup_policy = policy;
211        self
212    }
213
214    /// Set retry policy for failed executions
215    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
216        self.retry_policy = policy;
217        self
218    }
219
220    /// Set task group
221    pub fn with_group(mut self, group: String) -> Self {
222        self.group = Some(group);
223        self
224    }
225
226    /// Add a single tag
227    pub fn with_tag(mut self, tag: String) -> Self {
228        self.tags.insert(tag);
229        self
230    }
231
232    /// Add multiple tags
233    pub fn with_tags(mut self, tags: HashSet<String>) -> Self {
234        self.tags = tags;
235        self
236    }
237
238    /// Set alert configuration
239    pub fn with_alert_config(mut self, config: AlertConfig) -> Self {
240        self.alert_config = config;
241        self
242    }
243
244    /// Add a tag to existing tags
245    pub fn add_tag(&mut self, tag: String) {
246        self.tags.insert(tag);
247    }
248
249    /// Remove a tag
250    pub fn remove_tag(&mut self, tag: &str) -> bool {
251        self.tags.remove(tag)
252    }
253
254    /// Check if task has a specific tag
255    pub fn has_tag(&self, tag: &str) -> bool {
256        self.tags.contains(tag)
257    }
258
259    /// Check if task is in a specific group
260    pub fn is_in_group(&self, group: &str) -> bool {
261        self.group.as_deref() == Some(group)
262    }
263
264    /// Check if task is due to run
265    pub fn is_due(&self) -> Result<bool, ScheduleError> {
266        // Tasks that have never run are due immediately
267        if self.last_run_at.is_none() {
268            return Ok(true);
269        }
270        let mut next_run = self.schedule.next_run(self.last_run_at)?;
271
272        // Apply jitter if configured
273        if let Some(ref jitter) = self.jitter {
274            next_run = jitter.apply(next_run, &self.name);
275        }
276
277        Ok(Utc::now() >= next_run)
278    }
279
280    /// Get the next scheduled run time (with jitter if configured)
281    pub fn next_run_time(&self) -> Result<DateTime<Utc>, ScheduleError> {
282        // Return cached value if available
283        if let Some(cached) = self.cached_next_run {
284            return Ok(cached);
285        }
286
287        // Calculate and return (but don't cache in immutable self)
288        let mut next_run = self.schedule.next_run(self.last_run_at)?;
289
290        // Apply jitter if configured
291        if let Some(ref jitter) = self.jitter {
292            next_run = jitter.apply(next_run, &self.name);
293        }
294
295        Ok(next_run)
296    }
297
298    /// Calculate and cache the next run time
299    ///
300    /// This method calculates the next run time and caches it for future calls.
301    /// The cache is invalidated when the schedule changes or the task is executed.
302    pub fn update_next_run_cache(&mut self) {
303        if let Ok(next_run) = self.next_run_time_uncached() {
304            self.cached_next_run = Some(next_run);
305        } else {
306            self.cached_next_run = None;
307        }
308    }
309
310    /// Calculate next run time without using cache
311    fn next_run_time_uncached(&self) -> Result<DateTime<Utc>, ScheduleError> {
312        let mut next_run = self.schedule.next_run(self.last_run_at)?;
313
314        // Apply jitter if configured
315        if let Some(ref jitter) = self.jitter {
316            next_run = jitter.apply(next_run, &self.name);
317        }
318
319        Ok(next_run)
320    }
321
322    /// Invalidate the next run time cache
323    ///
324    /// This should be called whenever the schedule changes or the task is executed.
325    pub fn invalidate_next_run_cache(&mut self) {
326        self.cached_next_run = None;
327    }
328
329    /// Check if task is enabled
330    pub fn is_enabled(&self) -> bool {
331        self.enabled
332    }
333
334    /// Check if task has run at least once
335    pub fn has_run(&self) -> bool {
336        self.last_run_at.is_some()
337    }
338
339    /// Check if task has custom options
340    pub fn has_options(&self) -> bool {
341        self.options.has_queue() || self.options.has_priority() || self.options.has_expires()
342    }
343
344    /// Get age since last run (if task has run)
345    pub fn age_since_last_run(&self) -> Option<chrono::Duration> {
346        self.last_run_at.map(|last_run| Utc::now() - last_run)
347    }
348
349    /// Mark task execution as successful
350    pub fn mark_success(&mut self) {
351        self.retry_count = 0; // Reset retry counter on success
352    }
353
354    /// Mark task execution as failed
355    pub fn mark_failure(&mut self) {
356        self.last_failure_at = Some(Utc::now());
357        self.total_failure_count += 1;
358        self.retry_count += 1;
359    }
360
361    /// Check if task should be retried
362    pub fn should_retry(&self) -> bool {
363        self.retry_policy.should_retry(self.retry_count)
364    }
365
366    /// Begin task execution (for crash recovery tracking)
367    ///
368    /// Marks the task as running and records the start time. This allows
369    /// detection of interrupted executions after a crash.
370    ///
371    /// # Arguments
372    /// * `timeout_seconds` - Optional timeout in seconds for execution detection
373    pub fn begin_execution(&mut self, timeout_seconds: Option<u64>) {
374        let timeout_after = timeout_seconds.map(|secs| Utc::now() + Duration::seconds(secs as i64));
375        self.execution_state = ExecutionState::Running {
376            started_at: Utc::now(),
377            timeout_after,
378        };
379    }
380
381    /// Complete task execution (for crash recovery tracking)
382    ///
383    /// Marks the task as idle and records the execution result. This should be
384    /// called after every execution (success, failure, or timeout).
385    pub fn complete_execution(&mut self) {
386        self.execution_state = ExecutionState::Idle;
387    }
388
389    /// Detect if this task has an interrupted execution
390    ///
391    /// Returns true if the task is marked as running but should have completed
392    /// based on timeout or time elapsed.
393    ///
394    /// # Returns
395    /// * `true` if execution appears to be interrupted
396    /// * `false` if execution is idle or still valid
397    pub fn detect_interrupted_execution(&self) -> bool {
398        match &self.execution_state {
399            ExecutionState::Idle => false,
400            ExecutionState::Running {
401                started_at,
402                timeout_after,
403            } => {
404                // Check explicit timeout
405                if let Some(timeout) = timeout_after {
406                    if Utc::now() > *timeout {
407                        return true;
408                    }
409                }
410
411                // Check if running for unreasonably long (fallback detection)
412                let running_duration = Utc::now() - *started_at;
413                let max_reasonable_duration = Duration::hours(24); // 24 hours max
414                running_duration > max_reasonable_duration
415            }
416        }
417    }
418
419    /// Recover from interrupted execution
420    ///
421    /// Handles cleanup and recording of an interrupted execution.
422    /// Creates an execution record marked as Interrupted and resets state.
423    ///
424    /// # Returns
425    /// Duration the task was running before interruption
426    pub fn recover_from_interruption(&mut self) -> Option<Duration> {
427        match &self.execution_state {
428            ExecutionState::Running { started_at, .. } => {
429                let duration = Utc::now() - *started_at;
430
431                // Record the interrupted execution in history
432                let record = ExecutionRecord::completed(*started_at, ExecutionResult::Interrupted);
433                self.execution_history.push(record);
434
435                // Trim history if needed
436                if self.max_history_size > 0 && self.execution_history.len() > self.max_history_size
437                {
438                    let remove_count = self.execution_history.len() - self.max_history_size;
439                    self.execution_history.drain(0..remove_count);
440                }
441
442                // Reset execution state
443                self.execution_state = ExecutionState::Idle;
444
445                // Increment retry count for interrupted executions
446                self.retry_count += 1;
447
448                Some(duration)
449            }
450            ExecutionState::Idle => None,
451        }
452    }
453
454    /// Check if task is ready for retry after interruption
455    pub fn is_ready_for_retry_after_crash(&self) -> bool {
456        // Task should be retried if it was interrupted and retry policy allows
457        if !self.execution_history.is_empty() {
458            if let Some(last_exec) = self.execution_history.last() {
459                if last_exec.is_interrupted() && self.should_retry() {
460                    return true;
461                }
462            }
463        }
464        false
465    }
466
467    /// Get next retry delay in seconds
468    pub fn next_retry_delay(&self) -> Option<u64> {
469        self.retry_policy.next_retry_delay(self.retry_count)
470    }
471
472    /// Calculate next retry time based on last failure
473    pub fn next_retry_time(&self) -> Option<DateTime<Utc>> {
474        if let (Some(last_failure), Some(delay)) = (self.last_failure_at, self.next_retry_delay()) {
475            Some(last_failure + Duration::seconds(delay as i64))
476        } else {
477            None
478        }
479    }
480
481    /// Check if task is ready for retry
482    pub fn is_ready_for_retry(&self) -> bool {
483        if !self.should_retry() {
484            return false;
485        }
486
487        if let Some(next_retry) = self.next_retry_time() {
488            Utc::now() >= next_retry
489        } else {
490            false
491        }
492    }
493
494    /// Get failure rate (0.0 to 1.0)
495    pub fn failure_rate(&self) -> f64 {
496        let total_attempts = self.total_run_count + self.total_failure_count;
497        if total_attempts == 0 {
498            0.0
499        } else {
500            self.total_failure_count as f64 / total_attempts as f64
501        }
502    }
503
504    /// Set maximum history size
505    pub fn with_max_history(mut self, max_size: usize) -> Self {
506        self.max_history_size = max_size;
507        self
508    }
509
510    /// Add execution record to history
511    pub fn add_execution_record(&mut self, record: ExecutionRecord) {
512        self.execution_history.push(record);
513
514        // Trim history if needed
515        if self.max_history_size > 0 && self.execution_history.len() > self.max_history_size {
516            let remove_count = self.execution_history.len() - self.max_history_size;
517            self.execution_history.drain(0..remove_count);
518        }
519    }
520
521    /// Get last N execution records
522    pub fn get_last_executions(&self, n: usize) -> &[ExecutionRecord] {
523        let start = self.execution_history.len().saturating_sub(n);
524        &self.execution_history[start..]
525    }
526
527    /// Get all execution records
528    pub fn get_all_executions(&self) -> &[ExecutionRecord] {
529        &self.execution_history
530    }
531
532    /// Get success count from history
533    pub fn history_success_count(&self) -> usize {
534        self.execution_history
535            .iter()
536            .filter(|r| r.is_success())
537            .count()
538    }
539
540    /// Get failure count from history
541    pub fn history_failure_count(&self) -> usize {
542        self.execution_history
543            .iter()
544            .filter(|r| r.is_failure())
545            .count()
546    }
547
548    /// Get consecutive failure count from the end of history
549    ///
550    /// Returns the number of consecutive failures at the end of the execution history.
551    /// If the last execution was successful or if there's no history, returns 0.
552    pub fn consecutive_failure_count(&self) -> u32 {
553        let mut count = 0u32;
554        for record in self.execution_history.iter().rev() {
555            if record.is_failure() {
556                count += 1;
557            } else {
558                break;
559            }
560        }
561        count
562    }
563
564    /// Get timeout count from history
565    pub fn history_timeout_count(&self) -> usize {
566        self.execution_history
567            .iter()
568            .filter(|r| r.is_timeout())
569            .count()
570    }
571
572    /// Get average execution duration (in milliseconds)
573    pub fn average_duration_ms(&self) -> Option<u64> {
574        let durations: Vec<u64> = self
575            .execution_history
576            .iter()
577            .filter_map(|r| r.duration_ms)
578            .collect();
579
580        if durations.is_empty() {
581            None
582        } else {
583            Some(durations.iter().sum::<u64>() / durations.len() as u64)
584        }
585    }
586
587    /// Get minimum execution duration (in milliseconds)
588    pub fn min_duration_ms(&self) -> Option<u64> {
589        self.execution_history
590            .iter()
591            .filter_map(|r| r.duration_ms)
592            .min()
593    }
594
595    /// Get maximum execution duration (in milliseconds)
596    pub fn max_duration_ms(&self) -> Option<u64> {
597        self.execution_history
598            .iter()
599            .filter_map(|r| r.duration_ms)
600            .max()
601    }
602
603    /// Get recent success rate from history (0.0 to 1.0)
604    pub fn history_success_rate(&self) -> f64 {
605        let total = self.execution_history.len();
606        if total == 0 {
607            0.0
608        } else {
609            self.history_success_count() as f64 / total as f64
610        }
611    }
612
613    /// Clear execution history
614    pub fn clear_history(&mut self) {
615        self.execution_history.clear();
616    }
617
618    /// Perform health check on this task
619    pub fn check_health(&self) -> HealthCheckResult {
620        let mut issues = Vec::new();
621
622        // Check if schedule can calculate next run
623        let next_run = match self.next_run_time() {
624            Ok(next) => Some(next),
625            Err(e) => {
626                issues.push(format!("Cannot calculate next run: {}", e));
627                None
628            }
629        };
630
631        // Check if task is stuck (hasn't run in a very long time)
632        if let Some(stuck_duration) = self.is_stuck() {
633            issues.push(format!(
634                "Task may be stuck (no execution in {} hours)",
635                stuck_duration.num_hours()
636            ));
637        }
638
639        // Check if task has high failure rate
640        let failure_rate = self.failure_rate();
641        let total_attempts = self.total_run_count + self.total_failure_count;
642        if failure_rate > 0.5 && total_attempts >= 10 {
643            issues.push(format!(
644                "High failure rate: {:.1}% ({} failures out of {} total)",
645                failure_rate * 100.0,
646                self.total_failure_count,
647                total_attempts
648            ));
649        }
650
651        // Check recent history for consecutive failures
652        if self.execution_history.len() >= 3 {
653            let last_3 = &self.execution_history[self.execution_history.len() - 3..];
654            if last_3.iter().all(|r| r.is_failure() || r.is_timeout()) {
655                issues.push("Last 3 executions failed".to_string());
656            }
657        }
658
659        // Determine overall health status
660        let health = if !self.enabled {
661            ScheduleHealth::Warning {
662                issues: vec!["Task is disabled".to_string()],
663            }
664        } else if issues.is_empty() {
665            ScheduleHealth::Healthy
666        } else if issues.iter().any(|i| i.contains("Cannot calculate")) {
667            ScheduleHealth::Unhealthy { issues }
668        } else {
669            ScheduleHealth::Warning { issues }
670        };
671
672        let time_since_last_run = self.age_since_last_run();
673
674        let mut result = HealthCheckResult::new(self.name.clone(), health);
675        if let Some(next) = next_run {
676            result = result.with_next_run(next);
677        }
678        if let Some(duration) = time_since_last_run {
679            result = result.with_time_since_last_run(duration);
680        }
681        result
682    }
683
684    /// Check if task is stuck (hasn't executed in a long time despite being enabled)
685    /// Returns Some(duration) if stuck, None otherwise
686    pub fn is_stuck(&self) -> Option<chrono::Duration> {
687        if !self.enabled {
688            return None;
689        }
690
691        if let Some(last_run) = self.last_run_at {
692            let age = Utc::now() - last_run;
693
694            // Determine expected run frequency
695            let expected_interval = match &self.schedule {
696                Schedule::Interval { every } => Duration::seconds(*every as i64),
697                #[cfg(feature = "cron")]
698                Schedule::Crontab { .. } => Duration::hours(24), // Assume daily as threshold
699                #[cfg(feature = "solar")]
700                Schedule::Solar { .. } => Duration::hours(24), // Solar events are daily
701                Schedule::OneTime { .. } => return None, // One-time schedules can't be stuck
702            };
703
704            // Task is stuck if it hasn't run in 10x the expected interval
705            let stuck_threshold = expected_interval * 10;
706            if age > stuck_threshold {
707                return Some(age);
708            }
709        }
710
711        None
712    }
713
714    /// Validate schedule syntax and configuration
715    pub fn validate_schedule(&self) -> Result<(), ScheduleError> {
716        // Try to calculate next run to validate schedule
717        self.schedule.next_run(self.last_run_at)?;
718        Ok(())
719    }
720
721    /// Update schedule and create a new version
722    pub fn update_schedule(&mut self, new_schedule: Schedule, change_reason: Option<String>) {
723        self.current_version += 1;
724        self.schedule = new_schedule;
725
726        // Invalidate and update cache after schedule change
727        self.update_next_run_cache();
728
729        let version = ScheduleVersion::from_task(self, self.current_version, change_reason);
730        self.version_history.push(version);
731    }
732
733    /// Update schedule configuration (enabled, jitter, catchup) and create a new version
734    pub fn update_config(
735        &mut self,
736        enabled: Option<bool>,
737        jitter: Option<Option<Jitter>>,
738        catchup_policy: Option<CatchupPolicy>,
739        change_reason: Option<String>,
740    ) {
741        let mut jitter_changed = false;
742        if let Some(e) = enabled {
743            self.enabled = e;
744        }
745        if let Some(j) = jitter {
746            self.jitter = j;
747            jitter_changed = true;
748        }
749        if let Some(c) = catchup_policy {
750            self.catchup_policy = c;
751        }
752
753        // Update cache if jitter changed (affects next run time)
754        if jitter_changed {
755            self.update_next_run_cache();
756        }
757
758        self.current_version += 1;
759        let version = ScheduleVersion::from_task(self, self.current_version, change_reason);
760        self.version_history.push(version);
761    }
762
763    /// Rollback to a previous version
764    pub fn rollback_to_version(&mut self, version_number: u32) -> Result<(), ScheduleError> {
765        let version = self
766            .version_history
767            .iter()
768            .find(|v| v.version == version_number)
769            .ok_or_else(|| {
770                ScheduleError::Invalid(format!("Version {} not found", version_number))
771            })?;
772
773        // Restore schedule and configuration from version
774        self.schedule = version.schedule.clone();
775        self.enabled = version.enabled;
776        self.jitter = version.jitter.clone();
777        self.catchup_policy = version.catchup_policy.clone();
778
779        // Create a new version record for the rollback
780        self.current_version += 1;
781        let rollback_version = ScheduleVersion::from_task(
782            self,
783            self.current_version,
784            Some(format!("Rolled back to version {}", version_number)),
785        );
786        self.version_history.push(rollback_version);
787
788        Ok(())
789    }
790
791    /// Get all versions in chronological order
792    pub fn get_version_history(&self) -> &[ScheduleVersion] {
793        &self.version_history
794    }
795
796    /// Get a specific version
797    pub fn get_version(&self, version_number: u32) -> Option<&ScheduleVersion> {
798        self.version_history
799            .iter()
800            .find(|v| v.version == version_number)
801    }
802
803    /// Get the latest version before current
804    pub fn get_previous_version(&self) -> Option<&ScheduleVersion> {
805        if self.current_version > 1 {
806            self.version_history.iter().rev().nth(1) // Skip current version
807        } else {
808            None
809        }
810    }
811
812    /// Add a dependency (task that must complete before this task)
813    pub fn add_dependency(&mut self, task_name: String) {
814        self.dependencies.insert(task_name);
815    }
816
817    /// Remove a dependency
818    pub fn remove_dependency(&mut self, task_name: &str) -> bool {
819        self.dependencies.remove(task_name)
820    }
821
822    /// Clear all dependencies
823    pub fn clear_dependencies(&mut self) {
824        self.dependencies.clear();
825    }
826
827    /// Check if task has any dependencies
828    pub fn has_dependencies(&self) -> bool {
829        !self.dependencies.is_empty()
830    }
831
832    /// Check if task depends on a specific task
833    pub fn depends_on(&self, task_name: &str) -> bool {
834        self.dependencies.contains(task_name)
835    }
836
837    /// Set multiple dependencies at once
838    pub fn with_dependencies(mut self, dependencies: HashSet<String>) -> Self {
839        self.dependencies = dependencies;
840        self
841    }
842
843    /// Set whether to wait for dependencies
844    pub fn with_wait_for_dependencies(mut self, wait: bool) -> Self {
845        self.wait_for_dependencies = wait;
846        self
847    }
848
849    /// Check dependency status against completed tasks
850    pub fn check_dependencies(&self, completed_tasks: &HashSet<String>) -> DependencyStatus {
851        if self.dependencies.is_empty() {
852            return DependencyStatus::Satisfied;
853        }
854
855        let pending: Vec<String> = self
856            .dependencies
857            .iter()
858            .filter(|dep| !completed_tasks.contains(*dep))
859            .cloned()
860            .collect();
861
862        if pending.is_empty() {
863            DependencyStatus::Satisfied
864        } else {
865            DependencyStatus::Waiting { pending }
866        }
867    }
868
869    /// Check dependency status with failed tasks tracking
870    pub fn check_dependencies_with_failures(
871        &self,
872        completed_tasks: &HashSet<String>,
873        failed_tasks: &HashSet<String>,
874    ) -> DependencyStatus {
875        if self.dependencies.is_empty() {
876            return DependencyStatus::Satisfied;
877        }
878
879        let failed: Vec<String> = self
880            .dependencies
881            .iter()
882            .filter(|dep| failed_tasks.contains(*dep))
883            .cloned()
884            .collect();
885
886        if !failed.is_empty() {
887            return DependencyStatus::Failed { failed };
888        }
889
890        let pending: Vec<String> = self
891            .dependencies
892            .iter()
893            .filter(|dep| !completed_tasks.contains(*dep))
894            .cloned()
895            .collect();
896
897        if pending.is_empty() {
898            DependencyStatus::Satisfied
899        } else {
900            DependencyStatus::Waiting { pending }
901        }
902    }
903}
904
905impl std::fmt::Display for ScheduledTask {
906    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
907        write!(f, "ScheduledTask[{}] schedule={}", self.name, self.schedule)?;
908        if !self.enabled {
909            write!(f, " (disabled)")?;
910        }
911        if let Some(last_run) = self.last_run_at {
912            let age = Utc::now() - last_run;
913            write!(f, " last_run={}s ago", age.num_seconds())?;
914        }
915        write!(f, " runs={}", self.total_run_count)?;
916        Ok(())
917    }
918}
919
920/// Task options for scheduled tasks
921#[derive(Debug, Clone, Serialize, Deserialize, Default)]
922pub struct TaskOptions {
923    /// Queue name
924    pub queue: Option<String>,
925
926    /// Priority
927    pub priority: Option<u8>,
928
929    /// Expiration time
930    pub expires: Option<u64>,
931}
932
933impl TaskOptions {
934    /// Check if queue is set
935    pub fn has_queue(&self) -> bool {
936        self.queue.is_some()
937    }
938
939    /// Check if priority is set
940    pub fn has_priority(&self) -> bool {
941        self.priority.is_some()
942    }
943
944    /// Check if expiration is set
945    pub fn has_expires(&self) -> bool {
946        self.expires.is_some()
947    }
948}
949
950impl std::fmt::Display for TaskOptions {
951    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
952        let mut parts = Vec::new();
953        if let Some(ref queue) = self.queue {
954            parts.push(format!("queue={}", queue));
955        }
956        if let Some(priority) = self.priority {
957            parts.push(format!("priority={}", priority));
958        }
959        if let Some(expires) = self.expires {
960            parts.push(format!("expires={}s", expires));
961        }
962        if parts.is_empty() {
963            write!(f, "TaskOptions[default]")
964        } else {
965            write!(f, "TaskOptions[{}]", parts.join(", "))
966        }
967    }
968}
969
970impl ScheduleVersion {
971    /// Create a new version from current task state
972    pub fn from_task(task: &ScheduledTask, version: u32, change_reason: Option<String>) -> Self {
973        Self {
974            version,
975            schedule: task.schedule.clone(),
976            created_at: Utc::now(),
977            change_reason,
978            enabled: task.enabled,
979            jitter: task.jitter.clone(),
980            catchup_policy: task.catchup_policy.clone(),
981        }
982    }
983}
984
985impl ScheduledTask {
986    /// Set business calendar for this task
987    ///
988    /// This doesn't currently enforce business hours (requires more extensive changes),
989    /// but provides the configuration for future use.
990    pub fn with_business_calendar(self, _calendar: BusinessCalendar) -> Self {
991        // Store for future use - currently just validation
992        self
993    }
994
995    /// Set holiday calendar for this task
996    ///
997    /// This doesn't currently enforce holidays (requires more extensive changes),
998    /// but provides the configuration for future use.
999    pub fn with_holiday_calendar(self, _calendar: HolidayCalendar) -> Self {
1000        // Store for future use - currently just validation
1001        self
1002    }
1003}
1004
1005impl ScheduledTask {
1006    /// Set WFQ weight for this task
1007    ///
1008    /// # Example
1009    /// ```
1010    /// use celers_beat::{ScheduledTask, Schedule};
1011    ///
1012    /// let task = ScheduledTask::new("important_task".to_string(), Schedule::interval(60))
1013    ///     .with_wfq_weight(5.0).unwrap();
1014    /// ```
1015    pub fn with_wfq_weight(mut self, weight: f64) -> Result<Self, String> {
1016        if self.wfq_state.is_none() {
1017            self.wfq_state = Some(WFQState::default());
1018        }
1019        self.wfq_state
1020            .as_mut()
1021            .expect("wfq_state initialized just above if None")
1022            .weight = TaskWeight::new(weight)?;
1023        Ok(self)
1024    }
1025
1026    /// Get WFQ weight for this task
1027    pub fn wfq_weight(&self) -> f64 {
1028        self.wfq_state
1029            .as_ref()
1030            .map(|state| state.weight.value())
1031            .unwrap_or(1.0)
1032    }
1033
1034    /// Get WFQ virtual finish time
1035    pub fn wfq_finish_time(&self) -> f64 {
1036        self.wfq_state
1037            .as_ref()
1038            .map(|state| state.finish_time())
1039            .unwrap_or(0.0)
1040    }
1041}