Skip to content

Commit 9b349c2

Browse files
committed
fix(scheduler): schedule tracker retries as one-shots, not date-pinned cron
A retry built as `{sec} {min} {hour} {dom} {month} *` parses in croner's Quartz mode (`dom_and_dow(true)`); if the persisted closure is ever lost from `tokio_cron_scheduler`'s in-memory `SimpleJobCode` map, the next persisted `next_tick` is advanced to the next matching date — typically a year out. Use `Job::new_one_shot_at_instant_async(Instant::now() + retry_in, ...)` instead: `schedule = None`, `job_type = OneShot`, `next_tick` is the absolute retry instant and is reset to `None` after firing, so there is no future tick that can drift. `try_resume` reconstitutes one-shot retries from `scheduler_jobs` and treats any leftover cron-style retry rows as stale, clearing `tracker.job_id` so the scheduling job re-arms the tracker with its real cron.
1 parent b15aab1 commit 9b349c2

1 file changed

Lines changed: 161 additions & 66 deletions

File tree

src/scheduler/scheduler_jobs/trackers_run_job.rs

Lines changed: 161 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ use crate::{
1111
use anyhow::Context;
1212
use croner::Cron;
1313
use retrack_types::trackers::Tracker;
14-
use std::{ops::Add, sync::Arc, time::Instant};
14+
use std::{
15+
sync::Arc,
16+
time::{Duration, Instant},
17+
};
1518
use time::OffsetDateTime;
1619
use tokio_cron_scheduler::{Job, JobScheduler};
1720
use tracing::{debug, error, warn};
@@ -94,20 +97,31 @@ impl TrackersRunJob {
9497
return Ok(None);
9598
}
9699

97-
// Check #6: The job should have the same parameters as during job creation. It's possible
98-
// that we need to resume the job that was scheduled for retry. In that case, the job
99-
// schedule will be different from the tracker schedule, and it's expected.
100-
let mut new_job = Self::create(
101-
api.clone(),
102-
if job_meta.retry_attempt > 0 {
103-
existing_job_data
104-
.schedule
105-
.as_ref()
106-
.unwrap_or(&job_config.schedule)
107-
} else {
108-
&job_config.schedule
109-
},
110-
)?;
100+
// Check #6: The job should have the same parameters as during job creation. Retry jobs
101+
// are persisted as `tokio_cron_scheduler` one-shots (`schedule = None`,
102+
// `job_type = OneShot`), reconstruct them via `create_retry` so the resumed `Job` is the
103+
// matching `NonCronJob` variant. Legacy retries persisted as date-pinned 6-field cron
104+
// strings (from before the one-shot migration) cannot be safely re-armed - clear the
105+
// tracker job and let `TrackersScheduleJob` re-schedule it with its real cron.
106+
let mut new_job = if job_meta.retry_attempt > 0 {
107+
if existing_job_data.schedule.is_some() {
108+
warn!(
109+
tracker.id = %tracker.id,
110+
tracker.name = tracker.name,
111+
job.id = %existing_job_data.id,
112+
"Found legacy cron-style retry job, clearing tracker job to allow re-scheduling."
113+
);
114+
trackers.clear_tracker_job(tracker.id).await?;
115+
return Ok(None);
116+
}
117+
118+
// The `Instant` here is a placeholder: `set_raw_job_data` below overwrites
119+
// `next_tick` with the persisted timestamp, and the scheduler reads that
120+
// persisted value (not the in-memory `Job`) when deciding when to fire.
121+
Self::create_retry(api.clone(), Instant::now() + Duration::from_secs(60))?
122+
} else {
123+
Self::create(api.clone(), &job_config.schedule)?
124+
};
111125
if !new_job.are_schedules_equal(&existing_job_data)? {
112126
debug!(
113127
tracker.id = %tracker.id,
@@ -160,6 +174,37 @@ impl TrackersRunJob {
160174
Ok(job)
161175
}
162176

177+
/// Creates a one-shot retry `TrackersRun` job that fires once at the given `instant`.
178+
///
179+
/// Retries deliberately do not reuse the cron path: a date-pinned 6-field cron
180+
/// (e.g. `0 30 10 21 5 *`) parses in Quartz mode (day-of-month AND day-of-week,
181+
/// `dom_and_dow(true)`) and, once the persisted closure is dropped from
182+
/// `tokio_cron_scheduler`'s in-memory `SimpleJobCode` map, the next persisted tick is
183+
/// computed as the next matching date - typically a year out. A `OneShot` job has no
184+
/// schedule string at all (`schedule = None`, `job_type = OneShot`), after firing,
185+
/// `next_tick` is set to `None` and the row is reaped by the scheduler's
186+
/// `to_be_deleted` pass, so there is no future tick to drift.
187+
pub fn create_retry<DR: DnsResolver>(
188+
api: Arc<Api<DR>>,
189+
instant: Instant,
190+
) -> anyhow::Result<Job> {
191+
let mut job = Job::new_one_shot_at_instant_async(instant, move |job_id, job_scheduler| {
192+
let api = api.clone();
193+
Box::pin(async move {
194+
debug!(job.id = %job_id, "Running retry job.");
195+
if let Err(err) = Self::execute(job_id, api, &job_scheduler).await {
196+
error!(job.id = %job_id, "Retry job failed with unexpected error: {err:?}.");
197+
} else {
198+
debug!(job.id = %job_id, "Finished running retry job.");
199+
}
200+
})
201+
})?;
202+
203+
job.set_job_meta(&Self::create_job_meta())?;
204+
205+
Ok(job)
206+
}
207+
163208
async fn execute<DR: DnsResolver>(
164209
job_id: Uuid,
165210
api: Arc<Api<DR>>,
@@ -316,27 +361,23 @@ impl TrackersRunJob {
316361
}
317362

318363
let retry_in = retry_strategy.interval(job_meta.retry_attempt);
319-
let next_at = OffsetDateTime::now_utc().add(retry_in);
320-
let new_schedule = format!(
321-
"{} {} {} {} {} *",
322-
next_at.second(),
323-
next_at.minute(),
324-
next_at.hour(),
325-
next_at.day(),
326-
u8::from(next_at.month()),
327-
);
328364
warn!(
329365
tracker.id = %tracker.id,
330366
tracker.name = tracker.name,
331367
job.id = %job_id,
332368
metrics.job_retries = job_meta.retry_attempt + 1,
333-
"Scheduled a retry to create tracker data revision in {} ({new_schedule}).",
369+
"Scheduled a one-shot retry to create tracker data revision in {}.",
334370
humantime::format_duration(retry_in)
335371
);
336372

337-
// Create a new job that inherits all parameters from the current job, but
338-
// with a new schedule based on retry strategy.
339-
let mut retry_job = Self::create(api.clone(), &new_schedule)?;
373+
// Schedule the retry as a `tokio_cron_scheduler` one-shot. This avoids the
374+
// year-pinned cron pattern that the previous implementation built from
375+
// `now + retry_in`: a date-pinned 6-field cron parses in Quartz mode and,
376+
// once the in-memory closure is lost for any reason, the scheduler advances
377+
// `next_tick` to the next matching date - typically a year out. With a
378+
// one-shot, `next_tick` is the absolute retry instant and is cleared to
379+
// `None` after firing, so there is no future tick to drift.
380+
let mut retry_job = Self::create_retry(api.clone(), Instant::now() + retry_in)?;
340381
retry_job.set_job_meta(
341382
Self::create_job_meta().set_retry_attempt(job_meta.retry_attempt + 1),
342383
)?;
@@ -347,7 +388,7 @@ impl TrackersRunJob {
347388
// closure store (two independent listener tasks process the `add` and
348389
// `remove` events on a shared `HashMap<Uuid, _>`); when the deletion
349390
// is applied after the insertion, the retry's closure is wiped while
350-
// its persisted metadata remains, and the next cron tick can't find a
391+
// its persisted metadata remains, and the next tick can't find a
351392
// closure for the id. With distinct ids the two listeners touch
352393
// different keys and cannot collide.
353394
let new_job_id = job_scheduler.add(retry_job).await?;
@@ -557,36 +598,35 @@ mod tests {
557598
let tracker = api.trackers().create_tracker(create_params).await?;
558599
api.trackers().set_tracker_job(tracker.id, job_id).await?;
559600

560-
// Create associated tracker job.
561-
let mut mock_job = mock_scheduler_job(job_id, SchedulerJob::TrackersRun, "0 10 10 10 * *");
601+
// Persist a one-shot retry job (schedule = None, job_type = OneShot/2) at the
602+
// shape that `TrackersRunJob::create_retry` writes when scheduling a retry.
603+
let mut mock_job = mock_scheduler_job(job_id, SchedulerJob::TrackersRun, "");
604+
mock_job.schedule = None;
605+
mock_job.job_type = 2;
606+
mock_job.next_tick =
607+
Some((OffsetDateTime::now_utc() + Duration::from_secs(60)).unix_timestamp());
562608
mock_job.extra = Some(Vec::try_from(
563609
&*SchedulerJobMetadata::new(SchedulerJob::TrackersRun).set_retry_attempt(2),
564610
)?);
565611
mock_upsert_scheduler_job(&api.db, &mock_job).await?;
566612

567613
let mut job = TrackersRunJob::try_resume(api.clone(), mock_job)
568614
.await?
569-
.unwrap();
570-
let job_data = job
571-
.job_data()
572-
.map(|job_data| (job_data.job_type, job_data.extra, job_data.job))?;
573-
assert_debug_snapshot!(job_data, @r###"
574-
(
575-
0,
576-
[
577-
2,
578-
0,
579-
2,
580-
],
581-
Some(
582-
CronJob(
583-
CronJob {
584-
schedule: "0 10 10 10 * *",
585-
},
586-
),
587-
),
588-
)
589-
"###);
615+
.expect("one-shot retry job should be resumable");
616+
let job_data = job.job_data()?;
617+
// `job_type = 2` (OneShot) and `retry_attempt = 2` survive the resume round-trip.
618+
assert_eq!(job_data.job_type, 2);
619+
assert_eq!(
620+
SchedulerJobMetadata::try_from(job_data.extra.as_slice())?,
621+
*SchedulerJobMetadata::new(SchedulerJob::TrackersRun).set_retry_attempt(2)
622+
);
623+
// The inner job is a `NonCronJob` (no cron schedule string).
624+
match job_data.job {
625+
Some(tokio_cron_scheduler::job::job_data_prost::job_stored_data::Job::NonCronJob(
626+
_,
627+
)) => {}
628+
other => panic!("expected NonCronJob, got {other:?}"),
629+
}
590630

591631
let unscheduled_trackers = api.trackers().get_trackers_to_schedule().await?;
592632
assert!(unscheduled_trackers.is_empty());
@@ -603,6 +643,58 @@ mod tests {
603643
Ok(())
604644
}
605645

646+
#[sqlx::test]
647+
async fn removes_legacy_cron_style_retry_job(pool: PgPool) -> anyhow::Result<()> {
648+
let api = Arc::new(mock_api(pool).await?);
649+
650+
let job_id = uuid!("00000000-0000-0000-0000-000000000000");
651+
652+
// Create tracker with retry config.
653+
let mut create_params = TrackerCreateParamsBuilder::new("tracker").build();
654+
create_params.config.job = Some(SchedulerJobConfig {
655+
schedule: "0 0 * * * *".to_string(),
656+
retry_strategy: Some(SchedulerJobRetryStrategy::Constant {
657+
interval: Duration::from_secs(60),
658+
max_attempts: 3,
659+
}),
660+
});
661+
let tracker = api.trackers().create_tracker(create_params).await?;
662+
api.trackers().set_tracker_job(tracker.id, job_id).await?;
663+
664+
// Mimic a retry job persisted by the pre-one-shot code path: a 6-field cron
665+
// (`<sec> <min> <hour> <dom> <month> *`) with a `retry_attempt > 0` metadata.
666+
// The new `try_resume` must recognise this as stale and unstick the tracker.
667+
let mut mock_job = mock_scheduler_job(job_id, SchedulerJob::TrackersRun, "0 10 10 10 5 *");
668+
mock_job.extra = Some(Vec::try_from(
669+
&*SchedulerJobMetadata::new(SchedulerJob::TrackersRun).set_retry_attempt(2),
670+
)?);
671+
mock_upsert_scheduler_job(&api.db, &mock_job).await?;
672+
673+
let job = TrackersRunJob::try_resume(api.clone(), mock_job).await?;
674+
assert!(
675+
job.is_none(),
676+
"legacy cron-style retry should be dropped from the scheduler"
677+
);
678+
679+
// Tracker should now be unscheduled (job_id cleared) and visible to the scheduling job.
680+
let unscheduled_trackers = api.trackers().get_trackers_to_schedule().await?;
681+
assert_eq!(
682+
unscheduled_trackers,
683+
vec![Tracker {
684+
job_id: None,
685+
..tracker
686+
}]
687+
);
688+
assert!(
689+
api.trackers()
690+
.get_tracker_by_job_id(job_id)
691+
.await?
692+
.is_none()
693+
);
694+
695+
Ok(())
696+
}
697+
606698
#[sqlx::test]
607699
async fn removes_job_if_does_not_have_meta(pool: PgPool) -> anyhow::Result<()> {
608700
let api = Arc::new(mock_api(pool).await?);
@@ -1641,25 +1733,28 @@ mod tests {
16411733
"original scheduler_jobs row should be removed once the retry is registered"
16421734
);
16431735

1644-
// The new `scheduler_jobs` row must carry the retry's 6-field cron schedule with
1645-
// day-of-week left unrestricted, plus `retry_attempt = 1`.
1736+
// The new `scheduler_jobs` row must be a `tokio_cron_scheduler` one-shot:
1737+
// `schedule = None`, `job_type = OneShot` (= 2), `next_tick` in the near future,
1738+
// and `retry_attempt = 1` in its metadata.
16461739
let new_job = mock_get_scheduler_job(&api.db, new_job_id)
16471740
.await?
16481741
.expect("retry scheduler_jobs row must exist");
1649-
let schedule = new_job
1650-
.schedule
1651-
.as_ref()
1652-
.expect("retry job must have a schedule")
1653-
.clone();
1654-
let fields: Vec<&str> = schedule.split_whitespace().collect();
1655-
assert_eq!(
1656-
fields.len(),
1657-
6,
1658-
"retry schedule must be a 6-field cron expression: {schedule}"
1742+
assert!(
1743+
new_job.schedule.is_none(),
1744+
"one-shot retry must not carry a cron schedule string: {:?}",
1745+
new_job.schedule
16591746
);
16601747
assert_eq!(
1661-
fields[5], "*",
1662-
"retry schedule must leave day-of-week unrestricted: {schedule}"
1748+
new_job.job_type, 2,
1749+
"one-shot retry must persist `job_type = OneShot` (2)"
1750+
);
1751+
let next_tick = new_job
1752+
.next_tick
1753+
.expect("one-shot retry must have a concrete next_tick");
1754+
let now = OffsetDateTime::now_utc().unix_timestamp();
1755+
assert!(
1756+
next_tick >= now - 5 && next_tick <= now + 120,
1757+
"retry next_tick should sit near `now + retry_interval` (60s): next_tick={next_tick}, now={now}"
16631758
);
16641759
let meta = SchedulerJobMetadata::try_from(new_job.extra.as_deref().unwrap())?;
16651760
assert_eq!(meta.job_type, SchedulerJob::TrackersRun);

0 commit comments

Comments
 (0)