iop/apps/edge/internal/service/run_submit.go
toki fef1f7a9dc feat(liveness): provider 실행 stall 관측을 구현한다
Node의 provider progress 기반 stall timeout, watchdog fencing과 bounded health probe evidence를 실행 경로에 반영한다. Edge-Node 계약과 구현 스펙, 테스트 및 Milestone 완료 evidence를 현재 상태와 맞춘다.
2026-08-05 09:45:14 +09:00

202 lines
6.4 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
}
// Only provider-pool selection owns a non-zero wire value. Direct callers
// retain the Node's zero-on-wire default regardless of DTO input.
req.ResponseStallTimeoutMS = 0
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
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
}
req.ResponseStallTimeoutMS = selected.responseStallTimeoutMS
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()),
ResponseStallTimeoutMS: dispatchResponseStallTimeout(runReq.GetResponseStallTimeoutMs()),
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
ProviderID: selected.providerID,
UsageAttribution: req.UsageAttribution,
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()),
ResponseStallTimeoutMS: dispatchResponseStallTimeout(runReq.GetResponseStallTimeoutMs()),
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
ProviderID: req.ProviderID,
UsageAttribution: req.UsageAttribution,
QueueReason: "dispatched",
}, sub), nil
}