diff --git a/weed/s3api/s3lifecycle/dailyrun/run.go b/weed/s3api/s3lifecycle/dailyrun/run.go index 56417be2e..d334fc8fc 100644 --- a/weed/s3api/s3lifecycle/dailyrun/run.go +++ b/weed/s3api/s3lifecycle/dailyrun/run.go @@ -172,11 +172,16 @@ func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow tim // one-time rewind to runNow - maxTTL, self-healing on save. rsh := engine.ReplayContentHash(snap) maxTTL := engine.MaxEffectiveTTL(snap) - // Operator-supplied retention falls back to maxTTL, which forces - // promoted-empty (no rule's TTL can exceed the max). Once the - // walker handles walk-bound rules, the handler will pass the real - // meta-log retention here and PromotedHash starts catching - // partition flips. + // Operator-supplied retention falls back to maxTTL. In steady + // state every active replay rule has TTL <= maxTTL by construction, + // so promoted is empty and the partition-flip trigger is dormant. + // During bootstrap (rules compiled but not yet active) maxTTL is + // 0, retentionWindow is 0, and every rule with TTL > 0 lands in + // the walk partition; the resulting non-empty promoted forces a + // recovery walk on the first run after rules activate, which is + // the intended bootstrap behavior. Once the handler plumbs the + // real meta-log retention here, PromotedHash starts catching + // retention-driven partition flips in addition. retentionWindow := cfg.RetentionWindow if retentionWindow <= 0 { retentionWindow = maxTTL diff --git a/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher.go b/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher.go index fa3cf48c3..77d8fd0fc 100644 --- a/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher.go +++ b/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher.go @@ -59,6 +59,12 @@ func (d *WalkerDispatcher) Delete(ctx context.Context, action *engine.CompiledAc if err != nil { return fmt.Errorf("walker dispatch %s/%s %s: %w", action.Bucket, objectPath, action.Key.ActionKind, err) } + if resp == nil { + // A misbehaving server stub returning (nil, nil) would panic on + // the switch below. Surface as an error so the walk halts at + // this entry, preserving the in-flight cursor's correctness. + return fmt.Errorf("walker dispatch %s/%s %s: nil response", action.Bucket, objectPath, action.Key.ActionKind) + } switch resp.Outcome { case s3_lifecycle_pb.LifecycleDeleteOutcome_DONE, s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED, diff --git a/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher_test.go b/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher_test.go index 808912b3c..a826ee51b 100644 --- a/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher_test.go +++ b/weed/s3api/s3lifecycle/dailyrun/walker_dispatcher_test.go @@ -20,6 +20,7 @@ type walkerStubClient struct { 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) { @@ -27,6 +28,9 @@ func (c *walkerStubClient) LifecycleDelete(_ context.Context, req *s3_lifecycle_ 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 } @@ -135,6 +139,16 @@ func TestWalkerDispatcher_TransportErrorReturnsWrappedError(t *testing.T) { 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_NilGuardsReturnError(t *testing.T) { d := &WalkerDispatcher{Client: &walkerStubClient{}} require.Error(t, d.Delete(context.Background(), nil, &bootstrap.Entry{Path: "obj"}))