Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3752db19 | ||
|
|
b06f992152 |
@@ -35,6 +35,14 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
the snapshot references, and `docs/DATAMODEL.md` now says
|
the snapshot references, and `docs/DATAMODEL.md` now says
|
||||||
`chunk_count` and `blob_count` count what the run added.
|
`chunk_count` and `blob_count` count what the run added.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a backup without `--cron` of a snapshot with two or
|
||||||
|
more `paths` complete instead of panicking with `close of closed
|
||||||
|
channel` ([issue #253](https://git.eeqj.de/sneak/vaultik/issues/253)).
|
||||||
|
`Scan` runs once per path and started and stopped the progress
|
||||||
|
reporter each time, and a second stop panics. The reporter is now
|
||||||
|
started and stopped once per snapshot, around the scans of all its
|
||||||
|
paths.
|
||||||
|
|
||||||
- 2026-10-06: Made a restore path argument select only that path and
|
- 2026-10-06: Made a restore path argument select only that path and
|
||||||
what is beneath it
|
what is beneath it
|
||||||
([issue #223](https://git.eeqj.de/sneak/vaultik/issues/223)). The
|
([issue #223](https://git.eeqj.de/sneak/vaultik/issues/223)). The
|
||||||
|
|||||||
@@ -544,6 +544,30 @@ func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
|
|||||||
return totalSize, nil
|
return totalSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSnapshotBlobSizes returns the total compressed and uncompressed sizes
|
||||||
|
// of all blobs referenced by a snapshot.
|
||||||
|
func (r *SnapshotRepository) GetSnapshotBlobSizes(
|
||||||
|
ctx context.Context, snapshotID string,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(SUM(b.compressed_size), 0),
|
||||||
|
COALESCE(SUM(b.uncompressed_size), 0)
|
||||||
|
FROM snapshot_blobs sb
|
||||||
|
JOIN blobs b ON sb.blob_hash = b.blob_hash
|
||||||
|
WHERE sb.snapshot_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var compressed, uncompressed int64
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(
|
||||||
|
&compressed, &uncompressed)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("querying snapshot blob sizes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed, uncompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
|
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
|
||||||
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
|
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
|
||||||
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
||||||
|
|||||||
@@ -145,6 +145,65 @@ func TestSnapshotRepositoryUpdateCounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSnapshotBlobSizes totals the blobs the snapshot references, and only
|
||||||
|
// those.
|
||||||
|
func TestSnapshotRepositoryGetSnapshotBlobSizes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
snapshot := &database.Snapshot{
|
||||||
|
ID: "2024-01-03T12:00:00Z",
|
||||||
|
Hostname: testHostname,
|
||||||
|
VaultikVersion: testVersion,
|
||||||
|
StartedAt: time.Now().Truncate(time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Snapshots.Create(ctx, nil, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := []*database.Blob{
|
||||||
|
{Hash: "referenced-1", CompressedSize: 10, UncompressedSize: 100},
|
||||||
|
{Hash: "referenced-2", CompressedSize: 20, UncompressedSize: 200},
|
||||||
|
{Hash: "unreferenced", CompressedSize: 40, UncompressedSize: 400},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, blob := range blobs {
|
||||||
|
blob.ID = types.NewBlobID()
|
||||||
|
blob.CreatedTS = time.Now().Truncate(time.Second)
|
||||||
|
|
||||||
|
err = repos.Blobs.Create(ctx, nil, blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create blob %s: %v", blob.Hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, blob := range blobs[:2] {
|
||||||
|
err = repos.Snapshots.AddBlob(ctx, nil, snapshot.ID.String(),
|
||||||
|
blob.ID, blob.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add blob %s to snapshot: %v", blob.Hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed, uncompressed, err := repos.Snapshots.GetSnapshotBlobSizes(
|
||||||
|
ctx, snapshot.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get snapshot blob sizes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if compressed != 30 || uncompressed != 300 {
|
||||||
|
t.Errorf("blob sizes: got %d and %d, want 30 and 300",
|
||||||
|
compressed, uncompressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSnapshotRepositoryListRecent(t *testing.T) {
|
func TestSnapshotRepositoryListRecent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -227,12 +227,6 @@ func (s *Scanner) Scan(
|
|||||||
log.Debug("No storage configured, blobs will not be uploaded")
|
log.Debug("No storage configured, blobs will not be uploaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start progress reporting if enabled
|
|
||||||
if s.progress != nil {
|
|
||||||
s.progress.Start()
|
|
||||||
defer s.progress.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 0: Repair any state left by an interrupted previous run, then
|
// Phase 0: Repair any state left by an interrupted previous run, then
|
||||||
// load known files and chunks from the database into memory for fast
|
// load known files and chunks from the database into memory for fast
|
||||||
// lookup.
|
// lookup.
|
||||||
@@ -302,6 +296,13 @@ func (s *Scanner) Scan(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProgress returns the progress reporter for this scanner, or nil when
|
||||||
|
// progress is off. Scan neither starts nor stops it: the caller does,
|
||||||
|
// once for all the paths it scans, because a second Stop panics.
|
||||||
|
func (s *Scanner) GetProgress() *ProgressReporter {
|
||||||
|
return s.progress
|
||||||
|
}
|
||||||
|
|
||||||
// loadDatabaseState loads known files and chunks from the database into
|
// loadDatabaseState loads known files and chunks from the database into
|
||||||
// memory for fast lookup. This avoids per-file and per-chunk database
|
// memory for fast lookup. This avoids per-file and per-chunk database
|
||||||
// queries during the scan and process phases.
|
// queries during the scan and process phases.
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ type snapshotStats struct {
|
|||||||
totalBytesUploaded int64
|
totalBytesUploaded int64
|
||||||
totalBlobsUploaded int
|
totalBlobsUploaded int
|
||||||
uploadDuration time.Duration
|
uploadDuration time.Duration
|
||||||
|
|
||||||
|
// The sizes of all blobs the snapshot references, set by
|
||||||
|
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||||
|
blobSize int64
|
||||||
|
blobUncompressedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNamedSnapshot creates a single named snapshot
|
// createNamedSnapshot creates a single named snapshot
|
||||||
@@ -279,6 +284,11 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
|||||||
func (v *Vaultik) scanAllDirectories(
|
func (v *Vaultik) scanAllDirectories(
|
||||||
scanner *snapshot.Scanner, resolvedDirs []string, snapshotID string,
|
scanner *snapshot.Scanner, resolvedDirs []string, snapshotID string,
|
||||||
) (*snapshotStats, error) {
|
) (*snapshotStats, error) {
|
||||||
|
if progress := scanner.GetProgress(); progress != nil {
|
||||||
|
progress.Start()
|
||||||
|
defer progress.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
stats := &snapshotStats{}
|
stats := &snapshotStats{}
|
||||||
|
|
||||||
for i, dir := range resolvedDirs {
|
for i, dir := range resolvedDirs {
|
||||||
@@ -342,7 +352,11 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
|||||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blobSize, blobUncompressedSize := v.getSnapshotBlobSizes(snapshotID)
|
stats.blobSize, stats.blobUncompressedSize, err =
|
||||||
|
v.Repositories.Snapshots.GetSnapshotBlobSizes(v.ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting snapshot blob sizes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
extStats := snapshot.ExtendedBackupStats{
|
extStats := snapshot.ExtendedBackupStats{
|
||||||
BackupStats: snapshot.BackupStats{
|
BackupStats: snapshot.BackupStats{
|
||||||
@@ -352,8 +366,8 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
|||||||
BlobsCreated: stats.totalBlobs,
|
BlobsCreated: stats.totalBlobs,
|
||||||
BytesUploaded: stats.totalBytesUploaded,
|
BytesUploaded: stats.totalBytesUploaded,
|
||||||
},
|
},
|
||||||
BlobSize: blobSize,
|
BlobSize: stats.blobSize,
|
||||||
BlobUncompressedSize: blobUncompressedSize,
|
BlobUncompressedSize: stats.blobUncompressedSize,
|
||||||
CompressionLevel: v.Config.CompressionLevel,
|
CompressionLevel: v.Config.CompressionLevel,
|
||||||
UploadDurationMs: stats.uploadDuration.Milliseconds(),
|
UploadDurationMs: stats.uploadDuration.Milliseconds(),
|
||||||
}
|
}
|
||||||
@@ -397,12 +411,10 @@ func (v *Vaultik) printSnapshotSummary(
|
|||||||
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped
|
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped
|
||||||
totalBytesAll := stats.totalBytes + stats.totalBytesSkipped
|
totalBytesAll := stats.totalBytes + stats.totalBytesSkipped
|
||||||
|
|
||||||
// Get total blob sizes from database
|
|
||||||
compressedSize, uncompressedSize := v.getSnapshotBlobSizes(snapshotID)
|
|
||||||
|
|
||||||
var compressionRatio float64
|
var compressionRatio float64
|
||||||
if uncompressedSize > 0 {
|
if stats.blobUncompressedSize > 0 {
|
||||||
compressionRatio = float64(compressedSize) / float64(uncompressedSize)
|
compressionRatio = float64(stats.blobSize) /
|
||||||
|
float64(stats.blobUncompressedSize)
|
||||||
} else {
|
} else {
|
||||||
compressionRatio = 1.0
|
compressionRatio = 1.0
|
||||||
}
|
}
|
||||||
@@ -430,8 +442,8 @@ func (v *Vaultik) printSnapshotSummary(
|
|||||||
|
|
||||||
if stats.totalBlobsUploaded > 0 {
|
if stats.totalBlobsUploaded > 0 {
|
||||||
v.UI.Detailf("Storage: %s compressed from %s (%.2fx ratio).",
|
v.UI.Detailf("Storage: %s compressed from %s (%.2fx ratio).",
|
||||||
v.UI.Size(compressedSize),
|
v.UI.Size(stats.blobSize),
|
||||||
v.UI.Size(uncompressedSize),
|
v.UI.Size(stats.blobUncompressedSize),
|
||||||
compressionRatio)
|
compressionRatio)
|
||||||
v.UI.Detailf("Upload: %d blobs, %s in %s (%s).",
|
v.UI.Detailf("Upload: %d blobs, %s in %s (%s).",
|
||||||
stats.totalBlobsUploaded,
|
stats.totalBlobsUploaded,
|
||||||
@@ -443,27 +455,6 @@ func (v *Vaultik) printSnapshotSummary(
|
|||||||
v.UI.Detailf("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
v.UI.Detailf("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSnapshotBlobSizes returns total compressed and uncompressed blob
|
|
||||||
// sizes for a snapshot.
|
|
||||||
func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (int64, int64) {
|
|
||||||
var compressed, uncompressed int64
|
|
||||||
|
|
||||||
blobHashes, err := v.Repositories.Snapshots.GetBlobHashes(v.ctx, snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range blobHashes {
|
|
||||||
blob, err := v.Repositories.Blobs.GetByHash(v.ctx, hash)
|
|
||||||
if err == nil && blob != nil {
|
|
||||||
compressed += blob.CompressedSize
|
|
||||||
uncompressed += blob.UncompressedSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return compressed, uncompressed
|
|
||||||
}
|
|
||||||
|
|
||||||
// SnapshotPurgeOptions contains options for the snapshot purge command.
|
// SnapshotPurgeOptions contains options for the snapshot purge command.
|
||||||
type SnapshotPurgeOptions struct {
|
type SnapshotPurgeOptions struct {
|
||||||
KeepLatest bool // Keep only the most recent snapshot per name
|
KeepLatest bool // Keep only the most recent snapshot per name
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"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/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A backup without --cron runs the progress reporter while one scanner
|
||||||
|
// scans each path of the snapshot in turn. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/253.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||||
|
func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
const snapshotName = "data"
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
firstDir := filepath.Join(tempDir, "first")
|
||||||
|
secondDir := filepath.Join(tempDir, "second")
|
||||||
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := writeFaultSourceTree(t, fs, firstDir)
|
||||||
|
maps.Copy(files, writeFaultSourceTree(t, fs, secondDir))
|
||||||
|
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
cfg.IndexPath = dbPath
|
||||||
|
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||||
|
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||||
|
snapshotName: {Paths: []string{firstDir, secondDir}},
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
|
|
||||||
|
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Snapshots: []string{snapshotName},
|
||||||
|
}))
|
||||||
|
|
||||||
|
id := localSnapshotID(ctx, t, repos, snapshotName)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||||
|
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Verify: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assertRestoredTree(t, fs, restoreDir, files)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user