Do not prune when a backup misses an earlier mount point (closes #395)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
snapshot create --prune purged the older snapshots holding a volume's files when the volume, mounted below a configured path, was not mounted: its empty mount point read as deleted files. The scan now records in the local index each directory at or below a configured path that holds an entry and is on a different device from its parent directory, looked up once the walk has listed it, as listing mounts an automounted volume. Under --prune, when an earlier snapshot of that name recorded such a directory and this run does not, the run skips the prune, keeps every older snapshot and exits non-zero, naming the directory. For a volume retired on purpose, snapshot purge removes the older snapshots. Model: opus-5-5
This commit is contained in:
+135
-10
@@ -73,6 +73,9 @@ type Scanner struct {
|
||||
skipErrors bool
|
||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||
ui *ui.Writer
|
||||
// deviceNumber returns the number of the device that holds a file;
|
||||
// never nil (defaults to deviceNumberFromStat).
|
||||
deviceNumber func(info os.FileInfo) (uint64, bool)
|
||||
|
||||
// In-memory cache of known chunk hashes for fast existence checks
|
||||
knownChunks map[string]struct{}
|
||||
@@ -136,6 +139,10 @@ type ScannerConfig struct {
|
||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none.
|
||||
PreviousSnapshotID string
|
||||
// DeviceNumber returns the number of the device that holds a file,
|
||||
// and false when there is none to read. nil reads it from the file's
|
||||
// stat; tests replace it, since the in-memory test filesystem has none.
|
||||
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||
}
|
||||
|
||||
// ScanResult contains the results of a scan operation. Files and bytes
|
||||
@@ -214,6 +221,11 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
uiw = ui.NewWithColor(io.Discard, false)
|
||||
}
|
||||
|
||||
deviceNumber := cfg.DeviceNumber
|
||||
if deviceNumber == nil {
|
||||
deviceNumber = deviceNumberFromStat
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
fs: cfg.FS,
|
||||
chunker: chunker.NewChunker(cfg.ChunkSize),
|
||||
@@ -229,6 +241,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
skipErrors: cfg.SkipErrors,
|
||||
previousSnapshotID: cfg.PreviousSnapshotID,
|
||||
ui: uiw,
|
||||
deviceNumber: deviceNumber,
|
||||
pendingChunkHashes: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
@@ -297,7 +310,7 @@ func (s *Scanner) Scan(
|
||||
}
|
||||
}
|
||||
|
||||
err = s.recordDirectories(ctx, scanResult.Directories)
|
||||
err = s.recordDirectories(ctx, scanResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -875,6 +888,10 @@ type ScanPhaseResult struct {
|
||||
FilesToProcess []*FileToProcess
|
||||
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
||||
Directories []*FileToProcess // Every directory found, to record
|
||||
// MountPoints are the directories found on a different device from
|
||||
// their parent directory, the scanned path included, that the walk
|
||||
// found an entry in.
|
||||
MountPoints []string
|
||||
}
|
||||
|
||||
// scanPhase performs the initial directory scan to identify files to process
|
||||
@@ -894,7 +911,7 @@ func (s *Scanner) scanPhase(
|
||||
// estimate leaves them out.
|
||||
estimatedTotal := countNonDirectories(knownFiles)
|
||||
|
||||
collector := &scanCollector{}
|
||||
collector := &scanCollector{mountPoints: make(map[string]bool)}
|
||||
|
||||
// Set up periodic status output
|
||||
startTime := time.Now()
|
||||
@@ -937,6 +954,8 @@ func (s *Scanner) scanPhase(
|
||||
// The scanned directory itself is not an entry found in it.
|
||||
if filePath != path || !info.IsDir() {
|
||||
result.EntriesFound++
|
||||
|
||||
collector.foundEntryIn(filepath.Dir(filePath))
|
||||
}
|
||||
|
||||
// Handle symlinks and directories
|
||||
@@ -973,6 +992,7 @@ func (s *Scanner) scanPhase(
|
||||
FilesToProcess: collector.filesToProcess,
|
||||
UnchangedFileIDs: collector.unchangedFileIDs,
|
||||
Directories: collector.directories,
|
||||
MountPoints: collector.mountPointsWithEntries(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -992,8 +1012,10 @@ func countNonDirectories(files map[string]*database.File) int64 {
|
||||
// recordSpecialEntry records symlinks and directories (which have no
|
||||
// data to chunk) and reports whether it handled the entry. A symlink is
|
||||
// counted and checked for changes as a regular file is; a directory is
|
||||
// set aside to be recorded and is not counted. For a symlink whose
|
||||
// target cannot be read it returns handleWalkError's result.
|
||||
// set aside to be recorded, also as a mount point when it is on a
|
||||
// different device from its parent directory, and is not counted. For a
|
||||
// symlink whose target cannot be read it returns handleWalkError's
|
||||
// result.
|
||||
func (s *Scanner) recordSpecialEntry(
|
||||
filePath string, info os.FileInfo,
|
||||
existingFiles map[string]struct{},
|
||||
@@ -1022,6 +1044,10 @@ func (s *Scanner) recordSpecialEntry(
|
||||
existingFiles[filePath] = struct{}{}
|
||||
collector.addDirectory(filePath, info, file)
|
||||
|
||||
if s.onOtherDeviceThanParent(filePath, info) {
|
||||
collector.addMountPoint(filePath)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -1099,6 +1125,46 @@ type scanCollector struct {
|
||||
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
||||
directories []*FileToProcess
|
||||
filesScanned int64
|
||||
// mountPoints maps each directory found on a different device from
|
||||
// its parent directory to whether the walk found an entry in it.
|
||||
mountPoints map[string]bool
|
||||
}
|
||||
|
||||
// addMountPoint sets aside a directory found on a different device from
|
||||
// its parent directory, to be recorded if the walk finds an entry in it.
|
||||
func (c *scanCollector) addMountPoint(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.mountPoints[path] = false
|
||||
}
|
||||
|
||||
// foundEntryIn notes that the walk found an entry in directory dir.
|
||||
func (c *scanCollector) foundEntryIn(dir string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if _, ok := c.mountPoints[dir]; ok {
|
||||
c.mountPoints[dir] = true
|
||||
}
|
||||
}
|
||||
|
||||
// mountPointsWithEntries returns the mount points the walk found an
|
||||
// entry in. An empty one, as an automount point whose volume could not
|
||||
// be mounted, does not show a volume mounted there.
|
||||
func (c *scanCollector) mountPointsWithEntries() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
var mountPoints []string
|
||||
|
||||
for dir, foundEntry := range c.mountPoints {
|
||||
if foundEntry {
|
||||
mountPoints = append(mountPoints, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return mountPoints
|
||||
}
|
||||
|
||||
// addDirectory sets a directory aside to be recorded. It is not counted
|
||||
@@ -1290,20 +1356,79 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
||||
}
|
||||
}
|
||||
|
||||
// recordDirectories records every directory the walk found. A directory
|
||||
// has no data to back up, so it is not among the files counted and
|
||||
// backed up.
|
||||
// recordDirectories records every directory the walk found, and its
|
||||
// mount points (see recordMountPoints). A directory has no data to back
|
||||
// up, so it is not among the files counted and backed up.
|
||||
func (s *Scanner) recordDirectories(
|
||||
ctx context.Context, directories []*FileToProcess,
|
||||
ctx context.Context, scanned *ScanPhaseResult,
|
||||
) error {
|
||||
for _, dir := range directories {
|
||||
for _, dir := range scanned.Directories {
|
||||
err := s.recordNonRegularFile(ctx, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.recordMountPoints(ctx, scanned.MountPoints)
|
||||
}
|
||||
|
||||
// recordMountPoints records with the snapshot the mount points the walk
|
||||
// found (see ScanPhaseResult.MountPoints).
|
||||
func (s *Scanner) recordMountPoints(
|
||||
ctx context.Context, mountPoints []string,
|
||||
) error {
|
||||
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||
for _, dir := range mountPoints {
|
||||
log.Info("Found a mount point", "path", dir)
|
||||
|
||||
err := s.repos.Snapshots.AddMountPoint(txCtx, tx, s.snapshotID, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording mount point %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// onOtherDeviceThanParent reports whether the directory at path, which
|
||||
// info describes, is on a different device from its parent directory,
|
||||
// looked up now. The walk calls it on reaching the directory, by when it
|
||||
// has listed the parent (the scanned path aside). Listing a directory
|
||||
// mounts a volume mounted there on first access (a systemd automount),
|
||||
// so the walk's own entry for the parent, looked up before the listing,
|
||||
// can show the automounter's device instead. A directory whose device
|
||||
// number, or whose parent's, cannot be read counts as on its parent's
|
||||
// device: a mount point missed can only stop a prune, never let one run.
|
||||
func (s *Scanner) onOtherDeviceThanParent(path string, info os.FileInfo) bool {
|
||||
dirDevice, ok := s.deviceNumber(info)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
parent, err := s.fs.Stat(filepath.Dir(path))
|
||||
if err != nil {
|
||||
log.Warn("Could not look up the parent of a directory "+
|
||||
"to check for a mount point", "path", path, "error", err)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
parentDevice, ok := s.deviceNumber(parent)
|
||||
|
||||
return ok && dirDevice != parentDevice
|
||||
}
|
||||
|
||||
// deviceNumberFromStat returns the number of the device that holds the
|
||||
// file info describes, and false when info carries no stat, as from the
|
||||
// in-memory test filesystem.
|
||||
func deviceNumberFromStat(info os.FileInfo) (uint64, bool) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return uint64(stat.Dev), true //nolint:unconvert // Dev is an int32 on darwin
|
||||
}
|
||||
|
||||
// recordNonRegularFile writes a symlink or directory entry to the database
|
||||
|
||||
Reference in New Issue
Block a user