Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (push) Canceled after 0s

A backup uploads its blobs before the manifest that lists them, and the
process lock covers one user on one machine, so a prune from another host
or user sharing the destination deleted a running backup's blobs.

Prune now deletes an unreferenced blob only once its listed time is older
than the new config key prune_grace_period (default 24h; a negative value
is refused). A newer blob, or one listed with no time, is kept and counted
(blobs_too_new). The rclone backend takes each object's time from the
listing (use_server_modtime).

Decision: a blob a backup reuses from an earlier run that stopped before
its manifest keeps its old time and is not protected; the docs and config
comments say so.

Model: opus-5-5
This commit is contained in:
2026-10-14 10:49:00 +00:00
parent 2590f49583
commit ba22562e15
21 changed files with 554 additions and 52 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(),
}
}
+11 -2
View File
@@ -53,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
rclonePath += path
}
// On S3, Swift and some other remotes rclone keeps a time of its own in
// each object's metadata, which costs one request per object to read.
// rclone's use_server_modtime setting makes ModTime the time the
// listing gives instead: when the object was stored, which is what
// prune compares with prune_grace_period.
ctx, ci := fs.AddConfig(ctx)
ci.UseServerModTime = true
// Create the rclone filesystem
fsys, err := fs.NewFs(ctx, rclonePath)
if err != nil {
@@ -211,8 +219,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),
}
}
})
+75
View File
@@ -5,11 +5,17 @@ import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap"
@@ -429,3 +435,72 @@ func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
t.Errorf("object is not in the remote's directory: %v", err)
}
}
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
// rclone S3 remote takes each object's time from the listing itself. By
// default rclone reads the time it keeps in each object's metadata, one
// request per object, and every command that lists the destination store
// would slow down as it grows.
//
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
backend := s3mem.New()
err := backend.CreateBucket(s3TestBucket)
if err != nil {
t.Fatalf("create bucket: %v", err)
}
const body = "blob-bytes"
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("seed object: %v", err)
}
var headRequests atomic.Int64
fakeS3 := gofakes3.New(backend).Server()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
headRequests.Add(1)
}
fakeS3.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
remote := ":s3,provider=Other" +
",access_key_id=" + s3TestAccessKeyID +
",secret_access_key=" + s3TestSecretAccessKey +
",endpoint='" + srv.URL + "'"
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
headRequests.Store(0)
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)
}
if n := headRequests.Load(); n != 0 {
t.Errorf("ListStream made %d HEAD request(s), want none", n)
}
}
+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