Files
seaweedfs/weed/s3api/s3lifecycle/engine/compile.go
T
74ed12d6b3 s3 lifecycle: replay only the rules that fit the retention window (#11789)
* 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>
2026-10-17 15:24:35 -07:00

135 lines
4.3 KiB
Go

package engine
import (
"bytes"
"sort"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
)
var snapshotIDSeq atomic.Uint64
// CompileInput: at most one entry per Bucket. Duplicates would overwrite
// earlier entries' BucketIndex.
type CompileInput struct {
Bucket string
Rules []*s3lifecycle.Rule
Versioned bool
}
// PriorState carries durable per-action state into Compile. Missing keys
// are treated as bootstrap_complete=false.
type PriorState struct {
BootstrapComplete bool
Mode RuleMode
}
// MetaLogRetention=0 means unbounded; the retention gate doesn't trip.
type CompileOptions struct {
MetaLogRetention time.Duration
BootstrapLookbackMin time.Duration
PriorStates map[s3lifecycle.ActionKey]PriorState
}
const defaultBootstrapLookbackMin = 5 * s3lifecycle.SmallDelay
func (e *Engine) Compile(inputs []CompileInput, opts CompileOptions) *Snapshot {
if opts.BootstrapLookbackMin == 0 {
opts.BootstrapLookbackMin = defaultBootstrapLookbackMin
}
snap := &Snapshot{
id: snapshotIDSeq.Add(1),
buckets: make(map[string]*BucketIndex),
actions: make(map[s3lifecycle.ActionKey]*CompiledAction),
originalDelayGroups: make(map[time.Duration][]s3lifecycle.ActionKey),
dateActions: make(map[s3lifecycle.ActionKey]time.Time),
}
for _, in := range inputs {
bi := &BucketIndex{bucket: in.Bucket, versioned: in.Versioned}
snap.buckets[in.Bucket] = bi
for _, rule := range in.Rules {
ruleHash := s3lifecycle.RuleHash(rule)
for _, kind := range s3lifecycle.RuleActionKinds(rule) {
key := s3lifecycle.ActionKey{Bucket: in.Bucket, RuleHash: ruleHash, ActionKind: kind}
// Durable Mode wins; decideMode is only the seed for new
// or legacy-zero actions. Otherwise lag-fallback / operator
// SCAN_ONLY would re-promote on every rebuild.
prior, hasPrior := opts.PriorStates[key]
mode := prior.Mode
if !hasPrior || mode == ModeUnspecified {
mode = decideMode(rule, kind, opts.MetaLogRetention, opts.BootstrapLookbackMin)
}
// Active gates routing: MatchPath / MatchOriginalWrite skip
// !IsActive actions. ScanAtDate's only dispatch path is the
// bootstrap walk's MatchPath call, so the action must be
// considered active there or its rule is silently a no-op.
// Bootstrap-completion state is per-action and event-driven-
// shaped; date-based actions don't need a bootstrap rendezvous.
active := mode == ModeScanAtDate ||
(prior.BootstrapComplete && mode == ModeEventDriven)
ca := &CompiledAction{
Rule: rule,
Bucket: in.Bucket,
Key: key,
Delay: s3lifecycle.MinTriggerAge(rule, kind),
PredicateSensitive: rulePredicateSensitive(rule),
Mode: mode,
bootstrapComplete: prior.BootstrapComplete,
}
if active {
ca.markActive()
}
snap.actions[key] = ca
bi.actionKeys = append(bi.actionKeys, key)
// Index every action regardless of `active`; routing
// re-filters on IsActive() so MarkActive flips are visible
// without a recompile.
if mode == ModeScanAtDate {
snap.dateActions[key] = rule.ExpirationDate
}
if mode == ModeEventDriven {
snap.originalDelayGroups[ca.Delay] = append(snap.originalDelayGroups[ca.Delay], key)
if ca.PredicateSensitive {
snap.predicateActions = append(snap.predicateActions, key)
}
}
}
}
}
snap.allActionsSorted = make([]*CompiledAction, 0, len(snap.actions))
for _, a := range snap.actions {
snap.allActionsSorted = append(snap.allActionsSorted, a)
}
sort.Slice(snap.allActionsSorted, func(i, j int) bool {
a, b := snap.allActionsSorted[i], snap.allActionsSorted[j]
if a.Bucket != b.Bucket {
return a.Bucket < b.Bucket
}
if c := bytes.Compare(a.Key.RuleHash[:], b.Key.RuleHash[:]); c != 0 {
return c < 0
}
return a.Key.ActionKind < b.Key.ActionKind
})
e.current.Store(snap)
return snap
}
// rulePredicateSensitive: only tag filters can flip post-PUT. Size is
// immutable once written; a size change is a fresh write through the
// original-write stream.
func rulePredicateSensitive(rule *s3lifecycle.Rule) bool {
if rule == nil {
return false
}
return len(rule.FilterTags) > 0
}