Free a cached blob once no file still to restore needs it (closes #324)
check / check (push) Waiting to run
check / check (push) Waiting to run
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
This commit is contained in:
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
|
|||||||
// internally and will fall back to LRU eviction if curBytes exceeds
|
// internally and will fall back to LRU eviction if curBytes exceeds
|
||||||
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
||||||
// eviction itself via Delete() through restoreSweeper, which deletes
|
// eviction itself via Delete() through restoreSweeper, which deletes
|
||||||
// each blob the moment every file that references its chunks has been
|
// each blob once no file still to be restored in this run needs it.
|
||||||
// written. LRU never fires under that configuration; it is kept as a
|
// LRU never fires under that configuration; it is kept as a safety net
|
||||||
// safety net for callers that don't manage eviction themselves.
|
// for callers that don't manage eviction themselves.
|
||||||
//
|
//
|
||||||
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
||||||
// tests to assert that the restore code path uses ReadAt rather than
|
// tests to assert that the restore code path uses ReadAt rather than
|
||||||
|
|||||||
@@ -323,12 +323,6 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
_ = blobCache.Close()
|
_ = 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)
|
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -340,6 +334,12 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
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.
|
// Ranging over files rather than the map reports them in a stable order.
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if planErr, ok := unplanned[file.ID]; ok {
|
if planErr, ok := unplanned[file.ID]; ok {
|
||||||
@@ -427,10 +427,8 @@ func (v *Vaultik) runRestoreLoop(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the file as restored so the sweeper can free blobs
|
// Drop the file from the plan's indexes so future picks ignore
|
||||||
// once all referencing files are done, and drop it from the
|
// it and the sweeper can free blobs no pending file needs.
|
||||||
// plan's indexes so future picks ignore it.
|
|
||||||
session.sweeper.fileRestored(fileID.String())
|
|
||||||
plan.finishFile(fileID)
|
plan.finishFile(fileID)
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
buildLocalityFixture(t, fs, dataDir)
|
buildLocalityFixture(t, fs, dataDir)
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
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
|
// Point the "" temp paths (the blob cache directory and the
|
||||||
// snapshot-database directory) at a private directory so the test can
|
// snapshot-database directory) at a private directory so the test can
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ type localityCopy struct {
|
|||||||
data []byte
|
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
|
// buildLocalityFixture writes the adversarial source layout described
|
||||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
||||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||||
@@ -73,7 +77,6 @@ func buildLocalityFixture(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
srcBytes = 1024 * 1024
|
|
||||||
srcCount = 15
|
srcCount = 15
|
||||||
blobsCount = 3
|
blobsCount = 3
|
||||||
perBlob = srcCount / blobsCount
|
perBlob = srcCount / blobsCount
|
||||||
@@ -83,7 +86,7 @@ func buildLocalityFixture(
|
|||||||
for i := range srcCount {
|
for i := range srcCount {
|
||||||
s := &localitySource{
|
s := &localitySource{
|
||||||
path: fmt.Sprintf("src-%03d.bin", i+1),
|
path: fmt.Sprintf("src-%03d.bin", i+1),
|
||||||
data: randomBytes(t, srcBytes),
|
data: randomBytes(t, localitySourceBytes),
|
||||||
}
|
}
|
||||||
sources[i] = s
|
sources[i] = s
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(
|
||||||
@@ -115,19 +118,20 @@ func buildLocalityFixture(
|
|||||||
|
|
||||||
// setupLocalityBackup runs the backup half of the locality test: a
|
// setupLocalityBackup runs the backup half of the locality test: a
|
||||||
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
// 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
|
// files of srcBytes pack into 3 blobs. The index database is closed
|
||||||
// so the restore half runs from remote bytes only.
|
// before returning so the restore half runs from remote bytes only.
|
||||||
func setupLocalityBackup(
|
func setupLocalityBackup(
|
||||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||||
dataDir, storeDir, dbPath string,
|
dataDir, storeDir, dbPath string, srcBytes int64,
|
||||||
) (*config.Config, *storage.FileStorer, string) {
|
) (*config.Config, *storage.FileStorer, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
|
// An average chunk size of four times srcBytes makes the minimum
|
||||||
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
|
// chunk srcBytes, so each source file becomes one chunk. A blob limit
|
||||||
// per blob, yielding 3 blobs from 15 source files.
|
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
|
||||||
chunkSize := int64(4 * 1024 * 1024)
|
// blobs from 15 source files.
|
||||||
maxBlobSize := int64(5 * 1024 * 1024)
|
chunkSize := 4 * srcBytes
|
||||||
|
maxBlobSize := 5 * srcBytes
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -208,7 +212,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
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.
|
// Wrap the storer so we can count downloads per blob key.
|
||||||
counter := newCountingStorer(storer)
|
counter := newCountingStorer(storer)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ var (
|
|||||||
// with no further downloads.
|
// with no further downloads.
|
||||||
// - blobFiles: for each blob, the set of pending files referencing
|
// - blobFiles: for each blob, the set of pending files referencing
|
||||||
// it. Used to short-circuit "when this blob lands, which files
|
// 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 {
|
type restorePlan struct {
|
||||||
fileBlobs map[types.FileID]map[string]struct{}
|
fileBlobs map[types.FileID]map[string]struct{}
|
||||||
blobFiles map[string]map[types.FileID]struct{}
|
blobFiles map[string]map[types.FileID]struct{}
|
||||||
@@ -237,6 +238,12 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
|
|||||||
return out
|
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.
|
// hasPending reports whether any unfinished files remain.
|
||||||
func (p *restorePlan) hasPending() bool {
|
func (p *restorePlan) hasPending() bool {
|
||||||
return len(p.fileBlobs) > 0
|
return len(p.fileBlobs) > 0
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = cache.Close() })
|
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
|
// 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
|
// and which was never queued as ready: the loop can neither restore
|
||||||
// nor download it. This is the abandonment the guard must catch.
|
// nor download it. This is the abandonment the guard must catch.
|
||||||
@@ -83,6 +74,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
cached: map[string]struct{}{},
|
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)
|
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
||||||
require.ErrorIs(t, err, errRestoreIncomplete)
|
require.ErrorIs(t, err, errRestoreIncomplete)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,30 @@
|
|||||||
package vaultik
|
package vaultik
|
||||||
|
|
||||||
import (
|
// restoreSweeper frees cached blobs once no file still to be restored in
|
||||||
"context"
|
// this run needs them. It works as follows:
|
||||||
"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:
|
|
||||||
//
|
//
|
||||||
// 1. Callers add a file's ID to an in-memory restored set via
|
// 1. After each chunk is restored, chunkRestored accumulates a running
|
||||||
// fileRestored once the file is fully written to disk.
|
|
||||||
// 2. After each chunk is restored, chunkRestored accumulates a running
|
|
||||||
// byte count.
|
// 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
|
// configured blob size — so a sweep runs about a hundred times per
|
||||||
// blob's worth of restored bytes), the sweeper iterates every key in
|
// blob's worth of restored bytes), the sweeper iterates every key in
|
||||||
// the cache. For each cached blob it asks the DB which files
|
// the cache and deletes each blob that the restore plan no longer
|
||||||
// reference any chunk in that blob, then compares that list against
|
// needs.
|
||||||
// the in-memory restored set. If any referencing file is missing
|
|
||||||
// from the set the blob is kept; otherwise the cache entry is
|
|
||||||
// deleted.
|
|
||||||
//
|
//
|
||||||
// All DB reads happen against the snapshot's temporary metadata DB,
|
// The plan drops a file once it is restored or has failed, and holds
|
||||||
// which is local, indexed, and not under contention — the queries are
|
// only the files this run restores, so files outside the requested paths
|
||||||
// cheap and run at most once per blob per sweep interval.
|
// never keep a blob.
|
||||||
type restoreSweeper struct {
|
type restoreSweeper struct {
|
||||||
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
plan *restorePlan
|
||||||
repos *database.Repositories
|
|
||||||
cache *blobDiskCache
|
cache *blobDiskCache
|
||||||
threshold int64
|
threshold int64
|
||||||
bytesAccum int64
|
bytesAccum int64
|
||||||
restored map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRestoreSweeper returns a sweeper that triggers eviction every
|
// newRestoreSweeper returns a sweeper that triggers eviction every
|
||||||
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
||||||
func newRestoreSweeper(
|
func newRestoreSweeper(
|
||||||
ctx context.Context,
|
plan *restorePlan,
|
||||||
repos *database.Repositories,
|
|
||||||
cache *blobDiskCache,
|
cache *blobDiskCache,
|
||||||
threshold int64,
|
threshold int64,
|
||||||
) *restoreSweeper {
|
) *restoreSweeper {
|
||||||
@@ -49,21 +33,12 @@ func newRestoreSweeper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &restoreSweeper{
|
return &restoreSweeper{
|
||||||
ctx: ctx,
|
plan: plan,
|
||||||
repos: repos,
|
|
||||||
cache: cache,
|
cache: cache,
|
||||||
threshold: threshold,
|
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
|
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
||||||
// sweep if the threshold has been crossed since the last sweep.
|
// sweep if the threshold has been crossed since the last sweep.
|
||||||
func (s *restoreSweeper) chunkRestored(n int64) {
|
func (s *restoreSweeper) chunkRestored(n int64) {
|
||||||
@@ -76,60 +51,11 @@ func (s *restoreSweeper) chunkRestored(n int64) {
|
|||||||
s.sweep()
|
s.sweep()
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep deletes any cached blob whose chunks are no longer referenced
|
// sweep deletes every cached blob that no pending file references.
|
||||||
// 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.
|
|
||||||
func (s *restoreSweeper) sweep() {
|
func (s *restoreSweeper) sweep() {
|
||||||
for _, blobHash := range s.cache.Keys() {
|
for _, blobHash := range s.cache.Keys() {
|
||||||
needed, err := s.blobStillNeeded(blobHash)
|
if !s.plan.pendingFileUsesBlob(blobHash) {
|
||||||
if err != nil {
|
|
||||||
log.Debug("sweeper referencing-files query failed",
|
|
||||||
"blob_hash", blobHash[:16], "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !needed {
|
|
||||||
s.cache.Delete(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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user