Skip to main content

appcore_scheduler/
task.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: task.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/22 15:41:18 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use super::*;
12
13/// Result returned by one scheduled task invocation.
14pub type TaskResult = Result<(), String>;
15/// Thread-safe scheduled task callback.
16pub type TaskCallback = Arc<dyn Fn(TaskContext) -> TaskResult + Send + Sync + 'static>;
17
18/// Controlled scheduler failure.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum SchedulerError {
21    /// Scheduler configuration is invalid.
22    InvalidConfig(&'static str),
23    /// Task identity is empty, too long or malformed.
24    InvalidTaskId,
25    /// Task schedule or retry policy is invalid.
26    InvalidSchedule(&'static str),
27    /// Cron expression could not be parsed.
28    InvalidCron(String),
29    /// A task with the same identity already exists.
30    DuplicateTask(String),
31    /// Configured task capacity was reached.
32    CapacityExceeded {
33        /// Maximum registered tasks.
34        max_tasks: usize,
35    },
36    /// Scheduler no longer accepts work after shutdown.
37    Shutdown,
38    /// Coordinator or worker thread panicked.
39    WorkerPanicked,
40}
41
42impl fmt::Display for SchedulerError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(formatter, "{self:?}")
45    }
46}
47
48impl std::error::Error for SchedulerError {}
49
50/// Bounded scheduler process configuration.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SchedulerConfig {
53    /// Maximum registered tasks.
54    pub max_tasks: usize,
55    /// Maximum callbacks executing concurrently.
56    pub max_concurrent_tasks: usize,
57    /// Maximum coordinator sleep between due-task scans.
58    pub poll_interval: Duration,
59}
60
61impl Default for SchedulerConfig {
62    fn default() -> Self {
63        Self {
64            max_tasks: 1_024,
65            max_concurrent_tasks: 4,
66            poll_interval: Duration::from_millis(25),
67        }
68    }
69}
70
71/// Supported local task schedule.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum TaskSchedule {
74    /// Run once at the supplied wall-clock instant.
75    Once {
76        /// Scheduled execution instant.
77        run_at: SystemTime,
78    },
79    /// Run repeatedly at a fixed interval.
80    Interval {
81        /// Positive interval between executions.
82        every: Duration,
83        /// Optional first execution instant.
84        start_at: Option<SystemTime>,
85    },
86    /// A six- or seven-field cron expression evaluated in UTC.
87    Cron {
88        /// UTC cron expression.
89        expression: String,
90    },
91}
92
93/// Bounded exponential retry policy.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct RetryPolicy {
96    /// Total attempts, including the initial execution.
97    pub max_attempts: u32,
98    /// Delay before the first retry.
99    pub initial_backoff: Duration,
100    /// Maximum delay between retries.
101    pub max_backoff: Duration,
102    /// Backoff multiplier.
103    pub multiplier: u32,
104    /// Maximum deterministic jitter window.
105    pub jitter: Duration,
106}
107
108impl Default for RetryPolicy {
109    fn default() -> Self {
110        Self {
111            max_attempts: 1,
112            initial_backoff: Duration::from_millis(100),
113            max_backoff: Duration::from_secs(30),
114            multiplier: 2,
115            jitter: Duration::ZERO,
116        }
117    }
118}
119
120/// Immutable scheduled task definition.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ScheduledTask {
123    /// Stable task identity.
124    pub id: String,
125    /// Execution schedule.
126    pub schedule: TaskSchedule,
127    /// Retry policy.
128    pub retry: RetryPolicy,
129    /// Higher values run first when multiple tasks are due at the same time.
130    pub priority: u8,
131    /// Optional trace context propagated to callbacks.
132    pub trace: Option<TraceContext>,
133}
134
135impl ScheduledTask {
136    /// Validates the task definition without starting scheduler infrastructure.
137    pub fn validate(&self) -> Result<(), SchedulerError> {
138        validate_task_id(&self.id)?;
139        validate_retry(&self.retry)?;
140        validate_schedule(&self.schedule)
141    }
142}
143
144fn validate_schedule(schedule: &TaskSchedule) -> Result<(), SchedulerError> {
145    match schedule {
146        TaskSchedule::Once { .. } => Ok(()),
147        TaskSchedule::Interval { every, .. } if every.is_zero() => {
148            Err(SchedulerError::InvalidSchedule("zero interval"))
149        }
150        TaskSchedule::Interval { .. } => Ok(()),
151        TaskSchedule::Cron { expression } => Schedule::from_str(expression)
152            .map(|_| ())
153            .map_err(|error| SchedulerError::InvalidCron(error.to_string())),
154    }
155}
156
157fn validate_retry(policy: &RetryPolicy) -> Result<(), SchedulerError> {
158    if policy.max_attempts == 0 {
159        return Err(SchedulerError::InvalidSchedule(
160            "max_attempts must be positive",
161        ));
162    }
163    if policy.multiplier == 0 {
164        return Err(SchedulerError::InvalidSchedule(
165            "retry multiplier must be positive",
166        ));
167    }
168    if policy.initial_backoff > policy.max_backoff {
169        return Err(SchedulerError::InvalidSchedule(
170            "initial_backoff exceeds max_backoff",
171        ));
172    }
173    Ok(())
174}
175
176fn validate_task_id(task_id: &str) -> Result<(), SchedulerError> {
177    if task_id.is_empty()
178        || task_id.len() > MAX_TASK_ID_BYTES
179        || !task_id
180            .bytes()
181            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
182    {
183        return Err(SchedulerError::InvalidTaskId);
184    }
185    Ok(())
186}
187
188/// Observable state of one registered task.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct TaskSnapshot {
191    /// Stable task identity.
192    pub id: String,
193    /// Scheduling priority.
194    pub priority: u8,
195    /// Whether a callback is currently executing.
196    pub running: bool,
197    /// Attempts made for the current execution cycle.
198    pub attempts: u32,
199    /// Next planned wall-clock execution.
200    pub next_run: SystemTime,
201    /// Last redacted callback error.
202    pub last_error: Option<String>,
203    /// Optional trace context.
204    pub trace: Option<TraceContext>,
205}
206
207/// Observable scheduler state.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct SchedulerSnapshot {
210    /// Whether shutdown was requested.
211    pub shutdown: bool,
212    /// Number of callbacks currently executing.
213    pub active_tasks: usize,
214    /// Registered task snapshots ordered by ID.
215    pub tasks: Vec<TaskSnapshot>,
216}
217/// Cooperative execution context supplied to a task callback.
218pub struct TaskContext {
219    task_id: String,
220    attempt: u32,
221    cancelled: Arc<AtomicBool>,
222    shutdown: Arc<AtomicBool>,
223    trace: Option<TraceContext>,
224}
225
226impl TaskContext {
227    pub(crate) fn new(
228        task_id: String,
229        attempt: u32,
230        cancelled: Arc<AtomicBool>,
231        shutdown: Arc<AtomicBool>,
232        trace: Option<TraceContext>,
233    ) -> Self {
234        Self {
235            task_id,
236            attempt,
237            cancelled,
238            shutdown,
239            trace,
240        }
241    }
242
243    /// Returns the stable task identity.
244    pub fn task_id(&self) -> &str {
245        &self.task_id
246    }
247
248    /// Returns the current one-based attempt.
249    pub fn attempt(&self) -> u32 {
250        self.attempt
251    }
252
253    /// Reports whether task or scheduler cancellation was requested.
254    pub fn is_cancelled(&self) -> bool {
255        self.cancelled.load(Ordering::Acquire) || self.shutdown.load(Ordering::Acquire)
256    }
257
258    /// Returns propagated trace context.
259    pub fn trace(&self) -> Option<&TraceContext> {
260        self.trace.as_ref()
261    }
262}