iop/apps/edge/internal/service/provider_tunnel.go
toki 2f560e3f3b feat: provider pool admission, policy config, snapshot source task archive + runtime updates
- Archive completed subtask plans/code reviews (04, 05+03,04, 07+03)
- Add provider_pool_admission_test.go
- Update edge config types, load, catalog validation
- Update runtime, config refresh, service layers for admission
- Update test docs and inventory
- Update provider scheduling, resolution, tunnel, status modules
2026-07-19 22:41:05 +09:00

382 lines
11 KiB
Go

package service
import (
"context"
"sync"
"time"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
// tunnelFrameBuffer is the per-tunnel channel capacity. The router blocks on a
// full buffer instead of dropping so ordered passthrough bytes are never lost
// while the subscriber is alive.
const tunnelFrameBuffer = 4096
// providerTunnelRouter delivers ProviderTunnelFrame messages to the
// request-bound subscriber of their tunnel id. It is intentionally separate
// from events.Bus: tunnel body bytes are the passthrough source of truth and
// must not enter the lossy run event fanout.
type providerTunnelRouter struct {
mu sync.Mutex
subs map[string]*providerTunnelSub
}
type providerTunnelSub struct {
ch chan *iop.ProviderTunnelFrame
done chan struct{}
}
func newProviderTunnelRouter() *providerTunnelRouter {
return &providerTunnelRouter{subs: make(map[string]*providerTunnelSub)}
}
func (r *providerTunnelRouter) subscribe(tunnelID string, buffer int) (<-chan *iop.ProviderTunnelFrame, func()) {
sub := &providerTunnelSub{
ch: make(chan *iop.ProviderTunnelFrame, buffer),
done: make(chan struct{}),
}
r.mu.Lock()
r.subs[tunnelID] = sub
r.mu.Unlock()
var once sync.Once
unsubscribe := func() {
once.Do(func() {
r.mu.Lock()
if cur, ok := r.subs[tunnelID]; ok && cur == sub {
delete(r.subs, tunnelID)
}
r.mu.Unlock()
close(sub.done)
})
}
return sub.ch, unsubscribe
}
// route delivers a frame to its tunnel subscriber. Frames without a subscriber
// are dropped (the request already finished or was cancelled).
func (r *providerTunnelRouter) route(frame *iop.ProviderTunnelFrame) {
if frame == nil {
return
}
r.mu.Lock()
sub := r.subs[frame.GetTunnelId()]
r.mu.Unlock()
if sub == nil {
return
}
select {
case sub.ch <- frame:
case <-sub.done:
}
}
// RouteProviderTunnelFrame delivers a ProviderTunnelFrame received from Edge
// transport to its request-bound tunnel stream. It never publishes to the
// run/node event bus.
func (s *Service) RouteProviderTunnelFrame(frame *iop.ProviderTunnelFrame) {
if s == nil || s.tunnels == nil {
return
}
s.tunnels.route(frame)
}
// SubmitProviderTunnelRequest asks a node to open a raw provider HTTP request
// and relay the response as ordered ProviderTunnelFrame messages. It is the
// passthrough sibling of SubmitRunRequest and shares the provider-pool
// admission gate.
type SubmitProviderTunnelRequest struct {
NodeRef string
RunID string
ModelGroupKey string
Adapter string
Target string
SessionID string
Method string
Path string
Headers map[string]string
Body []byte
// BuildBody, when set, produces the provider request body from the final
// resolved target (provider-pool admission rewrites the target to the
// winning candidate's served model). It takes precedence over Body.
BuildBody func(target string) ([]byte, error)
Stream bool
TimeoutSec int
MaxQueue int
QueueTimeoutMS int
Metadata map[string]string
EstimatedInputTokens int
ContextClass string
ProviderPool bool
}
// ProviderTunnelStream carries the ordered raw provider frames of a dispatched
// tunnel. The channel is closed after the terminal END/ERROR frame or Close.
type ProviderTunnelStream struct {
Frames <-chan *iop.ProviderTunnelFrame
}
// ProviderTunnelResult is the surface-neutral handle for a dispatched provider
// tunnel, mirroring RunResult for the raw passthrough path.
type ProviderTunnelResult interface {
Dispatch() RunDispatch
Stream() ProviderTunnelStream
Close()
WaitTimeout() time.Duration
// SetHeaders allows late-binding of tunnel headers (e.g. provider auth
// validated after provider-pool selection). Implementations may ignore
// this if headers are already baked into the request.
SetHeaders(map[string]string)
}
// ProviderTunnelHandle implements ProviderTunnelResult for tunnels dispatched
// over the Edge-Node socket.
type ProviderTunnelHandle struct {
RunDispatch
TunnelID string
Headers map[string]string
frames <-chan *iop.ProviderTunnelFrame
closeOnce sync.Once
close func()
}
func (h *ProviderTunnelHandle) Dispatch() RunDispatch {
if h == nil {
return RunDispatch{}
}
return h.RunDispatch
}
func (h *ProviderTunnelHandle) Stream() ProviderTunnelStream {
if h == nil {
return ProviderTunnelStream{}
}
return ProviderTunnelStream{Frames: h.frames}
}
func (h *ProviderTunnelHandle) Close() {
if h != nil && h.close != nil {
h.closeOnce.Do(h.close)
}
}
func (h *ProviderTunnelHandle) WaitTimeout() time.Duration {
if h == nil {
return time.Duration(DefaultTimeoutSec+5) * time.Second
}
return time.Duration(normalizeTimeoutSec(h.TimeoutSec)+5) * time.Second
}
func (h *ProviderTunnelHandle) SetHeaders(hdrs map[string]string) {
h.Headers = hdrs
}
// SubmitProviderTunnel dispatches a raw provider tunnel request. Provider-pool
// requests go through the same admission gate as SubmitRun; the reserved slot
// is released when the tunnel reaches END/ERROR or the handle is closed
// (cancel), never via the run event bus.
func (s *Service) SubmitProviderTunnel(ctx context.Context, req SubmitProviderTunnelRequest) (ProviderTunnelResult, error) {
if req.ProviderPool && req.ModelGroupKey != "" && s.queue != nil {
return s.submitProviderTunnelQueued(ctx, req)
}
return s.submitProviderTunnelDirect(req)
}
func (s *Service) submitProviderTunnelQueued(ctx context.Context, req SubmitProviderTunnelRequest) (ProviderTunnelResult, error) {
tunnelRunReq := SubmitRunRequest{
NodeRef: req.NodeRef,
ModelGroupKey: req.ModelGroupKey,
Adapter: req.Adapter,
Target: req.Target,
MaxQueue: req.MaxQueue,
QueueTimeoutMS: req.QueueTimeoutMS,
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
ProviderPool: true,
}
candidates, returnedPolicy, err := s.resolveQueueCandidates(tunnelRunReq)
if err != nil {
return nil, err
}
long := req.ContextClass == contextClassLong
// Provider-pool tunnel uses the canonical policy from the runtime snapshot.
var policy groupPolicy
if tunnelRunReq.ProviderPool {
_, _, policy = s.runtimeConfigSnapshot()
} else {
policy = returnedPolicy
}
selected, queueReason, err := s.queue.admitWithReason(ctx, req.ModelGroupKey, req.Adapter, req.Target, candidates, policy, s.resolveQueueCandidatesClosure(tunnelRunReq), long, tunnelRunReq.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 tunnel hands it off to
// its terminal frame / close path.
reservation := newQueueReservation(s.queue, selected)
adapter := req.Adapter
if selected.adapter != "" {
adapter = selected.adapter
}
target := req.Target
if selected.servedTarget != "" {
target = selected.servedTarget
}
tunnelReq, runID, err := buildProviderTunnelRequest(req, adapter, target)
if err != nil {
reservation.release("build-error")
return nil, err
}
// Track inflight before send so END/ERROR/close release paths can find it
// even if the terminal frame arrives before Send returns.
reservation.track(runID)
handle, err := s.openProviderTunnel(selected.entry, tunnelReq, req, queueReason, true, selected.providerID, selected.providerType, string(selected.executionPath))
if err != nil {
reservation.release("send-error")
return nil, err
}
reservation.handOff()
return handle, nil
}
func (s *Service) submitProviderTunnelDirect(req SubmitProviderTunnelRequest) (ProviderTunnelResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, err
}
tunnelReq, _, err := buildProviderTunnelRequest(req, req.Adapter, req.Target)
if err != nil {
return nil, err
}
return s.openProviderTunnel(entry, tunnelReq, req, "dispatched", false, "", "", "")
}
// openProviderTunnel subscribes the request-bound frame channel, sends the
// tunnel request to the node, and wraps the raw channel so the reserved
// provider-pool slot is released exactly once on END/ERROR/close.
func (s *Service) openProviderTunnel(entry *edgenode.NodeEntry, tunnelReq *iop.ProviderTunnelRequest, req SubmitProviderTunnelRequest, queueReason string, tracked bool, providerID, providerType, executionPath string) (*ProviderTunnelHandle, error) {
raw, unsubscribe := s.tunnels.subscribe(tunnelReq.GetTunnelId(), tunnelFrameBuffer)
if err := entry.Client.Send(tunnelReq); err != nil {
unsubscribe()
return nil, err
}
runID := tunnelReq.GetRunId()
var releaseOnce sync.Once
release := func(reason string) {
releaseOnce.Do(func() {
if tracked && s.queue != nil {
s.queue.releaseRun(runID, reason)
}
})
}
out := make(chan *iop.ProviderTunnelFrame, tunnelFrameBuffer)
done := make(chan struct{})
go func() {
defer close(out)
for {
select {
case frame, ok := <-raw:
if !ok {
release("tunnel-stream-closed")
return
}
select {
case out <- frame:
case <-done:
release("tunnel-closed")
return
}
if isTerminalProviderTunnelFrame(frame) {
release("tunnel-" + frame.GetKind().String())
return
}
case <-done:
release("tunnel-closed")
return
}
}
}()
return &ProviderTunnelHandle{
RunDispatch: RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
ModelGroupKey: req.ModelGroupKey,
Adapter: tunnelReq.GetAdapter(),
Target: tunnelReq.GetTarget(),
SessionID: tunnelReq.GetSessionId(),
TimeoutSec: int(tunnelReq.GetTimeoutSec()),
EstimatedInputTokens: req.EstimatedInputTokens,
ContextClass: req.ContextClass,
ProviderID: providerID,
ProviderType: providerType,
ExecutionPath: executionPath,
QueueReason: queueReason,
},
TunnelID: tunnelReq.GetTunnelId(),
frames: out,
close: func() {
close(done)
unsubscribe()
release("tunnel-closed")
},
}, nil
}
func isTerminalProviderTunnelFrame(f *iop.ProviderTunnelFrame) bool {
switch f.GetKind() {
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR:
return true
default:
return false
}
}
func buildProviderTunnelRequest(req SubmitProviderTunnelRequest, adapter, target string) (*iop.ProviderTunnelRequest, string, error) {
runID := req.RunID
if runID == "" {
runID = NewRunID()
}
body := req.Body
if req.BuildBody != nil {
b, err := req.BuildBody(target)
if err != nil {
return nil, "", err
}
body = b
}
headers := make(map[string]string, len(req.Headers))
for k, v := range req.Headers {
headers[k] = v
}
metadata := make(map[string]string, len(req.Metadata))
for k, v := range req.Metadata {
metadata[k] = v
}
return &iop.ProviderTunnelRequest{
RunId: runID,
TunnelId: runID + "-tunnel",
Adapter: adapter,
Target: target,
Method: req.Method,
Path: req.Path,
Headers: headers,
Body: body,
Stream: req.Stream,
TimeoutSec: int32(normalizeTimeoutSec(req.TimeoutSec)),
Metadata: metadata,
SessionId: NormalizeSessionID(req.SessionID),
}, runID, nil
}