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

@ -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
}