Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (push) Successful in 11m32s

A backup uploads its blobs before the manifest that lists them, and the
process lock is per user per machine, so a prune from another host or
user sharing the destination deleted a running backup's blobs; that
backup still reported success and could not be restored.

Prune now deletes an unreferenced blob only once it is older than the new
config key prune_grace_period (default 24h), and reports how many it kept
(blobs_too_new under --json). The storage listing now carries each
object's modification time. remote nuke is unchanged. The README says what
the lock covers and that a shared destination relies on the grace period
or needs one prefix per host.

Model: opus-5-5
This commit is contained in:
2026-10-14 08:11:44 +00:00
parent 2590f49583
commit 5fcb2f8c60
18 changed files with 335 additions and 49 deletions
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"reflect"
"sort"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/storage"
)
@@ -19,15 +20,17 @@ import (
// constructor, so the contract is defined once.
//
// It exercises the public Storer interface: round-trip, stat, list with
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
// Get and Stat. Each section takes its own fresh backend instance, so the
// order of sections never matters and no section sees another's objects.
// prefix filtering, the modification time ListStream reports, overwrite,
// delete, delete-of-missing, and not-found on Get and Stat. Each section
// 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) {
t.Helper()
conformanceRoundTrip(t, newStorer(t))
conformanceOverwrite(t, newStorer(t))
conformanceList(t, newStorer(t))
conformanceListStreamModTime(t, newStorer(t))
conformanceDelete(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
// a missing key is not an error.
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
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
ch <- ObjectInfo{
Key: relPath,
Size: info.Size(),
Key: relPath,
Size: info.Size(),
ModTime: info.ModTime(),
}
}
+3 -2
View File
@@ -211,8 +211,9 @@ func (r *RcloneStorer) ListStream(
if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- ObjectInfo{
Key: key,
Size: obj.Size(),
Key: key,
Size: obj.Size(),
ModTime: obj.ModTime(ctx),
}
}
})
+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) {
ch <- ObjectInfo{
Key: info.Key,
Size: info.Size,
Err: info.Err,
Key: info.Key,
Size: info.Size,
ModTime: info.ModTime,
Err: info.Err,
}
}
}()
+7 -2
View File
@@ -14,6 +14,7 @@ import (
"context"
"errors"
"io"
"time"
)
// ErrNotFound is returned when an object does not exist.
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
type ObjectInfo struct {
Key string // Object key/path
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.
@@ -67,7 +71,8 @@ type Storer interface {
// For large result sets, prefer ListStream.
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.
// If an error occurs during listing, the final item will have Err set.
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo