Skip to main content

shared/models/server_backup/
retention.rs

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    /// Only free slots retention already considers expendable, so an interactive create never
98    /// destroys a backup the server's rules still keep.
99    Expendable,
100    /// Fall back to evicting a single unmanaged or still-retained backup of the same kind, so
101    /// unattended schedules keep running once a server settles on its backup limit.
102    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
192/// Ranks a server's backups by how expendable they are for a create of `kind`. Backups retention
193/// has already condemned are offered whatever their kind, since deleting them is due anyway, but
194/// the last-resort tiers are restricted to `kind` so a server backup never sacrifices a database
195/// one to make room.
196fn 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    /// Frees a slot for a server that has reached its `backup_limit` by making room for a backup
529    /// of `kind`, returning how many deletions were successfully dispatched. Node deletions
530    /// complete asynchronously, so the caller must subtract this from its own count rather than
531    /// counting again.
532    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}