Files
seaweedfs/weed/admin/plugin/cluster_rate_limit_test.go
Chris Lu 884b0bcbfd feat(s3/lifecycle): cluster rate-limit allocation (Phase 3) (#9456)
* feat(s3/lifecycle): cluster rate-limit allocation (Phase 3)

Admin computes a per-worker share of cluster_deletes_per_second at
ExecuteJob time and ships it to the worker via
ClusterContext.Metadata. The worker reads the share, constructs a
golang.org/x/time/rate.Limiter, and passes it to dailyrun.Run via
cfg.Limiter (Phase 2 already plumbed the field). Phase 5 deletes the
streaming path; until then streaming ignores the cap.

Why allocate at admin: the cluster cap is a single knob operators
care about. Dividing it locally per worker would either need
out-of-band coordination or accept N× the configured budget. Admin
is the only party that knows how many execute-capable workers there
are, so it owns the math.

Admin side (weed/admin/plugin):
- Registry.CountCapableExecutors(jobType) returns the number of
  non-stale workers with CanExecute=true.
- New file cluster_rate_limit.go: decorateClusterContextForJob clones
  the input ClusterContext and injects two metadata keys for
  s3_lifecycle. cloneClusterContext duplicates Metadata so per-job
  decoration doesn't race shared base state.
- executeJobWithExecutor calls the decorator after loading the admin
  config; other job types pass through unchanged.

Worker side (weed/worker/tasks/s3_lifecycle):
- New cluster_rate_limit.go declares the constants both sides agree
  on (admin-config field names, metadata keys). Plain strings on the
  admin side keep weed/admin/plugin free of a dependency on the
  s3_lifecycle worker package; the two sets of constants are pinned
  to identical values and a mismatch would silently disable rate
  limiting.
- handler.go executeDailyReplay reads ClusterContext.Metadata,
  builds a rate.Limiter, and passes it into dailyrun.Config{Limiter}.
  Missing/empty/non-positive values → no limiter (legacy unlimited
  behavior). burst defaults to 2 × rate, clamped to ≥1 to avoid a
  bucket that never refills.
- Admin form gains two fields under "Scope": cluster_deletes_per_second
  (rate, 0 = unlimited) and cluster_deletes_burst (0 = 2 × rate).

Metric:
- New S3LifecycleDispatchLimiterWaitSeconds histogram observes how
  long each Limiter.Wait blocks before a LifecycleDelete RPC.
  Operators tune the cap by reading p95 — near-zero means the cap
  isn't binding, a long tail at 1/rate means it is.

Tests:
- weed/admin/plugin/cluster_rate_limit_test.go: 9 cases covering
  pass-through for non-allocator job types, rps=0 / no-executors
  skip, even sharing, burst sharing, burst=0 omit (worker default
  kicks in), burst floor of 1, no mutation of input metadata, nil
  input.
- weed/worker/tasks/s3_lifecycle/cluster_rate_limit_test.go: 7 cases
  covering nil/empty/missing metadata, non-positive/invalid rate,
  positive rate builds correctly, burst missing defaults to 2× rate,
  tiny rate clamps burst to ≥1.

Build clean. Phase 2 (#9446) and Phase 4 engine (#9447) are the
parents; this branch stacks on Phase 2 since it consumes
dailyrun.Config{Limiter} which lands there.

* fix(s3/lifecycle): divide cluster budget by active workers, not all capable

gemini pointed out that s3_lifecycle has MaxJobsPerDetection=1
(handler.go:189) — it's a singleton job, only one worker is ever active.
Dividing the cluster_deletes_per_second budget by the count of capable
executors gave the single active worker just 1/N of the configured cap.

Pass adminRuntime.MaxJobsPerDetection through to the decorator. Divisor
is now min(executors, maxJobsPerDetection), clamped to >=1. For
s3_lifecycle (maxJobs=1) the active worker gets the full budget; for a
hypothetical parallel-dispatch job (maxJobs>1) the budget divides
across the running-set.

Tests swap the SharedEvenly case for two pinned scenarios:
  - SingletonJobGetsFullBudget: maxJobs=1 across 4 executors => 100/1
  - SharedEvenlyWhenParallelLimited: maxJobs=4 across 4 executors => 25/worker
  - MaxJobsExceedsExecutors: maxJobs=10 across 4 executors => divisor 4

* feat(s3/lifecycle): drop Worker Count knob from admin config form

The "Worker Count" admin field controlled in-process pipeline goroutines
across the 16-shard space — per-worker tuning, not a cluster-wide scope
concern. Operators looking at the form alongside Cluster Delete Rate
reasonably misread it as the number of workers in the cluster.

Drop the form field and DefaultValues entry. cfg.Workers is now hardcoded
to shardPipelineGoroutines (=1) inside ParseConfig; the rest of the
plumbing through dailyrun.Config.Workers stays so a future need can
re-introduce it as a worker-local knob (or just bump the constant).

handler_test.go pins that "workers" must NOT appear in the form so the
removal doesn't silently regress.
2026-05-11 19:17:06 -07:00

178 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pluginWithExecutors returns a Plugin whose registry contains n
// non-stale execute-capable workers for jobType. Helper for the
// allocator tests. Bypasses UpsertFromHello so tests don't have to
// build a full Hello message.
func pluginWithExecutors(t *testing.T, jobType string, n int) *Plugin {
t.Helper()
reg := NewRegistry()
now := time.Now()
for i := 0; i < n; i++ {
id := "worker-" + string(rune('a'+i))
reg.sessions[id] = &WorkerSession{
WorkerID: id,
LastSeenAt: now,
ConnectedAt: now,
Capabilities: map[string]*plugin_pb.JobTypeCapability{
jobType: {CanExecute: true},
},
}
}
return &Plugin{registry: reg}
}
// adminConfig builds an int64 admin config map for the given fields.
func adminConfig(pairs ...interface{}) map[string]*plugin_pb.ConfigValue {
if len(pairs)%2 != 0 {
panic("adminConfig expects key/value pairs")
}
out := map[string]*plugin_pb.ConfigValue{}
for i := 0; i < len(pairs); i += 2 {
key := pairs[i].(string)
switch v := pairs[i+1].(type) {
case int:
out[key] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(v)}}
case int64:
out[key] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}}
case float64:
out[key] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: v}}
default:
panic("adminConfig: unsupported value type")
}
}
return out
}
func TestDecorateClusterContext_NonS3LifecycleIsPassThrough(t *testing.T) {
// Any job type other than s3_lifecycle gets the input cc back
// unchanged. Future allocators add their own branch; the default
// is pass-through.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{Metadata: map[string]string{"unrelated": "v"}}
out := r.decorateClusterContextForJob(in, "some_other_job", adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 1)
assert.Same(t, in, out, "non-allocator job type must return the same pointer")
}
func TestDecorateClusterContext_RpsZeroSkipsAllocation(t *testing.T) {
// rps=0 means "operator hasn't configured a cap"; the worker
// treats missing keys as unlimited. We must NOT inject any
// metadata (in particular, not "0") because that would force the
// worker into a no-throughput state on a misconfigured cluster.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType, adminConfig(s3LifecycleClusterDeletesPerSecondKey, 0), 1)
if out.Metadata != nil {
_, hasRps := out.Metadata[s3LifecycleMetadataDeletesPerSecond]
assert.False(t, hasRps, "rps=0 must not write a deletes_per_second key")
}
}
func TestDecorateClusterContext_NoExecutorsSkipsAllocation(t *testing.T) {
r := pluginWithExecutors(t, s3LifecycleJobType, 0)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType, adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 1)
if out.Metadata != nil {
_, hasRps := out.Metadata[s3LifecycleMetadataDeletesPerSecond]
assert.False(t, hasRps, "0 executors must not write share metadata (would divide by zero)")
}
}
func TestDecorateClusterContext_SingletonJobGetsFullBudget(t *testing.T) {
// s3_lifecycle has MaxJobsPerDetection=1: only ONE worker runs the
// job at a time. The cluster budget must go to that worker undivided
// — dividing by N capable executors would starve the active worker
// to 1/N of the configured rps. Pin the singleton behavior.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 1)
require.NotNil(t, out.Metadata)
assert.Equal(t, "100", out.Metadata[s3LifecycleMetadataDeletesPerSecond], "singleton job: full budget to the single active worker")
}
func TestDecorateClusterContext_SharedEvenlyWhenParallelLimited(t *testing.T) {
// Hypothetical parallel-dispatch job type (maxJobs=4): budget
// divides across the running-set, which equals min(executors,
// maxJobs)=4. 100/4=25.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 4)
require.NotNil(t, out.Metadata)
assert.Equal(t, "25", out.Metadata[s3LifecycleMetadataDeletesPerSecond], "maxJobs=4 across 4 executors = 25/worker")
}
func TestDecorateClusterContext_MaxJobsExceedsExecutors(t *testing.T) {
// maxJobs=10 but only 4 executors exist — the divisor is the
// smaller value (executors) since you can't run more jobs in
// parallel than there are workers to run them.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 10)
require.NotNil(t, out.Metadata)
assert.Equal(t, "25", out.Metadata[s3LifecycleMetadataDeletesPerSecond])
}
func TestDecorateClusterContext_BurstSharedWhenParallel(t *testing.T) {
r := pluginWithExecutors(t, s3LifecycleJobType, 2)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100, s3LifecycleClusterDeletesBurstKey, 20), 2)
require.NotNil(t, out.Metadata)
assert.Equal(t, "50", out.Metadata[s3LifecycleMetadataDeletesPerSecond])
assert.Equal(t, "10", out.Metadata[s3LifecycleMetadataDeletesBurst])
}
func TestDecorateClusterContext_BurstZeroOmitsKey(t *testing.T) {
// burst=0 means "let the worker default it." Don't write the key —
// the worker's parsePositiveInt would then take the unset path
// and compute 2 × rps automatically.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100, s3LifecycleClusterDeletesBurstKey, 0), 4)
_, hasBurst := out.Metadata[s3LifecycleMetadataDeletesBurst]
assert.False(t, hasBurst, "burst=0 must NOT write the burst key (worker default kicks in)")
}
func TestDecorateClusterContext_BurstFloorIsOneWhenDividesBelowOne(t *testing.T) {
// burst=1 across 4 active workers would round to 0; clamp to 1 so
// the limiter doesn't become "single-token bucket that never refills."
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
in := &plugin_pb.ClusterContext{}
out := r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100, s3LifecycleClusterDeletesBurstKey, 1), 4)
assert.Equal(t, "1", out.Metadata[s3LifecycleMetadataDeletesBurst])
}
func TestDecorateClusterContext_DoesNotMutateInput(t *testing.T) {
// The same base ClusterContext is shared across many parallel
// ExecuteJob calls. The decorator must produce a fresh map so it
// can't race / leak per-job metadata into the base.
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
baseMeta := map[string]string{"existing": "value"}
in := &plugin_pb.ClusterContext{Metadata: baseMeta}
_ = r.decorateClusterContextForJob(in, s3LifecycleJobType,
adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 1)
_, leaked := baseMeta[s3LifecycleMetadataDeletesPerSecond]
assert.False(t, leaked, "decorator must not mutate the input metadata map")
assert.Equal(t, "value", baseMeta["existing"])
}
func TestDecorateClusterContext_NilInputPassesThrough(t *testing.T) {
r := pluginWithExecutors(t, s3LifecycleJobType, 4)
out := r.decorateClusterContextForJob(nil, s3LifecycleJobType, adminConfig(s3LifecycleClusterDeletesPerSecondKey, 100), 1)
assert.Nil(t, out)
}