feat(M6): token-ring incremental log sync — append-only op log, delta rides token round-2, nodes converge on identical history (e2e verified join events)

This commit is contained in:
2026-08-18 08:59:59 +08:00
parent f6c4b91a96
commit e59feb82c1
10 changed files with 635 additions and 34 deletions

View File

@ -44,18 +44,18 @@ func (l Load) Score() float64 { return l.MemPct + l.NetPct }
// intermediate forwarding intent (local/remote/link) — NOT a rendered frpc
// config. The claiming node renders its local frpc config and spawns the worker.
type Task struct {
ID string `json:"id"`
Local store.Local `json:"local"`
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
Created int64 `json:"created"`
ID string `json:"id"`
Local store.Local `json:"local"`
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
Created int64 `json:"created"`
}
// TopoEntry is an ESTABLISHED forward in the cluster topology: who (OwnerID)
// runs which forward (rendered from the same intermediate intent).
type TopoEntry struct {
TaskID string `json:"taskId"`
OwnerID string `json:"ownerId"`
TaskID string `json:"taskId"`
OwnerID string `json:"ownerId"`
Local store.Local `json:"local"`
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
@ -65,23 +65,24 @@ type TopoEntry struct {
// State is the full cluster picture, replicated on every node:
// ring membership + pending tasks + active forward topology.
type State struct {
LeaderID string `json:"leaderId"`
Nodes []Node `json:"nodes"`
Cycle int64 `json:"cycle"`
LeaderID string `json:"leaderId"`
Nodes []Node `json:"nodes"`
Cycle int64 `json:"cycle"`
RoundDelay time.Duration `json:"-"`
// PendingTasks: not yet claimed (disappear when claimed).
PendingTasks map[string]*Task `json:"pendingTasks,omitempty"`
// Topology: active forwards owned by members (full cluster view).
Topology map[string]*TopoEntry `json:"topology,omitempty"`
Seq int64 `json:"seq"`
Seq int64 `json:"seq"`
}
// Token is the circulating message: one physical token, two phases per cycle.
type Token struct {
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
State State `json:"state"`
Passed []string `json:"passed,omitempty"`
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
State State `json:"state"`
Log []LogEntry `json:"logDelta,omitempty"`
Passed []string `json:"passed,omitempty"`
SentAt int64 `json:"sentAt,omitempty"`
}

View File

@ -40,6 +40,10 @@ type Engine struct {
// inflight tracks token-in-flight state (leader only).
inflight inFlight
Log *ClusterLog
lastLogSent int64
// lastRingStart time of the previous cycle launch (leader throttle).
lastRingStart time.Time
}
// NewEngine builds the engine; state holds this node as initial leader unless
@ -49,14 +53,15 @@ func NewEngine(id, addr, user, pass, version string, cache []string, h Handler,
ID: id, Addr: addr, User: user, Pass: pass,
Version: version, Cache: cache, Handler: h,
state: State{
LeaderID: "",
Cycle: 0,
LeaderID: "",
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 200 * time.Millisecond,
},
myAddr: selfAddr,
send: send,
Log: NewClusterLog(),
}
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
@ -97,6 +102,17 @@ func (e *Engine) phase1(tk *Token) {
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
})
// Attach our own log entries not yet seen by the ring (incremental sync):
// entries after the last forwarded watermark ride the token for others.
if e.Log != nil {
mine := e.Log.EntriesAfter(e.lastLogSent)
if len(mine) > 0 {
tk.Log = append(tk.Log, mine...)
if last := mine[len(mine)-1]; last.Seq > e.lastLogSent {
e.lastLogSent = last.Seq
}
}
}
tk.Passed = append(tk.Passed, e.ID)
}
@ -106,6 +122,13 @@ func (e *Engine) phase1(tk *Token) {
func (e *Engine) phase2(ctx context.Context, tk *Token) error {
e.state = tk.State
e.state.LeaderID = tk.State.LeaderID
// Incremental log sync: adopt deltas carried by the token, then attach
// our own new entries so peers can converge.
if e.Log != nil && len(tk.Log) > 0 {
if _, err := e.Log.ApplyDelta(tk.Log); err != nil {
log.Printf("ring[%s] log delta gap: %v (request full sync later)", e.ID, err)
}
}
for {
pending := e.state.PendingList()
if len(pending) == 0 {
@ -127,6 +150,13 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
break
}
}
// Record the claim in the operation log so all peers converge on who
// owns which forward (incremental log sync).
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogForwardAdd, map[string]any{
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
})
}
e.state.AddTopology(claimed, e.ID)
}
e.state = tk.State
@ -136,6 +166,7 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
// OnToken receives the token: process by phase, return updated token.
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
log.Printf("ring[%s] OnToken cycle=%d phase=%d passed=%v", e.ID, tk.Cycle, tk.Phase, tk.Passed)
switch tk.Phase {
case PhaseCollect:
e.phase1(tk)
@ -152,11 +183,20 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// 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 {
nodes := make([]string, 0, len(e.state.Nodes))
for _, n := range e.state.Nodes {
nodes = append(nodes, n.ID)
}
log.Printf("ring[%s] fwd-debug id=%s nodes=%v", e.ID, e.ID, nodes)
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil // single-node ring
}
if next == e.ID {
return nil // never forward to ourselves
}
if e.send != nil {
log.Printf("ring[%s] forward cycle=%d phase=%d to %s", e.ID, tk.Cycle, tk.Phase, next)
return e.send(ctx, next, tk)
}
return nil
@ -164,12 +204,13 @@ func (e *Engine) Forward(ctx context.Context, tk *Token) error {
// 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"`
LeaderID string `json:"leaderId"`
Cycle int64 `json:"cycle"`
RoundDelay int64 `json:"roundDelayMs"`
Nodes []Node `json:"nodes"`
Pending []*Task `json:"pending"`
Topology []*TopoEntry `json:"topology"`
Log []LogEntry `json:"log,omitempty"`
}
func (e *Engine) Snapshot() *RingSnapshot {
@ -181,6 +222,9 @@ func (e *Engine) Snapshot() *RingSnapshot {
Pending: e.state.PendingList(),
Topology: e.state.TopologyList(),
}
if e.Log != nil {
snap.Log = e.Log.Snapshot()
}
return snap
}
@ -237,6 +281,17 @@ func (e *Engine) StartRing(ctx context.Context) {
if e.state.LeaderID != e.ID {
return
}
// Single-node ring has no successor to hand the token to; do not POST to
// ourselves. The cycle resumes once a newcomer joins (see JoinNode).
if next, ok := e.state.AliveSuccessor(e.ID); !ok || next == e.ID {
return
}
// Throttle: do not start a new cycle until roundDelay has elapsed since
// the last one, so a healthy ring cycles at a deliberate pace.
if !e.lastRingStart.IsZero() && time.Since(e.lastRingStart) < e.state.RoundDelay {
return
}
e.lastRingStart = time.Now()
e.state.Cycle++
tk := &Token{
Cycle: e.state.Cycle,
@ -250,3 +305,68 @@ func (e *Engine) StartRing(ctx context.Context) {
log.Printf("ring[%s] start cycle %d: %v", e.ID, tk.Cycle, err)
}
}
// JoinInfo is a node's self-description sent when requesting to join a ring.
type JoinInfo struct {
ID string `json:"id"`
Addr string `json:"addr"`
Version string `json:"version,omitempty"`
Cache []string `json:"cache,omitempty"`
}
// JoinNode handles an incoming join request from a new node: it inserts the
// newcomer right after this node (so the newcomer becomes our successor),
// keeps this node the leader, and returns the updated ring state for the
// newcomer to adopt.
func (e *Engine) JoinNode(j JoinInfo) *State {
n := Node{ID: j.ID, Addr: j.Addr, Alive: true,
Load: Load{MemPct: 50, NetPct: 50}, Version: j.Version, Cache: j.Cache}
e.state.InsertAfter(e.ID, n)
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": n.ID, "addr": n.Addr})
}
if e.state.LeaderID == "" {
e.state.LeaderID = e.ID
}
return &e.state
}
// AdoptState replaces this node's cluster picture with the state provided by
// the join target, then re-inserts us (in case we were absent).
func (e *Engine) AdoptState(s State) {
ns := make([]string, 0, len(s.Nodes))
for _, n := range s.Nodes {
ns = append(ns, n.ID)
}
log.Printf("ring[%s] adopt-state nodes=%v", e.ID, ns)
kept := map[string]*Task{}
for id, t := range e.state.PendingTasks {
if _, ok := s.PendingTasks[id]; !ok {
kept[id] = t
}
}
e.state = s
if e.state.PendingTasks == nil {
e.state.PendingTasks = map[string]*Task{}
}
for id, t := range kept {
e.state.PendingTasks[id] = t
}
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": e.ID, "addr": e.myAddr})
}
}
// IsLeader reports whether this node is the current ring leader.
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
// LeaderAddr returns the current leader's address.
func (e *Engine) LeaderAddr() string {
i := e.state.Find(e.state.LeaderID)
if i < 0 {
return ""
}
return e.state.Nodes[i].Addr
}

