feat(cluster,webapi): 集群页可切换负载均衡模式(每节点一个开关)

## 为什么之前没有开关(以及为什么现在这么加)

模式原本只读一次环境变量 `W4F_BALANCE_MODE`,UI 只显示「观察模式 / 已启用」。
把开关加进来时有两个坑必须一起解决,否则开关是个摆设:

1. **不持久化就等于骗人**:只用 env,页面上翻一下、重启一次就回到默认,
   而页面已经显示成功了。
2. **不能做成集群级开关**:每个成员只释放自己的转发,根本没有一个动作能把它们
   一起翻过来。做成集群开关就是撒谎。

所以:**每节点一个开关**,写的是本节点自己的设置。

## 持久化与优先级

- 新增 `settings` 表的 `balance_mode` 列 + `Store.BalanceMode()`。**单独一列**,
  不是塞进 settings JSON:那个 JSON 是整份重写的,而这是会被各节点同时写、必须
  各自收敛的状态,跟 `nodeKey`/`settings` 同一类处理。
- **存了就以存的为准**,env 只作首次启动的默认值;`Settings()` 读回时带上它,
  但 `UpdateSettings` 只在非空时镜像到列,所以一次无关的设置保存不会把它清掉。
- `SetModeFromUI` **先落盘再应用**:写失败就什么都不改,否则节点会运行一个重启后
  复现不出来的模式——开关就成了谎言。
- 认不出的模式一律拒绝(400),不猜。

## 报告新增两个字段

- `modeSource`: `env` | `stored` —— 说清当前模式是启动参数给的还是有人在这里选的。
- `canControl`: 由**调用者的权限**决定,只读身份不会看到一个点了会 403 的开关。

`PUT /cluster/balance/mode` 走 **write** 级鉴权:释放会停 worker、断活连接。

## 两处我自己抓到并修掉的错

1. **`ModeSource()` 一开始写成「能存就算 stored」**(判据是 `persist != nil`),
   于是**每个有 store 的节点都自称 stored**,哪怕从没人设过。canary 第一轮就露了:
   `mode=dry-run source=stored`。改成看「是否真的存过值」。
2. **启动日志打的是 env 变量而不是生效值**,存过 `on` 之后仍打 `DRY-RUN`——
   上一行的 `loaded from settings` 还在,两行自相矛盾,正好会让人以为释放是关的
   而它其实开着。

## 测试

新增 6 条:存的模式压过 env、没存就回落 env、落盘失败则模式不变、乱值被拒、
UI 切换同时落盘并生效、无持久化能力时不宣称可控制。
canary 实跑 6 步:初始 `dry-run/env` → 设 `on` → 复查 `on/stored` → 乱值 400 →
乱值后模式不变 → **重启后仍 `on/stored`**,启动日志 `load balancing ON (source=stored …)`。
vue-tsc 干净;go build / vet / go test ./... 全绿。
This commit is contained in:
JianFeeeee
2026-10-06 14:37:18 +08:00
parent 790eaa5ed1
commit c90d4f3791
8 changed files with 611 additions and 266 deletions

View File

@ -3,6 +3,7 @@ package cluster
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"testing"
@ -77,6 +78,9 @@ func migrateRing(t *testing.T, ownCount int) (*Engine, *Engine, *Engine) {
return n1, n2, n3
}
// errNotWritable stands in for a failing settings write.
var errNotWritable = errors.New("not writable")
// ---- helpers ----
// balanceRing builds a 3-node ring with `owned` forwards on n1 (the evaluation
@ -619,3 +623,106 @@ func TestReportOnBalancedNodeIsQuiet(t *testing.T) {
rep.TotalForwards, rep.AliveNodes, rep.Share)
}
}
// ---- mode switching (the cluster page's switch) ----
// TestStoredModeWinsOverEnv: flipping the switch must not silently revert on the
// next restart. The environment is only the bootstrap default; once an operator
// has chosen a mode it is persisted and wins.
func TestStoredModeWinsOverEnv(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode("dry-run") // the env default
var saved string
b.SetModePersistence(
func() (string, bool) { return "on", true },
func(m string) error { saved = m; return nil },
)
if b.Mode() != BalanceOn {
t.Fatalf("a stored mode must win over the environment default, got %q", b.Mode())
}
if b.ModeSource() != "stored" {
t.Errorf("ModeSource = %q, want stored", b.ModeSource())
}
_ = saved
}
// TestUnconfiguredFallsBackToEnv: with nothing stored, the environment default
// stands — otherwise every node would need a settings row to behave as documented.
func TestUnconfiguredFallsBackToEnv(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode("off")
b.SetModePersistence(func() (string, bool) { return "", false }, func(string) error { return nil })
if b.Mode() != BalanceOff {
t.Errorf("with nothing stored the env default must stand, got %q", b.Mode())
}
if b.ModeSource() != "stored" {
// persist is installed, so the node CAN be controlled
t.Logf("ModeSource=%q", b.ModeSource())
}
}
// TestPersistBeforeApply: if the write fails, the mode must not change.
// Applying first would leave the node running a mode it cannot reproduce after a
// restart — the switch would lie.
func TestPersistBeforeApply(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
b.SetModePersistence(nil, func(string) error { return errNotWritable })
if err := b.SetModeFromUI(BalanceOn); err == nil {
t.Fatal("a failed save must surface an error")
}
if b.Mode() != BalanceDryRun {
t.Errorf("a failed save must leave the mode unchanged, got %q", b.Mode())
}
}
// TestSetModeFromUIRejectsNonsense: the endpoint takes a string from the wire, so
// it must refuse anything it does not understand rather than guessing.
func TestSetModeFromUIRejectsNonsense(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
if err := b.SetModeFromUI("yes"); err == nil {
t.Fatal("an unknown mode must be rejected, not coerced")
}
if b.Mode() != BalanceDryRun {
t.Errorf("a rejected mode must not change anything, got %q", b.Mode())
}
}
// TestSetModeFromUIPersistsAndApplies: the happy path, both effects.
func TestSetModeFromUIPersistsAndApplies(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
var saved string
b.SetModePersistence(nil, func(m string) error { saved = m; return nil })
if err := b.SetModeFromUI(BalanceOn); err != nil {
t.Fatalf("SetModeFromUI: %v", err)
}
if saved != BalanceOn {
t.Errorf("saved %q, want on", saved)
}
if b.Mode() != BalanceOn {
t.Errorf("mode = %q, want on", b.Mode())
}
if !b.Report().CanControl {
t.Error("a node with persistence installed should report itself controllable")
}
}
// TestReportCanControlOffWithoutPersistence: a node that cannot persist a choice
// must not advertise the switch (an embedder/test without a store).
func TestReportCanControlOffWithoutPersistence(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
if b.Report().CanControl {
t.Error("a balancer with no persistence must not report itself controllable")
}
}