1 Commits
Author SHA1 Message Date
sneak 6c44b8aaff Delete metadata directories a backup left without a manifest (closes #390)
check / check (push) Canceled after 0s
A backup that stopped between uploading db.zst.age and its manifest
left that directory under metadata/ on the destination for good:
prune deleted nothing there.

The blob step of prune, which snapshot create --prune also runs, now
deletes each directory under metadata/ that has no manifest.json.zst,
that no local snapshot record claims, and all of whose objects are
older than prune_grace_period; an object listed with no time keeps its
directory, and only names that are remote keys are deleted. The prompt
and the --force output name each such directory, the --json result
counts them (metadata_dirs_deleted, metadata_dirs_failed), and a failed
delete fails the run. Every description of the grace period now
covers these directories.

Model: opus-5-5
2026-10-14 22:27:32 +00:00
9 changed files with 146 additions and 75 deletions
+28 -22
View File
@@ -98,11 +98,12 @@ vaultik snapshot restore <snapshot-id> ~/vaultik-restore
On a destination shared between hosts or users, the prune in that cron line
relies on `prune_grace_period` (default `24h`) to leave the blobs of a backup
another one is still running, so it must be longer than the longest backup
run. It does not protect a blob that backup reuses from an earlier run that
stopped before its manifest: the prune can delete such a blob, and the new
snapshot then cannot be restored. To not depend on the grace period, give each
host its own prefix or directory. See [locking](#locking).
another one is still running, and its directory under `metadata/` that has no
manifest yet, so it must be longer than the longest backup run. It does not
protect a blob that backup reuses from an earlier run that stopped before its
manifest: the prune can delete such a blob, and the new snapshot then cannot
be restored. To not depend on the grace period, give each host its own prefix
or directory. See [locking](#locking).
## restoring on another machine
@@ -204,18 +205,19 @@ corrupt the local index.
The lock covers one user on one machine. It does not stop another user,
or another host, from writing to the same destination store at the same
time. A backup uploads its blobs before the manifest that lists them, so a
prune from elsewhere at that moment finds blobs no snapshot lists. It
deletes such a blob only once it is older than `prune_grace_period`
(default `24h`), which therefore must be longer than the longest backup run
of any host or user sharing the destination. The grace period does not
protect a blob a backup reuses from an earlier run that stopped before its
manifest: a backup skips the upload of a blob already on the destination, so
that blob keeps the time of its first upload, and a prune from elsewhere
during the backup can delete it; the new snapshot then cannot be restored.
`remote nuke` deletes everything regardless. To not depend on the grace
period, give each host or user its own prefix (or directory) on the
destination.
time. A backup uploads its blobs, and then its `db.zst.age`, before the
manifest that lists them, so a prune from elsewhere at that moment finds
blobs no snapshot lists and a directory under `metadata/` with no manifest. It
deletes such a blob only once it is older than `prune_grace_period` (default
`24h`), and such a directory only once every object in it is, so the grace
period must be longer than the longest backup run of any host or user sharing
the destination. The grace period does not protect a blob a backup reuses
from an earlier run that stopped before its manifest: a backup skips the
upload of a blob already on the destination, so that blob keeps the time of
its first upload, and a prune from elsewhere during the backup can delete it;
the new snapshot then cannot be restored. `remote nuke` deletes everything
regardless. To not depend on the grace period, give each host or user its own
prefix (or directory) on the destination.
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
`remote info` — do not take the lock and are never blocked, so they run
@@ -318,9 +320,12 @@ value where a list belongs or a word where a number belongs), is refused.
error that explains how to fix it
* `--cron`: Silent on total success; warnings and errors are still printed
(for crontab)
* `--prune`: After backup, drop older snapshots of each backed-up name and
* `--prune`: After backup, drop older snapshots of each backed-up name,
remove orphaned blobs older than `prune_grace_period` from remote storage,
as `prune` does. By default keeps only the latest
and delete each directory under `metadata/` that has no
`manifest.json.zst` once every object in it is older than
`prune_grace_period` and its snapshot is not in the local index, as `prune`
does. By default keeps only the latest
snapshot per name; use `--keep-newer-than` for a rolling window. When
`--skip-errors` skipped a file or directory it could not read or list, the
prune does not run and every older snapshot is kept, since one of them may
@@ -552,8 +557,9 @@ and can be deleted.
A destination shared between hosts or users, each with its own local index,
relies on `prune_grace_period` to keep one's prune from deleting the blobs of
another's backup still running, so it must be longer than the longest backup
run. It does not protect a blob that backup reuses from an earlier run that
another's backup still running, or its directory under `metadata/` that has
no manifest yet, so it must be longer than the longest backup run. It does not
protect a blob that backup reuses from an earlier run that
stopped before its manifest, which the prune can delete. To not depend on the
grace period, give each host its own prefix (or directory). See
[locking](#locking).
@@ -693,7 +699,7 @@ complete annotated example also lives in
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit. A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp` |
| `compression_level` | `3` | zstd compression level (1-19) |
| `prune_grace_period` | `24h` | How old a blob that no snapshot lists must be before `prune` deletes it. A backup uploads its blobs before the manifest that lists them, so on a destination shared between hosts or users this must be longer than the longest backup run (see [locking](#locking)). It does not protect a blob a backup reuses from an earlier run that stopped before its manifest: that blob keeps the time of its first upload, so a prune can delete it. A duration such as `36h` or `90m`; `0s` deletes such blobs at once |
| `prune_grace_period` | `24h` | How old a blob that no snapshot lists must be before `prune` deletes it, and how old every object in a directory under `metadata/` with no manifest must be before `prune` deletes that directory. A backup uploads its blobs and its `db.zst.age` before the manifest that lists them, so on a destination shared between hosts or users this must be longer than the longest backup run (see [locking](#locking)). It does not protect a blob a backup reuses from an earlier run that stopped before its manifest: that blob keeps the time of its first upload, so a prune can delete it. A duration such as `36h` or `90m`; `0s` deletes such blobs and directories at once |
| `hostname` | system hostname | Hostname used in snapshot IDs |
| `index_path` | platform data dir | Local SQLite index path |
+6 -4
View File
@@ -329,10 +329,12 @@ storage_url: "rclone://myremote/path/to/backups"
# Default: 3
compression_level: 5
# How old a blob that no snapshot lists must be before prune deletes it
# A backup uploads its blobs before the manifest that lists them, so on a
# destination shared between hosts or users this must be longer than the
# longest backup run
# How old a blob that no snapshot lists must be before prune deletes it, and
# how old every object in a directory under metadata/ with no manifest must
# be before prune deletes that directory
# A backup uploads its blobs and its db.zst.age before the manifest that
# lists them, so on a destination shared between hosts or users this must be
# longer than the longest backup run
# It does not protect a blob a backup reuses from an earlier run that stopped
# before its manifest: that blob keeps the time of its first upload, so a
# prune can delete it
+4
View File
@@ -230,6 +230,10 @@ The restore process doesn't use the local database. Instead:
- Query blobs not referenced by any remaining snapshot
- Delete from S3, once older than `prune_grace_period`, and from the local
database
2. **Delete Metadata Directories With No Manifest**
- Delete each directory under `metadata/` that has no `manifest.json.zst`
and no local snapshot record, once every object in it is older than
`prune_grace_period`
### 6. Incomplete Snapshot Cleanup
+2 -2
View File
@@ -163,10 +163,10 @@ These are known, deliberate properties of the format and the tooling, recorded s
## Pruning Safety
The prune operation is safe because:
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references. Under `metadata/` it deletes only a directory that has no `manifest.json.zst`, whose name is a remote key, and that no local snapshot record claims
2. Manifests are unencrypted and can be read without keys
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`). A backup uploads its blobs before the manifest that lists them, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`), and a directory under `metadata/` with no manifest only once every object in it is. A backup uploads its blobs and its `db.zst.age` before the manifest that lists them, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
## Restoration Requirements
+4 -2
View File
@@ -239,8 +239,10 @@ storage_url: ""
# Default: 3
# compression_level: 3
# How old a blob that no snapshot lists must be before prune deletes it.
# A backup uploads its blobs before the manifest that lists them, so on a
# How old a blob that no snapshot lists must be before prune deletes it,
# and how old every object in a directory under metadata/ with no manifest
# must be before prune deletes that directory. A backup uploads its blobs
# and its db.zst.age before the manifest that lists them, so on a
# destination shared between hosts or users this must be longer than the
# longest backup run. It does not protect a blob a backup reuses from an
# earlier run that stopped before its manifest: that blob keeps the time of
+6 -3
View File
@@ -131,9 +131,12 @@ on Linux), then /etc/vaultik/config.yml.`,
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
"Run in cron mode (silent unless warning or error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+
"orphaned blobs older than prune_grace_period (not after a "+
"backup that skipped unreadable files)")
"After backup, drop older snapshots of the same name, remove "+
"orphaned blobs older than prune_grace_period, and delete each "+
"directory under metadata/ with no manifest once all its "+
"objects are older than prune_grace_period and its snapshot is "+
"not in the local index (not after a backup that skipped "+
"unreadable files)")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
+6 -4
View File
@@ -192,10 +192,12 @@ type Config struct {
CompressionLevel int `yaml:"compression_level"`
// PruneGracePeriod is how old a blob no manifest lists must be before
// a prune deletes it. A backup lists its blobs in a manifest only
// once it has uploaded them all, so a younger one may belong to a
// backup still running, on this host or another sharing the
// destination store.
// a prune deletes it, and how old every object in a directory under
// metadata/ with no manifest must be before a prune deletes that
// directory. A backup writes its manifest only once it has uploaded
// all its blobs and its db.zst.age, so a younger blob or directory may
// belong to a backup still running, on this host or another sharing
// the destination store.
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
// StorageURL specifies the storage backend using a URL format.
+33 -25
View File
@@ -265,7 +265,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
"metadata_dirs_without_manifest", len(dirsWithoutManifest))
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
len(dirsWithoutManifest)) {
dirsWithoutManifest) {
return nil
}
@@ -296,43 +296,51 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return errors.Join(failures...)
}
// confirmPrune states what prune found to delete and, unless --force or
// --json is given, asks the operator to confirm. It reports whether to
// go ahead.
// confirmPrune states what prune found to delete, naming each directory
// under metadata/ by its remote key, and, unless --force or --json is
// given, asks the operator to confirm. It reports whether to go ahead.
func (v *Vaultik) confirmPrune(
opts *PruneOptions, blobCount int, blobsSize int64, dirCount int,
opts *PruneOptions, blobCount int, blobsSize int64, dirs []string,
) bool {
if opts.JSON {
return true
}
var found []string
if blobCount > 0 {
found = append(found, fmt.Sprintf(
"Found %d unreferenced blob(s) totaling %s.",
blobCount, ubytes(blobsSize)))
}
if dirCount > 0 {
found = append(found, fmt.Sprintf(
"Found %d metadata director(ies) left by backups that "+
"stopped before their manifest.", dirCount))
}
dirCount := len(dirs)
blobsFound := fmt.Sprintf("Found %d unreferenced blob(s) totaling %s.",
blobCount, ubytes(blobsSize))
dirsFound := fmt.Sprintf("Found %d metadata director(ies) left by "+
"backups that stopped before their manifest:", dirCount)
if opts.Force {
for _, line := range found {
v.UI.Infof("%s", line)
if blobCount > 0 {
v.UI.Infof("%s", blobsFound)
}
if dirCount > 0 {
v.UI.Infof("%s", dirsFound)
for _, remoteKey := range dirs {
v.UI.Detailf("metadata/%s/", remoteKey)
}
}
return true
}
// The counts, the prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
for _, line := range found {
v.stdoutf("%s\n", line)
// The counts, the names, the prompt and its answer bypass the UI:
// they are an interactive exchange the operator must see, so --quiet
// does not silence them.
if blobCount > 0 {
v.stdoutf("%s\n", blobsFound)
}
if dirCount > 0 {
v.stdoutf("%s\n", dirsFound)
for _, remoteKey := range dirs {
v.stdoutf(" metadata/%s/\n", remoteKey)
}
}
switch {
+57 -13
View File
@@ -102,29 +102,33 @@ func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
assert.Equal(t, 1, result.MetadataDirsDeleted)
}
// TestPruneKeepsMetadataDirClaimedOrOfUnknownAge checks that prune keeps
// a directory under metadata/ with no manifest when a local snapshot
// record claims it, or when the listing gives no time for an object in
// it. A third such directory is deleted and reported, although there are
// no unreferenced blobs to delete.
func TestPruneKeepsMetadataDirClaimedOrOfUnknownAge(t *testing.T) {
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
// prune --force keeps a directory under metadata/ with no manifest when a
// local snapshot record claims it, when the listing gives no time for an
// object in it, or when its name is not a remote key. A fourth such
// directory is named, deleted and reported, although there are no
// unreferenced blobs to delete.
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
env := newListEnv(t)
const claimedID = "testhost_home_2026-01-01T00:00:00Z"
const (
claimedID = "testhost_home_2026-01-01T00:00:00Z"
leftID = "testhost_home_2026-01-03T00:00:00Z"
)
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
"db.zst.age")
leftDB := metadataObjectKey("testhost_home_2026-01-03T00:00:00Z",
"db.zst.age")
leftDB := metadataObjectKey(leftID, "db.zst.age")
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
for _, key := range []string{claimedDB, leftDB} {
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
}
@@ -138,12 +142,52 @@ func TestPruneKeepsMetadataDirClaimedOrOfUnknownAge(t *testing.T) {
"a directory a local snapshot record claims must be kept")
assert.True(t, env.store.hasKey(unknownAgeDB),
"a directory holding an object of unknown age must be kept")
assert.True(t, env.store.hasKey(notRemoteKeyDB),
"a directory whose name is not a remote key must be kept")
assert.False(t, env.store.hasKey(leftDB),
"a directory with no manifest, unclaimed and old, must be deleted")
assert.Contains(t, env.stdout.String(),
output := env.stdout.String()
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
"backups that stopped before their manifest:")
assert.Contains(t, output,
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
assert.NotContains(t, output, "not-a-remote-key")
assert.Contains(t, output,
"Deleted 1 metadata director(ies) with no manifest.")
assert.NotContains(t, env.stdout.String(),
"No unreferenced blobs to remove.")
assert.NotContains(t, output, "No unreferenced blobs to remove.")
}
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
// names a directory under metadata/ with no manifest that prune is about
// to delete, and that answering no keeps it.
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
const leftID = "testhost_home_2026-01-01T00:00:00Z"
leftDB := metadataObjectKey(leftID, "db.zst.age")
require.NoError(t, env.store.Put(context.Background(), leftDB,
strings.NewReader("db")))
env.v.Stdin = strings.NewReader("n\n")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
assert.True(t, env.store.hasKey(leftDB),
"a declined prune must keep the directory")
output := env.stdout.String()
assert.Contains(t, output,
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
assert.Contains(t, output, "Cancelled")
assert.NotContains(t, output, "Deleted")
}
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json