Files
seaweedfs/weed/s3api/s3lifecycle/dailyrun/walker_interval_test.go
T
Chris Lu c6582228b8 feat(s3/lifecycle): throttle steady-state walker by cfg.WalkerInterval (#9484)
* feat(s3/lifecycle): throttle steady-state walker by cfg.WalkerInterval

The steady-state and empty-replay walker fired on every dailyrun.Run
invocation, which is fine when Run is called at the bucket-walk cadence
the operator intends (e.g., once per hour or once per day), but
catastrophic when a fast driver like the s3tests CI workflow or the
admin worker scheduler invokes Run at multi-second cadence — each tick
ran a full subtree scan per shard, crushing the filer.

Decouple walker cadence from Run() invocation cadence: persist
LastWalkedNs in the per-shard cursor and fire the steady-state /
empty-replay walker only when (runNow - LastWalkedNs) >= cfg.WalkerInterval.
Cold-start and recovery walker fires (RecoveryView) stay unconditional
since those are bounded events that must run when their trigger
condition (no cursor, hash mismatch) is met. Recovery walker fires also
update LastWalkedNs so the subsequent steady-state pass doesn't
double-walk.

cfg.WalkerInterval=0 keeps the prior "fire every pass" behavior — the
in-repo integration tests and s3tests fast driver continue to work
unchanged. Production deployments should set this to the walk cost
budget (typically 1h-24h depending on cluster size).

Cursor file is back-compat: last_walked_ns is omitempty, so cursor
files written before this change decode as LastWalkedNs=0, which
walkerDue treats as "never walked steady-state" → walker fires next
pass to establish the anchor (same path a cold-start cursor takes).
No version bump.

Operator surface for WalkerInterval is the dailyrun.Config struct;
plumbing through worker.tasks.s3_lifecycle.Config and the admin
schema is a follow-up.

* fix(s3/lifecycle): suppress walker double-fire within a single pass

Two gemini-code-assist findings:

1. walkerDue with interval=0 returned true even when lastWalkedNs ==
   runNow.UnixNano() — the cold-start / recovery branch already fired
   the walker this pass, and the steady-state fall-through fired it
   again. RecoveryView is a superset of every per-shard partition, so
   the second walk added zero coverage and burned a full subtree scan.
   Add a within-pass guard at the front of walkerDue: if the cursor's
   LastWalkedNs equals runNow's UnixNano, the walker already ran this
   pass — skip.

2. The empty-replay branch passed persisted.LastWalkedNs to walkerDue
   instead of the local lastWalkedNs variable the rest of runShard
   threads through. Trivially equal at this point in the function, but
   the inconsistency would mask a future bug if any code above the
   branch ever sets lastWalkedNs.

Test updates: TestWalkerDue gains the within-pass guard case plus a
companion "earlier same pass still fires" sanity check.
TestRunShard_ColdStartDoesNotDoubleWalk is new and pins the integration:
cold-start runShard with WalkerInterval=0 must call cfg.Walker exactly
once, not twice.

* fix(s3/lifecycle): reject negative WalkerInterval + lift within-pass guard

Two coderabbit findings:

1. validate() now rejects negative cfg.WalkerInterval. A typo like
   -1h previously fell through walkerDue's `interval <= 0` branch and
   silently re-enabled "walk every pass" — the exact behavior the
   throttle was added to prevent. The admin-config parser already
   clamps negative input to zero, but callers using dailyrun.Config
   directly (tests, embedders) now get a loud error instead.

2. Within-pass double-fire suppression moves out of walkerDue and
   into runShard's walkedThisPass local flag. walkerDue's equality
   check (lastWalkedNs == runNow.UnixNano) was correct in production
   (each pass freezes runNow at time.Now().UTC, no collisions) but
   fragile in tests that inject the same runNow across distinct
   passes — the test would see false suppression. Separating the
   concerns also makes walkerDue answer one question (persisted-state
   throttle) and runShard another (within-pass call-site dedup).

walker_interval_test.go: TestValidate_RejectsNegativeWalkerInterval
pins the new validation. TestWalkerDue's within-pass cases move out
(the function is pure throttle now); TestRunShard_ColdStartDoesNot
DoubleWalk still pins the integration behavior end-to-end.
2026-05-13 14:09:13 -07:00

277 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))
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)
require.NoError(t, runShard(context.Background(), cfg, snap, runNow2, 0, closedEvents2))
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")
} else {
assert.Equal(t, runNow.UnixNano(), afterPass2.LastWalkedNs, "throttle suppressed the second walk; anchor unchanged")
}
})
}
}
// 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))
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))
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