mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 16:28:30 +01:00
* 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>
280 lines
11 KiB
Go
280 lines
11 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"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"
|
|
)
|
|
|
|
// readerEventAlias keeps the test free of the reader import collision
|
|
// in the recovery test file when we need a typed nil channel.
|
|
type readerEventAlias = reader.Event
|
|
|
|
// TestWalkerDue covers the throttle decision matrix in isolation.
|
|
func TestWalkerDue(t *testing.T) {
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
cases := []struct {
|
|
name string
|
|
lastWalkedNs int64
|
|
interval time.Duration
|
|
want bool
|
|
}{
|
|
// interval=0 keeps the prior "fire every pass" semantics — the
|
|
// in-repo integration tests and the s3tests fast driver rely on
|
|
// this so the rule-change-then-walk behavior surfaces within a
|
|
// single test runtime. Within-pass double-fire suppression
|
|
// lives in runShard's walkedThisPass local; walkerDue answers
|
|
// the persisted-state question only.
|
|
{"interval zero always due", runNow.UnixNano(), 0, true},
|
|
// LastWalkedNs=0 marks "never walked steady-state" — the post-
|
|
// upgrade cold-start case for cursor files that predate the
|
|
// LastWalkedNs field. Fire so the throttle anchor gets seeded.
|
|
{"never walked is due", 0, time.Hour, true},
|
|
// Throttle on: interval elapsed → due.
|
|
{"interval elapsed", runNow.Add(-2 * time.Hour).UnixNano(), time.Hour, true},
|
|
{"interval exactly elapsed", runNow.Add(-time.Hour).UnixNano(), time.Hour, true},
|
|
// Throttle on: not yet → skip.
|
|
{"interval not yet elapsed", runNow.Add(-30 * time.Minute).UnixNano(), time.Hour, false},
|
|
// Future LastWalkedNs (clock skew or replay of an older runNow
|
|
// against a newer cursor) must not panic and must not fire —
|
|
// treat as "throttled" because the cursor claims a future walk.
|
|
{"future lastWalked", runNow.Add(time.Hour).UnixNano(), time.Hour, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, walkerDue(tc.lastWalkedNs, runNow, tc.interval))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunShard_WalkerThrottle confirms two back-to-back runShard
|
|
// invocations only fire the steady-state walker once when the second
|
|
// pass starts inside the WalkerInterval window. The interval-zero
|
|
// control case keeps firing on every pass — same shape, different
|
|
// expectation — pinning the prior behavior so a default-zero config
|
|
// (tests, in-repo integration) doesn't regress.
|
|
func TestRunShard_WalkerThrottle(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
interval time.Duration
|
|
secondPassAfter time.Duration
|
|
wantTotalCalls int
|
|
wantSecondAdvNs bool // did LastWalkedNs change between pass 1 and pass 2?
|
|
}{
|
|
{"interval=0 fires every pass", 0, 30 * time.Second, 2, true},
|
|
{"throttled: second pass within interval", time.Hour, 30 * time.Second, 1, false},
|
|
{"throttled: second pass past interval", time.Hour, 2 * time.Hour, 2, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30) // replay-eligible only; steady-state walker would normally skip
|
|
// Force a walker-only partition so the steady-state branch
|
|
// has something to walk: set retentionWindow=0 in cfg, which
|
|
// makes promoted non-empty and walkView non-empty.
|
|
p := newMemPersister()
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ []int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
RetentionWindow: -1, // negative sentinel: falls back to maxTTL — leaves walkView empty
|
|
WalkerInterval: tc.interval,
|
|
}
|
|
// Pre-seed cursor matching snap's hashes so cold-start /
|
|
// recovery branches don't fire and we measure ONLY the
|
|
// steady-state walker.
|
|
rsh := engine.ReplayContentHash(snap)
|
|
promoted := engine.PromotedHash(snap, engine.MaxEffectiveTTL(snap))
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{
|
|
TsNs: runNow.UnixNano(),
|
|
RuleSetHash: rsh,
|
|
PromotedHash: promoted,
|
|
}))
|
|
|
|
// With the snapshot's only rule being replay-eligible, the
|
|
// steady-state walkView is empty and the walker won't fire
|
|
// regardless of throttle. Use a snapshot that has BOTH a
|
|
// replay rule AND a scan-only walker rule by forcing
|
|
// retentionWindow to a very small value via cfg.
|
|
cfg.RetentionWindow = time.Nanosecond
|
|
// Recompute promoted with the test retention so persisted
|
|
// hashes match what runShard sees on each pass.
|
|
snapPromoted := engine.PromotedHash(snap, time.Nanosecond)
|
|
require.NoError(t, p.Save(context.Background(), 0, Cursor{
|
|
TsNs: runNow.UnixNano(),
|
|
RuleSetHash: rsh,
|
|
PromotedHash: snapPromoted,
|
|
}))
|
|
|
|
// runShard reaches drainShardEvents after the steady-state
|
|
// walker fires (replay-eligible rules present). A nil events
|
|
// channel would block forever; a closed one returns
|
|
// immediately so drain exits cleanly and the cursor save
|
|
// still records LastWalkedNs.
|
|
closedEvents := make(chan *readerEventAlias)
|
|
close(closedEvents)
|
|
|
|
// Pass 1.
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 0, closedEvents, nil))
|
|
afterPass1, _, _ := p.Load(context.Background(), 0)
|
|
require.Equal(t, 1, calls, "pass 1 must fire the walker (cold-start anchor)")
|
|
require.Equal(t, runNow.UnixNano(), afterPass1.LastWalkedNs)
|
|
|
|
// Pass 2.
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidate_RejectsNegativeWalkerInterval pins the loud-failure
|
|
// contract for an embedder or test that constructs dailyrun.Config
|
|
// directly with a negative WalkerInterval. The admin-config parser
|
|
// clamps negative input to zero (worker/tasks/s3_lifecycle/config.go),
|
|
// but a caller bypassing that parser would otherwise get the silent
|
|
// fall-through-to-walk-every-pass behavior the throttle is trying to
|
|
// prevent.
|
|
func TestValidate_RejectsNegativeWalkerInterval(t *testing.T) {
|
|
// validate only checks for nil fields, not behavior — type-assert
|
|
// each interface to its zero value via a stub. The stubs never run.
|
|
cfg := validatableConfig()
|
|
cfg.WalkerInterval = -time.Hour
|
|
err := validate(cfg)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "WalkerInterval")
|
|
// And the zero sentinel still passes.
|
|
cfg.WalkerInterval = 0
|
|
require.NoError(t, validate(cfg))
|
|
}
|
|
|
|
// validatableConfig builds a minimal dailyrun.Config that passes the
|
|
// nil checks in validate() so individual fields can be poked. The
|
|
// stubs intentionally don't implement any meaningful behavior — they
|
|
// only need to be non-nil interface values.
|
|
func validatableConfig() Config {
|
|
return Config{
|
|
Engine: engine.New(),
|
|
FilerClient: stubFilerClient{},
|
|
Client: stubLifecycleClient{},
|
|
Persister: newMemPersister(),
|
|
Lister: stubSiblingLister{},
|
|
BucketsPath: "/buckets",
|
|
}
|
|
}
|
|
|
|
type stubFilerClient struct{ filer_pb.SeaweedFilerClient }
|
|
type stubLifecycleClient struct{}
|
|
|
|
func (stubLifecycleClient) LifecycleDelete(_ context.Context, _ *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stubSiblingLister struct{}
|
|
|
|
func (stubSiblingLister) Survivors(_ context.Context, _, _ string) (router.Survivors, error) {
|
|
return router.Survivors{}, nil
|
|
}
|
|
func (stubSiblingLister) LookupVersion(_ context.Context, _, _, _ string) (*filer_pb.Entry, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubSiblingLister) ListVersions(_ context.Context, _, _ string) ([]*filer_pb.Entry, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubSiblingLister) LookupNullVersion(_ context.Context, _, _ string) (*filer_pb.Entry, bool, error) {
|
|
return nil, false, nil
|
|
}
|
|
|
|
// TestRunShard_ColdStartDoesNotDoubleWalk pins the within-pass guard
|
|
// in runShard's walkedThisPass local. Before the guard, a cold-start
|
|
// pass with WalkerInterval=0 fired the walker twice in one pass:
|
|
// once via the mustWalkColdStart branch (with RecoveryView) and again
|
|
// immediately via the steady-state branch (with the per-shard walk
|
|
// view, which is a subset of RecoveryView). The second walk added no
|
|
// coverage and burned a full bucket scan. Guard against regression.
|
|
func TestRunShard_ColdStartDoesNotDoubleWalk(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
// No persisted cursor → mustWalkColdStart=true. WalkerInterval=0
|
|
// would, before the fix, allow the steady-state branch to fire
|
|
// again with no time elapsed.
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ []int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
WalkerInterval: 0,
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
|
|
closedEvents := make(chan *readerEventAlias)
|
|
close(closedEvents)
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 0, closedEvents, nil))
|
|
|
|
assert.Equal(t, 1, calls, "cold-start walker must fire exactly once per pass even with interval=0")
|
|
}
|
|
|
|
// TestRunShard_RecoveryWalkerSetsLastWalkedAnchor pins that the
|
|
// unconditional recovery-branch walker fire still updates the
|
|
// LastWalkedNs anchor so the NEXT pass's throttle starts counting
|
|
// from this walk rather than re-firing immediately.
|
|
func TestRunShard_RecoveryWalkerSetsLastWalkedAnchor(t *testing.T) {
|
|
snap := snapshotWithRule(t, 30)
|
|
p := newMemPersister()
|
|
var stale [32]byte
|
|
for i := range stale {
|
|
stale[i] = 0xAA
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 5, Cursor{
|
|
TsNs: 1234,
|
|
RuleSetHash: stale,
|
|
LastWalkedNs: 0, // never walked anchor
|
|
}))
|
|
|
|
calls := 0
|
|
cfg := Config{
|
|
Persister: p,
|
|
Walker: func(_ context.Context, _ *engine.Snapshot, _ []int) error {
|
|
calls++
|
|
return nil
|
|
},
|
|
WalkerInterval: time.Hour,
|
|
}
|
|
runNow := time.Unix(1_700_000_000, 0).UTC()
|
|
require.NoError(t, runShard(context.Background(), cfg, snap, runNow, 5, nil, nil))
|
|
|
|
require.Equal(t, 1, calls, "recovery branch fires the walker unconditionally")
|
|
got, ok, _ := p.Load(context.Background(), 5)
|
|
require.True(t, ok)
|
|
assert.Equal(t, runNow.UnixNano(), got.LastWalkedNs,
|
|
"recovery walk must update LastWalkedNs so the steady-state pass after it can throttle")
|
|
}
|
|
|
|
// Compile-time check that the documented sentinel passes the type system.
|
|
var _ = s3lifecycle.ShardCount
|