Delete metadata directories a backup left without a manifest (closes #390)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-14 23:09:10 +00:00
parent 6a57750a7b
commit b5a9e2317a
14 changed files with 569 additions and 120 deletions
+43 -27
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 its
manifest, 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
@@ -453,22 +458,29 @@ may belong to a backup still running, on this host or another sharing the
destination store, that has not yet uploaded its manifest. The grace period
does not protect a blob such a backup reuses from an earlier run that stopped
before its manifest: that blob keeps the time of its first upload, so it can
be deleted. Step (3) reads all remote manifests — network cost scales with
the number of snapshots.
be deleted. Step (3) also deletes each directory under `metadata/` that has
no `manifest.json.zst`, as a backup leaves when it stops after uploading
`db.zst.age` but before its manifest, once every object in it is older than
`prune_grace_period` and its snapshot is not in the local index. An object the
listing gives no time for keeps its directory, and a directory whose name is
not a remote key is left alone. Step (3) reads all remote manifests — network
cost scales with the number of snapshots.
`snapshot create --prune` runs the same cleanup automatically; this is the
manual entry point for the same work. If the destination store cannot
be listed, or the local index holds snapshots and the destination has
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
prune fails and deletes nothing. `snapshot purge` does the same. If a
blob cannot be deleted, the command (and `snapshot create --prune`)
exits non-zero.
blob or such a directory cannot be deleted, the command (and
`snapshot create --prune`) exits non-zero.
* `--force`: Skip confirmation prompt
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
`--force` does. A blob that cannot be deleted is counted in
`blobs_failed`, and the document is printed before the non-zero exit.
A blob kept for being newer than `prune_grace_period`, or because the
listing of the destination gives no time for it, is counted in
`blobs_too_new` and not in `blobs_found`.
`blobs_too_new` and not in `blobs_found`. A directory under `metadata/`
with no manifest is counted in `metadata_dirs_deleted` once deleted, or
in `metadata_dirs_failed` if it cannot be.
**`info`**: Display system configuration, storage settings, encryption
recipients, and local database statistics.
@@ -545,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).
@@ -639,6 +652,9 @@ format does and does not protect.
3. List all blobs in storage
4. Delete any blob not in the referenced set that is older than
`prune_grace_period`
5. Delete each directory under `metadata/` with no manifest whose objects
are all older than `prune_grace_period`, unless its snapshot is in the
local index
### chunking and deduplication
@@ -683,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 its manifest, 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 |