iop/apps/node/internal/node/run_handler.go
toki f9442edfef feat(runtime): provider liveness 복구를 완성한다
장시간 무응답 attempt를 안전하게 fence하고 provider health와 분리 관측해야 중복 출력 없이 기존 recovery budget으로 재실행할 수 있다.
2026-08-06 08:49:59 +09:00

231 lines
7.5 KiB
Go

package node
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
runtime "iop/packages/go/execution"
iop "iop/proto/gen/iop"
)
// OnRunRequest handles an incoming RunRequest from a transport Session.
func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *iop.RunRequest) error {
n.logger.Info("run request received", zap.String("run_id", req.GetRunId()), zap.String("adapter", req.GetAdapter()), zap.String("target", req.GetTarget()))
rr := runRequestFromProto(req)
printEdgeMessage(n.out, rr.Input)
if err := n.validateRunStallTimeout(sess, req, &rr); err != nil {
return err
}
n.configSetMu.RLock()
configLocked := true
defer func() {
if configLocked {
n.configSetMu.RUnlock()
}
}()
spec, adapter, err := n.router.ResolveAdapter(ctx, rr)
if err != nil {
n.sendPreExecuteError(sess, req.GetRunId(), req.GetSessionId(), req.GetBackground(), n.nodeID, err.Error())
return fmt.Errorf("node: resolve: %w", err)
}
var caps runtime.Capabilities
if c, capsErr := adapter.Capabilities(ctx); capsErr == nil {
caps = c
}
admission := n.admissionFor(spec.Adapter, caps)
ticket, err := admission.acquire()
if err != nil {
n.logger.Warn("run admission rejected",
zap.String("run_id", spec.RunID),
zap.String("adapter", spec.Adapter),
zap.String("reason", string(admissionRejectReason(err))),
)
n.rejectRun(ctx, sess, spec, err)
return fmt.Errorf("node: run %s: %w", spec.RunID, err)
}
if err := n.store.InsertRun(ctx, store.RunRecord{
RunID: spec.RunID,
Adapter: spec.Adapter,
Target: spec.Target,
SessionID: normalizeSessionID(spec.SessionID),
Background: spec.Background,
Status: "running",
CreatedAt: time.Now(),
}); err != nil {
n.logger.Warn("store: insert run", zap.String("run_id", spec.RunID), zap.Error(err))
}
// Session listeners supply their connection-lifetime context. Direct callers
// retain the context they provided.
execCtx, cancel := context.WithCancel(ctx)
if spec.TimeoutSec > 0 {
execCtx, cancel = context.WithTimeout(ctx, time.Duration(spec.TimeoutSec)*time.Second)
}
h := &runHandle{
runID: spec.RunID,
adapter: spec.Adapter,
target: spec.Target,
sessionID: normalizeSessionID(spec.SessionID),
cancel: cancel,
done: make(chan struct{}),
}
n.runs.register(h)
configLocked = false
n.configSetMu.RUnlock()
var sender protoSender = noopSender{}
var seq healthObservationSequencer
if sess != nil && sess.IsAlive() {
sender = sess
seq = sess
}
probe := healthProbeFor(adapter, caps.AdapterName, caps.InstanceKey, spec.Target)
run := func() error {
return n.executeNormalizedAttempt(ctx, execCtx, cancel, adapter, spec, ticket, h, sender, probe, seq)
}
if spec.Background {
go func() { _ = run() }()
return nil
}
return run()
}
// admissionFor returns an admissionManager with only the per-adapter gate for
// the given adapter instance key. Node-wide global admission gating has been
// removed; each adapter controls its own concurrency via Capabilities().MaxConcurrency.
func (n *Node) admissionFor(adapterKey string, caps runtime.Capabilities) *admissionManager {
return &admissionManager{
adapter: n.adapterGateFor(adapterKey, caps),
}
}
// adapterGateFor returns the shared gate for the given adapter instance key,
// creating one from caps if not yet seen. MaxConcurrency <= 0 means the adapter gate is unlimited.
func (n *Node) adapterGateFor(adapterKey string, caps runtime.Capabilities) *fifoGate {
n.adapterGatesMu.Lock()
defer n.adapterGatesMu.Unlock()
g, ok := n.adapterGates[adapterKey]
if !ok {
g = newFifoGate(caps.MaxConcurrency)
n.adapterGates[adapterKey] = g
}
return g
}
// rejectRun records a rejected run in the store and sends an error RunEvent to
// the session so Edge can observe the rejection through the normal event stream.
// The rejection message carries the concrete reason (concurrency_unavailable) extracted from the admission error.
func (n *Node) rejectRun(ctx context.Context, sess *transport.Session, spec runtime.ExecutionSpec, admitErr error) {
errMsg := admissionMessage(admitErr)
// Since we reject immediately without queueing, insert as a terminal rejected record.
if err := n.store.InsertRun(ctx, store.RunRecord{
RunID: spec.RunID,
Adapter: spec.Adapter,
Target: spec.Target,
SessionID: normalizeSessionID(spec.SessionID),
Background: spec.Background,
Status: "rejected",
CreatedAt: time.Now(),
}); err != nil {
n.logger.Warn("store: insert rejected run", zap.String("run_id", spec.RunID), zap.Error(err))
}
if err := n.store.CompleteRun(ctx, spec.RunID, "rejected", errMsg); err != nil {
n.logger.Warn("store: complete rejected run", zap.String("run_id", spec.RunID), zap.Error(err))
}
if sess != nil && sess.IsAlive() {
re := &iop.RunEvent{
RunId: spec.RunID,
Type: string(runtime.EventTypeError),
Error: errMsg,
Timestamp: time.Now().UnixNano(),
SessionId: normalizeSessionID(spec.SessionID),
Background: spec.Background,
NodeId: n.nodeID,
}
if err := sess.Send(re); err != nil {
n.logger.Warn("session: send reject event", zap.String("run_id", spec.RunID), zap.Error(err))
}
}
}
// sendPreExecuteError sends an error RunEvent when an error occurs before
// adapter execution (e.g. ResolveAdapter failure). No store record is needed
// because the run never reached execution. This ensures Edge can observe the
// failure and avoid inflight slot leaks.
func (n *Node) sendPreExecuteError(sess *transport.Session, runID, sessionID string, background bool, nodeID, errMsg string) {
if sess != nil && sess.IsAlive() {
re := &iop.RunEvent{
RunId: runID,
Type: string(runtime.EventTypeError),
Error: errMsg,
Timestamp: time.Now().UnixNano(),
SessionId: normalizeSessionID(sessionID),
Background: background,
NodeId: nodeID,
}
if err := sess.Send(re); err != nil {
n.logger.Warn("session: send pre-execute error event", zap.String("run_id", runID), zap.Error(err))
}
}
}
func (n *Node) completeRun(spec runtime.ExecutionSpec, execErr error) {
status := "completed"
errMsg := ""
if execErr != nil {
if errors.Is(execErr, runtime.ErrRunCancelled) {
status = "cancelled"
} else {
status = "failed"
errMsg = execErr.Error()
}
n.logger.Warn("run ended", zap.String("run_id", spec.RunID), zap.String("status", status), zap.Error(execErr))
}
if err := n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg); err != nil {
n.logger.Warn("store: complete run", zap.String("run_id", spec.RunID), zap.Error(err))
}
}
// synthAndEmitTerminal queues a terminal event when the adapter returned
// without emitting one. The caller flushes it after local admission release, so
// Edge can observe run completion without over-dispatching back into Node.
func (n *Node) synthAndEmitTerminal(ctx context.Context, sink *terminalDeferringSink, spec runtime.ExecutionSpec, execErr error) error {
event := runtime.RuntimeEvent{
RunID: spec.RunID,
Timestamp: time.Now(),
}
switch {
case errors.Is(execErr, runtime.ErrRunCancelled):
event.Type = runtime.EventTypeCancelled
case execErr != nil:
event.Type = runtime.EventTypeError
event.Error = execErr.Error()
event.Failure = runtime.FailureFromError(execErr)
default:
event.Type = runtime.EventTypeComplete
event.Message = "adapter completed without terminal event"
}
if err := sink.Emit(ctx, event); err != nil {
return fmt.Errorf("synthesize terminal event: %w", err)
}
return nil
}