mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
feat(cluster,httpapi): GET /cluster/balance 只读负载视图
## 为什么需要它
balancer 的判断原本只能从日志看,而且**只在"确实失衡 且 迟滞窗口已满"时才输出**。
运维问"这个集群均衡吗"——在健康状态下得到的是**沉默**,与"负载均衡没开"
完全无法区分。这个端点把它的判断做成可主动查询的。
## 关键性质:Report() 不扰动迟滞计时器
Report 只读,**不碰 overSince**。否则好奇的运维反复轮询这个端点就会不断重置
"失衡已持续多久"的计时,导致真正的迁移永远不会触发——一个"看一看就把它看没了"
的只读接口。测试 TestReportDoesNotDisturbHysteresis 正是钉住这点:轮询 5 次后
计时器必须原封不动,且随后满足窗口时迁移仍要发生。
## 只 leader 报计划
`wouldMigrate`/`overloadedSince` 仅在 `IsLeader()` 时给出。非 leader 仍返回各节点
Score(那是信息性的),但不报"会迁什么"——否则运维随便查一台节点会拿到与集群
实际行为不符的答案。TestReportOnFollowerHasNoPlan 钉住。
## 返回内容
nodes[] 各节点 Forwards/MemPct/NetPct/Score
isLeader 只有 leader 会行动
overloadedSince 超载已持续多久(迟滞进度)
wouldMigrate 窗口满足后会迁哪条、从哪到哪
inFlight 两阶段迁移进行中(revoke 慢 vs 卡死可区分)
dryRun / enabled 当前是观察还是真迁
## 接线
Balancer 在 NewBalancer 时就创建(HTTP 层需要),W4F_BALANCE_* 解析完再用
SetRatioAndSustain 应用;新增 SetDryRun / DryRun / SetRatioAndSustain,
避免初始化顺序耦合。Handler.Balancer 为 nil 时端点返回 404。
## 测试
新增 4 条:Report 不扰动迟滞(轮询后计时器不变、且迁移仍能触发)、
follower 无计划、窗口满足前后计划的有无、in-flight 可见。
go clean -testcache 后逐包实跑 7/7 全绿。
This commit is contained in:
@ -417,3 +417,107 @@ func TestOnlyLeaderBalances(t *testing.T) {
|
||||
}
|
||||
_ = before
|
||||
}
|
||||
|
||||
// TestReportDoesNotDisturbHysteresis: Report is a read-only view that an operator
|
||||
// can call at will. If it touched the overSince timer, a curious operator
|
||||
// polling the endpoint would keep resetting the sustained-imbalance clock and
|
||||
// balancing would never fire. It must observe without perturbing.
|
||||
func TestReportDoesNotDisturbHysteresis(t *testing.T) {
|
||||
n1, _, _ := migrateRing(t, 3)
|
||||
b := NewBalancer(n1, 2, 1800)
|
||||
|
||||
// First Step only starts the timer (no action yet).
|
||||
_ = b.Step(1000)
|
||||
started, seen := b.overSince["n1"]
|
||||
if !seen {
|
||||
t.Fatal("precondition: the overload timer should have been started")
|
||||
}
|
||||
|
||||
// Poll the report repeatedly, as an operator would.
|
||||
for i := 0; i < 5; i++ {
|
||||
rep := b.Report(int64(2000 + i*600))
|
||||
if len(rep.Nodes) != 3 {
|
||||
t.Fatalf("report listed %d nodes, want 3", len(rep.Nodes))
|
||||
}
|
||||
}
|
||||
if got := b.overSince["n1"]; got != started {
|
||||
t.Errorf("Report reset the overload timer: %d -> %d", started, got)
|
||||
}
|
||||
|
||||
// And the real path must still work: past the window it acts.
|
||||
_ = b.Step(1000 + 1801)
|
||||
if b.revoking == nil {
|
||||
t.Error("polling the report suppressed the migration it should have allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportOnFollowerHasNoPlan: only the leader acts, so a follower must not
|
||||
// claim it would migrate anything — otherwise an operator querying a random node
|
||||
// gets an answer that does not reflect what the cluster will actually do.
|
||||
func TestReportOnFollowerHasNoPlan(t *testing.T) {
|
||||
_, n2, _ := migrateRing(t, 3) // n2 is not the leader
|
||||
b := NewBalancer(n2, 2, 0)
|
||||
b.overSince = map[string]int64{"n1": 1000}
|
||||
|
||||
rep := b.Report(9999)
|
||||
if rep.IsLeader {
|
||||
t.Fatal("precondition: n2 must not be the leader")
|
||||
}
|
||||
if rep.WouldMigrate != nil {
|
||||
t.Errorf("a follower reported a migration plan: %+v", rep.WouldMigrate)
|
||||
}
|
||||
if len(rep.Nodes) == 0 {
|
||||
t.Error("a follower should still report the node scores (that part is informational)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportShowsPlanOnceHysteresisSatisfied pins the operator-facing contract:
|
||||
// before the window elapses there is no plan; after it, the plan appears with
|
||||
// the concrete forward and both endpoints.
|
||||
func TestReportShowsPlanOnceHysteresisSatisfied(t *testing.T) {
|
||||
n1, _, _ := migrateRing(t, 3)
|
||||
b := NewBalancer(n1, 2, 1800)
|
||||
|
||||
// Start the timer but do not satisfy the window.
|
||||
b.overSince = map[string]int64{"n1": 1000}
|
||||
if rep := b.Report(1500); rep.WouldMigrate != nil {
|
||||
t.Errorf("plan reported before the sustained window elapsed: %+v", rep.WouldMigrate)
|
||||
}
|
||||
|
||||
rep := b.Report(1000 + 1801)
|
||||
if rep.WouldMigrate == nil {
|
||||
t.Fatal("no plan reported even though the imbalance is past the window")
|
||||
}
|
||||
m := rep.WouldMigrate
|
||||
if m.From == "" || m.To == "" || m.Local == "" || m.Port == 0 {
|
||||
t.Fatalf("plan is incomplete: %+v", m)
|
||||
}
|
||||
if m.From == m.To {
|
||||
t.Fatalf("plan moves a forward to the node it already lives on: %+v", m)
|
||||
}
|
||||
if !rep.Enabled == false && rep.DryRun {
|
||||
// sanity: default is observe-only
|
||||
if !rep.DryRun {
|
||||
t.Error("balancer should default to dry-run")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportSurfacesInFlightMigration: the two-phase machine must be visible while
|
||||
// it runs, otherwise an operator sees a stalled plan and cannot tell a slow revoke
|
||||
// from a stuck one.
|
||||
func TestReportSurfacesInFlightMigration(t *testing.T) {
|
||||
n1, _, _ := migrateRing(t, 3)
|
||||
b := NewBalancer(n1, 2, 0)
|
||||
_ = b.Step(1000) // starts phase 1
|
||||
if !b.InFlight() {
|
||||
t.Fatal("precondition: a migration should be in flight")
|
||||
}
|
||||
rep := b.Report(1001)
|
||||
if rep.InFlight == nil {
|
||||
t.Fatal("report did not surface the in-flight migration")
|
||||
}
|
||||
if rep.InFlight.Local == "" || rep.InFlight.To == "" {
|
||||
t.Fatalf("in-flight plan is incomplete: %+v", rep.InFlight)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user