iop/apps/edge/internal/transport/server.go
toki 4cafc91323 fix(transport): 터널 지연에 맞춰 heartbeat 경계를 보강한다
장시간 provider prefill과 stream backpressure가 정상 노드를 끊지 않도록 liveness window를 확장하고 Chronos 분리 완료 문서와 잔여 artifact를 정리한다.
2026-08-02 22:30:25 +09:00

411 lines
13 KiB
Go

package transport
import (
"context"
"crypto/tls"
"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 (
// Match the Node's tunnel-tolerant liveness profile. Long provider prefill and
// streamed-response backpressure must not fence a healthy connection before
// it can service the next heartbeat.
heartbeatIntervalSec = 30
// heartbeatWaitSec mirrors the node side. See the comment in
// apps/node/internal/transport/client.go for rationale.
heartbeatWaitSec = 45
)
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)
peerMu sync.RWMutex
peerConnections map[*toki.TcpClient]net.Conn
requirePeerID bool
stopping atomic.Bool
HeartbeatInterval int
HeartbeatWait int
}
func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) {
return NewServerTLS(listen, nil, registry, nodeStore, logger)
}
// NewServerTLS creates the Edge-Node server with optional mTLS. Once supplied,
// the TLS configuration is used for every accepted connection; the server does
// not maintain a plaintext fallback listener.
func NewServerTLS(listen string, tlsConfig *tls.Config, 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,
peerConnections: make(map[*toki.TcpClient]net.Conn),
requirePeerID: tlsConfig != nil,
HeartbeatInterval: heartbeatIntervalSec,
HeartbeatWait: heartbeatWaitSec,
}
newClient := func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, s.HeartbeatInterval, s.HeartbeatWait, edgeParserMap())
if tlsConfig != nil {
s.peerMu.Lock()
s.peerConnections[client] = conn
s.peerMu.Unlock()
client.AddDisconnectListener(func(disconnected *toki.TcpClient) {
s.peerMu.Lock()
delete(s.peerConnections, disconnected)
s.peerMu.Unlock()
})
// NewTcpClient starts its read loop immediately. If the peer closed in
// the tiny window before the listener above was attached, remove the
// retained identity source here as well.
if !client.IsAlive() {
s.peerMu.Lock()
delete(s.peerConnections, client)
s.peerMu.Unlock()
}
}
return client
}
if tlsConfig != nil {
s.tcp = toki.NewTcpServerTLS(host, port, tlsConfig, newClient)
} else {
s.tcp = toki.NewTcpServer(host, port, newClient)
}
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)
err := s.tcp.Stop()
s.peerMu.Lock()
clear(s.peerConnections)
s.peerMu.Unlock()
return err
}
func (s *Server) peerConnection(client *toki.TcpClient) (net.Conn, bool) {
s.peerMu.RLock()
defer s.peerMu.RUnlock()
conn, ok := s.peerConnections[client]
return conn, ok
}
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
}