package agent import ( "context" "errors" "fmt" "time" "github.com/syncova/syncova/packages/repository" ) // ChangeKind benennt das Ergebnis der Änderungserkennung für ein Objekt. type ChangeKind string const ( // ChangeKindAdded ist ein Objekt, das im Elternbackup nicht vorkam. ChangeKindAdded ChangeKind = "added" // ChangeKindModified ist ein verändertes Objekt. ChangeKindModified ChangeKind = "modified" // ChangeKindUnchanged ist ein unverändertes Objekt. // // Sein Inhalt wird nicht erneut gelesen; die Blockverweise des // Elternbackups werden übernommen. ChangeKindUnchanged ChangeKind = "unchanged" // ChangeKindMetadataOnly ist ein Objekt mit unverändertem Inhalt, aber // geänderten Rechten. // // Es zählt zu den übernommenen Objekten — gelesen werden muss es nicht —, // wird aber gesondert ausgewiesen, damit eine Rechteänderung nicht // unbemerkt bleibt. ChangeKindMetadataOnly ChangeKind = "metadata_only" ) // ChangeDecision ist das Urteil über ein einzelnes erfasstes Objekt. type ChangeDecision struct { // Entry ist das erfasste Objekt. Entry DiscoveredEntry // Kind ist das Ergebnis des Vergleichs. Kind ChangeKind // Reason erklärt ein „geändert" verständlich. // // Ohne diese Begründung liesse sich später nicht mehr nachvollziehen, warum // ein vermeintlich unverändertes Backup doch alles neu gelesen hat. Reason string // ParentEntry ist der zugehörige Eintrag des Elternbackups. // // Er ist nur bei übernommenen Objekten gesetzt und liefert die // Blockverweise samt Inhaltsprüfsumme. ParentEntry *repository.ManifestEntry } // ChangeSet ist das Ergebnis der Änderungserkennung für einen ganzen Lauf. type ChangeSet struct { // Decisions sind die Urteile in der Reihenfolge der Erfassung. Decisions []ChangeDecision // DeletedPaths sind Pfade, die im Elternbackup lagen und in der Quelle // fehlen. // // Sie erscheinen nicht im neuen Manifest. Sichtbar gemacht werden sie // trotzdem: eine unbemerkt verschwundene Datei ist genau der Fall, den ein // Backup aufdecken soll. DeletedPaths []string // ParentBackupID ist die Kennung des verglichenen Elternbackups. ParentBackupID string // ParentStartedAt ist der Beginn des Elternbackups in UTC. ParentStartedAt time.Time } // CountOf zählt die Objekte einer Art. func (changeSet *ChangeSet) CountOf(changeKind ChangeKind) int { var matchCount int for _, changeDecision := range changeSet.Decisions { if changeDecision.Kind == changeKind { matchCount++ } } return matchCount } // ReusedFileCount zählt die Dateien, deren Inhalt nicht erneut gelesen wird. // // Gezählt werden ausschließlich Dateien. Verzeichnisse und Verweise tragen // keine Daten; sie unter „übernommen" mitzuzählen liesse die Ersparnis größer // erscheinen, als sie ist. func (changeSet *ChangeSet) ReusedFileCount() int { var reusedCount int for _, changeDecision := range changeSet.Decisions { if changeDecision.Entry.EntryType != EntryTypeFile { continue } if changeDecision.ParentEntry != nil { reusedCount++ } } return reusedCount } // ChangedFileCount zählt die Dateien, die gelesen werden müssen. func (changeSet *ChangeSet) ChangedFileCount() int { var changedCount int for _, changeDecision := range changeSet.Decisions { if changeDecision.Entry.EntryType != EntryTypeFile { continue } if changeDecision.ParentEntry == nil { changedCount++ } } return changedCount } // BytesToRead ist die Datenmenge, die tatsächlich gelesen werden muss. func (changeSet *ChangeSet) BytesToRead() int64 { var pendingBytes int64 for _, changeDecision := range changeSet.Decisions { if changeDecision.Entry.EntryType == EntryTypeFile && changeDecision.ParentEntry == nil { pendingBytes += changeDecision.Entry.SizeBytes } } return pendingBytes } // ErrNoParentBackup meldet eine Zusatzsicherung ohne verwendbares Elternbackup. var ErrNoParentBackup = errors.New("für diese quelle gibt es kein vollständiges elternbackup") // FindParentBackup sucht das jüngste abgeschlossene Backup derselben Quelle. // // Maßgeblich ist die Quellkennung, nicht die Kette: wer denselben Pfad erneut // sichert, meint dieselben Daten. Ein Backup einer anderen Quelle als // Elternbackup heranzuziehen wäre der sicherste Weg, unveränderte Objekte // falsch zuzuordnen. func FindParentBackup(searchContext context.Context, sourceRepository *repository.LocalRepository, sourceIdentifier string) (*repository.Manifest, error) { catalogEntries, listError := sourceRepository.ListBackups(searchContext) if listError != nil { return nil, listError } var newestEntry *repository.CatalogEntry for entryIndex := range catalogEntries { catalogEntry := &catalogEntries[entryIndex] if catalogEntry.SourceID != sourceIdentifier { continue } if newestEntry == nil || catalogEntry.CompletedAt.After(newestEntry.CompletedAt) { newestEntry = catalogEntry } } if newestEntry == nil { return nil, fmt.Errorf("%w: %s", ErrNoParentBackup, sourceIdentifier) } // Gelesen wird das Manifest, nicht der Katalogeintrag: der Katalog ist nur // ein Beschleuniger und könnte veraltet sein. Das Manifest ist die Quelle // der Wahrheit — und nur es enthält die Blockverweise. parentManifest, readError := sourceRepository.ReadManifest(searchContext, newestEntry.BackupID) if readError != nil { return nil, fmt.Errorf("das manifest des elternbackups %s konnte nicht gelesen werden: %w", newestEntry.BackupID, readError) } if !parentManifest.Complete { return nil, fmt.Errorf("%w: das jüngste backup %s ist unvollständig", ErrNoParentBackup, newestEntry.BackupID) } return parentManifest, nil } // DetectChanges vergleicht die erfassten Objekte gegen ein Elternmanifest. // // Das Verfahren beruht auf Größe und Änderungszeitpunkt. Das ist eine bewusste // Abwägung: Der Inhalt jeder Datei erneut zu lesen, nur um festzustellen, dass // er gleich geblieben ist, hebt den gesamten Zweck einer Zusatzsicherung auf. // Die Grenze des Verfahrens wird nicht verschwiegen — wer eine Datei verändert // und ihren Zeitstempel anschließend zurücksetzt, täuscht es. Für diesen Fall // gibt es die vollständige Sicherung. // // Eine Falle behandelt die Funktion ausdrücklich: Wird eine Datei in derselben // Sekunde geschrieben, in der das Elternbackup sie gelesen hat, kann ihr // Zeitstempel unverändert aussehen, obwohl der Inhalt ein anderer ist. Manche // Dateisysteme lösen Zeitstempel nur sekundengenau auf. Deshalb gilt jedes // Objekt als geändert, dessen Zeitstempel nicht *vor* dem Beginn des // Elternbackups liegt. func DetectChanges(discoveredEntries []DiscoveredEntry, parentManifest *repository.Manifest) *ChangeSet { changeSet := &ChangeSet{ Decisions: make([]ChangeDecision, 0, len(discoveredEntries)), DeletedPaths: make([]string, 0), } if parentManifest == nil { // Ohne Elternbackup ist alles neu. for _, discoveredEntry := range discoveredEntries { changeSet.Decisions = append(changeSet.Decisions, ChangeDecision{ Entry: discoveredEntry, Kind: ChangeKindAdded, Reason: "Es gibt kein Elternbackup zum Vergleichen.", }) } return changeSet } changeSet.ParentBackupID = parentManifest.BackupID changeSet.ParentStartedAt = parentManifest.StartedAt // Der Index macht den Vergleich linear statt quadratisch. Bei einer Million // Dateien wäre die verschachtelte Suche sonst nicht mehr vertretbar. parentEntriesByPath := make(map[string]*repository.ManifestEntry, len(parentManifest.Entries)) for entryIndex := range parentManifest.Entries { parentEntry := &parentManifest.Entries[entryIndex] parentEntriesByPath[parentEntry.Path] = parentEntry } seenPaths := make(map[string]struct{}, len(discoveredEntries)) for _, discoveredEntry := range discoveredEntries { seenPaths[discoveredEntry.RelativePath] = struct{}{} parentEntry, existedBefore := parentEntriesByPath[discoveredEntry.RelativePath] if !existedBefore { changeSet.Decisions = append(changeSet.Decisions, ChangeDecision{ Entry: discoveredEntry, Kind: ChangeKindAdded, Reason: "Das Objekt kam im Elternbackup nicht vor.", }) continue } changeSet.Decisions = append(changeSet.Decisions, compareAgainstParent(discoveredEntry, parentEntry, parentManifest.StartedAt)) } for _, parentEntry := range parentManifest.Entries { if _, stillPresent := seenPaths[parentEntry.Path]; !stillPresent { changeSet.DeletedPaths = append(changeSet.DeletedPaths, parentEntry.Path) } } return changeSet } // compareAgainstParent beurteilt ein einzelnes Objekt gegen seinen Vorgänger. func compareAgainstParent(discoveredEntry DiscoveredEntry, parentEntry *repository.ManifestEntry, parentStartedAt time.Time) ChangeDecision { changeDecision := ChangeDecision{Entry: discoveredEntry} // Ein Typwechsel — etwa Datei zu Verzeichnis — macht jeden weiteren // Vergleich sinnlos. if string(discoveredEntry.EntryType) != parentEntry.EntryType { changeDecision.Kind = ChangeKindModified changeDecision.Reason = fmt.Sprintf("Die Art des Objekts wechselte von %s zu %s.", parentEntry.EntryType, discoveredEntry.EntryType) return changeDecision } switch discoveredEntry.EntryType { case EntryTypeDirectory: // Ein Verzeichnis trägt keine Daten. Es wird immer neu vermerkt, das // kostet nichts; nur seine Rechte können sich ändern. changeDecision.Kind = ChangeKindUnchanged if discoveredEntry.Mode != parentEntry.Mode { changeDecision.Kind = ChangeKindMetadataOnly changeDecision.Reason = fmt.Sprintf("Die Rechte wechselten von %s zu %s.", parentEntry.Mode, discoveredEntry.Mode) } return changeDecision case EntryTypeSymlink: changeDecision.Kind = ChangeKindUnchanged if discoveredEntry.LinkTarget != parentEntry.LinkTarget { changeDecision.Kind = ChangeKindModified changeDecision.Reason = fmt.Sprintf("Das Verweisziel wechselte von %q zu %q.", parentEntry.LinkTarget, discoveredEntry.LinkTarget) } return changeDecision } if discoveredEntry.SizeBytes != parentEntry.SizeBytes { changeDecision.Kind = ChangeKindModified changeDecision.Reason = fmt.Sprintf("Die Größe wechselte von %d auf %d Byte.", parentEntry.SizeBytes, discoveredEntry.SizeBytes) return changeDecision } if !discoveredEntry.ModifiedAt.Equal(parentEntry.ModifiedAt) { changeDecision.Kind = ChangeKindModified changeDecision.Reason = fmt.Sprintf("Der Änderungszeitpunkt wechselte von %s auf %s.", parentEntry.ModifiedAt.Format(time.RFC3339), discoveredEntry.ModifiedAt.Format(time.RFC3339)) return changeDecision } // Die Zeitstempel-Falle: eine Datei, die während des Elternbackups oder // danach geschrieben wurde, kann bei grober Zeitauflösung gleich aussehen. // Im Zweifel wird gelesen — ein zu viel gelesenes Backup kostet Zeit, ein // zu wenig gelesenes verliert Daten. if !parentStartedAt.IsZero() && !discoveredEntry.ModifiedAt.Before(parentStartedAt) { changeDecision.Kind = ChangeKindModified changeDecision.Reason = "Der Änderungszeitpunkt liegt nicht vor dem Beginn des Elternbackups; der Inhalt könnte sich unbemerkt geändert haben." return changeDecision } // Ein Elterneintrag ohne Blockverweise trägt keinen übernehmbaren Inhalt. // Das darf nicht vorkommen, wäre aber ein stiller Datenverlust — deshalb // wird im Zweifel gelesen. if len(parentEntry.Chunks) == 0 && parentEntry.SizeBytes > 0 { changeDecision.Kind = ChangeKindModified changeDecision.Reason = "Das Elternbackup enthält für dieses Objekt keine Blockverweise." return changeDecision } changeDecision.ParentEntry = parentEntry changeDecision.Kind = ChangeKindUnchanged if discoveredEntry.Mode != parentEntry.Mode { changeDecision.Kind = ChangeKindMetadataOnly changeDecision.Reason = fmt.Sprintf("Der Inhalt ist unverändert, die Rechte wechselten von %s zu %s.", parentEntry.Mode, discoveredEntry.Mode) } return changeDecision }