mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-04 23:27:09 +00:00
* 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.
409 lines
14 KiB
Go
409 lines
14 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
)
|
|
|
|
// recorder captures dispatched (action, entry) pairs for assertion.
|
|
type recorder struct {
|
|
calls []dispatchCall
|
|
err error // when set, every Delete returns this error
|
|
}
|
|
|
|
type dispatchCall struct {
|
|
kind s3lifecycle.ActionKind
|
|
path string
|
|
}
|
|
|
|
func (r *recorder) Delete(ctx context.Context, action *engine.CompiledAction, entry *Entry) error {
|
|
if r.err != nil {
|
|
return r.err
|
|
}
|
|
r.calls = append(r.calls, dispatchCall{kind: action.Key.ActionKind, path: entry.Path})
|
|
return nil
|
|
}
|
|
|
|
func mustTime(t *testing.T, s string) time.Time {
|
|
t.Helper()
|
|
tm, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", s, err)
|
|
}
|
|
return tm
|
|
}
|
|
|
|
func compileEvDriven(t *testing.T, bucket string, rules ...*s3lifecycle.Rule) *engine.Snapshot {
|
|
t.Helper()
|
|
prior := map[s3lifecycle.ActionKey]engine.PriorState{}
|
|
for _, r := range rules {
|
|
rh := s3lifecycle.RuleHash(r)
|
|
for _, k := range s3lifecycle.RuleActionKinds(r) {
|
|
prior[s3lifecycle.ActionKey{Bucket: bucket, RuleHash: rh, ActionKind: k}] = engine.PriorState{BootstrapComplete: true}
|
|
}
|
|
}
|
|
e := engine.New()
|
|
return e.Compile([]engine.CompileInput{{Bucket: bucket, Rules: rules}}, engine.CompileOptions{PriorStates: prior})
|
|
}
|
|
|
|
func TestWalk_DispatchesDueActions(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 30,
|
|
Prefix: "logs/",
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 60) // past the 30d threshold
|
|
entries := []*Entry{
|
|
{Path: "data/x", IsLatest: true, ModTime: mod}, // wrong prefix
|
|
{Path: "logs/a", IsLatest: true, ModTime: mod}, // due
|
|
{Path: "logs/b", IsLatest: true, ModTime: now}, // not yet due (mod=now)
|
|
}
|
|
|
|
rec := &recorder{}
|
|
cp, err := Walk(context.Background(), snap, "bk", EntryCallback(entries), rec, WalkOptions{Now: now})
|
|
if err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if !cp.Completed {
|
|
t.Fatalf("walk should complete")
|
|
}
|
|
if cp.LastScannedPath != "logs/b" {
|
|
t.Fatalf("checkpoint last scanned want logs/b, got %q", cp.LastScannedPath)
|
|
}
|
|
if len(rec.calls) != 1 || rec.calls[0].path != "logs/a" {
|
|
t.Fatalf("dispatched calls want [logs/a], got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_MultiActionRule_AllDueDispatched(t *testing.T) {
|
|
// One rule with three actions; all currently-due for the entry. The
|
|
// walker must dispatch one Delete per action — this is the
|
|
// regression that the per-action keying fixes.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "multi",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 30,
|
|
NoncurrentVersionExpirationDays: 7,
|
|
AbortMPUDaysAfterInitiation: 5,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 100) // past every threshold
|
|
// Use a single entry that satisfies all three action shapes is
|
|
// unrealistic; in practice each shape is a different entry. Cover
|
|
// each shape independently.
|
|
entries := []*Entry{
|
|
// Current version under ExpirationDays.
|
|
{Path: "obj/a", IsLatest: true, ModTime: mod},
|
|
// Non-current version under NoncurrentDays.
|
|
{Path: "obj/a/.versions/v1", IsLatest: false, ModTime: mod, SuccessorModTime: mod},
|
|
// MPU init under AbortMPU. Real init is a directory; DestKey
|
|
// carries the eventual object key for prefix matching.
|
|
{Path: ".uploads/u1", IsDirectory: true, IsMPUInit: true, DestKey: "obj/a", ModTime: mod},
|
|
}
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback(entries), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
|
|
// Exact-shape assertion: each entry dispatches exactly one action,
|
|
// and ABORT_MPU only fires on the .uploads/<id> entry. A weaker
|
|
// "kinds-as-set" check would have missed the (kind, info) gating
|
|
// regression where an MPU init also fired NONCURRENT_DAYS.
|
|
want := []dispatchCall{
|
|
{kind: s3lifecycle.ActionKindAbortMPU, path: ".uploads/u1"},
|
|
{kind: s3lifecycle.ActionKindExpirationDays, path: "obj/a"},
|
|
{kind: s3lifecycle.ActionKindNoncurrentDays, path: "obj/a/.versions/v1"},
|
|
}
|
|
got := append([]dispatchCall(nil), rec.calls...)
|
|
sort.Slice(got, func(i, j int) bool {
|
|
if got[i].path != got[j].path {
|
|
return got[i].path < got[j].path
|
|
}
|
|
return got[i].kind < got[j].kind
|
|
})
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("dispatch calls mismatch:\n got %+v\nwant %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWalk_NotYetDueSkipped(t *testing.T) {
|
|
// The reader (Phase 3) is responsible for not-yet-due entries; the
|
|
// walker dispatches only currently-due ones, so the meta-log path
|
|
// stays the steady-state route.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 30,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.Add(s3lifecycle.DaysToDuration(10)) // before the 30d threshold
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "x/a", IsLatest: true, ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Fatalf("not-yet-due entry should not dispatch, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_DateActionsSkipped(t *testing.T) {
|
|
// Date kind is handled by its own SCAN_AT_DATE bootstrap, not by the
|
|
// regular bootstrap walker.
|
|
date := mustTime(t, "2025-06-15T00:00:00Z")
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "d",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDate: date,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "x/a", IsLatest: true, ModTime: mustTime(t, "2024-01-01T00:00:00Z")},
|
|
}), rec, WalkOptions{Now: date.AddDate(0, 1, 0)}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Fatalf("date kind should not dispatch from walker, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_DirectoryEntriesSkipped(t *testing.T) {
|
|
// SeaweedFS directory entries can co-exist in the listing; the walker
|
|
// must not dispatch deletes against them even when their path matches.
|
|
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 10)
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "x", IsDirectory: true, ModTime: mod}, // directory; must skip
|
|
{Path: "x/file", IsLatest: true, ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 1 || rec.calls[0].path != "x/file" {
|
|
t.Fatalf("only the file should dispatch, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_DisabledModeSkipped(t *testing.T) {
|
|
// An operator-flipped ModeDisabled must short-circuit the walker even
|
|
// when the XML rule status is "Enabled" and EvaluateAction would
|
|
// otherwise fire.
|
|
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
|
|
rh := s3lifecycle.RuleHash(rule)
|
|
prior := map[s3lifecycle.ActionKey]engine.PriorState{
|
|
{Bucket: "bk", RuleHash: rh, ActionKind: s3lifecycle.ActionKindExpirationDays}: {
|
|
BootstrapComplete: true, Mode: engine.ModeDisabled,
|
|
},
|
|
}
|
|
e := engine.New()
|
|
snap := e.Compile([]engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, engine.CompileOptions{PriorStates: prior})
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 10)
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "x/a", IsLatest: true, ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Fatalf("disabled action must not dispatch, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_PendingBootstrapNotDispatched(t *testing.T) {
|
|
// Without bootstrap_complete=true in PriorStates, the engine compiles
|
|
// the action as inactive. MatchPath filters on IsActive, so the
|
|
// walker won't dispatch.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 1,
|
|
}
|
|
e := engine.New()
|
|
snap := e.Compile([]engine.CompileInput{{Bucket: "bk", Rules: []*s3lifecycle.Rule{rule}}}, engine.CompileOptions{})
|
|
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 10)
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "x/a", IsLatest: true, ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Fatalf("inactive action should not dispatch, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_DispatchErrorHaltsAtCheckpoint(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 1,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 10)
|
|
entries := []*Entry{
|
|
{Path: "a", IsLatest: true, ModTime: mod},
|
|
{Path: "b", IsLatest: true, ModTime: mod},
|
|
{Path: "c", IsLatest: true, ModTime: mod},
|
|
}
|
|
|
|
wantErr := errors.New("dispatch boom")
|
|
rec := &recorder{err: wantErr}
|
|
cp, err := Walk(context.Background(), snap, "bk", EntryCallback(entries), rec, WalkOptions{Now: now})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("want dispatch error, got %v", err)
|
|
}
|
|
if cp.Completed {
|
|
t.Fatalf("walk should not be Completed on dispatch failure")
|
|
}
|
|
// Walker stops on first failure; checkpoint stays at whatever was
|
|
// recorded BEFORE the failed entry. Path "a" is the failing entry,
|
|
// so LastScannedPath stays at the resume point (empty here).
|
|
if cp.LastScannedPath != "" {
|
|
t.Fatalf("checkpoint should not advance past failing entry, got %q", cp.LastScannedPath)
|
|
}
|
|
}
|
|
|
|
func TestWalk_ResumeFromCheckpoint(t *testing.T) {
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
ExpirationDays: 1,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 10)
|
|
entries := []*Entry{
|
|
{Path: "a", IsLatest: true, ModTime: mod},
|
|
{Path: "b", IsLatest: true, ModTime: mod},
|
|
{Path: "c", IsLatest: true, ModTime: mod},
|
|
}
|
|
|
|
rec := &recorder{}
|
|
cp, err := Walk(context.Background(), snap, "bk", EntryCallback(entries), rec, WalkOptions{Now: now, Resume: "b"})
|
|
if err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if !cp.Completed {
|
|
t.Fatalf("walk should complete")
|
|
}
|
|
// Only "c" is processed (entries with Path <= "b" are skipped).
|
|
if len(rec.calls) != 1 || rec.calls[0].path != "c" {
|
|
t.Fatalf("Resume should only process c, got %v", rec.calls)
|
|
}
|
|
if cp.LastScannedPath != "c" {
|
|
t.Fatalf("checkpoint want c, got %q", cp.LastScannedPath)
|
|
}
|
|
}
|
|
|
|
func TestWalk_MPUInitDirMatchesByDestKey(t *testing.T) {
|
|
// Existing in-flight MPUs predate the meta-log subscription, so they
|
|
// only get cleaned up via the bootstrap walk. The init record is a
|
|
// directory whose path is .uploads/<id>; the rule's Filter.Prefix
|
|
// applies to the destination object key, not the upload directory.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r-mpu",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
Prefix: "logs/",
|
|
AbortMPUDaysAfterInitiation: 7,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 8) // past the 7d threshold
|
|
|
|
entries := []*Entry{
|
|
// Matches: dest key under logs/.
|
|
{Path: ".uploads/u-match", IsDirectory: true, IsMPUInit: true, DestKey: "logs/foo.txt", ModTime: mod},
|
|
// Filtered out: dest key under data/.
|
|
{Path: ".uploads/u-skip", IsDirectory: true, IsMPUInit: true, DestKey: "data/foo.txt", ModTime: mod},
|
|
// No DestKey: malformed init mid-write; skip rather than guess.
|
|
{Path: ".uploads/u-bare", IsDirectory: true, IsMPUInit: true, ModTime: mod},
|
|
}
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback(entries), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 1 {
|
|
t.Fatalf("expected 1 dispatch (u-match only), got %v", rec.calls)
|
|
}
|
|
if rec.calls[0].path != ".uploads/u-match" {
|
|
t.Fatalf("dispatch path=%q, want .uploads/u-match (the rm target)", rec.calls[0].path)
|
|
}
|
|
if rec.calls[0].kind != s3lifecycle.ActionKindAbortMPU {
|
|
t.Fatalf("dispatch kind=%v, want AbortMPU", rec.calls[0].kind)
|
|
}
|
|
}
|
|
|
|
func TestWalk_NonMPUDirectorySkipped(t *testing.T) {
|
|
// Non-MPU directories must still be skipped — the relaxed
|
|
// IsDirectory check is gated on IsMPUInit.
|
|
rule := &s3lifecycle.Rule{ID: "r", Status: s3lifecycle.StatusEnabled, ExpirationDays: 1}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 100)
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: "a/", IsDirectory: true, IsLatest: true, ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Fatalf("plain directory should not dispatch, got %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWalk_MPUInitDoesNotFireNoncurrent(t *testing.T) {
|
|
// Same rule covers both AbortMPU and NoncurrentVersionExpiration; the
|
|
// MPU init record must dispatch only the AbortMPU action. Without the
|
|
// engine guard, NONCURRENT_DAYS would fire (IsLatest=false) and the
|
|
// server would BLOCK on empty version_id, freezing the cursor.
|
|
rule := &s3lifecycle.Rule{
|
|
ID: "r",
|
|
Status: s3lifecycle.StatusEnabled,
|
|
AbortMPUDaysAfterInitiation: 7,
|
|
NoncurrentVersionExpirationDays: 7,
|
|
}
|
|
snap := compileEvDriven(t, "bk", rule)
|
|
mod := mustTime(t, "2024-01-01T00:00:00Z")
|
|
now := mod.AddDate(0, 0, 30)
|
|
|
|
rec := &recorder{}
|
|
if _, err := Walk(context.Background(), snap, "bk", EntryCallback([]*Entry{
|
|
{Path: ".uploads/u1", IsDirectory: true, IsMPUInit: true, DestKey: "obj/a", ModTime: mod},
|
|
}), rec, WalkOptions{Now: now}); err != nil {
|
|
t.Fatalf("Walk: %v", err)
|
|
}
|
|
if len(rec.calls) != 1 {
|
|
t.Fatalf("expected 1 dispatch (AbortMPU only), got %v", rec.calls)
|
|
}
|
|
if rec.calls[0].kind != s3lifecycle.ActionKindAbortMPU {
|
|
t.Fatalf("kind=%v, want AbortMPU", rec.calls[0].kind)
|
|
}
|
|
}
|