feat(queue): 增加非文本模型分布式准入队列

使用 PostgreSQL 统一同步与异步非文本任务的并发准入、持久化等待和 Worker 容量分配,并将生产 API 与独立 Worker 角色拆分。

补充策略管理、共享契约、OpenAPI、Kubernetes 双节点 Worker 清单及跨节点验收脚本;未默认启用任何生产 queue_size 策略。

已在原基线完成 Go、前端、迁移、Shell、Kustomize 与长任务容量验收;合入最新主干后将重新执行发布门禁。
This commit is contained in:
2026-07-29 16:15:43 +08:00
parent 2e5a90731b
commit 9e4fc7362d
55 changed files with 4565 additions and 237 deletions
+33 -18
View File
@@ -345,6 +345,10 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
input.Config = config
platform, err := s.store.CreatePlatform(r.Context(), input)
if err != nil {
if store.IsInvalidRateLimitPolicy(err) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
s.logger.Error("create platform failed", "error", err)
writeError(w, http.StatusInternalServerError, "create platform failed")
return
@@ -398,6 +402,10 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
input.Config = config
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
if err != nil {
if store.IsInvalidRateLimitPolicy(err) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "platform not found")
return
@@ -471,7 +479,7 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
}
model, err := s.store.CreatePlatformModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) || store.IsInvalidRateLimitPolicy(err) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
@@ -519,7 +527,7 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) || store.IsInvalidRateLimitPolicy(err) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
@@ -1206,8 +1214,9 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return
}
if responsePlan.asyncMode {
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
writeTaskError(http.StatusInternalServerError, err.Error(), nil, "enqueue_failed")
if err := s.runner.SubmitAsyncTask(r.Context(), task); err != nil {
applyRunErrorHeaders(w, err)
writeTaskError(statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), runErrorCode(err))
return
}
writeTaskAccepted(w, task)
@@ -1307,7 +1316,7 @@ func openAIEmbeddingsDoc() {}
// openAIImagesDoc godoc
// @Summary 创建或编辑 OpenAI Images
// @Description 默认同步返回 OpenAI-compatible Images 响应;设置 X-Async=true 时异步创建任务并返回 202。
// @Description 默认同步返回 OpenAI-compatible Images 响应;非文本模型并发饱和且配置队列时等待准入,超过最长等待返回 504 queue_timeout设置 X-Async=true 时异步创建任务并返回 202。
// @Tags images
// @Accept json
// @Produce json
@@ -1319,6 +1328,7 @@ func openAIEmbeddingsDoc() {}
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 504 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/images/generations [post]
// @Router /api/v1/images/edits [post]
@@ -1326,7 +1336,7 @@ func openAIImagesDoc() {}
// easyAIMediaTasksDoc godoc
// @Summary 创建 EasyAI 媒体任务
// @Description 默认同步返回 EasyAI GeneratedResponse;设置 X-Async=true 时返回同时兼容 Gateway 与 server-main EasyAIClient 的异步提交结构。
// @Description 默认同步返回 EasyAI GeneratedResponse非文本模型并发饱和且配置队列时等待准入,超过最长等待返回 504 queue_timeout设置 X-Async=true 时返回同时兼容 Gateway 与 server-main EasyAIClient 的异步提交结构。
// @Tags media
// @Accept json
// @Produce json
@@ -1342,6 +1352,7 @@ func openAIImagesDoc() {}
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 504 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
@@ -1351,19 +1362,10 @@ func openAIImagesDoc() {}
func easyAIMediaTasksDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
return base, func() {}
return runner.NewRequestExecutionContext(r.Context(), context.Background())
}
ctx, cancel := context.WithCancel(base)
go func() {
select {
case <-s.ctx.Done():
cancel()
case <-ctx.Done():
}
}()
return ctx, cancel
return runner.NewRequestExecutionContext(r.Context(), s.ctx)
}
func requestStillConnected(r *http.Request) bool {
@@ -1663,6 +1665,8 @@ func statusFromRunError(err error) int {
return http.StatusNotFound
case store.ModelCandidateErrorCode(err) == "platform_cooling_down" || store.ModelCandidateErrorCode(err) == "model_cooling_down":
return http.StatusTooManyRequests
case errors.Is(err, store.ErrQueueTimeout) || clients.ErrorCode(err) == "queue_timeout":
return http.StatusGatewayTimeout
case errors.Is(err, store.ErrNoModelCandidate):
return http.StatusNotFound
case errors.Is(err, store.ErrRateLimited):
@@ -1710,7 +1714,11 @@ func runErrorDetails(err error) map[string]any {
}
func applyRunErrorHeaders(w http.ResponseWriter, err error) {
if retryAfter := store.ModelCandidateRetryAfter(err); retryAfter > 0 {
retryAfter := store.ModelCandidateRetryAfter(err)
if limitRetryAfter := store.RateLimitRetryAfter(err); limitRetryAfter > 0 {
retryAfter = limitRetryAfter
}
if retryAfter > 0 {
seconds := int((retryAfter + time.Second - 1) / time.Second)
if seconds < 1 {
seconds = 1
@@ -1783,6 +1791,13 @@ func rateLimitErrorDetail(err error) map[string]any {
"limit": limitErr.Limit,
},
}
if limitErr.Reason != "" {
detail["reason"] = limitErr.Reason
}
if limitErr.QueueLimit > 0 {
detail["queueDepth"] = limitErr.QueueDepth
detail["queueLimit"] = limitErr.QueueLimit
}
if limitErr.RetryAfter > 0 {
detail["retryAfterMs"] = limitErr.RetryAfter.Milliseconds()
}