Files
seaweedfs/weed/s3api/s3lifecycle/router/router.go
T
Chris LuandDevin bf3081e23a s3 lifecycle: route version-file writes to current-version expiration (#11956)
* 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>
2026-10-23 03:19:41 -07:00

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"
}