Files
seaweedfs/weed/admin/plugin/cluster_rate_limit.go
Chris Lu 87fdea5330 fix(admin): carry filer addresses as ServerAddress in plugin cluster context (#9600)
The plugin cluster context forwarded filers as gRPC-only addresses
(host:grpcPort). The admin-script worker stored that in
ShellOptions.FilerAddress, whose shell commands re-derive the gRPC port
via ToGrpcAddress() and re-add the +10000 offset, dialing a non-existent
host:28888.

Carry filers in pb.ServerAddress form (host:httpPort.grpcPort) and let
each consumer convert when it dials: the admin shell uses it verbatim,
while the s3_lifecycle and iceberg workers collapse it to a gRPC address.
Rename the proto field filer_grpc_addresses -> filer_addresses so the
name matches the content.
2026-05-21 02:10:27 -07:00

151 lines
5.9 KiB
Go

package plugin
import (
"strconv"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// Job types whose ExecuteJobRequest needs per-worker rate-allocation
// metadata injected. Keyed by the job-type string so plugin.go's
// generic dispatch path stays job-agnostic.
//
// To add a new job type to the share-allocation pipeline: register an
// entry here that knows how to read its admin-config field(s) and
// produce the metadata keys/values the worker reads.
// s3LifecycleClusterDeletesPerSecondKey, s3LifecycleClusterDeletesBurstKey,
// s3LifecycleMetadataDeletesPerSecond, and s3LifecycleMetadataDeletesBurst
// are the contract between admin and worker. The values must match the
// constants exported from weed/worker/tasks/s3_lifecycle/cluster_rate_limit.go
// — duplicated here as plain strings rather than imported so the admin
// plugin package doesn't pull a dependency on the worker handler
// package. A mismatch on either side would silently disable rate
// limiting; tests pin the constants in both packages against the same
// values.
const (
s3LifecycleJobType = "s3_lifecycle"
s3LifecycleClusterDeletesPerSecondKey = "cluster_deletes_per_second"
s3LifecycleClusterDeletesBurstKey = "cluster_deletes_burst"
s3LifecycleMetadataDeletesPerSecond = "s3_lifecycle.deletes_per_second"
s3LifecycleMetadataDeletesBurst = "s3_lifecycle.deletes_burst"
)
// decorateClusterContextForJob returns a new ClusterContext with any
// per-job-type metadata the admin needs to inject before the
// ExecuteJobRequest is sent. Returns the input cc unchanged when no
// decoration applies.
//
// Today only s3_lifecycle decorates; the function exists so a future
// job type's plumbing slots in alongside without touching
// executeJobWithExecutor.
//
// maxJobsPerDetection is the job-type's AdminRuntimeConfig.MaxJobsPerDetection
// — the cap on how many parallel instances of this job the scheduler will
// dispatch per detection cycle. For singleton jobs (s3_lifecycle has
// MaxJobsPerDetection=1) only one worker is ever active, so the cluster
// budget must go to that worker undivided. For parallel-dispatch jobs the
// budget divides across the actually-running set, not across every
// capable worker. The divisor is min(executors, maxJobsPerDetection),
// clamped to ≥1.
func (r *Plugin) decorateClusterContextForJob(cc *plugin_pb.ClusterContext, jobType string, adminConfigValues map[string]*plugin_pb.ConfigValue, maxJobsPerDetection int) *plugin_pb.ClusterContext {
if cc == nil {
return cc
}
if jobType != s3LifecycleJobType {
return cc
}
rps := readNonNegativeInt(adminConfigValues, s3LifecycleClusterDeletesPerSecondKey)
burst := readNonNegativeInt(adminConfigValues, s3LifecycleClusterDeletesBurstKey)
if rps <= 0 {
// Operator hasn't configured a cluster cap; nothing to allocate.
// The worker treats missing metadata keys as "unlimited," which
// is the legacy behavior.
return cc
}
executors := r.registry.CountCapableExecutors(jobType)
if executors <= 0 {
// No executors means the job won't dispatch at all; metadata
// would be discarded. Log so the case is visible in ops.
glog.V(2).Infof("decorateClusterContext: %s rps=%d but no execute-capable workers; skipping allocation", jobType, rps)
return cc
}
// Divide by the number of *concurrently-running* workers, not the
// number of capable ones. A singleton job (maxJobs=1) gets the full
// budget on its single active worker.
activeWorkers := executors
if maxJobsPerDetection > 0 && maxJobsPerDetection < activeWorkers {
activeWorkers = maxJobsPerDetection
}
perWorkerRps := float64(rps) / float64(activeWorkers)
perWorkerBurst := 0
if burst > 0 {
perWorkerBurst = burst / activeWorkers
if perWorkerBurst < 1 {
perWorkerBurst = 1
}
}
// Clone so we don't mutate the shared base context. The metadata
// map is small; a fresh allocation per ExecuteJob is fine.
out := cloneClusterContext(cc)
if out.Metadata == nil {
out.Metadata = map[string]string{}
}
out.Metadata[s3LifecycleMetadataDeletesPerSecond] = strconv.FormatFloat(perWorkerRps, 'f', -1, 64)
if perWorkerBurst > 0 {
out.Metadata[s3LifecycleMetadataDeletesBurst] = strconv.Itoa(perWorkerBurst)
}
glog.V(3).Infof("decorateClusterContext: %s rps=%d burst=%d executors=%d maxJobs=%d active=%d -> per-worker rps=%g burst=%d",
jobType, rps, burst, executors, maxJobsPerDetection, activeWorkers, perWorkerRps, perWorkerBurst)
return out
}
// cloneClusterContext returns a shallow-but-safe copy: the top-level
// fields are reassigned, and the Metadata map is duplicated so the
// caller can mutate it without racing other consumers of the input.
// Slices of strings (master/filer/volume/s3 addresses) are copied by
// reference — those are treated as immutable elsewhere in the codebase.
func cloneClusterContext(in *plugin_pb.ClusterContext) *plugin_pb.ClusterContext {
if in == nil {
return nil
}
out := &plugin_pb.ClusterContext{
MasterGrpcAddresses: in.MasterGrpcAddresses,
FilerAddresses: in.FilerAddresses,
VolumeGrpcAddresses: in.VolumeGrpcAddresses,
S3GrpcAddresses: in.S3GrpcAddresses,
}
if in.Metadata != nil {
out.Metadata = make(map[string]string, len(in.Metadata))
for k, v := range in.Metadata {
out.Metadata[k] = v
}
}
return out
}
// readNonNegativeInt reads an int64 admin config value, treating
// missing fields and non-int kinds as 0. Negative values are clamped
// to 0 since the AdminConfigForm declares MinValue=0 on both fields.
func readNonNegativeInt(values map[string]*plugin_pb.ConfigValue, field string) int {
v, ok := values[field]
if !ok || v == nil {
return 0
}
switch k := v.Kind.(type) {
case *plugin_pb.ConfigValue_Int64Value:
if k.Int64Value < 0 {
return 0
}
return int(k.Int64Value)
case *plugin_pb.ConfigValue_DoubleValue:
if k.DoubleValue < 0 {
return 0
}
return int(k.DoubleValue)
}
return 0
}