feat(M6): token ring data plane + engine — pending adds disappear on claim, topology written back for full cluster view (per authoritative design)

This commit is contained in:
2026-08-18 08:24:37 +08:00
parent 5bd70b90c0
commit f6c4b91a96
9 changed files with 1166 additions and 202 deletions

View File

@ -0,0 +1,252 @@
// Package cluster implements the token-ring cooperative network.
// Authoritative design: plan.md §M6 (令牌环网拓扑).
package cluster
import (
"context"
"log"
"time"
)
// Phase constants for the two-round cycle.
const (
PhaseCollect = 1 // round 1: append own info to token
PhaseSync = 2 // round 2: sync cluster state, claim tasks
)
// Handler is what the engine calls when the node must act on a task
// (create the frpc worker / forward). Injected to avoid import cycle.
type Handler interface {
Claim(ctx context.Context, tk *Task) error
RuntimeLoad() Load
}
// Engine drives one member of the token ring.
type Engine struct {
ID string
Addr string
User string
Pass string
Version string
Cache []string
Handler Handler
state State
// myAddr maps our Node ID to the address peers dial.
myAddr string
// send moves the token to the next node (injected transport).
send func(ctx context.Context, next string, tk *Token) error
// inflight tracks token-in-flight state (leader only).
inflight inFlight
}
// NewEngine builds the engine; state holds this node as initial leader unless
// a peer list says otherwise (creation node starts the ring).
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool) *Engine {
e := &Engine{
ID: id, Addr: addr, User: user, Pass: pass,
Version: version, Cache: cache, Handler: h,
state: State{
LeaderID: "",
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 200 * time.Millisecond,
},
myAddr: selfAddr,
send: send,
}
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
e.state.UpsertNode(n)
return e
}
// State returns the engine's current cluster picture.
func (e *Engine) State() *State { return &e.state }
// nextRecipient picks the successor to hand the token to, skipping dead nodes.
func (e *Engine) nextRecipient() (string, bool) {
return e.state.AliveSuccessor(e.ID)
}
// myNode returns this node's entry from state.
func (e *Engine) myNode() Node {
i := e.state.Find(e.ID)
if i < 0 {
return Node{ID: e.ID, Addr: e.myAddr, Alive: true}
}
return e.state.Nodes[i]
}
// loadSnapshot reads our runtime load (mem+net) from the handler.
func (e *Engine) loadSnapshot() Load {
if e.Handler != nil {
return e.Handler.RuntimeLoad()
}
return Load{MemPct: 20, NetPct: 20}
}
// phase1 appends this node's info to the token (round 1).
func (e *Engine) phase1(tk *Token) {
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true,
IsLeader: e.state.LeaderID == e.ID || tk.State.LeaderID == e.ID,
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
})
tk.Passed = append(tk.Passed, e.ID)
}
// phase2 syncs cluster info from the token and claims pending tasks if we are
// the lowest-load node. A claimed task DISAPPEARS from pending and is
// written into the active topology so every member knows who runs what.
func (e *Engine) phase2(ctx context.Context, tk *Token) error {
e.state = tk.State
e.state.LeaderID = tk.State.LeaderID
for {
pending := e.state.PendingList()
if len(pending) == 0 {
break
}
low := e.state.LowestAlive()
if low == nil || low.ID != e.ID {
break
}
tk0 := pending[0]
claimed := e.state.ClaimPending(tk0.ID)
if claimed == nil {
break
}
if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
log.Printf("ring[%s] claim %s failed: %v", e.ID, claimed.ID, err)
break
}
}
e.state.AddTopology(claimed, e.ID)
}
e.state = tk.State
tk.Passed = append(tk.Passed, e.ID)
return nil
}
// OnToken receives the token: process by phase, return updated token.
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
switch tk.Phase {
case PhaseCollect:
e.phase1(tk)
case PhaseSync:
if err := e.phase2(ctx, tk); err != nil {
return tk, err
}
default:
return tk, nil
}
return tk, nil
}
// Forward hands the token to this node's successor over the injected send.
// It is the transport hook used by the HTTP handler after OnToken.
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil // single-node ring
}
if e.send != nil {
return e.send(ctx, next, tk)
}
return nil
}
// Snapshot returns a serializable view of the ring for the frontend.
type RingSnapshot struct {
LeaderID string `json:"leaderId"`
Cycle int64 `json:"cycle"`
RoundDelay int64 `json:"roundDelayMs"`
Nodes []Node `json:"nodes"`
Pending []*Task `json:"pending"`
Topology []*TopoEntry `json:"topology"`
}
func (e *Engine) Snapshot() *RingSnapshot {
snap := &RingSnapshot{
LeaderID: e.state.LeaderID,
Cycle: e.state.Cycle,
RoundDelay: e.state.RoundDelay.Milliseconds(),
Nodes: e.state.Nodes,
Pending: e.state.PendingList(),
Topology: e.state.TopologyList(),
}
return snap
}
// advanceToken decides the next recipient: normal successor; if we are leader
// and everyone passed, flip phase for the second round.
func (e *Engine) advanceToken(ctx context.Context, tk *Token) error {
if tk.Phase == PhaseCollect {
all := true
for _, n := range e.state.Nodes {
if !n.Alive {
continue
}
if !contains(tk.Passed, n.ID) {
all = false
break
}
}
if all && e.state.LeaderID == e.ID {
tk.Phase = PhaseSync
tk.Passed = nil
e.state.RoundDelay = tkDelaySince(tk)
}
}
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil
}
if e.send != nil {
return e.send(ctx, next, tk)
}
return nil
}
// tkDelaySince measures elapsed ms since token SentAt (leader round delay).
func tkDelaySince(tk *Token) time.Duration {
if tk.SentAt == 0 {
return 200 * time.Millisecond
}
return time.Duration(time.Now().UnixMilli()-tk.SentAt) * time.Millisecond
}
func contains(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
// StartRing kicks off the cycle from the leader by sending the first token
// (phase 1) to the next node. Called once at leader boot.
func (e *Engine) StartRing(ctx context.Context) {
if e.state.LeaderID != e.ID {
return
}
e.state.Cycle++
tk := &Token{
Cycle: e.state.Cycle,
Phase: PhaseCollect,
State: e.state,
Passed: []string{e.ID},
SentAt: time.Now().UnixMilli(),
}
e.phase1(tk)
if err := e.advanceToken(ctx, tk); err != nil {
log.Printf("ring[%s] start cycle %d: %v", e.ID, tk.Cycle, err)
}
}