mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-13 15:29:12 +01: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.
151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"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"
|
|
)
|
|
|
|
// memPersister is a minimal in-memory CursorPersister. Phase 4b tests
|
|
// drive runShard directly; the recovery-branch path returns before
|
|
// drainShardEvents so the heavier filer/client/lister fakes aren't
|
|
// needed here. Locked because Run fans out a goroutine per shard and
|
|
// they all load and save through the same persister.
|
|
type memPersister struct {
|
|
mu sync.Mutex
|
|
store map[int]Cursor
|
|
}
|
|
|
|
func newMemPersister() *memPersister { return &memPersister{store: map[int]Cursor{}} }
|
|
|
|
func (p *memPersister) Load(_ context.Context, shardID int) (Cursor, bool, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
c, ok := p.store[shardID]
|
|
return c, ok, nil
|
|
}
|
|
|
|
func (p *memPersister) Save(_ context.Context, shardID int, c Cursor) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.store[shardID] = c
|
|
return nil
|
|
}
|
|
|
|
func snapshotWithRule(t *testing.T, days int) *engine.Snapshot {
|
|
t.Helper()
|
|
e := engine.New()
|
|
e.Compile([]engine.CompileInput{
|
|
{Bucket: "b1", Rules: []*s3lifecycle.Rule{
|
|
{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: days},
|
|
}},
|
|
}, engine.CompileOptions{})
|
|
snap := e.Snapshot()
|
|
for _, a := range snap.AllActions() {
|
|
snap.MarkActive(a.Key)
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func TestRunShard_WalkerInvokedOnRecoveryBranch(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
// Seed a persisted cursor whose RuleSetHash differs from snap's, so
|
|
// the rule-change branch fires.
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xAA
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 3, Cursor{TsNs: 1234, RuleSetHash: stale}))
|
|
|
|
var gotView *engine.Snapshot
|
|
var gotShards []int
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, view *engine.Snapshot, shards []int) error {
|
|
calls++
|
|
gotView = view
|
|
gotShards = shards
|
|
return nil
|
|
},
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 3, nil, nil))
|
|
|
|
assert.Equal(t, 1, calls, "walker must fire exactly once on recovery")
|
|
require.NotNil(t, gotView, "walker received the RecoveryView")
|
|
assert.Equal(t, []int{3}, gotShards)
|
|
|
|
// Cursor rewound to runNow - maxTTL with the new hashes persisted.
|
|
got, ok, err := p.Load(context.Background(), 3)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
expectedFloor := runNow.Add(-engine.MaxEffectiveTTL(snap)).UnixNano()
|
|
assert.Equal(t, expectedFloor, got.TsNs, "cursor must rewind to runNow - maxTTL after recovery walk")
|
|
assert.NotEqual(t, stale, got.RuleSetHash, "stale hash must be replaced")
|
|
}
|
|
|
|
func TestRunShard_NilWalkerOnRecoveryIsNoop(t *testing.T) {
|
|
// Phase 4a behavior: a nil Walker must not crash and must still
|
|
// rewind the cursor.
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xBB
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{TsNs: 9999, RuleSetHash: stale}))
|
|
|
|
cfg := Config{Persister: p} // Walker nil
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 0, nil, nil))
|
|
|
|
got, ok, err := p.Load(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, runNow.Add(-engine.MaxEffectiveTTL(snap)).UnixNano(), got.TsNs)
|
|
}
|
|
|
|
func TestRunShard_WalkerErrorPropagates(t *testing.T) {
|
|
// A walker failure must surface as the runShard error so the daily
|
|
// run treats the shard as interrupted, and must NOT advance the
|
|
// cursor (the seeded cursor stays).
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xCC
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 7, Cursor{TsNs: 42, RuleSetHash: stale}))
|
|
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ []int) error {
|
|
return errors.New("walker boom")
|
|
},
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
err := runShard(context.Background(), cfg, snap, runNow, 7, nil, nil)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "walker boom")
|
|
|
|
// Cursor untouched.
|
|
got, _, _ := p.Load(context.Background(), 7)
|
|
assert.Equal(t, int64(42), got.TsNs, "walker failure must leave cursor unchanged")
|
|
assert.Equal(t, stale, got.RuleSetHash)
|
|
}
|
|
|
|
// The "matching cursor doesn't invoke walker" case is implicit: the
|
|
// walker call is inside the rule-change / partition-flip `if` and
|
|
// can't be reached otherwise. Exercising it end-to-end requires the
|
|
// full filer + lister + client harness; covered by the integration
|
|
// tests once Phase 4b is wired into the handler.
|