Skip to content

Commit 70a2c9f

Browse files
committed
make scheduling mandatory
1 parent bb0679c commit 70a2c9f

6 files changed

Lines changed: 264 additions & 54 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Defines a continuously-refreshed replica of a PostgreSQL database restored from
8686
|-------|------|----------|---------|-------------|
8787
| `kopiaSecretRef` | `SecretReference` | Yes | — | Reference to a Secret containing Kopia repository credentials (`bucket`, `region`, `repositoryPassword`, `accessKeyId`, `secretAccessKey`). |
8888
| `snapshotFilter` | `SnapshotFilter` | No | — | Filter criteria to select which Kopia snapshot to restore. |
89-
| `schedule` | `string` | No | — | Cron expression controlling how often new restores are triggered. |
89+
| `schedule` | `string` | Yes | — | Cron expression controlling how often new restores are triggered. |
9090
| `scheduleJitter` | `string` | No | `"10m"` | Random jitter added to scheduled restores (friendly duration, e.g. `"5m"`, `"1h"`). |
9191
| `minimumTtl` | `string` | No | — | Don't restore a new snapshot within this duration of the last restore completing. |
9292
| `switchoverGracePeriod` | `string` | No | `"5m"` | How long to wait before deleting the old restore after a switchover. |

src/controllers/replica.rs

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crate::{
2020
kopia,
2121
types::*,
2222
};
23+
use scheduling::ScheduleDecision;
2324

2425
mod resources;
2526
mod scheduling;
@@ -239,13 +240,6 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
239240
.send_notifications(client, &ctx.http_client, switching, &ctx.metrics)
240241
.await;
241242

242-
// Recompute next scheduled restore in case schedule changed while restore was in flight
243-
if let Some(next) = replica.compute_next_scheduled_restore(now) {
244-
replica
245-
.update_status_field(client, "nextScheduledRestore", Time(next))
246-
.await?;
247-
}
248-
249243
return Ok(Action::requeue(Duration::from_secs(10)));
250244
}
251245

@@ -402,22 +396,42 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
402396
.and_then(|s| s.last_restore_completed_at.as_ref())
403397
.is_none();
404398

399+
// Ensure nextScheduledRestore is populated
400+
if replica
401+
.status
402+
.as_ref()
403+
.and_then(|s| s.next_scheduled_restore.as_ref())
404+
.is_none()
405+
&& let Some(next) = replica.compute_next_scheduled_restore(now)
406+
{
407+
replica
408+
.update_status_field(client, "nextScheduledRestore", Time(next))
409+
.await?;
410+
}
411+
405412
if never_restored {
406413
info!(
407414
replica = name,
408415
"no successful restore yet, triggering immediately"
409416
);
410417
}
411418

412-
let should_restore = never_restored || replica.should_trigger_scheduled_restore();
419+
let schedule_decision = replica.check_schedule();
413420

