mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-19 20:44:28 +02:00
* s3 lifecycle: replay only the rules that fit the retention window runShard handed drainShardEvents the full snapshot, so rules the retention window assigns to the walk were also matched during meta-log replay. Their matches are not due yet, and drainShardEvents freezes the cursor at the first event with a not-yet-due match. With a 3-day and a 30-day expiration rule on one bucket and RetentionWindow of 8 days, every shard's cursor stopped at the first 30-day object's PUT and stayed there until that object came due. Each pass then re-read the log from that point and re-dispatched every 3-day expiration already handled, which surfaces as NOOP_RESOLVED dispatches, a growing cursor_lag_max, and passes that still report status=ok. Snapshot.ReplayView drops the replay-kind actions promoted to the walk and keeps every other action as compiled, and the drain uses it. Promoted rules are served by the walk as before. RulesForShard, PromotedHash and ReplayView share one isPromoted predicate. With RetentionWindow unset no active rule is promoted, so behaviour without a configured window is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: scale the replay-view test with DaysToDuration The test took its retention window from DaysToDuration but dated its PUTs in hours, so under the s3tests build tag (one lifecycle day = 10s) every event fell before the seeded cursor and replay never ran. Express the event ages in DaysToDuration too, so the test exercises replay at both scales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: rehabilitate scan_only mode in the replay drain view ReplayView shared the base CompiledAction pointers, so a replay-kind rule locked into ModeScanOnly by a prior compile stayed invisible to router.Route's event_driven gate even when the runtime retention window kept it in the replay partition - and it is absent from the walk view too, so the rule would be served by neither. Clone non-promoted replay-kind actions the way RulesForShard does (Mode forced to event_driven), reactivating the scan_only case whose bootstrap rendezvous already completed, while preserving the base active bit for genuinely pre-bootstrap actions. Disabled actions are excluded for parity with the other views, and DESIGN.md now documents the view. * s3 lifecycle: keep drain activation observable, require walker with retention ReplayView now shares pointers for event-driven actions so a mid-pass MarkActive flip remains visible to the drain; only a scan_only-locked action is cloned to event_driven, and reactivated only when its bootstrap rendezvous completed (tracked via a new bootstrapComplete field on CompiledAction). validate() rejects RetentionWindow without Walker: promoted rules would lose their only dispatch path while the drain cursor still advanced past their events. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
390 lines
15 KiB
Go
390 lines
15 KiB
Go
package engine
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// buildSnapshotForViews compiles a snapshot with a single bucket containing
|
|
// one rule per supplied factory, marking every action bootstrap_complete so
|
|
// the base actions are active. Tests can then call RulesForShard and assert
|
|
// the per-view clones come out independent.
|
|
func buildSnapshotForViews(t *testing.T, bucket string, rules ...*s3lifecycle.Rule) *Snapshot {
|
|
t.Helper()
|
|
prior := map[s3lifecycle.ActionKey]PriorState{}
|
|
for _, r := range rules {
|
|
rh := s3lifecycle.RuleHash(r)
|
|
for _, k := range s3lifecycle.RuleActionKinds(r) {
|
|
prior[s3lifecycle.ActionKey{Bucket: bucket, RuleHash: rh, ActionKind: k}] = PriorState{
|
|
BootstrapComplete: true,
|
|
}
|
|
}
|
|
}
|
|
return New().Compile(
|
|
[]CompileInput{{Bucket: bucket, Rules: rules}},
|
|
CompileOptions{PriorStates: prior},
|
|
)
|
|
}
|
|
|
|
func TestCurrentSnapshot_NilWhenNoEngineRegistered(t *testing.T) {
|
|
// Save and restore the package-level pointer so tests stay isolated.
|
|
prev := currentEngine.Load()
|
|
t.Cleanup(func() { currentEngine.Store(prev) })
|
|
|
|
SetCurrentEngine(nil)
|
|
assert.Nil(t, CurrentSnapshot())
|
|
}
|
|
|
|
func TestCurrentSnapshot_ReturnsRegisteredEngineSnapshot(t *testing.T) {
|
|
prev := currentEngine.Load()
|
|
t.Cleanup(func() { currentEngine.Store(prev) })
|
|
|
|
e := New()
|
|
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7}
|
|
snap := e.Compile([]CompileInput{{Bucket: "b", Rules: []*s3lifecycle.Rule{rule}}}, CompileOptions{})
|
|
SetCurrentEngine(e)
|
|
assert.Same(t, snap, CurrentSnapshot())
|
|
}
|
|
|
|
func TestRulesForShard_NilSnapshot(t *testing.T) {
|
|
var s *Snapshot
|
|
replay, walk := s.RulesForShard(0, 30*24*time.Hour)
|
|
assert.Nil(t, replay)
|
|
assert.Nil(t, walk)
|
|
}
|
|
|
|
func TestRulesForShard_ReplayPartitionMembership(t *testing.T) {
|
|
// ExpirationDays / NoncurrentDays / AbortMPU below retentionWindow all
|
|
// land in replay; the replay map carries clones with active=true and
|
|
// Mode=ModeEventDriven.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "all-replay",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 3,
|
|
NoncurrentVersionExpirationDays: 5,
|
|
AbortMPUDaysAfterInitiation: 2,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
replay, walk := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
require.NotNil(t, replay, "all three replay-eligible kinds should land in replay")
|
|
assert.Nil(t, walk, "no walker kinds, no scan_only promotion, walk should be empty")
|
|
assert.Len(t, replay.actions, 3)
|
|
for k, a := range replay.actions {
|
|
assert.True(t, a.IsActive(), "replay clone must be active for kind %v", k.ActionKind)
|
|
assert.Equal(t, ModeEventDriven, a.Mode, "replay Mode must be ModeEventDriven for kind %v", k.ActionKind)
|
|
}
|
|
}
|
|
|
|
func TestRulesForShard_WalkOnlyKindsRouteToWalk(t *testing.T) {
|
|
// ExpirationDate, ExpiredObjectDeleteMarker, NewerNoncurrent are walker-
|
|
// only and must land in walk regardless of retentionWindow.
|
|
when := time.Now().Add(24 * time.Hour)
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "walker-mix",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: when,
|
|
ExpiredObjectDeleteMarker: true,
|
|
NewerNoncurrentVersions: 2,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
replay, walk := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
assert.Nil(t, replay, "walker-only rule has no replay partition")
|
|
require.NotNil(t, walk)
|
|
assert.Len(t, walk.actions, 3, "all three walker kinds should land in walk")
|
|
for _, a := range walk.actions {
|
|
assert.True(t, a.IsActive())
|
|
}
|
|
}
|
|
|
|
func TestRulesForShard_MultiActionXMLRuleSplits(t *testing.T) {
|
|
// A single XML rule that compiles to ExpirationDays + NewerNoncurrent
|
|
// must split: ExpirationDays → replay, NewerNoncurrent → walk.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "split",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 3,
|
|
NewerNoncurrentVersions: 5,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
replay, walk := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
require.NotNil(t, replay)
|
|
require.NotNil(t, walk)
|
|
assert.Len(t, replay.actions, 1)
|
|
assert.Len(t, walk.actions, 1)
|
|
for k := range replay.actions {
|
|
assert.Equal(t, s3lifecycle.ActionKindExpirationDays, k.ActionKind)
|
|
}
|
|
for k := range walk.actions {
|
|
assert.Equal(t, s3lifecycle.ActionKindNewerNoncurrent, k.ActionKind)
|
|
}
|
|
}
|
|
|
|
func TestRulesForShard_ScanOnlyPromotionRoutesToWalk(t *testing.T) {
|
|
// A replay-eligible kind whose TTL exceeds retentionWindow promotes to
|
|
// walk. The clone's active=true is set; Mode is preserved as-is from
|
|
// the base (the walker only rejects ModeDisabled).
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "long",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 90,
|
|
AbortMPUDaysAfterInitiation: 1,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
// Retention window of 30 days promotes ExpirationDays(90d) to walk;
|
|
// AbortMPU(1d) stays in replay.
|
|
replay, walk := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
require.NotNil(t, replay)
|
|
require.NotNil(t, walk)
|
|
assert.Len(t, replay.actions, 1)
|
|
assert.Len(t, walk.actions, 1)
|
|
for k, a := range replay.actions {
|
|
assert.Equal(t, s3lifecycle.ActionKindAbortMPU, k.ActionKind)
|
|
assert.Equal(t, ModeEventDriven, a.Mode)
|
|
}
|
|
for k := range walk.actions {
|
|
assert.Equal(t, s3lifecycle.ActionKindExpirationDays, k.ActionKind)
|
|
}
|
|
}
|
|
|
|
func TestRulesForShard_ReplayRewritesScanOnlyMode(t *testing.T) {
|
|
// A base action carrying Mode=ModeScanOnly via PriorState must still
|
|
// land in replay as ModeEventDriven once retention rehabilitates its
|
|
// TTL. router.Route gates on ModeEventDriven, so this is the
|
|
// rehabilitation contract.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "rehab",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 5,
|
|
}
|
|
rh := s3lifecycle.RuleHash(rule)
|
|
key := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: rh, ActionKind: s3lifecycle.ActionKindExpirationDays}
|
|
snap := New().Compile(
|
|
[]CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{rule}}},
|
|
CompileOptions{
|
|
PriorStates: map[s3lifecycle.ActionKey]PriorState{
|
|
// Persistent ModeScanOnly carried over from a prior compile.
|
|
key: {BootstrapComplete: true, Mode: ModeScanOnly},
|
|
},
|
|
},
|
|
)
|
|
// Verify the base really did keep ModeScanOnly (Compile preserves it).
|
|
require.Equal(t, ModeScanOnly, snap.actions[key].Mode)
|
|
|
|
replay, _ := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
require.NotNil(t, replay)
|
|
clone := replay.actions[key]
|
|
require.NotNil(t, clone)
|
|
assert.Equal(t, ModeEventDriven, clone.Mode, "replay must force ModeEventDriven on the clone")
|
|
assert.True(t, clone.IsActive())
|
|
}
|
|
|
|
func TestRulesForShard_DisabledRuleExcludedFromBothViews(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{ID: "off", Status: s3lifecycle.StatusDisabled, ExpirationDays: 7}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
replay, walk := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
assert.Nil(t, replay)
|
|
assert.Nil(t, walk)
|
|
}
|
|
|
|
func TestRulesForShard_ClonesAreIndependentOfBase(t *testing.T) {
|
|
// Toggling active on a view clone must never propagate to the base
|
|
// action — that's the whole point of cloning per view.
|
|
rule := &s3lifecycle.Rule{ID: "iso", Status: s3lifecycle.StatusEnabled, ExpirationDays: 3}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
key := s3lifecycle.ActionKey{
|
|
Bucket: "b1",
|
|
RuleHash: s3lifecycle.RuleHash(rule),
|
|
ActionKind: s3lifecycle.ActionKindExpirationDays,
|
|
}
|
|
base := snap.Action(key)
|
|
require.NotNil(t, base)
|
|
baseActiveBefore := base.IsActive()
|
|
|
|
replay, _ := snap.RulesForShard(0, s3lifecycle.DaysToDuration(30))
|
|
require.NotNil(t, replay)
|
|
clone := replay.actions[key]
|
|
require.NotNil(t, clone)
|
|
assert.NotSame(t, base, clone, "clone must be a distinct *CompiledAction")
|
|
|
|
// Force the clone inactive (write directly to its atomic so we exercise
|
|
// the active bit boundary, not just markActive's setter).
|
|
clone.engineState.Store(engineStateInactive)
|
|
assert.False(t, clone.IsActive())
|
|
assert.Equal(t, baseActiveBefore, base.IsActive(),
|
|
"toggling the clone must not perturb the base's active bit")
|
|
}
|
|
|
|
func TestRulesForShard_ZeroRetentionRoutesAllReplayKindsToWalk(t *testing.T) {
|
|
// retentionWindow == 0 (no proven retention) must route every replay-
|
|
// eligible action into walk — the partition is "ttl > 0 && ttl <=
|
|
// retentionWindow", and 0 fails the upper bound. This is the safe
|
|
// default before the meta-log horizon is known.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "any",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 1,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
replay, walk := snap.RulesForShard(0, 0)
|
|
assert.Nil(t, replay)
|
|
require.NotNil(t, walk)
|
|
assert.Len(t, walk.actions, 1)
|
|
}
|
|
|
|
func TestReplayView_PartitionAndScanOnlyRehabilitation(t *testing.T) {
|
|
short := &s3lifecycle.Rule{ID: "short", Status: s3lifecycle.StatusEnabled, ExpirationDays: 3}
|
|
long := &s3lifecycle.Rule{ID: "long", Status: s3lifecycle.StatusEnabled, ExpirationDays: 30}
|
|
date := &s3lifecycle.Rule{ID: "date", Status: s3lifecycle.StatusEnabled, ExpirationDate: time.Now().Add(48 * time.Hour)}
|
|
off := &s3lifecycle.Rule{ID: "off", Status: s3lifecycle.StatusDisabled, ExpirationDays: 7}
|
|
pending := &s3lifecycle.Rule{ID: "pending", Status: s3lifecycle.StatusEnabled, ExpirationDays: 5}
|
|
locked := &s3lifecycle.Rule{ID: "locked", Status: s3lifecycle.StatusEnabled, ExpirationDays: 4}
|
|
shortKey := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: s3lifecycle.RuleHash(short), ActionKind: s3lifecycle.ActionKindExpirationDays}
|
|
longKey := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: s3lifecycle.RuleHash(long), ActionKind: s3lifecycle.ActionKindExpirationDays}
|
|
pendingKey := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: s3lifecycle.RuleHash(pending), ActionKind: s3lifecycle.ActionKindExpirationDays}
|
|
lockedKey := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: s3lifecycle.RuleHash(locked), ActionKind: s3lifecycle.ActionKindExpirationDays}
|
|
snap := New().Compile(
|
|
[]CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{short, long, date, off, pending, locked}}},
|
|
CompileOptions{PriorStates: map[s3lifecycle.ActionKey]PriorState{
|
|
shortKey: {BootstrapComplete: true, Mode: ModeScanOnly},
|
|
longKey: {BootstrapComplete: true},
|
|
lockedKey: {Mode: ModeScanOnly}, // demoted before bootstrap finished
|
|
}},
|
|
)
|
|
|
|
rv := snap.ReplayView(s3lifecycle.DaysToDuration(8))
|
|
require.NotNil(t, rv)
|
|
|
|
// The 30d rule is promoted to walk; the drain must not route it.
|
|
assert.Nil(t, rv.actions[longKey])
|
|
// Disabled rules stay out entirely; short+date+pending+locked remain.
|
|
assert.Len(t, rv.actions, 4)
|
|
// ScanOnly lock-in rehabilitates to event_driven once bootstrap
|
|
// completed; the base is untouched.
|
|
clone := rv.actions[shortKey]
|
|
require.NotNil(t, clone)
|
|
assert.NotSame(t, snap.actions[shortKey], clone)
|
|
assert.Equal(t, ModeEventDriven, clone.Mode)
|
|
assert.True(t, clone.IsActive())
|
|
assert.Equal(t, ModeScanOnly, snap.actions[shortKey].Mode)
|
|
// ScanOnly without a completed bootstrap stays inactive — the drain
|
|
// must not dispatch rules whose rendezvous never ran.
|
|
lockedClone := rv.actions[lockedKey]
|
|
require.NotNil(t, lockedClone)
|
|
assert.Equal(t, ModeEventDriven, lockedClone.Mode)
|
|
assert.False(t, lockedClone.IsActive())
|
|
// Pre-bootstrap event-driven actions keep active=false and share the
|
|
// base pointer so a mid-pass MarkActive stays observable.
|
|
assert.Same(t, snap.actions[pendingKey], rv.actions[pendingKey])
|
|
assert.False(t, rv.actions[pendingKey].IsActive())
|
|
// Walk-bound kinds share the base pointer, Mode preserved.
|
|
dateKey := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: s3lifecycle.RuleHash(date), ActionKind: s3lifecycle.ActionKindExpirationDate}
|
|
assert.Same(t, snap.actions[dateKey], rv.actions[dateKey])
|
|
assert.Equal(t, ModeScanAtDate, rv.actions[dateKey].Mode)
|
|
}
|
|
|
|
func TestReplayView_AllPromotedReturnsNil(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{ID: "long", Status: s3lifecycle.StatusEnabled, ExpirationDays: 90}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
assert.Nil(t, snap.ReplayView(s3lifecycle.DaysToDuration(8)))
|
|
assert.Nil(t, snap.ReplayView(0))
|
|
assert.Nil(t, snap.ReplayView(0))
|
|
}
|
|
|
|
func TestReplayView_NilSnapshot(t *testing.T) {
|
|
var s *Snapshot
|
|
assert.Nil(t, s.ReplayView(0))
|
|
}
|
|
|
|
func TestRecoveryView_ActivatesEveryNonDisabledAction(t *testing.T) {
|
|
// Base snapshot has no PriorState → event-driven actions land inactive
|
|
// (pre-BootstrapComplete). Recovery view must clone them with active=
|
|
// true so the recovery walker emits matches for them.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "pending",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 7,
|
|
}
|
|
snap := New().Compile(
|
|
[]CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{rule}}},
|
|
CompileOptions{}, // no PriorStates → action lands inactive
|
|
)
|
|
key := s3lifecycle.ActionKey{
|
|
Bucket: "b1",
|
|
RuleHash: s3lifecycle.RuleHash(rule),
|
|
ActionKind: s3lifecycle.ActionKindExpirationDays,
|
|
}
|
|
require.False(t, snap.actions[key].IsActive(), "precondition: base action must be inactive")
|
|
|
|
rv := RecoveryView(snap)
|
|
require.NotNil(t, rv)
|
|
clone := rv.actions[key]
|
|
require.NotNil(t, clone)
|
|
assert.True(t, clone.IsActive(), "recovery view must force-activate even pre-BootstrapComplete actions")
|
|
assert.Equal(t, ModeEventDriven, clone.Mode, "recovery preserves base Mode")
|
|
}
|
|
|
|
func TestRecoveryView_PreservesBaseModeForScanAtDate(t *testing.T) {
|
|
// ScanAtDate actions are active at compile time (no BootstrapComplete
|
|
// requirement). Recovery view must keep their Mode as ModeScanAtDate so
|
|
// the walker's date evaluator runs as expected — a Mode rewrite here
|
|
// would re-route them through the event-driven gate by mistake.
|
|
when := time.Now().Add(48 * time.Hour)
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "d",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: when,
|
|
}
|
|
snap := New().Compile(
|
|
[]CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{rule}}},
|
|
CompileOptions{},
|
|
)
|
|
rv := RecoveryView(snap)
|
|
require.NotNil(t, rv)
|
|
require.Len(t, rv.actions, 1)
|
|
for _, a := range rv.actions {
|
|
assert.Equal(t, ModeScanAtDate, a.Mode)
|
|
assert.True(t, a.IsActive())
|
|
}
|
|
}
|
|
|
|
func TestRecoveryView_ExcludesDisabledRules(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "off",
|
|
Status: s3lifecycle.StatusDisabled,
|
|
ExpirationDays: 7,
|
|
}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
rv := RecoveryView(snap)
|
|
assert.Nil(t, rv, "disabled rules must not surface in recovery view")
|
|
}
|
|
|
|
func TestRecoveryView_ClonesAreIndependentOfBase(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{ID: "iso", Status: s3lifecycle.StatusEnabled, ExpirationDays: 3}
|
|
snap := buildSnapshotForViews(t, "b1", rule)
|
|
key := s3lifecycle.ActionKey{
|
|
Bucket: "b1",
|
|
RuleHash: s3lifecycle.RuleHash(rule),
|
|
ActionKind: s3lifecycle.ActionKindExpirationDays,
|
|
}
|
|
base := snap.Action(key)
|
|
baseActiveBefore := base.IsActive()
|
|
|
|
rv := RecoveryView(snap)
|
|
require.NotNil(t, rv)
|
|
clone := rv.actions[key]
|
|
require.NotNil(t, clone)
|
|
assert.NotSame(t, base, clone)
|
|
|
|
clone.engineState.Store(engineStateInactive)
|
|
assert.Equal(t, baseActiveBefore, base.IsActive(),
|
|
"flipping the recovery clone must not perturb the base")
|
|
}
|
|
|
|
func TestRecoveryView_NilSnapshot(t *testing.T) {
|
|
assert.Nil(t, RecoveryView(nil))
|
|
}
|