- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
450 lines
14 KiB
Go
450 lines
14 KiB
Go
package node
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
const (
|
|
LifecycleAccepted = "accepted"
|
|
LifecycleOnline = "online"
|
|
LifecycleFailed = "failed"
|
|
LifecycleConnected = "connected"
|
|
)
|
|
|
|
// NodeEntry represents one connected node. ConnectionGeneration is the internal
|
|
// monotonic identity the registry assigns to each accepted connection of a node.
|
|
// It is not a wire/config field; provider leases and dispatch handoffs bind to it
|
|
// so a stale (superseded or rejected) connection can be fenced by identity, not
|
|
// only by client pointer.
|
|
//
|
|
// DispatchReady separates an accepted connection that has only claimed ownership
|
|
// and received its config (pending) from one the node has confirmed is ready to
|
|
// receive dispatch (config applied, handler installed). A pending entry occupies
|
|
// the id so duplicate registrations are rejected, but it is excluded from run
|
|
// dispatch, config-refresh push, and connected snapshots/events until the node's
|
|
// NodeReadyRequest flips this to true. The flag is only ever mutated under the
|
|
// registry lock; read it through the registry's ready-aware helpers, never off a
|
|
// shared *NodeEntry, so the transition stays race-free.
|
|
type NodeEntry struct {
|
|
NodeID string
|
|
Alias string
|
|
AgentKind string
|
|
LifecycleState string
|
|
Client *toki.TcpClient
|
|
Index int
|
|
HasIndex bool
|
|
ConnectionGeneration uint64
|
|
DispatchReady bool
|
|
}
|
|
|
|
// Registry manages all nodes connected to edge.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
byID map[string]*NodeEntry
|
|
byAlias map[string]*NodeEntry
|
|
byIndex map[int]*NodeEntry
|
|
indexByID map[string]int
|
|
nextIndex int
|
|
// genByID holds the per-node connection generation counter. It is never
|
|
// reset — a reconnecting node always draws a strictly higher generation, so
|
|
// an old connection's late callback can never be mistaken for the new owner.
|
|
genByID map[string]uint64
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
byID: make(map[string]*NodeEntry),
|
|
byAlias: make(map[string]*NodeEntry),
|
|
byIndex: make(map[int]*NodeEntry),
|
|
indexByID: make(map[string]int),
|
|
genByID: make(map[string]uint64),
|
|
}
|
|
}
|
|
|
|
// Register inserts entry unconditionally and marks it dispatch-ready. It models a
|
|
// fully-connected, dispatchable node in one step; the transport's two-phase
|
|
// accepted→ready handshake uses RegisterIfAbsent (pending) plus
|
|
// MarkDispatchReadyIfClient instead.
|
|
func (r *Registry) Register(entry *NodeEntry) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry.DispatchReady = true
|
|
r.registerLocked(entry)
|
|
}
|
|
|
|
// RegisterIfAbsent registers entry only when the node id is not already
|
|
// connected. The check and insert happen under one lock so concurrent duplicate
|
|
// registration attempts cannot both be accepted by the transport server. The
|
|
// entry is left pending (DispatchReady=false): it claims the id so duplicates are
|
|
// rejected, but it is excluded from dispatch/refresh/connected snapshots until
|
|
// MarkDispatchReadyIfClient flips it ready on the node's NodeReadyRequest.
|
|
func (r *Registry) RegisterIfAbsent(entry *NodeEntry) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, exists := r.byID[entry.NodeID]; exists {
|
|
return false
|
|
}
|
|
r.registerLocked(entry)
|
|
return true
|
|
}
|
|
|
|
func (r *Registry) registerLocked(entry *NodeEntry) {
|
|
if entry.AgentKind == "" {
|
|
entry.AgentKind = config.AgentKindGenericNode
|
|
}
|
|
if entry.LifecycleState == "" {
|
|
entry.LifecycleState = LifecycleConnected
|
|
}
|
|
// A connection generation is minted only for an accepted registration, so a
|
|
// rejected duplicate (RegisterIfAbsent returns before reaching here) never
|
|
// bumps the live node's generation.
|
|
r.genByID[entry.NodeID]++
|
|
entry.ConnectionGeneration = r.genByID[entry.NodeID]
|
|
if idx, ok := r.indexByID[entry.NodeID]; ok {
|
|
entry.Index = idx
|
|
entry.HasIndex = true
|
|
} else {
|
|
if !entry.HasIndex {
|
|
entry.Index = r.nextIndex
|
|
}
|
|
entry.HasIndex = true
|
|
r.indexByID[entry.NodeID] = entry.Index
|
|
if entry.Index >= r.nextIndex {
|
|
r.nextIndex = entry.Index + 1
|
|
}
|
|
}
|
|
r.byID[entry.NodeID] = entry
|
|
r.byIndex[entry.Index] = entry
|
|
if entry.Alias != "" {
|
|
r.byAlias[entry.Alias] = entry
|
|
}
|
|
}
|
|
|
|
func (r *Registry) UpdateLifecycle(nodeID string, state string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, ok := r.byID[nodeID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
entry.LifecycleState = state
|
|
return true
|
|
}
|
|
|
|
func (e *NodeEntry) DisplayLabel() string {
|
|
if e == nil {
|
|
return "unknown"
|
|
}
|
|
if e.HasIndex {
|
|
return fmt.Sprintf("node%d", e.Index)
|
|
}
|
|
if e.Alias != "" {
|
|
return e.Alias
|
|
}
|
|
if e.NodeID != "" {
|
|
return e.NodeID
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func (r *Registry) Unregister(nodeID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if entry, ok := r.byID[nodeID]; ok {
|
|
r.unregisterLocked(nodeID, entry)
|
|
}
|
|
}
|
|
|
|
// UnregisterIfClient removes nodeID only when the currently registered entry
|
|
// belongs to client, returning the removed owner's connection generation. Late
|
|
// disconnect callbacks from rejected or superseded connections must not clear the
|
|
// live registry entry, and the returned generation lets the transport fence the
|
|
// exact connection that closed rather than whatever owns the id now.
|
|
func (r *Registry) UnregisterIfClient(nodeID string, client *toki.TcpClient) (uint64, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, ok := r.byID[nodeID]
|
|
if !ok || entry.Client != client {
|
|
return 0, false
|
|
}
|
|
generation := entry.ConnectionGeneration
|
|
r.unregisterLocked(nodeID, entry)
|
|
return generation, true
|
|
}
|
|
|
|
// CurrentGeneration returns the connection generation of the node id's current
|
|
// registry owner, or false when no entry is registered.
|
|
func (r *Registry) CurrentGeneration(nodeID string) (uint64, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
entry, ok := r.byID[nodeID]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return entry.ConnectionGeneration, true
|
|
}
|
|
|
|
// IsCurrentOwnerGeneration reports whether generation still matches the node id's
|
|
// current registry owner. A dispatch path calls it just before sending so a lease
|
|
// minted for a connection that has since disconnected or been superseded by a
|
|
// reconnect is fenced instead of dispatched to a dead client.
|
|
func (r *Registry) IsCurrentOwnerGeneration(nodeID string, generation uint64) bool {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
entry, ok := r.byID[nodeID]
|
|
return ok && entry.ConnectionGeneration == generation
|
|
}
|
|
|
|
// Clone returns a shallow copy of NodeEntry, preserving pointers but copying fields
|
|
// for safe, lock-free snapshot reads after registry lock release.
|
|
func (e *NodeEntry) Clone() *NodeEntry {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return &NodeEntry{
|
|
NodeID: e.NodeID,
|
|
Alias: e.Alias,
|
|
AgentKind: e.AgentKind,
|
|
LifecycleState: e.LifecycleState,
|
|
Client: e.Client,
|
|
Index: e.Index,
|
|
HasIndex: e.HasIndex,
|
|
ConnectionGeneration: e.ConnectionGeneration,
|
|
DispatchReady: e.DispatchReady,
|
|
}
|
|
}
|
|
|
|
// MarkDispatchReadyIfClient transitions the node id's current owner to
|
|
// dispatch-ready, but only when the still-registered entry belongs to client. It
|
|
// returns the owner's connection generation, whether this call performed the
|
|
// pending→ready transition, and whether client is the current owner at all.
|
|
//
|
|
// - ok=false: client no longer owns the entry (superseded by a reconnect, or the
|
|
// entry was already removed by a disconnect). The ready signal is stale and
|
|
// must be rejected — the transport tells the node to reconnect.
|
|
// - ok=true, transitioned=true: the first ready for this connection. The caller
|
|
// opens dispatch eligibility, pumps stranded waiters, and emits the connected
|
|
// event exactly once.
|
|
// - ok=true, transitioned=false: a duplicate ready for an already-ready owner.
|
|
// The caller acks success but runs no additional pump or event.
|
|
func (r *Registry) MarkDispatchReadyIfClient(nodeID string, client *toki.TcpClient) (generation uint64, transitioned bool, ok bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, exists := r.byID[nodeID]
|
|
if !exists || entry.Client != client {
|
|
return 0, false, false
|
|
}
|
|
if entry.DispatchReady {
|
|
return entry.ConnectionGeneration, false, true
|
|
}
|
|
entry.DispatchReady = true
|
|
return entry.ConnectionGeneration, true, true
|
|
}
|
|
|
|
// MarkDispatchReadyOwner transitions the node id's current owner to dispatch-ready
|
|
// under the registry lock, returning a cloned snapshot of the NodeEntry, transitioned flag, and ok.
|
|
// Use this snapshot to drive downstream lifecycle callbacks and ready notifications safely.
|
|
func (r *Registry) MarkDispatchReadyOwner(nodeID string, client *toki.TcpClient) (entry *NodeEntry, transitioned bool, ok bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, exists := r.byID[nodeID]
|
|
if !exists || entry.Client != client {
|
|
return nil, false, false
|
|
}
|
|
if entry.DispatchReady {
|
|
return entry.Clone(), false, true
|
|
}
|
|
entry.DispatchReady = true
|
|
return entry.Clone(), true, true
|
|
}
|
|
|
|
// WithCurrentOwner executes fn under the registry lock only when the currently
|
|
// registered owner for entry.NodeID exactly matches the provided entry's Client
|
|
// and ConnectionGeneration. This guarantees the callback runs against the current
|
|
// active owner, ensuring no stale ready events are emitted.
|
|
func (r *Registry) WithCurrentOwner(entry *NodeEntry, fn func()) bool {
|
|
if entry == nil {
|
|
return false
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
current, exists := r.byID[entry.NodeID]
|
|
if !exists || current.Client != entry.Client || current.ConnectionGeneration != entry.ConnectionGeneration {
|
|
return false
|
|
}
|
|
fn()
|
|
return true
|
|
}
|
|
|
|
// WithCurrentDispatchOwner executes fn under the registry lock only when the currently
|
|
// registered owner for nodeID matches client and generation. It prevents check-then-act
|
|
// races between connection generation checks and dispatch enqueuing/handoff.
|
|
func (r *Registry) WithCurrentDispatchOwner(nodeID string, client *toki.TcpClient, generation uint64, fn func() error) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
current, exists := r.byID[nodeID]
|
|
if !exists || current.Client != client || current.ConnectionGeneration != generation {
|
|
return fmt.Errorf("provider node %q connection changed before dispatch (fenced generation %d)", nodeID, generation)
|
|
}
|
|
return fn()
|
|
}
|
|
|
|
func (r *Registry) unregisterLocked(nodeID string, entry *NodeEntry) {
|
|
if entry.Alias != "" {
|
|
delete(r.byAlias, entry.Alias)
|
|
}
|
|
delete(r.byIndex, entry.Index)
|
|
delete(r.byID, nodeID)
|
|
}
|
|
|
|
func (r *Registry) Get(nodeID string) (*NodeEntry, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
e, ok := r.byID[nodeID]
|
|
return e, ok
|
|
}
|
|
|
|
// GetReady returns the entry for nodeID only when it exists and is
|
|
// dispatch-ready. Connected snapshots and config-refresh push use it so a pending
|
|
// (accepted-but-not-yet-ready) connection is reported offline and never pushed to.
|
|
func (r *Registry) GetReady(nodeID string) (*NodeEntry, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
e, ok := r.byID[nodeID]
|
|
if !ok || !e.DispatchReady {
|
|
return nil, false
|
|
}
|
|
return e, true
|
|
}
|
|
|
|
func (r *Registry) Resolve(ref string) (*NodeEntry, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.resolveLocked(ref, false)
|
|
}
|
|
|
|
// ResolveReady resolves ref exactly like Resolve but only ever returns a
|
|
// dispatch-ready entry. Direct-dispatch paths (run/cancel/tunnel/node-command)
|
|
// use it so a request is never sent to a pending connection whose node has not
|
|
// installed its handler yet; observational lookups keep using Resolve.
|
|
func (r *Registry) ResolveReady(ref string) (*NodeEntry, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.resolveLocked(ref, true)
|
|
}
|
|
|
|
func (r *Registry) resolveLocked(ref string, readyOnly bool) (*NodeEntry, error) {
|
|
eligible := func(entry *NodeEntry) bool {
|
|
return entry != nil && (!readyOnly || entry.DispatchReady)
|
|
}
|
|
|
|
if ref != "" {
|
|
if entry, ok := r.byID[ref]; ok && eligible(entry) {
|
|
return entry, nil
|
|
}
|
|
if entry, ok := r.byAlias[ref]; ok && eligible(entry) {
|
|
return entry, nil
|
|
}
|
|
if entry, ok := r.resolveDisplayLabelLocked(ref); ok && eligible(entry) {
|
|
return entry, nil
|
|
}
|
|
return nil, fmt.Errorf("node %q not found", ref)
|
|
}
|
|
|
|
var only *NodeEntry
|
|
count := 0
|
|
for _, entry := range r.byID {
|
|
if !eligible(entry) {
|
|
continue
|
|
}
|
|
count++
|
|
only = entry
|
|
}
|
|
switch count {
|
|
case 1:
|
|
return only, nil
|
|
case 0:
|
|
return nil, fmt.Errorf("no nodes connected")
|
|
default:
|
|
return nil, fmt.Errorf("multiple nodes connected; select one with /node <id|alias>")
|
|
}
|
|
}
|
|
|
|
func (r *Registry) resolveDisplayLabelLocked(ref string) (*NodeEntry, bool) {
|
|
idx, ok := parseDisplayNodeIndex(ref)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
entry, ok := r.byIndex[idx]
|
|
return entry, ok
|
|
}
|
|
|
|
func parseDisplayNodeIndex(ref string) (int, bool) {
|
|
if !strings.HasPrefix(ref, "node") {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimPrefix(ref, "node")
|
|
if raw == "" {
|
|
return 0, false
|
|
}
|
|
idx, err := strconv.Atoi(raw)
|
|
if err != nil || idx < 0 {
|
|
return 0, false
|
|
}
|
|
return idx, true
|
|
}
|
|
|
|
func (r *Registry) All() []*NodeEntry {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.collectLocked(false)
|
|
}
|
|
|
|
// AllReady returns only the dispatch-ready entries, sorted like All. Candidate
|
|
// resolution and capability listing use it so a pending accepted connection is
|
|
// never offered as a run/provider-pool dispatch target before its node signals
|
|
// readiness.
|
|
func (r *Registry) AllReady() []*NodeEntry {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.collectLocked(true)
|
|
}
|
|
|
|
func (r *Registry) collectLocked(readyOnly bool) []*NodeEntry {
|
|
out := make([]*NodeEntry, 0, len(r.byID))
|
|
for _, e := range r.byID {
|
|
if readyOnly && !e.DispatchReady {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].HasIndex != out[j].HasIndex {
|
|
return out[i].HasIndex
|
|
}
|
|
if out[i].HasIndex && out[i].Index != out[j].Index {
|
|
return out[i].Index < out[j].Index
|
|
}
|
|
return out[i].NodeID < out[j].NodeID
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (r *Registry) Count() int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return len(r.byID)
|
|
}
|
|
|
|
// Pick is deprecated: use Resolve("") instead for single-node fallback, or Resolve(ref) for explicit selection.
|
|
func (r *Registry) Pick() (*NodeEntry, error) {
|
|
return r.Resolve("")
|
|
}
|