iop/apps/edge/internal/service/run_submit.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

196 lines
6 KiB
Go

package service
import (
"context"
"fmt"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
// runSubscription holds the request-bound event channels of a dispatched run.
// A background run has no subscriber, so every field stays nil and close is a
// no-op.
type runSubscription struct {
runEvents <-chan *iop.RunEvent
nodeEvents <-chan *iop.EdgeNodeEvent
unregisterRun func()
unregisterNode func()
}
// close unregisters both subscriptions. It is safe on a background (empty)
// subscription.
func (sub *runSubscription) close() {
if sub.unregisterRun != nil {
sub.unregisterRun()
}
if sub.unregisterNode != nil {
sub.unregisterNode()
}
}
// subscribeRun opens the run and node event channels a foreground run needs.
// It runs before the send so events emitted immediately after dispatch are not
// missed.
func (s *Service) subscribeRun(runID, nodeID string, background bool) (*runSubscription, error) {
sub := &runSubscription{}
if background {
return sub, nil
}
if s.events == nil {
return nil, fmt.Errorf("event bus is not configured")
}
sub.runEvents, sub.unregisterRun = s.events.SubscribeRun(runID, 4096)
sub.nodeEvents, sub.unregisterNode = s.events.SubscribeNode(nodeID, 16)
return sub, nil
}
func (s *Service) SubmitRun(ctx context.Context, req SubmitRunRequest) (RunResult, error) {
if req.ProviderPool && req.ModelGroupKey != "" && s.queue != nil {
return s.submitRunQueued(ctx, req)
}
return s.submitRunDirect(req)
}
func (s *Service) submitRunDirect(req SubmitRunRequest) (RunResult, error) {
entry, err := s.ResolveDispatchReady(req.NodeRef)
if err != nil {
return nil, err
}
return s.dispatchToEntry(entry, req)
}
func (s *Service) submitRunQueued(ctx context.Context, req SubmitRunRequest) (RunResult, error) {
candidates, returnedPolicy, err := s.resolveQueueCandidates(req)
if err != nil {
return nil, err
}
long := req.ContextClass == contextClassLong
// For provider-pool requests the canonical policy is owned by the atomic
// runtime snapshot, not by the resolution path. Legacy paths use the
// policy derived from the request or store.
providerPool := req.ProviderPool
var policy groupPolicy
if providerPool {
_, _, policy = s.runtimeConfigSnapshot()
} else {
policy = returnedPolicy
}
selected, queueReason, err := s.queue.admitWithReason(ctx, req.ModelGroupKey, req.Adapter, req.Target, candidates, policy, s.resolveQueueCandidatesClosure(req), long, providerPool)
if err != nil {
return nil, err
}
// The admitted slot is owned by one reservation from here on: every failure
// path below releases through it, and a dispatched run hands it off to the
// event watcher.
reservation := newQueueReservation(s.queue, selected)
// Rewrite adapter and target for provider-pool dispatch: the winning candidate
// carries the concrete adapter and served model name determined at selection time.
if selected.adapter != "" {
req.Adapter = selected.adapter
}
if selected.servedTarget != "" {
req.Target = selected.servedTarget
}
runReq, runID, err := BuildRunRequest(req)
if err != nil {
reservation.release("build-error")
return nil, err
}
// Track inflight before send so the event watcher can release the slot
// even if a terminal event arrives before the Send call completes.
reservation.track(runID)
// Pre-send generation fence: if the selected connection was superseded by a
// disconnect/reconnect since admission, release the lease and fail instead of
// dispatching to a dead client.
if !s.candidateIsCurrentOwner(selected) {
reservation.release("stale-generation")
return nil, staleGenerationError(selected)
}
sub, err := s.subscribeRun(runID, selected.entry.NodeID, runReq.GetBackground())
if err != nil {
reservation.release("no-event-bus")
return nil, err
}
if err := selected.entry.Client.Send(runReq); err != nil {
reservation.release("send-error")
sub.close()
return nil, err
}
// The run now owns the slot: the event watcher releases it on the terminal
// run event or on node disconnect.
reservation.handOff()
return newRunHandle(RunDispatch{
RunID: runID,
NodeID: selected.entry.NodeID,
NodeLabel: nodeLabel(selected.entry),
ModelGroupKey: req.ModelGroupKey,
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
ProviderID: selected.providerID,
ProviderType: selected.providerType,
ExecutionPath: string(selected.executionPath),
QueueReason: queueReason,
}, sub), nil
}
// newRunHandle wraps a dispatched run's identity and event subscription into
// the surface-neutral handle callers read.
func newRunHandle(disp RunDispatch, sub *runSubscription) *RunHandle {
return &RunHandle{
RunDispatch: disp,
RunStream: RunStream{
Events: sub.runEvents,
NodeEvents: sub.nodeEvents,
},
close: sub.close,
}
}
func (s *Service) dispatchToEntry(entry *edgenode.NodeEntry, req SubmitRunRequest) (RunResult, error) {
runReq, runID, err := BuildRunRequest(req)
if err != nil {
return nil, err
}
sub, err := s.subscribeRun(runID, entry.NodeID, runReq.GetBackground())
if err != nil {
return nil, err
}
if err := entry.Client.Send(runReq); err != nil {
sub.close()
return nil, err
}
return newRunHandle(RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
ModelGroupKey: req.ModelGroupKey,
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
QueueReason: "dispatched",
}, sub), nil
}