mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-18 20:40:54 +02:00
* feat(s3/lifecycle): bootstrap re-walk cadence + operator hooks (Phase 8) scan_only actions only fire from the bootstrap walk: the engine classifies a rule as scan_only when its retention horizon exceeds the meta-log retention, so event-driven routing can't be trusted. Today each bucket walks once per process, so a long-running worker never revisits — scan_only retention only catches up when the worker restarts. Replace BucketBootstrapper.known (set) with BucketBootstrapper.lastWalk (name -> completion time). KickOffNew now re-walks a bucket whose last walk completed more than BootstrapInterval ago. Zero interval preserves the legacy walk-once-per-process behavior so existing deployments don't change cadence by default. walkBucket re-stamps on success and clears the stamp on failure (via MarkDirty), so the next KickOffNew picks failed walks back up. Add MarkDirty / MarkAllDirty operator hooks for forced re-walks, and a Now func() for testable time travel. weed shell run-shard grows --bootstrap-interval (cadence knob) and --force-bootstrap (drop in-memory state at startup so every bucket walks again immediately, useful when a config change should take effect without a restart). Tests: cadence respected (skip inside interval, re-walk past it); zero interval keeps once-per-process; MarkDirty forces re-walk under a 24h interval; MarkAllDirty resets every record. The fakeClock helper guards the test clock with a mutex so race-detector runs are clean. * fix(s3/lifecycle): split walk state, thread BootstrapInterval through worker, drop dead flag Three issues with the Phase 8 cadence work as it landed: 1. lastWalk did double duty as both completed-walk timestamp and in-flight debounce. A walk that took longer than BootstrapInterval would have a fresh KickOffNew start a duplicate goroutine on the next refresh tick because the stamp from KickOffNew looked stale against the interval. Split into lastCompleted (set on success) and inFlight (set on dispatch, cleared after the walk goroutine returns success or failure). KickOffNew skips inFlight buckets regardless of cadence. 2. The cadence knob existed on `weed shell` but not on the production path: scheduler.Scheduler constructed BucketBootstrapper without BootstrapInterval, and weed/worker/tasks/s3_lifecycle/Config had no field for it. Add Scheduler.BootstrapInterval, parse `bootstrap_interval_minutes` in ParseConfig (zero = legacy walk- once-per-process; negative clamps to zero), and forward it from the handler. Tests cover default, override, clamp, and explicit-zero. 3. --force-bootstrap was a no-op: BucketBootstrapper is freshly allocated at command start, so MarkAllDirty on empty state does nothing, and the flag couldn't influence an already-running process anyway. Remove it; a real runtime trigger (SIGHUP, control RPC) is a separate change. In-flight regression: a blockingInjector pins the first walk in progress while the test advances the clock past the interval. The second KickOffNew is a no-op (inFlight check). After release, the post-completion KickOffNew within the interval is also a no-op. * test(s3/lifecycle): wait for lastCompleted stamp before advancing fake clock The cadence test polled listedN to know "the walk happened" — but that fires once both list passes are issued, while the success-stamp lands later, after walkBucketDir returns. A clock.Advance(30m) between those two events would record the stamp at clock+30m instead of T0; the next assertion would then see now.Sub(last) < 1h and skip the expected re-walk. Tight in practice but exposed under -race / load. Add a waitForCompleted helper that polls b.lastCompleted directly, and use it before each clock advance in both the cadence and zero- interval tests. * fix(s3/lifecycle): expose bootstrap interval in worker UI; honor MarkDirty during walks Two follow-ups on Phase 8. The worker config descriptor had no bootstrap_interval_minutes field, so the production operator UI couldn't enable the cadence — only the internal ParseConfig + Scheduler wiring knew about it. Add the field to the cadence section (MinValue=0 since 0 is the legacy default) and include the default in DefaultValues so existing deployments see the knob with the right preset. MarkDirty / MarkAllDirty silently lost their effect when a walk was in flight: the methods cleared lastCompleted, but the walk's success path then wrote a fresh timestamp, hiding the operator's invalidation. Track a pendingDirty set; the walk goroutine consumes the flag on exit and skips the success stamp, so the next KickOffNew picks the bucket up immediately. Regression: pin a walk in progress with a blockingInjector, MarkDirty the bucket, release the walk, and assert lastCompleted stayed empty plus the next KickOffNew triggers a new walk inside the BootstrapInterval window. * refactor(s3/lifecycle): drop unused MarkDirty / MarkAllDirty + pendingDirty These methods were the operator-hook half of Phase 8, but the only caller (--force-bootstrap on the shell command) was removed when it turned out to be a no-op against a freshly-allocated bootstrapper. Nothing in production calls them anymore. Strip the dead surface: MarkDirty, MarkAllDirty, the pendingDirty set, the dirty-suppression branch in walkBucket, and the three tests that only exercised those methods. BootstrapInterval-driven re-bootstrap is the live mechanism. A real runtime trigger (SIGHUP, control RPC) is a separate change with a real call site.
1297 lines
46 KiB
Go
1297 lines
46 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/metadata"
|
|
)
|
|
|
|
// fakeListStream implements grpc.ServerStreamingClient[filer_pb.ListEntriesResponse].
|
|
type fakeListStream struct {
|
|
responses []*filer_pb.ListEntriesResponse
|
|
index int
|
|
ctx context.Context
|
|
}
|
|
|
|
func (s *fakeListStream) Recv() (*filer_pb.ListEntriesResponse, error) {
|
|
if s.ctx != nil {
|
|
if err := s.ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if s.index >= len(s.responses) {
|
|
return nil, io.EOF
|
|
}
|
|
resp := s.responses[s.index]
|
|
s.index++
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *fakeListStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
|
|
func (s *fakeListStream) Trailer() metadata.MD { return metadata.MD{} }
|
|
func (s *fakeListStream) CloseSend() error { return nil }
|
|
func (s *fakeListStream) Context() context.Context {
|
|
if s.ctx != nil {
|
|
return s.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
func (s *fakeListStream) SendMsg(any) error { return nil }
|
|
func (s *fakeListStream) RecvMsg(any) error { return nil }
|
|
|
|
// fakeFilerClient embeds SeaweedFilerClient (nil interface) and overrides
|
|
// ListEntries + LookupDirectoryEntry. Calling any other method panics,
|
|
// which is fine for these tests.
|
|
type fakeFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
|
|
mu sync.Mutex
|
|
tree map[string][]*filer_pb.Entry // dir path -> immediate children
|
|
listed []string // dirs the walker asked about, in order
|
|
listedN int32 // atomic counter for cross-goroutine reads
|
|
}
|
|
|
|
func (c *fakeFilerClient) LookupDirectoryEntry(ctx context.Context, in *filer_pb.LookupDirectoryEntryRequest, opts ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, e := range c.tree[in.Directory] {
|
|
if e != nil && e.Name == in.Name {
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: e}, nil
|
|
}
|
|
}
|
|
return nil, filer_pb.ErrNotFound
|
|
}
|
|
|
|
func (c *fakeFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
|
|
c.mu.Lock()
|
|
c.listed = append(c.listed, in.Directory)
|
|
c.mu.Unlock()
|
|
atomic.AddInt32(&c.listedN, 1)
|
|
|
|
// Mirror the filer: sort children by name, honor StartFromFileName
|
|
// (exclusive unless InclusiveStartFrom), and cap at Limit. listAll's
|
|
// pagination loop depends on these semantics to advance correctly.
|
|
src := c.tree[in.Directory]
|
|
filtered := make([]*filer_pb.Entry, 0, len(src))
|
|
for _, e := range src {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if in.StartFromFileName != "" {
|
|
if in.InclusiveStartFrom {
|
|
if e.Name < in.StartFromFileName {
|
|
continue
|
|
}
|
|
} else if e.Name <= in.StartFromFileName {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
sort.SliceStable(filtered, func(i, j int) bool { return filtered[i].Name < filtered[j].Name })
|
|
if in.Limit > 0 && uint32(len(filtered)) > in.Limit {
|
|
filtered = filtered[:in.Limit]
|
|
}
|
|
responses := make([]*filer_pb.ListEntriesResponse, 0, len(filtered))
|
|
for _, e := range filtered {
|
|
responses = append(responses, &filer_pb.ListEntriesResponse{Entry: e})
|
|
}
|
|
return &fakeListStream{responses: responses, ctx: ctx}, nil
|
|
}
|
|
|
|
func (c *fakeFilerClient) listedCopy() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]string, len(c.listed))
|
|
copy(out, c.listed)
|
|
return out
|
|
}
|
|
|
|
// recordingInjector captures injected events; optionally returns a
|
|
// pre-set error to test propagation.
|
|
type recordingInjector struct {
|
|
mu sync.Mutex
|
|
events []*reader.Event
|
|
err error
|
|
}
|
|
|
|
func (r *recordingInjector) InjectEvent(_ context.Context, ev *reader.Event) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.events = append(r.events, ev)
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingInjector) snapshot() []*reader.Event {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]*reader.Event, len(r.events))
|
|
copy(out, r.events)
|
|
return out
|
|
}
|
|
|
|
func dirEntry(name string, extended map[string][]byte) *filer_pb.Entry {
|
|
return &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{},
|
|
Extended: extended,
|
|
}
|
|
}
|
|
|
|
func fileEntry(name string) *filer_pb.Entry {
|
|
return &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: false,
|
|
Attributes: &filer_pb.FuseAttributes{},
|
|
}
|
|
}
|
|
|
|
// ---------- isMPUInitDir ----------
|
|
|
|
func TestIsMPUInitDir_DetectsValidInitDir(t *testing.T) {
|
|
e := dirEntry("upload-id-1", map[string][]byte{
|
|
s3_constants.ExtMultipartObjectKey: []byte("foo/bar.txt"),
|
|
})
|
|
assert.True(t, isMPUInitDir(s3_constants.MultipartUploadsFolder+"/upload-id-1", e))
|
|
}
|
|
|
|
func TestIsMPUInitDir_RejectsUploadsRoot(t *testing.T) {
|
|
e := dirEntry(s3_constants.MultipartUploadsFolder, nil)
|
|
// key = ".uploads/" -> rest = "" -> false
|
|
assert.False(t, isMPUInitDir(s3_constants.MultipartUploadsFolder+"/", e))
|
|
}
|
|
|
|
func TestIsMPUInitDir_RejectsPartFile(t *testing.T) {
|
|
e := fileEntry("part-0001")
|
|
assert.False(t, isMPUInitDir(s3_constants.MultipartUploadsFolder+"/upload-id-1/part-0001", e))
|
|
}
|
|
|
|
func TestIsMPUInitDir_RejectsRegularKey(t *testing.T) {
|
|
e := fileEntry("key.txt")
|
|
assert.False(t, isMPUInitDir("regular/object/key", e))
|
|
}
|
|
|
|
func TestIsMPUInitDir_RejectsEmptyExtended(t *testing.T) {
|
|
e := dirEntry("upload-id-1", map[string][]byte{})
|
|
assert.False(t, isMPUInitDir(s3_constants.MultipartUploadsFolder+"/upload-id-1", e))
|
|
}
|
|
|
|
func TestIsMPUInitDir_RejectsEmptyMultipartObjectKeyValue(t *testing.T) {
|
|
e := dirEntry("upload-id-1", map[string][]byte{
|
|
s3_constants.ExtMultipartObjectKey: []byte(""),
|
|
})
|
|
assert.False(t, isMPUInitDir(s3_constants.MultipartUploadsFolder+"/upload-id-1", e))
|
|
}
|
|
|
|
// ---------- walkBucketDir ----------
|
|
|
|
const testBucketRoot = "/buckets/b1"
|
|
|
|
func TestWalkBucketDir_SingleRegularFile(t *testing.T) {
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {fileEntry("a.txt")},
|
|
},
|
|
}
|
|
var keys []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
keys = append(keys, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"a.txt"}, keys)
|
|
}
|
|
|
|
func TestWalkBucketDir_NestedDirectoriesUseBucketRelativeKeys(t *testing.T) {
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {dirEntry("sub", nil)},
|
|
testBucketRoot + "/sub": {dirEntry("deeper", nil), fileEntry("mid.txt")},
|
|
testBucketRoot + "/sub/deeper": {fileEntry("leaf.txt")},
|
|
},
|
|
}
|
|
var keys []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
keys = append(keys, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"sub/deeper/leaf.txt", "sub/mid.txt"}, keys)
|
|
}
|
|
|
|
func TestWalkBucketDir_MPUInitDirEmittedOnceAndNotRecursed(t *testing.T) {
|
|
uploadsDir := testBucketRoot + "/" + s3_constants.MultipartUploadsFolder
|
|
initDir := uploadsDir + "/upload-id-1"
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {dirEntry(s3_constants.MultipartUploadsFolder, nil)},
|
|
uploadsDir: {dirEntry("upload-id-1", map[string][]byte{
|
|
s3_constants.ExtMultipartObjectKey: []byte("foo/bar.txt"),
|
|
})},
|
|
// If recursed (it shouldn't), this part-0001 file would be emitted.
|
|
initDir: {fileEntry("part-0001")},
|
|
},
|
|
}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{s3_constants.MultipartUploadsFolder + "/upload-id-1"}, seen)
|
|
|
|
// Walker must not have descended into the init dir.
|
|
for _, d := range client.listedCopy() {
|
|
assert.NotEqual(t, initDir, d, "walker must not list init dir contents")
|
|
}
|
|
}
|
|
|
|
func TestWalkBucketDir_NonMPUDirUnderUploadsRecurses(t *testing.T) {
|
|
// Directory under .uploads/ that lacks ExtMultipartObjectKey: walker
|
|
// recurses normally and emits inner files.
|
|
uploadsDir := testBucketRoot + "/" + s3_constants.MultipartUploadsFolder
|
|
notInitDir := uploadsDir + "/missing-key"
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {dirEntry(s3_constants.MultipartUploadsFolder, nil)},
|
|
uploadsDir: {dirEntry("missing-key", nil)},
|
|
notInitDir: {fileEntry("part-0001")},
|
|
},
|
|
}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{s3_constants.MultipartUploadsFolder + "/missing-key/part-0001"}, seen)
|
|
}
|
|
|
|
func TestWalkBucketDir_MixedTreeBucketRelativeKeys(t *testing.T) {
|
|
uploadsDir := testBucketRoot + "/" + s3_constants.MultipartUploadsFolder
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {
|
|
fileEntry("top.txt"),
|
|
dirEntry("nested", nil),
|
|
dirEntry(s3_constants.MultipartUploadsFolder, nil),
|
|
},
|
|
testBucketRoot + "/nested": {fileEntry("inner.txt")},
|
|
uploadsDir: {dirEntry("upload-id-1", map[string][]byte{
|
|
s3_constants.ExtMultipartObjectKey: []byte("destkey"),
|
|
})},
|
|
},
|
|
}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{
|
|
"top.txt",
|
|
"nested/inner.txt",
|
|
s3_constants.MultipartUploadsFolder + "/upload-id-1",
|
|
}, seen)
|
|
}
|
|
|
|
func TestWalkBucketDir_CallbackErrorPropagates(t *testing.T) {
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {fileEntry("a.txt"), fileEntry("b.txt")},
|
|
},
|
|
}
|
|
cbErr := errors.New("boom")
|
|
calls := 0
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, _ string) error {
|
|
calls++
|
|
return cbErr
|
|
})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, cbErr)
|
|
// Walk must stop at the first failing callback.
|
|
assert.Equal(t, 1, calls)
|
|
}
|
|
|
|
func TestWalkBucketDir_ContextCancelledStopsWalk(t *testing.T) {
|
|
// Big tree under root; cancel context up front so list call returns
|
|
// the context error immediately.
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {fileEntry("a.txt"), dirEntry("d", nil)},
|
|
testBucketRoot + "/d": {fileEntry("inner.txt")},
|
|
},
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
var seen []string
|
|
err := walkBucketDir(ctx, client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.Error(t, err)
|
|
assert.Empty(t, seen, "no entries should be emitted after context cancel")
|
|
}
|
|
|
|
// ---------- BucketBootstrapper.KickOffNew ----------
|
|
|
|
// emptyClient: a filer client that always returns no children. Walks
|
|
// finish almost immediately.
|
|
func newEmptyFilerClient() *fakeFilerClient {
|
|
return &fakeFilerClient{tree: map[string][]*filer_pb.Entry{}}
|
|
}
|
|
|
|
// waitFor polls until cond returns true or fails the test.
|
|
func waitFor(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timeout waiting: %s", msg)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_LaunchesPerBucket(t *testing.T) {
|
|
client := newEmptyFilerClient()
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
}
|
|
|
|
b.KickOffNew(context.Background(), []string{"bucketA", "bucketB"})
|
|
|
|
// Each walk lists the bucket root twice (pass 1: .versions/, pass 2:
|
|
// everything else); 2 buckets * 2 passes = 4 listings total.
|
|
waitFor(t, func() bool {
|
|
return atomic.LoadInt32(&client.listedN) >= 4
|
|
}, "both bucket walks to complete")
|
|
|
|
listed := client.listedCopy()
|
|
seen := map[string]bool{}
|
|
for _, d := range listed {
|
|
seen[d] = true
|
|
}
|
|
assert.Equal(t, map[string]bool{"/buckets/bucketA": true, "/buckets/bucketB": true}, seen)
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, hasA := b.lastCompleted["bucketA"]
|
|
_, hasB := b.lastCompleted["bucketB"]
|
|
assert.True(t, hasA)
|
|
assert.True(t, hasB)
|
|
assert.Len(t, b.lastCompleted, 2)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_SkipsAlreadyKnown(t *testing.T) {
|
|
client := newEmptyFilerClient()
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
}
|
|
|
|
b.KickOffNew(context.Background(), []string{"bucketA", "bucketB"})
|
|
// Each walk does two ListEntries calls (pass 1: .versions/, pass 2:
|
|
// everything else). 2 buckets * 2 passes = 4 listings.
|
|
waitFor(t, func() bool {
|
|
return atomic.LoadInt32(&client.listedN) >= 4
|
|
}, "first wave to complete")
|
|
|
|
firstWave := atomic.LoadInt32(&client.listedN)
|
|
|
|
// Second call: bucketA is already known, bucketC is new. Only one
|
|
// new walk should fire (2 listings).
|
|
b.KickOffNew(context.Background(), []string{"bucketA", "bucketC"})
|
|
waitFor(t, func() bool {
|
|
return atomic.LoadInt32(&client.listedN) >= firstWave+2
|
|
}, "bucketC walk to complete")
|
|
|
|
// Give a moment for any spurious bucketA walk to also tick.
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
listed := client.listedCopy()
|
|
// Each bucket walks once across both calls; the walk does two
|
|
// listings of the bucket root (pass 1 + pass 2).
|
|
bucketCount := map[string]int{}
|
|
for _, d := range listed {
|
|
bucketCount[d]++
|
|
}
|
|
assert.Equal(t, 2, bucketCount["/buckets/bucketA"], "bucketA must be walked exactly once (=2 list calls) across both calls")
|
|
assert.Equal(t, 2, bucketCount["/buckets/bucketB"])
|
|
assert.Equal(t, 2, bucketCount["/buckets/bucketC"])
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
assert.Len(t, b.lastCompleted, 3)
|
|
_, hasC := b.lastCompleted["bucketC"]
|
|
assert.True(t, hasC)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_NilInjectorIsNoop(t *testing.T) {
|
|
client := newEmptyFilerClient()
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: nil,
|
|
}
|
|
require.NotPanics(t, func() {
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
})
|
|
// No walks must have been kicked off, and the lastWalk map must
|
|
// remain empty.
|
|
time.Sleep(20 * time.Millisecond)
|
|
assert.Equal(t, int32(0), atomic.LoadInt32(&client.listedN))
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
assert.Empty(t, b.lastCompleted)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_EmptyBucketListIsNoop(t *testing.T) {
|
|
client := newEmptyFilerClient()
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
}
|
|
b.KickOffNew(context.Background(), nil)
|
|
b.KickOffNew(context.Background(), []string{})
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
assert.Equal(t, int32(0), atomic.LoadInt32(&client.listedN))
|
|
assert.Empty(t, inj.snapshot())
|
|
}
|
|
|
|
// versionFile builds a version-file entry with the given mtime, version_id,
|
|
// and optional delete-marker flag.
|
|
func versionFile(versionID string, mtime time.Time, isMarker bool) *filer_pb.Entry {
|
|
ext := map[string][]byte{
|
|
s3_constants.ExtVersionIdKey: []byte(versionID),
|
|
}
|
|
if isMarker {
|
|
ext[s3_constants.ExtDeleteMarkerKey] = []byte("true")
|
|
}
|
|
return &filer_pb.Entry{
|
|
Name: "v_" + versionID,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: mtime.Unix(),
|
|
},
|
|
Extended: ext,
|
|
}
|
|
}
|
|
|
|
func TestWalkBucketDir_VersionsDirEmittedOnceAndNotRecursed(t *testing.T) {
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v2"),
|
|
})
|
|
now := time.Now()
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", now.Add(-2*time.Hour), false),
|
|
versionFile("v2", now, false),
|
|
},
|
|
},
|
|
}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo" + s3_constants.VersionsFolder}, seen)
|
|
assert.NotContains(t, client.listedCopy(), testBucketRoot+"/foo"+s3_constants.VersionsFolder)
|
|
}
|
|
|
|
func TestWalkBucketDir_VersionsDirEmittedRegardlessOfLatestPointer(t *testing.T) {
|
|
// walkBucketDir matches <x>.versions/ purely on the name suffix —
|
|
// gating on ExtLatestVersionIdKey would lose the race window where
|
|
// the version file exists before the parent's metadata update lands.
|
|
// expandVersionsDir handles disambiguation by inspecting children.
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, nil)
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {fileEntry("inner.txt")},
|
|
},
|
|
}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo" + s3_constants.VersionsFolder}, seen)
|
|
}
|
|
|
|
func TestExpandVersionsDir_CoincidentallyNamedFolderRecursesViaFallback(t *testing.T) {
|
|
// A user-created folder happening to end in .versions/ has children
|
|
// without ExtVersionIdKey. expandVersionsDir must recurse via the
|
|
// fallback callback so inner files still emit normal events.
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, nil)
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
fileEntry("inner.txt"),
|
|
dirEntry("sub", nil),
|
|
},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder + "/sub": {fileEntry("deep.txt")},
|
|
},
|
|
}
|
|
var seen []string
|
|
cb := func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets"}
|
|
count, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, cb, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, count, "fallback path returns 0 — events go through cb")
|
|
assert.ElementsMatch(t, []string{"foo" + s3_constants.VersionsFolder + "/inner.txt", "foo" + s3_constants.VersionsFolder + "/sub/deep.txt"}, seen)
|
|
}
|
|
|
|
func TestExpandVersionsDir_RaceWithMissingPointerStillExpands(t *testing.T) {
|
|
// Real .versions container whose parent metadata update hasn't
|
|
// landed yet (no ExtLatestVersionIdKey on the dir). Children DO
|
|
// carry ExtVersionIdKey. expandVersionsDir must still emit version
|
|
// events; missing-pointer fallback (newest-by-mtime as latest)
|
|
// covers retention safety.
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, nil) // no Extended at all
|
|
now := time.Now()
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", now.Add(-2*time.Hour), false),
|
|
versionFile("v2", now.Add(-1*time.Hour), false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
count, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, count)
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v2"].IsLatest, "newest by mtime is latest when pointer missing")
|
|
}
|
|
|
|
func TestExpandVersionsDir_LatestAndNoncurrentsByMtime(t *testing.T) {
|
|
now := time.Now()
|
|
v1mt := now.Add(-3 * time.Hour)
|
|
v2mt := now.Add(-2 * time.Hour)
|
|
v3mt := now.Add(-1 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v3"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
versionFile("v2", v2mt, false),
|
|
versionFile("v3", v3mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
|
|
count, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 3, count)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
require.NotNil(t, ev.BootstrapVersion)
|
|
assert.Equal(t, "foo", ev.BootstrapVersion.LogicalKey)
|
|
assert.Equal(t, 3, ev.BootstrapVersion.NumVersions)
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
require.Contains(t, byID, "v1")
|
|
require.Contains(t, byID, "v2")
|
|
require.Contains(t, byID, "v3")
|
|
|
|
assert.True(t, byID["v3"].IsLatest)
|
|
assert.True(t, byID["v3"].SuccessorModTime.IsZero(), "newest sibling has no successor")
|
|
|
|
assert.False(t, byID["v2"].IsLatest)
|
|
assert.Equal(t, 0, byID["v2"].NoncurrentIndex, "newest noncurrent")
|
|
assert.Equal(t, v3mt.Unix(), byID["v2"].SuccessorModTime.Unix())
|
|
|
|
assert.False(t, byID["v1"].IsLatest)
|
|
assert.Equal(t, 1, byID["v1"].NoncurrentIndex)
|
|
assert.Equal(t, v2mt.Unix(), byID["v1"].SuccessorModTime.Unix())
|
|
}
|
|
|
|
func TestExpandVersionsDir_LatestPointerOutOfOrderByMtime(t *testing.T) {
|
|
// Backdated PUT scenario: latest pointer names v1 but v1's mtime is
|
|
// OLDER than v2's. After newest-first sort the order is [v2, v1] so
|
|
// latestPos == 1, exercising the rank-skip path for the noncurrent.
|
|
now := time.Now()
|
|
v1mt := now.Add(-3 * time.Hour)
|
|
v2mt := now.Add(-1 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v1"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
versionFile("v2", v2mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v1"].IsLatest)
|
|
assert.False(t, byID["v2"].IsLatest)
|
|
assert.Equal(t, 0, byID["v2"].NoncurrentIndex, "v2 is the only noncurrent → rank 0")
|
|
}
|
|
|
|
func TestExpandVersionsDir_MissingLatestPointerFallsBackToNewest(t *testing.T) {
|
|
// No latest pointer (rare race window): treat the newest sibling
|
|
// by mtime as latest so retention isn't unsafe.
|
|
now := time.Now()
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", now.Add(-2*time.Hour), false),
|
|
versionFile("v2", now.Add(-1*time.Hour), false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v2"].IsLatest, "newest by mtime is latest when pointer missing")
|
|
assert.False(t, byID["v1"].IsLatest)
|
|
}
|
|
|
|
func TestExpandVersionsDir_DeleteMarkerFlagPropagated(t *testing.T) {
|
|
now := time.Now()
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v-marker"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v-marker", now, true),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
events := inj.snapshot()
|
|
require.Len(t, events, 1)
|
|
assert.True(t, events[0].BootstrapVersion.IsDeleteMarker)
|
|
assert.True(t, events[0].BootstrapVersion.IsLatest)
|
|
}
|
|
|
|
func bareFile(name string, mtime time.Time) *filer_pb.Entry {
|
|
return &filer_pb.Entry{
|
|
Name: name,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: mtime.Unix(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// suspendedNullFile mirrors the suspended-versioning write path: the
|
|
// bare entry carries ExtVersionIdKey="null" so bootstrap can tell it
|
|
// apart from a pre-versioning bare object during a pointer-missing
|
|
// race window.
|
|
func suspendedNullFile(name string, mtime time.Time) *filer_pb.Entry {
|
|
return &filer_pb.Entry{
|
|
Name: name,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: mtime.Unix(),
|
|
},
|
|
Extended: map[string][]byte{
|
|
s3_constants.ExtVersionIdKey: []byte("null"),
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestExpandVersionsDir_PreVersioningNullIsNoncurrent(t *testing.T) {
|
|
// Object existed pre-versioning as the bare key. Versioning was
|
|
// enabled and a newer version v1 was PUT under .versions/. The
|
|
// .versions/ latest pointer names v1, so null is noncurrent.
|
|
now := time.Now()
|
|
v1mt := now.Add(-1 * time.Hour)
|
|
nullMt := now.Add(-3 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v1"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {bareFile("foo", nullMt), versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
skipBare := map[string]bool{}
|
|
count, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, skipBare)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, count)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v1"].IsLatest)
|
|
assert.False(t, byID["null"].IsLatest)
|
|
assert.Equal(t, 0, byID["null"].NoncurrentIndex)
|
|
assert.Equal(t, 2, byID["null"].NumVersions)
|
|
assert.True(t, skipBare["foo"], "bare-key skip recorded")
|
|
}
|
|
|
|
func TestExpandVersionsDir_SuspendedNullIsCurrent(t *testing.T) {
|
|
// Suspended-bucket scenario: a write to the null version cleared the
|
|
// .versions/ latest pointer AND tagged the bare entry with
|
|
// ExtVersionIdKey="null". Older real versions remain in .versions/.
|
|
// Null must be IsLatest=true; .versions/ children become noncurrent.
|
|
now := time.Now()
|
|
v1mt := now.Add(-3 * time.Hour)
|
|
v2mt := now.Add(-2 * time.Hour)
|
|
nullMt := now.Add(-1 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{}) // pointer cleared
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {suspendedNullFile("foo", nullMt), versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
versionFile("v2", v2mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
skipBare := map[string]bool{}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, skipBare)
|
|
require.NoError(t, err)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["null"].IsLatest, "pointer cleared + null exists -> null is latest")
|
|
assert.False(t, byID["v1"].IsLatest)
|
|
assert.False(t, byID["v2"].IsLatest)
|
|
assert.True(t, skipBare["foo"])
|
|
}
|
|
|
|
func TestExpandVersionsDir_NullVersionDirectoryKeyMarker(t *testing.T) {
|
|
// Directory-key marker (object name ends in /): the bare entry is a
|
|
// directory with Mime set. Treat as null version.
|
|
now := time.Now()
|
|
v1mt := now.Add(-1 * time.Hour)
|
|
dirMarker := &filer_pb.Entry{
|
|
Name: "foo",
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: now.Add(-3 * time.Hour).Unix(),
|
|
Mime: "application/x-directory",
|
|
},
|
|
}
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v1"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {dirMarker, versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
skipBare := map[string]bool{}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, skipBare)
|
|
require.NoError(t, err)
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
require.Contains(t, byID, "null", "directory-key marker counts as null version")
|
|
assert.True(t, skipBare["foo"])
|
|
}
|
|
|
|
func TestWalkBucketDir_VersionsDirOrderingClaimsNullSibling(t *testing.T) {
|
|
// End-to-end through walkBucket: bare foo + foo.versions/ are
|
|
// siblings in the same directory. The two-pass walker processes
|
|
// the .versions/ first; expandVersionsDir claims "foo" as null;
|
|
// the second pass sees skipBare["foo"]==true and emits no regular
|
|
// event for the bare entry.
|
|
now := time.Now()
|
|
v1mt := now.Add(-1 * time.Hour)
|
|
nullMt := now.Add(-3 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v1"),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {bareFile("foo", nullMt), versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
b.KickOffNew(context.Background(), []string{"b1"})
|
|
// give the goroutine time to finish
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && len(inj.snapshot()) < 2 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
events := inj.snapshot()
|
|
|
|
// Exactly two events: v1 (latest) and null (noncurrent). NO third
|
|
// regular event for the bare "foo" entry.
|
|
assert.Len(t, events, 2)
|
|
versionIDs := []string{}
|
|
for _, ev := range events {
|
|
require.NotNil(t, ev.BootstrapVersion, "all events must be BootstrapVersion-tagged")
|
|
versionIDs = append(versionIDs, ev.BootstrapVersion.VersionID)
|
|
}
|
|
assert.ElementsMatch(t, []string{"v1", "null"}, versionIDs)
|
|
}
|
|
|
|
func TestExpandVersionsDir_VersionIDTiebreakOnSameSecondMtime(t *testing.T) {
|
|
// Two versions written in the same second: Mtime ties. The
|
|
// CompareVersionIds tiebreak puts the version_id with newer
|
|
// canonical ordering first. Use new-format IDs (inverted timestamps)
|
|
// so smaller string sorts as newer.
|
|
now := time.Now().Truncate(time.Second)
|
|
idNewer := "8000000000000000aaaaaaaaaaaaaaaa"
|
|
idOlder := "9000000000000000bbbbbbbbbbbbbbbb"
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte(idNewer),
|
|
})
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile(idOlder, now, false),
|
|
versionFile(idNewer, now, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID[idNewer].IsLatest, "newer canonical id wins the tiebreak")
|
|
assert.False(t, byID[idOlder].IsLatest)
|
|
assert.Equal(t, 0, byID[idOlder].NoncurrentIndex)
|
|
}
|
|
|
|
func TestExpandVersionsDir_PreVersioningNullDuringPointerRaceFallsBackToNewest(t *testing.T) {
|
|
// Pre-versioning bare object existed when versioning was enabled.
|
|
// A new version v1 was just written under .versions/<file> but the
|
|
// parent's ExtLatestVersionIdKey update has not landed yet. The
|
|
// bare entry has NO ExtVersionIdKey marker — distinguishing it from
|
|
// a suspended-bucket write. Bootstrap must treat v1 as latest (the
|
|
// newest sibling) and the implicit null as noncurrent, so the null
|
|
// expiration is scheduled this run instead of waiting for a future
|
|
// bootstrap.
|
|
now := time.Now()
|
|
v1mt := now.Add(-1 * time.Hour) // newer
|
|
nullMt := now.Add(-3 * time.Hour)
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{}) // pointer not yet written
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {bareFile("foo", nullMt), versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v1", v1mt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
skipBare := map[string]bool{}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, skipBare)
|
|
require.NoError(t, err)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v1"].IsLatest, "newest sibling wins when null is implicit")
|
|
assert.False(t, byID["null"].IsLatest, "implicit null is noncurrent during pointer-missing race")
|
|
}
|
|
|
|
func TestExpandVersionsDir_SuspendedThenReEnabledNullIsNoncurrent(t *testing.T) {
|
|
// Bucket was suspended: bare entry was written with
|
|
// ExtVersionIdKey="null" and the .versions/ pointer cleared.
|
|
// Versioning was re-enabled and a fresh PUT created
|
|
// .versions/<v-new> with newer mtime, but the pointer-update for
|
|
// that new version hasn't landed yet. Bootstrap running in this
|
|
// window must keep v-new as latest (it's newest by mtime); the
|
|
// explicit null is noncurrent. Promoting the older null to latest
|
|
// just because it's explicit would skip current-version expiration
|
|
// of v-new and never schedule the null's noncurrent retention.
|
|
now := time.Now()
|
|
nullMt := now.Add(-3 * time.Hour) // OLDER bare-null
|
|
vNewMt := now.Add(-1 * time.Hour) // newer real version
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{}) // pointer not yet written
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot: {suspendedNullFile("foo", nullMt), versionsDir},
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: {
|
|
versionFile("v-new", vNewMt, false),
|
|
},
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
skipBare := map[string]bool{}
|
|
_, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, skipBare)
|
|
require.NoError(t, err)
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
assert.True(t, byID["v-new"].IsLatest, "newest sibling wins even when an older explicit null exists")
|
|
assert.False(t, byID["null"].IsLatest)
|
|
assert.Equal(t, 0, byID["null"].NoncurrentIndex)
|
|
}
|
|
|
|
func TestExpandVersionsDir_PaginatesBeyondListingLimit(t *testing.T) {
|
|
// The filer caps SeaweedList(..., limit=0) at DirListingLimit per
|
|
// call. Expanding a hot key with more versions than that limit
|
|
// would silently truncate, so the rank/sort math would be wrong
|
|
// past the boundary. listAll paginates via StartFromFileName.
|
|
// Shrink listPageSize so the test doesn't need thousands of entries.
|
|
prevPageSize := listPageSize.Load()
|
|
listPageSize.Store(2)
|
|
t.Cleanup(func() { listPageSize.Store(prevPageSize) })
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
const total = 7
|
|
versionsDir := dirEntry("foo"+s3_constants.VersionsFolder, map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte("v0"),
|
|
})
|
|
versions := make([]*filer_pb.Entry, 0, total)
|
|
for i := 0; i < total; i++ {
|
|
// v0 is newest; v6 is oldest. Names are v_v00 .. v_v06 so the
|
|
// sort-by-name in the fake matches the sort-by-mtime here.
|
|
versions = append(versions, versionFile(fmt.Sprintf("v%02d", i), now.Add(-time.Duration(i)*time.Hour), false))
|
|
}
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
testBucketRoot + "/foo" + s3_constants.VersionsFolder: versions,
|
|
},
|
|
}
|
|
inj := &recordingInjector{}
|
|
b := &BucketBootstrapper{FilerClient: client, BucketsPath: "/buckets", Injector: inj}
|
|
count, err := b.expandVersionsDir(context.Background(), "b1", testBucketRoot, "foo"+s3_constants.VersionsFolder, versionsDir, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, total, count, "every page must reach the injector")
|
|
|
|
listed := client.listedCopy()
|
|
calls := 0
|
|
for _, d := range listed {
|
|
if d == testBucketRoot+"/foo"+s3_constants.VersionsFolder {
|
|
calls++
|
|
}
|
|
}
|
|
// Pages of 2 over 7 items = 4 calls (2+2+2+1). Loop exits once
|
|
// page count < listPageSize on the 4th call.
|
|
assert.Equal(t, 4, calls, "must paginate via StartFromFileName")
|
|
|
|
byID := map[string]*reader.BootstrapVersion{}
|
|
for _, ev := range inj.snapshot() {
|
|
byID[ev.BootstrapVersion.VersionID] = ev.BootstrapVersion
|
|
}
|
|
require.Len(t, byID, total, "no version dropped at a page boundary")
|
|
for i := 0; i < total; i++ {
|
|
bv := byID[fmt.Sprintf("v%02d", i)]
|
|
require.NotNil(t, bv)
|
|
assert.Equal(t, total, bv.NumVersions, "NumVersions reflects every page")
|
|
}
|
|
// v0 is the latest pointer target and the newest by mtime.
|
|
assert.True(t, byID["v00"].IsLatest)
|
|
// v6 is the oldest noncurrent.
|
|
assert.Equal(t, total-2, byID["v06"].NoncurrentIndex)
|
|
}
|
|
|
|
func TestWalkBucketDir_PaginatesBeyondListingLimit(t *testing.T) {
|
|
// Same correctness story for the bucket-level walk: hot buckets
|
|
// with thousands of objects must not silently truncate.
|
|
prevPageSize := listPageSize.Load()
|
|
listPageSize.Store(2)
|
|
t.Cleanup(func() { listPageSize.Store(prevPageSize) })
|
|
|
|
const total = 5
|
|
rootChildren := make([]*filer_pb.Entry, 0, total)
|
|
for i := 0; i < total; i++ {
|
|
rootChildren = append(rootChildren, fileEntry(fmt.Sprintf("k%02d", i)))
|
|
}
|
|
client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{testBucketRoot: rootChildren}}
|
|
var seen []string
|
|
err := walkBucketDir(context.Background(), client, testBucketRoot, testBucketRoot, func(_ *filer_pb.Entry, key string) error {
|
|
seen = append(seen, key)
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
want := make([]string, 0, total)
|
|
for i := 0; i < total; i++ {
|
|
want = append(want, fmt.Sprintf("k%02d", i))
|
|
}
|
|
assert.ElementsMatch(t, want, seen, "every page processed")
|
|
|
|
listed := client.listedCopy()
|
|
calls := 0
|
|
for _, d := range listed {
|
|
if d == testBucketRoot {
|
|
calls++
|
|
}
|
|
}
|
|
// Each directory is streamed twice (pass 1: .versions/, pass 2:
|
|
// everything else) to keep memory bounded on flat buckets. 5
|
|
// entries at page 2 = 3 paginated calls per pass = 6 total.
|
|
assert.Equal(t, 6, calls)
|
|
}
|
|
|
|
// fakeClock is a thread-safe time source for tests that need to fast-
|
|
// forward across a BootstrapInterval boundary. Bootstrap goroutines
|
|
// read it concurrently with the test advancing it, so a plain
|
|
// `clock := time.Now()` plus closure write would race under -race.
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
t time.Time
|
|
}
|
|
|
|
func newFakeClock() *fakeClock { return &fakeClock{t: time.Now()} }
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.t
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
c.t = c.t.Add(d)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// waitForCompleted blocks until the bootstrapper has stamped a
|
|
// lastCompleted entry for the given bucket. Polling listedN is not
|
|
// enough — that fires once both list passes have started, but
|
|
// walkBucket stamps lastCompleted only after walkBucketDir returns,
|
|
// so a clock.Advance between those events would record the stamp at
|
|
// post-advance time and skew BootstrapInterval cadence assertions.
|
|
func waitForCompleted(t *testing.T, b *BucketBootstrapper, bucket string) {
|
|
t.Helper()
|
|
waitFor(t, func() bool {
|
|
b.mu.Lock()
|
|
_, ok := b.lastCompleted[bucket]
|
|
b.mu.Unlock()
|
|
return ok
|
|
}, "lastCompleted stamp for "+bucket)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_BootstrapIntervalRevisitsBucket(t *testing.T) {
|
|
// scan_only actions only fire from bootstrap, so a long-running
|
|
// worker has to revisit each bucket on a cadence. With
|
|
// BootstrapInterval set, KickOffNew re-walks once enough wall-clock
|
|
// has passed since the last completed walk.
|
|
client := newEmptyFilerClient()
|
|
inj := &recordingInjector{}
|
|
clock := newFakeClock()
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
BootstrapInterval: time.Hour,
|
|
Now: clock.Now,
|
|
}
|
|
|
|
// First wave: walks once. Wait for the goroutine to actually stamp
|
|
// lastCompleted before advancing the clock — otherwise the stamp
|
|
// could land at clock+30m instead of T0 and the cadence assertion
|
|
// would race.
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
waitForCompleted(t, b, "bucketA")
|
|
firstCount := atomic.LoadInt32(&client.listedN)
|
|
|
|
// Inside the interval: skip.
|
|
clock.Advance(30 * time.Minute)
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&client.listedN); got != firstCount {
|
|
t.Fatalf("inside interval, must not re-walk; listedN=%d, want %d", got, firstCount)
|
|
}
|
|
|
|
// Past the interval: re-walk.
|
|
clock.Advance(45 * time.Minute) // total elapsed > 1h
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
waitFor(t, func() bool { return atomic.LoadInt32(&client.listedN) >= firstCount+2 }, "re-walk after interval")
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_ZeroIntervalLegacyOnceOnly(t *testing.T) {
|
|
// BootstrapInterval == 0 preserves the original "walk once per
|
|
// process" behavior so existing deployments don't get a different
|
|
// cadence by default.
|
|
client := newEmptyFilerClient()
|
|
inj := &recordingInjector{}
|
|
clock := newFakeClock()
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
Now: clock.Now,
|
|
}
|
|
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
waitForCompleted(t, b, "bucketA")
|
|
firstCount := atomic.LoadInt32(&client.listedN)
|
|
|
|
// Even after 100 hours, KickOffNew skips the bucket.
|
|
clock.Advance(100 * time.Hour)
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&client.listedN); got != firstCount {
|
|
t.Fatalf("zero interval must keep the once-per-process behavior; listedN=%d, want %d", got, firstCount)
|
|
}
|
|
}
|
|
|
|
// blockingInjector lets the test pin a walk in progress until it
|
|
// signals release. Useful for asserting in-flight debounce.
|
|
type blockingInjector struct {
|
|
mu sync.Mutex
|
|
events []*reader.Event
|
|
released chan struct{}
|
|
}
|
|
|
|
func newBlockingInjector() *blockingInjector {
|
|
return &blockingInjector{released: make(chan struct{})}
|
|
}
|
|
|
|
func (b *blockingInjector) InjectEvent(ctx context.Context, ev *reader.Event) error {
|
|
select {
|
|
case <-b.released:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
b.mu.Lock()
|
|
b.events = append(b.events, ev)
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *blockingInjector) release() { close(b.released) }
|
|
|
|
func (b *blockingInjector) eventCount() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return len(b.events)
|
|
}
|
|
|
|
func TestBucketBootstrapper_KickOffNew_InFlightDebounceBlocksDuplicate(t *testing.T) {
|
|
// A walk that takes longer than BootstrapInterval would otherwise
|
|
// have a fresh KickOffNew start a duplicate goroutine on the next
|
|
// refresh tick. The inFlight set prevents that. Verify by:
|
|
// 1) Pinning a walk in progress via a blockingInjector,
|
|
// 2) Advancing the clock past BootstrapInterval,
|
|
// 3) Confirming the second KickOffNew is a no-op while the first
|
|
// is still running,
|
|
// 4) Releasing the first walk and asserting only one walk
|
|
// completed (one bucket-root listing pair).
|
|
client := &fakeFilerClient{
|
|
tree: map[string][]*filer_pb.Entry{
|
|
"/buckets/bucketA": {fileEntry("a.txt")},
|
|
},
|
|
}
|
|
inj := newBlockingInjector()
|
|
clock := newFakeClock()
|
|
b := &BucketBootstrapper{
|
|
FilerClient: client,
|
|
BucketsPath: "/buckets",
|
|
Injector: inj,
|
|
BootstrapInterval: time.Hour,
|
|
Now: clock.Now,
|
|
}
|
|
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
// Wait for the walk goroutine to actually start listing (pass 1
|
|
// fires a ListEntries before InjectEvent).
|
|
waitFor(t, func() bool { return atomic.LoadInt32(&client.listedN) >= 1 }, "first walk to begin listing")
|
|
|
|
// Advance past the interval — a stale-state KickOffNew would now
|
|
// see the lastCompleted as expired and try again.
|
|
clock.Advance(2 * time.Hour)
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := inj.eventCount(); got != 0 {
|
|
t.Fatalf("first walk still blocked, got %d injected events from a phantom second walk", got)
|
|
}
|
|
|
|
// Release: the first walk completes. eventCount goes to 1.
|
|
inj.release()
|
|
waitFor(t, func() bool { return inj.eventCount() == 1 }, "first walk to drain")
|
|
// Even after another KickOffNew at the same simulated time, the
|
|
// in-flight is now cleared and lastCompleted is fresh — second
|
|
// KickOffNew within interval is a no-op.
|
|
prevListed := atomic.LoadInt32(&client.listedN)
|
|
b.KickOffNew(context.Background(), []string{"bucketA"})
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&client.listedN); got != prevListed {
|
|
t.Fatalf("post-release KickOffNew within interval must be a no-op; listedN=%d, want %d", got, prevListed)
|
|
}
|
|
}
|
|
|