@@ -11,7 +11,10 @@ use crate::{
1111use anyhow:: Context ;
1212use croner:: Cron ;
1313use retrack_types:: trackers:: Tracker ;
14- use std:: { ops:: Add , sync:: Arc , time:: Instant } ;
14+ use std:: {
15+ sync:: Arc ,
16+ time:: { Duration , Instant } ,
17+ } ;
1518use time:: OffsetDateTime ;
1619use tokio_cron_scheduler:: { Job , JobScheduler } ;
1720use 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