mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* feat(s3): stamp noncurrent_since on versioned demotions A version's noncurrent TTL clock starts when the next version is written, not at its own mtime. Today the lifecycle engine derives that moment from the next-newer sibling's mtime — a heuristic that drifts if the sibling is later modified and is unavailable when the demoting event sits outside meta-log retention. Stamp Seaweed-X-Amz-Noncurrent-Since-Ns on the demoted entry at the two places where a PUT flips the latest pointer: updateLatestVersionInDirectory and updateIsLatestFlagsForSuspendedVersioning. Timestamp source is time.Now().UnixNano() captured once per demotion — the documented Phase 1 fallback until the filer write API surfaces its own TsNs. Engine reads the stamp on both the bootstrap walker path and the event-driven router; missing/zero falls back to the legacy sibling-mtime derivation, so pre-stamp entries keep working. Prerequisite for the daily-replay lifecycle worker (Phase 2+). * fix(s3): address CI failure and PR review feedback - Backdating tests must move both clocks: the lifecycle integration tests backdate version mtimes to simulate aging, but my earlier commit made the engine prefer the explicit demotion stamp over sibling mtime, so a real-now stamp dominated a backdated mtime and the rule never fired. Update backdateVersionedMtime to also rewrite Seaweed-X-Amz-Noncurrent-Since-Ns when the entry already carries it. This is a test simplification — production stamps record when the successor was written, not the demoted version's own mtime — but the resulting clock is correctly old enough. - Refactor stamp parsing into one shared helper. Per gemini-code-assist: the parsing logic for ExtNoncurrentSinceNsKey was duplicated in router/router.go and scheduler/bootstrap.go. Move it to a new weed/s3api/s3lifecycle/noncurrent_since.go as exported SuccessorFromEntryStamp; both call sites now go through it. - Make the parser ordering test deterministic. Per coderabbitai: time.Now().UnixNano() drops the monotonic clock component, so two back-to-back calls can decrease if the wall clock steps backward — the prior test was exercising OS clock behavior rather than the parser. Replace with fixed nanosecond values. - Close a suspended-versioning race. Per coderabbitai: the prior putSuspendedVersioningObject called updateIsLatestFlagsForSuspendedVersioning after putToFiler returned, i.e. after the object write lock released. A concurrent PUT could promote a newer latest version, which we'd then wipe — leaving the older "null" object incorrectly current. Move the cleanup into the afterCreate callback so the null write and the .versions pointer clear (including the new demotion stamp) run atomically under the same lock. Best-effort logging is preserved. * fix(s3/lifecycle): clear noncurrent_since stamp on test backdate Backdating a version's mtime in tests is not a coherent claim about when it became noncurrent — production stamps record the successor's PUT time, which the test doesn't manipulate. The prior commit rewrote the stamp to the backdated instant, but for TestLifecycleNewerNoncurrent that creates an inconsistent state: v3's stamp says "demoted 30 days ago" while v4's mtime (the supposed demoter) is real-now. With both NewerNoncurrentVersions and NoncurrentDays in the same rule, the NoncurrentDays floor passes against the backdated stamp and the rank-based check then deletes v3 via the meta-log historical replay that misranks against current state. Clearing the stamp instead lets the lifecycle engine fall back to the sibling-mtime derivation the tests were originally written against: the legacy code path is preserved end-to-end while the new explicit- stamp path is exercised by the unit tests in s3lifecycle/noncurrent_since_test.go and the bootstrap-walker integration in scheduler/bootstrap_test.go. The deeper interaction — historical meta-log replay ranking against current state inside routePointerTransitionExpand — is pre-existing and is no longer masked by the freshly-PUT successor's mtime once the stamp is read. Tracked separately; not blocking this PR. * fix(s3): stamp noncurrent_since before the .versions/ pointer flip The pointer-flip on the .versions/ directory emits a meta-log event that the lifecycle router consumes via routePointerTransition. The router then calls LookupVersion on the demoted version's id. With the prior ordering — pointer flip first, stamp second — the router could read the demoted entry before markVersionNoncurrent landed and fall back to the legacy sibling-mtime derivation. Versioned COPY is the clean break: the new latest version keeps the source object's mtime instead of recording the moment v_old was demoted, so the fallback's successor clock can be arbitrarily wrong. Reorder both updateLatestVersionInDirectory and updateIsLatestFlagsForSuspendedVersioning so the stamp is written first; the pointer flip then emits an event into a state where the stamp is already present. Failure of the stamp write remains non-fatal — lifecycle still falls back to the legacy derivation in that case, with the same caveats as before the PR but no race window.
874 lines
31 KiB
Go
874 lines
31 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
)
|
|
|
|
// SiblingLister inspects the surviving versions of a versioned key.
|
|
// nil receiver or an error means "unknown" — callers suppress. Four
|
|
// queries: Survivors paginates the .versions/ container plus the bare
|
|
// null version (used by sole-survivor and bootstrap); LookupVersion
|
|
// fetches a single version file by id (used by pointer-transition
|
|
// routing to read the displaced version's identity and mtime);
|
|
// ListVersions paginates every version file in the .versions/
|
|
// container (used to compute NoncurrentIndex when a NewerNoncurrent
|
|
// rule is active); LookupNullVersion returns the bare-key entry that
|
|
// represents the null version (used by pointer-transition routing
|
|
// when oldID is empty and to include the null in expansion ranks).
|
|
type SiblingLister interface {
|
|
Survivors(ctx context.Context, bucket, objectKey string) (Survivors, error)
|
|
LookupVersion(ctx context.Context, bucket, objectKey, versionID string) (*filer_pb.Entry, error)
|
|
ListVersions(ctx context.Context, bucket, objectKey string) ([]*filer_pb.Entry, error)
|
|
LookupNullVersion(ctx context.Context, bucket, objectKey string) (entry *filer_pb.Entry, explicit bool, err error)
|
|
}
|
|
|
|
// Survivors describes the state under .versions/<key>/ plus the bare
|
|
// null-version that exists when versioning was turned on after the
|
|
// object was first PUT (s3api_object_versioning.go treats <bucket>/<key>
|
|
// as a regular file, the null version, in that case).
|
|
type Survivors struct {
|
|
Count int // entries under .versions/<key>/, capped at 2
|
|
LoneEntry *filer_pb.Entry // populated when Count == 1
|
|
HasNullVersion bool // bare <bucket>/<key> exists as a regular file
|
|
}
|
|
|
|
// Match is one (event, action) pair where EvaluateAction fired. The
|
|
// dispatcher runs `LifecycleDelete` at DueTime; identity-CAS in the RPC
|
|
// guards against drift between schedule time and dispatch time.
|
|
//
|
|
// For NoncurrentDays / NewerNoncurrent on a versioned bucket, ObjectKey
|
|
// is the seaweedfs storage path (logical-key + ".versions/" + version_id)
|
|
// so the dispatcher can locate the specific version, and VersionID
|
|
// carries the AWS-visible version ID separately.
|
|
type Match struct {
|
|
Key s3lifecycle.ActionKey
|
|
Action *engine.CompiledAction
|
|
Result s3lifecycle.EvalResult
|
|
EventTs time.Time
|
|
DueTime time.Time
|
|
Bucket string
|
|
ObjectKey string
|
|
VersionID string
|
|
Identity *EntryIdentity
|
|
}
|
|
|
|
// EntryIdentity is the schedule-time CAS witness; the dispatcher serializes
|
|
// it into the LifecycleDelete request. The fields mirror
|
|
// s3_lifecycle_pb.EntryIdentity but stay in-package so the router doesn't
|
|
// pull a proto dependency.
|
|
type EntryIdentity struct {
|
|
MtimeNs int64
|
|
Size int64
|
|
HeadFid string
|
|
ExtendedHash []byte
|
|
}
|
|
|
|
// Route returns the matches that fire for ev against snap. Only active
|
|
// event-driven actions are considered; SCAN_AT_DATE and DISABLED bypass
|
|
// this path.
|
|
func Route(ctx context.Context, snap *engine.Snapshot, ev *reader.Event, now time.Time, lister SiblingLister) []Match {
|
|
if snap == nil || ev == nil {
|
|
return nil
|
|
}
|
|
keys := snap.BucketActionKeys(ev.Bucket)
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
// Bootstrap-expanded version event: sibling state is pre-computed,
|
|
// info.Key is the LOGICAL key so rule prefixes match. Skip the
|
|
// meta-log path's version-folder skip.
|
|
if ev.BootstrapVersion != nil {
|
|
return routeBootstrapVersion(snap, ev, keys)
|
|
}
|
|
|
|
versioned := snap.BucketVersioned(ev.Bucket)
|
|
|
|
// .versions/ directory metadata update: when ExtLatestVersionIdKey
|
|
// changes, the OLD pointer value names a version that's now
|
|
// noncurrent. Route NoncurrentDays / NewerNoncurrent for it without
|
|
// waiting for the next bootstrap.
|
|
if versioned && ev.NewEntry != nil && ev.OldEntry != nil && ev.NewEntry.IsDirectory && isVersionsContainerKey(ev.Key) {
|
|
if !hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindNoncurrentDays) &&
|
|
!hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindNewerNoncurrent) {
|
|
return nil
|
|
}
|
|
return routePointerTransition(ctx, snap, ev, keys, lister)
|
|
}
|
|
|
|
// EXP_DM can fire on two version-folder events: the marker create
|
|
// (sole survivor immediately) and a noncurrent hard-delete that
|
|
// leaves only the marker behind. Both reach routeSoleSurvivorMarker.
|
|
if versioned && isVersionFolderPath(ev.Key) {
|
|
if !hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindExpiredDeleteMarker) {
|
|
return nil
|
|
}
|
|
return routeSoleSurvivorMarker(ctx, snap, ev, keys, lister)
|
|
}
|
|
|
|
if ev.NewEntry == nil {
|
|
return nil
|
|
}
|
|
info := buildObjectInfo(ev, versioned)
|
|
if info == nil {
|
|
return nil
|
|
}
|
|
eventTime := time.Unix(0, ev.TsNs)
|
|
|
|
var matches []Match
|
|
for _, key := range keys {
|
|
action := snap.Action(key)
|
|
if action == nil || !action.IsActive() {
|
|
continue
|
|
}
|
|
if action.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
// (kind, info) shape gate: ABORT_MPU only fires on MPU init events,
|
|
// and other kinds never do. Without this an MPU init would be
|
|
// matched against NONCURRENT_DAYS (IsLatest=false reads as a
|
|
// non-current version) and the dispatcher would BLOCK on empty
|
|
// version_id.
|
|
if info.IsMPUInit && key.ActionKind != s3lifecycle.ActionKindAbortMPU {
|
|
continue
|
|
}
|
|
if !info.IsMPUInit && key.ActionKind == s3lifecycle.ActionKindAbortMPU {
|
|
continue
|
|
}
|
|
// Schedule from the per-kind due moment. ExpirationDate is
|
|
// rule-relative (the date IS the moment); other kinds are
|
|
// ModTime-relative. Using ModTime+Delay for ExpirationDate
|
|
// (Delay=0) puts dueTime at the entry's mtime — a backdated
|
|
// object's mtime is BEFORE the rule's date, so the eligibility
|
|
// check below would skip it. ComputeDueAt encapsulates both
|
|
// shapes; the dispatcher's identity-CAS catches drift if the
|
|
// object changes meanwhile.
|
|
dueTime := s3lifecycle.ComputeDueAt(action.Rule, key.ActionKind, info)
|
|
if dueTime.IsZero() {
|
|
continue
|
|
}
|
|
res := s3lifecycle.EvaluateAction(action.Rule, key.ActionKind, info, dueTime)
|
|
if res.Action == s3lifecycle.ActionNone {
|
|
continue
|
|
}
|
|
matches = append(matches, Match{
|
|
Key: key,
|
|
Action: action,
|
|
Result: res,
|
|
EventTs: eventTime,
|
|
DueTime: dueTime,
|
|
Bucket: ev.Bucket,
|
|
ObjectKey: ev.Key,
|
|
Identity: buildIdentity(ev),
|
|
})
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// routePointerTransition handles a .versions/ container update where
|
|
// ExtLatestVersionIdKey changed: the OLD pointer value names a version
|
|
// that just became noncurrent. Two lookup shapes:
|
|
//
|
|
// - Pure NoncurrentVersionExpirationDays without NewerNoncurrentVersions:
|
|
// a single LookupVersion of oldID is enough — the displaced version
|
|
// is the only one that newly entered eligibility for this rule.
|
|
//
|
|
// - Any active NewerNoncurrentVersions rule: a pointer flip shifts
|
|
// every prior noncurrent's rank by one, so the version that *just
|
|
// crossed* the keep-count threshold needs evaluation too. List the
|
|
// full .versions/ container, rank newest-first, and route every
|
|
// eligible noncurrent. Identity-CAS handles dedup with earlier
|
|
// schedules.
|
|
//
|
|
// Without this branch the worker has to wait for the next bootstrap to
|
|
// schedule retention on a freshly-noncurrent version.
|
|
func routePointerTransition(ctx context.Context, snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey, lister SiblingLister) []Match {
|
|
if lister == nil {
|
|
return nil
|
|
}
|
|
logical := strings.TrimSuffix(ev.Key, s3_constants.VersionsFolder)
|
|
if logical == "" {
|
|
return nil
|
|
}
|
|
oldID := string(ev.OldEntry.Extended[s3_constants.ExtLatestVersionIdKey])
|
|
newID := string(ev.NewEntry.Extended[s3_constants.ExtLatestVersionIdKey])
|
|
if oldID == newID {
|
|
// Same id means the update didn't transition the pointer.
|
|
return nil
|
|
}
|
|
// oldID == "" doesn't mean "nothing displaced": a bare null may
|
|
// have been the implicit/explicit latest before the pointer
|
|
// flipped to a real id.
|
|
// newID == "" means a suspended-versioning write cleared the
|
|
// pointer and made the bare null current. The cached
|
|
// ExtLatestVersionMtimeKey may still hold the prior latest's
|
|
// mtime (stale), so we must NOT use successorModTimeFromContainer
|
|
// in that case — derive the successor clock from the null entry's
|
|
// mtime instead. latestIDForExpand carries the same substitution
|
|
// so the expansion path's latestPos lookup matches the null sibling.
|
|
var successor time.Time
|
|
latestIDForExpand := newID
|
|
if newID == "" {
|
|
nullEntry, _, err := lister.LookupNullVersion(ctx, ev.Bucket, logical)
|
|
if err != nil {
|
|
glog.V(2).Infof("lifecycle router: lookup null %s/%s: %v", ev.Bucket, logical, err)
|
|
return nil
|
|
}
|
|
if nullEntry == nil || nullEntry.Attributes == nil {
|
|
return nil
|
|
}
|
|
successor = time.Unix(nullEntry.Attributes.Mtime, int64(nullEntry.Attributes.MtimeNs))
|
|
latestIDForExpand = "null"
|
|
} else {
|
|
successor = successorModTimeFromContainer(ev.NewEntry)
|
|
}
|
|
if successor.IsZero() {
|
|
return nil
|
|
}
|
|
if needsFullExpansion(snap, keys) {
|
|
return routePointerTransitionExpand(ctx, snap, ev, keys, lister, logical, latestIDForExpand, successor)
|
|
}
|
|
return routePointerTransitionDisplaced(ctx, snap, ev, keys, lister, logical, oldID, successor)
|
|
}
|
|
|
|
// needsFullExpansion reports whether any active event-driven rule on
|
|
// this bucket cares about NoncurrentIndex (NewerNoncurrentVersions > 0
|
|
// in either NoncurrentDays or pure-count NewerNoncurrent).
|
|
func needsFullExpansion(snap *engine.Snapshot, keys []s3lifecycle.ActionKey) bool {
|
|
for _, k := range keys {
|
|
if k.ActionKind != s3lifecycle.ActionKindNoncurrentDays && k.ActionKind != s3lifecycle.ActionKindNewerNoncurrent {
|
|
continue
|
|
}
|
|
a := snap.Action(k)
|
|
if a == nil || !a.IsActive() || a.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
if a.Rule != nil && a.Rule.NewerNoncurrentVersions > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// successorModTimeFromContainer reads the cached latest-version mtime
|
|
// from the .versions/ container's Extended map.
|
|
// updateLatestVersionInDirectory writes it via setCachedListMetadata
|
|
// alongside ExtLatestVersionIdKey, but the directory's own
|
|
// Attributes.Mtime is preserved across pointer updates — using it
|
|
// directly would let a stale dir mtime trigger expiration immediately.
|
|
// Returns zero time if the cached mtime is missing or unparseable; the
|
|
// caller suppresses in that case.
|
|
func successorModTimeFromContainer(entry *filer_pb.Entry) time.Time {
|
|
raw, ok := entry.Extended[s3_constants.ExtLatestVersionMtimeKey]
|
|
if !ok || len(raw) == 0 {
|
|
return time.Time{}
|
|
}
|
|
secs, err := strconv.ParseInt(string(raw), 10, 64)
|
|
if err != nil || secs <= 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(secs, 0)
|
|
}
|
|
|
|
// routePointerTransitionDisplaced is the single-lookup path: only the
|
|
// displaced version's noncurrent eligibility could have changed, so
|
|
// fetching just its file is enough. oldID == "" routes the bare null
|
|
// version instead — it was the implicit latest before the pointer
|
|
// flipped to a real id.
|
|
func routePointerTransitionDisplaced(ctx context.Context, snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey, lister SiblingLister, logical, oldID string, successor time.Time) []Match {
|
|
var displaced *filer_pb.Entry
|
|
displacedID := oldID
|
|
if oldID == "" {
|
|
nullEntry, _, err := lister.LookupNullVersion(ctx, ev.Bucket, logical)
|
|
if err != nil {
|
|
glog.V(2).Infof("lifecycle router: lookup null version %s/%s: %v", ev.Bucket, logical, err)
|
|
return nil
|
|
}
|
|
if nullEntry == nil {
|
|
return nil
|
|
}
|
|
displaced = nullEntry
|
|
displacedID = "null"
|
|
} else {
|
|
entry, err := lister.LookupVersion(ctx, ev.Bucket, logical, oldID)
|
|
if err != nil {
|
|
glog.V(2).Infof("lifecycle router: lookup displaced version %s/%s/%s: %v", ev.Bucket, logical, oldID, err)
|
|
return nil
|
|
}
|
|
displaced = entry
|
|
}
|
|
if displaced == nil || displaced.Attributes == nil {
|
|
return nil
|
|
}
|
|
// Prefer the explicit demotion stamp on the displaced entry over the
|
|
// container-derived successor. Stamp is written by the S3 PUT handler
|
|
// at the moment the pointer flipped; container value is derived from
|
|
// the new latest's mtime and may drift across retries.
|
|
effectiveSuccessor := successor
|
|
if stamp := s3lifecycle.SuccessorFromEntryStamp(displaced); !stamp.IsZero() {
|
|
effectiveSuccessor = stamp
|
|
}
|
|
idx := 0
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: logical,
|
|
ModTime: time.Unix(displaced.Attributes.Mtime, int64(displaced.Attributes.MtimeNs)),
|
|
Size: int64(displaced.Attributes.FileSize),
|
|
IsLatest: false,
|
|
IsDeleteMarker: string(displaced.Extended[s3_constants.ExtDeleteMarkerKey]) == "true",
|
|
NoncurrentIndex: &idx,
|
|
SuccessorModTime: effectiveSuccessor,
|
|
}
|
|
if tags := extractTags(displaced.Extended); len(tags) > 0 {
|
|
info.Tags = tags
|
|
}
|
|
return emitNoncurrentMatches(snap, ev, keys, info, displaced, displacedID, effectiveSuccessor)
|
|
}
|
|
|
|
// routePointerTransitionExpand routes only the versions that newly
|
|
// became eligible by the pointer flip:
|
|
//
|
|
// - rank 0: the displaced version (newly noncurrent), needed for the
|
|
// pure-NoncurrentDays clock,
|
|
// - rank == rule.NewerNoncurrentVersions for each active rule that
|
|
// gates on count: the version at exactly that rank just crossed
|
|
// from kept to expired.
|
|
//
|
|
// Emitting every eligible noncurrent on every PUT would push
|
|
// O(versions) heap entries per flip — Schedule.Add doesn't dedup, so
|
|
// identity-CAS at dispatch only stops the wasted RPC, not the heap
|
|
// growth. Bootstrap still owns full backfill.
|
|
func routePointerTransitionExpand(ctx context.Context, snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey, lister SiblingLister, logical, newID string, successor time.Time) []Match {
|
|
rawVersions, err := lister.ListVersions(ctx, ev.Bucket, logical)
|
|
if err != nil {
|
|
glog.V(2).Infof("lifecycle router: list versions %s/%s: %v", ev.Bucket, logical, err)
|
|
return nil
|
|
}
|
|
// Include the bare null version in the sibling set: count-based
|
|
// ranks are wrong if a pre-versioning or suspended-null entry
|
|
// exists outside .versions/. ID is "null" for sort + emit.
|
|
nullEntry, _, nullErr := lister.LookupNullVersion(ctx, ev.Bucket, logical)
|
|
if nullErr != nil {
|
|
glog.V(2).Infof("lifecycle router: lookup null %s/%s: %v", ev.Bucket, logical, nullErr)
|
|
return nil
|
|
}
|
|
type sibling struct {
|
|
entry *filer_pb.Entry
|
|
id string
|
|
}
|
|
siblings := make([]sibling, 0, len(rawVersions)+1)
|
|
for _, v := range rawVersions {
|
|
if v == nil || v.Attributes == nil {
|
|
continue
|
|
}
|
|
id := string(v.Extended[s3_constants.ExtVersionIdKey])
|
|
if id == "" {
|
|
continue
|
|
}
|
|
siblings = append(siblings, sibling{entry: v, id: id})
|
|
}
|
|
if nullEntry != nil && nullEntry.Attributes != nil {
|
|
siblings = append(siblings, sibling{entry: nullEntry, id: "null"})
|
|
}
|
|
if len(siblings) == 0 {
|
|
return nil
|
|
}
|
|
sort.SliceStable(siblings, func(i, j int) bool {
|
|
mi := siblings[i].entry.Attributes.Mtime*int64(1e9) + int64(siblings[i].entry.Attributes.MtimeNs)
|
|
mj := siblings[j].entry.Attributes.Mtime*int64(1e9) + int64(siblings[j].entry.Attributes.MtimeNs)
|
|
if mi != mj {
|
|
return mi > mj
|
|
}
|
|
return s3lifecycle.CompareVersionIds(siblings[i].id, siblings[j].id) < 0
|
|
})
|
|
// Resolve latestPos by finding newID. Default to -1 so a missing
|
|
// newID (race with the listing, or torn write) suppresses the
|
|
// expansion: we'd otherwise call the actual newest sibling "latest"
|
|
// against the pointer's intent and misrank every noncurrent.
|
|
// Bootstrap repairs state on the next walk.
|
|
latestPos := -1
|
|
if newID != "" {
|
|
for i, s := range siblings {
|
|
if s.id == newID {
|
|
latestPos = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if latestPos < 0 {
|
|
glog.V(2).Infof("lifecycle router: pointer transition %s/%s: new id %s not found in listing", ev.Bucket, logical, newID)
|
|
return nil
|
|
}
|
|
noncurrentCount := len(siblings) - 1
|
|
|
|
// Collect the target noncurrent ranks: 0 (the freshly displaced)
|
|
// plus N for each active count-gated rule.
|
|
rankSet := map[int]struct{}{0: {}}
|
|
for _, k := range keys {
|
|
if k.ActionKind != s3lifecycle.ActionKindNoncurrentDays && k.ActionKind != s3lifecycle.ActionKindNewerNoncurrent {
|
|
continue
|
|
}
|
|
a := snap.Action(k)
|
|
if a == nil || !a.IsActive() || a.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
if a.Rule != nil && a.Rule.NewerNoncurrentVersions > 0 {
|
|
rankSet[a.Rule.NewerNoncurrentVersions] = struct{}{}
|
|
}
|
|
}
|
|
ranks := make([]int, 0, len(rankSet))
|
|
for r := range rankSet {
|
|
ranks = append(ranks, r)
|
|
}
|
|
sort.Ints(ranks)
|
|
|
|
var matches []Match
|
|
for _, rank := range ranks {
|
|
if rank >= noncurrentCount {
|
|
continue
|
|
}
|
|
// Convert noncurrent rank to position in the sorted slice,
|
|
// skipping the latest's slot.
|
|
i := rank
|
|
if rank >= latestPos {
|
|
i = rank + 1
|
|
}
|
|
s := siblings[i]
|
|
// Successor mtime: the entry directly newer than this one in
|
|
// the sorted list. When the next-newer slot is the latest,
|
|
// use the cached successor (the new latest's mtime); otherwise
|
|
// the immediate predecessor's mtime.
|
|
var thisSuccessor time.Time
|
|
if i > 0 && i-1 != latestPos {
|
|
thisSuccessor = time.Unix(siblings[i-1].entry.Attributes.Mtime, int64(siblings[i-1].entry.Attributes.MtimeNs))
|
|
} else {
|
|
thisSuccessor = successor
|
|
}
|
|
// Override with the explicit demotion stamp when present —
|
|
// PUT-time wall clock beats derived sibling mtime for accuracy
|
|
// and is immune to mtime edits on the sibling itself.
|
|
if stamp := s3lifecycle.SuccessorFromEntryStamp(s.entry); !stamp.IsZero() {
|
|
thisSuccessor = stamp
|
|
}
|
|
idx := rank
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: logical,
|
|
ModTime: time.Unix(s.entry.Attributes.Mtime, int64(s.entry.Attributes.MtimeNs)),
|
|
Size: int64(s.entry.Attributes.FileSize),
|
|
IsLatest: false,
|
|
IsDeleteMarker: string(s.entry.Extended[s3_constants.ExtDeleteMarkerKey]) == "true",
|
|
NoncurrentIndex: &idx,
|
|
SuccessorModTime: thisSuccessor,
|
|
NumVersions: len(siblings),
|
|
}
|
|
if tags := extractTags(s.entry.Extended); len(tags) > 0 {
|
|
info.Tags = tags
|
|
}
|
|
matches = append(matches, emitNoncurrentMatches(snap, ev, keys, info, s.entry, s.id, thisSuccessor)...)
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// emitNoncurrentMatches walks NoncurrentDays / NewerNoncurrent action
|
|
// keys and emits Matches for each one that fires. Shared between the
|
|
// single-lookup and full-expansion paths.
|
|
func emitNoncurrentMatches(snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey, info *s3lifecycle.ObjectInfo, entry *filer_pb.Entry, versionID string, successor time.Time) []Match {
|
|
eventTime := time.Unix(0, ev.TsNs)
|
|
identity := buildIdentityFromEntry(entry)
|
|
var matches []Match
|
|
for _, key := range keys {
|
|
if key.ActionKind != s3lifecycle.ActionKindNoncurrentDays && key.ActionKind != s3lifecycle.ActionKindNewerNoncurrent {
|
|
continue
|
|
}
|
|
action := snap.Action(key)
|
|
if action == nil || !action.IsActive() || action.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
clock := successor
|
|
if clock.IsZero() {
|
|
clock = info.ModTime
|
|
}
|
|
dueTime := clock.Add(action.Delay)
|
|
res := s3lifecycle.EvaluateAction(action.Rule, key.ActionKind, info, dueTime)
|
|
if res.Action == s3lifecycle.ActionNone {
|
|
continue
|
|
}
|
|
matches = append(matches, Match{
|
|
Key: key,
|
|
Action: action,
|
|
Result: res,
|
|
EventTs: eventTime,
|
|
DueTime: dueTime,
|
|
Bucket: ev.Bucket,
|
|
ObjectKey: info.Key,
|
|
VersionID: versionID,
|
|
Identity: identity,
|
|
})
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// routeSoleSurvivorMarker emits an EXP_DM Match against the LOGICAL key
|
|
// (so the dispatcher can call deleteSpecificObjectVersion) with the
|
|
// marker's version_id. Handles two events: a marker create (the new
|
|
// entry IS the marker) and a noncurrent hard-delete that leaves the
|
|
// marker behind (the listing's lone entry IS the marker). The server
|
|
// re-checks before deleting.
|
|
func routeSoleSurvivorMarker(ctx context.Context, snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey, lister SiblingLister) []Match {
|
|
if lister == nil {
|
|
return nil
|
|
}
|
|
// Skip the listing RPC for events that can't possibly produce a
|
|
// sole-survivor marker: a regular non-marker version create/update
|
|
// always lands at Count >= 2.
|
|
if ev.NewEntry != nil && !isDeleteMarkerEntry(ev.NewEntry) {
|
|
return nil
|
|
}
|
|
logicalKey, ok := logicalKeyFromVersionPath(ev.Key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
s, err := lister.Survivors(ctx, ev.Bucket, logicalKey)
|
|
if err != nil {
|
|
glog.V(2).Infof("lifecycle router: survivors %s/%s: %v", ev.Bucket, logicalKey, err)
|
|
return nil
|
|
}
|
|
// Pre-versioning bare-key objects (the "null" version) live outside
|
|
// .versions/. Treating count==1 as sole-survivor while a null
|
|
// version exists would let lifecycle delete the marker and re-expose
|
|
// the old object.
|
|
if s.Count != 1 || s.HasNullVersion || s.LoneEntry == nil {
|
|
return nil
|
|
}
|
|
if !isDeleteMarkerEntry(s.LoneEntry) {
|
|
return nil
|
|
}
|
|
versionID := string(s.LoneEntry.Extended[s3_constants.ExtVersionIdKey])
|
|
if versionID == "" {
|
|
// Empty version_id would BLOCK at dispatch and freeze the cursor.
|
|
return nil
|
|
}
|
|
entry := s.LoneEntry
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: logicalKey,
|
|
ModTime: time.Unix(entry.Attributes.Mtime, int64(entry.Attributes.MtimeNs)),
|
|
Size: int64(entry.Attributes.FileSize),
|
|
IsLatest: true,
|
|
IsDeleteMarker: true,
|
|
NumVersions: 1,
|
|
}
|
|
if tags := extractTags(entry.Extended); len(tags) > 0 {
|
|
info.Tags = tags
|
|
}
|
|
eventTime := time.Unix(0, ev.TsNs)
|
|
identity := buildIdentityFromEntry(entry)
|
|
var matches []Match
|
|
for _, key := range keys {
|
|
if key.ActionKind != s3lifecycle.ActionKindExpiredDeleteMarker {
|
|
continue
|
|
}
|
|
action := snap.Action(key)
|
|
if action == nil || !action.IsActive() || action.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
dueTime := info.ModTime.Add(action.Delay)
|
|
res := s3lifecycle.EvaluateAction(action.Rule, key.ActionKind, info, dueTime)
|
|
if res.Action == s3lifecycle.ActionNone {
|
|
continue
|
|
}
|
|
matches = append(matches, Match{
|
|
Key: key,
|
|
Action: action,
|
|
Result: res,
|
|
EventTs: eventTime,
|
|
DueTime: dueTime,
|
|
Bucket: ev.Bucket,
|
|
ObjectKey: logicalKey,
|
|
VersionID: versionID,
|
|
Identity: identity,
|
|
})
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// routeBootstrapVersion handles a synthesized event from BucketBootstrapper.
|
|
// The bootstrap walker has already listed .versions/<key>/, sorted siblings
|
|
// newest-first, and stamped each one's IsLatest / NoncurrentIndex /
|
|
// SuccessorModTime. The router only needs to assemble ObjectInfo and run
|
|
// the match loop with the standard kind gates. ev.NewEntry is the version
|
|
// file itself; ev.Key is the version-folder path; the LOGICAL key from
|
|
// BootstrapVersion drives prefix matching and the dispatcher.
|
|
func routeBootstrapVersion(snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey) []Match {
|
|
bv := ev.BootstrapVersion
|
|
entry := ev.NewEntry
|
|
if entry == nil || entry.Attributes == nil || bv.LogicalKey == "" {
|
|
return nil
|
|
}
|
|
idx := bv.NoncurrentIndex
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: bv.LogicalKey,
|
|
ModTime: time.Unix(entry.Attributes.Mtime, int64(entry.Attributes.MtimeNs)),
|
|
Size: int64(entry.Attributes.FileSize),
|
|
IsLatest: bv.IsLatest,
|
|
IsDeleteMarker: bv.IsDeleteMarker,
|
|
NumVersions: bv.NumVersions,
|
|
SuccessorModTime: bv.SuccessorModTime,
|
|
}
|
|
if !bv.IsLatest {
|
|
info.NoncurrentIndex = &idx
|
|
}
|
|
if tags := extractTags(entry.Extended); len(tags) > 0 {
|
|
info.Tags = tags
|
|
}
|
|
eventTime := time.Unix(0, ev.TsNs)
|
|
identity := buildIdentityFromEntry(entry)
|
|
var matches []Match
|
|
for _, key := range keys {
|
|
action := snap.Action(key)
|
|
if action == nil || !action.IsActive() || action.Mode != engine.ModeEventDriven {
|
|
continue
|
|
}
|
|
// ABORT_MPU never applies to a versioned object.
|
|
if key.ActionKind == s3lifecycle.ActionKindAbortMPU {
|
|
continue
|
|
}
|
|
// Noncurrent rules clock from when this version was replaced
|
|
// (SuccessorModTime), not from when it was originally written.
|
|
// Bootstrap populates SuccessorModTime; fall back to ModTime
|
|
// for the latest version (no successor exists).
|
|
clock := info.ModTime
|
|
if !info.IsLatest && !info.SuccessorModTime.IsZero() {
|
|
clock = info.SuccessorModTime
|
|
}
|
|
dueTime := clock.Add(action.Delay)
|
|
res := s3lifecycle.EvaluateAction(action.Rule, key.ActionKind, info, dueTime)
|
|
if res.Action == s3lifecycle.ActionNone {
|
|
continue
|
|
}
|
|
// Pin the version_id only for kinds the dispatcher needs to
|
|
// target by version: noncurrent retention and the marker
|
|
// itself. EXPIRATION_DAYS / EXPIRATION_DATE on the latest
|
|
// must NOT carry it — between schedule and dispatch a fresh
|
|
// PUT can land, and identity-CAS against the original
|
|
// version's bytes would still pass even though the latest has
|
|
// moved on. Empty VersionID makes the dispatcher fetch the
|
|
// current latest, where identity-CAS resolves to STALE_IDENTITY
|
|
// and bootstrap re-schedules with the new latest's identity.
|
|
var matchVersionID string
|
|
switch key.ActionKind {
|
|
case s3lifecycle.ActionKindNoncurrentDays,
|
|
s3lifecycle.ActionKindNewerNoncurrent,
|
|
s3lifecycle.ActionKindExpiredDeleteMarker:
|
|
matchVersionID = bv.VersionID
|
|
}
|
|
matches = append(matches, Match{
|
|
Key: key,
|
|
Action: action,
|
|
Result: res,
|
|
EventTs: eventTime,
|
|
DueTime: dueTime,
|
|
Bucket: ev.Bucket,
|
|
ObjectKey: bv.LogicalKey,
|
|
VersionID: matchVersionID,
|
|
Identity: identity,
|
|
})
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// logicalKeyFromVersionPath extracts <logical> from <logical>.versions/<file>.
|
|
// Returns false for a bucket-root marker (path = ".versions/<file>"), since
|
|
// AWS S3 has no concept of an object at the bucket key itself.
|
|
func logicalKeyFromVersionPath(versionPath string) (string, bool) {
|
|
lastSlash := strings.LastIndex(versionPath, "/")
|
|
if lastSlash <= 0 {
|
|
return "", false
|
|
}
|
|
parent := versionPath[:lastSlash]
|
|
if !strings.HasSuffix(parent, s3_constants.VersionsFolder) {
|
|
return "", false
|
|
}
|
|
logical := strings.TrimSuffix(parent, s3_constants.VersionsFolder)
|
|
if logical == "" {
|
|
return "", false
|
|
}
|
|
return logical, true
|
|
}
|
|
|
|
// buildObjectInfo derives an ObjectInfo from a meta-log event. Returns
|
|
// nil for shapes the router can't classify safely: missing attributes,
|
|
// non-MPU directories, version-folder files (those route through
|
|
// routeSoleSurvivorMarker upstream when EXP_DM applies). On a versioned
|
|
// bucket the latest pointer lives in the .versions/ directory's
|
|
// Extended map; without it we leave NumVersions=0 so the bootstrap walk
|
|
// drives noncurrent retention.
|
|
func buildObjectInfo(ev *reader.Event, versioned bool) *s3lifecycle.ObjectInfo {
|
|
entry := ev.NewEntry
|
|
if entry == nil || entry.Attributes == nil {
|
|
return nil
|
|
}
|
|
if destKey, ok := mpuInitInfo(ev, entry); ok {
|
|
return &s3lifecycle.ObjectInfo{
|
|
Key: destKey,
|
|
ModTime: time.Unix(entry.Attributes.Mtime, int64(entry.Attributes.MtimeNs)),
|
|
IsMPUInit: true,
|
|
}
|
|
}
|
|
if entry.IsDirectory {
|
|
return nil
|
|
}
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: ev.Key,
|
|
ModTime: time.Unix(entry.Attributes.Mtime, int64(entry.Attributes.MtimeNs)),
|
|
Size: int64(entry.Attributes.FileSize),
|
|
IsLatest: true,
|
|
NumVersions: 1,
|
|
}
|
|
if versioned {
|
|
if isVersionFolderPath(ev.Key) {
|
|
return nil
|
|
}
|
|
info.NumVersions = 0
|
|
}
|
|
if tags := extractTags(entry.Extended); len(tags) > 0 {
|
|
info.Tags = tags
|
|
}
|
|
if isDeleteMarkerEntry(entry) {
|
|
info.IsDeleteMarker = true
|
|
}
|
|
return info
|
|
}
|
|
|
|
// isVersionsContainerKey reports whether the bucket-relative key IS a
|
|
// .versions/ container itself (e.g. "logs/foo.versions"), as opposed to
|
|
// a file inside one. Used to recognize directory-level events whose
|
|
// Extended map carries the latest pointer for an object.
|
|
func isVersionsContainerKey(key string) bool {
|
|
if key == s3_constants.VersionsFolder {
|
|
// Bucket-root .versions: no logical object key.
|
|
return false
|
|
}
|
|
return strings.HasSuffix(key, s3_constants.VersionsFolder)
|
|
}
|
|
|
|
// isVersionFolderPath reports whether the bucket-relative key sits inside a
|
|
// .versions/ folder — i.e. the path's parent segment ends with the
|
|
// VersionsFolder suffix. Used by the versioned-bucket gate so the router
|
|
// skips version-file events that need sibling state to be classified
|
|
// safely.
|
|
func isVersionFolderPath(key string) bool {
|
|
idx := strings.LastIndex(key, "/")
|
|
if idx <= 0 {
|
|
return false
|
|
}
|
|
parent := key[:idx]
|
|
parentIdx := strings.LastIndex(parent, "/")
|
|
leaf := parent[parentIdx+1:]
|
|
return strings.HasSuffix(leaf, s3_constants.VersionsFolder)
|
|
}
|
|
|
|
// mpuInitInfo recognizes a multipart-upload init: a directory entry at
|
|
// `.uploads/<upload_id>` carrying the destination key in Extended. Sub-events
|
|
// for part uploads (deeper paths under the upload directory) are deliberately
|
|
// rejected — they ride a different mtime and would over-fire ABORT_MPU.
|
|
func mpuInitInfo(ev *reader.Event, entry *filer_pb.Entry) (destKey string, ok bool) {
|
|
uploadsPrefix := s3_constants.MultipartUploadsFolder + "/"
|
|
if !entry.IsDirectory || !strings.HasPrefix(ev.Key, uploadsPrefix) {
|
|
return "", false
|
|
}
|
|
rest := ev.Key[len(uploadsPrefix):]
|
|
if rest == "" || strings.ContainsRune(rest, '/') {
|
|
// `.uploads/` itself or `.uploads/<id>/<part>...`; not the init.
|
|
return "", false
|
|
}
|
|
keyBytes, hasKey := entry.Extended[s3_constants.ExtMultipartObjectKey]
|
|
if !hasKey || len(keyBytes) == 0 {
|
|
return "", false
|
|
}
|
|
return string(keyBytes), true
|
|
}
|
|
|
|
// buildIdentity captures the entry's schedule-time fingerprint for the CAS
|
|
// witness. Returns nil if the event has no entry to fingerprint (deletes).
|
|
func buildIdentity(ev *reader.Event) *EntryIdentity {
|
|
return buildIdentityFromEntry(ev.NewEntry)
|
|
}
|
|
|
|
func buildIdentityFromEntry(entry *filer_pb.Entry) *EntryIdentity {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
id := &EntryIdentity{}
|
|
if entry.Attributes != nil {
|
|
// Mirror the server-side computeEntryIdentity encoding: Mtime
|
|
// (seconds) and MtimeNs (nanosecond component) combine into
|
|
// EntryIdentity.MtimeNs as nanoseconds-since-epoch.
|
|
id.MtimeNs = entry.Attributes.Mtime*int64(1e9) + int64(entry.Attributes.MtimeNs)
|
|
id.Size = int64(entry.Attributes.FileSize)
|
|
}
|
|
if len(entry.GetChunks()) > 0 {
|
|
// Meta-log events arrive with chunk.FileId cleared by
|
|
// BeforeEntrySerialization; GetFileIdString reconstructs it from
|
|
// Fid so the worker matches the server-side fingerprint.
|
|
id.HeadFid = entry.GetChunks()[0].GetFileIdString()
|
|
}
|
|
id.ExtendedHash = s3lifecycle.HashExtended(entry.Extended)
|
|
return id
|
|
}
|
|
|
|
func extractTags(ext map[string][]byte) map[string]string {
|
|
if len(ext) == 0 {
|
|
return nil
|
|
}
|
|
prefix := s3_constants.AmzObjectTagging + "-"
|
|
var out map[string]string
|
|
for k, v := range ext {
|
|
if !strings.HasPrefix(k, prefix) {
|
|
continue
|
|
}
|
|
if out == nil {
|
|
out = map[string]string{}
|
|
}
|
|
out[k[len(prefix):]] = string(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasActiveEventDrivenAction gates I/O (e.g. sibling listing) on whether
|
|
// a match could actually fire. Mirrors the per-key filter in Route so
|
|
// disabled or scan-only actions don't pay the RPC.
|
|
func hasActiveEventDrivenAction(snap *engine.Snapshot, keys []s3lifecycle.ActionKey, kind s3lifecycle.ActionKind) bool {
|
|
for _, k := range keys {
|
|
if k.ActionKind != kind {
|
|
continue
|
|
}
|
|
a := snap.Action(k)
|
|
if a == nil {
|
|
continue
|
|
}
|
|
if a.IsActive() && a.Mode == engine.ModeEventDriven {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isDeleteMarkerEntry mirrors every read site for ExtDeleteMarkerKey:
|
|
// production writes []byte("true").
|
|
func isDeleteMarkerEntry(entry *filer_pb.Entry) bool {
|
|
if entry == nil || len(entry.Extended) == 0 {
|
|
return false
|
|
}
|
|
v, ok := entry.Extended[s3_constants.ExtDeleteMarkerKey]
|
|
return ok && string(v) == "true"
|
|
}
|