- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
292 lines
10 KiB
Go
292 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
edgenode "iop/apps/edge/internal/node"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// NodeSnapshot is a surface-neutral view of a registered node, suitable for
|
|
// CLI, HTTP, or other transports. Connected reports whether the node is
|
|
// currently attached to the live registry; configured records that have never
|
|
// connected (or whose connection has dropped) still appear with Connected=false
|
|
// so the snapshot source of truth is the configured NodeStore, not the live
|
|
// registry.
|
|
// Label is the short display label (node0, node1). AgentKind and
|
|
// LifecycleState carry the registry entry's generic node classification and
|
|
// lifecycle when present; for configured-only records they are empty.
|
|
type NodeSnapshot struct {
|
|
NodeID string
|
|
Alias string
|
|
Label string
|
|
AgentKind string
|
|
LifecycleState string
|
|
Connected bool
|
|
Config *iop.NodeConfigPayload
|
|
ProviderSnapshots []*iop.ProviderSnapshot
|
|
}
|
|
|
|
// ListNodeSnapshots returns surface-neutral node descriptors derived from the
|
|
// current registry contents. For nodes backed by a queue manager, the entire
|
|
// snapshot is built inside a single queue-held critical section so that
|
|
// runtime config, queue state, and candidate pressure are observed atomically
|
|
// under the same lock order (queue → service) that SetRuntimeConfig follows.
|
|
// Nodes without a queue fall back to the catalog-only path under s.mu.
|
|
func (s *Service) ListNodeSnapshots() []NodeSnapshot {
|
|
if s.queue == nil {
|
|
return s.buildSnapshotNoQueue()
|
|
}
|
|
// Queue-backed: acquire the queue lock for the entire snapshot build so
|
|
// the runtime config, resource state, and candidate pressure are captured
|
|
// as a single consistent old-or-new tuple (no torn mixed state).
|
|
s.queue.mu.Lock()
|
|
defer s.queue.mu.Unlock()
|
|
return s.buildSnapshotLocked()
|
|
}
|
|
|
|
// buildSnapshotNoQueue builds node snapshots using the configured NodeStore
|
|
// catalog as the identity source, joined against the current Registry to
|
|
// determine live connectivity. Nodes that are configured but not currently
|
|
// connected still appear with Connected=false so the snapshot never silently
|
|
// drops offline records. When no node store is configured the builder falls
|
|
// back to the live registry so callers without a store still receive snapshots.
|
|
func (s *Service) buildSnapshotNoQueue() []NodeSnapshot {
|
|
store, _, _ := s.runtimeConfigSnapshot()
|
|
if store == nil {
|
|
return s.buildSnapshotFromRegistry()
|
|
}
|
|
records := store.All()
|
|
out := make([]NodeSnapshot, 0, len(records))
|
|
for _, rec := range records {
|
|
snap := s.emptySnapshotForConfigured(rec)
|
|
if r, ok := store.FindByID(rec.ID); ok {
|
|
if payload, err := edgenode.BuildConfigPayload(r); err == nil {
|
|
snap.Config = payload
|
|
}
|
|
if len(r.Providers) > 0 {
|
|
snap.ProviderSnapshots = staticProviderCatalogSnapshots(r, snap.Connected)
|
|
}
|
|
}
|
|
out = append(out, snap)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildSnapshotLocked builds node snapshots under the assumption that the
|
|
// caller already holds s.queue.mu. ListNodeSnapshots uses this variant to keep
|
|
// runtime config and queue state inside one queue-held critical section. The
|
|
// identity source is the configured NodeStore catalog joined with current
|
|
// Registry connectivity; the ordering is deterministic across calls. When no
|
|
// node store is configured the builder falls back to the live registry so
|
|
// callers without a store still receive snapshots.
|
|
func (s *Service) buildSnapshotLocked() []NodeSnapshot {
|
|
store, _, _ := s.runtimeConfigSnapshot()
|
|
if store == nil {
|
|
return s.buildSnapshotFromRegistry()
|
|
}
|
|
records := store.All()
|
|
out := make([]NodeSnapshot, 0, len(records))
|
|
for _, rec := range records {
|
|
snap := s.emptySnapshotForConfigured(rec)
|
|
if r, ok := store.FindByID(rec.ID); ok {
|
|
if payload, err := edgenode.BuildConfigPayload(r); err == nil {
|
|
snap.Config = payload
|
|
}
|
|
snap.ProviderSnapshots = s.queue.getSnapshotForNodeLocked(rec.ID, r, snap.Connected)
|
|
}
|
|
out = append(out, snap)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildSnapshotFromRegistry is the legacy catalog path used when no node store
|
|
// is configured. It preserves snapshot output for callers that build a Service
|
|
// without SetNodeStore.
|
|
func (s *Service) buildSnapshotFromRegistry() []NodeSnapshot {
|
|
entries := s.registry.AllReady()
|
|
out := make([]NodeSnapshot, 0, len(entries))
|
|
for _, entry := range entries {
|
|
out = append(out, s.emptySnapshot(entry))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// emptySnapshotForConfigured builds a NodeSnapshot from a configured NodeRecord.
|
|
// If registry has a live entry for the same NodeID it is merged in so Alias,
|
|
// Label, AgentKind, LifecycleState reflect the current connection; otherwise
|
|
// the snapshot is labeled from the configured ID/index and Connected is false.
|
|
func (s *Service) emptySnapshotForConfigured(rec *edgenode.NodeRecord) NodeSnapshot {
|
|
snap := NodeSnapshot{
|
|
NodeID: rec.ID,
|
|
Alias: rec.Alias,
|
|
Connected: false,
|
|
}
|
|
// GetReady, not Get: a pending accepted connection has not signalled dispatch
|
|
// readiness, so it is reported offline until its node applies config and
|
|
// installs its handler. This keeps "connected" aligned with "dispatchable".
|
|
if entry, ok := s.registry.GetReady(rec.ID); ok {
|
|
snap.Connected = true
|
|
snap.Label = entry.DisplayLabel()
|
|
snap.AgentKind = entry.AgentKind
|
|
snap.LifecycleState = entry.LifecycleState
|
|
} else {
|
|
snap.Label = configuredLabel(rec)
|
|
}
|
|
return snap
|
|
}
|
|
|
|
// configuredLabel builds a display label from a configured NodeRecord when no
|
|
// live registry entry is present. Uses index if available, otherwise alias,
|
|
// otherwise node ID.
|
|
func configuredLabel(rec *edgenode.NodeRecord) string {
|
|
if rec.Index >= 0 {
|
|
return fmt.Sprintf("node%d", rec.Index)
|
|
}
|
|
if rec.Alias != "" {
|
|
return rec.Alias
|
|
}
|
|
return rec.ID
|
|
}
|
|
|
|
// emptySnapshot returns a NodeSnapshot populated from the registry entry. Used
|
|
// for registry-only (non-catalog) lookups such as ResolveNodeSnapshot.
|
|
func (s *Service) emptySnapshot(entry *edgenode.NodeEntry) NodeSnapshot {
|
|
return NodeSnapshot{
|
|
NodeID: entry.NodeID,
|
|
Alias: entry.Alias,
|
|
Label: entry.DisplayLabel(),
|
|
AgentKind: entry.AgentKind,
|
|
LifecycleState: entry.LifecycleState,
|
|
Connected: true,
|
|
}
|
|
}
|
|
|
|
// NodeEntrySnapshot converts a registry entry into the surface-neutral DTO.
|
|
func NodeEntrySnapshot(entry *edgenode.NodeEntry) NodeSnapshot {
|
|
if entry == nil {
|
|
return NodeSnapshot{}
|
|
}
|
|
return NodeSnapshot{
|
|
NodeID: entry.NodeID,
|
|
Alias: entry.Alias,
|
|
Label: entry.DisplayLabel(),
|
|
AgentKind: entry.AgentKind,
|
|
LifecycleState: entry.LifecycleState,
|
|
Connected: true,
|
|
}
|
|
}
|
|
|
|
// staticProviderCatalogSnapshots builds catalog-based ProviderSnapshots for
|
|
// nodes whose service has no queue. The catalog entry (id/adapter/type/category/models)
|
|
// is always preserved so operators can see configured providers. When connected
|
|
// is false, effective status/health/capacity/longContext counters are dropped
|
|
// to unavailable/offline/0 so the snapshot reflects disconnect without removing
|
|
// the configured provider from the list. disabled providers keep status/health=disabled
|
|
// regardless of connectivity.
|
|
func staticProviderCatalogSnapshots(rec *edgenode.NodeRecord, connected bool) []*iop.ProviderSnapshot {
|
|
snaps := make([]*iop.ProviderSnapshot, 0, len(rec.Providers))
|
|
for _, prov := range rec.Providers {
|
|
if prov.ID == "" {
|
|
continue
|
|
}
|
|
servedModels := make([]string, len(prov.Models))
|
|
copy(servedModels, prov.Models)
|
|
lifecycleCaps := make([]string, len(prov.LifecycleCapabilities))
|
|
copy(lifecycleCaps, prov.LifecycleCapabilities)
|
|
|
|
if !config.ProviderEnabled(prov) {
|
|
snaps = append(snaps, &iop.ProviderSnapshot{
|
|
Adapter: prov.Adapter,
|
|
Status: "disabled",
|
|
Capacity: 0,
|
|
Id: prov.ID,
|
|
Type: prov.Type,
|
|
Category: string(prov.Category),
|
|
ServedModels: servedModels,
|
|
Health: "disabled",
|
|
LifecycleCapabilities: lifecycleCaps,
|
|
LongContextCapacity: 0,
|
|
LongInFlight: 0,
|
|
LongQueued: 0,
|
|
})
|
|
continue
|
|
}
|
|
|
|
snaps = append(snaps, &iop.ProviderSnapshot{
|
|
Adapter: prov.Adapter,
|
|
Status: effectiveStatus(connected),
|
|
Health: effectiveHealth(connected, prov.Health),
|
|
Capacity: int32(effectiveCount(connected, prov.Capacity)),
|
|
Id: prov.ID,
|
|
Type: prov.Type,
|
|
Category: string(prov.Category),
|
|
ServedModels: servedModels,
|
|
LifecycleCapabilities: lifecycleCaps,
|
|
LongContextCapacity: int32(effectiveCount(connected, prov.LongContextCapacity)),
|
|
LongInFlight: 0,
|
|
LongQueued: 0,
|
|
})
|
|
}
|
|
return snaps
|
|
}
|
|
|
|
func (s *Service) ResolveNode(ref string) (*edgenode.NodeEntry, error) {
|
|
return s.registry.Resolve(ref)
|
|
}
|
|
|
|
// ResolveDispatchReady resolves ref exactly like ResolveNode but only ever returns a
|
|
// dispatch-ready entry.
|
|
func (s *Service) ResolveDispatchReady(ref string) (*edgenode.NodeEntry, error) {
|
|
return s.registry.ResolveReady(ref)
|
|
}
|
|
|
|
// ResolveNodeSnapshot returns a surface-neutral DTO for the resolved node.
|
|
func (s *Service) ResolveNodeSnapshot(ref string) (NodeSnapshot, error) {
|
|
entry, err := s.ResolveNode(ref)
|
|
if err != nil {
|
|
return NodeSnapshot{}, err
|
|
}
|
|
return NodeEntrySnapshot(entry), nil
|
|
}
|
|
|
|
func (s *Service) GetCapabilities() []*iop.EdgeCapabilitySummary {
|
|
var entries []*edgenode.NodeEntry
|
|
if s.registry != nil {
|
|
entries = s.registry.AllReady()
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
return []*iop.EdgeCapabilitySummary{
|
|
{
|
|
Kind: "run",
|
|
Available: true,
|
|
Status: "ready",
|
|
Summary: "Generic execution node available",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Service) GetDomainAgents() []*iop.EdgeDomainAgentSummary {
|
|
return nil
|
|
}
|
|
|
|
// HealthStatusView is the Edge-owned status summary used by control commands
|
|
// such as health.check. It is derived from the live node registry and
|
|
// capability snapshot rather than a fixed simulation delay.
|
|
type HealthStatusView struct {
|
|
Nodes []NodeSnapshot
|
|
Capabilities []*iop.EdgeCapabilitySummary
|
|
}
|
|
|
|
// HealthStatus returns a status summary derived from the current node registry
|
|
// and capability snapshot. It is the real Edge-owned boundary that backs the
|
|
// health.check control command.
|
|
func (s *Service) HealthStatus() HealthStatusView {
|
|
return HealthStatusView{
|
|
Nodes: s.ListNodeSnapshots(),
|
|
Capabilities: s.GetCapabilities(),
|
|
}
|
|
}
|