mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
feat: Phase C 完成 — ModelRouter 风格 UI 重设计 + 模拟 frps 测试 + lastSync 修复 + 集群作坊搭建
- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射 - 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航 - 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格 - 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区 - 令牌环: 新增 lastSync 上次同步时间替代周期计数 - 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本 - 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
This commit is contained in:
@ -1,11 +1,8 @@
|
||||
// Leader-side cycle control + fault tolerance for the token ring:
|
||||
// - phase flip (round1 -> round2 -> next cycle) decided by the leader when
|
||||
// the token returns with all alive nodes passed;
|
||||
// - token-loss resend when it does not come back within roundDelay/2 + 20ms;
|
||||
// - predecessor monitors the leader by heartbeat and promotes itself when the
|
||||
// leader is gone;
|
||||
// - send failures (successor down) mark the neighbor offline, reattach its
|
||||
// tasks, and hop to the next alive successor.
|
||||
// Leader-side minimal roles for the token ring. Per the authoritative design:
|
||||
// the leader only (1) initiates the first token, and (2) judges token loss.
|
||||
// All other processing (adopt cluster picture, append own info, run commands,
|
||||
// forward) is IDENTICAL between leader and ordinary nodes — no phase flips,
|
||||
// no passed-accounting, no cycle bookkeeping in the leader.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
@ -15,18 +12,23 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LossTimeout returns the token-loss threshold per the authoritative design:
|
||||
// roundDelay/2 + 20ms, floored at 600ms so a healthy fast ring is never
|
||||
// misjudged as lost.
|
||||
// ringHopDelay paces each token hop (real-world cadence, avoids busy-loop).
|
||||
// 500ms keeps a 3-node ring well under the LossTimeout floor (2500ms) so a
|
||||
// healthy round is never misjudged lost, while not idly burning CPU/HTTP at
|
||||
// 20Hz like the old 50ms did.
|
||||
const ringHopDelay = 500 * time.Millisecond
|
||||
|
||||
// LossTimeout is the token-loss threshold per design: roundDelay/2 + 20ms,
|
||||
// floored so a healthy fast ring is never misjudged.
|
||||
func LossTimeout(roundDelay time.Duration) time.Duration {
|
||||
t := roundDelay/2 + 20*time.Millisecond
|
||||
if t < 600*time.Millisecond {
|
||||
return 600 * time.Millisecond
|
||||
if t < 2500*time.Millisecond {
|
||||
return 2500 * time.Millisecond
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// inFlight tracks token-in-flight state (leader only).
|
||||
// inFlight tracks token-in-flight state (leader only, for loss judging).
|
||||
type inFlight struct {
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
@ -69,53 +71,22 @@ func (f *inFlight) lastDelay() time.Duration {
|
||||
return f.delay
|
||||
}
|
||||
|
||||
// flipIfComplete promotes the collect round to the sync round when every alive
|
||||
// node has been stamped; at the end of sync it completes the cycle. Only the
|
||||
// leader flips. Passed carries the set of nodes that saw this token this round;
|
||||
// it is NOT reset between phases (only between cycles).
|
||||
func (e *Engine) flipIfComplete(tk *Token) {
|
||||
if e.state.LeaderID != e.ID {
|
||||
return
|
||||
}
|
||||
switch tk.Phase {
|
||||
case PhaseCollect:
|
||||
if allAlivePassed(&e.state, tk.Passed) {
|
||||
// Round 1 done: go to round 2 (sync). Round-2 stamping restarts
|
||||
// from the leader so the token walks the ring once more.
|
||||
tk.Phase = PhaseSync
|
||||
tk.Passed = []string{e.ID}
|
||||
e.state.RoundDelay = tkDelaySince(tk)
|
||||
}
|
||||
case PhaseSync:
|
||||
if allAlivePassed(&e.state, tk.Passed) {
|
||||
// Cycle done: leader holds token; next StartRing bumps cycle.
|
||||
e.state.RoundDelay = tkDelaySince(tk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allAlivePassed(s *State, passed []string) bool {
|
||||
for _, n := range s.Nodes {
|
||||
if !n.Alive {
|
||||
continue
|
||||
}
|
||||
if !contains(passed, n.ID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Send moves the token onward (or completes the cycle at the leader). It is
|
||||
// called by the HTTP handler after OnToken. A full cycle stops at the leader.
|
||||
// Send is called after OnToken for the LEADER. The token has come full
|
||||
// circle (one round complete), so the leader bumps the cycle, stamps a fresh
|
||||
// SentAt (so any stale in-flight older token is dropped downstream), and
|
||||
// forwards to the successor to start the next round. This is what makes
|
||||
// `cycle` advance under the perpetual-flow model — without it cycle stuck
|
||||
// at the StartRing value forever.
|
||||
func (e *Engine) Send(ctx context.Context, tk *Token) error {
|
||||
if e.state.LeaderID == e.ID {
|
||||
e.flipIfComplete(tk)
|
||||
// If the sync round completed, the leader holds the token; watchdog
|
||||
// StartRing launches the next cycle. Clear in-flight marker.
|
||||
if tk.Phase == PhaseSync && allAlivePassed(&e.state, tk.Passed) {
|
||||
e.inflight.clear()
|
||||
return nil
|
||||
if !e.selfRemoved {
|
||||
e.state.Cycle = tk.Cycle + 1
|
||||
tk.Cycle = e.state.Cycle
|
||||
// Keep State.Cycle in lockstep with Token.Cycle so the snapshot
|
||||
// (which reads e.state.Cycle) reflects the real round number.
|
||||
tk.State = e.state
|
||||
tk.SentAt = time.Now().UnixMilli()
|
||||
if tk.SentAt > e.lastTokenAt {
|
||||
e.lastTokenAt = tk.SentAt
|
||||
}
|
||||
}
|
||||
return e.forwardToNext(ctx, tk)
|
||||
@ -135,14 +106,23 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
|
||||
}
|
||||
err := e.send(ctx, next, tk)
|
||||
if err == nil {
|
||||
e.inflight.mark(e.state.RoundDelay)
|
||||
if e.state.LeaderID == e.ID {
|
||||
e.inflight.mark(e.state.RoundDelay)
|
||||
}
|
||||
// Pace the ring so tokens circulate at a realistic cadence.
|
||||
// Pacing handled by the parallel rhythm timer in OnToken.
|
||||
return nil
|
||||
}
|
||||
log.Printf("ring[%s] send to %s failed: %v", e.ID, next, err)
|
||||
e.state.MarkOffline(next)
|
||||
e.state.OfflineReassign(next)
|
||||
if e.state.LeaderID == next {
|
||||
e.becomeLeader()
|
||||
// Do not declare a neighbor offline on a single timeout: transient
|
||||
// send failures (network jitter, busy handler) must not break the
|
||||
// ring. Only after consecutive failures do we evict the node.
|
||||
e.failMu.Lock()
|
||||
e.failCount[next]++
|
||||
if e.failCount[next] >= 2 {
|
||||
e.state.MarkOffline(next)
|
||||
e.state.OfflineReassign(next)
|
||||
delete(e.failCount, next)
|
||||
}
|
||||
}
|
||||
e.inflight.clear()
|
||||
@ -158,8 +138,8 @@ func (e *Engine) becomeLeader() {
|
||||
log.Printf("ring[%s] promoted to leader", e.ID)
|
||||
}
|
||||
|
||||
// WatchLeader runs the heartbeat monitor: if our successor is the leader, ping
|
||||
// it; on failure mark it offline and promote ourselves. Stops on ctx cancel.
|
||||
// WatchLeader runs the leader liveness monitor: the predecessor of the leader
|
||||
// pings it; on failure it marks the leader offline and promotes itself.
|
||||
func (e *Engine) WatchLeader(ctx context.Context) {
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
defer tick.Stop()
|
||||
@ -168,44 +148,21 @@ func (e *Engine) WatchLeader(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
succ, ok := e.state.AliveSuccessor(e.ID)
|
||||
if !ok || succ == e.ID {
|
||||
continue // single-node ring: nothing to monitor
|
||||
if e.state.LeaderID == e.ID {
|
||||
continue // we are the leader; predecessor monitors us
|
||||
}
|
||||
// Only the leader's predecessor (the previous alive node) monitors
|
||||
// it; a node whose successor is the leader is the predecessor.
|
||||
if e.state.LeaderID != succ {
|
||||
continue
|
||||
}
|
||||
pred, ok := e.state.AlivePredecessor(e.ID)
|
||||
if !ok || pred == e.ID {
|
||||
continue
|
||||
}
|
||||
// We monitor the leader only if we ARE its predecessor.
|
||||
if succ != e.state.LeaderID {
|
||||
continue
|
||||
}
|
||||
// pred must be us: leader's predecessor sends the heartbeat.
|
||||
if e.state.Nodes[e.state.Find(succ)].ID == e.ID {
|
||||
continue
|
||||
}
|
||||
if e.state.Find(e.ID) == -1 {
|
||||
continue
|
||||
}
|
||||
// This node is the leader's predecessor iff leader's predecessor
|
||||
// (alive) equals our ID.
|
||||
leaderPred, ok := e.state.AlivePredecessor(e.state.LeaderID)
|
||||
if !ok || leaderPred != e.ID {
|
||||
continue
|
||||
continue // only the leader's predecessor pings it
|
||||
}
|
||||
if e.send != nil {
|
||||
hbCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
||||
err := e.send(hbCtx, succ, nil) // nil token = heartbeat ping
|
||||
err := e.send(hbCtx, e.state.LeaderID, nil) // nil = heartbeat
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("ring[%s] heartbeat to leader %s failed: %v", e.ID, succ, err)
|
||||
e.state.MarkOffline(succ)
|
||||
e.state.OfflineReassign(succ)
|
||||
log.Printf("ring[%s] heartbeat to leader %s failed: %v", e.ID, e.state.LeaderID, err)
|
||||
e.state.MarkOffline(e.state.LeaderID)
|
||||
e.state.OfflineReassign(e.state.LeaderID)
|
||||
e.becomeLeader()
|
||||
}
|
||||
}
|
||||
@ -213,8 +170,9 @@ func (e *Engine) WatchLeader(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// WatchTokenLoss runs on the leader: if a token was sent and does not return
|
||||
// within LossTimeout, resend it. Stops on ctx cancel.
|
||||
// WatchTokenLoss runs the leader's token-loss judge: if a token was sent and
|
||||
// does not return within LossTimeout, the leader issues a fresh token (all
|
||||
// nodes drop older stamps via the SentAt guard, so at most one circulates).
|
||||
func (e *Engine) WatchTokenLoss(ctx context.Context) {
|
||||
tick := time.NewTicker(200 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
|
||||
Reference in New Issue
Block a user