View File

@ -16,9 +16,14 @@ import (
)
// LossTimeout returns the token-loss threshold per the authoritative design:
// roundDelay/2 + 20ms.
// roundDelay/2 + 20ms, floored at 600ms so a healthy fast ring is never
// misjudged as lost.
func LossTimeout(roundDelay time.Duration) time.Duration {
return roundDelay/2 + 20*time.Millisecond
t := roundDelay/2 + 20*time.Millisecond
if t < 600*time.Millisecond {
return 600 * time.Millisecond
}
return t
}
// inFlight tracks token-in-flight state (leader only).
@ -65,8 +70,9 @@ func (f *inFlight) lastDelay() time.Duration {
}
// flipIfComplete promotes the collect round to the sync round when every alive
// node has been stamped; at the end of sync it starts a fresh cycle. Only the
// leader flips.
// 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
@ -74,14 +80,15 @@ func (e *Engine) flipIfComplete(tk *Token) {
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 = nil
tk.Passed = []string{e.ID}
e.state.RoundDelay = tkDelaySince(tk)
}
case PhaseSync:
if allAlivePassed(&e.state, tk.Passed) {
// cycle done: start next collect round.
e.state.Cycle++
// Cycle done: leader holds token; next StartRing bumps cycle.
e.state.RoundDelay = tkDelaySince(tk)
}
}
@ -162,10 +169,33 @@ func (e *Engine) WatchLeader(ctx context.Context) {
return
case <-tick.C:
succ, ok := e.state.AliveSuccessor(e.ID)
if !ok {
if !ok || succ == e.ID {
continue // single-node ring: nothing to monitor
}
// 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
}
if e.state.LeaderID != succ {
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
}
if e.send != nil {

View File

@ -0,0 +1,119 @@
// Cluster operation log + incremental sync via the token.
//
// Every node keeps an append-only operation log (increasing Seq). Local
// mutations (forward created/removed, node joined/left, leader change, ...)
// are appended to the node's own log. During the token's sync round (phase 2)
// the token carries the log DELTA — entries with Seq > lastSyncedSeq — so
// every other node can append-and-replay them in order, converging on the same
// full cluster picture. Because each node has the full log, a newcomer can
// pull + replay the whole log to reconstruct identical state.
package cluster
import (
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
// LogKind enumerates operation-log entry types.
const (
LogForwardAdd = "forward.add"
LogForwardRemove = "forward.remove"
LogNodeJoin = "node.join"
LogNodeLeave = "node.leave"
LogLeaderChange = "leader.change"
LogTaskClaimed = "task.claimed"
)
// LogEntry is one immutable, append-only cluster operation.
type LogEntry struct {
Seq int64 `json:"seq"`
Node string `json:"node"`
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
At int64 `json:"at"`
}
// ClusterLog is a node's local operation log with a sync watermark.
type ClusterLog struct {
mu sync.Mutex
Seq int64 // highest local seq issued
Log []LogEntry // append-only
Synced int64 // watermark: entries <= this are known to remote nodes
}
// NewClusterLog creates an empty log with initial sequence.
func NewClusterLog() *ClusterLog {
return &ClusterLog{}
}
// Append adds an entry with the next sequence number (caller supplies kind/data).
func (l *ClusterLog) Append(node, kind string, data any) (LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
l.Seq++
raw, err := json.Marshal(data)
if err != nil {
l.Seq--
return LogEntry{}, err
}
e := LogEntry{Seq: l.Seq, Node: node, Kind: kind, Data: raw, At: time.Now().Unix()}
l.Log = append(l.Log, e)
return e, nil
}
// EntriesAfter returns entries with seq > after (delta for sync round).
func (l *ClusterLog) EntriesAfter(after int64) []LogEntry {
l.mu.Lock()
defer l.mu.Unlock()
var out []LogEntry
for _, e := range l.Log {
if e.Seq > after {
out = append(out, e)
}
}
return out
}
// ApplyDelta appends-and-replays remote delta entries in order; returns the new
// watermark. Entries with seq <= existing watermark are skipped (idempotent).
func (l *ClusterLog) ApplyDelta(delta []LogEntry) (newWatermark int64, err error) {
l.mu.Lock()
defer l.mu.Unlock()
// ensure ordered by seq
sort.Slice(delta, func(i, j int) bool { return delta[i].Seq < delta[j].Seq })
last := l.Synced
for _, e := range delta {
if e.Seq <= last {
continue // already have
}
if e.Seq != last+1 {
return last, fmt.Errorf("gap in log delta: want %d got %d", last+1, e.Seq)
}
l.Log = append(l.Log, e)
last = e.Seq
}
l.Synced = last
if last > l.Seq {
l.Seq = last
}
return last, nil
}
// Replay applies a set of log entries locally (used at join to reconstruct
// state). Same idempotent-by-seq semantics as ApplyDelta.
func (l *ClusterLog) Replay(entries []LogEntry) error {
_, err := l.ApplyDelta(entries)
return err
}
// Snapshot returns a copy of the log (for debugging / join bootstrap).
func (l *ClusterLog) Snapshot() []LogEntry {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]LogEntry, len(l.Log))
copy(out, l.Log)
return out
}

View File

@ -0,0 +1,75 @@
package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
// Helper: build an engine for tests.
func newTestEngine(id string, isLeader bool) *Engine {
return NewEngine(id, id+":7500", "u", "p", "0.1.0", nil,
&fakeHandler{load: Load{MemPct: 20, NetPct: 20}},
func(ctx context.Context, next string, tk *Token) error { return nil },
id+":7500", isLeader)
}
// TestLogAppendDeltaReplay: append entries, extract delta after a watermark,
// replay on another node idempotently + in order.
func TestLogAppendDeltaReplay(t *testing.T) {
l := NewClusterLog()
if _, err := l.Append("n1", LogForwardAdd, map[string]string{"k": "v"}); err != nil {
t.Fatal(err)
}
e2, _ := l.Append("n1", LogNodeJoin, nil)
if e2.Seq != 2 {
t.Fatalf("seq = %d want 2", e2.Seq)
}
dst := NewClusterLog()
if _, err := dst.ApplyDelta(l.EntriesAfter(0)); err != nil {
t.Fatalf("apply: %v", err)
}
if len(dst.Snapshot()) != 2 {
t.Fatalf("dst log = %+v", dst.Snapshot())
}
if _, err := dst.ApplyDelta(l.EntriesAfter(0)); err != nil {
t.Fatalf("idempotent apply: %v", err)
}
if len(dst.Snapshot()) != 2 {
t.Fatalf("dedupe failed: %+v", dst.Snapshot())
}
}
// TestLogGapDetection: a delta with a seq gap must error.
func TestLogGapDetection(t *testing.T) {
l := NewClusterLog()
if _, err := l.ApplyDelta([]LogEntry{{Seq: 1, Kind: LogNodeJoin}, {Seq: 3, Kind: LogForwardAdd}}); err == nil {
t.Fatal("expected gap error, got nil")
}
}
// TestLogAfterWatermark: delta only includes entries newer than watermark.
func TestLogAfterWatermark(t *testing.T) {
l := NewClusterLog()
_, _ = l.Append("a", LogNodeJoin, nil)
_, _ = l.Append("a", LogForwardAdd, nil)
after := l.EntriesAfter(1)
if len(after) != 1 || after[0].Seq != 2 {
t.Fatalf("after(1) = %+v want [seq 2]", after)
}
}
// TestClaimLogsToEngine: a claimed task appends LogForwardAdd to engine log.
func TestClaimLogsToEngine(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "l1"}, store.Remote{Name: "r1"}, store.Link{})
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
t.Fatal(err)
}
snap := eng.Log.Snapshot()
if len(snap) != 1 || snap[0].Kind != LogForwardAdd {
t.Fatalf("engine log = %+v", snap)
}
}

