1use super::{DeleteServerBackupOptions, EvictionScope, ServerBackup, ServerBackupKind};
2use crate::prelude::*;
3use chrono::Datelike;
4use garde::Validate;
5use serde::{Deserialize, Serialize};
6use sqlx::Row;
7use std::collections::{HashMap, HashSet};
8use utoipa::ToSchema;
9
10#[derive(Debug, ToSchema, Validate, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
11#[serde(default)]
12pub struct BackupRetention {
13 #[garde(range(max = 2147483647))]
14 #[schema(minimum = 0, maximum = 2147483647)]
15 pub count: u32,
16 #[garde(range(max = 2147483647))]
17 #[schema(minimum = 0, maximum = 2147483647)]
18 pub days: u32,
19 #[garde(range(max = 2147483647))]
20 #[schema(minimum = 0, maximum = 2147483647)]
21 pub daily: u32,
22 #[garde(range(max = 2147483647))]
23 #[schema(minimum = 0, maximum = 2147483647)]
24 pub weekly: u32,
25 #[garde(range(max = 2147483647))]
26 #[schema(minimum = 0, maximum = 2147483647)]
27 pub monthly: u32,
28 #[garde(range(max = 2147483647))]
29 #[schema(minimum = 0, maximum = 2147483647)]
30 pub yearly: u32,
31}
32
33impl BackupRetention {
34 #[inline]
35 pub fn is_disabled(&self) -> bool {
36 self.count == 0
37 && self.days == 0
38 && self.daily == 0
39 && self.weekly == 0
40 && self.monthly == 0
41 && self.yearly == 0
42 }
43
44 fn retained_backups(
45 &self,
46 backups: impl Iterator<Item = (uuid::Uuid, chrono::NaiveDateTime)>,
47 now: chrono::NaiveDateTime,
48 ) -> HashSet<uuid::Uuid> {
49 let mut backups: Vec<_> = backups.collect();
50 backups.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
51
52 let mut retained = HashSet::new();
53 let mut days = HashSet::new();
54 let mut weeks = HashSet::new();
55 let mut months = HashSet::new();
56 let mut years = HashSet::new();
57
58 for (index, (uuid, created)) in backups.into_iter().enumerate() {
59 let week = created.iso_week();
60 let daily = days.len() < self.daily as usize && days.insert(created.date());
61 let weekly =
62 weeks.len() < self.weekly as usize && weeks.insert((week.year(), week.week()));
63 let monthly = months.len() < self.monthly as usize
64 && months.insert((created.year(), created.month()));
65 let yearly = years.len() < self.yearly as usize && years.insert(created.year());
66
67 let recent = self.days > 0
68 && now.signed_duration_since(created) <= chrono::Duration::days(self.days as i64);
69
70 if self.is_disabled()
71 || index < self.count as usize
72 || recent
73 || daily
74 || weekly
75 || monthly
76 || yearly
77 {
78 retained.insert(uuid);
79 }
80 }
81
82 retained
83 }
84}
85
86#[derive(Clone)]
87pub struct RetentionDeletionGuard {
88 pub backup_group_uuid: Option<uuid::Uuid>,
89 pub system_backup_policy_uuid: Option<uuid::Uuid>,
90 pub successful: bool,
91 pub completed: chrono::NaiveDateTime,
92 pub retention: BackupRetention,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum EvictionMode {
97 Expendable,
100 Any,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
106enum EvictionTier {
107 ExpiredFailed,
108 OverRetention,
109 RecentFailed,
110 Ungrouped,
111 Retained,
112}
113
114impl EvictionTier {
115 #[inline]
116 fn is_expendable(self) -> bool {
117 matches!(self, Self::ExpiredFailed | Self::OverRetention)
118 }
119
120 #[inline]
121 fn rule(self) -> &'static str {
122 match self {
123 Self::ExpiredFailed | Self::RecentFailed => "failed",
124 Self::OverRetention => "retention",
125 Self::Ungrouped => "ungrouped",
126 Self::Retained => "in-retention",
127 }
128 }
129}
130
131fn database_source(backup: &ServerBackup) -> Option<uuid::Uuid> {
132 if backup.kind == ServerBackupKind::Server {
133 return None;
134 }
135
136 Some(backup.database_instance_uuid.unwrap_or_else(|| {
137 backup.metadata["source_instance"]["uuid"]
138 .as_str()
139 .and_then(|uuid| uuid.parse().ok())
140 .unwrap_or(backup.uuid)
141 }))
142}
143
144fn deletion_candidates<'a>(
145 backups: impl IntoIterator<Item = &'a ServerBackup>,
146 retention: &BackupRetention,
147 now: chrono::NaiveDateTime,
148) -> HashSet<uuid::Uuid> {
149 let mut scopes: HashMap<_, Vec<&ServerBackup>> = HashMap::new();
150 let mut candidates = HashSet::new();
151
152 for backup in backups {
153 if backup.deleted.is_some() || backup.deleting.is_some() {
154 continue;
155 }
156
157 let Some(completed) = backup.completed else {
158 continue;
159 };
160
161 if !backup.successful {
162 if !backup.locked && completed <= now - chrono::Duration::hours(24) {
163 candidates.insert(backup.uuid);
164 }
165
166 continue;
167 }
168
169 let source = database_source(backup);
170 scopes
171 .entry((backup.kind, source))
172 .or_default()
173 .push(backup);
174 }
175
176 for scope in scopes.values() {
177 let retained = retention.retained_backups(
178 scope.iter().map(|backup| (backup.uuid, backup.created)),
179 now,
180 );
181
182 for backup in scope {
183 if !backup.locked && !retained.contains(&backup.uuid) {
184 candidates.insert(backup.uuid);
185 }
186 }
187 }
188
189 candidates
190}
191
192fn eviction_order<'a>(
197 backups: &'a [ServerBackup],
198 group_retentions: &HashMap<uuid::Uuid, BackupRetention>,
199 kind: ServerBackupKind,
200 now: chrono::NaiveDateTime,
201) -> Vec<(EvictionTier, &'a ServerBackup)> {
202 let mut scopes: HashMap<Option<uuid::Uuid>, Vec<&ServerBackup>> = HashMap::new();
203 for backup in backups {
204 scopes
205 .entry(backup.backup_group_uuid)
206 .or_default()
207 .push(backup);
208 }
209
210 let unmanaged = BackupRetention::default();
211
212 let mut ranked = Vec::new();
213 for (group_uuid, scope) in scopes {
214 let retention = group_uuid
215 .and_then(|uuid| group_retentions.get(&uuid))
216 .unwrap_or(&unmanaged);
217 let candidates = deletion_candidates(scope.iter().copied(), retention, now);
218
219 for backup in scope {
220 if backup.locked {
221 continue;
222 }
223
224 let tier = match (
225 backup.successful,
226 candidates.contains(&backup.uuid),
227 group_uuid.is_some(),
228 ) {
229 (false, true, _) => EvictionTier::ExpiredFailed,
230 (true, true, _) => EvictionTier::OverRetention,
231 (false, false, _) => EvictionTier::RecentFailed,
232 (true, false, false) => EvictionTier::Ungrouped,
233 (true, false, true) => EvictionTier::Retained,
234 };
235
236 if !tier.is_expendable() && backup.kind != kind {
237 continue;
238 }
239
240 ranked.push((tier, backup));
241 }
242 }
243
244 ranked.sort_unstable_by(|a, b| {
245 a.0.cmp(&b.0)
246 .then_with(|| a.1.created.cmp(&b.1.created))
247 .then_with(|| a.1.uuid.cmp(&b.1.uuid))
248 });
249
250 ranked
251}
252
253impl ServerBackup {
254 pub async fn prune_retention_for_backup(
255 state: &crate::State,
256 backup_uuid: uuid::Uuid,
257 ) -> Result<u64, anyhow::Error> {
258 let scope: Option<(Option<uuid::Uuid>, Option<uuid::Uuid>, Option<uuid::Uuid>)> =
259 sqlx::query_as(
260 r#"
261 SELECT
262 server_backups.server_uuid,
263 server_backups.backup_group_uuid,
264 server_backups.system_backup_policy_uuid
265 FROM server_backups
266 WHERE server_backups.uuid = $1
267 AND server_backups.deleted IS NULL
268 "#,
269 )
270 .bind(backup_uuid)
271 .fetch_optional(state.database.write())
272 .await?;
273
274 let Some((server_uuid, group_uuid, policy_uuid)) = scope else {
275 return Ok(0);
276 };
277
278 Self::prune_retention_scope(state, server_uuid, group_uuid, policy_uuid).await
279 }
280
281 async fn prune_retention_scope(
282 state: &crate::State,
283 server_uuid: Option<uuid::Uuid>,
284 group_uuid: Option<uuid::Uuid>,
285 policy_uuid: Option<uuid::Uuid>,
286 ) -> Result<u64, anyhow::Error> {
287 let (table, scope_column, empty_column, scope_uuid) = match (group_uuid, policy_uuid) {
288 (Some(uuid), None) => (
289 "server_backup_groups",
290 "backup_group_uuid",
291 "system_backup_policy_uuid",
292 uuid,
293 ),
294 (None, Some(uuid)) => (
295 "system_backup_policies",
296 "system_backup_policy_uuid",
297 "backup_group_uuid",
298 uuid,
299 ),
300 _ => return Ok(0),
301 };
302
303 let mut transaction = state.database.write().begin().await?;
304
305 let config = sqlx::query(sqlx::AssertSqlSafe(format!(
306 r#"
307 SELECT {table}.name, {table}.retention
308 FROM {table}
309 WHERE {table}.uuid = $1
310 FOR SHARE
311 "#
312 )))
313 .bind(scope_uuid)
314 .fetch_optional(&mut *transaction)
315 .await?;
316
317 let Some(config) = config else {
318 return Ok(0);
319 };
320
321 let name: compact_str::CompactString = config.try_get("name")?;
322 let retention: BackupRetention = serde_json::from_value(config.try_get("retention")?)?;
323 retention.validate()?;
324
325 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
326 r#"
327 SELECT {}
328 FROM server_backups
329 WHERE server_backups.{scope_column} = $1
330 AND server_backups.{empty_column} IS NULL
331 AND server_backups.server_uuid IS NOT DISTINCT FROM $2
332 AND server_backups.deleted IS NULL
333 AND server_backups.deleting IS NULL
334 AND server_backups.completed IS NOT NULL
335 ORDER BY server_backups.uuid
336 FOR UPDATE
337 "#,
338 Self::columns_sql(None)
339 )))
340 .bind(scope_uuid)
341 .bind(server_uuid)
342 .fetch_all(&mut *transaction)
343 .await?;
344
345 let backups = rows
346 .iter()
347 .map(|row| Self::map(None, row))
348 .try_collect_vec()?;
349
350 let candidates = deletion_candidates(&backups, &retention, chrono::Utc::now().naive_utc());
351
352 let mut claimed = Vec::new();
353 for backup in backups {
354 if !candidates.contains(&backup.uuid) || (server_uuid.is_none() && backup.successful) {
355 continue;
356 }
357
358 let Some(completed) = backup.completed else {
359 continue;
360 };
361
362 if backup.backup_configuration_in_maintenance(state).await? {
363 continue;
364 }
365
366 let options = DeleteServerBackupOptions {
367 retention: Some(RetentionDeletionGuard {
368 backup_group_uuid: group_uuid,
369 system_backup_policy_uuid: policy_uuid,
370 successful: backup.successful,
371 completed,
372 retention: retention.clone(),
373 }),
374 ..Default::default()
375 };
376 backup
377 .claim_deletion(state, &options, &mut transaction)
378 .await?;
379
380 claimed.push((backup, options));
381 }
382
383 transaction.commit().await?;
384
385 let mut pruned = 0;
386 for (backup, options) in claimed {
387 if let Err(err) = backup.dispatch_claimed_deletion(state, &options).await {
388 tracing::error!(backup = %backup.uuid, "failed to prune backup: {err:#?}");
389 continue;
390 }
391
392 if let Some(server_uuid) = server_uuid {
393 Self::log_eviction_activity(
394 state,
395 server_uuid,
396 &backup,
397 if backup.successful {
398 "retention"
399 } else {
400 "failed"
401 },
402 if group_uuid.is_some() {
403 EvictionScope::Group(name.as_str())
404 } else {
405 EvictionScope::Policy(name.as_str())
406 },
407 )
408 .await;
409 }
410
411 pruned += 1;
412 }
413
414 Ok(pruned)
415 }
416
417 pub async fn prune_ungrouped_backups(state: &crate::State) -> Result<u64, anyhow::Error> {
418 let servers: Vec<Option<uuid::Uuid>> = sqlx::query_scalar(
419 r#"
420 SELECT DISTINCT server_backups.server_uuid
421 FROM server_backups
422 WHERE server_backups.backup_group_uuid IS NULL
423 AND server_backups.system_backup_policy_uuid IS NULL
424 AND server_backups.deleted IS NULL
425 AND server_backups.deleting IS NULL
426 AND server_backups.completed IS NOT NULL
427 AND NOT server_backups.successful
428 "#,
429 )
430 .fetch_all(state.database.write())
431 .await?;
432
433 let mut pruned = 0;
434 for server_uuid in servers {
435 match Self::prune_ungrouped_scope(state, server_uuid).await {
436 Ok(count) => pruned += count,
437 Err(err) => {
438 tracing::error!(server = ?server_uuid, "failed to prune ungrouped backups: {err:#?}");
439 }
440 }
441 }
442
443 Ok(pruned)
444 }
445
446 async fn prune_ungrouped_scope(
447 state: &crate::State,
448 server_uuid: Option<uuid::Uuid>,
449 ) -> Result<u64, anyhow::Error> {
450 let mut transaction = state.database.write().begin().await?;
451
452 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
453 r#"
454 SELECT {}
455 FROM server_backups
456 WHERE server_backups.server_uuid IS NOT DISTINCT FROM $1
457 AND server_backups.backup_group_uuid IS NULL
458 AND server_backups.system_backup_policy_uuid IS NULL
459 AND server_backups.deleted IS NULL
460 AND server_backups.deleting IS NULL
461 AND server_backups.completed IS NOT NULL
462 AND NOT server_backups.successful
463 ORDER BY server_backups.uuid
464 FOR UPDATE
465 "#,
466 Self::columns_sql(None)
467 )))
468 .bind(server_uuid)
469 .fetch_all(&mut *transaction)
470 .await?;
471
472 let backups = rows
473 .iter()
474 .map(|row| Self::map(None, row))
475 .try_collect_vec()?;
476
477 let candidates = deletion_candidates(
478 &backups,
479 &BackupRetention::default(),
480 chrono::Utc::now().naive_utc(),
481 );
482
483 let options = DeleteServerBackupOptions::default();
484
485 let mut claimed = Vec::new();
486 for backup in backups {
487 if !candidates.contains(&backup.uuid) {
488 continue;
489 }
490
491 if backup.backup_configuration_in_maintenance(state).await? {
492 continue;
493 }
494
495 backup
496 .claim_deletion(state, &options, &mut transaction)
497 .await?;
498
499 claimed.push(backup);
500 }
501
502 transaction.commit().await?;
503
504 let mut pruned = 0;
505 for backup in claimed {
506 if let Err(err) = backup.dispatch_claimed_deletion(state, &options).await {
507 tracing::error!(backup = %backup.uuid, "failed to prune ungrouped backup: {err:#?}");
508 continue;
509 }
510
511 if let Some(server_uuid) = server_uuid {
512 Self::log_eviction_activity(
513 state,
514 server_uuid,
515 &backup,
516 "failed",
517 EvictionScope::Server,
518 )
519 .await;
520 }
521
522 pruned += 1;
523 }
524
525 Ok(pruned)
526 }
527
528 pub async fn evict_for_create(
533 state: &crate::State,
534 server_uuid: uuid::Uuid,
535 kind: ServerBackupKind,
536 mode: EvictionMode,
537 ) -> Result<u64, anyhow::Error> {
538 let mut transaction = state.database.write().begin().await?;
539
540 let group_rows = sqlx::query(
541 r#"
542 SELECT
543 server_backup_groups.uuid,
544 server_backup_groups.name,
545 server_backup_groups.retention
546 FROM server_backup_groups
547 WHERE server_backup_groups.server_uuid = $1
548 FOR SHARE
549 "#,
550 )
551 .bind(server_uuid)
552 .fetch_all(&mut *transaction)
553 .await?;
554
555 let mut group_names = HashMap::new();
556 let mut group_retentions = HashMap::new();
557 for row in group_rows {
558 let uuid: uuid::Uuid = row.try_get("uuid")?;
559 let name: compact_str::CompactString = row.try_get("name")?;
560 let retention: BackupRetention = serde_json::from_value(row.try_get("retention")?)?;
561 retention.validate()?;
562
563 group_names.insert(uuid, name);
564 group_retentions.insert(uuid, retention);
565 }
566
567 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
568 r#"
569 SELECT {}
570 FROM server_backups
571 WHERE server_backups.server_uuid = $1
572 AND server_backups.system_backup_policy_uuid IS NULL
573 AND server_backups.deleted IS NULL
574 AND server_backups.deleting IS NULL
575 AND server_backups.completed IS NOT NULL
576 ORDER BY server_backups.uuid
577 FOR UPDATE
578 "#,
579 Self::columns_sql(None)
580 )))
581 .bind(server_uuid)
582 .fetch_all(&mut *transaction)
583 .await?;
584
585 let backups = rows
586 .iter()
587 .map(|row| Self::map(None, row))
588 .try_collect_vec()?;
589 let ranked = eviction_order(
590 &backups,
591 &group_retentions,
592 kind,
593 chrono::Utc::now().naive_utc(),
594 );
595
596 let options = DeleteServerBackupOptions::default();
597
598 let mut claimed = Vec::new();
599 for (tier, backup) in ranked {
600 if !tier.is_expendable() && (mode == EvictionMode::Expendable || !claimed.is_empty()) {
601 break;
602 }
603
604 if backup.backup_configuration_in_maintenance(state).await? {
605 continue;
606 }
607
608 if tier == EvictionTier::Retained {
609 tracing::warn!(
610 server = %server_uuid,
611 backup = %backup.uuid,
612 "evicting a retained backup to satisfy backup_limit; retention quota exceeds backup_limit"
613 );
614 }
615
616 backup
617 .claim_deletion(state, &options, &mut transaction)
618 .await?;
619
620 claimed.push((tier, backup));
621 }
622
623 if mode == EvictionMode::Any && claimed.is_empty() {
624 tracing::warn!(
625 server = %server_uuid,
626 kind = ?kind,
627 "no backup could be evicted to satisfy backup_limit; every candidate is locked or belongs to another kind"
628 );
629 }
630
631 transaction.commit().await?;
632
633 let mut evicted = 0;
634 for (tier, backup) in claimed {
635 if let Err(err) = backup.dispatch_claimed_deletion(state, &options).await {
636 tracing::error!(backup = %backup.uuid, "failed to evict backup: {err:#?}");
637 continue;
638 }
639
640 Self::log_eviction_activity(
641 state,
642 server_uuid,
643 backup,
644 tier.rule(),
645 match backup
646 .backup_group_uuid
647 .and_then(|uuid| group_names.get(&uuid))
648 {
649 Some(name) => EvictionScope::Group(name.as_str()),
650 None => EvictionScope::Server,
651 },
652 )
653 .await;
654
655 evicted += 1;
656 }
657
658 Ok(evicted)
659 }
660
661 pub async fn prune_group_backups(state: &crate::State) -> Result<u64, anyhow::Error> {
662 let scopes: Vec<(uuid::Uuid, Option<uuid::Uuid>)> = sqlx::query_as(
663 r#"
664 SELECT DISTINCT server_backups.backup_group_uuid, server_backups.server_uuid
665 FROM server_backups
666 WHERE server_backups.backup_group_uuid IS NOT NULL
667 AND server_backups.deleted IS NULL
668 AND server_backups.deleting IS NULL
669 "#,
670 )
671 .fetch_all(state.database.write())
672 .await?;
673
674 let mut pruned = 0;
675 for (uuid, server_uuid) in scopes {
676 match Self::prune_retention_scope(state, server_uuid, Some(uuid), None).await {
677 Ok(count) => pruned += count,
678 Err(err) => {
679 tracing::error!(group = %uuid, "failed to prune backup group: {err:#?}");
680 }
681 }
682 }
683
684 Ok(pruned)
685 }
686
687 pub async fn prune_system_backups(state: &crate::State) -> Result<u64, anyhow::Error> {
688 let scopes: Vec<(uuid::Uuid, Option<uuid::Uuid>)> = sqlx::query_as(
689 r#"
690 SELECT DISTINCT server_backups.system_backup_policy_uuid, server_backups.server_uuid
691 FROM server_backups
692 WHERE server_backups.system_backup_policy_uuid IS NOT NULL
693 AND server_backups.deleted IS NULL
694 AND server_backups.deleting IS NULL
695 "#,
696 )
697 .fetch_all(state.database.write())
698 .await?;
699
700 let mut pruned = 0;
701 for (uuid, server_uuid) in scopes {
702 match Self::prune_retention_scope(state, server_uuid, None, Some(uuid)).await {
703 Ok(count) => pruned += count,
704 Err(err) => {
705 tracing::error!(policy = %uuid, "failed to prune system backups: {err:#?}");
706 }
707 }
708 }
709
710 Ok(pruned)
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::{
717 BackupRetention, EvictionTier, ServerBackup, ServerBackupKind, deletion_candidates,
718 eviction_order,
719 };
720 use crate::models::{
721 ByUuid,
722 node::Node,
723 server_backup::BackupDisk,
724 server_backup_group::{CreateServerBackupGroupOptions, UpdateServerBackupGroupOptions},
725 system_backup_policy::{CreateSystemBackupPolicyOptions, UpdateSystemBackupPolicyOptions},
726 };
727 use garde::Validate;
728 use std::collections::HashSet;
729
730 fn date(value: &str) -> chrono::NaiveDateTime {
731 chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S").unwrap()
732 }
733
734 fn ids(values: &[u128]) -> HashSet<uuid::Uuid> {
735 values.iter().copied().map(uuid::Uuid::from_u128).collect()
736 }
737
738 fn retained(
739 retention: BackupRetention,
740 backups: &[(u128, &str)],
741 now: &str,
742 ) -> HashSet<uuid::Uuid> {
743 retention.retained_backups(
744 backups
745 .iter()
746 .map(|(id, created)| (uuid::Uuid::from_u128(*id), date(created))),
747 date(now),
748 )
749 }
750
751 fn backup(id: u128, created: &str) -> ServerBackup {
752 let created = date(created);
753 ServerBackup {
754 uuid: uuid::Uuid::from_u128(id),
755 server: None,
756 node: Node::get_fetchable(uuid::Uuid::nil()),
757 backup_configuration: None,
758 backup_group_uuid: None,
759 system_backup_policy_uuid: None,
760 database_instance_uuid: None,
761 kind: ServerBackupKind::Server,
762 database_type: None,
763 name: "test".into(),
764 successful: true,
765 browsable: false,
766 streaming: false,
767 locked: false,
768 shared: false,
769 ignored_files: Vec::new(),
770 checksum: None,
771 bytes: 0,
772 files: 0,
773 disk: BackupDisk::Local,
774 upload_id: None,
775 upload_path: None,
776 metadata: serde_json::json!({}),
777 completed: Some(created + chrono::Duration::minutes(1)),
778 deleting: None,
779 deletion_retries: 0,
780 deleted: None,
781 created,
782 extension_data: Vec::new(),
783 }
784 }
785
786 #[test]
787 fn overlapping_calendar_rules_choose_newest_backup_in_each_occupied_period() {
788 let backups = [
789 (1, "2026-09-07 08:00:00"),
790 (2, "2026-09-07 20:00:00"),
791 (3, "2026-09-06 09:00:00"),
792 (4, "2026-09-06 21:00:00"),
793 (5, "2026-08-30 12:00:00"),
794 (6, "2026-07-31 12:00:00"),
795 (7, "2025-12-31 12:00:00"),
796 (8, "2025-01-01 12:00:00"),
797 ];
798 assert_eq!(
799 retained(
800 BackupRetention {
801 daily: 2,
802 weekly: 2,
803 monthly: 2,
804 yearly: 2,
805 ..Default::default()
806 },
807 &backups,
808 "2026-09-08 00:00:00",
809 ),
810 ids(&[2, 4, 5, 7]),
811 );
812 }
813
814 #[test]
815 fn missed_days_do_not_consume_daily_retention_slots() {
816 assert_eq!(
817 retained(
818 BackupRetention {
819 daily: 3,
820 ..Default::default()
821 },
822 &[
823 (1, "2026-09-07 12:00:00"),
824 (2, "2026-08-20 12:00:00"),
825 (3, "2026-05-01 12:00:00"),
826 (4, "2026-04-30 12:00:00"),
827 ],
828 "2026-09-08 00:00:00",
829 ),
830 ids(&[1, 2, 3]),
831 );
832 }
833
834 #[test]
835 fn weekly_retention_uses_monday_and_iso_week_year() {
836 assert_eq!(
837 retained(
838 BackupRetention {
839 weekly: 2,
840 ..Default::default()
841 },
842 &[
843 (1, "2021-01-04 00:00:00"),
844 (2, "2021-01-03 23:59:59"),
845 (3, "2020-12-31 23:59:59"),
846 (4, "2020-12-28 00:00:00"),
847 (5, "2020-12-27 23:59:59"),
848 ],
849 "2021-01-05 00:00:00",
850 ),
851 ids(&[1, 2]),
852 );
853 }
854
855 #[test]
856 fn monthly_and_yearly_retention_handle_leap_day_and_year_boundaries() {
857 let backups = [
858 (1, "2024-03-01 00:00:00"),
859 (2, "2024-02-29 23:59:59"),
860 (3, "2024-02-28 23:59:59"),
861 (4, "2024-01-01 00:00:00"),
862 (5, "2023-12-31 23:59:59"),
863 (6, "2023-03-01 00:00:00"),
864 (7, "2022-12-31 23:59:59"),
865 ];
866 assert_eq!(
867 retained(
868 BackupRetention {
869 monthly: 2,
870 yearly: 2,
871 ..Default::default()
872 },
873 &backups,
874 "2024-03-02 00:00:00",
875 ),
876 ids(&[1, 2, 5]),
877 );
878 }
879
880 #[test]
881 fn count_days_and_calendar_rules_form_a_union() {
882 assert_eq!(
883 retained(
884 BackupRetention {
885 count: 2,
886 days: 1,
887 monthly: 2,
888 ..Default::default()
889 },
890 &[
891 (1, "2026-09-07 11:00:00"),
892 (2, "2026-09-07 10:00:00"),
893 (3, "2026-09-06 12:00:00"),
894 (4, "2026-09-06 11:59:59"),
895 (5, "2026-08-31 23:59:59"),
896 (6, "2026-08-31 12:00:00"),
897 ],
898 "2026-09-07 12:00:00",
899 ),
900 ids(&[1, 2, 3, 5]),
901 );
902 assert_eq!(
903 retained(
904 BackupRetention {
905 count: 2,
906 days: 1,
907 ..Default::default()
908 },
909 &[
910 (1, "2026-09-07 11:00:00"),
911 (2, "2026-09-01 00:00:00"),
912 (3, "2026-08-01 00:00:00")
913 ],
914 "2026-09-07 12:00:00",
915 ),
916 ids(&[1, 2]),
917 );
918 }
919
920 #[test]
921 fn equal_timestamps_have_deterministic_selection() {
922 let retention = BackupRetention {
923 count: 1,
924 ..Default::default()
925 };
926 let backups = [(1, "2026-09-07 12:00:00"), (2, "2026-09-07 12:00:00")];
927 assert_eq!(
928 retained(retention.clone(), &backups, "2026-09-08 00:00:00"),
929 ids(&[2])
930 );
931 assert_eq!(
932 retained(retention, &[backups[1], backups[0]], "2026-09-08 00:00:00"),
933 ids(&[2])
934 );
935 }
936
937 #[test]
938 fn disabled_retention_preserves_all_successful_backups() {
939 let retention = BackupRetention::default();
940 assert!(retention.is_disabled());
941 assert!(retention.validate().is_ok());
942 assert_eq!(
943 retained(
944 retention,
945 &[(1, "2000-01-01 00:00:00"), (2, "2026-09-07 00:00:00")],
946 "2026-09-08 00:00:00"
947 ),
948 ids(&[1, 2]),
949 );
950 }
951
952 #[test]
953 fn failed_cleanup_uses_completion_time_and_protects_active_or_locked_backups() {
954 let now = date("2026-09-07 12:00:00");
955 let mut backups: Vec<_> = (1..=7)
956 .map(|id| backup(id, "2026-08-01 00:00:00"))
957 .collect();
958 for backup in &mut backups {
959 backup.successful = false;
960 backup.completed = Some(now - chrono::Duration::hours(24));
961 }
962 backups[1].completed =
963 Some(now - chrono::Duration::hours(24) + chrono::Duration::seconds(1));
964 backups[2].locked = true;
965 backups[3].completed = None;
966 backups[4].deleting = Some(now);
967 backups[5].deleted = Some(now);
968 backups[6].completed = Some(now - chrono::Duration::days(2));
969 assert_eq!(
970 deletion_candidates(
971 &backups,
972 &BackupRetention {
973 count: 1,
974 ..Default::default()
975 },
976 now
977 ),
978 ids(&[1, 7])
979 );
980 }
981
982 #[test]
983 fn protected_successful_backups_are_never_deleted_or_replaced_by_inflight_records() {
984 let now = date("2026-09-07 12:00:00");
985 let mut backups = vec![
986 backup(1, "2026-09-01 00:00:00"),
987 backup(2, "2026-09-02 00:00:00"),
988 backup(3, "2026-09-03 00:00:00"),
989 backup(4, "2026-09-04 00:00:00"),
990 backup(5, "2026-09-05 00:00:00"),
991 backup(6, "2026-09-06 00:00:00"),
992 ];
993 backups[0].locked = true;
994 backups[3].completed = None;
995 backups[4].deleting = Some(now);
996 backups[5].deleted = Some(now);
997 assert_eq!(
998 deletion_candidates(
999 &backups,
1000 &BackupRetention {
1001 count: 1,
1002 ..Default::default()
1003 },
1004 now
1005 ),
1006 ids(&[2])
1007 );
1008 }
1009
1010 #[test]
1011 fn failed_replacement_does_not_displace_previous_successful_backup() {
1012 let now = date("2026-09-07 12:00:00");
1013 let mut backups = vec![
1014 backup(1, "2026-09-01 00:00:00"),
1015 backup(2, "2026-09-07 11:00:00"),
1016 ];
1017 backups[1].successful = false;
1018 let retention = BackupRetention {
1019 count: 1,
1020 ..Default::default()
1021 };
1022 assert!(deletion_candidates(&backups, &retention, now).is_empty());
1023 assert_eq!(
1024 deletion_candidates(&backups, &retention, now + chrono::Duration::days(2)),
1025 ids(&[2])
1026 );
1027 }
1028
1029 #[test]
1030 fn database_histories_are_isolated_and_orphan_metadata_preserves_identity() {
1031 let now = date("2026-09-07 12:00:00");
1032 let mut backups: Vec<_> = (1..=10)
1033 .map(|id| backup(id, "2026-09-01 00:00:00"))
1034 .collect();
1035 for backup in &mut backups[2..] {
1036 backup.kind = ServerBackupKind::DatabaseInstance;
1037 backup.database_type = Some(db_agent_api::DatabaseAgentType::Postgres);
1038 }
1039 backups[2].database_instance_uuid = Some(uuid::Uuid::from_u128(100));
1040 backups[2].metadata =
1041 serde_json::json!({"source_instance": {"uuid": uuid::Uuid::from_u128(200)}});
1042 backups[3].metadata =
1043 serde_json::json!({"source_instance": {"uuid": uuid::Uuid::from_u128(100)}});
1044 backups[4].database_instance_uuid = Some(uuid::Uuid::from_u128(200));
1045 backups[5].database_instance_uuid = Some(uuid::Uuid::from_u128(200));
1046 for backup in &mut backups[6..8] {
1047 backup.metadata =
1048 serde_json::json!({"source_instance": {"uuid": uuid::Uuid::from_u128(300)}});
1049 }
1050 backups[8].metadata = serde_json::json!({"source_instance": {"uuid": "invalid"}});
1051 assert_eq!(
1052 deletion_candidates(
1053 &backups,
1054 &BackupRetention {
1055 count: 1,
1056 ..Default::default()
1057 },
1058 now
1059 ),
1060 ids(&[1, 3, 5, 7])
1061 );
1062 }
1063
1064 #[test]
1065 fn disabled_retention_still_reaps_failed_backups_past_the_grace_period() {
1066 let now = date("2026-09-07 12:00:00");
1067 let mut backups: Vec<_> = (1..=4)
1068 .map(|id| backup(id, "2026-01-01 00:00:00"))
1069 .collect();
1070 backups[1].successful = false;
1071 backups[1].completed = Some(now - chrono::Duration::hours(24));
1072 backups[2].successful = false;
1073 backups[2].completed = Some(now - chrono::Duration::hours(23));
1074 backups[3].successful = false;
1075 backups[3].completed = Some(now - chrono::Duration::days(7));
1076 backups[3].locked = true;
1077
1078 assert_eq!(
1079 deletion_candidates(&backups, &BackupRetention::default(), now),
1080 ids(&[2])
1081 );
1082 }
1083
1084 #[test]
1085 fn eviction_prefers_expendable_backups_and_never_offers_locked_ones() {
1086 let now = date("2026-09-07 12:00:00");
1087 let group = uuid::Uuid::from_u128(900);
1088
1089 let mut backups: Vec<_> = (1..=8)
1090 .map(|id| backup(id, "2026-09-01 00:00:00"))
1091 .collect();
1092 for backup in &mut backups[..5] {
1093 backup.backup_group_uuid = Some(group);
1094 }
1095 backups[0].successful = false;
1096 backups[0].completed = Some(now - chrono::Duration::hours(24));
1097 backups[1].successful = false;
1098 backups[1].completed = Some(now - chrono::Duration::hours(1));
1099 backups[2].created = date("2026-09-02 00:00:00");
1100 backups[3].created = date("2026-09-03 00:00:00");
1101 backups[4].locked = true;
1102 backups[6].created = date("2026-09-04 00:00:00");
1103 backups[7].successful = false;
1104 backups[7].completed = Some(now - chrono::Duration::days(3));
1105
1106 let retentions = std::collections::HashMap::from([(
1107 group,
1108 BackupRetention {
1109 count: 1,
1110 ..Default::default()
1111 },
1112 )]);
1113
1114 assert_eq!(
1115 eviction_order(&backups, &retentions, ServerBackupKind::Server, now)
1116 .into_iter()
1117 .map(|(tier, backup)| (tier, backup.uuid))
1118 .collect::<Vec<_>>(),
1119 vec![
1120 (EvictionTier::ExpiredFailed, uuid::Uuid::from_u128(1)),
1121 (EvictionTier::ExpiredFailed, uuid::Uuid::from_u128(8)),
1122 (EvictionTier::OverRetention, uuid::Uuid::from_u128(3)),
1123 (EvictionTier::RecentFailed, uuid::Uuid::from_u128(2)),
1124 (EvictionTier::Ungrouped, uuid::Uuid::from_u128(6)),
1125 (EvictionTier::Ungrouped, uuid::Uuid::from_u128(7)),
1126 (EvictionTier::Retained, uuid::Uuid::from_u128(4)),
1127 ],
1128 );
1129 }
1130
1131 #[test]
1132 fn fallback_eviction_only_sacrifices_backups_of_the_kind_being_created() {
1133 let now = date("2026-09-07 12:00:00");
1134 let instance = uuid::Uuid::from_u128(500);
1135
1136 let mut backups = vec![
1137 backup(1, "2026-09-01 00:00:00"),
1138 backup(2, "2026-09-02 00:00:00"),
1139 backup(3, "2026-09-03 00:00:00"),
1140 ];
1141 for backup in &mut backups[1..] {
1142 backup.kind = ServerBackupKind::DatabaseInstance;
1143 backup.database_type = Some(db_agent_api::DatabaseAgentType::Postgres);
1144 backup.database_instance_uuid = Some(instance);
1145 }
1146 backups[2].successful = false;
1147 backups[2].completed = Some(now - chrono::Duration::days(2));
1148
1149 let order = |kind| {
1150 eviction_order(&backups, &std::collections::HashMap::new(), kind, now)
1151 .into_iter()
1152 .map(|(tier, backup)| (tier, backup.uuid))
1153 .collect::<Vec<_>>()
1154 };
1155
1156 assert_eq!(
1157 order(ServerBackupKind::Server),
1158 vec![
1159 (EvictionTier::ExpiredFailed, uuid::Uuid::from_u128(3)),
1160 (EvictionTier::Ungrouped, uuid::Uuid::from_u128(1)),
1161 ],
1162 );
1163 assert_eq!(
1164 order(ServerBackupKind::DatabaseInstance),
1165 vec![
1166 (EvictionTier::ExpiredFailed, uuid::Uuid::from_u128(3)),
1167 (EvictionTier::Ungrouped, uuid::Uuid::from_u128(2)),
1168 ],
1169 );
1170 }
1171
1172 #[test]
1173 fn retention_patch_distinguishes_omission_null_and_object() {
1174 let group: UpdateServerBackupGroupOptions =
1175 serde_json::from_value(serde_json::json!({})).unwrap();
1176 let policy: UpdateSystemBackupPolicyOptions =
1177 serde_json::from_value(serde_json::json!({})).unwrap();
1178 assert!(group.retention.is_none());
1179 assert!(policy.retention.is_none());
1180 assert!(
1181 serde_json::to_value(group)
1182 .unwrap()
1183 .get("retention")
1184 .is_none()
1185 );
1186 assert!(
1187 serde_json::to_value(policy)
1188 .unwrap()
1189 .get("retention")
1190 .is_none()
1191 );
1192 assert!(
1193 serde_json::from_value::<UpdateServerBackupGroupOptions>(
1194 serde_json::json!({"retention": null})
1195 )
1196 .is_err()
1197 );
1198 assert!(
1199 serde_json::from_value::<UpdateSystemBackupPolicyOptions>(
1200 serde_json::json!({"retention": null})
1201 )
1202 .is_err()
1203 );
1204 for value in [
1205 serde_json::json!({}),
1206 serde_json::json!({"count": 20, "days": 7, "monthly": 12}),
1207 ] {
1208 let group: UpdateServerBackupGroupOptions =
1209 serde_json::from_value(serde_json::json!({"retention": value})).unwrap();
1210 let policy: UpdateSystemBackupPolicyOptions =
1211 serde_json::from_value(serde_json::json!({"retention": value})).unwrap();
1212 assert_eq!(group.retention, policy.retention);
1213 assert!(group.retention.is_some());
1214 assert!(group.validate().is_ok());
1215 assert!(policy.validate().is_ok());
1216 }
1217 }
1218
1219 #[test]
1220 fn create_retention_defaults_to_unlimited_and_rejects_null() {
1221 let mut group = serde_json::json!({"server_uuid": uuid::Uuid::nil(), "name": "test"});
1222 let mut policy = serde_json::json!({"name": "test", "enabled": true, "cron": "0 0 0 * * *", "parallelism": 2});
1223 assert!(
1224 serde_json::from_value::<CreateServerBackupGroupOptions>(group.clone())
1225 .unwrap()
1226 .retention
1227 .is_disabled()
1228 );
1229 assert!(
1230 serde_json::from_value::<CreateSystemBackupPolicyOptions>(policy.clone())
1231 .unwrap()
1232 .retention
1233 .is_disabled()
1234 );
1235 group["retention"] = serde_json::Value::Null;
1236 policy["retention"] = serde_json::Value::Null;
1237 assert!(serde_json::from_value::<CreateServerBackupGroupOptions>(group).is_err());
1238 assert!(serde_json::from_value::<CreateSystemBackupPolicyOptions>(policy).is_err());
1239 }
1240
1241 #[test]
1242 fn retention_bounds_preserve_legacy_integer_range_and_reject_overflow() {
1243 for field in ["count", "days", "daily", "weekly", "monthly", "yearly"] {
1244 let mut value = serde_json::json!({});
1245 value[field] = serde_json::json!(i32::MAX);
1246 let retention: BackupRetention = serde_json::from_value(value.clone()).unwrap();
1247 assert!(retention.validate().is_ok(), "{field}");
1248 value[field] = serde_json::json!(i32::MAX as u32 + 1);
1249 let retention: BackupRetention = serde_json::from_value(value.clone()).unwrap();
1250 assert!(retention.validate().is_err(), "{field}");
1251 value[field] = serde_json::json!(-1);
1252 assert!(
1253 serde_json::from_value::<BackupRetention>(value.clone()).is_err(),
1254 "{field}"
1255 );
1256 value[field] = serde_json::json!(u32::MAX as u64 + 1);
1257 assert!(
1258 serde_json::from_value::<BackupRetention>(value).is_err(),
1259 "{field}"
1260 );
1261 }
1262 }
1263}