Skip to main content

runledger_runtime/
scheduler.rs

1use std::str::FromStr;
2
3use chrono::{DateTime, Duration, SecondsFormat, Utc};
4use cron::Schedule;
5use runledger_postgres::jobs::{self, JobEnqueue};
6use serde_json::{Value, json};
7use tokio::sync::watch;
8use tokio::time::sleep;
9use tracing::{info, warn};
10
11use crate::config::JobsConfig;
12use crate::{Result, RuntimeLoopExit, SchedulerError};
13
14const FAILED_SCHEDULE_RETRY_DELAY_SECONDS: i64 = 30;
15const SCHEDULE_STALE_CATCHUP_JITTER_SEARCH_LIMIT: usize =
16    jobs::JOB_SCHEDULE_MAX_JITTER_SECONDS as usize + 2;
17const CREATE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL: &str = "SAVEPOINT materialize_due_schedule";
18const ROLLBACK_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL: &str =
19    "ROLLBACK TO SAVEPOINT materialize_due_schedule";
20const RELEASE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL: &str =
21    "RELEASE SAVEPOINT materialize_due_schedule";
22
23/// Runs cron schedule materialization until shutdown is requested.
24///
25/// The loop claims due schedules in batches using
26/// [`JobsConfig::claim_batch_size`], materializes each due schedule into a job,
27/// advances the schedule's next UTC fire cursor, then waits for either shutdown
28/// or [`JobsConfig::schedule_poll_interval`] before polling again. Individual
29/// materialization failures are logged and deferred so one bad schedule does not
30/// permanently starve other due schedules.
31///
32/// When a schedule is stale, one missed fire is materialized with its original
33/// `scheduled_for` metadata and the cursor is then coalesced to the first future
34/// fire after the scheduler's current clock. This bounds outage catch-up instead
35/// of replaying every missed cron tick.
36///
37/// Shutdown is requested by sending `true` on `shutdown` or by dropping the
38/// watch sender. This function returns [`RuntimeLoopExit::Shutdown`] after the
39/// shutdown signal is observed.
40///
41/// This lower-level loop remains public for custom runtime orchestration.
42/// Prefer [`crate::Supervisor`] for ordinary worker processes so scheduler,
43/// worker, and reaper tasks are started, monitored, and shut down together.
44pub async fn run_scheduler_loop(
45    pool: runledger_postgres::DbPool,
46    config: JobsConfig,
47    mut shutdown: watch::Receiver<bool>,
48) -> RuntimeLoopExit {
49    if let Err(error) = config.validate_scheduler_loop() {
50        warn!(%error, "invalid jobs config; stopping scheduler loop");
51        return RuntimeLoopExit::InvalidConfig(error);
52    }
53
54    loop {
55        if shutdown_requested_or_closed(&shutdown) {
56            return scheduler_shutdown_complete();
57        }
58
59        if let Err(error) = materialize_due_schedules(&pool, config.claim_batch_size).await {
60            warn!(%error, "schedule materialization failed");
61        }
62
63        if wait_for_shutdown_or_poll(&mut shutdown, config.schedule_poll_interval).await {
64            return scheduler_shutdown_complete();
65        }
66    }
67}
68
69fn scheduler_shutdown_complete() -> RuntimeLoopExit {
70    info!("scheduler shutdown complete");
71    RuntimeLoopExit::Shutdown
72}
73
74fn shutdown_requested_or_closed(shutdown: &watch::Receiver<bool>) -> bool {
75    *shutdown.borrow() || shutdown.has_changed().is_err()
76}
77
78async fn wait_for_shutdown_or_poll(
79    shutdown: &mut watch::Receiver<bool>,
80    poll_interval: std::time::Duration,
81) -> bool {
82    tokio::select! {
83        changed = shutdown.changed() => changed.is_err() || *shutdown.borrow(),
84        _ = sleep(poll_interval) => false,
85    }
86}
87
88async fn materialize_due_schedules(
89    pool: &runledger_postgres::DbPool,
90    batch_size: i64,
91) -> Result<()> {
92    let mut tx = pool
93        .begin()
94        .await
95        .map_err(|error| SchedulerError::BeginTransaction {
96            source: runledger_postgres::Error::ConnectionError(error.to_string()),
97        })?;
98
99    let now = Utc::now();
100    materialize_due_schedules_tx(&mut tx, now, batch_size).await?;
101
102    tx.commit()
103        .await
104        .map_err(|error| SchedulerError::CommitTransaction {
105            source: runledger_postgres::Error::ConnectionError(error.to_string()),
106        })?;
107    Ok(())
108}
109
110fn savepoint_error_variant(
111    statement: &'static str,
112    source: runledger_postgres::Error,
113) -> SchedulerError {
114    match statement {
115        CREATE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
116            SchedulerError::SavepointCreate { statement, source }
117        }
118        ROLLBACK_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
119            SchedulerError::SavepointRollback { statement, source }
120        }
121        RELEASE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
122            SchedulerError::SavepointRelease { statement, source }
123        }
124        _ => unreachable!("unexpected savepoint statement: {statement}"),
125    }
126}
127
128async fn execute_savepoint_sql_tx(
129    tx: &mut runledger_postgres::DbTx<'_>,
130    statement: &'static str,
131) -> Result<()> {
132    match statement {
133        CREATE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
134            sqlx::query!("SAVEPOINT materialize_due_schedule")
135                .execute(&mut **tx)
136                .await
137                .map_err(|error| {
138                    savepoint_error_variant(
139                        statement,
140                        runledger_postgres::Error::ConnectionError(error.to_string()),
141                    )
142                })?;
143        }
144        ROLLBACK_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
145            sqlx::query!("ROLLBACK TO SAVEPOINT materialize_due_schedule")
146                .execute(&mut **tx)
147                .await
148                .map_err(|error| {
149                    savepoint_error_variant(
150                        statement,
151                        runledger_postgres::Error::ConnectionError(error.to_string()),
152                    )
153                })?;
154        }
155        RELEASE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL => {
156            sqlx::query!("RELEASE SAVEPOINT materialize_due_schedule")
157                .execute(&mut **tx)
158                .await
159                .map_err(|error| {
160                    savepoint_error_variant(
161                        statement,
162                        runledger_postgres::Error::ConnectionError(error.to_string()),
163                    )
164                })?;
165        }
166        _ => unreachable!("unexpected savepoint statement: {statement}"),
167    }
168
169    Ok(())
170}
171
172async fn materialize_due_schedules_tx(
173    tx: &mut runledger_postgres::DbTx<'_>,
174    now: DateTime<Utc>,
175    batch_size: i64,
176) -> Result<()> {
177    let schedules = jobs::claim_due_schedules_tx(tx, now, batch_size)
178        .await
179        .map_err(|source| SchedulerError::ClaimDueSchedules { source })?;
180    materialize_claimed_schedules_tx(tx, now, schedules).await
181}
182
183async fn materialize_claimed_schedules_tx(
184    tx: &mut runledger_postgres::DbTx<'_>,
185    now: DateTime<Utc>,
186    schedules: Vec<jobs::JobScheduleRecord>,
187) -> Result<()> {
188    for schedule in schedules {
189        execute_savepoint_sql_tx(tx, CREATE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL).await?;
190
191        if let Err(error) = materialize_schedule_tx(tx, &schedule, now).await {
192            warn!(
193                %error,
194                schedule_id=%schedule.id,
195                schedule_name=%schedule.name,
196                "schedule materialization failed; skipping"
197            );
198            execute_savepoint_sql_tx(tx, ROLLBACK_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL).await?;
199            execute_savepoint_sql_tx(tx, RELEASE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL).await?;
200
201            if matches!(
202                error,
203                crate::Error::Scheduler(SchedulerError::ClaimedScheduleMissing { .. })
204            ) {
205                return Err(error);
206            }
207
208            // Push failed schedules out of the immediate due window to avoid
209            // repeatedly selecting the same failing rows and starving valid schedules.
210            let retry_at = failed_schedule_retry_at(now);
211            defer_failed_schedule_tx(tx, schedule.id, retry_at).await?;
212            continue;
213        }
214
215        execute_savepoint_sql_tx(tx, RELEASE_MATERIALIZE_DUE_SCHEDULE_SAVEPOINT_SQL).await?;
216    }
217
218    Ok(())
219}
220
221async fn defer_failed_schedule_tx(
222    tx: &mut runledger_postgres::DbTx<'_>,
223    schedule_id: uuid::Uuid,
224    next_fire_at: DateTime<Utc>,
225) -> Result<()> {
226    let updated = sqlx::query!(
227        "UPDATE job_schedules
228         SET next_fire_at = $2,
229             updated_at = now()
230         WHERE id = $1",
231        schedule_id,
232        next_fire_at,
233    )
234    .execute(&mut **tx)
235    .await
236    .map_err(|error| SchedulerError::DeferFailedSchedule {
237        schedule_id,
238        source: runledger_postgres::Error::from_query_sqlx_with_context(
239            "defer failed schedule",
240            error,
241        ),
242    })?;
243
244    if updated.rows_affected() == 0 {
245        return Err(SchedulerError::ClaimedScheduleMissing {
246            schedule_id,
247            operation: "deferring failed schedule",
248        }
249        .into());
250    }
251
252    Ok(())
253}
254
255async fn materialize_schedule_tx(
256    tx: &mut runledger_postgres::DbTx<'_>,
257    schedule: &jobs::JobScheduleRecord,
258    now: DateTime<Utc>,
259) -> Result<()> {
260    let scheduled_for = schedule.next_fire_at;
261    let next_fire_at = compute_next_fire_at_with_stale_coalescing_utc(
262        &schedule.cron_expr,
263        scheduled_for,
264        now,
265        schedule.id,
266        schedule.max_jitter_seconds,
267    )
268    .ok_or_else(|| invalid_schedule_cron_error(schedule))?;
269
270    let mut payload = schedule.payload_template.clone();
271    merge_schedule_metadata(&mut payload, schedule.id, &schedule.name, scheduled_for);
272
273    let enqueue_payload = JobEnqueue {
274        job_type: schedule.job_type.as_borrowed(),
275        organization_id: schedule.organization_id,
276        payload: &payload,
277        priority: None,
278        max_attempts: None,
279        timeout_seconds: None,
280        next_run_at: Some(now),
281        idempotency_key: None,
282        stage: Some(runledger_core::jobs::JobStage::Scheduled),
283    };
284
285    jobs::enqueue_job_tx(tx, &enqueue_payload)
286        .await
287        .map_err(|source| SchedulerError::EnqueueScheduledJob {
288            schedule_id: schedule.id,
289            job_type: schedule.job_type.to_string(),
290            source,
291        })?;
292
293    let marked = jobs::mark_schedule_fired_tx(tx, schedule.id, now, next_fire_at)
294        .await
295        .map_err(|source| SchedulerError::MarkScheduleFired {
296            schedule_id: schedule.id,
297            source,
298        })?;
299    if !marked {
300        // The enqueue above is still inside the caller's schedule savepoint.
301        // Returning an error lets the caller roll it back instead of producing
302        // work for a schedule row that is no longer present.
303        return Err(SchedulerError::ClaimedScheduleMissing {
304            schedule_id: schedule.id,
305            operation: "marking schedule as fired",
306        }
307        .into());
308    }
309    Ok(())
310}
311
312/// Materializes at most one missed fire for a stale schedule, then coalesces the
313/// cursor to the first future fire so an outage cannot create unbounded replay.
314fn compute_next_fire_at_with_stale_coalescing_utc(
315    cron_expr: &str,
316    scheduled_for: DateTime<Utc>,
317    now: DateTime<Utc>,
318    schedule_id: uuid::Uuid,
319    max_jitter_seconds: i32,
320) -> Option<DateTime<Utc>> {
321    let schedule = Schedule::from_str(cron_expr).ok()?;
322    let next_after_scheduled =
323        next_jittered_fire_after_utc(&schedule, scheduled_for, schedule_id, max_jitter_seconds)?;
324    if next_after_scheduled <= now {
325        compute_coalesced_next_fire_after_now_utc(&schedule, now, schedule_id, max_jitter_seconds)
326    } else {
327        Some(next_after_scheduled)
328    }
329}
330
331fn invalid_schedule_cron_error(schedule: &jobs::JobScheduleRecord) -> SchedulerError {
332    SchedulerError::InvalidCronExpression {
333        schedule_id: schedule.id,
334        schedule_name: schedule.name.clone(),
335        cron_expr: schedule.cron_expr.clone(),
336    }
337}
338
339fn merge_schedule_metadata(
340    payload: &mut Value,
341    schedule_id: uuid::Uuid,
342    schedule_name: &str,
343    scheduled_for: DateTime<Utc>,
344) {
345    let metadata = json!({
346        "schedule_id": schedule_id,
347        "schedule_name": schedule_name,
348        "scheduled_for": scheduled_for.to_rfc3339_opts(SecondsFormat::AutoSi, true),
349    });
350
351    match payload {
352        Value::Object(map) => {
353            map.insert("_schedule".to_string(), metadata);
354        }
355        _ => {
356            let original_payload = std::mem::take(payload);
357            *payload = json!({
358                "payload": original_payload,
359                "_schedule": metadata,
360            });
361        }
362    }
363}
364
365/// Scheduling semantics are UTC-only across the jobs framework.
366#[cfg(test)]
367fn compute_next_fire_at_utc(
368    cron_expr: &str,
369    from: DateTime<Utc>,
370    schedule_id: uuid::Uuid,
371    max_jitter_seconds: i32,
372) -> Option<DateTime<Utc>> {
373    let schedule = Schedule::from_str(cron_expr).ok()?;
374    next_jittered_fire_after_utc(&schedule, from, schedule_id, max_jitter_seconds)
375}
376
377fn compute_coalesced_next_fire_after_now_utc(
378    schedule: &Schedule,
379    now: DateTime<Utc>,
380    schedule_id: uuid::Uuid,
381    max_jitter_seconds: i32,
382) -> Option<DateTime<Utc>> {
383    if max_jitter_seconds <= 0 {
384        return next_jittered_fire_after_utc(schedule, now, schedule_id, max_jitter_seconds);
385    }
386
387    let jitter_window_start = now
388        .checked_sub_signed(Duration::seconds(i64::from(max_jitter_seconds) + 1))
389        .unwrap_or(now);
390    first_jittered_fire_after_utc(
391        schedule,
392        jitter_window_start,
393        now,
394        schedule_id,
395        max_jitter_seconds,
396    )
397    .or_else(|| next_jittered_fire_after_utc(schedule, now, schedule_id, max_jitter_seconds))
398}
399
400fn first_jittered_fire_after_utc(
401    schedule: &Schedule,
402    from: DateTime<Utc>,
403    after: DateTime<Utc>,
404    schedule_id: uuid::Uuid,
405    max_jitter_seconds: i32,
406) -> Option<DateTime<Utc>> {
407    schedule
408        .after(&from)
409        .take(SCHEDULE_STALE_CATCHUP_JITTER_SEARCH_LIMIT)
410        .map(|next| apply_schedule_jitter(schedule_id, next, max_jitter_seconds))
411        .filter(|next| *next > after)
412        .min()
413}
414
415fn next_jittered_fire_after_utc(
416    schedule: &Schedule,
417    from: DateTime<Utc>,
418    schedule_id: uuid::Uuid,
419    max_jitter_seconds: i32,
420) -> Option<DateTime<Utc>> {
421    let next = schedule.after(&from).next()?;
422    Some(apply_schedule_jitter(schedule_id, next, max_jitter_seconds))
423}
424
425fn apply_schedule_jitter(
426    schedule_id: uuid::Uuid,
427    next_fire_at: DateTime<Utc>,
428    max_jitter_seconds: i32,
429) -> DateTime<Utc> {
430    // Jitter is intentionally non-negative; the stale coalescing search depends
431    // on never moving a cron base earlier than its scheduled time.
432    next_fire_at
433        + Duration::seconds(schedule_jitter_seconds(
434            schedule_id,
435            next_fire_at,
436            max_jitter_seconds,
437        ))
438}
439
440fn schedule_jitter_seconds(
441    schedule_id: uuid::Uuid,
442    next_fire_at: DateTime<Utc>,
443    max_jitter_seconds: i32,
444) -> i64 {
445    if max_jitter_seconds <= 0 {
446        return 0;
447    }
448
449    let max_range = max_jitter_seconds as u128 + 1;
450    let next_millis = next_fire_at.timestamp_millis() as u128;
451    ((schedule_id.as_u128() ^ next_millis) % max_range) as i64
452}
453
454fn failed_schedule_retry_at(now: DateTime<Utc>) -> DateTime<Utc> {
455    now + Duration::seconds(FAILED_SCHEDULE_RETRY_DELAY_SECONDS)
456}
457
458#[cfg(test)]
459mod tests;