mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* 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.
1654 lines
61 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|