feat: implement full-stack TaskPilot scheduler
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const MAX_PREVIEW_RUNS: usize = 100;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CronPreviewError {
|
||||
#[error("invalid cron expression: {0}")]
|
||||
InvalidExpression(String),
|
||||
#[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")]
|
||||
TooManyRuns,
|
||||
#[error("cron expression has no future occurrence")]
|
||||
NoFutureOccurrence,
|
||||
#[error("invalid IANA timezone: {0}")]
|
||||
InvalidTimezone(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CronPreviewRequest {
|
||||
pub expression: String,
|
||||
pub timezone: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CronPreviewResponse {
|
||||
pub timezone: String,
|
||||
pub runs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Calculates the next occurrences of a cron expression in local time.
|
||||
pub fn next_occurrences(
|
||||
expression: &str,
|
||||
from: DateTime<Local>,
|
||||
count: usize,
|
||||
) -> Result<Vec<DateTime<Local>>, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
let mut cursor = from;
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next);
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn preview_in_timezone(
|
||||
expression: &str,
|
||||
timezone: &str,
|
||||
from: DateTime<Utc>,
|
||||
count: usize,
|
||||
) -> Result<CronPreviewResponse, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
let timezone: Tz = timezone
|
||||
.parse()
|
||||
.map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?;
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut cursor = from.with_timezone(&timezone);
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(CronPreviewResponse {
|
||||
timezone: timezone.name().to_owned(),
|
||||
runs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Datelike, TimeZone, Weekday};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn local_time(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> DateTime<Local> {
|
||||
Local
|
||||
.with_ymd_and_hms(year, month, day, hour, minute, second)
|
||||
.single()
|
||||
.expect("test timestamp should be unambiguous")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_five_field_expression() {
|
||||
let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1)
|
||||
.expect("five field expression should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_six_field_expression_with_seconds() {
|
||||
let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2)
|
||||
.expect("six field expression should parse");
|
||||
|
||||
assert_eq!(
|
||||
runs,
|
||||
vec![
|
||||
local_time(2026, 1, 1, 12, 0, 15),
|
||||
local_time(2026, 1, 1, 12, 0, 30)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_hourly_alias() {
|
||||
let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1)
|
||||
.expect("hourly alias should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_step() {
|
||||
let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1)
|
||||
.expect_err("zero step should be rejected");
|
||||
|
||||
assert!(matches!(error, CronPreviewError::InvalidExpression(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_posix_or_semantics_for_day_of_month_and_weekday() {
|
||||
let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("POSIX cron expression should parse");
|
||||
|
||||
assert_eq!(runs[0].day(), 5);
|
||||
assert_eq!(runs[0].weekday(), Weekday::Mon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_explicit_day_and_weekday_and_semantics() {
|
||||
let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("AND semantics should parse");
|
||||
|
||||
assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_runs_in_iana_timezone() {
|
||||
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
|
||||
let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap();
|
||||
|
||||
assert_eq!(preview.timezone, "Asia/Shanghai");
|
||||
assert_eq!(
|
||||
preview.runs,
|
||||
vec![
|
||||
"2026-01-02 02:00:00".to_owned(),
|
||||
"2026-01-03 02:00:00".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user