HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
11 KiB
Rust
298 lines
11 KiB
Rust
//! 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<DateTime<Utc>>,
|
|
created_at: DateTime<Utc>,
|
|
now: DateTime<Utc>,
|
|
) -> Option<DateTime<Utc>> {
|
|
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<Utc>,
|
|
schedule: String,
|
|
timezone: String,
|
|
last_fired_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// 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<Utc>) -> Result<usize, sqlx::Error> {
|
|
let mut tx = pool.begin().await?;
|
|
let rows: Vec<DueRow> = 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<Utc> {
|
|
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"));
|
|
}
|
|
}
|