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
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::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 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 return Err(SchedulerError::ClaimedScheduleMissing {
291 schedule_id: schedule.id,
292 operation: "marking schedule as fired",
293 }
294 .into());
295 }
296 Ok(())
297}
298
299fn 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#[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 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;