Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (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). A backup uploads a blob already on the destination again
instead of skipping it, so it gets a fresh time. The rclone backend takes
each object's time from the listing (use_server_modtime).

Cost: after database delete, the next backup uploads all its data again.
Side effect: a rebuilt blob replaces the stored copy; if compression_level
or the recipients changed since, older snapshots listing it fail verify.

Model: opus-5-5
This commit is contained in:
2026-10-14 09:03:21 +00:00
parent 2590f49583
commit 67c4421033
26 changed files with 633 additions and 118 deletions
+39 -8
View File
@@ -96,6 +96,12 @@ vaultik snapshot restore <snapshot-id> ~/vaultik-restore
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w # 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; otherwise give each host its own prefix or directory. See
[locking](#locking).
## restoring on another machine ## restoring on another machine
Restoring on a host that never ran the backup — a replacement machine Restoring on a host that never ran the backup — a replacement machine
@@ -192,7 +198,17 @@ process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only (`~/.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 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 "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. `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 Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
`remote info` — do not take the lock and are never blocked, so they run `remote info` — do not take the lock and are never blocked, so they run
@@ -296,7 +312,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 * `--cron`: Silent on total success; warnings and errors are still printed
(for crontab) (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 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 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 `--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 prune does not run and every older snapshot is kept, since one of them may
@@ -423,9 +440,12 @@ local snapshot whose remote metadata is missing is dropped from the
local index; (2) delete orphaned local rows (files, chunks, blobs no local index; (2) delete orphaned local rows (files, chunks, blobs no
longer referenced by any completed snapshot); (3) list every remote longer referenced by any completed snapshot); (3) list every remote
manifest on the destination store to compute the still-referenced blob 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 set and delete any blob not in that set that is older than
manifests — network cost scales with the number of snapshots. `snapshot `prune_grace_period` (default `24h`). A newer one is kept and counted: it
create --prune` runs the same cleanup automatically; this is the may belong to a backup still running, on this host or another sharing the
destination store, that has not yet uploaded its manifest. 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 manual entry point for the same work. If the destination store cannot
be listed, or the local index holds snapshots and the destination has be listed, or the local index holds snapshots and the destination has
no `vaultik-destination.txt` (an unplugged volume's empty mount point), no `vaultik-destination.txt` (an unplugged volume's empty mount point),
@@ -436,6 +456,9 @@ exits non-zero.
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as * `--json`: Output stats as JSON. Also skips the confirmation prompt, as
`--force` does. A blob that cannot be deleted is counted in `--force` does. A blob that cannot be deleted is counted in
`blobs_failed`, and the document is printed before the non-zero exit. `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 **`info`**: Display system configuration, storage settings, encryption
recipients, and local database statistics. recipients, and local database statistics.
@@ -470,8 +493,8 @@ deletes what is left.
**`database delete`**: Delete the local SQLite state database file **`database delete`**: Delete the local SQLite state database file
entirely. Remote storage is unaffected; the next backup will do a full entirely. Remote storage is unaffected; the next backup will do a full
scan and re-deduplicate against existing remote blobs, and the local scan and upload all the data again, and the local index will re-bind to
index will re-bind to the currently configured storage destination. the currently configured storage destination.
Use this after changing `storage_url`, or `s3.endpoint`, `s3.bucket` or Use this after changing `storage_url`, or `s3.endpoint`, `s3.bucket` or
`s3.prefix`, to a different destination. `s3.prefix`, to a different destination.
* `--force`: Skip confirmation prompt * `--force`: Skip confirmation prompt
@@ -510,6 +533,12 @@ 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 which a later backup takes for complete. A leftover `.partial` file is ignored
and can be deleted. 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; otherwise give each host its own prefix (or directory). See
[locking](#locking).
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
still supported for backward compatibility. `storage_url` takes precedence if still supported for backward compatibility. `storage_url` takes precedence if
both are set. both are set.
@@ -596,7 +625,8 @@ format does and does not protect.
1. List all snapshot manifests 1. List all snapshot manifests
2. Build set of all referenced blob hashes 2. Build set of all referenced blob hashes
3. List all blobs in storage 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 ### chunking and deduplication
@@ -641,6 +671,7 @@ complete annotated example also lives in
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking | | `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` | | `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) | | `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)). A duration such as `36h` or `90m`; `0s` deletes such blobs at once |
| `hostname` | system hostname | Hostname used in snapshot IDs | | `hostname` | system hostname | Hostname used in snapshot IDs |
| `index_path` | platform data dir | Local SQLite index path | | `index_path` | platform data dir | Local SQLite index path |
+9
View File
@@ -328,6 +328,15 @@ storage_url: "rclone://myremote/path/to/backups"
# Higher = better compression but slower # Higher = better compression but slower
# Default: 3 # Default: 3
compression_level: 5 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
# Supports: 36h, 90m, etc.; 0s deletes them at once
# Default: 24h
#prune_grace_period: 24h
# Hostname to use in backup metadata # Hostname to use in backup metadata
# Default: system hostname # Default: system hostname
#hostname: myserver #hostname: myserver
+4 -3
View File
@@ -90,7 +90,7 @@ Stores information about packed, compressed, and encrypted blob files.
- `created_ts` (INTEGER NOT NULL) - Creation timestamp - `created_ts` (INTEGER NOT NULL) - Creation timestamp
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress) - `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression - `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy - `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded) - `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs. **Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
@@ -185,7 +185,7 @@ Tracks blob upload metrics.
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing - `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
4. **Upload** 4. **Upload**
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored) - `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (a blob already stored is uploaded again, so that a prune does not take it for an unreferenced blob older than `prune_grace_period`)
- `INSERT INTO uploads` - Record upload metrics with snapshot_id - `INSERT INTO uploads` - Record upload metrics with snapshot_id
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot - `INSERT INTO snapshot_blobs` - Associate blob with snapshot
@@ -228,7 +228,8 @@ The restore process doesn't use the local database. Instead:
1. **Identify Unreferenced Blobs** 1. **Identify Unreferenced Blobs**
- Query blobs not referenced by any remaining snapshot - 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 ### 6. Incomplete Snapshot Cleanup
+2 -2
View File
@@ -150,7 +150,7 @@ These are known, deliberate properties of the format and the tooling, recorded s
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them. 3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target. 4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead. 5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix. 6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option. 7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
## Consistency Guarantees ## Consistency Guarantees
@@ -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 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 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 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. `remote nuke` has no such check
## Restoration Requirements ## Restoration Requirements
+7
View File
@@ -239,6 +239,13 @@ storage_url: ""
# Default: 3 # Default: 3
# compression_level: 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. Accepts: 36h, 90m, etc. 0s deletes them at once.
# Default: 24h
# prune_grace_period: 24h
# Hostname used in snapshot IDs. Default: system hostname. # Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver # hostname: myserver
+3 -3
View File
@@ -48,9 +48,9 @@ This will erase all local tracking of:
- The storage-binding record - The storage-binding record
The remote storage is NOT affected. After deletion, the next backup The remote storage is NOT affected. After deletion, the next backup
will perform a full scan and re-deduplicate against existing remote will perform a full scan and upload all the data again, and the local
blobs, and the local index will re-bind to the currently configured index will re-bind to the currently configured storage destination on
storage destination on that run. that run.
Use --force to skip the confirmation prompt.`, Use --force to skip the confirmation prompt.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
+6 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -14,8 +15,8 @@ import (
const flagCron = "--cron" const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the // plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, so that a prune deletes it. Returns its // destination store at storeDir, dated long before any prune grace period,
// path. // so that a prune deletes it. Returns its path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string { func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper() 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.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600)) require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
longAgo := time.Unix(0, 0)
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
return path return path
} }
+4 -1
View File
@@ -20,7 +20,10 @@ Local cleanup drops incomplete snapshots and any files, chunks, or
blobs no longer referenced by a completed snapshot. Remote cleanup blobs no longer referenced by a completed snapshot. Remote cleanup
scans every snapshot manifest in the destination store, builds the scans every snapshot manifest in the destination store, builds the
set of still-referenced blob hashes, and deletes any blob not in that 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.
Snapshot create --prune runs the same cleanup automatically; this Snapshot create --prune runs the same cleanup automatically; this
command is the manual entry point for the same work (e.g. after a 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)") "Run in cron mode (silent unless warning or error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false, cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+ "After backup, drop older snapshots of the same name and remove "+
"orphaned blobs (not after a backup that skipped unreadable "+ "orphaned blobs older than prune_grace_period (not after a "+
"files)") "backup that skipped unreadable files)")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "", cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+ "With --prune: keep snapshots newer than this duration "+
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+ "(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
+17
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
@@ -38,6 +39,7 @@ const (
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3 defaultCompressionLevel = 3
defaultPruneGracePeriod = 24 * time.Hour
minChunkSize = 1000 * 1000 // 1MB minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1 minCompressionLevel = 1
maxCompressionLevel = 19 maxCompressionLevel = 19
@@ -67,6 +69,8 @@ var (
errFileURLNotAbsolute = errors.New( errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " + "a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup") "as in file:///path/to/backup")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New( errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " + "storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials") "s3.bucket + credentials")
@@ -187,6 +191,13 @@ type Config struct {
Snapshots map[string]SnapshotConfig `yaml:"snapshots"` Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
CompressionLevel int `yaml:"compression_level"` 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. // StorageURL specifies the storage backend using a URL format.
// Takes precedence over S3Config if set. // Takes precedence over S3Config if set.
// Supported formats: // Supported formats:
@@ -288,6 +299,7 @@ func Parse(data []byte) (*Config, error) {
ChunkSize: defaultChunkSize, ChunkSize: defaultChunkSize,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"), IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel, CompressionLevel: defaultCompressionLevel,
PruneGracePeriod: defaultPruneGracePeriod,
S3: S3Config{PartSize: defaultS3PartSize}, S3: S3Config{PartSize: defaultS3PartSize},
} }
@@ -356,6 +368,7 @@ func Parse(data []byte) (*Config, error) {
// exceeds the configured limit // exceeds the configured limit
// - Compression level must be between 1 and 19 // - Compression level must be between 1 and 19
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts // - 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. // Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error { func (c *Config) Validate() error {
@@ -405,6 +418,10 @@ func (c *Config) Validate() error {
return errBadS3PartSize return errBadS3PartSize
} }
if c.PruneGracePeriod < 0 {
return errNegativePruneGracePeriod
}
return nil return nil
} }
+38
View File
@@ -412,6 +412,44 @@ 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)
}
})
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a // TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the // relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write // working directory, so a backup run from another directory would write
+1 -3
View File
@@ -155,9 +155,7 @@ func (r *BlobRepository) UpdateFinished(
} }
// UpdateUploaded marks a blob as uploaded and records the size of the // UpdateUploaded marks a blob as uploaded and records the size of the
// stored object as its compressed size. That size can differ from the // stored object as its compressed size.
// local copy's when an identical blob was already stored with another
// compression level or other recipients.
func (r *BlobRepository) UpdateUploaded( func (r *BlobRepository) UpdateUploaded(
ctx context.Context, tx *sql.Tx, id string, storedSize int64, ctx context.Context, tx *sql.Tx, id string, storedSize int64,
) error { ) error {
+8 -3
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
@@ -302,7 +303,10 @@ func IsNotFound(err error) bool {
type ObjectInfo struct { type ObjectInfo struct {
Key string Key string
Size int64 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. // ListObjectsStream lists objects with the given prefix and returns a channel.
@@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream(
} }
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: key, Key: key,
Size: *obj.Size, Size: *obj.Size,
ModTime: aws.ToTime(obj.LastModified),
} }
} }
} }
+23 -42
View File
@@ -1641,8 +1641,7 @@ func (s *Scanner) handleBlobReady(
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blobPath := fmt.Sprintf("blobs/%s/%s/%s",
finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash) finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash)
storedSize, blobExists, err := s.uploadBlobIfNeeded( err := s.uploadBlob(ctx, blobPath, blobWithReader, startTime, result)
ctx, blobPath, blobWithReader, startTime, result)
if err != nil { if err != nil {
cleanupErr := s.cleanupBlobTempFile(blobWithReader) cleanupErr := s.cleanupBlobTempFile(blobWithReader)
@@ -1650,8 +1649,7 @@ func (s *Scanner) handleBlobReady(
fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err), cleanupErr) fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err), cleanupErr)
} }
err = s.recordBlobMetadata( err = s.recordBlobMetadata(ctx, finishedBlob, startTime)
ctx, finishedBlob, storedSize, blobExists, startTime)
if err != nil { if err != nil {
cleanupErr := s.cleanupBlobTempFile(blobWithReader) cleanupErr := s.cleanupBlobTempFile(blobWithReader)
@@ -1675,46 +1673,33 @@ func (s *Scanner) handleBlobReady(
return nil return nil
} }
// uploadBlobIfNeeded uploads the blob to storage if it doesn't already // uploadBlob uploads the blob to storage. A blob already there, left by
// exist. It returns the size of the object in storage, which for an // an earlier run stopped before its manifest, is uploaded again rather
// existing object can differ from the local copy's, and whether it // than skipped: keeping its old time would let a prune elsewhere delete
// already existed. // it as an unreferenced blob older than prune_grace_period before this
func (s *Scanner) uploadBlobIfNeeded( // backup's manifest lists it.
func (s *Scanner) uploadBlob(
ctx context.Context, ctx context.Context,
blobPath string, blobPath string,
blobWithReader *blob.WithReader, blobWithReader *blob.WithReader,
startTime time.Time, startTime time.Time,
result *ScanResult, result *ScanResult,
) (int64, bool, error) { ) error {
finishedBlob := blobWithReader.FinishedBlob finishedBlob := blobWithReader.FinishedBlob
// Check if blob already exists (deduplication after restart)
destination := s.storage.Info().Location destination := s.storage.Info().Location
stored, err := s.storage.Stat(ctx, blobPath)
if err == nil {
log.Info("Blob already exists in storage, skipping upload",
"hash", finishedBlob.Hash,
"size", humanize.Bytes(safeUint64(finishedBlob.Compressed)))
s.ui.Infof("Blob %s (%s) already exists at %s. Skipping upload.",
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed),
s.ui.Path(destination))
return stored.Size, true, nil
}
s.ui.Beginf("Uploading blob %s (%s) to %s.", s.ui.Beginf("Uploading blob %s (%s) to %s.",
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed), s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed),
s.ui.Path(destination)) s.ui.Path(destination))
progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime) progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime)
err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader, err := s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader,
finishedBlob.Compressed, progressCallback) finishedBlob.Compressed, progressCallback)
if err != nil { if err != nil {
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err) log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
return 0, false, fmt.Errorf("uploading blob to storage: %w", err) return fmt.Errorf("uploading blob to storage: %w", err)
} }
uploadDuration := time.Since(startTime) uploadDuration := time.Since(startTime)
@@ -1745,7 +1730,7 @@ func (s *Scanner) uploadBlobIfNeeded(
stats.BytesUploaded.Add(finishedBlob.Compressed) stats.BytesUploaded.Add(finishedBlob.Compressed)
} }
return finishedBlob.Compressed, false, nil return nil
} }
// makeUploadProgressCallback creates a progress callback for blob uploads. // makeUploadProgressCallback creates a progress callback for blob uploads.
@@ -1815,8 +1800,6 @@ func (s *Scanner) makeUploadProgressCallback(
func (s *Scanner) recordBlobMetadata( func (s *Scanner) recordBlobMetadata(
ctx context.Context, ctx context.Context,
finishedBlob *blob.FinishedBlob, finishedBlob *blob.FinishedBlob,
storedSize int64,
blobExists bool,
startTime time.Time, startTime time.Time,
) error { ) error {
finishedBlobID, err := types.ParseBlobID(finishedBlob.ID) finishedBlobID, err := types.ParseBlobID(finishedBlob.ID)
@@ -1828,7 +1811,7 @@ func (s *Scanner) recordBlobMetadata(
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error { return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
err := s.repos.Blobs.UpdateUploaded( err := s.repos.Blobs.UpdateUploaded(
txCtx, tx, finishedBlob.ID, storedSize) txCtx, tx, finishedBlob.ID, finishedBlob.Compressed)
if err != nil { if err != nil {
return fmt.Errorf("updating blob upload timestamp: %w", err) return fmt.Errorf("updating blob upload timestamp: %w", err)
} }
@@ -1839,19 +1822,17 @@ func (s *Scanner) recordBlobMetadata(
return fmt.Errorf("adding blob to snapshot: %w", err) return fmt.Errorf("adding blob to snapshot: %w", err)
} }
if !blobExists { upload := &database.Upload{
upload := &database.Upload{ BlobHash: finishedBlob.Hash,
BlobHash: finishedBlob.Hash, SnapshotID: s.snapshotID,
SnapshotID: s.snapshotID, UploadedAt: startTime,
UploadedAt: startTime, Size: finishedBlob.Compressed,
Size: finishedBlob.Compressed, DurationMs: uploadDuration.Milliseconds(),
DurationMs: uploadDuration.Milliseconds(), }
}
err := s.repos.Uploads.Create(txCtx, tx, upload) err = s.repos.Uploads.Create(txCtx, tx, upload)
if err != nil { if err != nil {
return fmt.Errorf("recording upload metrics: %w", err) return fmt.Errorf("recording upload metrics: %w", err)
}
} }
return nil return nil
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"reflect" "reflect"
"sort" "sort"
"testing" "testing"
"time"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
) )
@@ -19,15 +20,17 @@ import (
// constructor, so the contract is defined once. // constructor, so the contract is defined once.
// //
// It exercises the public Storer interface: round-trip, stat, list with // It exercises the public Storer interface: round-trip, stat, list with
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on // prefix filtering, the modification time ListStream reports, overwrite,
// Get and Stat. Each section takes its own fresh backend instance, so the // delete, delete-of-missing, and not-found on Get and Stat. Each section
// order of sections never matters and no section sees another's objects. // 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) { func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
t.Helper() t.Helper()
conformanceRoundTrip(t, newStorer(t)) conformanceRoundTrip(t, newStorer(t))
conformanceOverwrite(t, newStorer(t)) conformanceOverwrite(t, newStorer(t))
conformanceList(t, newStorer(t)) conformanceList(t, newStorer(t))
conformanceListStreamModTime(t, newStorer(t))
conformanceDelete(t, newStorer(t)) conformanceDelete(t, newStorer(t))
conformanceNotFound(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 // conformanceDelete checks that Delete removes an object and that deleting
// a missing key is not an error. // a missing key is not an error.
func conformanceDelete(t *testing.T, s storage.Storer) { 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 // Normalize path separators
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/") relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: relPath, Key: relPath,
Size: info.Size(), 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 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 // Create the rclone filesystem
fsys, err := fs.NewFs(ctx, rclonePath) fsys, err := fs.NewFs(ctx, rclonePath)
if err != nil { if err != nil {
@@ -211,8 +219,9 @@ func (r *RcloneStorer) ListStream(
if prefix == "" || strings.HasPrefix(key, prefix) { if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: key, Key: key,
Size: obj.Size(), Size: obj.Size(),
ModTime: obj.ModTime(ctx),
} }
} }
}) })
+75
View File
@@ -5,11 +5,17 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config" "github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap" "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) 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) { for info := range s.client.ListObjectsStream(ctx, prefix, false) {
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: info.Key, Key: info.Key,
Size: info.Size, Size: info.Size,
Err: info.Err, ModTime: info.ModTime,
Err: info.Err,
} }
} }
}() }()
+7 -2
View File
@@ -14,6 +14,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"time"
) )
// ErrNotFound is returned when an object does not exist. // ErrNotFound is returned when an object does not exist.
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
type ObjectInfo struct { type ObjectInfo struct {
Key string // Object key/path Key string // Object key/path
Size int64 // Size in bytes 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. // Info provides human-readable storage configuration.
@@ -67,7 +71,8 @@ type Storer interface {
// For large result sets, prefer ListStream. // For large result sets, prefer ListStream.
List(ctx context.Context, prefix string) ([]string, error) 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. // The channel is closed when listing completes.
// If an error occurs during listing, the final item will have Err set. // If an error occurs during listing, the final item will have Err set.
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
+5 -4
View File
@@ -586,10 +586,11 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
ctx, t, cfg, inner, repos, db, fs, restoreDir, testFiles) ctx, t, cfg, inner, repos, db, fs, restoreDir, testFiles)
} }
// assertRetryConsistentAndRestorable checks the end state after the retry // assertRetryConsistentAndRestorable checks the end state after a backup
// backup in TestBackupCompletesOnlyAfterMetadataExport: the interrupted // retried on the same index once an earlier run stopped at its metadata
// snapshot is pruned, exactly one completed snapshot remains, its metadata // export: the interrupted snapshot is pruned, exactly one completed
// is at the destination, and it restores to the original tree. // snapshot remains, its metadata is at the destination, and it restores
// to the original tree.
func assertRetryConsistentAndRestorable( func assertRetryConsistentAndRestorable(
ctx context.Context, t *testing.T, cfg *config.Config, ctx context.Context, t *testing.T, cfg *config.Config,
inner storage.Storer, repos *database.Repositories, db *database.DB, inner storage.Storer, repos *database.Repositories, db *database.DB,
+4 -4
View File
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
return err return err
} }
for _, size := range allBlobs { for _, blob := range allBlobs {
result.TotalBlobCount++ result.TotalBlobCount++
result.TotalBlobSize += size result.TotalBlobSize += blob.Size
} }
// A blob named only by a manifest that could not be read, or by one // 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 orphanedSize int64
) )
for hash, size := range allBlobs { for hash, blob := range allBlobs {
if _, referenced := referencedBlobs[hash]; !referenced { if _, referenced := referencedBlobs[hash]; !referenced {
orphanedCount++ orphanedCount++
orphanedSize += size orphanedSize += blob.Size
} }
} }
+59 -17
View File
@@ -5,8 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
) )
// PruneOptions contains options for the prune command // PruneOptions contains options for the prune command
@@ -163,6 +165,10 @@ type PruneBlobsResult struct {
BlobsDeleted int `json:"blobs_deleted"` BlobsDeleted int `json:"blobs_deleted"`
BlobsFailed int `json:"blobs_failed,omitempty"` BlobsFailed int `json:"blobs_failed,omitempty"`
BytesFreed int64 `json:"bytes_freed"` 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 // Prune removes orphaned data from the local index database AND
@@ -208,9 +214,15 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err 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 { if len(unreferencedBlobs) == 0 {
log.Info("No unreferenced blobs found") log.Info("No unreferenced blobs found")
@@ -344,11 +356,12 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
return snapshotIDs, nil return snapshotIDs, nil
} }
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage // listAllRemoteBlobs returns a map of all blob hashes in remote storage
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) { // to their listing entries.
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
log.Info("Listing all blobs in storage") 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/") blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
for object := range blobObjectCh { for object := range blobObjectCh {
@@ -372,7 +385,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
continue continue
} }
allBlobs[parts[3]] = object.Size allBlobs[parts[3]] = object
} }
log.Info("Found blobs in storage", "count", len(allBlobs)) log.Info("Found blobs in storage", "count", len(allBlobs))
@@ -380,30 +393,59 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
return allBlobs, nil return allBlobs, nil
} }
// findUnreferencedBlobs returns blob hashes not referenced by any // findUnreferencedBlobs returns the hashes of the blobs no manifest
// manifest and their total size. // 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( func (v *Vaultik) findUnreferencedBlobs(
allBlobs map[string]int64, referenced map[string]bool, allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
) ([]string, int64) { ) ([]string, int64, int) {
var ( var (
unreferenced []string unreferenced []string
totalSize int64 totalSize int64
tooNew int
) )
for hash, size := range allBlobs { cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
if !referenced[hash] {
unreferenced = append(unreferenced, hash) for hash, blob := range allBlobs {
totalSize += size 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 // deleteUnreferencedBlobs deletes the given blobs from storage and
// populates the result. // populates the result.
func (v *Vaultik) deleteUnreferencedBlobs( 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") log.Info("Deleting unreferenced blobs")
@@ -418,7 +460,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
} }
result.BlobsDeleted++ result.BlobsDeleted++
result.BytesFreed += allBlobs[hash] result.BytesFreed += allBlobs[hash].Size
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 { if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
log.Info("Deletion progress", log.Info("Deletion progress",
+249
View File
@@ -0,0 +1,249 @@
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)
}
// TestPruneDuringBackupKeepsBlobsOfKilledRun covers a backup that builds
// the blobs an earlier run, stopped before its manifest upload, left on
// the destination store, older than the grace period by the time of the
// backup. A prune from another host runs before the backup's manifest
// upload, and the backup must then restore.
func TestPruneDuringBackupKeepsBlobsOfKilledRun(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()
testFiles := writeFaultSourceTree(t, fs, dataDir)
cfg := faultTestConfig()
cfg.IndexPath = dbPath
cfg.ChunkSize = config.Size(faultChunkSize)
cfg.Snapshots = map[string]config.SnapshotConfig{
"data": {Paths: []string{dataDir}},
}
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
otherHostCfg := faultTestConfig()
otherHostCfg.PruneGracePeriod = time.Hour
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
var (
killedRun = true
pruned bool
pruneErr error
)
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if !strings.HasSuffix(key, "/manifest.json.zst") {
return faultstore.PutNormal
}
if killedRun {
return faultstore.PutFail
}
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)
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
opts := &vaultik.SnapshotCreateOptions{Cron: true}
require.Error(t, v.CreateSnapshot(opts),
"the first run must stop at its manifest upload")
beforeGracePeriod := time.Now().Add(-2 * otherHostCfg.PruneGracePeriod)
for object := range inner.ListStream(ctx, "blobs/") {
require.NoError(t, object.Err)
require.NoError(t, os.Chtimes(filepath.Join(storeDir, object.Key),
beforeGracePeriod, beforeGracePeriod))
}
killedRun = false
require.NoError(t, v.CreateSnapshot(opts))
require.True(t, pruned, "the prune must run before the manifest upload")
require.NoError(t, pruneErr)
assertRetryConsistentAndRestorable(
ctx, t, cfg, inner, repos, db, 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 { type testStorer struct {
mu sync.Mutex mu sync.Mutex
data map[string][]byte data map[string][]byte
// putTimes holds when Put stored each object; ListStream reports it.
putTimes map[string]time.Time
} }
func newTestStorer() *testStorer { func newTestStorer() *testStorer {
return &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.data[key] = data
s.putTimes[key] = time.Now().UTC()
return nil return nil
} }
@@ -87,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.data, key) delete(s.data, key)
delete(s.putTimes, key)
return nil return nil
} }
@@ -120,8 +125,9 @@ func (s *testStorer) ListStream(
for key, data := range s.data { for key, data := range s.data {
if prefix == "" || strings.HasPrefix(key, prefix) { if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- storage.ObjectInfo{ ch <- storage.ObjectInfo{
Key: key, Key: key,
Size: int64(len(data)), Size: int64(len(data)),
ModTime: s.putTimes[key],
} }
} }
} }
+9 -9
View File
@@ -14,13 +14,13 @@ import (
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
// TestVerifyPassesWhenBackupReusesStoredBlob backs up a file, then backs it // TestVerifyPassesWhenBackupRebuildsStoredBlob backs up a file, then backs
// up again from an empty local index, as after `database delete`, with an // it up again from an empty local index, as after `database delete`, with
// extra age recipient. The second backup builds the same blob, finds it // an extra age recipient. The second backup builds the same blob, already
// already stored and skips the upload, but its own copy is larger, because // stored, and uploads it again; its copy is larger, because age writes a
// age writes a header entry for each recipient. The second snapshot must // header entry for each recipient. The second snapshot must record the
// record the size of the stored object, so shallow and deep verify pass. // size of the object now stored, so shallow and deep verify pass.
func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) { func TestVerifyPassesWhenBackupRebuildsStoredBlob(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -58,8 +58,8 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(), snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
env.cfg.Hostname, "second", dataDir, secondDBPath) env.cfg.Hostname, "second", dataDir, secondDBPath)
require.Positive(t, result.BlobsCreated) require.Positive(t, result.BlobsCreated)
require.Zero(t, result.BlobsUploaded, require.Equal(t, result.BlobsCreated, result.BlobsUploaded,
"the second backup should find every blob already stored") "the second backup should upload every blob again")
require.NoError(t, env.db.Close()) require.NoError(t, env.db.Close())