Skip to content

Commit c20787f

Browse files
authored
Merge pull request #347 from beyondessential/backup-maintenance-catchup
fix(backup): catch-up maintenance scheduling instead of knife-edge slots
2 parents bc4b246 + 4408185 commit c20787f

5 files changed

Lines changed: 229 additions & 54 deletions

File tree

crates/commons-servers/src/backup_jobs.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,58 @@ pub fn slot_is_due(
443443
secs_into_window >= slot && secs_into_window < slot + tick_secs
444444
}
445445

446+
/// The number of seconds a per-group deadline is held clear of its window's
447+
/// tail, so a run that starts a couple of ticks after its target still records
448+
/// within the same window (see [`slot_deadline_due`]).
449+
const DEADLINE_END_GUARD: i64 = 120;
450+
451+
/// Deadline-with-catch-up scheduling for periodic per-group work — use this
452+
/// instead of [`slot_is_due`] when *missing* the one-tick slot must not skip
453+
/// the whole period.
454+
///
455+
/// [`slot_is_due`] fires only on the single tick that lands inside a 60s slot;
456+
/// if that tick is missed (the per-minute loop drifts and its ticks are spaced
457+
/// slightly over a minute, the group is mid-op, or the process restarted across
458+
/// that minute) the work is deferred a whole `window`. For weekly work that
459+
/// means it can silently never run.
460+
///
461+
/// This instead treats the group's [`jitter_slot`] offset as a *deadline*
462+
/// within each epoch-anchored `window` and reports due once `now` has reached
463+
/// it — staying due on later ticks until a run actually happens. Concretely, due
464+
/// when either:
465+
/// - `now` has reached this window's target and the group hasn't run yet this
466+
/// window (`last` predates the window start), or
467+
/// - the last run is overdue by a full `window` (prompt catch-up after
468+
/// downtime, regardless of the slot).
469+
///
470+
/// `last` is the most recent run of this kind; `None` (never run) is due as soon
471+
/// as the current window's target passes. The target is held
472+
/// [`DEADLINE_END_GUARD`] seconds before the window boundary so a run caught a
473+
/// tick or two late still records within the window (otherwise a group whose
474+
/// offset landed in the final seconds would look un-run next window and slip to
475+
/// every-other-period). Still spreads the fleet: each group's target differs by
476+
/// its hash offset.
477+
pub fn slot_deadline_due(
478+
group_id: Uuid,
479+
window: Duration,
480+
last: Option<Timestamp>,
481+
now: Timestamp,
482+
) -> bool {
483+
let window_secs = window.as_secs().max(1) as i64;
484+
let offset = (jitter_slot(group_id, window).as_secs() as i64)
485+
.min((window_secs - DEADLINE_END_GUARD).max(0));
486+
let now_s = now.as_second();
487+
let window_start = now_s - now_s.rem_euclid(window_secs);
488+
let target = window_start + offset;
489+
match last {
490+
None => now_s >= target,
491+
Some(last) => {
492+
let last = last.as_second();
493+
(now_s >= target && last < window_start) || now_s - last >= window_secs
494+
}
495+
}
496+
}
497+
446498
#[cfg(test)]
447499
mod tests {
448500
use super::*;
@@ -479,6 +531,69 @@ mod tests {
479531
}
480532
}
481533

