iop/apps/edge/internal/transport/server.go
toki 4dcf6f0cf9 feat(edge): provider 리소스 승인 소유권 정렬 구현
- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady)
- ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐
- configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리)
- provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기
- 모델 대기열 승인/해제/스냅샷 서비스 구현
- 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
2026-07-22 18:10:54 +09:00

360 lines
11 KiB
Go

package transport
import (
"context"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"iop/apps/edge/internal/configrefresh"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/events"
iop "iop/proto/gen/iop"
)
const (
heartbeatIntervalSec = 2
// heartbeatWaitSec mirrors the node side. See the comment in
// apps/node/internal/transport/client.go for rationale.
heartbeatWaitSec = 5
)
const configRefreshTimeoutSec = 30
func edgeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.EdgeNodeEvent{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeNodeEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.ProviderTunnelFrame{}): func(b []byte) (proto.Message, error) {
m := &iop.ProviderTunnelFrame{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeReadyRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeReadyRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeCommandResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeCommandResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshResponse{}
return m, proto.Unmarshal(b, m)
},
}
}
// Server wraps proto-socket TcpServer and manages node connections.
type Server struct {
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
nodeStoreMu sync.RWMutex
nodeStore *edgenode.NodeStore
logger *zap.Logger
handlerMu sync.RWMutex
onRunEvent func(*iop.RunEvent)
onNodeEvent func(*iop.EdgeNodeEvent)
onTunnelFrame func(*iop.ProviderTunnelFrame)
// onRunLifecycle, onNodeConnect, and onNodeDisconnect are the authoritative
// lifecycle hooks. They run synchronously, ahead of the observability fanout,
// so resource accounting never depends on a bus delivery that is allowed to
// drop.
onRunLifecycle func(*iop.RunEvent)
onNodeConnect func(nodeID string, generation uint64)
onNodeDisconnect func(nodeID string, generation uint64, reason string)
stopping atomic.Bool
HeartbeatInterval int
HeartbeatWait int
}
func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) {
host, portStr, err := net.SplitHostPort(listen)
if err != nil {
return nil, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, err
}
s := &Server{
listen: listen,
registry: registry,
nodeStore: nodeStore,
logger: logger,
HeartbeatInterval: heartbeatIntervalSec,
HeartbeatWait: heartbeatWaitSec,
}
s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, s.HeartbeatInterval, s.HeartbeatWait, edgeParserMap())
})
s.tcp.OnClientConnected = s.onNodeConnected
return s, nil
}
func (s *Server) Start(ctx context.Context) error {
s.stopping.Store(false)
if err := s.tcp.Start(ctx); err != nil {
return err
}
s.logger.Info("edge listening for nodes", zap.String("addr", s.listen))
return nil
}
func (s *Server) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStoreMu.Lock()
s.nodeStore = store
s.nodeStoreMu.Unlock()
}
func (s *Server) nodeStoreSnapshot() *edgenode.NodeStore {
s.nodeStoreMu.RLock()
defer s.nodeStoreMu.RUnlock()
return s.nodeStore
}
func (s *Server) Stop() error {
s.stopping.Store(true)
return s.tcp.Stop()
}
func (s *Server) SetRunEventHandler(handler func(*iop.RunEvent)) {
s.handlerMu.Lock()
s.onRunEvent = handler
s.handlerMu.Unlock()
}
func (s *Server) SetNodeEventHandler(handler func(*iop.EdgeNodeEvent)) {
s.handlerMu.Lock()
s.onNodeEvent = handler
s.handlerMu.Unlock()
}
// SetTunnelFrameHandler registers the request-bound ProviderTunnelFrame
// handler. Tunnel frames carry raw provider passthrough bytes and are routed
// to a per-request channel by the handler; they must never be published to
// the run event bus.
func (s *Server) SetTunnelFrameHandler(handler func(*iop.ProviderTunnelFrame)) {
s.handlerMu.Lock()
s.onTunnelFrame = handler
s.handlerMu.Unlock()
}
// SetRunLifecycleHandler registers the authoritative run lifecycle handler. It
// is invoked for every run event, before the observability handler, so the
// service can settle terminal accounting regardless of event bus delivery.
func (s *Server) SetRunLifecycleHandler(handler func(*iop.RunEvent)) {
s.handlerMu.Lock()
s.onRunLifecycle = handler
s.handlerMu.Unlock()
}
// SetNodeConnectHandler registers the authoritative node connect handler. It is
// invoked only for an accepted (non-duplicate) registration, carrying that
// accepted connection's generation, before the connected event is emitted, so a
// reconnect re-activates the node's provider resources and pumps its stranded
// waiters regardless of event bus delivery.
func (s *Server) SetNodeConnectHandler(handler func(nodeID string, generation uint64)) {
s.handlerMu.Lock()
s.onNodeConnect = handler
s.handlerMu.Unlock()
}
// SetNodeDisconnectHandler registers the authoritative node disconnect handler.
// It is invoked only for the client that still owned the registry entry, carrying
// that owner's connection generation, before the disconnected event is emitted.
func (s *Server) SetNodeDisconnectHandler(handler func(nodeID string, generation uint64, reason string)) {
s.handlerMu.Lock()
s.onNodeDisconnect = handler
s.handlerMu.Unlock()
}
func (s *Server) HasRunLifecycleHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onRunLifecycle != nil
}
func (s *Server) HasNodeConnectHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onNodeConnect != nil
}
func (s *Server) HasNodeDisconnectHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onNodeDisconnect != nil
}
func (s *Server) HasTunnelFrameHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onTunnelFrame != nil
}
func (s *Server) HasRunEventHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onRunEvent != nil
}
func (s *Server) HasNodeEventHandler() bool {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
return s.onNodeEvent != nil
}
// onNodeConnected wires the per-connection listeners in a fixed order: run
// events, provider tunnel frames, the registration request handler, then the
// dispatch-ready request handler. The ready listener is bound at connect time so
// it is already installed when the node sends NodeReadyRequest after applying its
// config. The concrete listener bodies live in connection_handlers.go.
func (s *Server) onNodeConnected(client *toki.TcpClient) {
s.logger.Info("node connection established")
s.registerRunEventListener(client)
s.registerTunnelFrameListener(client)
s.registerNodeRequestListener(client)
s.registerNodeReadyListener(client)
}
// PushConfigRefresh sends a NodeConfigRefreshRequest to every configured node
// and collects per-node results. Nodes that are not in the live registry or
// have no alive client are recorded as skipped. Connected nodes receive a
// node-specific payload built from the refreshed NodeStore record. Transport
// failures are recorded as failed and do not roll back the local config commit.
func (s *Server) PushConfigRefresh(ctx context.Context, req *iop.NodeConfigRefreshRequest) []configrefresh.NodeResult {
ns := s.nodeStoreSnapshot()
var records []*edgenode.NodeRecord
if ns != nil {
records = ns.All()
}
results := make([]configrefresh.NodeResult, 0, len(records))
timeout := time.Duration(configRefreshTimeoutSec) * time.Second
for _, rec := range records {
nr := configrefresh.NodeResult{
NodeID: rec.ID,
Alias: rec.Alias,
}
// GetReady, not Get: a pending accepted connection has not installed its
// handler, so a config-refresh push would be dropped by the node. Skip it
// until it signals readiness — the node applies the latest config from its
// RegisterResponse on connect anyway.
entry, ok := s.registry.GetReady(rec.ID)
if !ok || entry.Client == nil || !entry.Client.IsAlive() {
nr.Status = "skipped"
results = append(results, nr)
continue
}
nodeReq := &iop.NodeConfigRefreshRequest{
RequestId: req.GetRequestId(),
ChangedPaths: req.GetChangedPaths(),
}
if payload, err := edgenode.BuildConfigPayload(rec); err == nil {
nodeReq.Config = payload
}
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&entry.Client.Communicator,
nodeReq,
timeout,
)
if err != nil {
nr.Status = "failed"
nr.Error = err.Error()
results = append(results, nr)
continue
}
switch resp.GetStatus() {
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED:
nr.Status = "applied"
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED:
nr.Status = "restart_required"
nr.RestartRequiredPaths = resp.GetRestartRequiredPaths()
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED:
nr.Status = "failed"
nr.Error = resp.GetError()
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_SKIPPED:
nr.Status = "skipped"
default:
nr.Status = "skipped"
}
results = append(results, nr)
}
return results
}
func (s *Server) enrichRunEvent(event *iop.RunEvent) {
if event == nil || event.GetNodeAlias() != "" || s.registry == nil {
return
}
nodeID := event.GetNodeId()
if nodeID == "" {
return
}
if entry, ok := s.registry.Get(nodeID); ok && entry.Alias != "" {
event.NodeAlias = entry.Alias
}
}
func (s *Server) emitNodeEvent(event *iop.EdgeNodeEvent) {
s.logger.Debug("node event received",
zap.String("node_id", event.GetNodeId()),
zap.String("type", event.GetType()),
zap.String("reason", event.GetReason()),
)
s.handlerMu.RLock()
handler := s.onNodeEvent
s.handlerMu.RUnlock()
if handler != nil {
handler(event)
}
}
func safePrefix(s string) string {
if len(s) > 8 {
return s[:8] + "..."
}
return s
}
func transportDisconnectMetadata(info toki.DisconnectInfo) map[string]string {
metadata := make(map[string]string, 2)
if info.Reason != "" {
metadata[events.MetadataTransportCloseReason] = info.Reason
}
if info.Error != "" {
metadata[events.MetadataTransportCloseError] = info.Error
}
if len(metadata) == 0 {
return nil
}
return metadata
}
func transportDisconnectFields(info toki.DisconnectInfo) []zap.Field {
fields := make([]zap.Field, 0, 2)
if info.Reason != "" {
fields = append(fields, zap.String("transport_close_reason", info.Reason))
}
if info.Error != "" {
fields = append(fields, zap.String("transport_close_error", info.Error))
}
return fields
}