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