Files
seaweedfs/weed/s3api/s3lifecycle/router/router_test.go
T
Chris Lu 05d31a04b6 fix(s3tests): wire lifecycle worker for expiration suite (#9374)
* fix(s3tests): wire lifecycle worker for expiration suite

The upstream s3-tests `test_lifecycle_expiration` / `test_lifecyclev2_expiration`
exercise the "set rule, wait, verify deletion" path. Phase 4 (#9367) intentionally
stripped the PUT-time back-stamp, so pre-existing objects no longer pick up TtlSec
on a freshly-applied rule. The s3tests CI bare-bones `weed -s3` had nothing left
driving expiration.

Three changes that work together:

- Engine scales `Days` by `util.LifeCycleInterval`. Production keeps the 24h day;
  the `s3tests` build tag shrinks it to 10s so a `Days: 1` rule completes inside
  the suite's 30s polling window. Exported `DaysToDuration` so sibling-package
  tests pin to the same scale.
- Scheduler/dispatcher tick defaults split into `_default` / `_s3tests` files.
  Production stays 5s/30s/5m; the test build runs at 500ms/2s/2s so deletions
  land within a couple ticks of becoming due.
- s3tests.yml spawns `weed shell s3.lifecycle.run-shard -shards 0-15 -events 0
  -runtime 1800s` alongside the s3 server in both the basic and SQL blocks; the
  shell command runs the full pipeline (reader + scheduler + dispatcher) for the
  duration of the suite. `test_lifecycle_expiration_versioning_enabled` is left
  out for now — versioned-bucket expiration via the worker still needs its own
  pass.

Drive-by: bump `TestWorkerDefaultJobTypes` to 7 to match the registered
handler count (8b87ceb0d updated `mini_plugin_test.go` for the s3_lifecycle
plugin but missed this twin test).

Two retention-gate engine tests `t.Skip` under the s3tests build because they
rely on absolute lookback-vs-retention math the day-rescale collapses; the prod
build still covers them.

* review: harden lifecycle worker spawn + assert handler identity

- Workflow: aliveness check on the backgrounded `weed shell` (a bad command
  exits in <1s and the suite would otherwise just opaque-timeout); move
  worker/server teardown into a `trap cleanup EXIT` so failure paths still
  print the worker log and reap the data dir.
- worker_test: check the actual job-type set by name, not just the count.

* fix(shell): keep s3.lifecycle.run-shard alive when no rules exist yet

The s3-tests CI runs the worker BEFORE any test creates a bucket, so
LoadCompileInputs returns empty and the shell command was bailing out
with "no buckets with enabled lifecycle rules found" within ~1s. The
aliveness check then fired exit 1 before tox ever started.

Two changes:

- Don't early-exit on empty inputs. Compile against the empty set, log a
  one-liner, and let the pipeline run normally — the meta-log subscription
  is already up, so events for buckets created later DO arrive; they just
  need the engine to know about them when they do.
- Add `-refresh <duration>` (default 5m, 2s in s3tests CI) that
  periodically re-runs LoadCompileInputs + engine.Compile so rules added
  after startup land in the snapshot the dispatcher reads on its next
  tick. Production deployments keep the 5m default; only the CI workflow
  drops to 2s.

Workflow passes `-refresh 2s` in both basic and SQL blocks.

* fix(shell): backfill pre-rule entries via bootstrap walker

The reader-driven path only sees meta-log events created AFTER its
engine snapshot knows the rule. The s3-tests CI scenario PUTs objects
first, then PUTs the lifecycle config, so by the time the engine
refresh picks up the new bucket the object events have already been
seen-and-dropped (BucketActionKeys returned empty for the bucket).

Wire bootstrap.Walk into the shell command:

- bucketBootstrapper tracks buckets seen so far. kickOffNew spawns one
  loop goroutine per fresh bucket.
- Each goroutine re-walks the bucket every walkInterval (defaults to
  the same value as -refresh, i.e. 2s in s3tests CI, 5m in prod) and
  feeds each entry through bootstrap.Walk; due actions dispatch via a
  direct LifecycleDelete RPC. Not-yet-due entries are silently skipped
  and picked up on a later iteration once they age past their (rescaled
  or real) threshold.
- LifecycleDelete is called with no expected_identity; the server-side
  identityMatches treats nil as "skip CAS", which is the right call
  for bootstrap (the bootstrap entry doesn't carry chunk fid /
  extended hash anyway).

The dispatcher's pkg-private toProtoActionKind is duplicated in the
shell file rather than exported, since the shape is six lines and the
reverse import would pull a proto dep into the s3lifecycle root.

* refactor(s3/lifecycle): hoist bucket bootstrapper into scheduler pkg

The shell command got the backfill in the previous commit but the worker
plugin (weed/worker/tasks/s3_lifecycle/handler.go) drives Scheduler.Run
directly and missed it — same root cause: the reader-driven path only
sees events created after the rule lands, so a daily cron picking up a
freshly-PUT rule wouldn't expire any pre-rule object.

Move the looping bucket walker into scheduler.BucketBootstrapper:

- Scheduler.Run now constructs one and calls KickOffNew on every engine
  refresh. Per-bucket goroutines re-walk every BootstrapWalkInterval
  (defaults to RefreshInterval — 5m in prod, 2s under s3tests).
- The shell command consumes the same struct instead of its own copy
  so the two paths can't drift in semantics.

* refactor(s3/lifecycle): walk-once + schedule via event injection

Previous per-bucket walker re-listed every WalkInterval forever. For a
bucket with N objects under a long rule, the worker did O(N * runtime /
walkInterval) listings even when nothing was newly due — way too much
for production-scale buckets.

New approach: walk each bucket exactly once on first sight, synthesize
one *reader.Event per existing entry, push it onto Pipeline.events.
Router.Route builds a Match with DueTime=mtime+delay; future-due matches
sit in the per-shard Schedule and fire when their DueTime arrives.
Currently-due matches fire on the very next dispatch tick.

Wiring:

- dispatcher.Pipeline lifts its events channel into a struct field
  with sync.Once init, and exposes InjectEvent(ctx, ev). Reader no
  longer closes the channel — the dispatch goroutine exits on runCtx
  cancellation, which works the same as channel-close did.
- scheduler.BucketBootstrapper drops the WalkInterval ticker. KickOffNew
  spawns one walker goroutine per fresh bucket; the goroutine lists,
  synthesizes events, then exits.
- scheduler.Scheduler builds its pipelines up front and exposes a
  pipelineFanout (shard -> Pipeline) as the EventInjector, so a multi-
  worker scheduler routes each synthesized event to the pipeline that
  owns its shard.
- Shell command's single-pipeline path passes pipeline.InjectEvent
  directly.

Synthesized events carry TsNs=0; dispatcher.advance treats that as a
no-op so the reader's persisted cursor isn't ratcheted past unprocessed
meta-log events. Identity (HeadFid + ExtendedHash) is still computed
from the real filer entry, so the server's identity-CAS catches an
overwrite between bootstrap and dispatch.

* debug(s3tests): make lifecycle worker progress visible in CI logs

The previous CI failure dumped an empty $LC_LOG even though the worker
was running. Two reasons:

1. weed shell suppresses glog by default (logtostderr / alsologtostderr
   set to false). Pass `-debug` so the bootstrapper's V(0) lines reach
   stderr instead of disappearing into /tmp/weed.*.log.
2. cleanup used `kill -9` which skips Go's stdout flush. SIGTERM first
   with a 1s grace, then SIGKILL the holdout, then read the log.

While here: bump the bootstrap walker's two informational logs to V(0)
so the diagnosis from CI doesn't require -v=1 on the worker.

* fix(s3/lifecycle/dispatcher): refresh snap on every event

Pipeline.Run captured snap at startup and only refreshed it on the
dispatch tick. With bootstrap event injection, the walker pushes events
seconds after engine.Compile sees the bucket — typically WITHIN the
same dispatch interval. Routing against the cached (empty) snap then
silently dropped every match because BucketActionKeys returned nil for
the bucket-not-yet-in-snapshot case.

Re-fetch on each event. Engine.Snapshot is an atomic.Pointer.Load, so
the cost is negligible. The dispatch-tick branch keeps using a fresh
local read for its own loop, so its semantics are unchanged.
2026-05-08 17:29:47 -07:00

565 lines
19 KiB
Go

package router
import (
"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(nil, eventCreate("bk", "k", 0, 1, 1), time.Now()); 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(snap, ev, time.Now()); 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(snap, ev, now); 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(snap, ev, now)
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(snap, ev, now)
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(snap, ev, now); 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(snap, ev, now); 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(snap, ev, now); 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(snap, ev, time.Now()); 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(snap, ev, now)
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(snap, ev, now)
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(snap, ev, now)
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(snap, ev, now); 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(snap, ev, now); 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(snap, ev, now); 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(snap, ev, now)
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(snap, ev, now)
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(snap, ev, now); 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(snap, ev, now)
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(snap, ev, now)
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)
}
}
func TestRouteVersionedExpiredDeleteMarkerSuppressedWithoutSiblings(t *testing.T) {
// ExpiredObjectDeleteMarker requires NumVersions==1 — the marker is
// the sole-survivor. Without sibling listing the router can't
// confirm that, so the rule must NOT fire just because the latest
// is a delete marker. A future PR adds sibling listing.
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 := eventCreate("bk", "logs/gone", old.Unix(), 0, old.UnixNano())
ev.NewEntry.Extended = map[string][]byte{
s3_constants.ExtDeleteMarkerKey: {1},
}
if got := Route(snap, ev, now); len(got) != 0 {
t.Fatalf("ExpiredDeleteMarker without sibling count must not fire, got %v", got)
}
}
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(snap, ev, now); len(got) != 0 {
t.Fatalf("version-folder event should be skipped, got %v", got)
}
})
}
}