1919use std:: { collections:: HashSet , time:: Duration } ;
2020
2121use commons_servers:: backup_jobs:: {
22- JobKind , SharedBackupConfig , backup_bucket_billing_tags, effective_retention_for_group, is_due ,
23- slot_is_due ,
22+ JobKind , SharedBackupConfig , backup_bucket_billing_tags, effective_retention_for_group,
23+ slot_deadline_due ,
2424} ;
2525use commons_types:: backup:: BackupPlacement ;
2626use database:: {
@@ -45,11 +45,6 @@ const TICK: Duration = Duration::from_secs(60);
4545const DAY : Duration = Duration :: from_secs ( 24 * 3600 ) ;
4646const WEEK : Duration = Duration :: from_secs ( 7 * 24 * 3600 ) ;
4747
48- fn secs_into ( now : Timestamp , window : Duration ) -> u64 {
49- let w = window. as_secs ( ) . max ( 1 ) as i64 ;
50- now. as_second ( ) . rem_euclid ( w) as u64
51- }
52-
5348/// Build the `{type → policy}` retention map for a group: the effective
5449/// `RetentionPolicy` per enabled backup type, serialised through JSON into the
5550/// kopia layer's [`RetentionMap`]. Empty when the group has no enabled types
@@ -88,20 +83,22 @@ fn kopia_env(
8883}
8984
9085/// Decide which maintenance kind (if any) a group is due for this tick.
86+ ///
87+ /// Full (weekly) takes priority over quick (daily) and subsumes it. Both use
88+ /// [`slot_deadline_due`], which fires on the first tick at or after the group's
89+ /// jittered per-period deadline and keeps firing until a run happens — so a
90+ /// missed slot no longer defers the work a whole period (the reason full
91+ /// maintenance could go weeks without running and quick slipped past daily).
9192fn due_kind (
9293 group_id : uuid:: Uuid ,
9394 last_quick_or_full : Option < Timestamp > ,
9495 last_full : Option < Timestamp > ,
9596 now : Timestamp ,
9697) -> Option < JobKind > {
97- let full_due =
98- is_due ( WEEK , last_full, now) && slot_is_due ( group_id, WEEK , TICK , secs_into ( now, WEEK ) ) ;
99- if full_due {
98+ if slot_deadline_due ( group_id, WEEK , last_full, now) {
10099 return Some ( JobKind :: MaintFull ) ;
101100 }
102- let quick_due = is_due ( DAY , last_quick_or_full, now)
103- && slot_is_due ( group_id, DAY , TICK , secs_into ( now, DAY ) ) ;
104- quick_due. then_some ( JobKind :: MaintQuick )
101+ slot_deadline_due ( group_id, DAY , last_quick_or_full, now) . then_some ( JobKind :: MaintQuick )
105102}
106103
107104/// Whether a config needs its init (repo-create) op run this tick. True iff the
@@ -542,16 +539,59 @@ mod tests {
542539
543540 #[ test]
544541 fn maintenance_due_logic ( ) {
542+ use commons_servers:: backup_jobs:: jitter_slot;
545543 let g = uuid:: Uuid :: from_u128 ( 3 ) ;
546- // Recent full + quick → nothing due (elapsed gate), independent of slot.
547- let now: Timestamp = "2026-06-16T12:00:00Z" . parse ( ) . unwrap ( ) ;
548- let recent: Timestamp = "2026-06-16T11:00:00Z" . parse ( ) . unwrap ( ) ;
549- assert_eq ! ( due_kind( g, Some ( recent) , Some ( recent) , now) , None ) ;
550-
551- // At the group's weekly slot with no prior full run, full is due (and
552- // subsumes quick).
553- let week_slot = commons_servers:: backup_jobs:: jitter_slot ( g, WEEK ) . as_secs ( ) as i64 ;
554- let at_slot = Timestamp :: from_second ( week_slot) . unwrap ( ) ;
555- assert_eq ! ( due_kind( g, None , None , at_slot) , Some ( JobKind :: MaintFull ) ) ;
544+ let week_secs = WEEK . as_secs ( ) as i64 ;
545+ let day_secs = DAY . as_secs ( ) as i64 ;
546+ let week_off = jitter_slot ( g, WEEK ) . as_secs ( ) as i64 ;
547+ let day_off = jitter_slot ( g, DAY ) . as_secs ( ) as i64 ;
548+ // Fixture sanity: this group's slots aren't in the guarded tail, so the
549+ // targets derived below match what the scheduler computes.
550+ assert ! ( week_off < week_secs - 120 && day_off < day_secs - 120 ) ;
551+ let ts = |s : i64 | Timestamp :: from_second ( s) . unwrap ( ) ;
552+
553+ // A WEEK boundary well after the epoch (also a DAY boundary: WEEK = 7·DAY).
554+ let week_start = 3000 * week_secs;
555+ let full_target = week_start + week_off;
556+
557+ // Just ran both → nothing due.
558+ let now0 = ts ( week_start + week_off + 12345 ) ;
559+ assert_eq ! ( due_kind( g, Some ( now0) , Some ( now0) , now0) , None ) ;
560+
561+ // Never run, at the weekly deadline → full (which subsumes quick).
562+ assert_eq ! (
563+ due_kind( g, None , None , ts( full_target) ) ,
564+ Some ( JobKind :: MaintFull )
565+ ) ;
566+
567+ // Full ran last week and this week's slot was missed (no tick landed on
568+ // it); a later tick still catches it up rather than deferring another
569+ // week — the bug this fix targets.
570+ let late = ( ( week_secs - week_off) / 2 ) . max ( 1 ) ;
571+ let last_week_full = full_target - week_secs;
572+ assert_eq ! (
573+ due_kind(
574+ g,
575+ Some ( ts( last_week_full + 100 ) ) ,
576+ Some ( ts( last_week_full) ) ,
577+ ts( full_target + late) ,
578+ ) ,
579+ Some ( JobKind :: MaintFull ) ,
580+ ) ;
581+
582+ // Full is current (ran yesterday, still this week), but quick's deadline
583+ // for today has passed since → quick is due (and full isn't).
584+ let day_start = week_start + 3 * day_secs; // mid-week day boundary
585+ let full_ran_yesterday = day_start - day_secs + 500 ;
586+ let now_q = ts ( day_start + day_off + 10 ) ;
587+ assert_eq ! (
588+ due_kind(
589+ g,
590+ Some ( ts( full_ran_yesterday) ) ,
591+ Some ( ts( full_ran_yesterday) ) ,
592+ now_q,
593+ ) ,
594+ Some ( JobKind :: MaintQuick ) ,
595+ ) ;
556596 }
557597}
0 commit comments