2 Commits
Author SHA1 Message Date
clawbot 6a57750a7b Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (push) Waiting to run
release / release (push) Waiting to run
A backup uploads its blobs before the manifest that lists them, and the
process lock covers one user on one machine, so a prune from another host
or user sharing the destination deleted a running backup's blobs.

Prune now deletes an unreferenced blob only once its listed time is older
than the new config key prune_grace_period (default 24h; a negative value
is refused). A newer blob, or one listed with no time, is kept and counted
(blobs_too_new). The rclone backend takes each object's time from the
listing (use_server_modtime).

Decision: a blob a backup reuses from an earlier run that stopped before
its manifest keeps its old time and is not protected; the docs and config
comments say so.

Model: opus-5-5
2026-10-14 13:06:35 +02:00
clawbot 2590f49583 Record the defect-hunt fixes in TODO.md Completed Steps (closes #334)
release / release (push) Failing after 1m25s
check / check (push) Failing after 21m10s
Adds a Completed Steps entry, newest first, for every issue from the
defect hunt (#288) that a merged
fix closed and that had none: the hunt's own issues, those filed while
fixing them, and the owner's ruling on
#239. Each entry says in one or
two sentences what was wrong and what changed; the detail is in each
fix's commit. Next Step now says the hunt's fixes have landed.

Judgement call: #332, closed as
covered by the fix for #296, is
named in that entry rather than given its own.
Judgement call: #351, a Docker
build fix filed while gating these fixes, gets an entry.

Model: opus-5-5
2026-10-14 09:18:09 +02:00
22 changed files with 670 additions and 346 deletions
+49 -6
View File
@@ -96,6 +96,14 @@ vaultik snapshot restore <snapshot-id> ~/vaultik-restore
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
```
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).
## restoring on another machine
Restoring on a host that never ran the backup — a replacement machine
@@ -192,7 +200,22 @@ process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
one of them runs at a time: a second one exits immediately with an
"already running" error rather than waiting, so two writers can never
corrupt the local index or the destination store.
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.
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
`remote info` — do not take the lock and are never blocked, so they run
@@ -296,7 +319,8 @@ value where a list belongs or a word where a number belongs), is refused.
* `--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
remove orphaned blobs from remote storage. By default keeps only the latest
remove orphaned blobs older than `prune_grace_period` from remote storage,
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
@@ -423,9 +447,15 @@ local snapshot whose remote metadata is missing is dropped from the
local index; (2) delete orphaned local rows (files, chunks, blobs no
longer referenced by any completed snapshot); (3) list every remote
manifest on the destination store to compute the still-referenced blob
set and delete any blob not in that set. 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
set and delete any blob not in that set that is older than
`prune_grace_period` (default `24h`). A newer one is kept and counted: it
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.
`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),
@@ -436,6 +466,9 @@ exits non-zero.
* `--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`.
**`info`**: Display system configuration, storage settings, encryption
recipients, and local database statistics.
@@ -510,6 +543,14 @@ being written, a killed upload can leave a truncated object under its name,
which a later backup takes for complete. A leftover `.partial` file is ignored
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
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).
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
still supported for backward compatibility. `storage_url` takes precedence if
both are set.
@@ -596,7 +637,8 @@ format does and does not protect.
1. List all snapshot manifests
2. Build set of all referenced blob hashes
3. List all blobs in storage
4. Delete any blob not in the referenced set
4. Delete any blob not in the referenced set that is older than
`prune_grace_period`
### chunking and deduplication
@@ -641,6 +683,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 |
| `hostname` | system hostname | Hostname used in snapshot IDs |
| `index_path` | platform data dir | Local SQLite index path |
+116 -294
View File
@@ -26,395 +26,217 @@ owner's; a `v*` tag runs the release build in
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that skipped files it could not read
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Under
`--skip-errors` the prune still ran, so by default it deleted the
previous snapshot of the name, the only one still holding a file that
had become unreadable, along with blobs only that snapshot used. When
`--skip-errors` skipped anything it could not read or list, the prune
now does not run and older snapshots are kept, a warning says why, and
the run still exits non-zero.
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Such a
prune could delete the only snapshot still holding those files.
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero when
it skips anything it cannot read
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)). The run
printed its skip errors, then `Finished successfully.`, and exited 0,
so a cron wrapper could not tell it from a complete backup. Such a
run now keeps its snapshots and then fails with
`Backup completed, but N file(s) could not be read`, and an entry the
walk could not read, such as a directory it could not list, now
counts as failed too.
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero,
instead of 0, when it skips anything it cannot read
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)).
- 2026-10-14: Kept the local index when `remote nuke` cannot list or
find the destination store
([issue #292](https://git.eeqj.de/sneak/vaultik/issues/292)). Nuke
took a store it could not list, or one without
`vaultik-destination.txt` while the local index held snapshots, as a
warning and cleared the local snapshot records anyway; for the empty
mount point of an unplugged volume it then reported the store empty
and exited 0. It now fails on either before it removes anything, and
deletes each snapshot's metadata on the destination store before its
local record, so a failed delete keeps the snapshot in the local
index.
had cleared the local snapshot records anyway; it now fails before
removing anything.
- 2026-10-14: Stopped counting directories and unchanged symlinks as
files to back up
([issue #305](https://git.eeqj.de/sneak/vaultik/issues/305)). Every
backup queued every directory and symlink to be backed up, so the
line ending the walk counted directories and their sizes as files and
data to back up while its enumerated count left them out, and every
symlink was reported as backed up on every run. Directories are no
longer counted as files or data to back up, and a symlink is compared
with its record like a regular file, so an unchanged one counts as
unchanged.
([issue #305](https://git.eeqj.de/sneak/vaultik/issues/305)).
- 2026-10-14: Refused a backup destination that an unplugged volume
left empty, as the owner ruled
([issue #239](https://git.eeqj.de/sneak/vaultik/issues/239)). A
`file://` destination at the mount point of an unplugged volume listed
as an empty store, so `prune` dropped every local snapshot record, and
`snapshot create` wrote a whole backup to the internal disk under the
empty mount point. Every backup now writes `vaultik-destination.txt`
at the destination root, and while the local index holds snapshots
stored there, a destination without it is treated as not present:
`snapshot create` refuses before writing, and `prune` deletes
nothing. The `file://` backend also never creates its destination
directory.
([issue #239](https://git.eeqj.de/sneak/vaultik/issues/239)). Every
backup now writes `vaultik-destination.txt` at the destination root,
and while the local index holds snapshots stored there, a destination
without it is treated as not present.
- 2026-10-14: Warned about restored ownership only when an entry's
owner or group differs
([issue #316](https://git.eeqj.de/sneak/vaultik/issues/316)). A
restore by a normal user warned that file ownership was not preserved
even when every entry came out with its recorded owner and group. The
owner and group the restored entries have on disk are now compared
with the recorded ones, and the warning is printed once if any differ.
owner or group differs from the recorded one
([issue #316](https://git.eeqj.de/sneak/vaultik/issues/316)); a
restore by a non-root user had warned even when none did.
- 2026-10-14: Made `snapshot list` report a changed destination instead
of advising `prune`
([issue #317](https://git.eeqj.de/sneak/vaultik/issues/317)). With the
local index bound to another destination than the configured one,
`snapshot list` reported its snapshots as missing from the destination
store and advised `prune`, which then refused. It now gives the
warning `prune`, `snapshot purge` and `snapshot remove` refuse with:
both destinations, and the choice of reverting the configuration or
running `vaultik database delete`.
of advising `prune`, which refuses in that case
([issue #317](https://git.eeqj.de/sneak/vaultik/issues/317)).
- 2026-10-14: Counted a file as deleted only if the previous snapshot
of its name had it
([issue #304](https://git.eeqj.de/sneak/vaultik/issues/304)). A backup
counted as deleted every file in the local index under the source
path that its walk did not find: files the snapshot excludes but
another snapshot backs up, and files removed long ago, which every
later backup reported again. It now counts only files that the latest
completed snapshot of the same name lists and that are gone from disk.
- 2026-10-14: Counted a file as deleted by a backup only if the
previous snapshot of the same name listed it
([issue #304](https://git.eeqj.de/sneak/vaultik/issues/304)); files
that snapshot did not list had been counted too.
- 2026-10-14: Named each prefix once in restore, verify and purge errors
([issue #327](https://git.eeqj.de/sneak/vaultik/issues/327)). A
missing blob during restore, a failed shallow verify and an invalid
purge duration each printed their prefix twice, and a deep verify log
line named S3 whatever the destination. Each prefix now appears once,
and that log line names storage instead.
- 2026-10-14: Named each prefix once in restore, verify and purge
errors, some of which repeated it
([issue #327](https://git.eeqj.de/sneak/vaultik/issues/327)). A deep
verify log line that named S3 whatever the destination now names
storage.
- 2026-10-14: Refused credentials in a storage URL's endpoint or rclone
remote name
([issue #322](https://git.eeqj.de/sneak/vaultik/issues/322)). Userinfo
inside the `endpoint=` value of an `s3://` URL, and rclone
`remote,param=value` parameters, were accepted and then printed by
`info`, `remote info` and `snapshot create`. Both are now refused
without echoing the value, as is an `s3://` or `rclone://` query that
cannot be decoded.
remote name, which were accepted and could then be printed
([issue #322](https://git.eeqj.de/sneak/vaultik/issues/322)).
- 2026-10-14: Ran `$EDITOR` through the shell in `config edit`
([issue #315](https://git.eeqj.de/sneak/vaultik/issues/315)). The
whole of `$EDITOR` was taken as the program name, so a value with
arguments such as `code --wait` or `emacsclient -t` failed with
`executable file not found`. It now runs as git runs it, through
`sh -c`, with the config path passed as a separate argument.
- 2026-10-14: Ran `$EDITOR` through the shell in `config edit`, so a
value with arguments, such as `code --wait`, no longer fails
([issue #315](https://git.eeqj.de/sneak/vaultik/issues/315)).
- 2026-10-14: Made `snapshot remove` fail when it cannot delete the
metadata of a snapshot that is only on the destination store
([issue #340](https://git.eeqj.de/sneak/vaultik/issues/340)). It
warned and exited 0 having removed nothing. That failure is now an
error; for a snapshot in the local index, which is still removed
there, it stays a warning. The README and the help now say the
`vaultik prune` follow-up is printed only when metadata was removed
from the destination store, and never under `--json`.
- 2026-10-14: Made `snapshot remove` fail, instead of exiting 0, when
it cannot delete the metadata of a snapshot that is only on the
destination store
([issue #340](https://git.eeqj.de/sneak/vaultik/issues/340)). The
README and the help now say when it prints the `vaultik prune`
follow-up.
- 2026-10-14: Labelled the totals in the local index cleanup before
each backup as totals
([issue #306](https://git.eeqj.de/sneak/vaultik/issues/306)). Each
removed count was followed by `(N remain)`, which read as incomplete
or orphaned rows left over but counted every snapshot, file, chunk or
blob left in the local index, so removing the only incomplete snapshot
printed `1 removed (1 remain)`. Each line now names that figure as the
total left in the local index.
was shown as `(N remain)`, which read as rows left to clean up.
- 2026-10-13: Freed a cached blob once no file still to restore needs
it ([issue #324](https://git.eeqj.de/sneak/vaultik/issues/324)). A
blob was freed only once every file in the snapshot that uses it had
been restored, which a file outside the selected paths, or one that
failed under `--skip-errors`, never is, so such a restore kept those
blobs in the temporary cache until it ended. A cached blob is now
freed once no file still pending in this restore uses it.
it ([issue #324](https://git.eeqj.de/sneak/vaultik/issues/324)); one
also used by a file outside the selected paths had stayed in the
temporary cache until the restore ended.
- 2026-10-13: Kept the whole startup error when it contains `): `
([issue #320](https://git.eeqj.de/sneak/vaultik/issues/320)). A
startup error was cut at its last `): ` to drop the annotations `fx`
adds, so the `blob_size_limit` message, which itself contains `): `,
lost everything but `need at least N bytes`, the setting's name
included. Only those annotations are now removed, and the error
underneath is shown whole.
([issue #320](https://git.eeqj.de/sneak/vaultik/issues/320)). Such
an error was cut at its last `): `, so the `blob_size_limit` one lost
the setting's name.
- 2026-10-13: Silenced the status lines of `prune` and snapshot removal
under `--cron` and `--quiet`
([issue #294](https://git.eeqj.de/sneak/vaultik/issues/294)). `prune`,
`snapshot purge`, `snapshot remove`, `remote nuke` and the prune step
of `snapshot create --prune` wrote their status lines straight to
stdout, so the README's cron line printed on every successful run and
`--quiet` did not silence them. Those lines are now dropped under both
flags; warnings, errors, `--json` documents and confirmation prompts
still print.
([issue #294](https://git.eeqj.de/sneak/vaultik/issues/294)).
- 2026-10-13: Read the rclone config file that `RCLONE_CONFIG` names
([issue #321](https://git.eeqj.de/sneak/vaultik/issues/321)). rclone
reads that variable only through its `--config` flag, which vaultik
does not have, so a remote defined only in that file was reported as
not found. vaultik now sets rclone's config path from `RCLONE_CONFIG`
when it is set.
([issue #321](https://git.eeqj.de/sneak/vaultik/issues/321)); a
remote defined only there had been reported as not found.
- 2026-10-13: Made `/**/` in an exclude pattern match zero directories
([issue #338](https://git.eeqj.de/sneak/vaultik/issues/338)). The
anchored `/**/x` missed an `x` directly under the snapshot path, and
`a/**/*.log` missed `a/x.log`. As in gitignore, a leading `**/` now
makes an anchored pattern match at every depth too, and each `/**/`
inside a pattern also matches a single `/`.
- 2026-10-13: Made `/**/` in an exclude pattern match zero directories,
as in gitignore
([issue #338](https://git.eeqj.de/sneak/vaultik/issues/338));
`a/**/*.log` had missed `a/x.log`.
- 2026-10-13: Made the `chunk_size` and `blob_size_limit` defaults and
the `chunk_size` minimum 1000-based
([issue #313](https://git.eeqj.de/sneak/vaultik/issues/313)). They
were 1024-based while the size parser, the docs and `info` use
1000-based units, so `chunk_size: 1MB` was rejected, writing out a
documented default changed it, and `info` showed an 11 GB blob size
limit. They are now 1000-based like the parser, and
`config.example.yml` and the `config init` template state the
minimum.
the `chunk_size` minimum 1000-based, like the size parser and the
docs ([issue #313](https://git.eeqj.de/sneak/vaultik/issues/313)).
- 2026-10-13: Kept a snapshot's remote metadata when `snapshot purge`
cannot remove it from the local index
([issue #337](https://git.eeqj.de/sneak/vaultik/issues/337)).
`snapshot purge`, and the purge step of `snapshot create --prune`,
logged that failure, deleted the snapshot's metadata from the
destination store anyway, and exited 0. The purge now stops at that
snapshot with an error before touching its remote metadata.
([issue #337](https://git.eeqj.de/sneak/vaultik/issues/337)); the
purge had deleted the metadata anyway and exited 0.
- 2026-10-13: Made `snapshot restore` fail for a requested path that
matches nothing
([issue #309](https://git.eeqj.de/sneak/vaultik/issues/309)). A path
that matched no entry in the snapshot, such as a typo or a relative
path, was ignored and the command exited 0, even when nothing matched.
Each such path is now named in a warning, the paths that do match are
still restored, and the command exits non-zero.
matches nothing, which it had ignored
([issue #309](https://git.eeqj.de/sneak/vaultik/issues/309)).
- 2026-10-13: Made `remote nuke` delete every object under `metadata/`
and `blobs/`
([issue #291](https://git.eeqj.de/sneak/vaultik/issues/291)). It
deleted only the snapshot metadata and blobs it recognised, so a
snapshot database whose manifest was never uploaded, or a stray file
such as `.DS_Store` under `blobs/`, survived while nuke reported the
store empty. It now deletes everything listed under both, reports the
store empty only when a listing afterwards finds nothing, and
otherwise names each object left and fails.
([issue #291](https://git.eeqj.de/sneak/vaultik/issues/291)); it had
left some, such as a stray `.DS_Store`, while reporting the store
empty.
- 2026-10-13: Made `config set` create a list for an index under a
missing key
([issue #308](https://git.eeqj.de/sneak/vaultik/issues/308)).
`config set age_recipients.0 age1...` on a config without
`age_recipients` wrote a map keyed `0`, after which no command could
load the config. A missing key is now created as a list when the next
path element is a number, and `config set` refuses a change that
would stop a config that loaded from loading.
([issue #308](https://git.eeqj.de/sneak/vaultik/issues/308)); it had
written a map, after which no command could load the config.
- 2026-10-13: Listed a `file://` destination through a symlinked
directory
([issue #296](https://git.eeqj.de/sneak/vaultik/issues/296)). The
listing did not follow a symlinked `metadata/` or `blobs/`, while
reads and writes did, so what was behind the link listed as empty;
with `metadata/` linked, `prune` then dropped the local snapshot
records and deleted every blob. The listings now read through such a
link, and fail when its target is missing
`metadata/` or `blobs/`, which had listed as empty
([issue #296](https://git.eeqj.de/sneak/vaultik/issues/296)). A link
to a missing target now fails the listing
([issue #332](https://git.eeqj.de/sneak/vaultik/issues/332)).
- 2026-10-13: Made `snapshot verify --deep --json` report its blob
counts ([issue #314](https://git.eeqj.de/sneak/vaultik/issues/314)).
Deep verify set `verified` only on success and never set `missing`,
so a failed run reported 0 for both. It now counts each blob whose
contents pass, and before reading any, counts missing and wrong-size
blobs as shallow verify does.
counts ([issue #314](https://git.eeqj.de/sneak/vaultik/issues/314));
a failed run had reported 0 missing and 0 verified.
- 2026-10-13: Limited and retried the module download in the Docker
build ([issue #351](https://git.eeqj.de/sneak/vaultik/issues/351)).
`go mod download` in the `Dockerfile` had no time limit, so a stalled
connection to the module proxy held the step, and every build on the
host sharing it, until the kernel gave up on the connection, once
after about 55 minutes. Each such step now runs under a five-minute
limit, is tried up to three times, and then fails the build with
`module download failed`.
build ([issue #351](https://git.eeqj.de/sneak/vaultik/issues/351)),
which had hung for as long as a stalled connection to the module
proxy lasted.
- 2026-10-13: Recorded each snapshot's uncompressed size when its
backup runs
([issue #360](https://git.eeqj.de/sneak/vaultik/issues/360)).
`snapshot list` summed the chunks a snapshot reaches through file
rows shared by every snapshot that lists the file and that a changed
file rewrites, so backing up a changed file changed the UNCOMPRESSED
SIZE of every older snapshot that lists that file. The backup now
stores that total in the `snapshots` table's
`uncompressed_chunk_size`, which `snapshot list` reads.
([issue #360](https://git.eeqj.de/sneak/vaultik/issues/360)). A later
backup could change an older snapshot's UNCOMPRESSED SIZE in
`snapshot list`.
- 2026-10-13: Stopped `snapshot restore --verify` reporting a file that
failed to restore again as a checksum failure
([issue #323](https://git.eeqj.de/sneak/vaultik/issues/323)). Under
`--skip-errors`, a file that failed to restore was verified anyway,
failed again because it was never written, and was listed twice as a
checksum mismatch. Verification now skips the files that failed to
restore and reports its own failures apart, each path once.
- 2026-10-13: Stopped `snapshot restore --skip-errors --verify`
reporting a file that failed to restore again as a checksum failure
([issue #323](https://git.eeqj.de/sneak/vaultik/issues/323)).
- 2026-10-13: Recorded the stored size of a blob a backup finds already
stored ([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)).
The upload was skipped, but the local index and the manifest kept the
size of the local copy, which can differ from the stored object when
`compression_level` or the recipients changed, so `snapshot verify`
failed on a good snapshot. The size the destination store reports for
the existing object is now recorded.
stored, not the size of its local copy, which can differ, so
`snapshot verify` could fail on a good snapshot
([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)).
- 2026-10-13: Stopped the local index and the blob upload step ending
the process on a rollback after Ctrl-C, a failed close of query
results, or a failed temp file cleanup
([issue #345](https://git.eeqj.de/sneak/vaultik/issues/345)). Each
ended the process at once and skipped the run's cleanup.
Read-only transactions now handle the rollback as other transactions
do, the query functions report a close failure through the error they
already return, and a failed close or removal of a blob temp file
fails the backup.
the process on a failed close of query results or a failed temp file
cleanup ([issue #345](https://git.eeqj.de/sneak/vaultik/issues/345));
both are now returned as errors.
- 2026-10-13: Stopped a stalled snapshot database read when restore or
deep verify is interrupted
([issue #356](https://git.eeqj.de/sneak/vaultik/issues/356)). Both
read the snapshot database without the fix for stalled blob reads, so
after Ctrl-C a database read stalled on a slow device still left the
decrypted database in the temporary directory. That read is now
closed on cancel too.
- 2026-10-13: Closed the snapshot database's storage reader too when
restore or deep verify is interrupted
([issue #356](https://git.eeqj.de/sneak/vaultik/issues/356)).
- 2026-10-13: Made `prune` and `remote nuke` fail when a delete fails
([issue #290](https://git.eeqj.de/sneak/vaultik/issues/290)). `prune`,
and the prune step of `snapshot create --prune`, counted a blob it
could not delete and still exited 0, and `remote nuke` only warned
when it could not delete a snapshot's metadata, advised
`vaultik prune`, which never removes metadata, and still reported the
store empty. Both now exit non-zero, and nuke says to run
`vaultik remote nuke --force` again.
- 2026-10-13: Made `prune` and `remote nuke` fail when a delete on the
backup destination store fails
([issue #290](https://git.eeqj.de/sneak/vaultik/issues/290)).
- 2026-10-13: Made an S3 listing that the server cuts short an error
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)). A
listing page marked truncated but carrying no continuation token
ended the listing without an error, so `prune` saw only the snapshots
on the first page and treated the blobs of the rest as unreferenced.
Such a page now fails the listing, so `prune` stops before deleting
any blob.
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)); `prune`
had taken it as complete and could delete blobs still in use.
- 2026-10-13: Made shallow `snapshot verify` report a blob it could not
check apart from a missing one
([issue #318](https://git.eeqj.de/sneak/vaultik/issues/318)). Every
error from checking a blob or the encrypted database counted as
missing and was printed nowhere, so a permission error or an S3 403
read as data loss. Only a not-found answer now counts as missing; any
other error is reported with its cause as could not be checked, in
the text report and under `--json`, and the verify fails.
([issue #318](https://git.eeqj.de/sneak/vaultik/issues/318)); any
error checking a blob had counted as missing, its cause dropped.
- 2026-10-13: Restored the other files when a file's chunk is in no
blob under `--skip-errors`
([issue #326](https://git.eeqj.de/sneak/vaultik/issues/326)). Restore
planning stopped at the first such file, before the flag could skip
it, so nothing was restored. That file now fails like any other file
that cannot be restored: skipped under the flag, and fatal without it.
([issue #326](https://git.eeqj.de/sneak/vaultik/issues/326)); that
file had stopped the restore before anything was restored.
- 2026-10-13: Recorded each snapshot's new chunk size when its backup
runs ([issue #311](https://git.eeqj.de/sneak/vaultik/issues/311)).
`snapshot list` worked out NEW CHUNK SIZE from file rows shared by
every snapshot that lists the file and that a changed file rewrites,
so a snapshot whose change was a modified file showed 0 B and older
snapshots' figures moved after later backups. The backup now stores
the size of the chunks it newly stored in the `snapshots` table's
`new_chunk_size`, which `snapshot list` reads.
`snapshot list` could show a NEW CHUNK SIZE of 0 B for a snapshot
whose change was a modified file.
- 2026-10-13: Made a backup interrupted during a local index write exit
as interrupted
([issue #301](https://git.eeqj.de/sneak/vaultik/issues/301)). After
Ctrl-C the transaction was already rolled back, so the explicit
rollback failed and was treated as fatal: the run printed
`FATAL: failed to rollback transaction`, exited 1 instead of 130, and
skipped its cleanup. The rollback's report that the transaction was
already over is now ignored, and a rollback that really fails is
returned with the original error instead of ending the process.
([issue #301](https://git.eeqj.de/sneak/vaultik/issues/301)); it could
end with `FATAL: failed to rollback transaction` and exit 1 instead of
130.
- 2026-10-13: Printed `snapshot list` and `snapshot purge` sizes like
every other command
([issue #312](https://git.eeqj.de/sneak/vaultik/issues/312)). They,
and the `bytes` value in terminal log lines, divided by 1024 but
labelled the result KB or MB, so 6,329,198 bytes read 6.0 MB there and
6.3 MB in other commands. All three now use 1000-based units, as other
commands do.
and the `bytes` value in terminal log lines, were 1024-based but
labelled KB or MB, and now use 1000-based units.
- 2026-10-13: Stopped a stalled blob read when restore or deep verify
is interrupted
([issue #325](https://git.eeqj.de/sneak/vaultik/issues/325)). Both
checked for a cancel only between blobs, and the `file://` backend's
reader ignores it, so after Ctrl-C a blob read stalled on a slow
device outlasted the 30-second shutdown timeout and the decrypted
snapshot database and blob cache were left in the temporary
directory. Each blob's storage reader is now closed as soon as the
command is cancelled, which ends a stalled read unless it is stuck in
the kernel.
- 2026-10-13: Closed each blob's storage reader when restore or deep
verify is interrupted, which ends a stalled read unless it is stuck in
the kernel ([issue #325](https://git.eeqj.de/sneak/vaultik/issues/325)).
- 2026-10-13: Made a mistyped subcommand under a group command an error
([issue #319](https://git.eeqj.de/sneak/vaultik/issues/319)).
`snapshot`, `config`, `remote`, `database` and `completion` printed
their help and exited 0 for a word that is not one of their
subcommands, so a mistyped cron line made no backup and reported
nothing. Each now prints its help only when given no argument, and
otherwise fails as an unknown command, as the top level does.
- 2026-10-13: Made a mistyped subcommand under `snapshot`, `config`,
`remote`, `database` or `completion` an error
([issue #319](https://git.eeqj.de/sneak/vaultik/issues/319)); each had
printed its help and exited 0.
- 2026-10-13: Made an exclude pattern starting with `**/` match
directly under the snapshot path
directly under the snapshot path too, as in gitignore
([issue #303](https://git.eeqj.de/sneak/vaultik/issues/303)).
`**/node_modules` and `**/*.pyc` excluded `sub/node_modules` and
`sub/y.pyc` but backed up `node_modules/` and `x.pyc` at the top of
the path. The leading `**/` is now dropped when the pattern is
compiled, so the pattern matches at every depth, the top level
included, as in gitignore.
- 2026-10-13: Refused a relative `file://` storage URL and decoded its
percent-escapes
([issue #299](https://git.eeqj.de/sneak/vaultik/issues/299)). The path
after `file://` was used as written, so `file://dest` followed the
working directory and a backup run from elsewhere exited 0 and could
not be restored, and `my%20stick` made a directory of that literal
name. Config load and the storage URL parser now refuse a path that
is not absolute, and the parser decodes escapes.
([issue #299](https://git.eeqj.de/sneak/vaultik/issues/299)); a
relative path had moved with the working directory.
- 2026-10-13: Removed the blob temp file when a backup stops part-way
([issue #302](https://git.eeqj.de/sneak/vaultik/issues/302)). When
`snapshot create` stopped on a file error, a full temporary directory
or Ctrl-C, the half-written `vaultik-blob-*.tmp` of the blob being
packed, up to `blob_size_limit` of encrypted data, stayed in
`$TMPDIR`. The backup now closes and removes it on the way out.
([issue #302](https://git.eeqj.de/sneak/vaultik/issues/302)); the
half-written blob had stayed in `$TMPDIR`.
- 2026-10-13: Checked `--keep-newer-than` before a backup starts
([issue #307](https://git.eeqj.de/sneak/vaultik/issues/307)). An
invalid value was read only by the prune after the backup, so the
snapshot was uploaded, the run reported it as failed and retention
never ran; without `--prune` the flag was ignored. `snapshot create`
now refuses an invalid duration, or the flag without `--prune`,
before it starts, and a failed post-backup prune is reported as
`Backup completed, but post-backup prune failed`.
([issue #307](https://git.eeqj.de/sneak/vaultik/issues/307)); an
invalid value had been read only by the prune after the backup, so
retention never ran.
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
is not a blob as an orphaned blob
+12
View File
@@ -328,6 +328,18 @@ storage_url: "rclone://myremote/path/to/backups"
# Higher = better compression but slower
# 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
# 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
# Supports: 36h, 90m, etc.; 0s deletes them at once
# Default: 24h
#prune_grace_period: 24h
# Hostname to use in backup metadata
# Default: system hostname
#hostname: myserver
+2 -1
View File
@@ -228,7 +228,8 @@ The restore process doesn't use the local database. Instead:
1. **Identify Unreferenced Blobs**
- Query blobs not referenced by any remaining snapshot
- Delete from S3 and local database
- Delete from S3, once older than `prune_grace_period`, and from the local
database
### 6. Incomplete Snapshot Cleanup
+1 -1
View File
@@ -166,7 +166,7 @@ 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
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. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
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
## Restoration Requirements
+10
View File
@@ -239,6 +239,16 @@ 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
# 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.
# Accepts: 36h, 90m, etc. 0s deletes them at once.
# Default: 24h
# prune_grace_period: 24h
# Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver
+6 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -14,8 +15,8 @@ import (
const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, so that a prune deletes it. Returns its
// path.
// destination store at storeDir, dated long before any prune grace period,
// so that a prune deletes it. Returns its path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
@@ -25,6 +26,9 @@ func plantUnreferencedBlob(t *testing.T, storeDir string) string {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
longAgo := time.Unix(0, 0)
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
return path
}
+6 -1
View File
@@ -20,7 +20,12 @@ Local cleanup drops incomplete snapshots and any files, chunks, or
blobs no longer referenced by a completed snapshot. Remote cleanup
scans every snapshot manifest in the destination store, builds the
set of still-referenced blob hashes, and deletes any blob not in that
set.
set that is older than prune_grace_period (default 24h). A newer one
is kept: it 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.
Snapshot create --prune runs the same cleanup automatically; this
command is the manual entry point for the same work (e.g. after a
+2 -2
View File
@@ -132,8 +132,8 @@ on Linux), then /etc/vaultik/config.yml.`,
"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 (not after a backup that skipped unreadable "+
"files)")
"orphaned blobs older than prune_grace_period (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) "+
+17
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/smartconfig"
@@ -38,6 +39,7 @@ const (
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3
defaultPruneGracePeriod = 24 * time.Hour
minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1
maxCompressionLevel = 19
@@ -67,6 +69,8 @@ var (
errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials")
@@ -187,6 +191,13 @@ type Config struct {
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
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.
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
// StorageURL specifies the storage backend using a URL format.
// Takes precedence over S3Config if set.
// Supported formats:
@@ -288,6 +299,7 @@ func Parse(data []byte) (*Config, error) {
ChunkSize: defaultChunkSize,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel,
PruneGracePeriod: defaultPruneGracePeriod,
S3: S3Config{PartSize: defaultS3PartSize},
}
@@ -356,6 +368,7 @@ func Parse(data []byte) (*Config, error) {
// exceeds the configured limit
// - Compression level must be between 1 and 19
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
// - Prune grace period must not be negative
//
// Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error {
@@ -405,6 +418,10 @@ func (c *Config) Validate() error {
return errBadS3PartSize
}
if c.PruneGracePeriod < 0 {
return errNegativePruneGracePeriod
}
return nil
}
+68
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/chunker"
"sneak.berlin/go/vaultik/internal/log"
@@ -412,6 +413,73 @@ func TestDocumentedSizeExamples(t *testing.T) {
}
}
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
// is refused rather than acting like 0s, which deletes unreferenced blobs
// at once, and that 0s and the documented examples are accepted.
func TestValidatePruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
setting string
wantErr error
}{
{"prune_grace_period: 0s", nil},
{"prune_grace_period: 90m", nil},
{"prune_grace_period: 36h", nil},
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
}
for _, tt := range tests {
t.Run(tt.setting, func(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
err = cfg.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestLoadPruneGracePeriod checks that a config file without
// prune_grace_period loads with the documented 24h. The value is written out
// here rather than taken from defaultPruneGracePeriod, so that a change of the
// default fails this test.
func TestLoadPruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if cfg.PruneGracePeriod != 24*time.Hour {
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write
+8 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"strings"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
@@ -302,7 +303,10 @@ func IsNotFound(err error) bool {
type ObjectInfo struct {
Key string
Size int64
Err error
// ModTime is the object's LastModified time. Only ListObjectsStream
// sets it.
ModTime time.Time
Err error
}
// ListObjectsStream lists objects with the given prefix and returns a channel.
@@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream(
}
ch <- ObjectInfo{
Key: key,
Size: *obj.Size,
Key: key,
Size: *obj.Size,
ModTime: aws.ToTime(obj.LastModified),
}
}
}
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"reflect"
"sort"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/storage"
)
@@ -19,15 +20,17 @@ import (
// constructor, so the contract is defined once.
//
// It exercises the public Storer interface: round-trip, stat, list with
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
// Get and Stat. Each section takes its own fresh backend instance, so the
// order of sections never matters and no section sees another's objects.
// prefix filtering, the modification time ListStream reports, overwrite,
// delete, delete-of-missing, and not-found on Get and Stat. Each section
// takes its own fresh backend instance, so the order of sections never
// matters and no section sees another's objects.
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
t.Helper()
conformanceRoundTrip(t, newStorer(t))
conformanceOverwrite(t, newStorer(t))
conformanceList(t, newStorer(t))
conformanceListStreamModTime(t, newStorer(t))
conformanceDelete(t, newStorer(t))
conformanceNotFound(t, newStorer(t))
}
@@ -118,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
}
}
// conformanceListStreamModTime checks that ListStream gives an object
// just written a modification time of about now, which prune compares
// with its grace period.
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
t.Helper()
ctx := context.Background()
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
if err != nil {
t.Fatalf("Put: %v", err)
}
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
}
// conformanceDelete checks that Delete removes an object and that deleting
// a missing key is not an error.
func conformanceDelete(t *testing.T, s storage.Storer) {
+3 -2
View File
@@ -235,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
// Normalize path separators
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
ch <- ObjectInfo{
Key: relPath,
Size: info.Size(),
Key: relPath,
Size: info.Size(),
ModTime: info.ModTime(),
}
}
+11 -2
View File
@@ -53,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
rclonePath += path
}
// On S3, Swift and some other remotes rclone keeps a time of its own in
// each object's metadata, which costs one request per object to read.
// rclone's use_server_modtime setting makes ModTime the time the
// listing gives instead: when the object was stored, which is what
// prune compares with prune_grace_period.
ctx, ci := fs.AddConfig(ctx)
ci.UseServerModTime = true
// Create the rclone filesystem
fsys, err := fs.NewFs(ctx, rclonePath)
if err != nil {
@@ -211,8 +219,9 @@ func (r *RcloneStorer) ListStream(
if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- ObjectInfo{
Key: key,
Size: obj.Size(),
Key: key,
Size: obj.Size(),
ModTime: obj.ModTime(ctx),
}
}
})
+75
View File
@@ -5,11 +5,17 @@ import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap"
@@ -429,3 +435,72 @@ func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
t.Errorf("object is not in the remote's directory: %v", err)
}
}
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
// rclone S3 remote takes each object's time from the listing itself. By
// default rclone reads the time it keeps in each object's metadata, one
// request per object, and every command that lists the destination store
// would slow down as it grows.
//
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
backend := s3mem.New()
err := backend.CreateBucket(s3TestBucket)
if err != nil {
t.Fatalf("create bucket: %v", err)
}
const body = "blob-bytes"
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("seed object: %v", err)
}
var headRequests atomic.Int64
fakeS3 := gofakes3.New(backend).Server()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
headRequests.Add(1)
}
fakeS3.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
remote := ":s3,provider=Other" +
",access_key_id=" + s3TestAccessKeyID +
",secret_access_key=" + s3TestSecretAccessKey +
",endpoint='" + srv.URL + "'"
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
headRequests.Store(0)
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
if n := headRequests.Load(); n != 0 {
t.Errorf("ListStream made %d HEAD request(s), want none", n)
}
}
+4 -3
View File
@@ -89,9 +89,10 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
ch <- ObjectInfo{
Key: info.Key,
Size: info.Size,
Err: info.Err,
Key: info.Key,
Size: info.Size,
ModTime: info.ModTime,
Err: info.Err,
}
}
}()
+7 -2
View File
@@ -14,6 +14,7 @@ import (
"context"
"errors"
"io"
"time"
)
// ErrNotFound is returned when an object does not exist.
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
type ObjectInfo struct {
Key string // Object key/path
Size int64 // Size in bytes
Err error // Error for streaming results (nil on success)
// ModTime is when the object was written, as the store reports it.
// Only ListStream sets it; Stat leaves it zero.
ModTime time.Time
Err error // Error for streaming results (nil on success)
}
// Info provides human-readable storage configuration.
@@ -67,7 +71,8 @@ type Storer interface {
// For large result sets, prefer ListStream.
List(ctx context.Context, prefix string) ([]string, error)
// ListStream returns a channel of ObjectInfo for large result sets.
// ListStream returns a channel of ObjectInfo, with each object's key,
// size and modification time, for large result sets.
// The channel is closed when listing completes.
// If an error occurs during listing, the final item will have Err set.
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
+4 -4
View File
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
return err
}
for _, size := range allBlobs {
for _, blob := range allBlobs {
result.TotalBlobCount++
result.TotalBlobSize += size
result.TotalBlobSize += blob.Size
}
// A blob named only by a manifest that could not be read, or by one
@@ -474,10 +474,10 @@ func (v *Vaultik) scanRemoteBlobStorage(
orphanedSize int64
)
for hash, size := range allBlobs {
for hash, blob := range allBlobs {
if _, referenced := referencedBlobs[hash]; !referenced {
orphanedCount++
orphanedSize += size
orphanedSize += blob.Size
}
}
+59 -17
View File
@@ -5,8 +5,10 @@ import (
"errors"
"fmt"
"strings"
"time"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
)
// PruneOptions contains options for the prune command
@@ -163,6 +165,10 @@ type PruneBlobsResult struct {
BlobsDeleted int `json:"blobs_deleted"`
BlobsFailed int `json:"blobs_failed,omitempty"`
BytesFreed int64 `json:"bytes_freed"`
// BlobsTooNew counts the unreferenced blobs kept because they are
// newer than the prune grace period, or of unknown age. BlobsFound
// does not include them.
BlobsTooNew int `json:"blobs_too_new"`
}
// Prune removes orphaned data from the local index database AND
@@ -208,9 +214,15 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err
}
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
allBlobs, allBlobsReferenced)
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
result := &PruneBlobsResult{
BlobsFound: len(unreferencedBlobs),
BlobsTooNew: tooNew,
}
v.reportBlobsTooNew(tooNew, opts)
if len(unreferencedBlobs) == 0 {
log.Info("No unreferenced blobs found")
@@ -344,11 +356,12 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
return snapshotIDs, nil
}
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
// to their listing entries.
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
log.Info("Listing all blobs in storage")
allBlobs := make(map[string]int64)
allBlobs := make(map[string]storage.ObjectInfo)
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
for object := range blobObjectCh {
@@ -372,7 +385,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
continue
}
allBlobs[parts[3]] = object.Size
allBlobs[parts[3]] = object
}
log.Info("Found blobs in storage", "count", len(allBlobs))
@@ -380,30 +393,59 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
return allBlobs, nil
}
// findUnreferencedBlobs returns blob hashes not referenced by any
// manifest and their total size.
// findUnreferencedBlobs returns the hashes of the blobs no manifest
// references that are older than the prune grace period, and their total
// size. It also returns how many unreferenced blobs it left out for
// being newer than that, or of unknown age because the listing gave no
// time for them.
func (v *Vaultik) findUnreferencedBlobs(
allBlobs map[string]int64, referenced map[string]bool,
) ([]string, int64) {
allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
) ([]string, int64, int) {
var (
unreferenced []string
totalSize int64
tooNew int
)
for hash, size := range allBlobs {
if !referenced[hash] {
unreferenced = append(unreferenced, hash)
totalSize += size
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
for hash, blob := range allBlobs {
if referenced[hash] {
continue
}
if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) {
tooNew++
continue
}
unreferenced = append(unreferenced, hash)
totalSize += blob.Size
}
return unreferenced, totalSize
return unreferenced, totalSize, tooNew
}
// reportBlobsTooNew says how many unreferenced blobs prune kept for being
// newer than the grace period or of unknown age. Under --json the
// document carries the count instead.
func (v *Vaultik) reportBlobsTooNew(tooNew int, opts *PruneOptions) {
if tooNew == 0 || opts.JSON {
return
}
v.UI.Infof("Kept %d unreferenced blob(s) newer than "+
"prune_grace_period (%s), or of unknown age: a backup still "+
"running may need them.",
tooNew, v.Config.PruneGracePeriod)
}
// deleteUnreferencedBlobs deletes the given blobs from storage and
// populates the result.
func (v *Vaultik) deleteUnreferencedBlobs(
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
result *PruneBlobsResult,
) {
log.Info("Deleting unreferenced blobs")
@@ -418,7 +460,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
}
result.BlobsDeleted++
result.BytesFreed += allBlobs[hash]
result.BytesFreed += allBlobs[hash].Size
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
log.Info("Deletion progress",
+166
View File
@@ -0,0 +1,166 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
// sharing the destination store at the moment a backup has uploaded its
// blobs but not yet its manifest, so no manifest lists those blobs. The
// prune must leave them, and the backup must then restore.
func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
cfg := faultTestConfig()
testFiles := writeFaultSourceTree(t, fs, dataDir)
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
otherHostCfg := faultTestConfig()
otherHostCfg.PruneGracePeriod = time.Hour
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
var (
pruned bool
pruneErr error
)
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if strings.HasSuffix(key, "/manifest.json.zst") {
pruned = true
pruneErr = otherHost.PruneBlobs(&vaultik.PruneOptions{Force: true})
}
return faultstore.PutNormal
}
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
id := fullFaultBackup(ctx, t, fs, store, cfg, repos, dataDir, dbPath, "shared")
require.NoError(t, db.Close())
require.True(t, pruned, "the prune must run before the manifest upload")
require.NoError(t, pruneErr)
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
SnapshotID: id,
TargetDir: restoreDir,
Verify: true,
}), "a prune during the backup must not delete the backup's blobs")
assertRestoredTree(t, fs, restoreDir, testFiles)
}
// TestPruneKeepsUnreferencedBlobWithNoTime checks that a blob no
// manifest lists, whose listing gives no modification time (as an S3
// listing entry without LastModified does), is kept and counted with
// the blobs too new to delete: its age is unknown.
func TestPruneKeepsUnreferencedBlobWithNoTime(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
// Written into the mock store's map directly rather than through
// Put, so its listing gives no time.
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
env.store.data[blobKey] = []byte("blob")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
assert.True(t, env.store.hasKey(blobKey),
"a blob of unknown age must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result))
assert.Equal(t, 0, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
// TestPruneDeletesUnreferencedBlobOlderThanGracePeriod checks that the
// grace period only delays deletion: a blob no manifest lists is deleted
// once it is older than the grace period, and kept and counted until
// then.
func TestPruneDeletesUnreferencedBlobOlderThanGracePeriod(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
oldKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
newKey := "blobs/" + testBlobHashB[:2] + "/" + testBlobHashB[2:4] +
"/" + testBlobHashB
for _, key := range []string{oldKey, newKey} {
require.NoError(t, store.Put(ctx, key, strings.NewReader("blob")))
}
gracePeriod := time.Hour
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
require.NoError(t, os.Chtimes(filepath.Join(storeDir, oldKey),
beforeGracePeriod, beforeGracePeriod))
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Config: &config.Config{PruneGracePeriod: gracePeriod},
Storage: store,
Stdout: stdout,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
_, err = store.Stat(ctx, oldKey)
require.ErrorIs(t, err, storage.ErrNotFound,
"a blob older than the grace period must be deleted")
_, err = store.Stat(ctx, newKey)
require.NoError(t, err, "a blob newer than the grace period must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
assert.Equal(t, 1, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
+9 -3
View File
@@ -26,11 +26,14 @@ import (
type testStorer struct {
mu sync.Mutex
data map[string][]byte
// putTimes holds when Put stored each object; ListStream reports it.
putTimes map[string]time.Time
}
func newTestStorer() *testStorer {
return &testStorer{
data: make(map[string][]byte),
data: make(map[string][]byte),
putTimes: make(map[string]time.Time),
}
}
@@ -44,6 +47,7 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
}
s.data[key] = data
s.putTimes[key] = time.Now().UTC()
return nil
}
@@ -87,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
defer s.mu.Unlock()
delete(s.data, key)
delete(s.putTimes, key)
return nil
}
@@ -120,8 +125,9 @@ func (s *testStorer) ListStream(
for key, data := range s.data {
if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- storage.ObjectInfo{
Key: key,
Size: int64(len(data)),
Key: key,
Size: int64(len(data)),
ModTime: s.putTimes[key],
}
}
}