mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 08:18:42 +01:00
* s3 lifecycle: collect per-pass bucket results and work counters WalkBuckets kept only the first bucket error and logged the rest. A RunStats shared by the walker, the walker dispatcher and the replay drain now records every bucket's walk result (walked, failed, skipped on cancellation), entry and object visits, replay events, and LifecycleDelete outcomes under the dispatch_total outcome labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: persist the last fully successful pass Shard cursors and walk timestamps advance on partial passes as well, so neither says when lifecycle last covered every policy. Keep that in its own record next to the cursors, plus a worker gauge for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: report each pass's result in the job record The job ended with a fixed success summary, or with only the first error, and malformed lifecycle policies were V(1) logs. The completion now carries the pass status (complete, partial, failed, canceled), bucket totals and failures, invalid policies, entry, object and event counts, LifecycleDelete outcomes, the last phase and bucket, the elapsed time and the last full pass, on failed passes as well. Only a complete pass advances the full-pass record; a malformed policy makes the pass partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep a failed job's result in its record Completions with Success=false dropped Result.Summary and Result.OutputValues, so a handler could not report what a failed run had done, which buckets failed, or how far it got. Keep them; the error message still drives the job's message and run record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * grafana: chart the time since the last full lifecycle pass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: a replay halted on an unresolved delete is a partial pass BLOCKED, RETRY_LATER and exhausted transport retries stop a shard's replay without an error, leaving its due events for a later pass. Count those shards and report the pass as partial, so it no longer advances the full-pass record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: a pass with throttled walks is not a full pass WalkerInterval can skip the walk for walk-only rules (ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent) with no error. Count those shards and keep the full-pass record for passes that evaluated every rule; the job reports full_pass and walks_deferred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: keep the saved full-pass time when the save fails Publishing a timestamp that never reached the filer let the gauge move back on the next job's reload. Keep the previous value and report the save error in the job result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: drop a failed attempt's summary when the retry reports none Retries reuse the job record, and failed completions now keep their result summary, so a retry that completes without a Result showed the failed attempt's summary, and a successful one used it as its message. Clear the summary when a completion carries no Result, as the output values already are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
11 KiB
Go
342 lines
11 KiB
Go
package shell
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/credential"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
|
|
)
|
|
|
|
func init() {
|
|
Commands = append(Commands, &commandS3LifecycleRunShard{})
|
|
}
|
|
|
|
type commandS3LifecycleRunShard struct{}
|
|
|
|
func (c *commandS3LifecycleRunShard) Name() string {
|
|
return "s3.lifecycle.run-shard"
|
|
}
|
|
|
|
func (c *commandS3LifecycleRunShard) Help() string {
|
|
return `manually run one daily-replay pass for the given shards
|
|
|
|
Drives dailyrun.Run once against the live filer + S3 server: builds
|
|
the engine snapshot from filer-backed bucket configs, opens the
|
|
meta-log subscription per shard, dispatches due actions via
|
|
LifecycleDelete, and walks the live tree for any walker-bound rules.
|
|
Persists each shard's cursor to /etc/s3/lifecycle/daily-cursors/
|
|
so subsequent runs resume.
|
|
|
|
Used by the s3-tests CI workflow and the test/s3/lifecycle/
|
|
integration tests to drive expirations on demand without standing up
|
|
the full admin+worker plugin stack.
|
|
|
|
# single shard
|
|
s3.lifecycle.run-shard -shard 0 -s3 localhost:8333 -events 100
|
|
|
|
# contiguous range
|
|
s3.lifecycle.run-shard -shards 0-15 -s3 localhost:8333 -events 5000
|
|
|
|
# explicit set
|
|
s3.lifecycle.run-shard -shards 0,3,7 -s3 localhost:8333
|
|
|
|
# bounded wall-clock
|
|
s3.lifecycle.run-shard -shards 0-15 -s3 localhost:8333 -runtime 10s
|
|
`
|
|
}
|
|
|
|
func (c *commandS3LifecycleRunShard) HasTag(CommandTag) bool { return false }
|
|
|
|
func (c *commandS3LifecycleRunShard) Do(args []string, env *CommandEnv, writer io.Writer) error {
|
|
fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
|
|
shard := fs.Int("shard", -1, "single shard id in [0, 16); use -shards for a range or set")
|
|
shardsSpec := fs.String("shards", "", "shard range \"lo-hi\" or comma list \"a,b,c\"; mutually exclusive with -shard")
|
|
s3Endpoint := fs.String("s3", "", "s3 server gRPC endpoint, host:port")
|
|
eventBudget := fs.Int("events", 1000, "max in-shard events per pass (0 = drain to now)")
|
|
runtime := fs.Duration("runtime", 0, "wall-clock cap on the whole run; 0 = no timeout")
|
|
// -refresh drives the inter-pass cadence when the command runs as a
|
|
// long-lived worker (the s3tests CI workflow case): every refresh
|
|
// the engine snapshot is re-loaded and dailyrun.Run fires another
|
|
// pass. 0 means "run once and exit" (the integration-test case).
|
|
cadence := fs.Duration("refresh", 0, "inter-pass interval; 0 = single pass, then exit")
|
|
// Obsolete flags kept for back-compat with existing CI scripts and
|
|
// integration tests. Accept and ignore.
|
|
_ = fs.Duration("dispatch", 0, "ignored (legacy streaming flag)")
|
|
_ = fs.Duration("checkpoint", 0, "ignored (legacy streaming flag)")
|
|
_ = fs.Duration("bootstrap-interval", 0, "ignored (legacy streaming flag)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
shards, err := resolveShardSelection(*shard, *shardsSpec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *s3Endpoint == "" {
|
|
return fmt.Errorf("-s3 required (host:port of s3 server gRPC)")
|
|
}
|
|
if *eventBudget < 0 {
|
|
return fmt.Errorf("-events must be >= 0 (0 = unbounded)")
|
|
}
|
|
|
|
bucketsPath, err := resolveBucketsPath(env)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve buckets path: %w", err)
|
|
}
|
|
fmt.Fprintf(writer, "buckets path: %s\n", bucketsPath)
|
|
|
|
dialCtx, dialCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
conn, err := pb.GrpcDial(dialCtx, *s3Endpoint, false, env.option.GrpcDialOption)
|
|
dialCancel()
|
|
if err != nil {
|
|
return fmt.Errorf("dial s3 %s: %w", *s3Endpoint, err)
|
|
}
|
|
defer conn.Close()
|
|
rpcClient := s3_lifecycle_pb.NewSeaweedS3LifecycleInternalClient(conn)
|
|
|
|
return env.WithFilerClient(true, func(filerClient filer_pb.SeaweedFilerClient) error {
|
|
ctx := context.Background()
|
|
var cancel context.CancelFunc
|
|
if *runtime > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, *runtime)
|
|
defer cancel()
|
|
}
|
|
client := &lifecycleClientCallable{c: rpcClient}
|
|
walkerDispatch := &dailyrun.WalkerDispatcher{Client: client}
|
|
|
|
fmt.Fprintf(writer, "running shards %s (event budget=%d, runtime=%s, refresh=%s)…\n",
|
|
formatShardLabel(shards), *eventBudget, *runtime, *cadence)
|
|
|
|
announcedLoad := false
|
|
runPass := func() error {
|
|
// When -refresh is set we MUST cap each pass; otherwise
|
|
// drainShardEvents blocks until the outer runtime ctx
|
|
// expires and the loop's ticker never fires. With cadence=0
|
|
// (one-shot) the pass uses the full ctx and returns when
|
|
// the runtime cap hits.
|
|
passCtx := ctx
|
|
if *cadence > 0 {
|
|
var passCancel context.CancelFunc
|
|
// Pass budget = cadence + grace. Grace gives the
|
|
// drain time to actually process events that arrived
|
|
// during the cadence window.
|
|
passCtx, passCancel = context.WithTimeout(ctx, *cadence+5*time.Second)
|
|
defer passCancel()
|
|
}
|
|
eng := engine.New()
|
|
inputs, parseErrors, err := scheduler.LoadCompileInputs(passCtx, filerClient, bucketsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load lifecycle configs: %w", err)
|
|
}
|
|
for i, pe := range parseErrors {
|
|
if i < 3 {
|
|
fmt.Fprintf(writer, "warning: %s: %v\n", pe.Bucket, pe.Err)
|
|
}
|
|
}
|
|
if extra := len(parseErrors) - 3; extra > 0 {
|
|
fmt.Fprintf(writer, "warning: %d additional bucket(s) had malformed lifecycle config\n", extra)
|
|
}
|
|
eng.Compile(inputs, engine.CompileOptions{PriorStates: scheduler.AllActivePriorStates(inputs)})
|
|
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 != "" {
|
|
buckets = append(buckets, in.Bucket)
|
|
}
|
|
}
|
|
walker := dailyrun.WalkerFunc(func(walkCtx context.Context, view *engine.Snapshot, shards []int) error {
|
|
return dailyrun.WalkBuckets(walkCtx, view, shards, buckets,
|
|
dailyrun.FilerListFunc(filerClient, bucketsPath, view), walkerDispatch, nil)
|
|
})
|
|
return dailyrun.Run(passCtx, dailyrun.Config{
|
|
Shards: shards,
|
|
BucketsPath: bucketsPath,
|
|
Engine: eng,
|
|
FilerClient: filerClient,
|
|
Client: client,
|
|
Persister: &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
|
|
Lister: dispatcher.NewFilerSiblingLister(filerClient, bucketsPath),
|
|
// The shell command is used for bounded one-shot sweeps in
|
|
// integration tests and CI. Fan out across the selected shards
|
|
// so recovery walks do not serialize 16 shard scans into a 10s
|
|
// timeout budget.
|
|
Workers: len(shards),
|
|
Walker: walker,
|
|
EventBudget: *eventBudget,
|
|
ClientName: fmt.Sprintf("shell-lifecycle-%s", formatShardLabel(shards)),
|
|
})
|
|
}
|
|
|
|
if err := runPass(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
return fmt.Errorf("daily_run: %w", err)
|
|
}
|
|
// cadence=0 → one-shot (test/s3/lifecycle/ uses this).
|
|
// cadence>0 → loop until ctx done (s3tests CI workflow uses this).
|
|
if *cadence > 0 {
|
|
ticker := time.NewTicker(*cadence)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Fprintf(writer, "shards %s complete; ctx done\n", formatShardLabel(shards))
|
|
return nil
|
|
case <-ticker.C:
|
|
if err := runPass(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
fmt.Fprintf(writer, "shards %s pass error: %v\n", formatShardLabel(shards), err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fmt.Fprintf(writer, "shards %s complete; cursors checkpointed\n", formatShardLabel(shards))
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// resolveShardSelection turns the -shard / -shards flags into a sorted,
|
|
// deduplicated []int. Exactly one form must be specified.
|
|
func resolveShardSelection(singleShard int, shardsSpec string) ([]int, error) {
|
|
if singleShard >= 0 && shardsSpec != "" {
|
|
return nil, fmt.Errorf("-shard and -shards are mutually exclusive")
|
|
}
|
|
if singleShard < 0 && shardsSpec == "" {
|
|
return nil, fmt.Errorf("specify -shard <id> or -shards <range|set>")
|
|
}
|
|
if singleShard >= 0 {
|
|
if singleShard >= s3lifecycle.ShardCount {
|
|
return nil, fmt.Errorf("-shard %d out of [0,%d)", singleShard, s3lifecycle.ShardCount)
|
|
}
|
|
return []int{singleShard}, nil
|
|
}
|
|
return parseShardsSpec(shardsSpec)
|
|
}
|
|
|
|
// parseShardsSpec accepts "lo-hi" (inclusive) or "a,b,c" and returns a
|
|
// sorted, deduplicated, in-range []int.
|
|
func parseShardsSpec(spec string) ([]int, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
seen := map[int]struct{}{}
|
|
add := func(v int) error {
|
|
if v < 0 || v >= s3lifecycle.ShardCount {
|
|
return fmt.Errorf("shard %d out of [0,%d)", v, s3lifecycle.ShardCount)
|
|
}
|
|
seen[v] = struct{}{}
|
|
return nil
|
|
}
|
|
if strings.Contains(spec, "-") && !strings.Contains(spec, ",") {
|
|
parts := strings.SplitN(spec, "-", 2)
|
|
lo, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("range lo: %w", err)
|
|
}
|
|
hi, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("range hi: %w", err)
|
|
}
|
|
if lo > hi {
|
|
return nil, fmt.Errorf("range lo %d > hi %d", lo, hi)
|
|
}
|
|
for v := lo; v <= hi; v++ {
|
|
if err := add(v); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
for _, part := range strings.Split(spec, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
v, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("shard list: %w", err)
|
|
}
|
|
if err := add(v); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if len(seen) == 0 {
|
|
return nil, fmt.Errorf("empty shard set")
|
|
}
|
|
out := make([]int, 0, len(seen))
|
|
for v := range seen {
|
|
out = append(out, v)
|
|
}
|
|
sort.Ints(out)
|
|
return out, nil
|
|
}
|
|
|
|
func formatShardLabel(shards []int) string {
|
|
if len(shards) == 1 {
|
|
return fmt.Sprintf("%d", shards[0])
|
|
}
|
|
contiguous := true
|
|
for i := 1; i < len(shards); i++ {
|
|
if shards[i] != shards[i-1]+1 {
|
|
contiguous = false
|
|
break
|
|
}
|
|
}
|
|
if contiguous {
|
|
return fmt.Sprintf("%d-%d", shards[0], shards[len(shards)-1])
|
|
}
|
|
parts := make([]string, len(shards))
|
|
for i, v := range shards {
|
|
parts[i] = strconv.Itoa(v)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// lifecycleClientCallable adapts the generated grpc client (variadic
|
|
// CallOption tail) to dailyrun.LifecycleClient.
|
|
type lifecycleClientCallable struct {
|
|
c s3_lifecycle_pb.SeaweedS3LifecycleInternalClient
|
|
}
|
|
|
|
func (l *lifecycleClientCallable) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
ctx, _ = credential.WithS3InternalAdminAuth(ctx)
|
|
return l.c.LifecycleDelete(ctx, req)
|
|
}
|
|
|
|
// resolveBucketsPath fetches the filer's configured buckets directory.
|
|
// Falls back to /buckets when the filer doesn't return one.
|
|
func resolveBucketsPath(env *CommandEnv) (string, error) {
|
|
var path string
|
|
err := env.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path = resp.GetDirBuckets()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if path == "" {
|
|
path = "/buckets"
|
|
}
|
|
return path, nil
|
|
}
|