Skip to main content

scheduler/model/
task.rs

1use crate::execution_guard::ExecutionGuardScope;
2use crate::model::{JobTimeWindow, MissedRunPolicy, OverlapPolicy, RunSkipReason, Schedule};
3use chrono::{DateTime, Utc};
4use chrono_tz::Tz;
5use std::any::type_name;
6use std::future::{Future, ready};
7use std::panic::resume_unwind;
8use std::pin::Pin;
9use std::sync::Arc;
10
11/// The task return type used by scheduled jobs.
12pub type JobResult = Result<(), String>;
13/// The boxed future returned by a scheduled job.
14pub type JobFuture = Pin<Box<dyn Future<Output = JobResult> + Send>>;
15pub(crate) type TaskHandler<D> = Arc<dyn Fn(TaskContext<D>) -> JobFuture + Send + Sync>;
16
17#[derive(Clone)]
18pub struct Task<D> {
19    pub(crate) handler: TaskHandler<D>,
20}
21
22impl<D> Task<D>
23where
24    D: Send + Sync + 'static,
25{
26    fn from_handler(handler: TaskHandler<D>) -> Self {
27        Self { handler }
28    }
29
30    /// Create an async task from the full [`TaskContext`].
31    pub fn from_async<F, Fut>(task: F) -> Self
32    where
33        F: Fn(TaskContext<D>) -> Fut + Send + Sync + 'static,
34        Fut: Future<Output = JobResult> + Send + 'static,
35    {
36        Self::from_handler(wrap_async_handler(Arc::new(task)))
37    }
38
39    /// Create a lightweight synchronous task from the full [`TaskContext`].
40    pub fn from_sync<F>(task: F) -> Self
41    where
42        F: Fn(TaskContext<D>) -> JobResult + Send + Sync + 'static,
43    {
44        Self::from_handler(wrap_sync_handler(Arc::new(task)))
45    }
46
47    /// Create a blocking synchronous task from the full [`TaskContext`].
48    pub fn from_blocking<F>(task: F) -> Self
49    where
50        F: Fn(TaskContext<D>) -> JobResult + Send + Sync + 'static,
51    {
52        Self::from_handler(wrap_blocking_handler(Arc::new(task)))
53    }
54}
55
56#[derive(Clone)]
57pub struct Job<D = ()> {
58    pub job_id: String,
59    pub execution_resource_id: String,
60    pub guard_scope: ExecutionGuardScope,
61    pub schedule: Schedule,
62    pub time_window: Option<JobTimeWindow>,
63    pub max_runs: Option<u32>,
64    pub missed_run_policy: MissedRunPolicy,
65    pub overlap_policy: OverlapPolicy,
66    pub(crate) task: TaskHandler<D>,
67    pub(crate) deps: Arc<D>,
68}
69
70impl Job<()> {
71    /// Create a job that uses no injected dependencies.
72    pub fn without_deps(job_id: impl Into<String>, schedule: Schedule, task: Task<()>) -> Self {
73        Self::from_parts(job_id.into(), schedule, Arc::new(()), task)
74    }
75}
76
77impl<D> Job<D>
78where
79    D: Send + Sync + 'static,
80{
81    /// Create a job from explicit dependencies and a task handler.
82    pub fn new(
83        job_id: impl Into<String>,
84        schedule: Schedule,
85        deps: impl Into<Arc<D>>,
86        task: Task<D>,
87    ) -> Self {
88        Self::from_parts(job_id.into(), schedule, deps.into(), task)
89    }
90}
91
92impl<D> Job<D> {
93    fn default_policies() -> (MissedRunPolicy, OverlapPolicy) {
94        (MissedRunPolicy::CatchUpOnce, OverlapPolicy::Forbid)
95    }
96
97    fn from_parts(job_id: String, schedule: Schedule, deps: Arc<D>, task: Task<D>) -> Self {
98        let (missed_run_policy, overlap_policy) = Self::default_policies();
99        Self {
100            execution_resource_id: job_id.clone(),
101            job_id,
102            guard_scope: ExecutionGuardScope::Occurrence,
103            schedule,
104            time_window: None,
105            max_runs: None,
106            missed_run_policy,
107            overlap_policy,
108            task: task.handler,
109            deps,
110        }
111    }
112
113    /// Limit how many triggers this job can consume before it exits.
114    ///
115    /// This applies to [`Schedule::Interval`], [`Schedule::StaggeredInterval`],
116    /// [`Schedule::GroupedInterval`], [`Schedule::WindowedInterval`],
117    /// [`Schedule::AtTimes`], and [`Schedule::Cron`].
118    /// A value of `0` makes the job exit immediately without running.
119    pub fn with_max_runs(mut self, max_runs: u32) -> Self {
120        self.max_runs = Some(max_runs);
121        self
122    }
123
124    pub fn with_missed_run_policy(mut self, policy: MissedRunPolicy) -> Self {
125        self.missed_run_policy = policy;
126        self
127    }
128
129    pub fn with_overlap_policy(mut self, policy: OverlapPolicy) -> Self {
130        self.overlap_policy = policy;
131        self
132    }
133
134    pub fn with_time_window(mut self, time_window: JobTimeWindow) -> Self {
135        self.time_window = Some(time_window);
136        self
137    }
138
139    pub fn with_execution_resource_id(mut self, resource_id: impl Into<String>) -> Self {
140        self.execution_resource_id = resource_id.into();
141        self
142    }
143
144    pub fn with_guard_scope(mut self, scope: ExecutionGuardScope) -> Self {
145        self.guard_scope = scope;
146        self
147    }
148
149    pub(crate) fn skip_reason_at(
150        &self,
151        now: DateTime<Utc>,
152        fallback_timezone: Tz,
153    ) -> Option<RunSkipReason> {
154        let window = self.time_window.as_ref()?;
155        if window.matches(now, fallback_timezone) {
156            None
157        } else {
158            Some(RunSkipReason::OutsideTimeWindow)
159        }
160    }
161}
162
163impl<D> std::fmt::Debug for Job<D> {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct("Job")
166            .field("job_id", &self.job_id)
167            .field("execution_resource_id", &self.execution_resource_id)
168            .field("guard_scope", &self.guard_scope)
169            .field("schedule", &self.schedule)
170            .field("time_window", &self.time_window)
171            .field("max_runs", &self.max_runs)
172            .field("missed_run_policy", &self.missed_run_policy)
173            .field("overlap_policy", &self.overlap_policy)
174            .field("deps", &type_name::<D>())
175            .finish_non_exhaustive()
176    }
177}
178
179fn wrap_async_handler<D, F, Fut>(task: Arc<F>) -> TaskHandler<D>
180where
181    D: Send + Sync + 'static,
182    F: Fn(TaskContext<D>) -> Fut + Send + Sync + 'static,
183    Fut: Future<Output = JobResult> + Send + 'static,
184{
185    Arc::new(move |context| Box::pin((*task)(context)))
186}
187
188fn wrap_sync_handler<D, F>(task: Arc<F>) -> TaskHandler<D>
189where
190    D: Send + Sync + 'static,
191    F: Fn(TaskContext<D>) -> JobResult + Send + Sync + 'static,
192{
193    Arc::new(move |context| Box::pin(ready((*task)(context))))
194}
195
196fn wrap_blocking_handler<D, F>(task: Arc<F>) -> TaskHandler<D>
197where
198    D: Send + Sync + 'static,
199    F: Fn(TaskContext<D>) -> JobResult + Send + Sync + 'static,
200{
201    Arc::new(move |context| {
202        let task = task.clone();
203        Box::pin(async move { await_blocking(move || (*task)(context)).await })
204    })
205}
206
207#[derive(Debug, Clone)]
208pub struct RunContext {
209    pub job_id: String,
210    pub scheduled_at: DateTime<Utc>,
211    pub catch_up: bool,
212    /// The scheduler-configured timezone for downstream task logic.
213    pub timezone: Tz,
214}
215
216#[derive(Clone)]
217pub struct TaskContext<D> {
218    pub run: RunContext,
219    pub deps: Arc<D>,
220}
221
222impl<D> std::fmt::Debug for TaskContext<D> {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        f.debug_struct("TaskContext")
225            .field("run", &self.run)
226            .field("deps", &type_name::<D>())
227            .finish()
228    }
229}
230
231async fn await_blocking<F>(task: F) -> JobResult
232where
233    F: FnOnce() -> JobResult + Send + 'static,
234{
235    match tokio::task::spawn_blocking(task).await {
236        Ok(result) => result,
237        Err(error) if error.is_panic() => resume_unwind(error.into_panic()),
238        Err(error) => panic!("blocking task failed to join: {error}"),
239    }
240}