414-
if should_restore {
415-
if let Some(next) = replica.compute_next_scheduled_restore(now) {
416-
replica
417-
.update_status_field(client, "nextScheduledRestore", Time(next))
418-
.await?;
419-
}
421+
let should_restore = never_restored || matches!(schedule_decision, ScheduleDecision::Trigger);
422+
423+
// Recompute nextScheduledRestore when a trigger fires (whether it proceeds or is skipped by TTL)
424+
if matches!(
425+
schedule_decision,
426+
ScheduleDecision::Trigger | ScheduleDecision::SkippedByTtl
427+
) && let Some(next) = replica.compute_next_scheduled_restore(now)
428+
{
429+
replica
430+
.update_status_field(client, "nextScheduledRestore", Time(next))
431+
.await?;
432+
}
420433

434+
if should_restore {
421435
// Check concurrent restore limit
422436
let mut queue = ctx.restore_queue.write().await;
423437
if !queue.can_start(ctx.max_concurrent_restores()) {

src/controllers/replica/scheduling.rs

Lines changed: 230 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ use tracing::{debug, info, warn};
88

99
use crate::types::*;
1010

11+
#[derive(Debug, Clone, PartialEq, Eq)]
12+
pub enum ScheduleDecision {
13+
NotDue,
14+
Trigger,
15+
SkippedByTtl,
16+
}
17+
1118
impl PostgresPhysicalReplica {
1219
pub fn compute_next_scheduled_restore(&self, now: Timestamp) -> Option<Timestamp> {
13-
let schedule = self.spec.schedule.as_deref()?;
20+
let schedule = &self.spec.schedule;
1421

1522
let Ok(cron) = parse_crontab_with(schedule, {
1623
let mut options = ParseOptions::default();
@@ -67,60 +74,250 @@ impl PostgresPhysicalReplica {
6774
Some(next.into())
6875
}
6976

70-
pub fn should_trigger_scheduled_restore(&self) -> bool {
77+
pub fn check_schedule(&self) -> ScheduleDecision {
7178
let name = self.name_any();
72-
let Some(schedule) = &self.spec.schedule else {
73-
debug!(replica = %name, "no schedule configured, skipping scheduled restore");
74-
return false;
79+
let schedule = &self.spec.schedule;
80+
let status = self.status.as_ref();
81+
let now = Timestamp::now().to_zoned(TimeZone::UTC);
82+
83+
let Some(next_scheduled) = status.and_then(|s| s.next_scheduled_restore.as_ref()) else {
84+
info!(
85+
replica = %name,
86+
schedule = schedule,
87+
"no nextScheduledRestore set, triggering"
88+
);
89+
return ScheduleDecision::Trigger;
7590
};
7691

77-
let status = self.status.as_ref();
92+
if now < next_scheduled.0.to_zoned(TimeZone::UTC) {
93+
debug!(
94+
replica = %name,
95+
next_scheduled = %next_scheduled.0,
96+
"scheduled restore not due yet, skipping"
97+
);
98+
return ScheduleDecision::NotDue;
99+
}
78100

79-
let now = Timestamp::now().to_zoned(TimeZone::UTC);
101+
info!(
102+
replica = %name,
103+
next_scheduled = %next_scheduled.0,
104+
"scheduled restore time reached"
105+
);
80106

81-
// Check minimumTTL (only if configured)
107+
// Check minimumTTL: prevent restoring too soon after the last one
82108
if let Some(ref minimum_ttl) = self.spec.minimum_ttl
83109
&& let Some(last_completed) = status.and_then(|s| s.last_restore_completed_at.as_ref())
84110
{
85111
let not_before = last_completed
86112
.0
87113
.to_zoned(TimeZone::UTC)
88114
.saturating_add(minimum_ttl.0);
89-
if not_before < now {
90-
debug!(
115+
if now < not_before {
116+
info!(
91117
replica = %name,
92118
last_completed = %last_completed.0,
93119
%minimum_ttl,
94120
%not_before,
95121
"last restore completed within minimum TTL, skipping"
96122
);
97-
return false;
123+
return ScheduleDecision::SkippedByTtl;
98124
}
99125
}
100126

101-
if let Some(next_scheduled) = status.and_then(|s| s.next_scheduled_restore.as_ref()) {
102-
if now >= next_scheduled.0.to_zoned(TimeZone::UTC) {
103-
info!(
104-
replica = %name,
105-
next_scheduled = %next_scheduled.0,
106-
"scheduled restore time reached, triggering"
107-
);
108-
} else {
109-
debug!(
110-
replica = %name,
111-
next_scheduled = %next_scheduled.0,
112-
"scheduled restore not due yet, skipping"
113-
);
114-
return false;
115-
}
116-
} else {
117-
info!(
118-
replica = %name,
119-
schedule = schedule,
120-
"no nextScheduledRestore set, triggering"
121-
);
127+
ScheduleDecision::Trigger
128+
}
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use std::collections::HashMap;
134+
135+
use jiff::Span;
136+
use k8s_openapi::{api::core::v1::SecretReference, apimachinery::pkg::apis::meta::v1::Time};
137+
use kube::api::ObjectMeta;
138+
139+
use crate::util::TimeSpan;
140+
141+
use super::*;
142+
143+
fn make_replica(
144+
schedule: &str,
145+
next_scheduled: Option<Timestamp>,
146+
last_completed: Option<Timestamp>,
147+
minimum_ttl: Option<TimeSpan>,
148+
) -> PostgresPhysicalReplica {
149+
PostgresPhysicalReplica {
150+
metadata: ObjectMeta {
151+
name: Some("test-replica".into()),
152+
namespace: Some("default".into()),
153+
uid: Some("test-uid-123".into()),
154+
..Default::default()
155+
},
156+
spec: PostgresPhysicalReplicaSpec {
157+
kopia_secret_ref: SecretReference {
158+
name: Some("test-secret".into()),
159+
namespace: None,
160+
},
161+
snapshot_filter: None,
162+
schedule: schedule.into(),
163+
schedule_jitter: TimeSpan(Span::new().seconds(0)),
164+
minimum_ttl,
165+
switchover_grace_period: TimeSpan(Span::new().minutes(5)),
166+
analytics_username: "analytics".into(),
167+
storage_class: None,
168+
storage_size_override: None,
169+
resources: None,
170+
service_annotations: None,
171+
pod_annotations: None,
172+
affinity: None,
173+
tolerations: Vec::new(),
174+
read_only: true,
175+
postgres_extra_config: None,
176+
notifications: Vec::new(),
177+
overlay_database: None,
178+
},
179+
status: Some(PostgresPhysicalReplicaStatus {
180+
next_scheduled_restore: next_scheduled.map(Time),
181+
last_restore_completed_at: last_completed.map(Time),
182+
..Default::default()
183+
}),
122184
}
185+
}
186+
187+
#[test]
188+
fn check_schedule_no_next_scheduled_triggers() {
189+
let replica = make_replica("0 */6 * * *", None, None, None);
190+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
191+
}
192+
193+
#[test]
194+
fn check_schedule_not_due_yet() {
195+
let future = Timestamp::now()
196+
.to_zoned(jiff::tz::TimeZone::UTC)
197+
.saturating_add(Span::new().hours(3))
198+
.timestamp();
199+
let replica = make_replica("0 */6 * * *", Some(future), None, None);
200+
assert_eq!(replica.check_schedule(), ScheduleDecision::NotDue);
201+
}
202+
203+
#[test]
204+
fn check_schedule_due_triggers() {
205+
let past = Timestamp::now()
206+
.to_zoned(jiff::tz::TimeZone::UTC)
207+
.saturating_add(Span::new().hours(-1))
208+
.timestamp();
209+
let replica = make_replica("0 */6 * * *", Some(past), None, None);
210+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
211+
}
212+
213+
#[test]
214+
fn check_schedule_due_but_within_ttl_skips() {
215+
let past = Timestamp::now()
216+
.to_zoned(jiff::tz::TimeZone::UTC)
217+
.saturating_add(Span::new().hours(-1))
218+
.timestamp();
219+
// Last restore completed 30 minutes ago, TTL is 2 hours
220+
let last_completed = Timestamp::now()
221+
.to_zoned(jiff::tz::TimeZone::UTC)
222+
.saturating_add(Span::new().minutes(-30))
223+
.timestamp();
224+
let ttl = TimeSpan(Span::new().hours(2));
225+
let replica = make_replica("0 */6 * * *", Some(past), Some(last_completed), Some(ttl));
226+
assert_eq!(replica.check_schedule(), ScheduleDecision::SkippedByTtl);
227+
}
228+
229+
#[test]
230+
fn check_schedule_due_and_past_ttl_triggers() {
231+
let past = Timestamp::now()
232+
.to_zoned(jiff::tz::TimeZone::UTC)
233+
.saturating_add(Span::new().hours(-1))
234+
.timestamp();
235+
// Last restore completed 3 hours ago, TTL is 2 hours
236+
let last_completed = Timestamp::now()
237+
.to_zoned(jiff::tz::TimeZone::UTC)
238+
.saturating_add(Span::new().hours(-3))
239+
.timestamp();
240+
let ttl = TimeSpan(Span::new().hours(2));
241+
let replica = make_replica("0 */6 * * *", Some(past), Some(last_completed), Some(ttl));
242+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
243+
}
244+
245+
#[test]
246+
fn check_schedule_no_ttl_configured_triggers() {
247+
let past = Timestamp::now()
248+
.to_zoned(jiff::tz::TimeZone::UTC)
249+
.saturating_add(Span::new().hours(-1))
250+
.timestamp();
251+
// Last restore completed recently but no TTL configured
252+
let last_completed = Timestamp::now()
253+
.to_zoned(jiff::tz::TimeZone::UTC)
254+
.saturating_add(Span::new().minutes(-10))
255+
.timestamp();
256+
let replica = make_replica("0 */6 * * *", Some(past), Some(last_completed), None);
257+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
258+
}
259+
260+
#[test]
261+
fn compute_next_scheduled_restore_valid_cron() {
262+
let replica = make_replica("0 */6 * * *", None, None, None);
263+
let now = Timestamp::now();
264+
let next = replica.compute_next_scheduled_restore(now);
265+
assert!(next.is_some());
266+
assert!(next.unwrap() > now);
267+
}
268+
269+
#[test]
270+
fn compute_next_scheduled_restore_invalid_cron() {
271+
let replica = make_replica("not a cron", None, None, None);
272+
let now = Timestamp::now();
273+
assert!(replica.compute_next_scheduled_restore(now).is_none());
274+
}
275+
276+
#[test]
277+
fn compute_next_scheduled_restore_with_jitter() {
278+
let mut replica = make_replica("0 */6 * * *", None, None, None);
279+
replica.spec.schedule_jitter = TimeSpan(Span::new().minutes(30));
280+
let now = Timestamp::now();
281+
282+
let mut results: Vec<Timestamp> = (0..10)
283+
.filter_map(|_| replica.compute_next_scheduled_restore(now))
284+
.collect();
285+
results.sort();
286+
results.dedup();
287+
288+
// With 30 minutes of jitter, repeated calls should produce varying results
289+
assert!(
290+
results.len() > 1,
291+
"jitter should produce different results across calls"
292+
);
293+
}
294+
295+
#[test]
296+
fn check_schedule_ttl_with_no_previous_restore_triggers() {
297+
let past = Timestamp::now()
298+
.to_zoned(jiff::tz::TimeZone::UTC)
299+
.saturating_add(Span::new().hours(-1))
300+
.timestamp();
301+
// TTL configured but no last_completed timestamp (first restore)
302+
let ttl = TimeSpan(Span::new().hours(2));
303+
let replica = make_replica("0 */6 * * *", Some(past), None, Some(ttl));
304+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
305+
}
123306

124-
true
307+
#[test]
308+
fn check_schedule_uses_tags_for_filtering() {
309+
// Verify that check_schedule works with snapshot filter tags
310+
// (tags are used elsewhere but shouldn't affect scheduling)
311+
let past = Timestamp::now()
312+
.to_zoned(jiff::tz::TimeZone::UTC)
313+
.saturating_add(Span::new().hours(-1))
314+
.timestamp();
315+
let mut replica = make_replica("0 */6 * * *", Some(past), None, None);
316+
replica.spec.snapshot_filter = Some(SnapshotFilter {
317+
tags: Some(HashMap::from([("env".into(), "prod".into())])),
318+
host_pattern: None,
319+
description_pattern: None,
320+
});
321+
assert_eq!(replica.check_schedule(), ScheduleDecision::Trigger);
125322
}
126323
}

src/controllers/restore/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ fn make_replica_with_opts(
2929
namespace: None,
3030
},
3131
snapshot_filter: None,
32-
schedule: None,
32+
schedule: "0 */6 * * *".into(),
3333
schedule_jitter: TimeSpan(Span::new().minutes(10)),
3434
minimum_ttl: None,
3535
switchover_grace_period: TimeSpan(Span::new().minutes(5)),

0 commit comments

Comments
 (0)