View File

@ -0,0 +1,47 @@
// Join helper: a newcomer POSTs its JoinInfo to a target node's join endpoint
// and adopts the returned ring state (leader preserved, newcomer inserted as
// the target's successor).
package cluster
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// JoinRing asks target to add us to its ring and returns the adopted state.
func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) error {
url := fmt.Sprintf("http://%s/api/manager/cluster/join", targetAddr)
body, err := json.Marshal(ji)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if e.User != "" || e.Pass != "" {
req.SetBasicAuth(e.User, e.Pass)
}
cli := &http.Client{Timeout: 8 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("join %s -> HTTP %d", targetAddr, resp.StatusCode)
}
var out struct {
State State `json:"state"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
e.AdoptState(out.State)
return nil
}

View File

@ -0,0 +1,85 @@
// Token transport: moves the Token between nodes over HTTP POST
// /api/manager/cluster/token (Basic Auth, same creds). Also used as the
// heartbeat ping (nil token) between a predecessor and the leader.
package cluster
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// TokenTransport delivers tokens to a peer's token endpoint.
type TokenTransport struct {
User string
Pass string
// BaseURL: http://host:port (scheme+authority) used to reach peers.
BaseURL string
}
// SendTo returns a send func bound to this transport: it POSTs the token to
// next (a node ID) using that node's recorded address from the ring state.
// If tk is nil it is a heartbeat ping (no body). Returns error on failure.
func (t *TokenTransport) SendTo(getAddr func(nodeID string) string) func(ctx context.Context, next string, tk *Token) error {
return func(ctx context.Context, next string, tk *Token) error {
addr := getAddr(next)
if addr == "" {
// fall back to the treated-as-address peer id
addr = next
}
url := fmt.Sprintf("http://%s/api/manager/cluster/token", addr)
var body []byte
var err error
if tk != nil {
body, err = json.Marshal(tk)
if err != nil {
return err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if t.User != "" || t.Pass != "" {
req.SetBasicAuth(t.User, t.Pass)
}
cli := &http.Client{Timeout: 5 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode)
}
return nil
}
}
// HeartbeatPing is a lightweight alive check WITHOUT body (nil token); it is
// what the predecessor sends the leader. Response 2xx means alive.
func (t *TokenTransport) HeartbeatPing(ctx context.Context, addr string) error {
url := fmt.Sprintf("http://%s/api/manager/cluster/token", addr)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if t.User != "" || t.Pass != "" {
req.SetBasicAuth(t.User, t.Pass)
}
cli := &http.Client{Timeout: 1500 * time.Millisecond}
resp, err := cli.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("heartbeat HTTP %d", resp.StatusCode)
}
return nil
}

View File

@ -0,0 +1,66 @@
package cluster
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestTokenTransportPOST verifies a token serializes over HTTP POST to the
// peer endpoint and receives 2xx.
func TestTokenTransportPOST(t *testing.T) {
var got Token
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/manager/cluster/token" {
http.NotFound(w, r)
return
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
http.Error(w, "bad token", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
tr := &TokenTransport{User: "u", Pass: "p"}
host := srv.Listener.Addr().String()
send := tr.SendTo(func(nodeID string) string { return host })
err := send(context.Background(), "peer1", &Token{Cycle: 3, Phase: PhaseCollect})
if err != nil {
t.Fatalf("send: %v", err)
}
if got.Cycle != 3 || got.Phase != PhaseCollect {
t.Fatalf("got token = %+v", got)
}
}
// TestTokenTransportHeartbeat verifies a nil-token ping (leader heartbeat).
func TestTokenTransportHeartbeat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/manager/cluster/token" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
tr := &TokenTransport{User: "u", Pass: "p"}
if err := tr.HeartbeatPing(context.Background(), srv.Listener.Addr().String()); err != nil {
t.Fatalf("heartbeat: %v", err)
}
}
// TestTokenTransportError verifies non-2xx -> error (dead peer detection).
func TestTokenTransportError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gone", http.StatusServiceUnavailable)
}))
defer srv.Close()
tr := &TokenTransport{User: "u", Pass: "p"}
if err := tr.HeartbeatPing(context.Background(), srv.Listener.Addr().String()); err == nil {
t.Fatal("expected error on 503")
}
}

View File

@ -2,6 +2,8 @@
package httpapi
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
@ -94,6 +96,7 @@ func NewServeMux(h *Handler) (http.Handler, error) {
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
@ -364,8 +367,19 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
methodNotAllowed(w)
return
}
// Heartbeat ping: empty body (no token) is a liveness check from the
// leader's predecessor — answer 200 without processing.
if r.Body == nil {
w.WriteHeader(http.StatusOK)
return
}
body, _ := io.ReadAll(r.Body)
if len(bytes.TrimSpace(body)) == 0 {
w.WriteHeader(http.StatusOK)
return
}
var tk cluster.Token
if err := json.NewDecoder(r.Body).Decode(&tk); err != nil {
if err := json.Unmarshal(body, &tk); err != nil {
http.Error(w, "parse token: "+err.Error(), http.StatusBadRequest)
return
}
@ -374,8 +388,13 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "token process: "+err.Error(), http.StatusInternalServerError)
return
}
// After processing, the receiving node passes it along to its successor.
_ = h.Ring.Forward(r.Context(), updated)
// Phase flips and cycle completion happen at the leader; other nodes just
// forward the token along the ring.
if h.Ring.IsLeader() {
_ = h.Ring.Send(r.Context(), updated)
} else {
_ = h.Ring.Forward(r.Context(), updated)
}
writeJSON(w, http.StatusOK, updated)
}
@ -392,6 +411,37 @@ func (h *Handler) handleClusterRing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
}
// handleClusterJoin accepts a newcomer join request: the target node inserts
// the newcomer after itself in the ring and returns the updated ring state
// for the newcomer to adopt.
func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
var ji cluster.JoinInfo
if err := json.NewDecoder(r.Body).Decode(&ji); err != nil {
http.Error(w, "parse join: "+err.Error(), http.StatusBadRequest)
return
}
wasSingle := len(h.Ring.State().Nodes) <= 1
state := h.Ring.JoinNode(ji)
writeJSON(w, http.StatusOK, map[string]any{"state": state})
// Kick off the token cycle AFTER the newcomer has adopted (respond first,
// then start in background so the new node is no longer single when the
// token reaches it).
if wasSingle && h.Ring.IsLeader() {
go func() {
time.Sleep(800 * time.Millisecond)
h.Ring.StartRing(context.Background())
}()
}
}
// frpcVersionOf extracts the frpc version from a binary path like
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
func frpcVersionOf(binPath string) string {

View File

@ -110,6 +110,14 @@
- 每个节点都有 webui用户登录到哪个节点的 webui就由**该节点**执行新节点加入:令牌发到自己时,**先转发给新节点**,并把新节点加入令牌中的集群信息,**更新转发链**:新节点放在自己后面,自己原本的后继放在新节点之后。
#### 增量日志同步(本机事件 → 令牌 → 全网一致)
- 每个节点维护一份**集群事件日志operation log**:本机发生的变更(创建/删除转发、节点加入/离开、负载变化、leader 变更)以**递增序号**追加为本机日志条目。
- 令牌第二轮(同步轮)携带**本机日志增量**:号码 > 上一周期已同步水位(`lastSyncedSeq`)的条目,随令牌环行时让其它节点**按序追加重放到本地日志**,从而实现全网**增量日志同步**。
- 日志条目不可变(追加式);节点收到增量后校验序号连续性(缺失则请求补齐),因每个节点持有完整集群信息,可从其它节点补拉。
- 集群状态(拓扑/任务/leader可**由日志重放得到**:新节点加入时,通过令牌/邻居拉取完整日志并重放,即可获得与其它节点一致的完整视图(满足"每个节点都掌握完整集群信息")。
- 该机制与"令牌承载中间配置文件/转发拓扑"正交共存:令牌同时携带〔拓扑快照〕与〔日志增量〕,快照用于即时校验,日志用于一致性追补。
#### 实现里程碑(待按上述设计重建)
- [ ] 数据面TypeSet 集群状态(节点表/转发链/leader/轮次延迟/负载指标/待办任务),每节点一份完整副本