package retention import ( "context" "errors" "fmt" "log/slog" "time" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/repository" ) // Enforcer wendet eine Aufbewahrungsregel auf ein Repository an. // // Die Trennung von `Apply` ist Absicht: Die Rechnung, **welche** Backups gehen, // ist reine Logik und ohne Repository pruefbar. Erst der Enforcer fasst Daten // an — und er tut es nur, wenn man ihn ausdruecklich darum bittet. type Enforcer struct { // targetRepository ist das betroffene Repository. targetRepository *repository.LocalRepository // logger protokolliert den Verlauf. logger *slog.Logger // timeSource liefert die Gegenwart; im Test austauschbar. timeSource func() time.Time } // NewEnforcer erzeugt die Anwendung einer Aufbewahrungsregel. func NewEnforcer(targetRepository *repository.LocalRepository, baseLogger *slog.Logger) (*Enforcer, error) { if targetRepository == nil { return nil, errors.New("die aufbewahrung braucht ein repository") } return &Enforcer{ targetRepository: targetRepository, logger: logging.WithComponent(baseLogger, "retention"), timeSource: time.Now, }, nil } // Preview berechnet den Plan, ohne etwas zu loeschen. // // Der Weg ueber die Vorschau ist der Regelfall. Eine Aufbewahrungsregel loescht // Daten dauerhaft; wer sie das erste Mal anwendet, soll vorher sehen, was // verschwindet — und zwar mit Begruendung je Backup. func (enforcer *Enforcer) Preview(previewContext context.Context, policy Policy) (*Plan, error) { candidates, collectError := enforcer.collectCandidates(previewContext) if collectError != nil { return nil, collectError } return Apply(policy, candidates, enforcer.timeSource()) } // ExecutionResult ist das Ergebnis einer angewandten Regel. type ExecutionResult struct { // Plan ist der zugrunde liegende Plan. Plan *Plan `json:"plan"` // DeletedBackupIDs sind die tatsaechlich geloeschten Backups. DeletedBackupIDs []string `json:"deleted_backup_ids"` // FailedBackupIDs sind die Backups, deren Loeschung scheiterte. FailedBackupIDs []string `json:"failed_backup_ids,omitempty"` // Failures erklaeren die gescheiterten Loeschungen. Failures []string `json:"failures,omitempty"` // ChunksRemoved ist die Zahl bereinigter Datenbloecke. ChunksRemoved int64 `json:"chunks_removed"` // BytesFreed ist der freigegebene Speicher. // // Er stammt aus der Bereinigung, nicht aus der Summe der Backupgroessen: // Wegen der Deduplizierung gibt ein geloeschtes Backup nur den Speicher // frei, den kein anderes mehr braucht. Die Backupgroessen zu addieren // ergaebe eine Zahl, die nie eintritt. BytesFreed int64 `json:"bytes_freed"` } // IsPartialFailure meldet einen Lauf mit uebergangenen Backups. func (result *ExecutionResult) IsPartialFailure() bool { return len(result.FailedBackupIDs) > 0 } // Summary fasst das Ergebnis in einem Satz zusammen. func (result *ExecutionResult) Summary() string { if result.IsPartialFailure() { return fmt.Sprintf("TEILWEISE FEHLGESCHLAGEN: %d von %d Backups geloescht, %d Fehler. "+ "%d Bloecke bereinigt, %d Byte frei.", len(result.DeletedBackupIDs), len(result.DeletedBackupIDs)+len(result.FailedBackupIDs), len(result.FailedBackupIDs), result.ChunksRemoved, result.BytesFreed) } summary := fmt.Sprintf("%d Backups geloescht, %d Bloecke bereinigt, %d Byte frei.", len(result.DeletedBackupIDs), result.ChunksRemoved, result.BytesFreed) // Geloescht und trotzdem nichts frei? Das sieht nach einem Fehler aus und ist // keiner: Wegen der Deduplizierung gibt ein Backup nur den Speicher frei, den // kein anderes mehr braucht. Bei wachsenden Daten verweist das juengste // Backup auf saemtliche Bloecke der aelteren. Ohne diesen Satz erzeugt jede // solche Ausgabe eine Rueckfrage. if len(result.DeletedBackupIDs) > 0 && result.BytesFreed == 0 { summary += " Es wurde kein Speicher frei: Die Bloecke der geloeschten Backups werden von " + "verbliebenen weiterhin gebraucht (Deduplizierung)." } return summary } // Execute wendet die Regel an und loescht die faelligen Backups. // // Der Plan wird **neu berechnet**, nicht uebergeben. Ein von aussen gereichter // Plan koennte Stunden alt sein; in der Zwischenzeit kann ein Legal Hold gesetzt // worden sein. Die Entscheidung, Daten zu vernichten, faellt hier und jetzt. func (enforcer *Enforcer) Execute(executeContext context.Context, policy Policy) (*ExecutionResult, error) { executionPlan, planError := enforcer.Preview(executeContext, policy) if planError != nil { return nil, planError } result := &ExecutionResult{ Plan: executionPlan, DeletedBackupIDs: make([]string, 0, executionPlan.DeletableCount), } if executionPlan.DeletableCount == 0 { enforcer.logger.Info("die aufbewahrungsregel loescht nichts", slog.String("regel", policy.Name), slog.Int("geschuetzt", executionPlan.ProtectedCount)) return result, nil } enforcer.logger.Warn("die aufbewahrungsregel loescht backups", slog.String("regel", policy.Name), slog.Int("anzahl", executionPlan.DeletableCount), slog.Int("bleiben", executionPlan.KeptCount)) for _, backupIdentifier := range executionPlan.DeletableBackupIDs() { if contextError := executeContext.Err(); contextError != nil { return result, contextError } // Das Repository prueft den Schutz **erneut**. Diese Doppelung ist kein // Versehen: Der Plan beruht auf einer Momentaufnahme, das Repository auf // dem Zustand der Datei. Bei Widerspruch gewinnt der Schutz. if deleteError := enforcer.targetRepository.DeleteBackup(executeContext, backupIdentifier); deleteError != nil { enforcer.logger.Error("ein backup liess sich nicht loeschen", slog.String("backup_id", backupIdentifier), slog.String("grund", deleteError.Error())) result.FailedBackupIDs = append(result.FailedBackupIDs, backupIdentifier) result.Failures = append(result.Failures, fmt.Sprintf("%s: %s", backupIdentifier, deleteError.Error())) continue } result.DeletedBackupIDs = append(result.DeletedBackupIDs, backupIdentifier) } // Die Bereinigung laeuft **nach** allen Loeschungen, nicht dazwischen: Ein // Block, den das eine Backup nicht mehr braucht, kann zum naechsten // gehoeren. Wer nach jedem Backup bereinigt, liest die Manifeste // unnoetig oft — und im schlimmsten Fall auf halbem Stand. removedChunks, freedBytes, pruneError := enforcer.targetRepository.PruneOrphanedChunks(executeContext, false) if pruneError != nil { // Die Bereinigung scheitert lieber, als auf unvollstaendiger Grundlage // zu loeschen. Die Backups sind bereits weg; das ist kein Grund, den // Lauf als gescheitert zu melden — der Speicher wird eben spaeter frei. enforcer.logger.Error("die bereinigung nach der aufbewahrung schlug fehl", slog.String("grund", pruneError.Error())) return result, nil } result.ChunksRemoved = removedChunks result.BytesFreed = freedBytes enforcer.logger.Info("die aufbewahrungsregel wurde angewandt", slog.String("regel", policy.Name), slog.Int("geloescht", len(result.DeletedBackupIDs)), slog.Int64("bloecke_bereinigt", removedChunks), slog.Int64("bytes_frei", freedBytes)) return result, nil } // collectCandidates liest die Backups des Repositorys samt Schutzlage. func (enforcer *Enforcer) collectCandidates(collectContext context.Context) ([]BackupCandidate, error) { catalogEntries, listError := enforcer.targetRepository.ListBackups(collectContext) if listError != nil { return nil, fmt.Errorf("die backups konnten nicht ermittelt werden: %w", listError) } // Welche Backups darf die Regel nicht loeschen, weil ein juengeres sie // braucht? // // Nur solche, deren **Kind nicht eigenstaendig** ist. Bei Syncova traegt jedes // Manifest die Blockverweise aller Objekte, auch die einer Zusatzsicherung; // ein Elternbackup zu loeschen beschaedigt das Kind nicht. // // Diese Unterscheidung entscheidet, ob die Aufbewahrung ueberhaupt etwas // tut: Ohne sie waere in einer fortlaufenden Kette **jedes** Backup ausser // dem juengsten ein Elternteil — und die Regel raeumte nie auf, waehrend der // Betreiber glaubt, eine Aufbewahrung zu haben. parentIdentifiers := make(map[string]struct{}, len(catalogEntries)) for _, catalogEntry := range catalogEntries { if catalogEntry.ParentBackupID == "" { continue } if catalogEntry.SelfContainedRestore { continue } parentIdentifiers[catalogEntry.ParentBackupID] = struct{}{} } candidates := make([]BackupCandidate, 0, len(catalogEntries)) for _, catalogEntry := range catalogEntries { protectionStatus, statusError := enforcer.targetRepository.ProtectionStatusOf( collectContext, catalogEntry.BackupID) if statusError != nil { // Ein Backup, dessen Schutzlage sich nicht feststellen laesst, gilt // als geschuetzt. Der umgekehrte Standard machte aus einer // beschaedigten Datei einen Datenverlust. enforcer.logger.Warn("die schutzlage eines backups liess sich nicht lesen; es bleibt erhalten", slog.String("backup_id", catalogEntry.BackupID), slog.String("grund", statusError.Error())) candidates = append(candidates, BackupCandidate{ BackupID: catalogEntry.BackupID, CompletedAt: catalogEntry.CompletedAt, SizeBytes: catalogEntry.StoredBytes, LegalHold: true, }) continue } _, isParent := parentIdentifiers[catalogEntry.BackupID] candidates = append(candidates, BackupCandidate{ BackupID: catalogEntry.BackupID, CompletedAt: catalogEntry.CompletedAt, SizeBytes: catalogEntry.StoredBytes, ImmutableUntil: protectionStatus.ImmutableUntil, LegalHold: protectionStatus.LegalHold, IsIncrementalParent: isParent, }) } return candidates, nil }