@@ -8,9 +8,16 @@ use tracing::{debug, info, warn};
88
99use crate :: types:: * ;
1010
11+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
12+ pub enum ScheduleDecision {
13+ NotDue ,
14+ Trigger ,
15+ SkippedByTtl ,
16+ }
17+
1118impl 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}
0 commit comments