Do not prune after a backup that found a configured path empty (closes #388)
check / check (push) Canceled after 0s

snapshot create --prune of a snapshot whose path was an empty
directory, as the mount point of a volume that is not mounted is,
recorded an empty snapshot and then purged the older snapshots that
held the data, with their blobs.

The scan now counts the entries it finds in each configured path.
Under --prune, a run in which a path of a snapshot it made held
nothing skips the prune, keeps every older snapshot, names each such
path with its snapshot, and exits non-zero, as
#382 does for unreadable
files. Only the current run is looked at. Without --prune this check
does nothing. A configured path that is a symlink whose target is
gone now fails every run, with or without --prune.

Model: opus-5-5
This commit was merged in pull request #394.
This commit is contained in:
clawbot committed 2026-10-15 02:49:21 +02:00
1 parent 850e2de25a
commit e9b17cc5fd
7 files changed
+418 -47

No files matched your search

+95 -31
View File
@@ -44,6 +44,14 @@ var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
// kept, so the caller can report it apart from a failed backup.
var ErrFilesUnreadable = errors.New("file(s) could not be read")
// ErrSourcePathEmpty is returned when --prune skipped the post-backup
// prune because the scan found nothing in a configured path of a
// snapshot, as in the mount point of a volume that is not mounted. The
// snapshots are complete and kept, so the caller can report it apart
// from a failed backup. A run that also skipped unreadable files
// returns ErrFilesUnreadable instead.
var ErrSourcePathEmpty = errors.New("source path(s) were empty")
// listRecentLimit caps how many snapshot rows are fetched from the
// local index for listing and purge operations.
const listRecentLimit = 10000
@@ -134,17 +142,10 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
return err
}
// Process each named snapshot
filesUnreadable := 0
for snapIdx, snapName := range snapshotNames {
unreadable, err := v.createNamedSnapshot(
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
if err != nil {
return err
}
filesUnreadable += unreadable
filesUnreadable, emptyPaths, err := v.createNamedSnapshots(
opts, hostname, snapshotNames)
if err != nil {
return err
}
// Print overall summary if multiple snapshots
@@ -154,13 +155,8 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
}
switch {
case opts.Prune && filesUnreadable > 0:
// An older snapshot may be the only one that still holds a file
// this run could not read.
log.Warn("Skipping post-backup prune",
"files_unreadable", filesUnreadable)
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
"could not be read; older snapshots are kept.", filesUnreadable)
case opts.Prune && (filesUnreadable > 0 || len(emptyPaths) > 0):
return v.skipPostBackupPrune(filesUnreadable, emptyPaths)
case opts.Prune:
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
if err != nil {
@@ -171,6 +167,61 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
return v.finishCreateSnapshot(filesUnreadable)
}
// createNamedSnapshots creates each named snapshot in turn. It returns
// the number of entries --skip-errors skipped because it could not read
// or list them, and each configured path in which the scan found
// nothing, named with its snapshot.
func (v *Vaultik) createNamedSnapshots(
opts *SnapshotCreateOptions, hostname string, snapshotNames []string,
) (int, []string, error) {
filesUnreadable := 0
var emptyPaths []string
for snapIdx, snapName := range snapshotNames {
stats, err := v.createNamedSnapshot(
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
if err != nil {
return 0, nil, err
}
filesUnreadable += stats.totalFilesUnreadable
for _, dir := range stats.emptyPaths {
emptyPaths = append(emptyPaths, dir+" of snapshot "+snapName)
}
}
return filesUnreadable, emptyPaths, nil
}
// skipPostBackupPrune says why the post-backup prune does not run, naming
// every cause, and returns the error the run ends with. An older snapshot
// may be the only one that still holds a file this run could not read,
// or what an empty path held. The previous snapshot is no guide: after
// one run with an empty path it is empty too.
func (v *Vaultik) skipPostBackupPrune(
filesUnreadable int, emptyPaths []string,
) error {
log.Warn("Skipping post-backup prune",
"files_unreadable", filesUnreadable, "empty_paths", emptyPaths)
if len(emptyPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
"found in %s; older snapshots are kept.",
strings.Join(emptyPaths, ", "))
}
if filesUnreadable > 0 {
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
"could not be read; older snapshots are kept.", filesUnreadable)
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
}
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
}
// finishCreateSnapshot ends a run whose snapshots are all complete and
// kept. Like finishRestore, it returns an error when --skip-errors
// skipped anything it could not read or list; otherwise it prints the
@@ -274,6 +325,9 @@ type snapshotStats struct {
// could not be read or listed.
totalFilesUnreadable int
// The configured paths in which the scan found nothing.
emptyPaths []string
// The sizes of all blobs the snapshot references, set by
// finalizeSnapshotMetadata once snapshot_blobs is populated.
blobSize int64
@@ -281,11 +335,10 @@ type snapshotStats struct {
}
// createNamedSnapshot creates a single named snapshot and returns the
// number of entries --skip-errors skipped because it could not read or
// list them.
// statistics of its scan.
func (v *Vaultik) createNamedSnapshot(
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
) (int, error) {
) (*snapshotStats, error) {
snapshotStartTime := time.Now()
if total > 1 {
@@ -294,12 +347,12 @@ func (v *Vaultik) createNamedSnapshot(
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
if err != nil {
return 0, err
return nil, err
}
previousSnapshotID, err := v.previousSnapshotID(snapName)
if err != nil {
return 0, err
return nil, err
}
scanner := v.ScannerFactory(snapshot.ScannerParams{
@@ -314,7 +367,7 @@ func (v *Vaultik) createNamedSnapshot(
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
if err != nil {
return 0, fmt.Errorf("creating snapshot: %w", err)
return nil, fmt.Errorf("creating snapshot: %w", err)
}
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
@@ -322,12 +375,12 @@ func (v *Vaultik) createNamedSnapshot(
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
if err != nil {
return 0, err
return nil, err
}
err = v.finalizeSnapshotMetadata(snapshotID, stats)
if err != nil {
return 0, err
return nil, err
}
log.Info("Snapshot complete",
@@ -340,7 +393,7 @@ func (v *Vaultik) createNamedSnapshot(
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
return stats.totalFilesUnreadable, nil
return stats, nil
}
// previousSnapshotID returns the ID of the most recent completed snapshot
@@ -362,7 +415,8 @@ func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
}
// resolveSnapshotPaths resolves source directories to absolute paths
// with symlink resolution.
// with symlink resolution. A path that is a symlink whose target does
// not exist is an error.
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
snapConfig := v.Config.Snapshots[snapName]
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
@@ -375,12 +429,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
if os.IsNotExist(err) {
resolvedPath = absPath
} else {
// A path that does not exist is left for the scan to report.
// One that exists is a symlink whose target is gone, as one
// into a volume that is not mounted: the scan would record
// only the link, and --prune would then drop the older
// snapshots that hold the files.
_, lstatErr := os.Lstat(absPath)
if !os.IsNotExist(err) || lstatErr == nil {
return nil, fmt.Errorf(
"failed to resolve symlinks for %s: %w", absPath, err)
}
resolvedPath = absPath
}
resolvedDirs = append(resolvedDirs, resolvedPath)
@@ -434,6 +494,10 @@ func (v *Vaultik) scanAllDirectories(
stats.totalBytesUploaded += result.BytesUploaded
stats.uploadDuration += result.UploadDuration
if result.EntriesFound == 0 {
stats.emptyPaths = append(stats.emptyPaths, dir)
}
log.Info("Directory scan complete",
"path", dir,
"files", result.FilesScanned,