534+
#[test]
535+
fn slot_deadline_fires_once_per_window_and_catches_up() {
536+
let g = Uuid::from_u128(0x9e37_79b9_7f4a_7c15_f39c_c060_5ced_c834);
537+
let window = Duration::from_secs(86400); // DAY
538+
let window_secs = 86400i64;
539+
let ts = |s: i64| Timestamp::from_second(s).unwrap();
540+
541+
// The effective (guarded) offset the function uses, and the room left to
542+
// the window's end — used to keep every fixture instant inside the window
543+
// regardless of where this group's hash lands.
544+
let offset =
545+
(jitter_slot(g, window).as_secs() as i64).min(window_secs - DEADLINE_END_GUARD);
546+
assert!(offset >= 1, "fixture assumes a non-zero slot offset");
547+
let room = window_secs - offset;
548+
// A concrete window anchored to a DAY boundary (100 days after the epoch).
549+
let window_start = 100 * window_secs;
550+
let target = window_start + offset;
551+
552+
// Never run: not due before the target, due once it's reached.
553+
assert!(!slot_deadline_due(g, window, None, ts(target - 1)));
554+
assert!(slot_deadline_due(g, window, None, ts(target)));
555+
556+
// Ran earlier this same window → not due again this window (once-per-window,
557+
// no double run even if the earlier run predated the slot).
558+
assert!(!slot_deadline_due(
559+
g,
560+
window,
561+
Some(ts(window_start + 5)),
562+
ts(target + 1)
563+
));
564+
565+
// Ran in the previous window → due once this window's target passes (the
566+
// daily catch-up: yesterday's run doesn't block today's), but not before.
567+
let prev = window_start - window_secs + offset;
568+
assert!(!slot_deadline_due(
569+
g,
570+
window,
571+
Some(ts(prev)),
572+
ts(target - 1)
573+
));
574+
assert!(slot_deadline_due(g, window, Some(ts(prev)), ts(target)));
575+
576+
// The slot was missed for this window (no tick landed on it), but a later
577+
// tick still fires it — the whole point, vs slot_is_due deferring a period.
578+
let late = (room / 2).max(1);
579+
assert!(slot_deadline_due(
580+
g,
581+
window,
582+
Some(ts(prev)),
583+
ts(target + late)
584+
));
585+
586+
// Overdue by more than a full window fires regardless of the slot, even
587+
// early in the window before the target (downtime recovery).
588+
let stale = window_start - window_secs - 10;
589+
assert!(slot_deadline_due(
590+
g,
591+
window,
592+
Some(ts(stale)),
593+
ts(window_start + 1)
594+
));
595+
}
596+
482597
#[test]
483598
fn retention_floor_raises_below_keeps_above() {
484599
let r = RetentionPolicy {

crates/jobs/src/backup/inspection.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
2424
use std::time::Duration;
2525

26-
use commons_servers::backup_jobs::{effective_interval_for_group, slot_is_due};
26+
use commons_servers::backup_jobs::{effective_interval_for_group, slot_deadline_due};
2727
use database::{
2828
BackupConfigStatus, BackupMaintenanceRun, BackupRepoSnapshot, BackupRun,
2929
ServerGroupBackupConfig,
@@ -46,11 +46,6 @@ const TICK: Duration = Duration::from_secs(60);
4646
/// a group's effective backup interval is longer (or absent).
4747
const INSPECT_FLOOR: Duration = Duration::from_secs(7 * 24 * 3600);
4848

49-
fn secs_into(now: Timestamp, window: Duration) -> u64 {
50-
let w = window.as_secs().max(1) as i64;
51-
now.as_second().rem_euclid(w) as u64
52-
}
53-
5449
/// Whether a backup has landed since the repo was last inspected — the signal to
5550
/// inspect promptly (so the stats panel freshens shortly after a backup,
5651
/// including a manual "back up now") instead of waiting for the weekly cadence.
@@ -182,8 +177,13 @@ async fn tick(worker: &Worker) -> Result<(), String> {
182177
.await
183178
.map_err(|e| e.to_string())?
184179
.map_or(INSPECT_FLOOR, |i| i.max(INSPECT_FLOOR));
185-
let into = secs_into(now, window);
186-
if !due_after_backup && !due_after_maint && !slot_is_due(c.group_id, window, TICK, into) {
180+
// Fallback cadence: fire on the first tick at/after the group's jittered
181+
// deadline and keep firing until an inspection lands (catch-up), rather
182+
// than only in a single 60s slot that a drifting/slow tick can skip.
183+
if !due_after_backup
184+
&& !due_after_maint
185+
&& !slot_deadline_due(c.group_id, window, last_inspected, now)
186+
{
187187
continue;
188188
}
189189
spawn_inspect(worker, c.clone());

crates/jobs/src/backup/maintenance.rs

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
use std::{collections::HashSet, time::Duration};
2020

2121
use 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
};
2525
use commons_types::backup::BackupPlacement;
2626
use database::{
@@ -45,11 +45,6 @@ const TICK: Duration = Duration::from_secs(60);
4545
const DAY: Duration = Duration::from_secs(24 * 3600);
4646
const 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).
9192
fn 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
}

crates/jobs/src/backup/preflight.rs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@
1616
//! bypasses per-server `is_monitored`. AWS calls are not unit-tested here (no
1717
//! live AWS in CI); the pure logic — the Object-Lock assertion — is.
1818
19-
use std::time::Duration;
19+
use std::{collections::HashMap, time::Duration};
2020

2121
use aws_sdk_s3::types::ObjectLockRetentionMode;
2222
use aws_sdk_sts::error::{ProvideErrorMetadata, SdkError};
2323
use aws_sdk_sts::operation::RequestId;
24-
use commons_servers::backup_jobs::slot_is_due;
24+
use commons_servers::backup_jobs::slot_deadline_due;
2525
use commons_types::issue::Severity;
2626
use database::{
2727
BackupConfigStatus, ServerGroupBackupConfig,
@@ -33,6 +33,7 @@ use tokio::{
3333
time::sleep,
3434
};
3535
use tracing::{debug, error, warn};
36+
use uuid::Uuid;
3637

3738
const TICK: Duration = Duration::from_secs(60);
3839
const DEEP_WINDOW: Duration = Duration::from_secs(3600);
@@ -102,13 +103,6 @@ async fn recover_identity_alert(
102103
Ok(())
103104
}
104105

105-
/// Seconds elapsed into the current `window` for `now` — used with
106-
/// [`slot_is_due`] to fire a group's hourly deep check on the right minute tick.
107-
fn secs_into_window(now: Timestamp, window: Duration) -> u64 {
108-
let w = window.as_secs().max(1) as i64;
109-
(now.as_second().rem_euclid(w)) as u64
110-
}
111-
112106
struct Aws {
113107
sts: aws_sdk_sts::Client,
114108
config: aws_config::SdkConfig,
@@ -298,6 +292,11 @@ pub fn spawn() -> JoinHandle<()> {
298292
sts: aws_sdk_sts::Client::new(&config),
299293
config,
300294
};
295+
// In-memory per-group anchor for the hourly deep check (no persisted
296+
// last-check timestamp): lets a missed slot catch up on a later tick and
297+
// keeps a slow predecessor group from pushing others past their slot.
298+
// Resets on restart, re-firing at the next deadline.
299+
let mut last_deep: HashMap<Uuid, Timestamp> = HashMap::new();
301300

302301
loop {
303302
sleep(TICK).await;
@@ -344,11 +343,17 @@ pub fn spawn() -> JoinHandle<()> {
344343
continue;
345344
}
346345

347-
// Per-group deep checks on their hash-jittered hourly slot.
346+
// Per-group deep checks on their hash-jittered hourly deadline, with
347+
// catch-up on a later tick if this one drifts past the slot.
348348
let now = Timestamp::now();
349-
let into = secs_into_window(now, DEEP_WINDOW);
350349
for cfg in &ready {
351-
if slot_is_due(cfg.group_id, DEEP_WINDOW, TICK, into) {
350+
if slot_deadline_due(
351+
cfg.group_id,
352+
DEEP_WINDOW,
353+
last_deep.get(&cfg.group_id).copied(),
354+
now,
355+
) {
356+
last_deep.insert(cfg.group_id, now);
352357
debug!(group = %cfg.group_id, "running hourly preflight deep check");
353358
deep_check_group(&mut db, &aws, cfg).await;
354359
}

0 commit comments

Comments
 (0)