mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 05:24:28 +02:00
* s3 lifecycle: share the daily bucket walk across shards The daily pass invoked the walker once per shard, and every shard's walk listed the full bucket tree while keeping only its own 1/16 of the entries — 16 full listings per pass by default (#11791). Run now groups the shards by the walk branch they'll take (recovery vs steady, using the same cursor data and hash checks runShard applies) and starts one shared walk per group, so a pass lists each bucket at most twice. WalkerFunc and WalkBuckets take a shard set instead of a single shard. runShard keeps its own decision and waits on its group's walk; a shard whose freshly-loaded cursor disagrees with the plan falls back to a per-shard walk, so mid-pass cursor rewrites can't under-cover a shard. * s3 lifecycle: skip the .versions/ pre-pass on non-versioned buckets walkBucketTree listed every directory twice — once looking for .versions/ dirs, once for regular entries. On a bucket that never had versioning enabled the first pass can only find coincidentally-named user folders, so the extra listing is pure cost (#11791). FilerListFunc now takes the walk view and consults Snapshot.BucketVersioned; non-versioned buckets skip pass 1 entirely. A *-suffixed .versions user folder on such a bucket is recursed into as a regular directory so real objects inside still surface; a nil view keeps the two-pass behavior. * s3 lifecycle: prune walk directories to active rule prefixes The daily walk listed every directory under a bucket even when no rule prefix could reach it. Collect the active rule prefixes of each bucket from the walk view (new Snapshot.BucketPrefixes) and skip recursing into directories whose keys can never match; a bucket with no active actions is skipped entirely, saving the listing. .uploads stays in scope regardless of prefixes because AbortMPU rules match DestKey, and .versions expansion is pruned only conservatively (the logical key is a string-prefix of the dir key). * s3 lifecycle: join planned walks before Run returns planWalks starts a shared walk goroutine per group, but a shard whose authoritative cursor load then fails exits runShard without waiting on it. Run returned while the walk still listed or dispatched deletes against connections the caller closes on return. Join every planned walk after the shard waitgroup. * s3 lifecycle: treat object-locked buckets as versioned in the walk Object Lock requires versioning and every writer sets both extended attributes, but IsBucketVersioned only read ExtVersioningKey. A locked bucket missing that attribute would have its .versions dirs walked as ordinary folders, hiding version siblings from lifecycle. Classify ExtObjectLockEnabledKey as versioned too.
233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Issue #11791: a pass used to invoke the walker once per shard, so the
|
|
// whole bucket tree was listed ShardCount times. The walker now gets
|
|
// the full shard set in at most two calls per pass — one for the
|
|
// recovery group, one for the steady group.
|
|
func TestRun_SharesBucketWalkAcrossShards(t *testing.T) {
|
|
e := engine.New()
|
|
e.Compile([]engine.CompileInput{
|
|
{Bucket: "b1", Rules: []*s3lifecycle.Rule{
|
|
{ID: "r", Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: time.Now().Add(24 * time.Hour)},
|
|
}},
|
|
}, engine.CompileOptions{})
|
|
|
|
shards := make([]int, 0, s3lifecycle.ShardCount)
|
|
for i := 0; i < s3lifecycle.ShardCount; i++ {
|
|
shards = append(shards, i)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var calls [][]int
|
|
p := newMemPersister()
|
|
cfg := Config{
|
|
Shards: shards,
|
|
BucketsPath: "/buckets",
|
|
Engine: e,
|
|
FilerClient: stubFilerClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: p,
|
|
Lister: stubSiblingLister{},
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, shards []int) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
calls = append(calls, append([]int{}, shards...))
|
|
return nil
|
|
},
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
cfg.Now = func() time.Time { return runNow }
|
|
require.NoError(t, Run(context.Background(), cfg))
|
|
|
|
require.LessOrEqual(t, len(calls), 2, "a pass must not list the bucket once per shard")
|
|
covered := map[int]bool{}
|
|
for _, c := range calls {
|
|
for _, sh := range c {
|
|
covered[sh] = true
|
|
}
|
|
}
|
|
for _, sh := range shards {
|
|
assert.True(t, covered[sh], "shard %d missed the shared walk", sh)
|
|
c, found, err := p.Load(context.Background(), sh)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
assert.Equal(t, runNow.UnixNano(), c.LastWalkedNs,
|
|
"shard %d must credit the shared walk to its own cursor", sh)
|
|
}
|
|
}
|
|
|
|
// A recovery group and a steady group in the same pass share one walk
|
|
// each: two walker calls total, each covering its own shard set and
|
|
// its own view.
|
|
func TestRun_RecoveryAndSteadyGroupsWalkSeparately(t *testing.T) {
|
|
e := engine.New()
|
|
e.Compile([]engine.CompileInput{
|
|
{Bucket: "b1", Rules: []*s3lifecycle.Rule{
|
|
{ID: "r", Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 30,
|
|
ExpirationDate: time.Now().Add(24 * time.Hour)},
|
|
}},
|
|
}, engine.CompileOptions{})
|
|
snap := e.Snapshot()
|
|
for _, a := range snap.AllActions() {
|
|
snap.MarkActive(a.Key)
|
|
}
|
|
|
|
p := newMemPersister()
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xAA
|
|
}
|
|
// Shard 0's stale rule hash sends it through the recovery walk.
|
|
// Shards 1 and 2 have matching hashes and a zero walk anchor, so
|
|
// the steady-state branch takes them.
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{TsNs: 42, RuleSetHash: stale}))
|
|
matching := Cursor{
|
|
TsNs: 1234,
|
|
RuleSetHash: engine.ReplayContentHash(snap),
|
|
PromotedHash: engine.PromotedHash(snap, engine.MaxEffectiveTTL(snap)),
|
|
}
|
|
for _, sh := range []int{1, 2} {
|
|
require.NoError(t, p.Save(context.Background(), sh, matching))
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var calls [][]int
|
|
var actions []int
|
|
cfg := Config{
|
|
Shards: []int{0, 1, 2},
|
|
BucketsPath: "/buckets",
|
|
Engine: e,
|
|
FilerClient: &idleSubscribeClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: p,
|
|
Lister: stubSiblingLister{},
|
|
Walker: func(_ context.Context, view *engine.Snapshot, shards []int) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
calls = append(calls, append([]int{}, shards...))
|
|
actions = append(actions, len(view.AllActions()))
|
|
return nil
|
|
},
|
|
}
|
|
require.NoError(t, Run(context.Background(), cfg))
|
|
|
|
require.Len(t, calls, 2, "one shared walk per group: recovery + steady")
|
|
for i, c := range calls {
|
|
sort.Ints(c)
|
|
switch {
|
|
case len(c) == 1 && c[0] == 0:
|
|
assert.Equal(t, 2, actions[i], "recovery view carries every non-disabled action")
|
|
case len(c) == 2 && c[0] == 1 && c[1] == 2:
|
|
assert.Equal(t, 1, actions[i], "walk view carries only the walker-bound action")
|
|
default:
|
|
t.Fatalf("unexpected walk group %v", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A shared walk error must surface on every shard it covered, not just
|
|
// the one that happened to run the listing.
|
|
func TestRun_SharedWalkErrorFailsCoveredShards(t *testing.T) {
|
|
e := engine.New()
|
|
e.Compile([]engine.CompileInput{
|
|
{Bucket: "b1", Rules: []*s3lifecycle.Rule{
|
|
{ID: "r", Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: time.Now().Add(24 * time.Hour)},
|
|
}},
|
|
}, engine.CompileOptions{})
|
|
|
|
cfg := Config{
|
|
Shards: []int{0, 1},
|
|
BucketsPath: "/buckets",
|
|
Engine: e,
|
|
FilerClient: stubFilerClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: newMemPersister(),
|
|
Lister: stubSiblingLister{},
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ []int) error {
|
|
return errors.New("walk boom")
|
|
},
|
|
}
|
|
err := Run(context.Background(), cfg)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "walk boom")
|
|
}
|
|
|
|
// countingPersister fails Load after the first failAfter calls: call 1
|
|
// feeds planWalks, the failing call 2 is runShard's authoritative read.
|
|
type countingPersister struct {
|
|
inner CursorPersister
|
|
calls atomic.Int32
|
|
failAfter int32
|
|
}
|
|
|
|
func (p *countingPersister) Load(ctx context.Context, shardID int) (Cursor, bool, error) {
|
|
if p.calls.Add(1) > p.failAfter {
|
|
return Cursor{}, false, errors.New("transient load failure")
|
|
}
|
|
return p.inner.Load(ctx, shardID)
|
|
}
|
|
|
|
func (p *countingPersister) Save(ctx context.Context, shardID int, c Cursor) error {
|
|
return p.inner.Save(ctx, shardID, c)
|
|
}
|
|
|
|
// A shard whose second cursor load fails exits runShard without
|
|
// awaiting the planned walk it was grouped into. The walk goroutine is
|
|
// already running — Run must still join it, or the handler tears down
|
|
// the filer connection while the walk is mid-listing.
|
|
func TestRun_JoinsPlannedWalkWhenShardExitsEarly(t *testing.T) {
|
|
e := engine.New()
|
|
e.Compile([]engine.CompileInput{
|
|
{Bucket: "b1", Rules: []*s3lifecycle.Rule{
|
|
{ID: "r", Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: time.Now().Add(24 * time.Hour)},
|
|
}},
|
|
}, engine.CompileOptions{})
|
|
|
|
walkStarted := make(chan struct{})
|
|
walkRelease := make(chan struct{})
|
|
cfg := Config{
|
|
Shards: []int{0},
|
|
BucketsPath: "/buckets",
|
|
Engine: e,
|
|
FilerClient: stubFilerClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: &countingPersister{inner: newMemPersister(), failAfter: 1},
|
|
Lister: stubSiblingLister{},
|
|
Walker: func(context.Context, *engine.Snapshot, []int) error {
|
|
close(walkStarted)
|
|
<-walkRelease
|
|
return nil
|
|
},
|
|
}
|
|
runDone := make(chan error, 1)
|
|
go func() { runDone <- Run(context.Background(), cfg) }()
|
|
|
|
<-walkStarted
|
|
select {
|
|
case <-runDone:
|
|
t.Fatal("Run returned while the planned walk was still running")
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
close(walkRelease)
|
|
require.Error(t, <-runDone)
|
|
}
|