mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 00:09:00 +01:00
* s3 lifecycle: send the listed entry's identity on walker deletes The daily walker dispatched LifecycleDelete with no ExpectedIdentity, so the server skipped its compare-and-swap and an object rewritten between listing and dispatch was deleted. The list adapter now records each listed entry's identity, computed the same way as on the event path, and the walker sends it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: current-version expiration must target the latest version The walker addresses the latest version of a versioned key by id. A version file never changes, so its identity still matches after a newer PUT, and the delete went ahead: a delete marker over the new version with versioning enabled, or the new null version deleted while suspended. LifecycleDelete now resolves the key's latest version for EXPIRATION_DAYS/EXPIRATION_DATE requests that carry a version id and answers NOOP_RESOLVED (VERSION_NOT_LATEST) when it is a different one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: check the latest version for unaddressed suspended expirations With versioning suspended, an expiration without a version id reads the plain entry, which is the null version but not necessarily the latest: a newer real version can sit in .versions. The latest-version check now runs in the expiration branch, by versioning state: for every suspended request (an unaddressed one addresses the null version) and for enabled requests that carry a version id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: test that each version sibling carries its own identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.5 KiB
Go
201 lines
8.5 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/bootstrap"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// walkerStubClient captures the last LifecycleDeleteRequest so tests
|
|
// can assert on the request shape produced by WalkerDispatcher.
|
|
type walkerStubClient struct {
|
|
lastReq *s3_lifecycle_pb.LifecycleDeleteRequest
|
|
outcome s3_lifecycle_pb.LifecycleDeleteOutcome
|
|
err error
|
|
reason string
|
|
nilResp bool // return (nil, nil) — pin the dispatcher's defensive guard
|
|
}
|
|
|
|
func (c *walkerStubClient) LifecycleDelete(_ context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
c.lastReq = req
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
if c.nilResp {
|
|
return nil, nil
|
|
}
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{Outcome: c.outcome, Reason: c.reason}, nil
|
|
}
|
|
|
|
func sampleAction(t *testing.T, kind s3lifecycle.ActionKind) *engine.CompiledAction {
|
|
t.Helper()
|
|
var rh [8]byte
|
|
for i := range rh {
|
|
rh[i] = byte(0xa0 + i)
|
|
}
|
|
return &engine.CompiledAction{
|
|
Bucket: "bkt",
|
|
Key: s3lifecycle.ActionKey{Bucket: "bkt", ActionKind: kind, RuleHash: rh},
|
|
Mode: engine.ModeEventDriven,
|
|
}
|
|
}
|
|
|
|
func TestWalkerDispatcher_NonVersionedSendsExpectedRequest(t *testing.T) {
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
d := &WalkerDispatcher{Client: c}
|
|
a := sampleAction(t, s3lifecycle.ActionKindExpirationDays)
|
|
identity := &router.EntryIdentity{MtimeNs: 1_700_000_000_123, Size: 42, HeadFid: "3,01637037d6", ExtendedHash: []byte{0xab}}
|
|
err := d.Delete(context.Background(), a, &bootstrap.Entry{Path: "obj", Identity: identity})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, c.lastReq)
|
|
assert.Equal(t, "bkt", c.lastReq.Bucket)
|
|
assert.Equal(t, "obj", c.lastReq.ObjectPath)
|
|
assert.Equal(t, "", c.lastReq.VersionId)
|
|
assert.Equal(t, a.Key.RuleHash[:], c.lastReq.RuleHash)
|
|
assert.Equal(t, s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS, c.lastReq.ActionKind)
|
|
require.NotNil(t, c.lastReq.ExpectedIdentity, "the listed entry's identity is the CAS witness")
|
|
assert.Equal(t, identity.MtimeNs, c.lastReq.ExpectedIdentity.MtimeNs)
|
|
assert.Equal(t, identity.Size, c.lastReq.ExpectedIdentity.Size)
|
|
assert.Equal(t, identity.HeadFid, c.lastReq.ExpectedIdentity.HeadFid)
|
|
assert.Equal(t, identity.ExtendedHash, c.lastReq.ExpectedIdentity.ExtendedHash)
|
|
}
|
|
|
|
func TestWalkerDispatcher_VersionedPassesVersionID(t *testing.T) {
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
d := &WalkerDispatcher{Client: c}
|
|
a := sampleAction(t, s3lifecycle.ActionKindNoncurrentDays)
|
|
err := d.Delete(context.Background(), a, &bootstrap.Entry{Path: "obj", VersionID: "v-abc"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "v-abc", c.lastReq.VersionId)
|
|
}
|
|
|
|
func TestWalkerDispatcher_MPUInitUsesUploadsPath(t *testing.T) {
|
|
// Rule-prefix matching uses DestKey to decide IF this MPU init
|
|
// matches; dispatch uses Path (.uploads/<id>) because the server's
|
|
// ABORT_MPU handler strips the .uploads/ prefix to get the upload
|
|
// id. Sending DestKey here would BLOCK with FATAL_EVENT_ERROR.
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
d := &WalkerDispatcher{Client: c}
|
|
a := sampleAction(t, s3lifecycle.ActionKindAbortMPU)
|
|
err := d.Delete(context.Background(), a, &bootstrap.Entry{
|
|
Path: ".uploads/abc123",
|
|
DestKey: "user/path/object",
|
|
IsMPUInit: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ".uploads/abc123", c.lastReq.ObjectPath)
|
|
}
|
|
|
|
func TestWalkerDispatcher_MPUInitEmptyDestKeyErrors(t *testing.T) {
|
|
// An MPU init record with no DestKey is mid-write before metadata
|
|
// landed; skipping silently in the walker is fine, but the
|
|
// dispatcher must not invent a path.
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
d := &WalkerDispatcher{Client: c}
|
|
a := sampleAction(t, s3lifecycle.ActionKindAbortMPU)
|
|
err := d.Delete(context.Background(), a, &bootstrap.Entry{
|
|
Path: ".uploads/abc123",
|
|
IsMPUInit: true,
|
|
})
|
|
require.Error(t, err)
|
|
assert.Nil(t, c.lastReq, "no RPC should be sent when DestKey is empty")
|
|
}
|
|
|
|
func TestWalkerDispatcher_AcceptsAllResolvedOutcomes(t *testing.T) {
|
|
for _, oc := range []s3_lifecycle_pb.LifecycleDeleteOutcome{
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_SKIPPED_OBJECT_LOCK,
|
|
} {
|
|
c := &walkerStubClient{outcome: oc}
|
|
d := &WalkerDispatcher{Client: c}
|
|
err := d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
assert.NoError(t, err, "outcome %s must be treated as resolved", oc)
|
|
}
|
|
}
|
|
|
|
func TestWalkerDispatcher_UnresolvedOutcomeReturnsError(t *testing.T) {
|
|
// RETRY_LATER, BLOCKED, and UNSPECIFIED all halt the walk so it
|
|
// resumes from Checkpoint.LastScannedPath on the next run.
|
|
for _, oc := range []s3_lifecycle_pb.LifecycleDeleteOutcome{
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_RETRY_LATER,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_LIFECYCLE_DELETE_OUTCOME_UNSPECIFIED,
|
|
} {
|
|
c := &walkerStubClient{outcome: oc, reason: "server said so"}
|
|
d := &WalkerDispatcher{Client: c}
|
|
err := d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
require.Error(t, err, "outcome %s must halt the walk", oc)
|
|
assert.Contains(t, err.Error(), oc.String())
|
|
}
|
|
}
|
|
|
|
func TestWalkerDispatcher_TransportErrorReturnsWrappedError(t *testing.T) {
|
|
c := &walkerStubClient{err: errors.New("transport boom")}
|
|
d := &WalkerDispatcher{Client: c}
|
|
err := d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "transport boom")
|
|
}
|
|
|
|
func TestWalkerDispatcher_NilResponseReturnsError(t *testing.T) {
|
|
// A server returning (nil, nil) would otherwise panic on the
|
|
// outcome switch.
|
|
c := &walkerStubClient{nilResp: true}
|
|
d := &WalkerDispatcher{Client: c}
|
|
err := d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "nil response")
|
|
}
|
|
|
|
func TestWalkerDispatcher_LimiterWaitsBeforeDispatch(t *testing.T) {
|
|
// Build a tiny limiter (1 token, slow refill) and pre-drain it so
|
|
// the next Wait blocks until the deadline. Dispatcher must respect
|
|
// the limiter — without the wait the test passes trivially.
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
lim := rate.NewLimiter(rate.Every(50*time.Millisecond), 1)
|
|
_ = lim.AllowN(time.Now(), 1) // burn the burst token
|
|
d := &WalkerDispatcher{Client: c, Limiter: lim}
|
|
|
|
start := time.Now()
|
|
err := d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
elapsed := time.Since(start)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, elapsed, 30*time.Millisecond,
|
|
"limiter must throttle the dispatch; elapsed=%v", elapsed)
|
|
}
|
|
|
|
func TestWalkerDispatcher_LimiterContextCancelHaltsWalker(t *testing.T) {
|
|
// Pre-drained limiter + canceled ctx. Limiter.Wait returns the
|
|
// cancel error; walker must surface it (not silently dispatch).
|
|
c := &walkerStubClient{outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
lim := rate.NewLimiter(rate.Every(time.Hour), 1)
|
|
_ = lim.AllowN(time.Now(), 1)
|
|
d := &WalkerDispatcher{Client: c, Limiter: lim}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err := d.Delete(ctx, sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"})
|
|
require.Error(t, err)
|
|
assert.Nil(t, c.lastReq, "no RPC should be sent when ctx was cancelled in Wait")
|
|
}
|
|
|
|
func TestWalkerDispatcher_NilGuardsReturnError(t *testing.T) {
|
|
d := &WalkerDispatcher{Client: &walkerStubClient{}}
|
|
require.Error(t, d.Delete(context.Background(), nil, &bootstrap.Entry{Path: "obj"}))
|
|
require.Error(t, d.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), nil))
|
|
|
|
nilClient := &WalkerDispatcher{}
|
|
require.Error(t, nilClient.Delete(context.Background(), sampleAction(t, s3lifecycle.ActionKindExpirationDays), &bootstrap.Entry{Path: "obj"}))
|
|
}
|