Keep unreferenced blobs newer than prune_grace_period (closes #387)
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 was merged in pull request #391.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user