Do not prune after a backup that found a volume not mounted (closes #395)
check / check (push) Waiting to run
check / check (push) Waiting to run
snapshot create --prune purged the older snapshots holding a volume's files when the volume, mounted below a configured path, was not mounted: its empty mount point read as deleted files. The scan now records in the local index each directory at or below a configured path that is on a different device from its parent directory. Under --prune, a run that does not find such a directory recorded by an earlier snapshot of that name skips the prune, keeps every older snapshot, names the directory and its snapshot, and exits non-zero, as for an empty configured path. A volume retired on purpose is cleared by removing the older snapshots with snapshot purge. Model: opus-5-5
This commit is contained in:
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
|
||||
-- Index for efficient blob lookups (used in orphan detection)
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
||||
|
||||
-- Snapshot mount points table: the directories at or below a configured
|
||||
-- path that the snapshot's backup found on a different filesystem from
|
||||
-- their parent directory, as a mount point is
|
||||
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
||||
snapshot_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (snapshot_id, path),
|
||||
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Uploads table: tracks blob upload metrics
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
|
||||
@@ -24,7 +24,8 @@ var (
|
||||
)
|
||||
|
||||
// SnapshotRepository provides access to the snapshots table and its
|
||||
// snapshot_files / snapshot_blobs association tables.
|
||||
// snapshot_files / snapshot_blobs association tables, and to the mount
|
||||
// points each snapshot's backup found (snapshot_mount_points).
|
||||
type SnapshotRepository struct {
|
||||
db *DB
|
||||
}
|
||||
@@ -494,33 +495,63 @@ func (r *SnapshotRepository) GetBlobHashes(
|
||||
ctx context.Context, snapshotID string,
|
||||
) ([]string, error) {
|
||||
query := `
|
||||
SELECT sb.blob_hash
|
||||
SELECT sb.blob_hash
|
||||
FROM snapshot_blobs sb
|
||||
WHERE sb.snapshot_id = ?
|
||||
ORDER BY sb.blob_hash
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
|
||||
blobs, err := r.queryStrings(ctx, query, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
return blobs, nil
|
||||
}
|
||||
|
||||
var blobs []string
|
||||
// AddMountPoint records dir as a directory that the backup of a snapshot
|
||||
// found on a different filesystem from its parent directory, as a mount
|
||||
// point is, using tx when non-nil.
|
||||
func (r *SnapshotRepository) AddMountPoint(
|
||||
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
|
||||
) error {
|
||||
query := `
|
||||
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
|
||||
for rows.Next() {
|
||||
var blobHash string
|
||||
|
||||
err := rows.Scan(&blobHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning blob hash: %w", err)
|
||||
}
|
||||
|
||||
blobs = append(blobs, blobHash)
|
||||
var err error
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
|
||||
} else {
|
||||
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
|
||||
}
|
||||
|
||||
return blobs, rows.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding mount point to snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMountPoints returns the directories AddMountPoint recorded for a
|
||||
// snapshot, sorted.
|
||||
func (r *SnapshotRepository) GetMountPoints(
|
||||
ctx context.Context, snapshotID string,
|
||||
) ([]string, error) {
|
||||
query := `
|
||||
SELECT path
|
||||
FROM snapshot_mount_points
|
||||
WHERE snapshot_id = ?
|
||||
ORDER BY path
|
||||
`
|
||||
|
||||
dirs, err := r.queryStrings(ctx, query, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying mount points: %w", err)
|
||||
}
|
||||
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
||||
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryStrings runs query, which selects one text column, and returns
|
||||
// its values in the order of the rows.
|
||||
func (r *SnapshotRepository) queryStrings(
|
||||
ctx context.Context, query string, args ...any,
|
||||
) ([]string, error) {
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var values []string
|
||||
|
||||
for rows.Next() {
|
||||
var value string
|
||||
|
||||
err := rows.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// extendedCompressionRatio computes the compression ratio for a snapshot
|
||||
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
||||
// when the uncompressed size is zero.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
|
||||
t.Error("expected error for duplicate snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a snapshot from the local index deletes the mount points its
|
||||
// backup recorded, and no other snapshot's. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repo := database.NewSnapshotRepository(db)
|
||||
|
||||
for _, id := range []types.SnapshotID{"kept", "deleted"} {
|
||||
err := repo.Create(ctx, nil, &database.Snapshot{
|
||||
ID: id,
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: testVersion,
|
||||
StartedAt: time.Now().Truncate(time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create snapshot: %v", err)
|
||||
}
|
||||
|
||||
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add mount point: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := repo.Delete(ctx, "deleted")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete snapshot: %v", err)
|
||||
}
|
||||
|
||||
for id, want := range map[string][]string{
|
||||
"kept": {"/data/nas"},
|
||||
"deleted": nil,
|
||||
} {
|
||||
got, err := repo.GetMountPoints(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read mount points: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user