iop/apps/edge/internal/openai/stream_gate_ingress.go
toki c1c1e19678 feat(streamgate): 요청 런타임 수명주기를 구현한다
증거 평가와 복구를 하나의 요청 루프로 수렴시켜 OpenAI ingress 재구성과 stream release를 일관되게 처리한다.
2026-07-26 21:03:39 +09:00

230 lines
6.2 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"iop/packages/go/streamgate"
)
const (
openAIIngressTypedViewName = "openai.request.semantic"
openAIRebuiltBodyViewName = "openai.request.rebuilt"
)
var (
errOpenAIIngressTooLarge = errors.New("openai request body exceeds ingress limit")
openAIIngressSequence atomic.Uint64
)
// openAIIngressSnapshot is the request-local owner of the caller's canonical
// JSON body and its bounded semantic ledger. It deliberately contains no HTTP
// headers, provider credentials, logger fields, or cross-request references.
type openAIIngressSnapshot struct {
mu sync.RWMutex
ref string
snapshot *streamgate.IngressSnapshot
retainedMax int64
closed bool
}
// readOpenAIIngressBody installs the HTTP body limit before reading. The
// standard library reader performs a limit+1 probe internally, so an exact
// limit body succeeds and the first excess byte is reported as overflow.
func readOpenAIIngressBody(w http.ResponseWriter, r *http.Request, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return nil, streamgate.ErrIngressSnapshotZeroMaxBytes
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return nil, errOpenAIIngressTooLarge
}
return nil, err
}
return body, nil
}
// buildOpenAIIngressSnapshot transfers the bounded canonical request and a
// serialized semantic view into the shared Core ledger. Equal payloads share
// one backing handle and are counted once.
func buildOpenAIIngressSnapshot(maxBytes int64, canonical []byte, semanticView any) (*openAIIngressSnapshot, error) {
var semantic []byte
if raw, ok := semanticView.(json.RawMessage); ok {
if !json.Valid(raw) {
return nil, fmt.Errorf("encode OpenAI ingress semantic view: invalid JSON")
}
semantic = raw
} else {
var err error
semantic, err = json.Marshal(semanticView)
if err != nil {
return nil, fmt.Errorf("encode OpenAI ingress semantic view: %w", err)
}
}
builder, err := streamgate.NewIngressSnapshotBuilder(maxBytes)
if err != nil {
return nil, err
}
defer builder.Close()
canonicalHandle, err := builder.IssueOwnedBackingHandle(canonical)
if err != nil {
return nil, err
}
if err := builder.SetCanonicalWithHandle(canonicalHandle); err != nil {
return nil, err
}
semanticHandle := canonicalHandle
if !bytes.Equal(semantic, canonical) {
semanticHandle, err = builder.IssueOwnedBackingHandle(semantic)
if err != nil {
return nil, err
}
}
if err := builder.AddTypedView(openAIIngressTypedViewName, semanticHandle); err != nil {
return nil, err
}
snapshot, err := builder.Build()
if err != nil {
return nil, err
}
ref := fmt.Sprintf("openai.ingress.%d", openAIIngressSequence.Add(1))
return &openAIIngressSnapshot{
ref: ref,
snapshot: snapshot,
retainedMax: snapshot.Accessor().RetainedBytes(),
}, nil
}
func (s *openAIIngressSnapshot) canonicalBody() ([]byte, error) {
if s == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.snapshot == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
return s.snapshot.Accessor().CanonicalAlias()
}
func (s *openAIIngressSnapshot) semanticBody() ([]byte, error) {
if s == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.snapshot == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
return s.snapshot.Accessor().TypedViewAlias(openAIIngressTypedViewName)
}
func (s *openAIIngressSnapshot) recoveryRef() (streamgate.RecoveryRequestSnapshotRef, error) {
if s == nil {
return streamgate.RecoveryRequestSnapshotRef{}, streamgate.ErrIngressSnapshotClosed
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.snapshot == nil {
return streamgate.RecoveryRequestSnapshotRef{}, streamgate.ErrIngressSnapshotClosed
}
accessor := s.snapshot.Accessor()
retained := accessor.RetainedBytes()
peak := retained
if s.retainedMax > peak {
peak = s.retainedMax
}
return streamgate.NewRecoveryRequestSnapshotRef(
s.ref,
uint64(retained),
uint64(peak),
uint64(accessor.MaxBytes()),
)
}
func (s *openAIIngressSnapshot) reserveRebuild(expectedBytes int64) (*streamgate.IngressSnapshotRebuildGuard, error) {
if s == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.snapshot == nil {
return nil, streamgate.ErrIngressSnapshotClosed
}
guard, err := s.snapshot.ReserveRebuild(expectedBytes)
if err != nil {
return nil, err
}
peak := s.snapshot.Accessor().RetainedBytes() + s.snapshot.ReservedTempBytes()
if peak > s.retainedMax {
s.retainedMax = peak
}
return guard, nil
}
func (s *openAIIngressSnapshot) accessor() (streamgate.IngressSnapshotAccessor, error) {
if s == nil {
return streamgate.IngressSnapshotAccessor{}, streamgate.ErrIngressSnapshotClosed
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.snapshot == nil {
return streamgate.IngressSnapshotAccessor{}, streamgate.ErrIngressSnapshotClosed
}
return s.snapshot.Accessor(), nil
}
func (s *openAIIngressSnapshot) Close() {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
if s.snapshot != nil {
s.snapshot.Close()
s.snapshot = nil
}
}
func (s *openAIIngressSnapshot) isClosed() bool {
if s == nil {
return true
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.closed || s.snapshot == nil
}
func (s *Server) maxIngressSnapshotBytes() int64 {
s.mu.RLock()
defer s.mu.RUnlock()
return int64(s.cfg.StreamEvidenceGate.EffectiveMaxIngressSnapshotBytes())
}
func writeOpenAIIngressError(w http.ResponseWriter, err error) {
if errors.Is(err, errOpenAIIngressTooLarge) ||
errors.Is(err, streamgate.ErrIngressSnapshotLimitExceeded) ||
errors.Is(err, streamgate.ErrIngressSnapshotRebuildOverflow) ||
errors.Is(err, streamgate.ErrRecoverySnapshotLimitExceeded) {
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body exceeds configured limit")
return
}
writeError(w, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
}