Files
seaweedfs/weed/s3api/s3lifecycle/router/router_test.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

1712 lines
63 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 TestRouteVersionedVersionFileWriteSchedulesExpiration(t *testing.T) {
// A versioned PUT shows up in the meta-log as the version file plus
// the .versions/ directory update — there is no bare-key event for
// the regular path to see. The version file is the latest at write
// time; the match carries its identity with an empty VersionID so
// dispatch CAS-checks the current latest at due time.
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"+s3_constants.VersionsFolder+"/v_v1", old.Unix(), 1, old.UnixNano())
ev.NewEntry.Extended = map[string][]byte{
s3_constants.ExtVersionIdKey: []byte("v1"),
}
matches := Route(context.Background(), snap, ev, now, nil)
if len(matches) != 1 {
t.Fatalf("expected 1 match (EXPIRATION_DAYS), got %v", matches)
}
m := matches[0]
if m.Key.ActionKind != s3lifecycle.ActionKindExpirationDays {
t.Fatalf("ActionKind=%v, want ExpirationDays", m.Key.ActionKind)
}
if m.ObjectKey != "logs/foo" {
t.Fatalf("ObjectKey=%q, want the logical key", m.ObjectKey)
}
if m.VersionID != "" {
t.Fatalf("VersionID=%q, want empty so dispatch resolves the current latest", m.VersionID)
}
if m.Identity == nil {
t.Fatal("Identity must fingerprint the version file for dispatch CAS")
}
if want := time.Unix(old.Unix(), 0).Add(s3lifecycle.DaysToDuration(1)); !m.DueTime.Equal(want) {
t.Fatalf("DueTime=%v, want %v", m.DueTime, want)
}
}
func TestRouteVersionedMarkerCreateDoesNotFireExpiration(t *testing.T) {
// A delete marker is a version file too, but ExpirationDays never
// applies to a marker — only EXP_DM, handled by the sole-survivor
// path. Without the marker gate, the expiration match would pin a
// marker identity and dispatch would stack a second marker on top.
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 := 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("marker create must not fire EXPIRATION_DAYS, got %v", got)
}
if len(lister.calls) != 0 {
t.Fatalf("lister consulted without an EXP_DM rule: calls=%v", lister.calls)
}
}
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, events inside a *.versions/ parent that are
// not stamped version-file writes stay unrouted — current-vs-noncurrent
// classification needs the container's latest pointer, and the file's
// own ExtVersionIdKey is what marks it as a version write the
// expiration path can schedule from.
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
}{
{
// A marker carries the version id but is a marker, not an
// expirable write — it routes to the sole-survivor path.
name: "delete marker",
key: "logs/foo.versions/v_v1",
ext: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("v1"), s3_constants.ExtDeleteMarkerKey: []byte("true")},
},
{
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)
}
}
}