Files
seaweedfs/weed/s3api/s3lifecycle/engine/hashes.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

202 lines
6.4 KiB
Go

package engine
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"hash"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
)
// hashItem pairs a ruleset member with its parent action so the
// sort+hash helpers below can be type-agnostic. Internal type — every
// hash callsite collects items into a slice, then hands it off.
type hashItem struct {
key s3lifecycle.ActionKey
action *CompiledAction
}
// sortHashItems orders items by (RuleHash, ActionKind, Bucket). The
// composition matches the ActionKey identity model: RuleHash is the
// primary content-derived identifier, ActionKind disambiguates siblings
// of one rule, Bucket scopes by bucket so the same XML in two buckets
// hashes distinctly. Shared between ReplayContentHash and PromotedHash
// so the two helpers agree on the on-wire ordering — if one drifted,
// the cursor could see "rule changed" on a no-op snapshot rebuild.
func sortHashItems(items []hashItem) {
sort.Slice(items, func(i, j int) bool {
if c := bytes.Compare(items[i].key.RuleHash[:], items[j].key.RuleHash[:]); c != 0 {
return c < 0
}
if items[i].key.ActionKind != items[j].key.ActionKind {
return items[i].key.ActionKind < items[j].key.ActionKind
}
return items[i].key.Bucket < items[j].key.Bucket
})
}
// hashWriter is the small varint-tagged writer the hash helpers use.
// Each field gets a one-byte tag so a future schema change (new field)
// can extend the on-wire format without colliding with existing values.
type hashWriter struct {
h hash.Hash
lenbuf [binary.MaxVarintLen64]byte
}
func newHashWriter() *hashWriter {
return &hashWriter{h: sha256.New()}
}
// writeField writes a length-prefixed byte field under tag.
func (w *hashWriter) writeField(tag byte, b []byte) {
_, _ = w.h.Write([]byte{tag})
n := binary.PutUvarint(w.lenbuf[:], uint64(len(b)))
_, _ = w.h.Write(w.lenbuf[:n])
_, _ = w.h.Write(b)
}
// writeInt writes a varint-encoded signed integer under tag.
func (w *hashWriter) writeInt(tag byte, v int64) {
_, _ = w.h.Write([]byte{tag})
n := binary.PutVarint(w.lenbuf[:], v)
_, _ = w.h.Write(w.lenbuf[:n])
}
func (w *hashWriter) sum() [32]byte {
var out [32]byte
copy(out[:], w.h.Sum(nil))
return out
}
// ReplayContentHash hashes the content (action kind, predicate, TTL value)
// of every replay-eligible compiled action in the base snapshot, returning
// the empty hash when no replay-eligible action exists. The hash is:
// - Partition-independent. A retention-driven scan_only promotion does
// NOT change this hash; only the dispatch path changes, not the rule
// content. (PromotedHash exists to catch partition flips separately.)
// - Stable across snapshot reorderings. Actions are pre-sorted by
// RuleHash + ActionKind + Bucket so two snapshots with the same rules
// compiled in any order hash identically.
// - Disabled-rule-aware. ModeDisabled actions are excluded so disabling
// a rule changes the hash (it changed the rule set the worker is
// scanning under).
//
// Used as cursor.RuleSetHash. A mismatch between persisted and current
// triggers the recovery branch on next daily_run.
func ReplayContentHash(s *Snapshot) [32]byte {
var empty [32]byte
if s == nil {
return empty
}
var items []hashItem
for k, a := range s.actions {
if a == nil || a.Mode == ModeDisabled {
continue
}
if !isReplayKind(k.ActionKind) {
continue
}
items = append(items, hashItem{key: k, action: a})
}
if len(items) == 0 {
return empty
}
sortHashItems(items)
w := newHashWriter()
for _, it := range items {
w.writeField(0x01, []byte(it.key.Bucket))
w.writeField(0x02, it.key.RuleHash[:])
w.writeInt(0x03, int64(it.key.ActionKind))
// RuleHash already covers the predicate (Prefix + FilterTags + size
// filters) and per-kind TTLs, so we don't need to re-canonicalise
// the *Rule. But we also include the action's effective TTL
// directly so that an "effective TTL of 0" (a malformed rule where
// the kind doesn't match the populated field) is distinguishable
// from a valid one.
w.writeInt(0x04, int64(effectiveTTL(it.action)))
}
return w.sum()
}
// PromotedHash hashes the set of replay-eligible actions that *would* land
// in walk (rather than replay) for the given retentionWindow, due to TTL >
// retentionWindow. Empty hash when no rules are promoted. Takes the SAME
// retentionWindow value as RulesForShard so the two helpers cannot disagree
// about partition membership.
//
// Detects partition flips in either direction:
// - replay → walk (retention dropped): rule appears in this hash but
// didn't before.
// - walk → replay (retention recovered): rule used to appear here but no
// longer does.
//
// In both cases the persisted hash differs from the freshly computed one,
// firing the recovery branch.
//
// A mismatch with the persisted PromotedHash triggers recovery even when
// rule content is unchanged.
func PromotedHash(s *Snapshot, retentionWindow time.Duration) [32]byte {
var empty [32]byte
if s == nil {
return empty
}
var items []hashItem
for k, a := range s.actions {
if a == nil || a.Mode == ModeDisabled {
continue
}
if !isReplayKind(k.ActionKind) {
continue
}
if !isPromoted(a, retentionWindow) {
continue
}
items = append(items, hashItem{key: k, action: a})
}
if len(items) == 0 {
return empty
}
sortHashItems(items)
w := newHashWriter()
for _, it := range items {
w.writeField(0x01, []byte(it.key.Bucket))
w.writeField(0x02, it.key.RuleHash[:])
w.writeInt(0x03, int64(it.key.ActionKind))
}
return w.sum()
}
// MaxEffectiveTTL returns the maximum effective TTL across the *active*
// replay-eligible actions in s. Returns 0 for a nil snapshot or one with no
// active replay actions; the caller is expected to be in the empty-replay
// branch already (per the design's sentinel-cursor logic).
//
// "Effective TTL" mirrors the partition predicate in views.go: derived from
// the rule field that matches the action kind. Walker-only action kinds
// (ExpirationDate / ExpiredDeleteMarker / NewerNoncurrent) contribute
// nothing — they're either not replay-eligible or, in a walk view, not
// active in the replay sense.
func MaxEffectiveTTL(s *Snapshot) time.Duration {
if s == nil {
return 0
}
var max time.Duration
for k, a := range s.actions {
if a == nil || !a.IsActive() {
continue
}
if !isReplayKind(k.ActionKind) {
continue
}
if ttl := effectiveTTL(a); ttl > max {
max = ttl
}
}
return max
}