Do not prune when a backup misses an earlier mount point (closes #395)
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:
2026-10-15 04:55:32 +00:00
committed by sneak
parent ff50a137dd
commit 9ffb6df721
17 changed files with 940 additions and 46 deletions
+135 -10
View File
@@ -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