Commit Graph

2 Commits

Author SHA1 Message Date
fc4cbaa726 fix(cluster): startup reconcile 不该等 leader,否则非 leader 永远不重建自己的 worker
前两个提交修好分裂与冻结后,滚动重启实测又暴露第三个缺口:

    .106 重入成功 → adopt-state(topology 里 .106 是 gitea_ssh 的 owner)
                  → 但 .106 没有任何 frpc worker,且日志里从无 reconcile

拓扑说 .106 拥有该转发,进程却不存在 —— 拓扑与实际不一致。

## 根因:代码与自己注释矛盾

启动 reconcile 的注释原文就是「every node (leader or not) checks its OWN topology
entries and re-claims any whose per-forward frpc worker is absent」,但代码第一行是

    for { if ring.IsLeader() { break }; sleep 2s }

即必须先当上 leader 才执行。reconcile 做的是**本节点**的活(重建自己拥有的
worker),而三分之一个环里每台都拥有转发 —— 于是所有非 leader 节点永远等不到,
worker 永远不重建。节点可以「已回到集群、拓扑里写着它 own 某转发、却没有任何进程」
这种自相矛盾的状态。

## 修法

新增 Engine.HasSelfInTopology():当前拓扑是否指名本节点为某条 active 转发
的 owner。reconcile 改等这个条件(30s 上限后无论如何继续,保证孤立节点也能推进),
不再等 leadership。领导者与 reconcile 本就无关:follower 也 own 转发。

## 测试

TestHasSelfInTopology:用**两节点**环,转发由非 leader 的 n2 持有,断言非 leader
仍然报告 true。

★ 这条测试第一版是**假绿**:单节点环里唯一的成员必然是 leader,两种实现结果相同,
加回 IsLeader 门控后测试仍全绿(已实测)。改成两节点、明确断言
`precondition: n2 must not be the leader` 之后,加回门控如期变红:

    a non-leader that owns a forward reports false — the startup reconcile would
    skip it and leave its worker down

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-10-05 17:54:29 +08:00
918ca5d5ba fix(cluster): 停用状态以「环」为权威,消除长期离线导致的永久分歧
承接用户指出的遗留:links 表是每节点本地副本,靠「store→环上行 + 环→store
下行」双向 sync 收敛,但两边都可能被覆盖。其中「本地 store 权威」这条规则有
硬伤,本次改掉。

## 先复现,再动手

写探针验证「入环 token 会不会冲掉本地未传播的决定」,结果比预期严重:

    after adopting a stale token: known=true disabled=false
    LOCAL DISABLE WAS WIPED by an incoming token

OnToken/AdoptState 是 `e.state = tk.State` **整体替换**。两次 token 之间做出的
停用决定,只要下一轮到达的 token 是「决定之前」捕获的,就会被整个洗掉 ——
决定永远传不出去,转发照旧运行。Group 之所以看起来没这个问题,是因为它每次
adoption 都被 `SetTopologySync` 从 store 重新推上去,而 disabled 没有对应的
「尚未传播」保护。

## 改法:环权威 + 本地决定带「未确认」标记

1. **环权威**:adoption 时把环上的 disabled 写穿本地 store(write-through,
   不是监听器),任何 peer 的决定都在一个 token 周期内落地。这终结了旧规则
   「各人信自己那份」造成的永久分歧。顺序上只有单向要求:引擎先重新断言本地
   未确认决定,再跑 host sync,所以读环绝不会覆盖用户刚做的操作。

2. **未确认决定受保护**(RingEngine.localDisabled):`UpdateTopologyDisabled`
   记下决定,adoption 后由 `reconcileLocalDisabled` 重新断言到刚采纳的 state 上,
   于是它会随下一轮 token 传出去。环报回同值时删除该键(全cluster已一致);
   转发从 topology 消失时一并清扫,map 不会无限增长。**false 同样受保护** ——
   重新启用也需要传播,丢掉它会把转发永久留在停用态。

3. `TopologyDisabled()` 对未确认决定短路返回本地值,避免状态页与用户刚做的
   操作相反。

## 测试(又抓到一个假绿)

新增 4 条:停用/启用跨 adoption 存活、确认后停止断言(让位给 peer 的后续决定)、
追踪表不累积。

★ `TestLocalDisableSurvivesAdoption` **第一版是假绿**:它断言
`TopologyDisabled()`,而该访问器会短路到本地决定,于是「即便即将转发的 state
仍是 enabled」它也报 true。改成断言**下游节点会看到什么**(用一个 peer 引擎
AdoptState 本引擎的 state)后,去掉重新断言如期变红:

    the forwarded token carries disabled=false, want true

双向验证通过,这个教训要记:测「声明」而不是测「实际传播的状态」,等于没测。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 11:44:28 +08:00