Files
seaweedfs/weed/s3api/s3lifecycle/router/router_test.go
T
Chris Lu edfa1ce210 feat(s3/lifecycle): pointer-transition routing for live PUTs (Phase 5b/4) (#9385)
* feat(s3/lifecycle): pointer-transition routing for live PUTs (Phase 5b/4)

Bootstrap covers existing versions, but a live PUT that creates a new
.versions/<v-new> file and updates the parent's ExtLatestVersionIdKey
didn't fire NoncurrentDays / NewerNoncurrent on the displaced prior
version until the next bootstrap. Close that runtime gap.

The meta-log already emits an Update event for the .versions/
directory itself when the latest pointer changes; the router was
dropping it because buildObjectInfo returns nil for directories. New
branch in Route detects that shape (versioned bucket, NewEntry +
OldEntry both directories with the .versions/ suffix, ExtLatestVersionIdKey
changed, ID different from the new ID) and emits a Match against the
LOGICAL key with VersionID=oldID. Match.Identity comes from a single
LookupVersion RPC for the displaced version file; SuccessorModTime is
the directory update's mtime, which is the moment the displaced
version became noncurrent.

SiblingLister grows LookupVersion(bucket, key, versionID) for that
single-RPC fetch. filerSiblingLister implements it; routing path
treats NotFound as "displaced version was hard-deleted in the
meantime, suppress" rather than an error.

The router gates the lookup on at least one active event-driven
NoncurrentDays / NewerNoncurrent rule for the bucket, so most buckets
pay nothing per directory update.

Tests: pointer-flip fires NoncurrentDays with displaced version_id;
unchanged pointer skips; empty old pointer skips (first-PUT scenario);
displaced-version NotFound suppresses; no-rule skips lookup;
NewerNoncurrentVersions retains rank-0; unversioned bucket skips.

* fix(s3/lifecycle): SuccessorModTime cache + NewerNoncurrent expansion

Two correctness gaps in pointer-transition routing.

The .versions/ directory's own Attributes.Mtime is preserved across
pointer updates by updateLatestVersionInDirectory: it's a stale clock
relative to the freshly-written latest version. Using it as the
displaced version's SuccessorModTime made NoncurrentDays compute
due = staleMtime + days, which fires immediately on a fresh PUT
into an old .versions/ container. Read ExtLatestVersionMtimeKey
written by setCachedListMetadata; suppress (return no matches) when
the cache is missing rather than fall back to dir mtime.

Single-oldID lookup is only enough for pure
NoncurrentVersionExpirationDays. Any rule with NewerNoncurrentVersions
> 0 cares about the noncurrent ranks, and a pointer flip shifts every
prior noncurrent's index by one — the version that just crossed the
keep-count threshold needs to be evaluated too. When any matching
rule needs ranks, list the full .versions/ container, sort newest-
first with mtime + version-id tiebreak, and route every noncurrent
with its real index. Identity-CAS dedups against earlier schedules.

SiblingLister grows ListVersions(bucket, key); filerSiblingLister's
implementation paginates the container fully.

Two regression tests: stale dir mtime + correct cached mtime
schedules ~30 days out (not immediate); NewerNoncurrentVersions=2
with 4 versions fires on the rank-2 entry that just crossed the
threshold while rank-0/1 are retained.

* fix(s3/lifecycle): bound pointer-transition expansion to threshold crossings

routePointerTransitionExpand emitted a Match for every eligible
noncurrent on every PUT. Schedule.Add doesn't dedup, so identity-CAS
at dispatch only saved the wasted RPC, not the heap slot. A hot key
with many already-eligible versions and a count rule would push
O(versions) entries per flip, repeatedly, until dispatch caught up.

Bound the emission to versions that newly entered eligibility on
this specific flip: rank 0 (the displaced version, for the
NoncurrentDays clock) plus rank == rule.NewerNoncurrentVersions
for each active count-gated rule (the version that just crossed
from kept to expired). Bootstrap still owns full backfill for
versions that were already over-threshold.

Adds a regression with 6 versions and NewerNoncurrentVersions=2:
asserts only the rank-2 entry that just crossed fires, not the
already-over-threshold rank-3/rank-4 entries.

* fix(s3/lifecycle): suppress pointer-transition expansion when newID missing

routePointerTransitionExpand defaulted latestPos to 0 if newID wasn't
found in the listing. That made the actual newest sibling latest
against the pointer's intent, then misranked every other version. A
race between the pointer write and the version write could land us
there.

Default latestPos to -1, set it only on a real match, and suppress
the expansion when the search misses. Bootstrap repairs state on
the next walk.

The NewerNoncurrentVersions retention test was setting only
lookupEntry, so Route never reached the expansion path it claimed to
exercise. Repoint to listVersions and assert ListVersions was
consulted while LookupVersion was not. Adds a regression covering
the missing-newID suppression directly.

* fix(s3/lifecycle): include bare null version in pointer-transition routing

Bootstrap models the bare-key object as a "null" sibling alongside
.versions/ children, but the live pointer-transition path didn't.
Two cases lost:

1. oldID == "" was treated as "nothing displaced". A pre-versioning
   bare object becomes noncurrent when the first versioned PUT lands
   and the pointer flips to a real id, but live routing skipped it
   and waited for the next bootstrap.

2. The expansion path's ListVersions returned only .versions/
   children. With a bare null in the picture, the noncurrent ranks
   were wrong, so NewerNoncurrentVersions could keep the wrong
   versions and delete the right ones (or vice versa).

SiblingLister grows LookupNullVersion(bucket, key) returning the
bare entry plus an explicit-null flag (matches the bootstrap shape).
filerSiblingLister implements it via util.NewFullPath +
filer_pb.LookupEntry.

routePointerTransitionDisplaced: oldID == "" now consults
LookupNullVersion. When the bare entry exists, route it as
VersionID="null" against the LOGICAL key.

routePointerTransitionExpand: collect .versions/ children plus the
null entry into one sibling slice before sorting and ranking. The
threshold-crossing logic now sees the same N-version set that
bootstrap would compute.

Three new tests: oldID == "" with no null is a no-op (one null
lookup, no version lookup); oldID == "" with a bare null schedules
NoncurrentDays as VersionID="null"; expansion with a bare null
between .versions/ siblings places null at its mtime-correct rank
and only that rank-N entry fires.

* fix(s3/lifecycle): atomic listPageSize so test cleanup doesn't race

KickOffNew dispatches walks via `go b.walkBucket(...)`. A test that
finishes before its goroutines drain leaves them running into the
next test's t.Cleanup, which mutates listPageSize. -race spots the
read/write collision intermittently. Convert listPageSize to
atomic.Uint32; tests use Load/Store. No production semantics change.

* fix(s3/lifecycle): null becomes latest when suspended PUT clears pointer

The router treated newID == "" as if the cached
ExtLatestVersionMtimeKey were still authoritative — but that cache
holds the displaced version's mtime, written by setCachedListMetadata
when the prior version became latest. Using it as SuccessorModTime
made NoncurrentDays=30 immediately fire on a 100-day-old displaced
version even though it became noncurrent today.

When newID == "" the bare null is the new latest. Look it up,
substitute its mtime as the successor clock, and substitute "null"
as the latestPos target for the expansion path's id match. Both
displaced and expand paths now derive the right clock.

updateIsLatestFlagsForSuspendedVersioning was the upstream cause of
the staleness — it cleared ExtLatestVersionIdKey and FileNameKey but
left the cached size/mtime/etag/owner/delete-marker behind. Call
clearCachedVersionMetadata so the .versions/ container is consistent
with "null is latest". The router-side guard is still needed for
older deployments that ran the buggy code, but new writes won't
exercise the workaround.

Two regressions: 100-day-old displaced under NoncurrentDays=30 with
a today-null PUT schedules ~30d out (not immediate); same shape with
NewerNoncurrentVersions=2 ranks the null at latest and only the
rank-2 entry fires.
2026-05-09 12:21:35 -07:00

1654 lines
61 KiB
Go

package router
import (
"context"
"errors"
"fmt"
"testing"
"time"
"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"
)
func compileWith(rule *s3lifecycle.Rule, prior map[s3lifecycle.ActionKey]engine.PriorState) *engine.Snapshot {
e := engine.New()
return e.Compile([]engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}},
engine.CompileOptions{PriorStates: prior})
}
func activatedPrior(rule *s3lifecycle.Rule) map[s3lifecycle.ActionKey]engine.PriorState {
prior := map[s3lifecycle.ActionKey]engine.PriorState{}
hash := s3lifecycle.RuleHash(rule)
for _, k := range s3lifecycle.RuleActionKinds(rule) {
prior[s3lifecycle.ActionKey{Bucket: "bk", RuleHash: hash, ActionKind: k}] = engine.PriorState{
BootstrapComplete: true,
Mode: engine.ModeEventDriven,
}
}
return prior
}
func eventCreate(bucket, key string, modTimeS, size int64, ts int64) *reader.Event {
return &reader.Event{
TsNs: ts,
Bucket: bucket,
Key: key,
NewEntry: &filer_pb.Entry{
Name: key,
Attributes: &filer_pb.FuseAttributes{
Mtime: modTimeS,
FileSize: uint64(size),
},
},
}
}
func TestRouteNoSnapshotNoMatches(t *testing.T) {
if got := Route(context.Background(), nil, eventCreate("bk", "k", 0, 1, 1), time.Now(), nil); got != nil {
t.Fatalf("nil snap should yield nil, got %v", got)
}
}
func TestRouteMissingBucketNoMatches(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
ev := eventCreate("other-bucket", "k", 0, 1, 1)
if got := Route(context.Background(), snap, ev, time.Now(), nil); got != nil {
t.Fatalf("foreign bucket should yield nil, got %v", got)
}
}
func TestRouteInactiveSkipped(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
// PriorStates omitted => BootstrapComplete=false => action stays inactive.
snap := compileWith(rule, nil)
now := time.Now()
old := now.Add(-48 * time.Hour) // past 1-day expiration
ev := eventCreate("bk", "k", old.Unix(), 1, old.UnixNano())
if got := Route(context.Background(), snap, ev, now, nil); got != nil {
t.Fatalf("inactive action should not match, got %v", got)
}
}
func TestRouteExpirationDaysFires(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.Add(-48 * time.Hour) // past 1-day expiration
ev := eventCreate("bk", "k", old.Unix(), 1, old.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %+v", matches)
}
m := matches[0]
if m.Bucket != "bk" || m.ObjectKey != "k" {
t.Fatalf("unexpected match shape: %+v", m)
}
if m.Result.Action != s3lifecycle.ActionDeleteObject {
t.Fatalf("action=%v, want DeleteObject", m.Result.Action)
}
if m.DueTime.Before(m.EventTs) {
t.Fatalf("DueTime < EventTs: %v < %v", m.DueTime, m.EventTs)
}
}
func TestRouteFreshObjectSchedulesInFuture(t *testing.T) {
// A fresh object DOES route — the action is scheduled for ModTime+N days.
// EvaluateAction is called at the scheduled dispatch time so the age
// gate passes; the dispatcher waits until DueTime to actually run.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 7}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
ev := eventCreate("bk", "k", now.Unix(), 1, now.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match (scheduled), got %v", matches)
}
if !matches[0].DueTime.After(now.Add(s3lifecycle.DaysToDuration(6))) {
t.Fatalf("DueTime=%v, want ~7 days from now", matches[0].DueTime)
}
}
func TestRouteRespectsPrefixFilter(t *testing.T) {
rule := &s3lifecycle.Rule{
ID: "r", Status: s3lifecycle.StatusEnabled,
Prefix: "logs/", ExpirationDays: 1,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.Add(-48 * time.Hour)
// Out of prefix: no match.
ev := eventCreate("bk", "data/file", old.Unix(), 1, old.UnixNano())
if got := Route(context.Background(), snap, ev, now, nil); got != nil {
t.Fatalf("out-of-prefix should not match, got %v", got)
}
// In prefix: matches.
ev = eventCreate("bk", "logs/file", old.Unix(), 1, old.UnixNano())
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 1 {
t.Fatalf("in-prefix should match, got %v", got)
}
}
func TestRouteSkipsHardDelete(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.Add(-48 * time.Hour)
// Hard delete: NewEntry is nil; OldEntry holds the gone object.
ev := &reader.Event{
TsNs: old.UnixNano(),
Bucket: "bk",
Key: "gone.txt",
OldEntry: &filer_pb.Entry{
Name: "gone.txt",
Attributes: &filer_pb.FuseAttributes{Mtime: old.Unix(), FileSize: 1},
},
}
if got := Route(context.Background(), snap, ev, now, nil); got != nil {
t.Fatalf("hard delete should not route, got %v", got)
}
}
func TestRouteSkipsMissingAttributes(t *testing.T) {
// Without Attributes there's no ModTime, and EvaluateAction would
// compute due against year-0001 and fire immediately. Skip the event.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
ev := &reader.Event{
TsNs: time.Now().UnixNano(),
Bucket: "bk",
Key: "k",
NewEntry: &filer_pb.Entry{Name: "k"}, // no Attributes
}
if got := Route(context.Background(), snap, ev, time.Now(), nil); got != nil {
t.Fatalf("missing-Attributes event should not route, got %v", got)
}
}
func TestRouteIdentityCapturedForNewEntry(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.Add(-48 * time.Hour)
ev := eventCreate("bk", "k", old.Unix(), 42, old.UnixNano())
ev.NewEntry.Chunks = []*filer_pb.FileChunk{{FileId: "1,abc"}}
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %v", matches)
}
id := matches[0].Identity
if id == nil || id.Size != 42 || id.HeadFid != "1,abc" {
t.Fatalf("identity capture: %+v", id)
}
wantNs := old.Unix()*int64(1e9) + 0
if id.MtimeNs != wantNs {
t.Fatalf("MtimeNs=%d, want %d (Mtime*1e9)", id.MtimeNs, wantNs)
}
}
func TestRouteIdentityHashesExtended(t *testing.T) {
// A normal S3 PUT stores ETag/content-type etc. in Extended; the worker
// must hash these into ExtendedHash so the server's identity-CAS sees
// the same fingerprint. Without this the live entry's non-nil
// ExtendedHash diverges from the worker's nil value and every
// dispatch returns NOOP_RESOLVED:STALE_IDENTITY.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.Add(-48 * time.Hour)
ev := eventCreate("bk", "k", old.Unix(), 1, old.UnixNano())
ev.NewEntry.Extended = map[string][]byte{
"X-Amz-Meta-Etag": []byte("abc123"),
"Content-Type": []byte("text/plain"),
}
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %v", matches)
}
id := matches[0].Identity
if id == nil || len(id.ExtendedHash) == 0 {
t.Fatalf("ExtendedHash not captured for non-empty Extended: %+v", id)
}
want := s3lifecycle.HashExtended(ev.NewEntry.Extended)
if string(id.ExtendedHash) != string(want) {
t.Fatalf("ExtendedHash mismatch:\n got %x\nwant %x", id.ExtendedHash, want)
}
}
func mpuInitEvent(bucket, uploadID, destKey string, initS, ts int64) *reader.Event {
return &reader.Event{
TsNs: ts,
Bucket: bucket,
Key: ".uploads/" + uploadID,
NewEntry: &filer_pb.Entry{
Name: uploadID,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: initS},
Extended: map[string][]byte{
s3_constants.ExtMultipartObjectKey: []byte(destKey),
},
},
}
}
func TestRouteMPUInitFiresAbortAfterDelay(t *testing.T) {
// 7-day MPU abort. Init 8 days ago must fire; rule's prefix matches the
// destination key, not the .uploads/ path.
rule := &s3lifecycle.Rule{
ID: "r-mpu",
Status: s3lifecycle.StatusEnabled,
Prefix: "logs/",
AbortMPUDaysAfterInitiation: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
init := now.AddDate(0, 0, -8)
ev := mpuInitEvent("bk", "u1", "logs/foo.txt", init.Unix(), init.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %v", matches)
}
if got, want := matches[0].Key.ActionKind, s3lifecycle.ActionKindAbortMPU; got != want {
t.Fatalf("ActionKind=%v, want %v", got, want)
}
if got, want := matches[0].ObjectKey, ".uploads/u1"; got != want {
t.Fatalf("ObjectKey=%q, want %q (server needs the upload directory path)", got, want)
}
}
func TestRouteMPUInitFilteredOutByPrefix(t *testing.T) {
// Same rule, MPU uploads to a different prefix → no match.
rule := &s3lifecycle.Rule{
ID: "r-mpu",
Status: s3lifecycle.StatusEnabled,
Prefix: "logs/",
AbortMPUDaysAfterInitiation: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
init := now.AddDate(0, 0, -8)
ev := mpuInitEvent("bk", "u2", "data/foo.txt", init.Unix(), init.UnixNano())
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("expected 0 matches for foreign prefix, got %v", got)
}
}
func TestRouteMPUInitMissingDestKeySkipped(t *testing.T) {
// .uploads/<id> directory without ExtMultipartObjectKey is malformed
// (mkdir wrote it before the metadata, or it's a stray dir). Skip.
rule := &s3lifecycle.Rule{
ID: "r-mpu",
Status: s3lifecycle.StatusEnabled,
AbortMPUDaysAfterInitiation: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
init := now.AddDate(0, 0, -8)
ev := &reader.Event{
TsNs: init.UnixNano(),
Bucket: "bk",
Key: ".uploads/u3",
NewEntry: &filer_pb.Entry{
Name: "u3",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: init.Unix()},
},
}
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("expected 0 matches for missing destKey, got %v", got)
}
}
func TestRouteMPUPartEventSkipped(t *testing.T) {
// Part-upload events at .uploads/<id>/<part> ride a different mtime and
// must not over-fire ABORT_MPU.
rule := &s3lifecycle.Rule{
ID: "r-mpu",
Status: s3lifecycle.StatusEnabled,
AbortMPUDaysAfterInitiation: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
init := now.AddDate(0, 0, -8)
ev := &reader.Event{
TsNs: init.UnixNano(),
Bucket: "bk",
Key: ".uploads/u4/0001",
NewEntry: &filer_pb.Entry{
Name: "0001",
Attributes: &filer_pb.FuseAttributes{Mtime: init.Unix()},
Extended: map[string][]byte{s3_constants.ExtMultipartObjectKey: []byte("logs/foo.txt")},
},
}
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("expected 0 matches for part event, got %v", got)
}
}
func TestRouteMPUInitDoesNotFireNoncurrent(t *testing.T) {
// One rule with both ABORT_MPU and NONCURRENT_DAYS; an MPU init must
// only emit the ABORT_MPU match. Otherwise the dispatcher receives a
// NONCURRENT_DAYS action with version_id="" and freezes the cursor.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
AbortMPUDaysAfterInitiation: 7,
NoncurrentVersionExpirationDays: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
init := now.AddDate(0, 0, -30)
ev := mpuInitEvent("bk", "u1", "logs/foo.txt", init.Unix(), init.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected exactly 1 match (ABORT_MPU only), got %v", matches)
}
if got := matches[0].Key.ActionKind; got != s3lifecycle.ActionKindAbortMPU {
t.Fatalf("ActionKind=%v, want AbortMPU", got)
}
}
func TestRouteRegularObjectUnderDualRuleSkipsAbortMPU(t *testing.T) {
// Converse of TestRouteMPUInitDoesNotFireNoncurrent: a regular
// current-version object under a rule that has both ExpirationDays
// and AbortIncompleteMultipartUpload must fire EXPIRATION_DAYS only.
// Without the gate the dispatcher would also receive an ABORT_MPU
// action targeting the object path, which would rm a regular object
// via the MPU code path.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpirationDays: 1,
AbortMPUDaysAfterInitiation: 7,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -2) // past the 1d expiration
ev := eventCreate("bk", "obj", old.Unix(), 1, old.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected exactly 1 match (EXPIRATION_DAYS only), got %v", matches)
}
if got := matches[0].Key.ActionKind; got != s3lifecycle.ActionKindExpirationDays {
t.Fatalf("ActionKind=%v, want ExpirationDays", got)
}
}
func compileWithVersioned(rule *s3lifecycle.Rule, prior map[s3lifecycle.ActionKey]engine.PriorState) *engine.Snapshot {
e := engine.New()
return e.Compile([]engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}, Versioned: true}},
engine.CompileOptions{PriorStates: prior})
}
func TestRouteVersionedNoncurrentEventDoesNotFireFromRouter(t *testing.T) {
// Versioned bucket: the storage layout <key>.versions/v_<id> is
// shared between the current latest and noncurrent versions, and
// the latest pointer lives in the parent directory's metadata —
// not on the version file itself. The router cannot distinguish
// without consulting the .versions/ directory, so it must not
// emit NONCURRENT_* matches; bootstrap (with sibling listing) is
// responsible for those.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 7,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -30)
ev := eventCreate("bk", "logs/foo.versions/v_v1", old.Unix(), 1, old.UnixNano())
ev.NewEntry.Extended = map[string][]byte{
s3_constants.ExtVersionIdKey: []byte("v1"),
}
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("router must not emit noncurrent matches yet, got %v", got)
}
}
func TestRouteVersionedCurrentEventStaysLatest(t *testing.T) {
// Versioned bucket, ExpirationDays rule. The current-version event
// arrives at the bare <key>; nothing about its path looks like a
// .versions/ entry, so IsLatest stays true and the rule fires.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -2)
ev := eventCreate("bk", "logs/foo", old.Unix(), 1, old.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match (EXPIRATION_DAYS), got %v", matches)
}
if matches[0].Key.ActionKind != s3lifecycle.ActionKindExpirationDays {
t.Fatalf("ActionKind=%v, want ExpirationDays", matches[0].Key.ActionKind)
}
if matches[0].VersionID != "" {
t.Fatalf("VersionID=%q, want empty for current-version path", matches[0].VersionID)
}
}
func TestRouteNonVersionedBucketIgnoresVersionsSuffix(t *testing.T) {
// A non-versioned bucket happens to have an object literally named
// "logs/foo.versions/v1" — that's just a regular path. The router
// must NOT classify it as noncurrent or set IsLatest=false; the
// rule fires as a normal current-object expiration.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -2)
ev := eventCreate("bk", "logs/foo.versions/v1", old.Unix(), 1, old.UnixNano())
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %v", matches)
}
if matches[0].VersionID != "" {
t.Fatalf("VersionID=%q, want empty for non-versioned bucket", matches[0].VersionID)
}
if matches[0].ObjectKey != "logs/foo.versions/v1" {
t.Fatalf("ObjectKey=%q, want unchanged for non-versioned bucket", matches[0].ObjectKey)
}
}
// markerEventBytes returns the production shape: a file event under
// <key>.versions/v_<version-id>, with ExtDeleteMarkerKey="true" and
// ExtVersionIdKey populated. Mirrors createDeleteMarker in
// s3api_object_versioning.go.
func markerEvent(bucket, logicalKey, versionID string, mtimeUnix, mtimeNs int64) *reader.Event {
versionPath := logicalKey + s3_constants.VersionsFolder + "/v_" + versionID
ev := eventCreate(bucket, versionPath, mtimeUnix, 0, mtimeNs)
ev.NewEntry.Extended = map[string][]byte{
s3_constants.ExtDeleteMarkerKey: []byte("true"),
s3_constants.ExtVersionIdKey: []byte(versionID),
}
return ev
}
// recordingLister captures Survivors calls. Configure with the exact
// state to return; calls list is appended on each invocation so tests
// can assert whether the lister was consulted at all.
type recordingLister struct {
calls []string
survivors Survivors
err error
lookupCalls []string
lookupEntry *filer_pb.Entry
lookupErr error
listCalls []string
listVersions []*filer_pb.Entry
listErr error
nullCalls []string
nullEntry *filer_pb.Entry
nullExplicit bool
nullErr error
}
func (r *recordingLister) ListVersions(_ context.Context, bucket, key string) ([]*filer_pb.Entry, error) {
r.listCalls = append(r.listCalls, bucket+"/"+key)
return r.listVersions, r.listErr
}
func (r *recordingLister) LookupNullVersion(_ context.Context, bucket, key string) (*filer_pb.Entry, bool, error) {
r.nullCalls = append(r.nullCalls, bucket+"/"+key)
return r.nullEntry, r.nullExplicit, r.nullErr
}
func (r *recordingLister) LookupVersion(_ context.Context, bucket, key, versionID string) (*filer_pb.Entry, error) {
r.lookupCalls = append(r.lookupCalls, bucket+"/"+key+"@"+versionID)
return r.lookupEntry, r.lookupErr
}
func (r *recordingLister) Survivors(_ context.Context, bucket, key string) (Survivors, error) {
r.calls = append(r.calls, bucket+"/"+key)
return r.survivors, r.err
}
func markerLoneEntry(versionID string, mtimeUnix, mtimeNs int64) *filer_pb.Entry {
return &filer_pb.Entry{
Name: "v_" + versionID,
Attributes: &filer_pb.FuseAttributes{
Mtime: mtimeUnix,
MtimeNs: int32(mtimeNs - mtimeUnix*int64(1e9)),
},
Extended: map[string][]byte{
s3_constants.ExtDeleteMarkerKey: []byte("true"),
s3_constants.ExtVersionIdKey: []byte(versionID),
},
}
}
func TestRouteVersionedExpiredDeleteMarkerNilListerSuppresses(t *testing.T) {
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("nil lister must suppress, got %v", got)
}
}
func TestRouteVersionedExpiredDeleteMarkerSoleSurvivorFires(t *testing.T) {
// Exactly one entry under .versions/<key>/ — the marker — and no
// bare null version: EXP_DM fires with the LOGICAL key + version_id.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{survivors: Survivors{
Count: 1,
LoneEntry: markerLoneEntry("2026-05-09-abc", old.Unix(), old.UnixNano()),
}}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("expected 1 match (ExpiredDeleteMarker), got %v", matches)
}
m := matches[0]
if m.Result.Action != s3lifecycle.ActionExpireDeleteMarker {
t.Fatalf("Action=%v, want ExpireDeleteMarker", m.Result.Action)
}
if m.ObjectKey != "logs/gone" {
t.Fatalf("ObjectKey=%q, want logical key logs/gone", m.ObjectKey)
}
if m.VersionID != "2026-05-09-abc" {
t.Fatalf("VersionID=%q, want 2026-05-09-abc", m.VersionID)
}
if len(lister.calls) != 1 || lister.calls[0] != "bk/logs/gone" {
t.Fatalf("lister calls=%v, want [bk/logs/gone]", lister.calls)
}
}
func TestRouteVersionedExpiredDeleteMarkerSiblingsRemainSuppressed(t *testing.T) {
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{survivors: Survivors{Count: 2}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("siblings present, must not fire, got %v", got)
}
}
func TestRouteVersionedExpiredDeleteMarkerNullVersionSuppresses(t *testing.T) {
// A pre-versioning bare-key object (HasNullVersion=true) still survives
// outside .versions/. Firing EXP_DM would re-expose it; suppress.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{survivors: Survivors{
Count: 1,
LoneEntry: markerLoneEntry("2026-05-09-abc", old.Unix(), old.UnixNano()),
HasNullVersion: true,
}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("null-version present, must not fire, got %v", got)
}
}
func TestRouteVersionedExpiredDeleteMarkerHardDeleteLeavesLoneMarkerFires(t *testing.T) {
// Sequence: object had v1 + DM, hard-delete of v1 leaves DM as the
// sole survivor. The hard-delete event has NewEntry=nil; the router
// must still consult the lister, see the lone DM, and emit a match
// using the LoneEntry's version_id and identity.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
versionPath := "logs/gone" + s3_constants.VersionsFolder + "/v_2026-05-08-old"
ev := &reader.Event{
TsNs: now.UnixNano(),
Bucket: "bk",
Key: versionPath,
OldEntry: &filer_pb.Entry{Name: "v_2026-05-08-old"},
}
lister := &recordingLister{survivors: Survivors{
Count: 1,
LoneEntry: markerLoneEntry("2026-05-09-abc", old.Unix(), old.UnixNano()),
}}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("expected 1 match after hard-delete, got %v", matches)
}
if matches[0].VersionID != "2026-05-09-abc" {
t.Fatalf("VersionID=%q, want lone-entry's version 2026-05-09-abc", matches[0].VersionID)
}
if matches[0].ObjectKey != "logs/gone" {
t.Fatalf("ObjectKey=%q, want logs/gone", matches[0].ObjectKey)
}
}
func TestRouteVersionedExpiredDeleteMarkerHardDeleteLoneNonMarkerNoFire(t *testing.T) {
// After a hard-delete the surviving entry is a regular version, not
// a marker. Nothing to expire.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
versionPath := "logs/gone" + s3_constants.VersionsFolder + "/v_2026-05-08-old"
ev := &reader.Event{
TsNs: now.UnixNano(),
Bucket: "bk",
Key: versionPath,
OldEntry: &filer_pb.Entry{Name: "v_2026-05-08-old"},
}
regular := &filer_pb.Entry{
Name: "v_v1",
Attributes: &filer_pb.FuseAttributes{Mtime: old.Unix()},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("v1")},
}
lister := &recordingLister{survivors: Survivors{Count: 1, LoneEntry: regular}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("non-marker survivor must not fire, got %v", got)
}
}
func TestRouteVersionedExpiredDeleteMarkerListerErrorSuppressed(t *testing.T) {
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{err: errors.New("filer down")}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("lister error must suppress, got %v", got)
}
}
func TestRouteVersionedExpiredDeleteMarkerInactiveActionSkipsListing(t *testing.T) {
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
// PriorStates omitted => BootstrapComplete=false => action stays inactive.
snap := compileWithVersioned(rule, nil)
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{survivors: Survivors{Count: 1}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("inactive action must not produce a match, got %v", got)
}
if len(lister.calls) != 0 {
t.Fatalf("inactive action must not consult lister, calls=%v", lister.calls)
}
}
func TestRouteVersionedRegularVersionCreateSkipsListing(t *testing.T) {
// A non-marker version create under .versions/<key>/ can never be the
// sole survivor (Count >= 2 by definition), so the lister must NOT
// be consulted on every regular versioned PUT.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
ExpiredObjectDeleteMarker: true,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
versionPath := "logs/keep" + s3_constants.VersionsFolder + "/v_v1"
ev := eventCreate("bk", versionPath, old.Unix(), 100, old.UnixNano())
ev.NewEntry.Extended = map[string][]byte{
s3_constants.ExtVersionIdKey: []byte("v1"),
}
lister := &recordingLister{survivors: Survivors{Count: 1}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("regular version create must not fire EXP_DM, got %v", got)
}
if len(lister.calls) != 0 {
t.Fatalf("lister consulted for regular version create: calls=%v", lister.calls)
}
}
func TestRouteVersionedDeleteMarkerNoExpDMRuleSkipsListing(t *testing.T) {
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -1)
ev := markerEvent("bk", "logs/gone", "2026-05-09-abc", old.Unix(), old.UnixNano())
lister := &recordingLister{survivors: Survivors{Count: 1}}
Route(context.Background(), snap, ev, now, lister)
if len(lister.calls) != 0 {
t.Fatalf("lister consulted without EXP_DM rule: calls=%v", lister.calls)
}
}
func TestRouteVersionedAllVersionFolderPathsSkipped(t *testing.T) {
// On a versioned bucket the router skips every event whose parent
// directory name ends with ".versions" — both the version files
// SeaweedFS itself writes (logs/foo.versions/v_v1) and any literal
// key the user happens to put under such a parent — because the
// current-vs-noncurrent classification needs the .versions/
// directory's latest pointer, which isn't carried by these events.
// Bootstrap covers retention for those entries.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -2)
cases := []struct {
name string
key string
isDir bool
ext map[string][]byte
}{
{
name: "tracked version file",
key: "logs/foo.versions/v_v1",
ext: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("v1")},
},
{
name: "literal-key collision",
key: "logs/backup.versions/2023",
},
{
name: "bucket-root .versions",
key: ".versions/v_v1",
ext: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("v1")},
},
{
// The .versions/ folder itself is a directory entry; the
// router must not emit ObjectInfo for it. Without the
// directory short-circuit it would route as a regular
// object and the dispatcher would target a directory path.
name: "versions dir itself",
key: "logs/foo.versions",
isDir: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ev := eventCreate("bk", tc.key, old.Unix(), 1, old.UnixNano())
if tc.ext != nil {
ev.NewEntry.Extended = tc.ext
}
if tc.isDir {
ev.NewEntry.IsDirectory = true
}
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("version-folder event should be skipped, got %v", got)
}
})
}
}
func bootstrapVersionEntry(versionID string, mtime time.Time, isDeleteMarker bool) *filer_pb.Entry {
ext := map[string][]byte{
s3_constants.ExtVersionIdKey: []byte(versionID),
}
if isDeleteMarker {
ext[s3_constants.ExtDeleteMarkerKey] = []byte("true")
}
mtimeUnix := mtime.Unix()
return &filer_pb.Entry{
Name: "v_" + versionID,
Attributes: &filer_pb.FuseAttributes{
Mtime: mtimeUnix,
MtimeNs: int32(mtime.UnixNano() - mtimeUnix*int64(1e9)),
},
Extended: ext,
}
}
func TestRouteBootstrapVersionLatestExpirationDaysFires(t *testing.T) {
// Bootstrap-emitted event for the LATEST version of a versioned
// object. ExpirationDays should fire (creates a delete marker at
// dispatch). ObjectKey is the LOGICAL key. VersionID must be EMPTY
// for EXPIRATION_DAYS so the dispatcher fetches the current latest:
// if a fresh PUT landed between schedule and dispatch, identity-CAS
// against the original version's bytes would pass even though the
// latest has moved on.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -2)
entry := bootstrapVersionEntry("v-current", old, false)
ev := &reader.Event{
Bucket: "bk",
Key: "logs/foo" + s3_constants.VersionsFolder + "/v_v-current",
NewEntry: entry,
BootstrapVersion: &reader.BootstrapVersion{
LogicalKey: "logs/foo",
VersionID: "v-current",
IsLatest: true,
NumVersions: 1,
},
}
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("want 1 match (ExpirationDays on latest), got %v", matches)
}
if matches[0].ObjectKey != "logs/foo" {
t.Fatalf("ObjectKey=%q, want logs/foo", matches[0].ObjectKey)
}
if matches[0].VersionID != "" {
t.Fatalf("VersionID=%q, want empty for EXPIRATION_DAYS", matches[0].VersionID)
}
}
func TestRouteBootstrapVersionNoncurrentDaysFires(t *testing.T) {
// Bootstrap-emitted event for a NONCURRENT version. NoncurrentDays
// uses SuccessorModTime as the clock — when this version was
// replaced by the next-newer sibling.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
successor := now.AddDate(0, 0, -3) // replaced 3 days ago
old := now.AddDate(0, 0, -10) // mtime older still
entry := bootstrapVersionEntry("v-old", old, false)
ev := &reader.Event{
Bucket: "bk",
Key: "logs/foo" + s3_constants.VersionsFolder + "/v_v-old",
NewEntry: entry,
BootstrapVersion: &reader.BootstrapVersion{
LogicalKey: "logs/foo",
VersionID: "v-old",
IsLatest: false,
NumVersions: 2,
NoncurrentIndex: 0,
SuccessorModTime: successor,
},
}
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("want 1 match (NoncurrentDays), got %v", matches)
}
if matches[0].VersionID != "v-old" {
t.Fatalf("VersionID=%q, want v-old", matches[0].VersionID)
}
}
func TestRouteBootstrapVersionNoncurrentRespectsNewerNoncurrentVersions(t *testing.T) {
// NewerNoncurrentVersions=2 keeps the two newest noncurrents safe.
// A version at NoncurrentIndex=0 (newest noncurrent) must NOT fire;
// index=2 (third-newest) MUST fire.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
successor := now.AddDate(0, 0, -3)
old := now.AddDate(0, 0, -10)
mk := func(idx int) *reader.Event {
return &reader.Event{
Bucket: "bk",
Key: "logs/foo" + s3_constants.VersionsFolder + "/v_old" + string(rune('0'+idx)),
NewEntry: bootstrapVersionEntry("old"+string(rune('0'+idx)), old, false),
BootstrapVersion: &reader.BootstrapVersion{
LogicalKey: "logs/foo",
VersionID: "old" + string(rune('0'+idx)),
IsLatest: false,
NumVersions: 4,
NoncurrentIndex: idx,
SuccessorModTime: successor,
},
}
}
if got := Route(context.Background(), snap, mk(0), now, nil); len(got) != 0 {
t.Fatalf("noncurrent rank 0 must be retained, got %v", got)
}
if got := Route(context.Background(), snap, mk(1), now, nil); len(got) != 0 {
t.Fatalf("noncurrent rank 1 must be retained, got %v", got)
}
if got := Route(context.Background(), snap, mk(2), now, nil); len(got) != 1 {
t.Fatalf("noncurrent rank 2 must fire, got %v", got)
}
}
func TestRouteBootstrapVersionAbortMPUNeverEmittedForVersion(t *testing.T) {
// AbortIncompleteMultipartUpload only applies to MPU init dirs, not
// versioned object versions. Even if the bucket has the rule, a
// bootstrap version event must not produce an ABORT_MPU match.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
AbortMPUDaysAfterInitiation: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
old := now.AddDate(0, 0, -10)
ev := &reader.Event{
Bucket: "bk",
Key: "logs/foo" + s3_constants.VersionsFolder + "/v_x",
NewEntry: bootstrapVersionEntry("x", old, false),
BootstrapVersion: &reader.BootstrapVersion{
LogicalKey: "logs/foo",
VersionID: "x",
IsLatest: true,
NumVersions: 1,
},
}
if got := Route(context.Background(), snap, ev, now, nil); len(got) != 0 {
t.Fatalf("bootstrap version event must not produce ABORT_MPU match, got %v", got)
}
}
// versionsContainerEvent builds a .versions/<key>/ directory update.
// The NEW entry carries the cached latest-version mtime (the value
// setCachedListMetadata writes alongside the latest pointer). The
// directory's own Mtime is preserved at containerStaleMtime so the
// router can't accidentally use it as the successor clock.
func versionsContainerEvent(bucket, logical, oldID, newID string, latestMtimeUnix int64) *reader.Event {
const containerStaleMtime int64 = 1
mk := func(id string, includeMtime bool) *filer_pb.Entry {
ext := map[string][]byte{}
if id != "" {
ext[s3_constants.ExtLatestVersionIdKey] = []byte(id)
}
if includeMtime {
ext[s3_constants.ExtLatestVersionMtimeKey] = []byte(fmt.Sprintf("%d", latestMtimeUnix))
}
return &filer_pb.Entry{
Name: logical + s3_constants.VersionsFolder,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: containerStaleMtime},
Extended: ext,
}
}
return &reader.Event{
Bucket: bucket,
Key: logical + s3_constants.VersionsFolder,
OldEntry: mk(oldID, false),
NewEntry: mk(newID, true),
}
}
func displacedVersionEntry(versionID string, mtimeUnix int64) *filer_pb.Entry {
return &filer_pb.Entry{
Name: "v_" + versionID,
Attributes: &filer_pb.FuseAttributes{
Mtime: mtimeUnix,
FileSize: 100,
},
Extended: map[string][]byte{
s3_constants.ExtVersionIdKey: []byte(versionID),
},
}
}
func TestRoutePointerTransitionFiresNoncurrentDays(t *testing.T) {
// .versions/<key>/ directory update flips ExtLatestVersionIdKey from
// v-old to v-new. v-old becomes noncurrent immediately. The router
// looks up v-old's file (one RPC) and emits NoncurrentDays Match
// against the LOGICAL key with VersionID=v-old.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
displaced := displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix())
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
lister := &recordingLister{lookupEntry: displaced}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("want 1 match (NoncurrentDays), got %v", matches)
}
m := matches[0]
if m.ObjectKey != "logs/foo" {
t.Fatalf("ObjectKey=%q, want logs/foo", m.ObjectKey)
}
if m.VersionID != "v-old" {
t.Fatalf("VersionID=%q, want displaced v-old", m.VersionID)
}
if len(lister.lookupCalls) != 1 || lister.lookupCalls[0] != "bk/logs/foo@v-old" {
t.Fatalf("lookup calls=%v, want [bk/logs/foo@v-old]", lister.lookupCalls)
}
}
func TestRoutePointerTransitionPointerUnchangedSkipped(t *testing.T) {
// Directory update with same OLD/NEW pointer (e.g. some other
// metadata changed): no transition; nothing to schedule and no
// lookup RPC.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-same", "v-same", now.Unix())
lister := &recordingLister{}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("unchanged pointer must not fire, got %v", got)
}
if len(lister.lookupCalls) != 0 {
t.Fatalf("unchanged pointer must not consult lister: %v", lister.lookupCalls)
}
}
func TestRoutePointerTransitionEmptyOldPointerNoNullSkipped(t *testing.T) {
// First PUT on a brand-new versioned object: OLD pointer is empty,
// no bare null version exists. Nothing displaced.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "", "v-new", now.Unix())
lister := &recordingLister{} // nullEntry is nil
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("empty old pointer + no null must not fire, got %v", got)
}
if len(lister.lookupCalls) != 0 {
t.Fatalf("LookupVersion must not be called for empty oldID, got %v", lister.lookupCalls)
}
if len(lister.nullCalls) != 1 {
t.Fatalf("expected exactly one LookupNullVersion call, got %v", lister.nullCalls)
}
}
func TestRoutePointerTransitionEmptyOldPointerWithNullSchedules(t *testing.T) {
// First versioned PUT after a pre-versioning bare object exists.
// OLD pointer is empty, but the bare null is the displaced version.
// NoncurrentDays must schedule it as VersionID="null" so the worker
// doesn't have to wait for the next bootstrap.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "", "v-new", now.Unix())
nullMt := now.AddDate(0, 0, -10)
lister := &recordingLister{nullEntry: &filer_pb.Entry{
Name: "foo",
Attributes: &filer_pb.FuseAttributes{
Mtime: nullMt.Unix(),
FileSize: 50,
},
}}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("want 1 match (NoncurrentDays on null), got %v", matches)
}
if matches[0].VersionID != "null" {
t.Fatalf("VersionID=%q, want \"null\"", matches[0].VersionID)
}
if matches[0].ObjectKey != "logs/foo" {
t.Fatalf("ObjectKey=%q, want logs/foo", matches[0].ObjectKey)
}
}
func TestRoutePointerTransitionExpansionIncludesNullVersion(t *testing.T) {
// Suspended-bucket history: bare null exists with a recent mtime.
// Versioning re-enabled and a new version was just written. With
// NewerNoncurrentVersions=2 the rank-2 entry is the threshold-
// crosser. Because the null sits between v-mid and v-old by mtime,
// it occupies a noncurrent rank slot and shifts what the rank-2
// entry actually IS. Without including null, ranks would be wrong.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-cur", "v-new", now.Unix())
// Mtimes (newest-first): v-new, v-cur, v-mid, null, v-old, v-old2.
// Post-flip ranks: latest=v-new, rank0=v-cur, rank1=v-mid,
// rank2=null, rank3=v-old, rank4=v-old2. The rank-2 crossing is
// "null" — and without including null in the sibling set, rank2
// would have been v-old (different version_id, wrong identity).
listVersions := []*filer_pb.Entry{
displacedVersionEntry("v-new", now.Unix()),
displacedVersionEntry("v-cur", now.AddDate(0, 0, -1).Unix()),
displacedVersionEntry("v-mid", now.AddDate(0, 0, -2).Unix()),
displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix()),
displacedVersionEntry("v-old2", now.AddDate(0, 0, -20).Unix()),
}
nullEntry := &filer_pb.Entry{
Name: "foo",
Attributes: &filer_pb.FuseAttributes{Mtime: now.AddDate(0, 0, -3).Unix()},
}
lister := &recordingLister{listVersions: listVersions, nullEntry: nullEntry}
matches := Route(context.Background(), snap, ev, now, lister)
versionIDs := []string{}
for _, m := range matches {
versionIDs = append(versionIDs, m.VersionID)
}
if !contains(versionIDs, "null") {
t.Fatalf("rank-2 should be null after including bare entry, matches=%v", versionIDs)
}
for _, id := range []string{"v-cur", "v-mid", "v-old", "v-old2", "v-new"} {
if contains(versionIDs, id) {
t.Fatalf("only the rank-2 (null) entry should fire, got %s in matches=%v", id, versionIDs)
}
}
}
func TestRoutePointerTransitionDisplacedVersionMissingSuppressed(t *testing.T) {
// Race: by the time the router looks up v-old, it's already been
// hard-deleted. LookupVersion returns (nil, nil); no Match.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
lister := &recordingLister{lookupEntry: nil} // not found
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("missing displaced version must suppress, got %v", got)
}
if len(lister.lookupCalls) != 1 {
t.Fatalf("lookup attempted once, got %v", lister.lookupCalls)
}
}
func TestRoutePointerTransitionNoNoncurrentRuleSkipsLookup(t *testing.T) {
// Bucket has only ExpirationDays — no NoncurrentDays / NewerNoncurrent.
// The router must NOT issue the lookup RPC.
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
lister := &recordingLister{lookupEntry: displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix())}
Route(context.Background(), snap, ev, now, lister)
if len(lister.lookupCalls) != 0 {
t.Fatalf("lister consulted without noncurrent rule: %v", lister.lookupCalls)
}
}
func TestRoutePointerTransitionNewerNoncurrentNewestNoncurrentRetained(t *testing.T) {
// NewerNoncurrentVersions=2 routes through the expansion path. The
// freshly-noncurrent version is at rank 0 (newest noncurrent) and
// the threshold-crossing rank N=2 doesn't exist (only 2 versions
// total). No match expected — and ListVersions must be the one
// consulted, not LookupVersion.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
lister := &recordingLister{listVersions: []*filer_pb.Entry{
displacedVersionEntry("v-new", now.Unix()),
displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix()),
}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("rank-0 noncurrent must be retained under NewerNoncurrentVersions=2, got %v", got)
}
if len(lister.listCalls) != 1 {
t.Fatalf("expansion path must consult ListVersions, calls=%v", lister.listCalls)
}
if len(lister.lookupCalls) != 0 {
t.Fatalf("expansion path must not consult LookupVersion, calls=%v", lister.lookupCalls)
}
}
func TestRoutePointerTransitionExpansionMissingNewIDSuppressed(t *testing.T) {
// Race window: by the time ListVersions returns, the new pointer's
// version file isn't visible yet. latestPos can't resolve, so the
// router suppresses (bootstrap repairs state) instead of treating
// the actual newest sibling as latest and misranking every other
// version.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
// Listing returns v-old + v-mid but NOT v-new (the just-named latest).
lister := &recordingLister{listVersions: []*filer_pb.Entry{
displacedVersionEntry("v-mid", now.AddDate(0, 0, -1).Unix()),
displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix()),
}}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("missing new id must suppress, got %v", got)
}
}
func TestRoutePointerTransitionUnversionedBucketSkipped(t *testing.T) {
// Same event shape on an unversioned bucket: should not even reach
// the pointer-transition branch.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWith(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
lister := &recordingLister{}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("unversioned bucket must not route pointer transitions, got %v", got)
}
}
func TestRoutePointerTransitionMissingCachedMtimeSuppressed(t *testing.T) {
// Older builds (or buggy paths) may write the latest pointer
// without ExtLatestVersionMtimeKey. Without a reliable successor
// clock NoncurrentDays would compute a year-0001 base and fire
// immediately. Suppress until the cache lands.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
// Hand-build the event WITHOUT ExtLatestVersionMtimeKey on NewEntry.
now := time.Now()
ev := &reader.Event{
Bucket: "bk",
Key: "logs/foo" + s3_constants.VersionsFolder,
OldEntry: &filer_pb.Entry{
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: 1},
Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("v-old")},
},
NewEntry: &filer_pb.Entry{
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: 1},
Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("v-new")},
},
}
displaced := displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix())
lister := &recordingLister{lookupEntry: displaced}
if got := Route(context.Background(), snap, ev, now, lister); len(got) != 0 {
t.Fatalf("missing cached mtime must suppress, got %v", got)
}
if len(lister.lookupCalls) != 0 {
t.Fatalf("must not consult lister without successor mtime, got %v", lister.lookupCalls)
}
}
func TestRoutePointerTransitionUsesCachedMtimeNotStaleDirMtime(t *testing.T) {
// Regression: the .versions/ directory's own Attributes.Mtime is
// preserved across pointer updates by updateLatestVersionInDirectory.
// Using it as SuccessorModTime would let a fresh pointer flip on an
// old directory fire NoncurrentDays right away. Use the cached
// latest-mtime instead.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 30,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
// The cached latest-mtime IS now (fresh PUT). Container's Attrs.Mtime
// is stale (containerStaleMtime=1). With the buggy old code, the
// match would fire immediately because successor=year-1970 +
// 30 days < now. With the fix, due = cached latest mtime + 30 days,
// which is in the future.
ev := versionsContainerEvent("bk", "logs/foo", "v-old", "v-new", now.Unix())
displaced := displacedVersionEntry("v-old", now.AddDate(0, 0, -10).Unix())
lister := &recordingLister{lookupEntry: displaced}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("want 1 match (scheduled, not fired), got %v", matches)
}
if !matches[0].DueTime.After(now.Add(29 * 24 * time.Hour)) {
t.Fatalf("DueTime=%v, want ~30d from now (cached mtime + 30d)", matches[0].DueTime)
}
}
func TestRoutePointerTransitionNewerNoncurrentExpansionFiresOnCrossingThreshold(t *testing.T) {
// NewerNoncurrentVersions=2 keeps the 2 newest noncurrents. Before
// the pointer flip there were 3 versions: v-cur (latest), v-mid
// (rank 0 noncurrent), v-old (rank 1 noncurrent). After flipping
// to v-new the ranks shift to: v-new latest, v-cur rank 0, v-mid
// rank 1, v-old rank 2 — v-old just crossed the threshold and
// must fire NoncurrentDays this run instead of waiting for the
// next bootstrap.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
// Successor mtime (cached on container) is "now" — the new latest.
ev := versionsContainerEvent("bk", "logs/foo", "v-cur", "v-new", now.Unix())
allVersions := []*filer_pb.Entry{
displacedVersionEntry("v-new", now.Unix()), // newest
displacedVersionEntry("v-cur", now.AddDate(0, 0, -1).Unix()),
displacedVersionEntry("v-mid", now.AddDate(0, 0, -10).Unix()),
displacedVersionEntry("v-old", now.AddDate(0, 0, -30).Unix()), // oldest
}
lister := &recordingLister{listVersions: allVersions}
matches := Route(context.Background(), snap, ev, now, lister)
// v-cur (rank 0) and v-mid (rank 1) retained; v-old (rank 2) fires.
versionIDs := []string{}
for _, m := range matches {
versionIDs = append(versionIDs, m.VersionID)
}
if !contains(versionIDs, "v-old") {
t.Fatalf("v-old at rank 2 (>= NewerNoncurrentVersions=2) must fire, matches=%v", versionIDs)
}
for _, id := range []string{"v-cur", "v-mid"} {
if contains(versionIDs, id) {
t.Fatalf("rank-%d noncurrent must be retained, matches=%v", indexOf([]string{"v-cur", "v-mid"}, id), versionIDs)
}
}
if len(lister.listCalls) != 1 {
t.Fatalf("expansion path must call ListVersions once, got %v", lister.listCalls)
}
if len(lister.lookupCalls) != 0 {
t.Fatalf("expansion path must not call LookupVersion, got %v", lister.lookupCalls)
}
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
func indexOf(ss []string, s string) int {
for i, x := range ss {
if x == s {
return i
}
}
return -1
}
func TestRoutePointerTransitionExpansionEmitsOnlyThresholdCrossing(t *testing.T) {
// Hot key with many already-eligible noncurrents under
// NewerNoncurrentVersions=2. A pointer flip must NOT enqueue every
// over-threshold version (Schedule.Add doesn't dedup; identity-CAS
// only saves the dispatch RPC, not the heap slot). Only the version
// that JUST crossed from kept to expired needs to enter the heap;
// everything else was already scheduled by an earlier transition or
// bootstrap.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
ev := versionsContainerEvent("bk", "logs/foo", "v-cur", "v-new", now.Unix())
// 6 versions total: v-new latest after flip, then v-cur (rank 0),
// v-mid (rank 1), v-2 (rank 2 — newly crossed), v-3, v-4 (already
// past threshold from previous flips). Only rank 2 should enter
// the heap on this flip.
allVersions := []*filer_pb.Entry{
displacedVersionEntry("v-new", now.Unix()),
displacedVersionEntry("v-cur", now.AddDate(0, 0, -1).Unix()),
displacedVersionEntry("v-mid", now.AddDate(0, 0, -2).Unix()),
displacedVersionEntry("v-2", now.AddDate(0, 0, -10).Unix()),
displacedVersionEntry("v-3", now.AddDate(0, 0, -20).Unix()),
displacedVersionEntry("v-4", now.AddDate(0, 0, -30).Unix()),
}
lister := &recordingLister{listVersions: allVersions}
matches := Route(context.Background(), snap, ev, now, lister)
versionIDs := []string{}
for _, m := range matches {
versionIDs = append(versionIDs, m.VersionID)
}
// Want exactly v-2 (rank 2 — newly crossed). Not v-3 / v-4 (already
// over) and not v-cur / v-mid (still kept).
if !contains(versionIDs, "v-2") {
t.Fatalf("v-2 at the new crossing rank must fire, matches=%v", versionIDs)
}
for _, id := range []string{"v-3", "v-4"} {
if contains(versionIDs, id) {
t.Fatalf("over-threshold %s must NOT re-enter the heap on this flip, matches=%v", id, versionIDs)
}
}
for _, id := range []string{"v-cur", "v-mid"} {
if contains(versionIDs, id) {
t.Fatalf("retained %s must not fire, matches=%v", id, versionIDs)
}
}
}
// versionsContainerEventStaleMtime simulates the pointer-cleared
// suspended-versioning shape: NewEntry has no ExtLatestVersionIdKey
// but DOES carry a stale ExtLatestVersionMtimeKey from the displaced
// version (the cached value the buggy server-side code left behind).
func versionsContainerEventStaleMtime(bucket, logical, oldID string, staleMtimeUnix int64) *reader.Event {
const containerStaleMtime int64 = 1
return &reader.Event{
Bucket: bucket,
Key: logical + s3_constants.VersionsFolder,
OldEntry: &filer_pb.Entry{
Name: logical + s3_constants.VersionsFolder,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: containerStaleMtime},
Extended: map[string][]byte{
s3_constants.ExtLatestVersionIdKey: []byte(oldID),
s3_constants.ExtLatestVersionMtimeKey: []byte(fmt.Sprintf("%d", staleMtimeUnix)),
},
},
NewEntry: &filer_pb.Entry{
Name: logical + s3_constants.VersionsFolder,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mtime: containerStaleMtime},
Extended: map[string][]byte{
// Pointer cleared. Cached mtime intentionally left stale
// (the bug we're guarding against on the router side).
s3_constants.ExtLatestVersionMtimeKey: []byte(fmt.Sprintf("%d", staleMtimeUnix)),
},
},
}
}
func TestRoutePointerTransitionSuspendedClearsPointerUsesNullMtime(t *testing.T) {
// Suspended write: NewEntry's ExtLatestVersionIdKey is gone. The
// cached ExtLatestVersionMtimeKey may still hold the displaced
// version's mtime (server-side bug + defensive router behavior).
// Router must use the null entry's mtime as the successor clock,
// not the cached stale value — otherwise NoncurrentDays=30 fires
// on a 100-day-old displaced version even though it became
// noncurrent today.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 30,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
staleMtime := now.AddDate(0, 0, -100).Unix() // displaced version is 100 days old
ev := versionsContainerEventStaleMtime("bk", "logs/foo", "v-old", staleMtime)
displaced := displacedVersionEntry("v-old", staleMtime)
// Null was written today (the suspended PUT itself).
nullEntry := &filer_pb.Entry{
Name: "foo",
Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix()},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("null")},
}
lister := &recordingLister{lookupEntry: displaced, nullEntry: nullEntry}
matches := Route(context.Background(), snap, ev, now, lister)
if len(matches) != 1 {
t.Fatalf("want 1 match (NoncurrentDays scheduled, NOT fired), got %v", matches)
}
// Successor = null mtime (today). Days threshold = 30. DueTime ≈ now+30d.
if !matches[0].DueTime.After(now.Add(29 * 24 * time.Hour)) {
t.Fatalf("DueTime=%v, want ~30d from null PUT (not from displaced version's age)", matches[0].DueTime)
}
if matches[0].VersionID != "v-old" {
t.Fatalf("VersionID=%q, want displaced v-old", matches[0].VersionID)
}
}
func TestRoutePointerTransitionSuspendedClearsPointerExpansionLatestPosIsNull(t *testing.T) {
// Same shape under a NewerNoncurrentVersions rule — exercises the
// expansion path. The new latest is "null", so latestPos must
// resolve to the null sibling. With a substitution latestIDForExpand
// = "null", the existing match-on-id logic finds it.
rule := &s3lifecycle.Rule{
ID: "r",
Status: s3lifecycle.StatusEnabled,
NoncurrentVersionExpirationDays: 1,
NewerNoncurrentVersions: 2,
}
snap := compileWithVersioned(rule, activatedPrior(rule))
now := time.Now()
staleMtime := now.AddDate(0, 0, -10).Unix()
ev := versionsContainerEventStaleMtime("bk", "logs/foo", "v-cur", staleMtime)
// Versions in .versions/, plus null. Mtime order newest-first:
// null (today), v-cur (-1d), v-mid (-2d), v-old (-30d).
// Post-flip ranks: latest=null, rank0=v-cur, rank1=v-mid, rank2=v-old.
// rank-2 v-old is the threshold-crosser. v-cur (rank0) and v-mid
// (rank1) retained.
listVersions := []*filer_pb.Entry{
displacedVersionEntry("v-cur", now.AddDate(0, 0, -1).Unix()),
displacedVersionEntry("v-mid", now.AddDate(0, 0, -2).Unix()),
displacedVersionEntry("v-old", now.AddDate(0, 0, -30).Unix()),
}
nullEntry := &filer_pb.Entry{
Name: "foo",
Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix()},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("null")},
}
lister := &recordingLister{listVersions: listVersions, nullEntry: nullEntry}
matches := Route(context.Background(), snap, ev, now, lister)
versionIDs := []string{}
for _, m := range matches {
versionIDs = append(versionIDs, m.VersionID)
}
if !contains(versionIDs, "v-old") {
t.Fatalf("rank-2 v-old must fire, matches=%v", versionIDs)
}
for _, id := range []string{"null", "v-cur", "v-mid"} {
if contains(versionIDs, id) {
t.Fatalf("%s must not fire (latest or retained), matches=%v", id, versionIDs)
}
}
}