test(s3/lifecycle/engine): pin delay-group dedup across buckets (#9418)

Compile a 100-bucket × 5-rule snapshot where the five Days values
include duplicates (1, 1, 7, 7, 30) and assert:

- snap.actions has 500 entries — every (bucket, rule) compiles to its
  own ActionKey, no collapse.
- snap.originalDelayGroups has exactly 3 entries — the routing index
  is keyed by Delay, so same-day rules across all buckets share a
  group. This is the property that lets the dispatcher index by
  delay group rather than per-rule.
- Per-group key count = (rules with that day) × buckets, so every
  action is reachable from its group entry.
This commit is contained in:
Chris Lu
2026-05-10 10:36:54 -07:00
committed by GitHub
parent 1b1d4aa814
commit 82648cca53
@@ -1,6 +1,7 @@
package engine
import (
"strconv"
"testing"
"time"
@@ -322,3 +323,60 @@ func TestEngine_SnapshotAtomicSwap(t *testing.T) {
t.Fatalf("Engine.Snapshot should return the latest")
}
}
// TestCompile_DelayGroupsDedupeAcrossBuckets pins the scaling property
// the dispatcher relies on: originalDelayGroups is keyed by Delay, so N
// rules sharing the same Days threshold across M buckets collapse into
// one delay group with N*M action keys — not N*M groups. The dispatch
// path can then index by delay group rather than per-rule.
func TestCompile_DelayGroupsDedupeAcrossBuckets(t *testing.T) {
const buckets = 100
const rulesPerBucket = 5
// Five rules per bucket, but only three distinct day values, so two
// pairs of rules per bucket share a delay group.
dayValues := []int{1, 1, 7, 7, 30}
distinctDays := map[int]bool{}
for _, d := range dayValues {
distinctDays[d] = true
}
inputs := make([]CompileInput, buckets)
prior := map[s3lifecycle.ActionKey]PriorState{}
for b := 0; b < buckets; b++ {
bucket := "b" + strconv.Itoa(b)
rules := make([]*s3lifecycle.Rule, rulesPerBucket)
for r, days := range dayValues {
rule := ruleExpDays("r"+strconv.Itoa(r), "p"+strconv.Itoa(r)+"/", days)
rules[r] = rule
prior[s3lifecycle.ActionKey{
Bucket: bucket,
RuleHash: s3lifecycle.RuleHash(rule),
ActionKind: s3lifecycle.ActionKindExpirationDays,
}] = PriorState{BootstrapComplete: true}
}
inputs[b] = CompileInput{Bucket: bucket, Rules: rules}
}
e := New()
snap := e.Compile(inputs, CompileOptions{PriorStates: prior})
if got := len(snap.actions); got != buckets*rulesPerBucket {
t.Fatalf("want %d actions (no collapse), got %d", buckets*rulesPerBucket, got)
}
if got := len(snap.originalDelayGroups); got != len(distinctDays) {
t.Fatalf("want %d delay groups (one per distinct Days), got %d",
len(distinctDays), got)
}
// Per-group key count: each distinct day appears in `count` rules per
// bucket, so the group holds count*buckets keys.
wantPerGroup := map[time.Duration]int{}
for _, d := range dayValues {
wantPerGroup[s3lifecycle.DaysToDuration(d)] += buckets
}
for delay, want := range wantPerGroup {
if got := len(snap.originalDelayGroups[delay]); got != want {
t.Fatalf("delay %v: want %d action keys, got %d", delay, want, got)
}
}
}