4 Commits
Author SHA1 Message Date
sneak ef0dc84ccd Make snapshot list report a changed destination instead of advising prune (closes #317)
check / check (push) Canceled after 0s
When the local index is bound to a different destination than the
configured one, snapshot list now gives the warning prune, snapshot
purge and snapshot remove refuse with: both destinations and the two
ways out (revert the configuration, or run vaultik database delete).
Below the table it replaces the local-only note and its prune advice.
With --json it goes to stderr through the logger, so stdout stays the
document, whose local rows show remote_present false.

Reading the binding moves out of EnsureStorageBinding into
storageBinding, which records nothing, so the read-only list can call
it without binding a fresh index.

Model: opus-5-5
2026-10-14 00:00:41 +00:00
clawbot 66ea7f5f45 Free a cached blob once no file still to restore needs it (closes #324)
check / check (push) Canceled after 0s
A restore of selected paths, or one where files failed under
--skip-errors, kept the blobs those files share in the temporary cache
until it ended: the sweeper freed a blob only once every file in the
snapshot database that uses it had been restored, and files outside the
selection or that failed never are.

The sweeper now asks the restore plan, through pendingFileUsesBlob,
whether any pending file still uses a cached blob. The plan holds only
this run's files and drops each one once it is restored or has failed.

The new test backs up 16 KiB files so it stays quick; the locality
backup helper takes the file size for that.

Model: opus-5-5
2026-10-14 01:47:48 +02:00
clawbot dd3667c8e9 Keep the whole startup error when it contains "): " (closes #320)
check / check (push) Canceled after 0s
A startup error was cut at its last "): " to drop the annotations fx
adds, so the blob_size_limit message, which itself contains
"(... size spread): ", lost everything but "need at least N bytes".
cleanStartupError now uses dig.RootCause, from the library fx is built
on, which removes exactly those annotations and returns the error
underneath unchanged.

The test starts a real fx app on a config whose blob_size_limit is below
the minimum. The old cases fed it plain strings that only looked like fx
output; such an error is now rightly left whole, so they are replaced.

go.uber.org/dig, already required through fx, becomes a direct
requirement in go.mod.

Model: opus-5-5
2026-10-14 01:34:38 +02:00
clawbot 30285c7a8c Silence prune and snapshot removal status under --cron and --quiet (closes #294)
check / check (push) Canceled after 0s
PruneBlobs, CleanupLocalSnapshots, RemoveSnapshot, RemoveAllSnapshots
and snapshot purge wrote their status lines straight to stdout, so
`snapshot create --cron --prune` printed on every successful run and
--quiet did not silence `prune`, `snapshot purge`, `snapshot remove` or
`remote nuke`. Those lines now go through internal/ui, which drops them
under --cron and --quiet; warnings, errors and --json documents still
print.

The confirmation prompts still write straight to stdout, as the
`database delete` prompt does, together with what they ask to delete:
the snapshot list before the purge prompt, and the blob count and size
before the prune prompt. With --force those lines are status lines.

The two purge test fixtures gained the UI writer production always sets.

Model: opus-5-5
2026-10-14 01:10:43 +02:00
19 changed files with 613 additions and 215 deletions
+4 -2
View File
@@ -718,8 +718,10 @@ the `NO_COLOR` environment variable is unset (https://no-color.org/).
reports and tables (`version`, `info`, `remote info`, `snapshot
verify`, `snapshot list`) but never the machine-consumed `config get`
value or the `--json` documents, which a script depends on. The
`database delete` confirmation prompt is also written this way and
always shown: it is an interactive exchange the operator must see.
confirmation prompts of `database delete`, `prune`, `snapshot purge`
and `snapshot remove`, each with what it asks to delete, are also
written this way and always shown: they are an interactive exchange
the operator must see.
`internal/ui` writes to stdout; it is the output the user asked for. The
exceptions are the startup banner and the error a failed command ends
+1 -1
View File
@@ -22,6 +22,7 @@ require (
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
@@ -256,7 +257,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
+6 -10
View File
@@ -8,12 +8,12 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/adrg/xdg"
"github.com/spf13/cobra"
"go.uber.org/dig"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
@@ -146,16 +146,12 @@ func (e *startupError) Error() string {
// could not build arguments for function "Y" (file:line): failed to build U:
// received non-nil error from function "Z" (file:line): <real error>
//
// Users care about the real error, not the DI plumbing. We strip everything
// up through the last "): " (which is always the close-paren of an fx
// function-location annotation followed by the wrapped error).
// Users care about the real error, not the DI plumbing. dig.RootCause,
// from the library fx is built on, unwraps those layers and returns the
// real error whole. Cutting the text at "): " instead would also cut a
// real error that contains it, such as the blob_size_limit one.
func cleanStartupError(err error) error {
msg := err.Error()
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
msg = msg[idx+3:]
}
return &startupError{msg: msg}
return &startupError{msg: dig.RootCause(err).Error()}
}
// RunApp starts the fx application, blocks until it is asked to stop, and
+47 -40
View File
@@ -2,58 +2,65 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
import (
"errors"
"os"
"path/filepath"
"testing"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/pidlock"
)
// TestCleanStartupError starts a real fx app whose config has a
// blob_size_limit below the minimum. fx puts annotations naming the
// functions it was building in front of the config error; only those
// may be removed, although the config error itself contains "): ".
func TestCleanStartupError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{
name: "real fx error chain",
in: `could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
`failed to build *vaultik.Vaultik: ` +
`could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
`failed to build storage.Storer: ` +
`received non-nil error from function ` +
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
},
{
name: "no fx wrapping",
in: "plain error",
want: "plain error",
},
{
name: "single fx wrapping",
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
want: "underlying problem",
},
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte("snapshots:\n"+
" test:\n"+
" paths: [/tmp/vaultik-test-source]\n"+
"storage_url: file:///tmp/vaultik-test-storage\n"+
"chunk_size: 10MB\n"+
"blob_size_limit: 30MB\n"), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
app := fx.New(
fx.NopLogger,
fx.Supply(config.Path(path)),
config.Module,
fx.Invoke(func(*config.Config) {}),
)
//nolint:err113 // test constructs errors from table input
got := cleanStartupError(errors.New(tt.in)).Error()
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
err = app.Start(t.Context())
if err == nil {
t.Fatal("app started; want a blob_size_limit error")
}
want := "failed to load config: invalid config: blob_size_limit must " +
"be at least the largest chunk the chunker can emit (chunk_size " +
"times the FastCDC size spread): need at least 40000000 bytes"
got := cleanStartupError(err).Error()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
// shown unchanged.
func TestCleanStartupErrorNotFromFx(t *testing.T) {
t.Parallel()
//nolint:err113 // a one-off error standing in for a startup failure
got := cleanStartupError(errors.New("plain error")).Error()
if got != "plain error" {
t.Errorf("got %q, want %q", got, "plain error")
}
}
+123
View File
@@ -0,0 +1,123 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flagCron is used to build the argument vectors below.
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.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
hash := strings.Repeat("b", 64)
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
return path
}
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
// `snapshot create --prune` under --cron or --quiet prints nothing on
// stdout or stderr, whether or not the prune finds a blob to delete.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
plantBlob bool
}{
{
name: "cron, no blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
},
{
name: "cron, blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
plantBlob: true,
},
{
name: "cron with keep-newer-than",
args: []string{
cmdSnapshot, cmdCreate, flagCron, flagPrune,
flagKeepNewerThan, "4w",
},
plantBlob: true,
},
{
name: "quiet",
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
plantBlob: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
var blob string
if testCase.plantBlob {
blob = plantUnreferencedBlob(t, storeDir)
}
code, stdout, stderr := runEntry(t,
append([]string{flagConfig, configPath}, testCase.args...)...)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.plantBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
// under --quiet prints nothing on stdout or stderr, both when there is
// nothing to remove and when it removes a stale local snapshot record
// and an unreferenced blob.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryPruneQuietIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
staleRecordAndBlob bool
}{
{name: "nothing to remove"},
{name: "stale record and blob to remove", staleRecordAndBlob: true},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
var blob string
if testCase.staleRecordAndBlob {
// writeHermeticPruneConfig puts the store beside the config.
blob = plantUnreferencedBlob(t,
filepath.Join(filepath.Dir(configPath), "store"))
}
code, stdout, stderr := runEntry(t,
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.staleRecordAndBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
+3 -3
View File
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
// internally and will fall back to LRU eviction if curBytes exceeds
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
// eviction itself via Delete() through restoreSweeper, which deletes
// each blob the moment every file that references its chunks has been
// written. LRU never fires under that configuration; it is kept as a
// safety net for callers that don't manage eviction themselves.
// each blob once no file still to be restored in this run needs it.
// LRU never fires under that configuration; it is kept as a safety net
// for callers that don't manage eviction themselves.
//
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
// tests to assert that the restore code path uses ReadAt rather than
+9 -4
View File
@@ -204,7 +204,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return v.outputPruneBlobsJSON(result)
}
v.printlnStdout("No unreferenced blobs to remove.")
v.UI.Infof("No unreferenced blobs to remove.")
return nil
}
@@ -212,12 +212,17 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
log.Info("Found unreferenced blobs",
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
if !opts.JSON {
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
if opts.Force && !opts.JSON {
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
len(unreferencedBlobs), ubytes(totalSize))
}
if !opts.Force && !opts.JSON {
// The count, the prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
len(unreferencedBlobs), ubytes(totalSize))
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string
@@ -244,7 +249,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err
}
} else {
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
v.UI.Infof("Deleted %d blob(s) totaling %s.",
result.BlobsDeleted, ubytes(result.BytesFreed))
}
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -58,13 +59,16 @@ func setupConsistencyTest(
require.NoError(t, err)
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Storage: mockStorage,
Repositories: repos,
DB: db,
Stdout: &bytes.Buffer{},
Stdout: stdout,
Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
+2
View File
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -86,6 +87,7 @@ func setupPurgeTest(
Stdout: stdout,
Stderr: stderr,
Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
+148
View File
@@ -0,0 +1,148 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
// --keep-latest --force` under --quiet deletes the older snapshot and
// prints nothing.
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
}))
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
assert.Empty(t, out.String())
}
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
// purge --keep-latest` without --force still lists the snapshots its
// prompt asks to delete under --quiet.
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
}))
assert.Contains(t, out.String(), snapHomeT0)
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
assert.Len(t, listRemainingSnapshots(t, v), 2)
}
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
// --force still states the count and total size of the blobs its prompt
// asks to delete under --quiet.
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.v.UI.SetQuiet(true)
// The store holds no manifest, so nothing references this blob.
addBlob(t, env.store.testStorer, testBlobHashA)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
assert.Contains(t, env.stdout.String(),
"Found 1 unreferenced blob(s) totaling")
assert.Contains(t, env.stdout.String(),
"Delete 1 unreferenced blob(s)? [y/N]")
}
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
// under --quiet prints nothing with --dry-run or with --force, and that
// the --json document still prints.
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.UI.SetQuiet(true)
return tv, store
}
tv, store := newQuietRemover()
_, err := tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{DryRun: true})
require.NoError(t, err)
assert.Empty(t, tv.Stdout.String(),
"--dry-run must print nothing under --quiet")
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String(),
"--force must print nothing under --quiet")
tv, _ = newQuietRemover()
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{JSON: true})
require.NoError(t, err)
var result vaultik.RemoveResult
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
"the --json document must still print under --quiet")
assert.True(t, result.RemoteRemoved)
}
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
// under --quiet prints nothing while it removes a snapshot's metadata
// and its blobs.
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
v.UI.SetQuiet(true)
require.NoError(t, v.NukeRemote(true))
assert.Empty(t, out.String())
}
+2 -2
View File
@@ -350,7 +350,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
}
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
@@ -504,7 +504,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
assert.Equal(t, initialCount, store.keyCount())
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
}
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
+8 -10
View File
@@ -323,12 +323,6 @@ func (v *Vaultik) restoreAllFiles(
_ = blobCache.Close()
}()
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob whose remaining file references
// are all already restored.
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
if err != nil {
return nil, err
@@ -340,6 +334,12 @@ func (v *Vaultik) restoreAllFiles(
return nil, fmt.Errorf("building restore plan: %w", err)
}
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob that no pending file in the
// plan references.
sweeper := newRestoreSweeper(plan, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
// Ranging over files rather than the map reports them in a stable order.
for _, file := range files {
if planErr, ok := unplanned[file.ID]; ok {
@@ -427,10 +427,8 @@ func (v *Vaultik) runRestoreLoop(
continue
}
// Record the file as restored so the sweeper can free blobs
// once all referencing files are done, and drop it from the
// plan's indexes so future picks ignore it.
session.sweeper.fileRestored(fileID.String())
// Drop the file from the plan's indexes so future picks ignore
// it and the sweeper can free blobs no pending file needs.
plan.finishFile(fileID)
processed++
+2 -1
View File
@@ -74,7 +74,8 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
buildLocalityFixture(t, fs, dataDir)
cfg, storer, snapshotID := setupLocalityBackup(
context.Background(), t, fs, dataDir, storeDir, dbPath)
context.Background(), t, fs, dataDir, storeDir, dbPath,
localitySourceBytes)
// Point the "" temp paths (the blob cache directory and the
// snapshot-database directory) at a private directory so the test can
+15 -11
View File
@@ -63,6 +63,10 @@ type localityCopy struct {
data []byte
}
// localitySourceBytes is the size of each source file of the locality
// fixture.
const localitySourceBytes = 1024 * 1024
// buildLocalityFixture writes the adversarial source layout described
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
@@ -73,7 +77,6 @@ func buildLocalityFixture(
t.Helper()
const (
srcBytes = 1024 * 1024
srcCount = 15
blobsCount = 3
perBlob = srcCount / blobsCount
@@ -83,7 +86,7 @@ func buildLocalityFixture(
for i := range srcCount {
s := &localitySource{
path: fmt.Sprintf("src-%03d.bin", i+1),
data: randomBytes(t, srcBytes),
data: randomBytes(t, localitySourceBytes),
}
sources[i] = s
require.NoError(t, afero.WriteFile(
@@ -115,19 +118,20 @@ func buildLocalityFixture(
// setupLocalityBackup runs the backup half of the locality test: a
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
// files pack into 3 blobs. The index database is closed before returning
// so the restore half runs from remote bytes only.
// files of srcBytes pack into 3 blobs. The index database is closed
// before returning so the restore half runs from remote bytes only.
func setupLocalityBackup(
ctx context.Context, t *testing.T, fs afero.Fs,
dataDir, storeDir, dbPath string,
dataDir, storeDir, dbPath string, srcBytes int64,
) (*config.Config, *storage.FileStorer, string) {
t.Helper()
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
// per blob, yielding 3 blobs from 15 source files.
chunkSize := int64(4 * 1024 * 1024)
maxBlobSize := int64(5 * 1024 * 1024)
// An average chunk size of four times srcBytes makes the minimum
// chunk srcBytes, so each source file becomes one chunk. A blob limit
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
// blobs from 15 source files.
chunkSize := 4 * srcBytes
maxBlobSize := 5 * srcBytes
storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
@@ -208,7 +212,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
ctx := context.Background()
cfg, storer, snapshotID := setupLocalityBackup(
ctx, t, fs, dataDir, storeDir, dbPath)
ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes)
// Wrap the storer so we can count downloads per blob key.
counter := newCountingStorer(storer)
+8 -1
View File
@@ -31,7 +31,8 @@ var (
// with no further downloads.
// - blobFiles: for each blob, the set of pending files referencing
// it. Used to short-circuit "when this blob lands, which files
// become ready" without a global scan.
// become ready" without a global scan, and by the sweeper to tell
// whether a cached blob is still needed.
type restorePlan struct {
fileBlobs map[types.FileID]map[string]struct{}
blobFiles map[string]map[types.FileID]struct{}
@@ -237,6 +238,12 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
return out
}
// pendingFileUsesBlob reports whether any pending file references the
// named blob, whether or not the blob is cached.
func (p *restorePlan) pendingFileUsesBlob(blobHash string) bool {
return len(p.blobFiles[blobHash]) > 0
}
// hasPending reports whether any unfinished files remain.
func (p *restorePlan) hasPending() bool {
return len(p.fileBlobs) > 0
+9 -9
View File
@@ -63,15 +63,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
t.Cleanup(func() { _ = cache.Close() })
v := &Vaultik{ctx: ctx}
session := &restoreSession{
v: v,
ctx: ctx,
repos: repos,
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
result: &RestoreResult{},
}
// A file that is still pending but whose uncached-blob set is empty
// and which was never queued as ready: the loop can neither restore
// nor download it. This is the abandonment the guard must catch.
@@ -83,6 +74,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
cached: map[string]struct{}{},
}
v := &Vaultik{ctx: ctx}
session := &restoreSession{
v: v,
ctx: ctx,
repos: repos,
sweeper: newRestoreSweeper(plan, cache, 1),
result: &RestoreResult{},
}
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
require.ErrorIs(t, err, errRestoreIncomplete)
}
+14 -88
View File
@@ -1,46 +1,30 @@
package vaultik
import (
"context"
"fmt"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
)
// restoreSweeper frees cached blobs once all files that reference any of
// their chunks have been restored. It works as follows:
// restoreSweeper frees cached blobs once no file still to be restored in
// this run needs them. It works as follows:
//
// 1. Callers add a file's ID to an in-memory restored set via
// fileRestored once the file is fully written to disk.
// 2. After each chunk is restored, chunkRestored accumulates a running
// 1. After each chunk is restored, chunkRestored accumulates a running
// byte count.
// 3. When the accumulator crosses a threshold (one hundredth of the
// 2. When the accumulator crosses a threshold (one hundredth of the
// configured blob size — so a sweep runs about a hundred times per
// blob's worth of restored bytes), the sweeper iterates every key in
// the cache. For each cached blob it asks the DB which files
// reference any chunk in that blob, then compares that list against
// the in-memory restored set. If any referencing file is missing
// from the set the blob is kept; otherwise the cache entry is
// deleted.
// the cache and deletes each blob that the restore plan no longer
// needs.
//
// All DB reads happen against the snapshot's temporary metadata DB,
// which is local, indexed, and not under contention — the queries are
// cheap and run at most once per blob per sweep interval.
// The plan drops a file once it is restored or has failed, and holds
// only the files this run restores, so files outside the requested paths
// never keep a blob.
type restoreSweeper struct {
ctx context.Context //nolint:containedctx // ctx bound at construction by design
repos *database.Repositories
plan *restorePlan
cache *blobDiskCache
threshold int64
bytesAccum int64
restored map[string]struct{}
}
// newRestoreSweeper returns a sweeper that triggers eviction every
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
func newRestoreSweeper(
ctx context.Context,
repos *database.Repositories,
plan *restorePlan,
cache *blobDiskCache,
threshold int64,
) *restoreSweeper {
@@ -49,21 +33,12 @@ func newRestoreSweeper(
}
return &restoreSweeper{
ctx: ctx,
repos: repos,
plan: plan,
cache: cache,
threshold: threshold,
restored: make(map[string]struct{}),
}
}
// fileRestored records a file as fully restored. After this call, any
// blob whose only remaining references come from files in the restored
// set may be evicted on the next sweep.
func (s *restoreSweeper) fileRestored(fileID string) {
s.restored[fileID] = struct{}{}
}
// chunkRestored accounts n bytes against the sweep threshold and runs a
// sweep if the threshold has been crossed since the last sweep.
func (s *restoreSweeper) chunkRestored(n int64) {
@@ -76,60 +51,11 @@ func (s *restoreSweeper) chunkRestored(n int64) {
s.sweep()
}
// sweep deletes any cached blob whose chunks are no longer referenced
// by an unrestored file. Per-blob DB failures are logged and the blob
// is kept — we'd rather hold a blob longer than risk a re-download.
// sweep deletes every cached blob that no pending file references.
func (s *restoreSweeper) sweep() {
for _, blobHash := range s.cache.Keys() {
needed, err := s.blobStillNeeded(blobHash)
if err != nil {
log.Debug("sweeper referencing-files query failed",
"blob_hash", blobHash[:16], "error", err)
continue
}
if !needed {
if !s.plan.pendingFileUsesBlob(blobHash) {
s.cache.Delete(blobHash)
}
}
}
// blobStillNeeded returns true if any file that references a chunk in
// this blob has not yet been restored. On any error the function
// returns true — keeping the blob is always the safe answer because we
// can't prove we're done with it.
func (s *restoreSweeper) blobStillNeeded(blobHash string) (bool, error) {
rows, err := s.repos.DB().Conn().QueryContext(s.ctx, `
SELECT DISTINCT fc.file_id
FROM file_chunks fc
JOIN blob_chunks bc ON bc.chunk_hash = fc.chunk_hash
JOIN blobs b ON b.id = bc.blob_id
WHERE b.blob_hash = ?
`, blobHash)
if err != nil {
return true, fmt.Errorf("querying referencing files: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var fileID string
err := rows.Scan(&fileID)
if err != nil {
return true, fmt.Errorf("scanning file_id: %w", err)
}
if _, ok := s.restored[fileID]; !ok {
return true, nil
}
}
err = rows.Err()
if err != nil {
return true, err
}
return false, nil
}
+163
View File
@@ -0,0 +1,163 @@
package vaultik //nolint:testpackage // reads the restore blob cache's peak size
import (
"context"
"fmt"
"io"
"path/filepath"
"testing"
"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/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/ui"
)
// TestRestoreFreesBlobsNoPendingFileNeeds asserts that the restore cache
// frees a blob once no file still to be restored in this run needs it.
// Every original file has a copy with the same content, so every blob
// holds chunks of copies: a restore that leaves the copies out of its
// paths, or fails on them, must not keep the blobs for them.
func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
// Small files keep the backup and both restores quick.
const fileBytes = 16 * 1024
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
originalsDir := filepath.Join(dataDir, "originals")
copiesDir := filepath.Join(dataDir, "copies")
storeDir := filepath.Join(tempDir, "remote")
dbPath := filepath.Join(tempDir, "index.sqlite")
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
ctx := context.Background()
cfg, storer, snapshotID := setupLocalityBackup(
ctx, t, fs, dataDir, storeDir, dbPath, fileBytes)
t.Run("restore by path", func(t *testing.T) {
t.Parallel()
restoreDir := filepath.Join(tempDir, "by-path")
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
&RestoreOptions{
SnapshotID: snapshotID,
TargetDir: restoreDir,
Paths: []string{originalsDir},
})
require.NoError(t, err)
requireRestoredFiles(t, fs, restoreDir, originals)
assert.LessOrEqualf(t, peak, 1,
"peak cached blobs was %d; blobs were kept for unselected files",
peak)
})
t.Run("restore with failed files", func(t *testing.T) {
t.Parallel()
restoreDir := filepath.Join(tempDir, "with-failures")
// A regular file where the copies directory belongs makes every
// copy fail to restore.
blocker := filepath.Join(restoreDir, copiesDir)
require.NoError(t, fs.MkdirAll(filepath.Dir(blocker), 0o755))
require.NoError(t, afero.WriteFile(fs, blocker, nil, 0o644))
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
&RestoreOptions{
SnapshotID: snapshotID,
TargetDir: restoreDir,
SkipErrors: true,
})
require.ErrorIs(t, err, errFilesFailedRestore)
requireRestoredFiles(t, fs, restoreDir, originals)
assert.LessOrEqualf(t, peak, 1,
"peak cached blobs was %d; blobs were kept for failed files", peak)
})
}
// writeFilesWithCopies writes 15 random files of fileBytes into
// originalsDir and a copy of each into copiesDir. With
// setupLocalityBackup given the same size, each file is one chunk and the
// 15 chunks fill 3 blobs. Returns the originals' content by path.
func writeFilesWithCopies(
t *testing.T, fs afero.Fs, originalsDir, copiesDir string, fileBytes int,
) map[string][]byte {
t.Helper()
const fileCount = 15
require.NoError(t, fs.MkdirAll(originalsDir, 0o755))
require.NoError(t, fs.MkdirAll(copiesDir, 0o755))
originals := make(map[string][]byte, fileCount)
for i := range fileCount {
name := fmt.Sprintf("file-%02d.bin", i)
data := randomBytes(t, fileBytes)
original := filepath.Join(originalsDir, name)
originals[original] = data
require.NoError(t, afero.WriteFile(fs, original, data, 0o644))
require.NoError(t, afero.WriteFile(
fs, filepath.Join(copiesDir, name), data, 0o644))
}
return originals
}
// requireRestoredFiles requires every file in want to have been restored
// under restoreDir with its content intact.
func requireRestoredFiles(
t *testing.T, fs afero.Fs, restoreDir string, want map[string][]byte,
) {
t.Helper()
for path, data := range want {
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, path))
require.NoErrorf(t, err, "file missing after restore: %s", path)
require.Equalf(t, data, got, "byte mismatch for %s", path)
}
}
// restorePeakCachedBlobs runs a restore and returns the most blobs its
// cache held at once, together with the restore's error.
func restorePeakCachedBlobs(
ctx context.Context, t *testing.T, fs afero.Fs,
cfg *config.Config, storer storage.Storer, opts *RestoreOptions,
) (int, error) {
t.Helper()
var cache *blobDiskCache
v := &Vaultik{
Config: cfg,
Storage: storer,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
restoreCacheObserver: func(c *blobDiskCache) {
cache = c
},
}
v.SetContext(ctx)
err := v.Restore(opts)
require.NotNil(t, cache, "restoreCacheObserver must fire during restore")
return cache.PeakLen(), err
}
+44 -32
View File
@@ -574,7 +574,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
if len(toDelete) == 0 {
if !opts.Quiet {
v.printlnStdout("No snapshots to delete")
v.UI.Infof("No snapshots to delete.")
}
return nil
@@ -631,19 +631,19 @@ func selectSnapshotsToPurge(
func (v *Vaultik) confirmAndExecutePurge(
toDelete []SnapshotInfo, force, quiet bool,
) error {
if !quiet {
// Confirm unless --force is used. The list, the prompt and its answer
// bypass the UI: they are an interactive exchange the operator must
// see, so --quiet does not silence them.
if !force {
v.stdoutf("The following snapshots will be deleted:\n\n")
for _, snap := range toDelete {
v.stdoutf(" %s (%s, %s)\n",
snap.ID,
snap.Timestamp.Format("2006-01-02 15:04:05"),
snap.Timestamp.Format(time.DateTime),
ubytes(snap.CompressedSize))
}
}
// Confirm unless --force is used
if !force {
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
var confirm string
@@ -661,7 +661,16 @@ func (v *Vaultik) confirmAndExecutePurge(
return nil
}
} else if !quiet {
v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
v.UI.Infof("The following snapshots will be deleted:")
for _, snap := range toDelete {
v.UI.Detailf("%s (%s, %s)",
v.UI.Snapshot(snap.ID.String()),
snap.Timestamp.Format(time.DateTime),
ubytes(snap.CompressedSize))
}
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
}
// Delete snapshots (both local and remote)
@@ -694,9 +703,9 @@ func (v *Vaultik) confirmAndExecutePurge(
}
if !quiet {
v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
v.printlnStdout(
"\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete))
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.",
pruneCommandHint)
}
return nil
@@ -1069,7 +1078,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
log.Info("Removing stale local snapshot record", "snapshot_id", id)
if !opts.JSON {
v.stdoutf("Removing stale local record: %s\n", id)
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id))
}
err = v.deleteSnapshotFromLocalDB(id)
@@ -1092,9 +1101,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
}
if removed == 0 {
v.printlnStdout("No stale local snapshots found.")
v.UI.Infof("No stale local snapshots found.")
} else {
v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
v.UI.Infof("Removed %d stale local snapshot record(s).", removed)
}
return nil
@@ -1273,14 +1282,15 @@ func (v *Vaultik) RemoveSnapshot(
}
if inLocalIndex {
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
v.UI.Infof("Removed snapshot '%s' from local database.",
v.UI.Snapshot(snapshotID))
}
if result.RemoteRemoved {
v.printlnStdout("Removed snapshot metadata from remote storage")
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
v.UI.Infof("Removed snapshot metadata from remote storage.")
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+
"Run '%s' to delete any blobs no longer referenced by any "+
"remaining remote snapshot.\n", pruneCommandHint)
"remaining remote snapshot.", pruneCommandHint)
}
return result, nil
@@ -1336,21 +1346,23 @@ func (v *Vaultik) removeSnapshotDryRun(
return result, v.outputRemoveJSON(result)
}
v.stdoutf("Would remove snapshot: %s\n", snapshotID)
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID))
if !opts.LocalOnly {
v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched)")
v.UI.Infof("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).")
}
v.printlnStdout("[Dry run - no changes made]")
v.UI.Noticef("Dry run: no changes made.")
return result, nil
}
// confirmRemoveSnapshot prompts for confirmation before removing a
// snapshot. Returns false (treating EOF/read errors as "no") unless the
// user answers "y".
// user answers "y". The prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
if opts.LocalOnly {
v.stdoutf("Remove snapshot '%s' from local database "+
@@ -1456,7 +1468,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
if !opts.JSON {
v.printlnStdout("No snapshots found")
v.UI.Infof("No snapshots found.")
}
return &RemoveResult{}, nil
@@ -1561,27 +1573,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
}
if !opts.JSON {
v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps))
for _, id := range localSnaps {
v.stdoutf(" %s\n", id)
v.UI.Detailf("%s", v.UI.Snapshot(id))
}
if !opts.LocalOnly {
if len(orphanRemoteKeys) > 0 {
v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):",
len(orphanRemoteKeys))
for _, key := range orphanRemoteKeys {
v.stdoutf(" %s\n", key)
v.UI.Detailf("%s", key)
}
} else {
v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched)")
v.UI.Infof("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).")
}
}
v.printlnStdout("[Dry run - no changes made]")
v.UI.Noticef("Dry run: no changes made.")
}
if opts.JSON {
@@ -1630,10 +1642,10 @@ func (v *Vaultik) executeRemoveAll(
return result, v.outputRemoveJSON(result)
}
v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved))
if !opts.LocalOnly && result.RemoteRemoved {
v.printlnStdout("Removed snapshot metadata from remote storage")
v.UI.Infof("Removed snapshot metadata from remote storage.")
}
return result, nil