mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 14:30:43 +01:00
* s3lifecycle: schedule ExpirationDays from version-file writes On a versioned bucket a PUT lands in the meta-log as the version file plus the .versions/ directory update — there is no bare-key event, so the version-folder skip dropped every version-file event for all kinds but ExpiredDeleteMarker. ExpirationDays therefore only fired via the recovery walker's cold-start / rule-change pass: once a shard had a saved cursor, no daily run ever scheduled another delete marker. A version-file write is the latest for its logical key at write time, so route it through the same event-driven evaluation the regular path runs. The match carries the file's identity with an empty VersionID — the dispatcher resolves the current latest at due time, and a fresher PUT meanwhile resolves to STALE_IDENTITY there. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * lifecycle test: versioned Expiration fires on the second daily run Establishes the shard cursor with a first pass, then PUTs and backdates a new versioned object and runs again. The delete marker must appear without a rule change forcing a recovery walk. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: run TestLifecycleVersionedBucketExpiresOnSecondRun in the lifecycle matrix --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
947 lines
34 KiB
Go
947 lines
34 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.
|
|
// A stamped version-file write is also the latest for its logical key
|
|
// at write time — the versioned layout emits no bare-key event the
|
|
// regular path could see, so ExpirationDays must schedule here or it
|
|
// only ever fires via a cold-start / rule-change recovery walk.
|
|
if versioned && isVersionFolderPath(ev.Key) {
|
|
var matches []Match
|
|
if isVersionFileWrite(ev.NewEntry) {
|
|
matches = routeVersionFileExpiration(snap, ev, keys)
|
|
}
|
|
if hasActiveEventDrivenAction(snap, keys, s3lifecycle.ActionKindExpiredDeleteMarker) {
|
|
matches = append(matches, routeSoleSurvivorMarker(ctx, snap, ev, keys, lister)...)
|
|
}
|
|
return matches
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// isVersionFileWrite reports whether the event's new entry is a genuine
|
|
// version-file write: a non-marker file carrying ExtVersionIdKey. The
|
|
// version id stamp is what separates it from a literal user file under a
|
|
// *.versions/ parent.
|
|
func isVersionFileWrite(entry *filer_pb.Entry) bool {
|
|
if entry == nil || entry.IsDirectory || isDeleteMarkerEntry(entry) {
|
|
return false
|
|
}
|
|
return len(entry.Extended[s3_constants.ExtVersionIdKey]) > 0
|
|
}
|
|
|
|
// routeVersionFileExpiration schedules current-version expiration from a
|
|
// version-file write. The file is the latest at write time, and VersionID
|
|
// stays empty so the dispatcher resolves the latest at due time — a
|
|
// fresher PUT meanwhile resolves to STALE_IDENTITY there, and that PUT's
|
|
// own event carried its own schedule. A write that turns out to be an
|
|
// older version's metadata update no-ops the same way.
|
|
func routeVersionFileExpiration(snap *engine.Snapshot, ev *reader.Event, keys []s3lifecycle.ActionKey) []Match {
|
|
logical, ok := logicalKeyFromVersionPath(ev.Key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
entry := ev.NewEntry
|
|
if entry.Attributes == nil {
|
|
return nil
|
|
}
|
|
info := &s3lifecycle.ObjectInfo{
|
|
Key: logical,
|
|
ModTime: time.Unix(entry.Attributes.Mtime, int64(entry.Attributes.MtimeNs)),
|
|
Size: int64(entry.Attributes.FileSize),
|
|
IsLatest: true,
|
|
}
|
|
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
|
|
}
|
|
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: logical,
|
|
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"
|
|
}
|