Files
seaweedfs/weed/s3api/s3lifecycle/bootstrap/walker.go
T
Chris LuandGitHub 4f79d8e358 feat(s3/lifecycle): bucket-level bootstrap walker (#9350)
* feat(worker): add TaskTypeS3Lifecycle constant

Single job type for the lifecycle worker; the S3LifecycleParams.Subtype
field (READ / BOOTSTRAP / DRAIN) dispatches inside the handler. The
"s3_lifecycle" string is already wired to LaneLifecycle in
admin/plugin/scheduler_lane.go so adding the constant doesn't change
runtime behavior — it lets future commits reference the type name
without sprinkling string literals.

* feat(s3/lifecycle): bucket-level bootstrap walker

Iterates entries in a bucket, evaluates every active ActionKey in the
engine snapshot against each entry, and dispatches inline-delete for
currently-due actions. Date-kind actions and pending_bootstrap actions
are skipped — the former are handled by their own SCAN_AT_DATE
bootstrap, the latter aren't IsActive() yet.

Walker is callback-driven so callers supply the listing source
(real filer_pb.SeaweedList or test fake) and the dispatcher (real
LifecycleDelete client or test fake). This keeps the walker free of
filer_pb dependencies and makes the per-action evaluation flow
unit-testable in isolation.

Checkpoint state (LastScannedPath, Completed) is returned to the
caller, who is responsible for persisting it under
/etc/s3/lifecycle/<bucket>/_bootstrap. Walk() honours opts.Resume so
a kill-resumed task picks up where the previous walker stopped.

Tests cover: prefix-mismatched skip, not-yet-due skip (reader's job),
date-kind skip, pending_bootstrap skip, multi-action rule (one rule
with three actions dispatches three times — the regression that
per-action keying fixes), dispatch error halts at last-successful
checkpoint, Resume skips entries up to and including the resume
path.

* test(s3/lifecycle): walker test uses bucket-scoped ActionKey

Mechanical follow-up to the bucket-scoped ActionKey on
lifecycle-engine: the bootstrap walker tests construct ActionKeys to
seed PriorStates and need the Bucket field to match what
engine.Compile keys against.

* fix(s3/lifecycle): walker quick wins

Two minor cleanups noted on review:

- Drop the redundant Resume re-filter inside the Walk callback.
  ListFunc's contract already promises "skip entries with Path <=
  start"; trusting that contract avoids divergence if the filter
  logic ever changes on one side and not the other.

- Hoist the ObjectInfo allocation out of the per-action loop in
  walkEntry. Multi-action rules previously allocated one ObjectInfo
  per (entry, kind) pair; now it's one per entry, reused across all
  matching kinds.

* fix(s3/lifecycle): walker Entry.NoncurrentIndex tracks ObjectInfo's *int

ObjectInfo.NoncurrentIndex is now *int so unset is unambiguous;
mirror that on bootstrap.Entry so the per-entry construction stays
type-clean. Phase 5 (versioned-bucket walks) is the first caller
that will populate the field.

* refactor(s3/lifecycle): trim narration from bootstrap walker

Drop the inline step-by-step on Walk and the multi-paragraph package
preamble; the function names already say it. Keep one-liner WHYs at
the SCAN_AT_DATE skip and the once-per-entry ObjectInfo build.

* fix(s3/lifecycle): walker skips directories and ModeDisabled actions

Two safety findings from review:

1. SeaweedFS directory entries can appear in the listing alongside
   objects; without an IsDirectory check the walker would treat a dir
   like any other entry and could dispatch a delete against it. Add
   IsDirectory to bootstrap.Entry and short-circuit it before
   walkEntry.

2. ModeDisabled is set by the operator (e.g. shell pause) independent
   of the XML rule's Status field. EvaluateAction gates on Status and
   would still fire for an operator-disabled action whose XML status
   is "Enabled". Skip ModeDisabled explicitly in walkEntry alongside
   the existing SCAN_AT_DATE skip.

Two regression tests pin both cases.

* perf(s3/lifecycle): reuse ObjectInfo across walker entries

Walker allocated one ObjectInfo struct per entry. For buckets with
millions of objects that's measurable GC pressure. Hoist the
allocation out of the per-entry callback (one per Walk) and reuse
via field assignment in walkEntry.

EvaluateAction reads ObjectInfo synchronously and doesn't retain a
reference, so the reuse is safe — the next iteration's overwrite
can't corrupt an in-flight evaluation.

* refactor(s3/lifecycle): trim narration on walker

Drop the multi-line Entry / ObjectInfo-reuse / SCAN_AT_DATE+DISABLED
explanations. The walker's structure is small enough that the
condition itself reads as the documentation.
2026-05-07 15:04:51 -07:00

154 lines
4.7 KiB
Go

// Package bootstrap is the bucket-level lifecycle walker. The walker
// iterates every entry under a bucket, evaluates every active ActionKey
// against it, and dispatches inline-delete RPCs for currently-due actions;
// not-yet-due entries are left for the meta-log reader to pick up later.
//
// Callback-driven so the listing source and the LifecycleDelete dispatcher
// can be supplied separately (real client or test fake).
package bootstrap
import (
"context"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
)
// Entry is the routing-relevant slice of a filer entry. SuccessorModTime
// and NoncurrentIndex are populated only on versioned-bucket walks; the
// retention path bails out conservatively when they're zero / nil.
type Entry struct {
Path string
ModTime time.Time
Size int64
IsDirectory bool
IsLatest bool
IsDeleteMarker bool
IsMPUInit bool
NumVersions int
Tags map[string]string
SuccessorModTime time.Time
NoncurrentIndex *int
}
// ListFunc must skip entries with Path <= start so kill-resume picks up
// where the previous run stopped.
type ListFunc func(ctx context.Context, bucket, start string, cb func(*Entry) error) error
// Dispatcher executes one (action, entry) verdict. An error halts the walk;
// the caller decides whether to retry from the recorded last_scanned_path.
type Dispatcher interface {
Delete(ctx context.Context, action *engine.CompiledAction, entry *Entry) error
}
// Checkpoint is the resume state. Caller persists it under
// /etc/s3/lifecycle/<bucket>/_bootstrap.
type Checkpoint struct {
LastScannedPath string
Completed bool
}
type WalkOptions struct {
Resume string
Now time.Time
}
// Walk iterates entries via list, evaluates each active ActionKey via
// MatchPath + EvaluateAction, and calls Dispatcher.Delete for currently-due
// actions. SCAN_AT_DATE actions are skipped (their bootstrap is scheduled
// separately).
func Walk(ctx context.Context, snap *engine.Snapshot, bucket string, list ListFunc, dispatch Dispatcher, opts WalkOptions) (Checkpoint, error) {
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
cp := Checkpoint{LastScannedPath: opts.Resume}
// Reuse one ObjectInfo across the walk; EvaluateAction reads it
// synchronously without retaining.
var info s3lifecycle.ObjectInfo
err := list(ctx, bucket, opts.Resume, func(entry *Entry) error {
if entry == nil || entry.Path == "" {
return nil
}
// Lifecycle never applies to directory entries.
if entry.IsDirectory {
cp.LastScannedPath = entry.Path
return nil
}
if err := walkEntry(ctx, snap, bucket, entry, dispatch, now, &info); err != nil {
return err
}
cp.LastScannedPath = entry.Path
return nil
})
if err != nil {
return cp, err
}
cp.Completed = true
return cp, nil
}
func walkEntry(ctx context.Context, snap *engine.Snapshot, bucket string, entry *Entry, dispatch Dispatcher, now time.Time, info *s3lifecycle.ObjectInfo) error {
keys := snap.MatchPath(bucket, entry.Path, nil)
if len(keys) == 0 {
return nil
}
*info = s3lifecycle.ObjectInfo{
Key: entry.Path,
ModTime: entry.ModTime,
Size: entry.Size,
IsLatest: entry.IsLatest,
IsDeleteMarker: entry.IsDeleteMarker,
IsMPUInit: entry.IsMPUInit,
NumVersions: entry.NumVersions,
SuccessorModTime: entry.SuccessorModTime,
NoncurrentIndex: entry.NoncurrentIndex,
Tags: entry.Tags,
}
for _, key := range keys {
action := snap.Action(key)
if action == nil {
continue
}
// SCAN_AT_DATE runs its own date-triggered bootstrap. DISABLED can
// be flipped at runtime independent of XML Status, so skip it even
// though EvaluateAction would also reject.
if action.Mode == engine.ModeScanAtDate || action.Mode == engine.ModeDisabled {
continue
}
res := s3lifecycle.EvaluateAction(action.Rule, key.ActionKind, info, now)
if res.Action == s3lifecycle.ActionNone {
continue
}
if err := dispatch.Delete(ctx, action, entry); err != nil {
glog.Warningf("lifecycle bootstrap: dispatch %s/%s kind=%s: %v",
bucket, entry.Path, key.ActionKind, err)
return err
}
}
return nil
}
// EntryCallback wraps an in-memory slice as a ListFunc; useful for tests.
func EntryCallback(entries []*Entry) ListFunc {
return func(ctx context.Context, bucket, start string, cb func(*Entry) error) error {
for _, e := range entries {
if start != "" && e.Path <= start {
continue
}
if err := cb(e); err != nil {
return err
}
}
return nil
}
}
func HasPrefix(path, prefix string) bool { return strings.HasPrefix(path, prefix) }