//! Cron scheduler — the v1.1.4 time-based trigger source. //! //! A single tokio task polls `cron_trigger_details` on a tick (default //! 30s; `PICLOUD_CRON_TICK_INTERVAL_MS`). For each enabled cron trigger //! whose next scheduled fire is due, it enqueues ONE outbox row //! (`source_kind = 'cron'`) and updates `last_fired_at` — both in the //! same transaction, claimed via `FOR UPDATE SKIP LOCKED` so a future //! multi-node deploy can't double-fire. //! //! The scheduler does NOT dispatch or touch the `ExecutionGate`: it only //! enqueues. The existing dispatcher picks the row up and acquires the //! gate exactly as it does for kv/docs/dead_letter rows. //! //! **Catch-up policy (matches the brief):** a trigger that missed N fire //! windows since `last_fired_at` fires exactly ONCE on the next tick, //! not N times. This falls out of the design: [`next_due`] returns a //! single canonical scheduled time (the first slot after the reference //! point), and after firing we set `last_fired_at = now`, so the next //! tick computes from `now` and sees only future slots. Backfilling //! missed windows is intentionally out of scope (an explicit replay //! action is the v1.2+ escape hatch). use std::str::FromStr; use std::time::Duration; use chrono::{DateTime, Utc}; use chrono_tz::Tz; use cron::Schedule; use picloud_shared::TriggerEvent; use sqlx::PgPool; use uuid::Uuid; /// Validate a 6-field cron expression. Returns the parse error message /// on failure. /// /// # Errors /// /// Returns the underlying parse error string when `schedule` is not a /// valid cron expression. pub fn validate_schedule(schedule: &str) -> Result<(), String> { Schedule::from_str(schedule) .map(|_| ()) .map_err(|e| e.to_string()) } /// Validate an IANA timezone name (e.g. `America/Los_Angeles`). /// /// # Errors /// /// Returns an error string when `timezone` is not a known IANA name. pub fn validate_timezone(timezone: &str) -> Result<(), String> { Tz::from_str(timezone) .map(|_| ()) .map_err(|_| format!("unknown IANA timezone: {timezone}")) } /// Compute whether a cron trigger is due, and if so its canonical /// scheduled-at moment (UTC). /// /// Returns `Some(scheduled_at)` when the first scheduled slot after the /// reference point (`last_fired_at`, or `created_at` if never fired) is /// at/before `now`; `None` otherwise. Returns `None` if the schedule or /// timezone fails to parse (the row is skipped — it should never have /// been inserted, since the admin endpoint validates). #[must_use] pub fn next_due( schedule: &str, timezone: &str, last_fired_at: Option>, created_at: DateTime, now: DateTime, ) -> Option> { let sched = Schedule::from_str(schedule).ok()?; let tz = Tz::from_str(timezone).ok()?; // Reference: the last actual fire, or creation if never fired. A // never-fired trigger fires at its first slot at/after creation. let base = last_fired_at.unwrap_or(created_at); let base_tz = base.with_timezone(&tz); let next = sched.after(&base_tz).next()?; let next_utc = next.with_timezone(&Utc); (next_utc <= now).then_some(next_utc) } /// Spawn the scheduler loop. Runs for the process lifetime. pub fn spawn_cron_scheduler(pool: PgPool, tick_interval_ms: u32) { // Floor the tick at 1s so a misconfigured 0 can't spin. let interval = Duration::from_millis(u64::from(tick_interval_ms).max(1_000)); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); // Skip the immediate first fire so we don't race startup. ticker.tick().await; loop { ticker.tick().await; if let Err(e) = tick(&pool, Utc::now()).await { tracing::warn!(?e, "cron scheduler tick errored"); } } }); } #[derive(sqlx::FromRow)] struct DueRow { id: Uuid, app_id: Uuid, script_id: Uuid, registered_by_principal: Uuid, created_at: DateTime, schedule: String, timezone: String, last_fired_at: Option>, } /// One scheduler tick: claim enabled cron rows, enqueue the due ones, /// bump `last_fired_at`. Returns the number of triggers fired. async fn tick(pool: &PgPool, now: DateTime) -> Result { let mut tx = pool.begin().await?; let rows: Vec = sqlx::query_as( "SELECT t.id, t.app_id, t.script_id, t.registered_by_principal, t.created_at, \ d.schedule, d.timezone, d.last_fired_at \ FROM cron_trigger_details d \ JOIN triggers t ON t.id = d.trigger_id \ WHERE t.enabled = TRUE \ FOR UPDATE OF d SKIP LOCKED", ) .fetch_all(&mut *tx) .await?; let mut fired = 0usize; for r in rows { let Some(scheduled_at) = next_due(&r.schedule, &r.timezone, r.last_fired_at, r.created_at, now) else { continue; }; let event = TriggerEvent::Cron { schedule: r.schedule.clone(), timezone: r.timezone.clone(), scheduled_at, fired_at: now, }; let payload = serde_json::to_value(&event) .map_err(|e| sqlx::Error::Decode(Box::new(std::io::Error::other(e))))?; // Enqueue exactly one outbox row. Relies on the same column // defaults the OutboxEventEmitter uses (next_attempt_at = NOW(), // attempt_count = 0, claimed_at NULL → immediately due). sqlx::query( "INSERT INTO outbox \ (app_id, source_kind, trigger_id, script_id, payload, \ origin_principal, trigger_depth) \ VALUES ($1, 'cron', $2, $3, $4, $5, 0)", ) .bind(r.app_id) .bind(r.id) .bind(r.script_id) .bind(payload) .bind(r.registered_by_principal) .execute(&mut *tx) .await?; sqlx::query("UPDATE cron_trigger_details SET last_fired_at = $2 WHERE trigger_id = $1") .bind(r.id) .bind(now) .execute(&mut *tx) .await?; fired += 1; } tx.commit().await?; Ok(fired) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[test] fn valid_six_field_schedule_accepted() { // sec min hour dom mon dow — "every weekday at 9am". validate_schedule("0 0 9 * * MON-FRI").unwrap(); validate_schedule("*/5 * * * * *").unwrap(); validate_schedule("0 0 0 1 1 *").unwrap(); } #[test] fn invalid_schedules_rejected() { // 5-field (no seconds) is not the format we accept. assert!(validate_schedule("* * * * *").is_err()); // Gibberish. assert!(validate_schedule("not a cron").is_err()); assert!(validate_schedule("").is_err()); // Out-of-range hour. assert!(validate_schedule("0 0 99 * * *").is_err()); } #[test] fn known_timezones_accepted() { validate_timezone("UTC").unwrap(); validate_timezone("America/Los_Angeles").unwrap(); validate_timezone("Europe/Berlin").unwrap(); } #[test] fn unknown_timezones_rejected() { assert!(validate_timezone("Mars/Phobos").is_err()); assert!(validate_timezone("PST").is_err()); // abbreviations aren't IANA names assert!(validate_timezone("").is_err()); } fn ts(s: &str) -> DateTime { DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) } #[test] fn due_when_next_slot_is_at_or_before_now() { // Every minute at second 0. Last fired 90s ago → the next slot // after that is due now. let created = ts("2026-06-01T00:00:00Z"); let last = Some(ts("2026-06-15T11:58:10Z")); let now = ts("2026-06-15T12:00:05Z"); let due = next_due("0 * * * * *", "UTC", last, created, now); assert_eq!(due, Some(ts("2026-06-15T11:59:00Z"))); } #[test] fn not_due_when_next_slot_is_in_the_future() { let created = ts("2026-06-01T00:00:00Z"); let last = Some(ts("2026-06-15T12:00:00Z")); let now = ts("2026-06-15T12:00:30Z"); // Next minute slot is 12:01:00 — still in the future. assert_eq!(next_due("0 * * * * *", "UTC", last, created, now), None); } #[test] fn never_fired_uses_created_at_as_reference() { let created = ts("2026-06-15T12:00:10Z"); let now = ts("2026-06-15T12:01:30Z"); // First slot after creation is 12:01:00, which is <= now → due. let due = next_due("0 * * * * *", "UTC", None, created, now); assert_eq!(due, Some(ts("2026-06-15T12:01:00Z"))); } /// Catch-up policy: a trigger that missed many windows fires exactly /// ONCE. We simulate two consecutive scheduler ticks the way the DB /// loop does — fire once, set last_fired = now, then re-evaluate. #[test] fn catch_up_fires_exactly_once_after_missed_windows() { let created = ts("2026-06-15T09:00:00Z"); // Last fired over 5 minutes (5 windows) ago. let mut last_fired = Some(ts("2026-06-15T11:54:30Z")); let now = ts("2026-06-15T12:00:05Z"); // Tick 1: due → fire once, advance last_fired to `now`. let first = next_due("0 * * * * *", "UTC", last_fired, created, now); assert!(first.is_some(), "should be due after missing windows"); last_fired = Some(now); // Tick 2 (same wall-clock): NOT due again — only one fire total, // not one-per-missed-window. let second = next_due("0 * * * * *", "UTC", last_fired, created, now); assert_eq!(second, None, "catch-up must fire exactly once"); } #[test] fn timezone_affects_fire_time() { // "9am every day" in Los Angeles. On 2026-06-15, PDT = UTC-7, so // 09:00 local = 16:00 UTC. let created = ts("2026-06-15T00:00:00Z"); let last = Some(ts("2026-06-15T15:59:00Z")); let now = ts("2026-06-15T16:00:30Z"); let due = next_due("0 0 9 * * *", "America/Los_Angeles", last, created, now); assert_eq!(due, Some(ts("2026-06-15T16:00:00Z"))); // Sanity: the same expression in UTC would NOT be due at 16:00. assert_eq!(next_due("0 0 9 * * *", "UTC", last, created, now), None); } #[test] fn bad_schedule_or_tz_yields_none() { let created = ts("2026-06-15T00:00:00Z"); let now = ts("2026-06-15T12:00:00Z"); assert_eq!(next_due("garbage", "UTC", None, created, now), None); assert_eq!( next_due("0 * * * * *", "Mars/Phobos", None, created, now), None ); } #[test] fn utc_offset_constructor_smoke() { // Guard the chrono TimeZone import is actually exercised. let dt = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap(); assert_eq!(dt, ts("2026-06-15T12:00:00Z")); } }