diff --git a/weed/s3api/s3lifecycle/dispatcher/dispatcher_helpers_test.go b/weed/s3api/s3lifecycle/dispatcher/dispatcher_helpers_test.go new file mode 100644 index 000000000..460c29b89 --- /dev/null +++ b/weed/s3api/s3lifecycle/dispatcher/dispatcher_helpers_test.go @@ -0,0 +1,109 @@ +package dispatcher + +import ( + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router" + "github.com/stretchr/testify/assert" +) + +// Direct coverage for dispatcher pure helpers (keyOf, budget, backoff) +// and the retryKey identity that drives the retry-budget map. The +// existing dispatcher tests exercise these only through Tick; pinning +// each one separately makes a regression in the helper itself fail at +// the helper level. + +func TestKeyOf_DerivesIdentityFromMatch(t *testing.T) { + hash := [8]byte{0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4} + m := router.Match{ + Key: s3lifecycle.ActionKey{ + Bucket: "bk", + RuleHash: hash, + ActionKind: s3lifecycle.ActionKindExpirationDays, + }, + ObjectKey: "obj.txt", + VersionID: "v_abc", + } + got := keyOf(m) + assert.Equal(t, "bk", got.bucket) + assert.Equal(t, hash, got.ruleHash) + assert.Equal(t, s3lifecycle.ActionKindExpirationDays, got.kind) + assert.Equal(t, "obj.txt", got.objectKey) + assert.Equal(t, "v_abc", got.versionID) +} + +func TestKeyOf_EqualMatchesProduceEqualKeys(t *testing.T) { + // retryKey is used as a map key in the retry budget; equality must + // hold between two Match values with identical fields so the second + // dispatch finds the first's retry counter. + hash := [8]byte{0xde, 0xad, 0xbe, 0xef} + m1 := router.Match{ + Key: s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays}, + ObjectKey: "obj", + VersionID: "v_x", + } + m2 := m1 + assert.Equal(t, keyOf(m1), keyOf(m2)) +} + +func TestKeyOf_DistinctVersionIDsProduceDistinctKeys(t *testing.T) { + // Two versions of the same logical object must NOT share a retry + // budget; otherwise a noisy version could starve a healthy one. + base := router.Match{ + Key: s3lifecycle.ActionKey{Bucket: "bk", ActionKind: s3lifecycle.ActionKindNoncurrentDays}, + ObjectKey: "obj", + VersionID: "v_a", + } + other := base + other.VersionID = "v_b" + assert.NotEqual(t, keyOf(base), keyOf(other)) +} + +func TestKeyOf_DistinctActionKindsProduceDistinctKeys(t *testing.T) { + // The same (bucket, object, version) hit by two different action + // kinds must each have their own retry budget. + base := router.Match{ + Key: s3lifecycle.ActionKey{Bucket: "bk", ActionKind: s3lifecycle.ActionKindExpirationDays}, + ObjectKey: "obj", + } + other := base + other.Key.ActionKind = s3lifecycle.ActionKindNoncurrentDays + assert.NotEqual(t, keyOf(base), keyOf(other)) +} + +func TestDispatcherBudget_ReturnsConfiguredValueWhenSet(t *testing.T) { + d := &Dispatcher{RetryBudget: 9} + assert.Equal(t, 9, d.budget()) +} + +func TestDispatcherBudget_FallsBackToDefaultWhenZero(t *testing.T) { + // Operators leaving the budget at zero opt into the documented + // default. A regression that returns 0 would NOOP every retry. + d := &Dispatcher{} + assert.Equal(t, defaultRetryBudget, d.budget()) +} + +func TestDispatcherBudget_NegativeFallsBackToDefault(t *testing.T) { + // budget() guards on > 0, so a negative value falls back rather + // than producing nonsense. Pin the contract so a refactor that + // flips the comparison is caught. + d := &Dispatcher{RetryBudget: -1} + assert.Equal(t, defaultRetryBudget, d.budget()) +} + +func TestDispatcherBackoff_ReturnsConfiguredValueWhenSet(t *testing.T) { + d := &Dispatcher{RetryBackoff: 5 * time.Second} + assert.Equal(t, 5*time.Second, d.backoff()) +} + +func TestDispatcherBackoff_FallsBackToDefaultWhenZero(t *testing.T) { + d := &Dispatcher{} + assert.Equal(t, defaultRetryBackoff, d.backoff()) +} + +func TestDispatcherBackoff_NegativeFallsBackToDefault(t *testing.T) { + d := &Dispatcher{RetryBackoff: -time.Second} + assert.Equal(t, defaultRetryBackoff, d.backoff()) +} diff --git a/weed/s3api/s3lifecycle/engine/snapshot_accessors_test.go b/weed/s3api/s3lifecycle/engine/snapshot_accessors_test.go new file mode 100644 index 000000000..eebe48da6 --- /dev/null +++ b/weed/s3api/s3lifecycle/engine/snapshot_accessors_test.go @@ -0,0 +1,201 @@ +package engine + +import ( + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Direct tests for Snapshot.OriginalDelayGroups / PredicateActions / +// DateActions accessors and MarkActive/IsActive transitions. The Compile +// tests exercise the construction path; these tests pin the read-side +// surface the router and dispatcher reach for at runtime. + +func TestSnapshot_OriginalDelayGroupsExposesCompiledGroups(t *testing.T) { + // Two action kinds with different delays must land in distinct + // delay groups so the dispatcher polls each at the right cadence. + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 7, + AbortMPUDaysAfterInitiation: 3, + } + hash := s3lifecycle.RuleHash(rule) + prior := map[s3lifecycle.ActionKey]PriorState{} + for _, k := range s3lifecycle.RuleActionKinds(rule) { + prior[s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: k}] = PriorState{ + BootstrapComplete: true, + Mode: ModeEventDriven, + } + } + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{PriorStates: prior}, + ) + groups := snap.OriginalDelayGroups() + require.NotNil(t, groups) + + // 7d expiration delay group must contain the specific expiration key. + expirationDelay := s3lifecycle.MinTriggerAge(rule, s3lifecycle.ActionKindExpirationDays) + expirationKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays} + assert.Contains(t, groups[expirationDelay], expirationKey, "expiration delay group must carry its specific key") + + // 3d abort-mpu delay group is distinct and contains the abort key. + abortDelay := s3lifecycle.MinTriggerAge(rule, s3lifecycle.ActionKindAbortMPU) + assert.NotEqual(t, expirationDelay, abortDelay) + abortKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindAbortMPU} + assert.Contains(t, groups[abortDelay], abortKey, "abort-mpu delay group must carry its specific key") +} + +func TestSnapshot_OriginalDelayGroupsScanOnlyExcluded(t *testing.T) { + // Scan-only actions don't go through originalDelayGroups (they + // fire from the bootstrap walk only), so the dispatcher's + // MatchOriginalWrite path won't see them. + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + hash := s3lifecycle.RuleHash(rule) + scanOnlyKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays} + prior := map[s3lifecycle.ActionKey]PriorState{ + scanOnlyKey: {BootstrapComplete: true, Mode: ModeScanOnly}, + } + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{PriorStates: prior}, + ) + groups := snap.OriginalDelayGroups() + for delay, keys := range groups { + for _, k := range keys { + assert.NotEqual(t, scanOnlyKey, k, "scan-only action leaked into delay group %v", delay) + } + } +} + +func TestSnapshot_PredicateActionsContainsTagSensitive(t *testing.T) { + // A tag-sensitive rule's actions must surface in PredicateActions + // so MatchPredicateChange routes them. + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 7, + FilterTags: map[string]string{"env": "prod"}, + } + hash := s3lifecycle.RuleHash(rule) + prior := map[s3lifecycle.ActionKey]PriorState{} + for _, k := range s3lifecycle.RuleActionKinds(rule) { + prior[s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: k}] = PriorState{ + BootstrapComplete: true, + Mode: ModeEventDriven, + } + } + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{PriorStates: prior}, + ) + predicates := snap.PredicateActions() + require.NotEmpty(t, predicates, "tag-sensitive rule must populate predicateActions") + // Verify the specific key landed (not just non-empty) so a routing + // regression that emits a wrong ActionKey is caught. + expectedKey := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays} + assert.Contains(t, predicates, expectedKey, "predicateActions must carry the rule's specific ActionKey") +} + +func TestSnapshot_PredicateActionsEmptyForNonTagSensitiveRule(t *testing.T) { + // A rule without FilterTags is not predicate-sensitive; the + // predicate-action list must stay empty so MatchPredicateChange + // never routes irrelevant kinds. + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{}, + ) + assert.Empty(t, snap.PredicateActions()) +} + +func TestSnapshot_DateActionsContainsExpirationDate(t *testing.T) { + // EXPIRATION_DATE rules go into dateActions (not delay groups); + // the scan-at-date scheduler reads from this map. + when := time.Now().Add(24 * time.Hour) + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDate: when, + } + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{}, + ) + dateActions := snap.DateActions() + require.NotEmpty(t, dateActions) + for _, dt := range dateActions { + assert.Equal(t, when, dt, "DateActions must surface the rule's ExpirationDate verbatim") + } +} + +func TestSnapshot_DateActionsEmptyForNonDateRule(t *testing.T) { + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{}, + ) + assert.Empty(t, snap.DateActions()) +} + +func TestSnapshot_MarkActiveUnknownKeyIsNoOp(t *testing.T) { + // MarkActive must silently skip keys that aren't in the snapshot; + // the durable bootstrap-complete write can race with a recompile + // that drops the rule, and a panic here would crash the worker. + snap := New().Compile(nil, CompileOptions{}) + snap.MarkActive(s3lifecycle.ActionKey{Bucket: "ghost", ActionKind: s3lifecycle.ActionKindExpirationDays}) +} + +func TestSnapshot_MarkActiveFlipsCompiledActionToActive(t *testing.T) { + // Bootstrap-pending actions land inactive; MarkActive transitions + // them to active so the routing filter starts emitting matches. + rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7} + hash := s3lifecycle.RuleHash(rule) + key := s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: s3lifecycle.ActionKindExpirationDays} + // No prior state -> the action lands inactive (BootstrapComplete=false). + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{}, + ) + a := snap.Action(key) + require.NotNil(t, a) + assert.False(t, a.IsActive(), "fresh action without prior state must be inactive") + snap.MarkActive(key) + assert.True(t, a.IsActive(), "MarkActive must transition the action to active") +} + +func TestSnapshot_BucketIndexedActionKeysCoverAllKinds(t *testing.T) { + // Cross-check that BucketActionKeys lists every kind compiled from + // the rule, regardless of mode. The router iterates these for + // MatchPath, so a missed kind silently disables prefix-only routing + // for that action. + rule := &s3lifecycle.Rule{ + ID: "r", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 7, + AbortMPUDaysAfterInitiation: 3, + ExpiredObjectDeleteMarker: true, + } + snap := New().Compile( + []CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, + CompileOptions{}, + ) + keys := snap.BucketActionKeys("bk") + wantKinds := s3lifecycle.RuleActionKinds(rule) + require.Len(t, keys, len(wantKinds)) + + // Verify the contents, not just the count: every key must carry + // the right bucket scope, and the kinds must match RuleActionKinds + // element-for-element (in any order). Catches an indexing + // regression that produces N keys but with wrong kinds. + gotKinds := make([]s3lifecycle.ActionKind, 0, len(keys)) + for _, k := range keys { + assert.Equal(t, "bk", k.Bucket, "key leaked across bucket") + gotKinds = append(gotKinds, k.ActionKind) + } + assert.ElementsMatch(t, wantKinds, gotKinds, "kinds must match RuleActionKinds") +}