openai_ws_session_preemption.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strings"
  7. "sync"
  8. "time"
  9. "github.com/gin-gonic/gin"
  10. "github.com/google/uuid"
  11. )
  12. var errOpenAIWSSessionPreempted = errors.New("openai ws session preempted by newer request")
  13. const (
  14. openAIWSSessionPreemptOwnerTTL = 2 * time.Hour
  15. openAIWSSessionPreemptWatchInterval = 2 * time.Second
  16. openAIWSSessionPreemptCachePrefix = "wspreempt:"
  17. )
  18. // OpenAIWSSessionPreemptionCache is an optional GatewayCache capability. The
  19. // production Redis cache implements all operations atomically; cache stubs do
  20. // not need to implement it for ordinary gateway tests.
  21. type OpenAIWSSessionPreemptionCache interface {
  22. ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error)
  23. CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error)
  24. CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error)
  25. }
  26. func NewOpenAIWSSessionPreemptedError() error {
  27. return errOpenAIWSSessionPreempted
  28. }
  29. type openAIWSSessionPreemptKey struct {
  30. groupID int64
  31. apiKeyID int64
  32. sessionHash string
  33. }
  34. type openAIWSSessionPreemptContextKey struct{}
  35. // BeginOpenAIWSIngressSessionPreemption keeps a persistent inbound WS session
  36. // registered across upstream retry attempts. Nested forwarding calls reuse the
  37. // registration so returning from one attempt cannot create a preemption gap.
  38. func (s *OpenAIGatewayService) BeginOpenAIWSIngressSessionPreemption(
  39. ctx context.Context,
  40. c *gin.Context,
  41. account *Account,
  42. firstClientMessage []byte,
  43. ) (context.Context, func(), bool) {
  44. if ctx == nil {
  45. ctx = context.Background()
  46. }
  47. if armed, _ := ctx.Value(openAIWSSessionPreemptContextKey{}).(bool); armed {
  48. return ctx, func() {}, true
  49. }
  50. if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled && account != nil {
  51. switch account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) {
  52. case OpenAIWSIngressModePassthrough, OpenAIWSIngressModeHTTPBridge:
  53. // These modes own their upstream transport and replay state per
  54. // inbound connection. Sharing a sticky/cache identity does not mean
  55. // a new connection should cancel an independent in-flight request.
  56. return ctx, func() {}, false
  57. }
  58. }
  59. preemptSessionHash := ""
  60. preemptGroupID := getOpenAIGroupIDFromContext(c)
  61. if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
  62. preemptSessionHash = s.GenerateSessionHash(c, firstClientMessage)
  63. }
  64. preemptCtx, cleanup, armed, preemptedPrevious := s.beginOpenAIWSSessionPreemptContext(
  65. ctx,
  66. account,
  67. preemptGroupID,
  68. getAPIKeyIDFromContext(c),
  69. preemptSessionHash,
  70. false,
  71. )
  72. if !armed {
  73. return ctx, func() {}, false
  74. }
  75. if preemptedPrevious {
  76. if stateStore := s.getOpenAIWSStateStore(); stateStore != nil {
  77. stateStore.DeleteSessionTurnState(preemptGroupID, preemptSessionHash)
  78. stateStore.DeleteSessionConn(preemptGroupID, preemptSessionHash)
  79. }
  80. }
  81. return context.WithValue(preemptCtx, openAIWSSessionPreemptContextKey{}, true), cleanup, true
  82. }
  83. func newOpenAIWSSessionPreemptKey(groupID, apiKeyID int64, sessionHash string) (openAIWSSessionPreemptKey, bool) {
  84. sessionHash = strings.TrimSpace(sessionHash)
  85. if groupID <= 0 || apiKeyID <= 0 || sessionHash == "" {
  86. return openAIWSSessionPreemptKey{}, false
  87. }
  88. return openAIWSSessionPreemptKey{groupID: groupID, apiKeyID: apiKeyID, sessionHash: sessionHash}, true
  89. }
  90. func openAIWSSessionPreemptCacheHash(apiKeyID int64, sessionHash string) string {
  91. return fmt.Sprintf("%s%d:%s", openAIWSSessionPreemptCachePrefix, apiKeyID, strings.TrimSpace(sessionHash))
  92. }
  93. type openAIWSSessionPreemptEntry struct {
  94. generation uint64
  95. cancel func()
  96. }
  97. type openAIWSSessionPreemptRegistry struct {
  98. mu sync.Mutex
  99. next uint64
  100. active map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry
  101. }
  102. func (r *openAIWSSessionPreemptRegistry) Begin(key openAIWSSessionPreemptKey, cancel func()) (cleanup func(), preemptedPrevious bool) {
  103. if r == nil || strings.TrimSpace(key.sessionHash) == "" {
  104. return func() {}, false
  105. }
  106. r.mu.Lock()
  107. if r.active == nil {
  108. r.active = make(map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry)
  109. }
  110. r.next++
  111. generation := r.next
  112. previous, hadPrevious := r.active[key]
  113. r.active[key] = openAIWSSessionPreemptEntry{generation: generation, cancel: cancel}
  114. r.mu.Unlock()
  115. if hadPrevious && previous.cancel != nil {
  116. previous.cancel()
  117. }
  118. return func() {
  119. r.mu.Lock()
  120. defer r.mu.Unlock()
  121. current, ok := r.active[key]
  122. if ok && current.generation == generation {
  123. delete(r.active, key)
  124. }
  125. }, hadPrevious
  126. }
  127. func (s *OpenAIGatewayService) beginOpenAIWSSessionPreemptContext(
  128. ctx context.Context,
  129. account *Account,
  130. groupID, apiKeyID int64,
  131. sessionHash string,
  132. httpIngressWSOneShot bool,
  133. ) (context.Context, func(), bool, bool) {
  134. if ctx == nil {
  135. ctx = context.Background()
  136. }
  137. if s == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth || httpIngressWSOneShot {
  138. return ctx, func() {}, false, false
  139. }
  140. key, ok := newOpenAIWSSessionPreemptKey(groupID, apiKeyID, sessionHash)
  141. if !ok {
  142. return ctx, func() {}, false, false
  143. }
  144. preemptCtx, cancel := context.WithCancelCause(ctx)
  145. ownerToken := uuid.NewString()
  146. var preemptOnce sync.Once
  147. preempt := func() {
  148. preemptOnce.Do(func() {
  149. if stateStore := s.getOpenAIWSStateStore(); stateStore != nil {
  150. stateStore.DeleteSessionTurnState(key.groupID, key.sessionHash)
  151. stateStore.DeleteSessionConn(key.groupID, key.sessionHash)
  152. }
  153. cancel(errOpenAIWSSessionPreempted)
  154. })
  155. }
  156. previousRemoteOwner, remoteClaimed := s.claimOpenAIWSSessionPreemptOwner(ctx, key, ownerToken)
  157. preemptedPrevious := remoteClaimed && previousRemoteOwner != "" && previousRemoteOwner != ownerToken
  158. cleanupLocal, hadLocalPrevious := s.openaiWSSessionPreemptions.Begin(key, preempt)
  159. preemptedPrevious = preemptedPrevious || hadLocalPrevious
  160. stopWatch := func() {}
  161. if remoteClaimed {
  162. stopWatch = s.watchOpenAIWSSessionPreemptOwner(preemptCtx, key, ownerToken, preempt)
  163. }
  164. return preemptCtx, func() {
  165. stopWatch()
  166. cleanupLocal()
  167. if remoteClaimed {
  168. s.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, ownerToken)
  169. }
  170. cancel(nil)
  171. }, true, preemptedPrevious
  172. }
  173. func (s *OpenAIGatewayService) openAIWSSessionPreemptionCache() OpenAIWSSessionPreemptionCache {
  174. if s == nil || s.cache == nil {
  175. return nil
  176. }
  177. cache, _ := s.cache.(OpenAIWSSessionPreemptionCache)
  178. return cache
  179. }
  180. func (s *OpenAIGatewayService) claimOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) (string, bool) {
  181. cache := s.openAIWSSessionPreemptionCache()
  182. if cache == nil || strings.TrimSpace(ownerToken) == "" {
  183. return "", false
  184. }
  185. cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout)
  186. defer cancel()
  187. previous, err := cache.ClaimOpenAIResponsesSessionWindow(
  188. cacheCtx,
  189. key.groupID,
  190. openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash),
  191. []byte(strings.TrimSpace(ownerToken)),
  192. openAIWSSessionPreemptOwnerTTL,
  193. )
  194. if err != nil {
  195. return "", false
  196. }
  197. return strings.TrimSpace(string(previous)), true
  198. }
  199. func (s *OpenAIGatewayService) releaseOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) {
  200. cache := s.openAIWSSessionPreemptionCache()
  201. if cache == nil || strings.TrimSpace(ownerToken) == "" {
  202. return
  203. }
  204. cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout)
  205. defer cancel()
  206. _, _ = cache.CompareAndDeleteOpenAIResponsesSessionWindow(
  207. cacheCtx,
  208. key.groupID,
  209. openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash),
  210. []byte(strings.TrimSpace(ownerToken)),
  211. )
  212. }
  213. func (s *OpenAIGatewayService) watchOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string, onLost func()) func() {
  214. cache := s.openAIWSSessionPreemptionCache()
  215. if cache == nil || onLost == nil || strings.TrimSpace(ownerToken) == "" {
  216. return func() {}
  217. }
  218. stopCh := make(chan struct{})
  219. var once sync.Once
  220. go func() {
  221. ticker := time.NewTicker(openAIWSSessionPreemptWatchInterval)
  222. defer ticker.Stop()
  223. for {
  224. select {
  225. case <-stopCh:
  226. return
  227. case <-ctx.Done():
  228. return
  229. case <-ticker.C:
  230. cacheCtx, cancel := context.WithTimeout(context.Background(), openAIWSStateStoreRedisTimeout)
  231. owned, err := cache.CompareAndRefreshOpenAIResponsesSessionWindow(
  232. cacheCtx,
  233. key.groupID,
  234. openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash),
  235. []byte(strings.TrimSpace(ownerToken)),
  236. openAIWSSessionPreemptOwnerTTL,
  237. )
  238. cancel()
  239. if err == nil && !owned {
  240. onLost()
  241. return
  242. }
  243. }
  244. }
  245. }()
  246. return func() { once.Do(func() { close(stopCh) }) }
  247. }
  248. func isOpenAIWSSessionPreempted(ctx context.Context) bool {
  249. return ctx != nil && errors.Is(context.Cause(ctx), errOpenAIWSSessionPreempted)
  250. }
  251. func IsOpenAIWSSessionPreemptedError(err error) bool {
  252. if err == nil {
  253. return false
  254. }
  255. if errors.Is(err, errOpenAIWSSessionPreempted) {
  256. return true
  257. }
  258. var fallbackErr *openAIWSFallbackError
  259. return errors.As(err, &fallbackErr) && fallbackErr != nil && strings.TrimPrefix(strings.TrimSpace(fallbackErr.Reason), "prewarm_") == "session_preempted"
  260. }