mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-17 12:46:59 +00:00
* feat: throughput limits for replicate, EC shard, and worker-driven moves VolumeCopy was the only rate-limitable transfer; EC shard copies, replica creation, and worker-driven moves all ran at whatever the receiving server's maintenance rate allowed, with no per-operation control. - proto: VolumeEcShardsCopyRequest and the balance / ec_balance task params and configs gain io_byte_per_second; 0 keeps today's behavior (the volume server's own maintenance rate governs). - volume server: VolumeEcShardsCopy throttles with one WriteThrottler per request, shared across the shard, .ecx, .ecj, .vif, and .ecsum copies so the limit caps the transfer as a whole - the same shape as VolumeCopy. - volume_move: ReplicateVolume accepts the limit; EcMoveOptions carries it through MoveEcShards/CopyAndMountEcShards into the copy request, with fake-client tests asserting propagation. - shell: ec.balance gains -ioBytePerSecond; volume.tier.move's replication top-up honors the command's existing -ioBytePerSecond instead of running unthrottled. - worker: balance and ec_balance configs gain io_byte_per_second (surfaced in the admin config schema), carried through detection and plugin job parameters into task params and handed to the shared mover; batch balance jobs inherit the limit from their detection results. The limit is per copy stream, so maxParallelization multiplies the aggregate ceiling. * worker plugins: expose io_byte_per_second in the plugin config and derive it The plugin-driven detection path derives its task Config from the plugin configuration values, and both balance and ec_balance left IoBytePerSecond at zero there - a configured limit silently reverted to the server maintenance rate. Both derive functions now read the field (clamped at zero), and the plugin descriptors expose it with defaults so the configuration form carries it.
340 lines
11 KiB
Go
340 lines
11 KiB
Go
package shell
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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}
|
|
listFn := dailyrun.FilerListFunc(filerClient, bucketsPath)
|
|
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, shardID int) error {
|
|
return dailyrun.WalkBuckets(walkCtx, view, shardID, buckets, listFn, walkerDispatch)
|
|
})
|
|
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) {
|
|
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
|
|
}
|