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
23pub 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 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 return Err(SchedulerError::ClaimedScheduleMissing {
304 schedule_id: schedule.id,
305 operation: "marking schedule as fired",
306 }
307 .into());
308 }
309 Ok(())
310}
311
312fn 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#[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 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;