mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 08:18:42 +01:00
s3 lifecycle: per-pass run results and last full-pass status (#12643)
* s3 lifecycle: collect per-pass bucket results and work counters WalkBuckets kept only the first bucket error and logged the rest. A RunStats shared by the walker, the walker dispatcher and the replay drain now records every bucket's walk result (walked, failed, skipped on cancellation), entry and object visits, replay events, and LifecycleDelete outcomes under the dispatch_total outcome labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: persist the last fully successful pass Shard cursors and walk timestamps advance on partial passes as well, so neither says when lifecycle last covered every policy. Keep that in its own record next to the cursors, plus a worker gauge for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: report each pass's result in the job record The job ended with a fixed success summary, or with only the first error, and malformed lifecycle policies were V(1) logs. The completion now carries the pass status (complete, partial, failed, canceled), bucket totals and failures, invalid policies, entry, object and event counts, LifecycleDelete outcomes, the last phase and bucket, the elapsed time and the last full pass, on failed passes as well. Only a complete pass advances the full-pass record; a malformed policy makes the pass partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep a failed job's result in its record Completions with Success=false dropped Result.Summary and Result.OutputValues, so a handler could not report what a failed run had done, which buckets failed, or how far it got. Keep them; the error message still drives the job's message and run record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * grafana: chart the time since the last full lifecycle pass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: a replay halted on an unresolved delete is a partial pass BLOCKED, RETRY_LATER and exhausted transport retries stop a shard's replay without an error, leaving its due events for a later pass. Count those shards and report the pass as partial, so it no longer advances the full-pass record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: a pass with throttled walks is not a full pass WalkerInterval can skip the walk for walk-only rules (ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent) with no error. Count those shards and keep the full-pass record for passes that evaluated every rule; the job reports full_pass and walks_deferred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: keep the saved full-pass time when the save fails Publishing a timestamp that never reached the filer let the gauge move back on the next job's reload. Keep the previous value and report the save error in the job result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: drop a failed attempt's summary when the retry reports none Retries reuse the job record, and failed completions now keep their result summary, so a retry that completes without a Result showed the failed attempt's summary, and a successful one used it as its message. Clear the summary when a completion carries no Result, as the output values already are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ad4b07598
commit
c819e3fbea
18 files changed
+1119
-52
No files matched your search
@@ -18,6 +18,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
|
||||
@@ -161,3 +162,58 @@ func (p *FilerCursorPersister) Save(ctx context.Context, shardID int, c Cursor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const fullPassFileName = "full-pass.json"
|
||||
|
||||
// FullPass is the last pass that covered every shard and every lifecycle
|
||||
// policy without an error. Shard cursors advance on partial passes too,
|
||||
// so they cannot answer that.
|
||||
type FullPass struct {
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type fullPassFile struct {
|
||||
Version int `json:"version"`
|
||||
StartedAtNs int64 `json:"started_at_ns"`
|
||||
CompletedAtNs int64 `json:"completed_at_ns"`
|
||||
}
|
||||
|
||||
func (p *FilerCursorPersister) LoadFullPass(ctx context.Context) (FullPass, bool, error) {
|
||||
if p.Store == nil {
|
||||
return FullPass{}, false, errors.New("FilerCursorPersister: nil Store")
|
||||
}
|
||||
content, err := p.Store.Read(ctx, CursorDir, fullPassFileName)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return FullPass{}, false, nil
|
||||
}
|
||||
return FullPass{}, false, fmt.Errorf("full pass read: %w", err)
|
||||
}
|
||||
var f fullPassFile
|
||||
if err := json.Unmarshal(content, &f); err != nil {
|
||||
return FullPass{}, false, fmt.Errorf("full pass decode: %w", err)
|
||||
}
|
||||
if f.Version != cursorFileVersion {
|
||||
return FullPass{}, false, fmt.Errorf("full pass version: got %d, want %d", f.Version, cursorFileVersion)
|
||||
}
|
||||
return FullPass{StartedAt: time.Unix(0, f.StartedAtNs).UTC(), CompletedAt: time.Unix(0, f.CompletedAtNs).UTC()}, true, nil
|
||||
}
|
||||
|
||||
func (p *FilerCursorPersister) SaveFullPass(ctx context.Context, fp FullPass) error {
|
||||
if p.Store == nil {
|
||||
return errors.New("FilerCursorPersister: nil Store")
|
||||
}
|
||||
content, err := json.Marshal(fullPassFile{
|
||||
Version: cursorFileVersion,
|
||||
StartedAtNs: fp.StartedAt.UnixNano(),
|
||||
CompletedAtNs: fp.CompletedAt.UnixNano(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("full pass encode: %w", err)
|
||||
}
|
||||
if err := p.Store.Save(ctx, CursorDir, fullPassFileName, content); err != nil {
|
||||
return fmt.Errorf("full pass save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -153,3 +154,32 @@ func TestFilerCursorPersister_NilStoreErrors(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Error(t, p.Save(context.Background(), 0, Cursor{}))
|
||||
}
|
||||
|
||||
func TestFilerCursorPersister_FullPassRoundTrip(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
p := &FilerCursorPersister{Store: store}
|
||||
_, found, err := p.LoadFullPass(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
|
||||
want := FullPass{
|
||||
StartedAt: time.Unix(1_700_000_000, 123).UTC(),
|
||||
CompletedAt: time.Unix(1_700_003_600, 456).UTC(),
|
||||
}
|
||||
require.NoError(t, p.SaveFullPass(context.Background(), want))
|
||||
got, found, err := p.LoadFullPass(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, want, got)
|
||||
|
||||
_, found, err = p.Load(context.Background(), 0)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found, "the full-pass record must not read as a shard cursor")
|
||||
}
|
||||
|
||||
func TestFilerCursorPersister_FullPassRejectsUnknownVersion(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
require.NoError(t, store.Save(context.Background(), CursorDir, fullPassFileName, []byte(`{"version":99,"completed_at_ns":1}`)))
|
||||
_, _, err := (&FilerCursorPersister{Store: store}).LoadFullPass(context.Background())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -91,6 +91,10 @@ type Config struct {
|
||||
|
||||
// 0 -> defaultSubscriptionReceiveTimeout.
|
||||
SubscriptionReceiveTimeout time.Duration
|
||||
|
||||
// nil -> no per-pass summary. Share it with the walker's
|
||||
// WalkBuckets and WalkerDispatcher to cover both paths.
|
||||
Stats *RunStats
|
||||
}
|
||||
|
||||
// Above the filer's 15m maxGapStall, so a subscriber parked on a gap is
|
||||
@@ -240,6 +244,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
first = readerErr
|
||||
}
|
||||
}
|
||||
cfg.Stats.setShardErrors(errCount)
|
||||
status := "ok"
|
||||
if first != nil {
|
||||
status = "error"
|
||||
@@ -562,6 +567,8 @@ func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow tim
|
||||
lastWalkedNs = runNow.UnixNano()
|
||||
walkedThisPass = true
|
||||
}
|
||||
} else if cfg.Walker != nil {
|
||||
countDeferredWalk(cfg.Stats, snap, shardID, retentionWindow)
|
||||
}
|
||||
_ = walkedThisPass // empty-replay branch returns; no downstream walker.
|
||||
return saveCursorAndPublish(ctx, cfg.Persister, shardID, Cursor{
|
||||
@@ -625,6 +632,8 @@ func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow tim
|
||||
}
|
||||
lastWalkedNs = runNow.UnixNano()
|
||||
}
|
||||
} else if cfg.Walker != nil && !walkedThisPass {
|
||||
countDeferredWalk(cfg.Stats, snap, shardID, retentionWindow)
|
||||
}
|
||||
|
||||
// Cold start: scan from now-maxTTL so already-due objects within
|
||||
@@ -644,7 +653,10 @@ func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow tim
|
||||
// Walk-bound rules are never due inside the retention window; keep them
|
||||
// out of the drain so their matches don't pin the cursor.
|
||||
replayView := snap.ReplayView(retentionWindow)
|
||||
lastOK, _, drainErr := drainShardEvents(ctx, cfg, runNow, shardID, replayView, startTsNs, events)
|
||||
lastOK, halted, drainErr := drainShardEvents(ctx, cfg, runNow, shardID, replayView, startTsNs, events)
|
||||
if halted && drainErr == nil {
|
||||
cfg.Stats.shardHalted()
|
||||
}
|
||||
// Fresh ctx: a wall-clock cap on the pass (the shell driver's
|
||||
// -runtime) cancels the drain's, and saving with it would drop the
|
||||
// cursor and re-replay from the same floor next pass.
|
||||
@@ -684,6 +696,17 @@ func saveCursorAndPublish(ctx context.Context, p CursorPersister, shardID int, c
|
||||
return nil
|
||||
}
|
||||
|
||||
// countDeferredWalk counts a shard whose walk-only rules waited for
|
||||
// WalkerInterval, so the pass left them unevaluated.
|
||||
func countDeferredWalk(s *RunStats, snap *engine.Snapshot, shardID int, retentionWindow time.Duration) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if _, walkView := snap.RulesForShard(shardID, retentionWindow); walkView != nil && len(walkView.AllActions()) > 0 {
|
||||
s.walkDeferred()
|
||||
}
|
||||
}
|
||||
|
||||
// walkerDue answers the persisted-state throttle: has enough time
|
||||
// elapsed since the last walker fire on this shard? interval == 0
|
||||
// means "fire every pass" (the prior, unconditional behavior).
|
||||
@@ -719,6 +742,7 @@ func drainShardEvents(ctx context.Context, cfg Config, runNow time.Time, shardID
|
||||
cursorAdvanceTo := startTsNs
|
||||
stuck := false
|
||||
halted := false
|
||||
cfg.Stats.startReplay()
|
||||
|
||||
drain:
|
||||
for {
|
||||
@@ -739,6 +763,7 @@ drain:
|
||||
continue
|
||||
}
|
||||
stats.S3LifecycleDailyRunEventsScanned.WithLabelValues(strconv.Itoa(shardID)).Inc()
|
||||
cfg.Stats.scanEvent()
|
||||
matches := router.Route(ctx, snap, ev, runNow, cfg.Lister)
|
||||
eventSkipped, eventHalted, eventErr := processMatches(ctx, cfg, runNow, ev, matches)
|
||||
if eventErr != nil {
|
||||
@@ -786,10 +811,10 @@ func processMatches(ctx context.Context, cfg Config, runNow time.Time, ev *reade
|
||||
// "RPC_ERROR" matches the streaming dispatcher's label
|
||||
// (dispatcher/dispatcher.go) so transport failures
|
||||
// aggregate under one outcome key across paths.
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(m.Bucket, m.Key.ActionKind.String(), "RPC_ERROR").Inc()
|
||||
countDispatch(cfg.Stats, m.Bucket, m.Key.ActionKind.String(), "RPC_ERROR")
|
||||
return skippedAny, true, nil
|
||||
}
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(m.Bucket, m.Key.ActionKind.String(), outcome.String()).Inc()
|
||||
countDispatch(cfg.Stats, m.Bucket, m.Key.ActionKind.String(), outcome.String())
|
||||
switch outcome {
|
||||
case s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
||||
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
||||
|
||||
@@ -126,6 +126,7 @@ func TestRun_HaltedShardDoesNotStarveOthers(t *testing.T) {
|
||||
Persister: persister,
|
||||
Lister: stubSiblingLister{},
|
||||
Now: func() time.Time { return runNow },
|
||||
Stats: &RunStats{},
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
@@ -145,4 +146,11 @@ func TestRun_HaltedShardDoesNotStarveOthers(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Greater(t, starvedCursor.TsNs, seeded.TsNs, "starved shard's cursor must advance")
|
||||
|
||||
sum := cfg.Stats.Summary()
|
||||
assert.Equal(t, PhaseReplay, sum.Phase)
|
||||
assert.Equal(t, int64(2), sum.EventsScanned, "the halted shard stops after its first event")
|
||||
assert.Equal(t, map[string]int64{"BLOCKED": 1, "DONE": 1}, sum.Outcomes)
|
||||
assert.Zero(t, sum.ShardErrors)
|
||||
assert.Equal(t, 1, sum.ShardsHalted, "a BLOCKED delete halts its shard without an error")
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package dailyrun
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
)
|
||||
|
||||
// Bounds what a summary carries per pass; the counts stay exact.
|
||||
const (
|
||||
maxReportedBucketFailures = 10
|
||||
maxReportedErrorLen = 256
|
||||
)
|
||||
|
||||
const (
|
||||
PhaseWalk = "walk"
|
||||
PhaseReplay = "replay"
|
||||
)
|
||||
|
||||
type bucketState uint8
|
||||
|
||||
const (
|
||||
bucketSkipped bucketState = iota + 1
|
||||
bucketWalked
|
||||
bucketFailed
|
||||
)
|
||||
|
||||
type BucketFailure struct {
|
||||
Bucket string
|
||||
Error string
|
||||
}
|
||||
|
||||
// RunSummary is a copy of a RunStats at one point in time. A bucket walked
|
||||
// more than once in a pass counts once, as failed if any walk failed.
|
||||
type RunSummary struct {
|
||||
Phase string
|
||||
Bucket string
|
||||
BucketsWalked int
|
||||
BucketsFailed int
|
||||
BucketsSkipped int
|
||||
// The first maxReportedBucketFailures failed buckets.
|
||||
BucketFailures []BucketFailure
|
||||
// Every listed entry, versions and delete markers included.
|
||||
EntriesVisited int64
|
||||
// Distinct object keys among the listed entries.
|
||||
ObjectsVisited int64
|
||||
EventsScanned int64
|
||||
// LifecycleDelete outcomes keyed by the dispatch_total outcome label.
|
||||
// They count RPC results, not reclaimed space.
|
||||
Outcomes map[string]int64
|
||||
ShardErrors int
|
||||
// Shards whose replay stopped on an unresolved delete; their due
|
||||
// events wait for a later pass.
|
||||
ShardsHalted int
|
||||
// Shards whose walk-only rules waited for WalkerInterval.
|
||||
WalksDeferred int
|
||||
}
|
||||
|
||||
// RunStats collects one pass's work. Safe for concurrent use; a nil
|
||||
// *RunStats ignores every call.
|
||||
type RunStats struct {
|
||||
entries atomic.Int64
|
||||
objects atomic.Int64
|
||||
events atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
phase string
|
||||
bucket string
|
||||
buckets map[string]bucketState
|
||||
failures []BucketFailure
|
||||
outcomes map[string]int64
|
||||
shardErrors int
|
||||
shardsHalted int
|
||||
deferred int
|
||||
}
|
||||
|
||||
func (s *RunStats) startBucket(bucket string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.phase, s.bucket = PhaseWalk, bucket
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) finishBucket(bucket string, state bucketState, err error) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.buckets == nil {
|
||||
s.buckets = map[string]bucketState{}
|
||||
}
|
||||
prev := s.buckets[bucket]
|
||||
if state <= prev {
|
||||
return
|
||||
}
|
||||
s.buckets[bucket] = state
|
||||
if state == bucketFailed && len(s.failures) < maxReportedBucketFailures {
|
||||
s.failures = append(s.failures, BucketFailure{Bucket: bucket, Error: truncateError(err)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RunStats) startReplay() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.phase, s.bucket = PhaseReplay, ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) visitEntry(newObject bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.entries.Add(1)
|
||||
if newObject {
|
||||
s.objects.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RunStats) scanEvent() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.events.Add(1)
|
||||
}
|
||||
|
||||
func (s *RunStats) recordOutcome(outcome string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.outcomes == nil {
|
||||
s.outcomes = map[string]int64{}
|
||||
}
|
||||
s.outcomes[outcome]++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) setShardErrors(n int) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.shardErrors = n
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) shardHalted() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.shardsHalted++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) walkDeferred() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.deferred++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RunStats) Summary() RunSummary {
|
||||
if s == nil {
|
||||
return RunSummary{}
|
||||
}
|
||||
out := RunSummary{
|
||||
EntriesVisited: s.entries.Load(),
|
||||
ObjectsVisited: s.objects.Load(),
|
||||
EventsScanned: s.events.Load(),
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out.Phase, out.Bucket = s.phase, s.bucket
|
||||
for _, state := range s.buckets {
|
||||
switch state {
|
||||
case bucketWalked:
|
||||
out.BucketsWalked++
|
||||
case bucketFailed:
|
||||
out.BucketsFailed++
|
||||
case bucketSkipped:
|
||||
out.BucketsSkipped++
|
||||
}
|
||||
}
|
||||
out.BucketFailures = append([]BucketFailure(nil), s.failures...)
|
||||
if len(s.outcomes) > 0 {
|
||||
out.Outcomes = make(map[string]int64, len(s.outcomes))
|
||||
for k, v := range s.outcomes {
|
||||
out.Outcomes[k] = v
|
||||
}
|
||||
}
|
||||
out.ShardErrors = s.shardErrors
|
||||
out.ShardsHalted = s.shardsHalted
|
||||
out.WalksDeferred = s.deferred
|
||||
return out
|
||||
}
|
||||
|
||||
func countDispatch(s *RunStats, bucket, kind, outcome string) {
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(bucket, kind, outcome).Inc()
|
||||
s.recordOutcome(outcome)
|
||||
}
|
||||
|
||||
func truncateError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) <= maxReportedErrorLen {
|
||||
return msg
|
||||
}
|
||||
return strings.ToValidUTF8(msg[:maxReportedErrorLen], "") + "..."
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package dailyrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/bootstrap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func allShards() []int {
|
||||
out := make([]int, s3lifecycle.ShardCount)
|
||||
for i := range out {
|
||||
out[i] = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWalkBuckets_RecordsEveryBucketFailure(t *testing.T) {
|
||||
list := func(_ context.Context, bucket, _ string, _ func(*bootstrap.Entry) error) error {
|
||||
if strings.HasPrefix(bucket, "bad") {
|
||||
return errors.New("list " + bucket + " failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runStats := &RunStats{}
|
||||
err := WalkBuckets(context.Background(), snapshotForBucketRule(t, "good", 7), []int{0},
|
||||
[]string{"bad-a", "good", "bad-b"}, list, &recordingDispatcher{}, runStats)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "bad-a", "the returned error stays the first bucket's")
|
||||
|
||||
sum := runStats.Summary()
|
||||
assert.Equal(t, 1, sum.BucketsWalked)
|
||||
assert.Equal(t, 2, sum.BucketsFailed)
|
||||
assert.Equal(t, 0, sum.BucketsSkipped)
|
||||
require.Len(t, sum.BucketFailures, 2)
|
||||
assert.Equal(t, "bad-a", sum.BucketFailures[0].Bucket)
|
||||
assert.Contains(t, sum.BucketFailures[0].Error, "list bad-a failed")
|
||||
assert.Equal(t, "bad-b", sum.BucketFailures[1].Bucket)
|
||||
assert.Equal(t, PhaseWalk, sum.Phase)
|
||||
assert.Equal(t, "bad-b", sum.Bucket)
|
||||
}
|
||||
|
||||
func TestWalkBuckets_CancelMarksRemainingBucketsSkipped(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
list := func(ctx context.Context, bucket, _ string, _ func(*bootstrap.Entry) error) error {
|
||||
if bucket == "second" {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runStats := &RunStats{}
|
||||
err := WalkBuckets(ctx, snapshotForBucketRule(t, "first", 7), []int{0},
|
||||
[]string{"first", "second", "third"}, list, &recordingDispatcher{}, runStats)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
sum := runStats.Summary()
|
||||
assert.Equal(t, 1, sum.BucketsWalked)
|
||||
assert.Equal(t, 0, sum.BucketsFailed, "a cancelled walk is not a bucket failure")
|
||||
assert.Equal(t, 2, sum.BucketsSkipped)
|
||||
assert.Empty(t, sum.BucketFailures)
|
||||
}
|
||||
|
||||
func TestRunStats_FailedWalkWinsAcrossWalksOfOneBucket(t *testing.T) {
|
||||
runStats := &RunStats{}
|
||||
runStats.finishBucket("b", bucketSkipped, nil)
|
||||
runStats.finishBucket("b", bucketWalked, nil)
|
||||
runStats.finishBucket("b", bucketFailed, errors.New("first"))
|
||||
runStats.finishBucket("b", bucketFailed, errors.New("second"))
|
||||
runStats.finishBucket("b", bucketWalked, nil)
|
||||
|
||||
sum := runStats.Summary()
|
||||
assert.Equal(t, 1, sum.BucketsFailed)
|
||||
assert.Equal(t, 0, sum.BucketsWalked)
|
||||
require.Len(t, sum.BucketFailures, 1)
|
||||
assert.Equal(t, "first", sum.BucketFailures[0].Error)
|
||||
}
|
||||
|
||||
func TestRunStats_BoundsReportedFailures(t *testing.T) {
|
||||
runStats := &RunStats{}
|
||||
long := strings.Repeat("x", 2*maxReportedErrorLen)
|
||||
for i := 0; i < maxReportedBucketFailures+5; i++ {
|
||||
runStats.finishBucket("b-"+intToStr(i), bucketFailed, errors.New(long))
|
||||
}
|
||||
sum := runStats.Summary()
|
||||
assert.Equal(t, maxReportedBucketFailures+5, sum.BucketsFailed, "the count stays exact")
|
||||
require.Len(t, sum.BucketFailures, maxReportedBucketFailures)
|
||||
assert.LessOrEqual(t, len(sum.BucketFailures[0].Error), maxReportedErrorLen+len("..."))
|
||||
}
|
||||
|
||||
func TestWalkBuckets_CountsEntriesAndObjects(t *testing.T) {
|
||||
old := time.Now().Add(-90 * 24 * time.Hour)
|
||||
entries := []*bootstrap.Entry{
|
||||
{Path: "a", VersionID: "v3", ModTime: old, IsLatest: true},
|
||||
{Path: "a", VersionID: "v2", ModTime: old},
|
||||
{Path: "a", VersionID: "v1", ModTime: old},
|
||||
{Path: "b", ModTime: old, IsLatest: true},
|
||||
{Path: "dir", IsDirectory: true},
|
||||
{Path: ".uploads/u1", DestKey: "c", IsDirectory: true, IsMPUInit: true, ModTime: old},
|
||||
}
|
||||
runStats := &RunStats{}
|
||||
require.NoError(t, WalkBuckets(context.Background(), snapshotForBucketRule(t, "bkt", 7), allShards(),
|
||||
[]string{"bkt"}, bootstrap.EntryCallback(entries), &recordingDispatcher{}, runStats))
|
||||
|
||||
sum := runStats.Summary()
|
||||
assert.Equal(t, int64(5), sum.EntriesVisited, "versions and upload records count, plain directories do not")
|
||||
assert.Equal(t, int64(2), sum.ObjectsVisited, "versions of one key count as one object")
|
||||
assert.Equal(t, 1, sum.BucketsWalked)
|
||||
}
|
||||
|
||||
func TestWalkerDispatcher_CountsOutcomes(t *testing.T) {
|
||||
runStats := &RunStats{}
|
||||
a := sampleAction(t, s3lifecycle.ActionKindExpirationDays)
|
||||
e := &bootstrap.Entry{Path: "obj"}
|
||||
|
||||
d := &WalkerDispatcher{Client: &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}, Stats: runStats}
|
||||
require.NoError(t, d.Delete(context.Background(), a, e))
|
||||
require.NoError(t, d.Delete(context.Background(), a, e))
|
||||
d.Client = &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_SKIPPED_OBJECT_LOCK}
|
||||
require.NoError(t, d.Delete(context.Background(), a, e))
|
||||
d.Client = &walkerStubClient{err: errors.New("unavailable")}
|
||||
require.Error(t, d.Delete(context.Background(), a, e))
|
||||
d.Client = &walkerStubClient{nilResp: true}
|
||||
require.Error(t, d.Delete(context.Background(), a, e))
|
||||
|
||||
assert.Equal(t, map[string]int64{"DONE": 2, "SKIPPED_OBJECT_LOCK": 1, "RPC_ERROR": 2}, runStats.Summary().Outcomes)
|
||||
}
|
||||
|
||||
func TestProcessMatches_CountsOutcomes(t *testing.T) {
|
||||
runNow := time.Now()
|
||||
rule := s3lifecycle.ActionKey{Bucket: "b", RuleHash: [8]byte{1}, ActionKind: s3lifecycle.ActionKindExpirationDays}
|
||||
matches := []router.Match{
|
||||
{Key: rule, Bucket: "b", ObjectKey: "a", DueTime: runNow.Add(-time.Hour)},
|
||||
{Key: rule, Bucket: "b", ObjectKey: "b", DueTime: runNow.Add(-time.Hour)},
|
||||
{Key: rule, Bucket: "b", ObjectKey: "c", DueTime: runNow.Add(-time.Hour)},
|
||||
}
|
||||
client := &recordingClient{responses: []s3_lifecycle_pb.LifecycleDeleteOutcome{
|
||||
s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
||||
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
||||
s3_lifecycle_pb.LifecycleDeleteOutcome_RETRY_LATER,
|
||||
}}
|
||||
runStats := &RunStats{}
|
||||
_, halted, err := processMatches(context.Background(), Config{Client: client, Stats: runStats}, runNow, &reader.Event{}, matches)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, halted)
|
||||
assert.Equal(t, map[string]int64{"DONE": 1, "NOOP_RESOLVED": 1, "RETRY_LATER": 1}, runStats.Summary().Outcomes)
|
||||
}
|
||||
|
||||
func TestRunStats_NilIsANoOp(t *testing.T) {
|
||||
var runStats *RunStats
|
||||
runStats.startBucket("b")
|
||||
runStats.finishBucket("b", bucketFailed, errors.New("x"))
|
||||
runStats.startReplay()
|
||||
runStats.visitEntry(true)
|
||||
runStats.scanEvent()
|
||||
runStats.recordOutcome("DONE")
|
||||
runStats.setShardErrors(1)
|
||||
assert.Equal(t, RunSummary{}, runStats.Summary())
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
// WalkBuckets runs bootstrap.Walk for each bucket using a per-bucket
|
||||
// ListFunc wrapped to drop entries whose ShardID isn't in shards.
|
||||
// Returns the first bucket error; remaining buckets log and continue
|
||||
// so one bucket's filer error doesn't kill the whole walk.
|
||||
// so one bucket's filer error doesn't kill the whole walk. Every
|
||||
// bucket's result goes to runStats when it is non-nil.
|
||||
//
|
||||
// Composable shape: callers (the worker handler) supply a real
|
||||
// filer-backed ListFunc and a real Dispatcher (WalkerDispatcher).
|
||||
// Tests pass bootstrap.EntryCallback and a stub Dispatcher.
|
||||
func WalkBuckets(ctx context.Context, view *engine.Snapshot, shards []int, buckets []string, list bootstrap.ListFunc, dispatch bootstrap.Dispatcher) error {
|
||||
func WalkBuckets(ctx context.Context, view *engine.Snapshot, shards []int, buckets []string, list bootstrap.ListFunc, dispatch bootstrap.Dispatcher, runStats *RunStats) error {
|
||||
if view == nil {
|
||||
return errors.New("WalkBuckets: nil view")
|
||||
}
|
||||
@@ -33,13 +34,26 @@ func WalkBuckets(ctx context.Context, view *engine.Snapshot, shards []int, bucke
|
||||
for _, sh := range shards {
|
||||
shardSet[sh] = true
|
||||
}
|
||||
filtered := perShardListFunc(list, shardSet)
|
||||
filtered := perShardListFunc(list, shardSet, runStats)
|
||||
var firstErr error
|
||||
for _, b := range buckets {
|
||||
for i, b := range buckets {
|
||||
if err := ctx.Err(); err != nil {
|
||||
for _, rest := range buckets[i:] {
|
||||
runStats.finishBucket(rest, bucketSkipped, nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := bootstrap.Walk(ctx, view, b, filtered, dispatch, bootstrap.WalkOptions{}); err != nil {
|
||||
runStats.startBucket(b)
|
||||
_, err := bootstrap.Walk(ctx, view, b, filtered, dispatch, bootstrap.WalkOptions{})
|
||||
switch {
|
||||
case err == nil:
|
||||
runStats.finishBucket(b, bucketWalked, nil)
|
||||
case ctx.Err() != nil:
|
||||
runStats.finishBucket(b, bucketSkipped, nil)
|
||||
default:
|
||||
runStats.finishBucket(b, bucketFailed, err)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("walk %s: %w", b, err)
|
||||
} else {
|
||||
@@ -57,8 +71,10 @@ func WalkBuckets(ctx context.Context, view *engine.Snapshot, shards []int, bucke
|
||||
// MPU init records carry the user's destination in DestKey, so the
|
||||
// shard predicate must use DestKey there to match what the dispatcher
|
||||
// will send.
|
||||
func perShardListFunc(inner bootstrap.ListFunc, shardSet map[int]bool) bootstrap.ListFunc {
|
||||
func perShardListFunc(inner bootstrap.ListFunc, shardSet map[int]bool, runStats *RunStats) bootstrap.ListFunc {
|
||||
return func(ctx context.Context, bucket, start string, cb func(*bootstrap.Entry) error) error {
|
||||
// Listings emit every version of a key back to back.
|
||||
lastPath := ""
|
||||
return inner(ctx, bucket, start, func(e *bootstrap.Entry) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
@@ -66,6 +82,10 @@ func perShardListFunc(inner bootstrap.ListFunc, shardSet map[int]bool) bootstrap
|
||||
if !shardSet[entryShardID(bucket, e)] {
|
||||
return nil
|
||||
}
|
||||
if !e.IsDirectory || e.IsMPUInit {
|
||||
runStats.visitEntry(!e.IsMPUInit && e.Path != lastPath)
|
||||
lastPath = e.Path
|
||||
}
|
||||
return cb(e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestWalkBuckets_DispatchesOnlyShardMatchingEntries(t *testing.T) {
|
||||
snap := snapshotForBucketRule(t, bucket, 7) // 30-day-old objects expire
|
||||
d := &recordingDispatcher{}
|
||||
err := WalkBuckets(context.Background(), snap, []int{targetShard}, []string{bucket},
|
||||
bootstrap.EntryCallback(all), d)
|
||||
bootstrap.EntryCallback(all), d, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotPaths := make([]string, 0, len(d.calls))
|
||||
@@ -124,9 +124,9 @@ func TestWalkBuckets_DispatchesOnlyShardMatchingEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWalkBuckets_NilGuards(t *testing.T) {
|
||||
require.Error(t, WalkBuckets(context.Background(), nil, []int{0}, nil, bootstrap.EntryCallback(nil), &recordingDispatcher{}))
|
||||
require.Error(t, WalkBuckets(context.Background(), snapshotForBucketRule(t, "b", 7), []int{0}, nil, nil, &recordingDispatcher{}))
|
||||
require.Error(t, WalkBuckets(context.Background(), snapshotForBucketRule(t, "b", 7), []int{0}, nil, bootstrap.EntryCallback(nil), nil))
|
||||
require.Error(t, WalkBuckets(context.Background(), nil, []int{0}, nil, bootstrap.EntryCallback(nil), &recordingDispatcher{}, nil))
|
||||
require.Error(t, WalkBuckets(context.Background(), snapshotForBucketRule(t, "b", 7), []int{0}, nil, nil, &recordingDispatcher{}, nil))
|
||||
require.Error(t, WalkBuckets(context.Background(), snapshotForBucketRule(t, "b", 7), []int{0}, nil, bootstrap.EntryCallback(nil), nil, nil))
|
||||
}
|
||||
|
||||
func TestWalkBuckets_OneBucketErrorDoesNotStopOthers(t *testing.T) {
|
||||
@@ -141,7 +141,7 @@ func TestWalkBuckets_OneBucketErrorDoesNotStopOthers(t *testing.T) {
|
||||
}
|
||||
|
||||
snap := snapshotForBucketRule(t, "good", 7)
|
||||
err := WalkBuckets(context.Background(), snap, []int{0}, []string{"bad", "good"}, list, &recordingDispatcher{})
|
||||
err := WalkBuckets(context.Background(), snap, []int{0}, []string{"bad", "good"}, list, &recordingDispatcher{}, nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, listErr)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestWalkBuckets_HonorsContextCancellationBetweenBuckets(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := WalkBuckets(ctx, snapshotForBucketRule(t, "b", 7), []int{0}, []string{"b"},
|
||||
bootstrap.EntryCallback(nil), &recordingDispatcher{})
|
||||
bootstrap.EntryCallback(nil), &recordingDispatcher{}, nil)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type WalkerDispatcher struct {
|
||||
// daily-run's processMatches uses so the walker and replay paths
|
||||
// can't combine to burst past the cap. nil disables throttling.
|
||||
Limiter *rate.Limiter
|
||||
// nil skips per-pass outcome counts.
|
||||
Stats *RunStats
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
@@ -75,17 +77,17 @@ func (d *WalkerDispatcher) Delete(ctx context.Context, action *engine.CompiledAc
|
||||
if err != nil {
|
||||
// "RPC_ERROR" matches the streaming dispatcher and processMatches
|
||||
// labels so transport-class failures aggregate under one key.
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(action.Bucket, kindLabel, "RPC_ERROR").Inc()
|
||||
countDispatch(d.Stats, action.Bucket, kindLabel, "RPC_ERROR")
|
||||
return fmt.Errorf("walker dispatch %s/%s %s: %w", action.Bucket, objectPath, action.Key.ActionKind, err)
|
||||
}
|
||||
if resp == nil {
|
||||
// A misbehaving server stub returning (nil, nil) would panic on
|
||||
// the switch below. Bucket under RPC_ERROR rather than a new
|
||||
// label — operationally it's the same class of failure.
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(action.Bucket, kindLabel, "RPC_ERROR").Inc()
|
||||
countDispatch(d.Stats, action.Bucket, kindLabel, "RPC_ERROR")
|
||||
return fmt.Errorf("walker dispatch %s/%s %s: nil response", action.Bucket, objectPath, action.Key.ActionKind)
|
||||
}
|
||||
stats.S3LifecycleDispatchCounter.WithLabelValues(action.Bucket, kindLabel, resp.Outcome.String()).Inc()
|
||||
countDispatch(d.Stats, action.Bucket, kindLabel, resp.Outcome.String())
|
||||
switch resp.Outcome {
|
||||
case s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
||||
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
||||
|
||||
@@ -136,13 +136,16 @@ func TestRunShard_WalkerThrottle(t *testing.T) {
|
||||
runNow2 := runNow.Add(tc.secondPassAfter)
|
||||
closedEvents2 := make(chan *readerEventAlias)
|
||||
close(closedEvents2)
|
||||
cfg.Stats = &RunStats{}
|
||||
require.NoError(t, runShard(context.Background(), cfg, snap, runNow2, 0, closedEvents2, nil))
|
||||
afterPass2, _, _ := p.Load(context.Background(), 0)
|
||||
assert.Equal(t, tc.wantTotalCalls, calls, "walker calls after 2 passes")
|
||||
if tc.wantSecondAdvNs {
|
||||
assert.Equal(t, runNow2.UnixNano(), afterPass2.LastWalkedNs, "throttle allowed the second walk")
|
||||
assert.Zero(t, cfg.Stats.Summary().WalksDeferred)
|
||||
} else {
|
||||
assert.Equal(t, runNow.UnixNano(), afterPass2.LastWalkedNs, "throttle suppressed the second walk; anchor unchanged")
|
||||
assert.Equal(t, 1, cfg.Stats.Summary().WalksDeferred, "a throttled walk leaves its rules unevaluated")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user