fix(s3/lifecycle): WalkerDispatcher uses entry.Path for ABORT_MPU + shell announces load

Two CI-surfaced bugs caught by PR #9471's S3 Lifecycle Tests run on
master after PRs #9475 + #9466:

1. Walker dispatch for ABORT_MPU was sending entry.DestKey as
   req.ObjectPath. The server's ABORT_MPU handler
   (weed/s3api/s3api_internal_lifecycle.go) strips the .uploads/
   prefix to extract the upload id and reads the init record from
   that directory, so it expects the .uploads/<id> path verbatim.
   DestKey looks like a regular object path; the server's prefix
   check fails and the dispatch returns BLOCKED with
   "FATAL_EVENT_ERROR: ABORT_MPU object_path missing .uploads/
   prefix". The test fix renames TestWalkerDispatcher_MPUInitUsesDestKey
   to ...UsesUploadsPath and inverts the assertion to match the
   actual server contract.

   DestKey is still used for the WalkBuckets shard predicate and
   for rule-prefix matching in bootstrap.walker; both surfaces want
   the user's intended path, while DISPATCH wants the .uploads/<id>
   directory. The bootstrap test
   (TestLifecycleAbortIncompleteMultipartUpload) caught this when
   the walker's BLOCKED error surfaced as FATAL output.

2. test/s3/lifecycle/s3_lifecycle_empty_bucket_test.go asserts the
   shell command logs "loaded lifecycle for N bucket(s)" so a
   regression that produces half-shaped output (no load summary)
   is caught. The restored shell command (PR #9475) didn't print
   that line; add it back on the first pass that finds non-zero
   inputs.
This commit is contained in:
Chris Lu
2026-05-12 20:55:34 -07:00
parent 7b8647e8bc
commit 0663a41bd5
3 changed files with 20 additions and 11 deletions
@@ -35,16 +35,18 @@ func (d *WalkerDispatcher) Delete(ctx context.Context, action *engine.CompiledAc
}
objectPath := entry.Path
if entry.IsMPUInit {
// Rule-prefix matching used DestKey; the server takes the
// canonical object path for the LifecycleDelete RPC, which
// is also DestKey. The walker hits the .uploads/<id>
// directory itself only when ActionKind=ABORT_MPU, and the
// server resolves the upload from (bucket, object_path) +
// the init record's metadata.
// Rule-prefix matching uses DestKey (the user's intended
// object key); dispatch uses entry.Path (.uploads/<id>),
// which is what the server's ABORT_MPU handler expects in
// req.ObjectPath — it strips the .uploads/ prefix to get
// the upload id and reads the init record from that
// directory. DestKey is the dispatch ANTI-pattern here: it
// looks like a regular object path, the server's check for
// the .uploads/ prefix fails, and the dispatch comes back
// as BLOCKED FATAL_EVENT_ERROR.
if entry.DestKey == "" {
return fmt.Errorf("walker dispatch: MPU init entry with empty DestKey: %s", entry.Path)
}
objectPath = entry.DestKey
}
rh := action.Key.RuleHash
req := &s3_lifecycle_pb.LifecycleDeleteRequest{
@@ -72,9 +72,11 @@ func TestWalkerDispatcher_VersionedPassesVersionID(t *testing.T) {
assert.Equal(t, "v-abc", c.lastReq.VersionId)
}
func TestWalkerDispatcher_MPUInitUsesDestKey(t *testing.T) {
// Rule-prefix matching used DestKey; the RPC ObjectPath must match
// so the server resolves the upload from the user's intended key.
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)
@@ -84,7 +86,7 @@ func TestWalkerDispatcher_MPUInitUsesDestKey(t *testing.T) {
IsMPUInit: true,
})
require.NoError(t, err)
assert.Equal(t, "user/path/object", c.lastReq.ObjectPath)
assert.Equal(t, ".uploads/abc123", c.lastReq.ObjectPath)
}
func TestWalkerDispatcher_MPUInitEmptyDestKeyErrors(t *testing.T) {
@@ -122,6 +122,7 @@ func (c *commandS3LifecycleRunShard) Do(args []string, env *CommandEnv, writer i
fmt.Fprintf(writer, "running shards %s (event budget=%d, runtime=%s, refresh=%s)…\n",
formatShardLabel(shards), *eventBudget, *runtime, *cadence)
announcedLoad := false
runPass := func() error {
eng := engine.New()
inputs, parseErrors, err := scheduler.LoadCompileInputs(ctx, filerClient, bucketsPath)
@@ -140,6 +141,10 @@ func (c *commandS3LifecycleRunShard) Do(args []string, env *CommandEnv, writer i
if len(inputs) == 0 {
return nil
}
if !announcedLoad {
fmt.Fprintf(writer, "loaded lifecycle for %d bucket(s)\n", len(inputs))
announcedLoad = true
}
buckets := make([]string, 0, len(inputs))
for _, in := range inputs {
if in.Bucket != "" {