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
+432
View File
@@ -0,0 +1,432 @@
package runner
import (
"context"
"errors"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type taskAdmissionPlan struct {
Candidate store.RuntimeModelCandidate
Body map[string]any
ModelType string
GroupID string
Scopes []store.AdmissionScope
Eligible bool
}
func distributedAdmissionModelType(modelType string) bool {
switch strings.ToLower(strings.TrimSpace(modelType)) {
case "image_generate",
"image_edit",
"image_analysis",
"image_vectorize",
"video_generate",
"video_enhance",
"image_to_video",
"text_to_video",
"video_edit",
"video_reference",
"video_first_last_frame",
"video_understanding",
"omni_video",
"omni",
"audio_generate",
"audio_understanding",
"text_to_speech",
"voice_clone":
return true
default:
return false
}
}
func (s *Service) buildTaskAdmissionPlan(ctx context.Context, task store.GatewayTask, user *auth.User) (taskAdmissionPlan, error) {
restoredRequest, err := s.restoreTaskRequestReferences(ctx, task)
if err != nil {
return taskAdmissionPlan{}, err
}
body := normalizeRequest(task.Kind, restoredRequest)
modelType := modelTypeFromKind(task.Kind, body)
if !distributedAdmissionModelType(modelType) {
return taskAdmissionPlan{Body: body, ModelType: modelType}, nil
}
if err := validateRequest(task.Kind, body); err != nil {
return taskAdmissionPlan{}, parameterPreprocessClientError(err)
}
body, clonedVoice, err := s.resolveClonedVoiceBinding(ctx, user, task.Kind, body)
if err != nil {
return taskAdmissionPlan{}, err
}
runnerPolicy, err := s.store.GetActiveRunnerPolicy(ctx)
if err != nil {
return taskAdmissionPlan{}, err
}
cacheAffinityKeys := buildCacheAffinityKeys(task.Kind, modelType, body)
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user, store.ListModelCandidatesOptions{
CacheAffinityKey: cacheAffinityKeys.Primary,
CacheAffinityKeys: cacheAffinityKeys.Lookup,
CacheAffinityPolicy: runnerPolicy.CacheAffinityPolicy,
})
if err == nil {
candidates, err = filterCandidatesByRequestedPlatform(candidates, body)
}
if err == nil {
candidates, err = filterCandidatesByClonedVoiceBinding(candidates, clonedVoice)
}
if err == nil {
candidates, _, err = filterRuntimeCandidatesByRequest(task.Kind, task.Model, modelType, body, candidates)
}
if err == nil {
candidates, _, err = filterRuntimeCandidatesByOutputTokens(task.Kind, task.Model, modelType, body, candidates)
}
if err != nil {
return taskAdmissionPlan{}, err
}
for _, candidate := range candidates {
available, availabilityErr := s.store.RuntimeCandidateAvailable(ctx, candidate.PlatformID, candidate.PlatformModelID)
if availabilityErr != nil {
return taskAdmissionPlan{}, availabilityErr
}
if !available {
continue
}
scopes, groupID := s.admissionScopes(ctx, user, candidate)
hasConcurrentLimit := false
for _, scope := range scopes {
if scope.ConcurrentLimit > 0 {
hasConcurrentLimit = true
break
}
}
if !hasConcurrentLimit {
return taskAdmissionPlan{
Candidate: candidate,
Body: body,
ModelType: modelType,
}, nil
}
if err := s.store.CheckRateLimits(ctx, s.rateLimitReservations(ctx, user, candidate, body)); err != nil {
return taskAdmissionPlan{}, err
}
return taskAdmissionPlan{
Candidate: candidate,
Body: body,
ModelType: modelType,
GroupID: groupID,
Scopes: scopes,
Eligible: true,
}, nil
}
return taskAdmissionPlan{}, store.ErrNoModelCandidate
}
func (s *Service) tryTaskAdmission(ctx context.Context, task store.GatewayTask, plan taskAdmissionPlan, waiterID string) (store.TaskAdmissionResult, error) {
return s.tryTaskAdmissionWithAdmittedHook(ctx, task, plan, waiterID, nil)
}
func (s *Service) tryTaskAdmissionWithAdmittedHook(
ctx context.Context,
task store.GatewayTask,
plan taskAdmissionPlan,
waiterID string,
onAdmitted func(pgx.Tx) error,
) (store.TaskAdmissionResult, error) {
mode := "sync"
if task.AsyncMode {
mode = "async"
}
input := store.TaskAdmissionInput{
TaskID: task.ID,
PlatformID: plan.Candidate.PlatformID,
PlatformModelID: plan.Candidate.PlatformModelID,
UserGroupID: plan.GroupID,
QueueKey: plan.Candidate.QueueKey,
Mode: mode,
Priority: task.Priority,
WaiterID: waiterID,
Scopes: plan.Scopes,
}
current, currentErr := s.store.GetTaskAdmission(ctx, task.ID)
if currentErr != nil && !errors.Is(currentErr, pgx.ErrNoRows) {
return store.TaskAdmissionResult{}, currentErr
}
if currentErr == nil && current.Status == "waiting" &&
(current.PlatformID != input.PlatformID || current.PlatformModelID != input.PlatformModelID || current.UserGroupID != input.UserGroupID) {
if _, rebindErr := s.store.RebindWaitingTaskAdmission(ctx, input); rebindErr != nil {
return store.TaskAdmissionResult{}, rebindErr
}
s.observeTaskAdmission("candidate_migrated")
}
var result store.TaskAdmissionResult
var err error
if onAdmitted == nil {
result, err = s.store.TryTaskAdmission(ctx, input)
} else {
result, err = s.store.TryTaskAdmissionWithAdmittedHook(ctx, input, onAdmitted)
}
if result.NewlyAdmitted {
s.observeTaskAdmission("admitted")
s.observeTaskAdmissionWait(time.Since(result.Admission.EnqueuedAt))
}
var limitErr *store.RateLimitExceededError
switch {
case errors.As(err, &limitErr) && limitErr.Reason == "queue_full":
s.observeTaskAdmission("queue_full")
case errors.Is(err, store.ErrQueueTimeout):
s.observeTaskAdmission("timeout")
}
return result, err
}
func (s *Service) waitForSynchronousAdmission(ctx context.Context, task store.GatewayTask, plan taskAdmissionPlan) (store.TaskAdmissionResult, error) {
waiterID := uuid.NewString()
return s.waitForTaskAdmission(ctx, task, plan, waiterID)
}
func (s *Service) waitForTaskAdmission(ctx context.Context, task store.GatewayTask, plan taskAdmissionPlan, waiterID string) (store.TaskAdmissionResult, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
result, err := s.tryTaskAdmission(ctx, task, plan, waiterID)
if err != nil || result.Admitted {
return result, err
}
select {
case <-ctx.Done():
_ = s.store.DeleteTaskAdmission(context.WithoutCancel(ctx), task.ID)
s.observeTaskAdmission("cancelled")
return store.TaskAdmissionResult{}, ctx.Err()
case <-s.currentAdmissionWake():
case <-ticker.C:
}
}
}
func (s *Service) ensureCandidateAdmission(
ctx context.Context,
task store.GatewayTask,
user *auth.User,
body map[string]any,
candidate store.RuntimeModelCandidate,
) (store.TaskAdmissionResult, bool, error) {
scopes, groupID := s.admissionScopes(ctx, user, candidate)
hasConcurrentLimit := false
for _, scope := range scopes {
if scope.ConcurrentLimit > 0 {
hasConcurrentLimit = true
break
}
}
if !hasConcurrentLimit {
if err := s.store.DeleteTaskAdmission(context.WithoutCancel(ctx), task.ID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return store.TaskAdmissionResult{}, false, err
}
return store.TaskAdmissionResult{}, false, nil
}
if err := s.store.CheckRateLimits(ctx, s.rateLimitReservations(ctx, user, candidate, body)); err != nil {
return store.TaskAdmissionResult{}, true, err
}
plan := taskAdmissionPlan{
Candidate: candidate,
Body: body,
ModelType: candidate.ModelType,
GroupID: groupID,
Scopes: scopes,
Eligible: true,
}
waiterID := ""
if !task.AsyncMode {
waiterID = uuid.NewString()
}
result, err := s.waitForTaskAdmission(ctx, task, plan, waiterID)
return result, true, err
}
func (s *Service) observeTaskAdmission(event string) {
observer, ok := s.billingMetrics.(interface {
ObserveTaskAdmission(string)
})
if ok {
observer.ObserveTaskAdmission(event)
}
}
func (s *Service) observeTaskAdmissionWait(wait time.Duration) {
observer, ok := s.billingMetrics.(interface {
ObserveTaskAdmissionWait(time.Duration)
})
if ok {
observer.ObserveTaskAdmissionWait(wait)
}
}
func (s *Service) StartAdmissionNotifier(ctx context.Context) {
s.admissionListener.Do(func() {
go func() {
for ctx.Err() == nil {
err := s.store.ListenTaskAdmissionNotifications(ctx, func(string) {
s.broadcastAdmissionWake()
})
if ctx.Err() != nil {
return
}
if s.logger != nil {
s.logger.Warn("task admission LISTEN connection interrupted; periodic polling remains active", "error", err)
}
timer := time.NewTimer(time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}()
})
}
func (s *Service) currentAdmissionWake() <-chan struct{} {
s.admissionWakeMu.Lock()
defer s.admissionWakeMu.Unlock()
return s.admissionWake
}
func (s *Service) broadcastAdmissionWake() {
s.admissionWakeMu.Lock()
close(s.admissionWake)
s.admissionWake = make(chan struct{})
s.admissionWakeMu.Unlock()
}
func (s *Service) SubmitAsyncTask(ctx context.Context, task store.GatewayTask) error {
if !task.AsyncMode {
return errors.New("only asynchronous tasks can be submitted to the async queue")
}
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
user := authUserFromTask(task)
plan, err := s.buildTaskAdmissionPlan(ctx, task, user)
if err != nil {
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
_, _ = s.store.FailQueuedTask(context.WithoutCancel(ctx), task.ID, clients.ErrorCode(err), err.Error())
return err
}
if !plan.Eligible {
if err := s.EnqueueAsyncTask(ctx, task); err != nil {
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
_, _ = s.store.FailQueuedTask(context.WithoutCancel(ctx), task.ID, "enqueue_failed", err.Error())
return err
}
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
return nil
}
result, err := s.tryTaskAdmissionWithAdmittedHook(ctx, task, plan, "", func(tx pgx.Tx) error {
return s.enqueueAsyncTaskTx(ctx, tx, task.ID, asyncTaskInsertOpts(task))
})
if err != nil {
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
_, _ = s.store.FailQueuedTask(context.WithoutCancel(ctx), task.ID, clients.ErrorCode(err), err.Error())
return err
}
if !result.Admitted {
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
return nil
}
if s.cancelAsyncSubmissionIfDisconnected(ctx, task.ID) {
return ctx.Err()
}
return nil
}
func (s *Service) cancelAsyncSubmissionIfDisconnected(ctx context.Context, taskID string) bool {
if ctx.Err() == nil {
return false
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, changed, err := s.store.CancelQueuedTask(cleanupCtx, taskID, "client disconnected before upstream submission"); err != nil {
if s.logger != nil {
s.logger.Warn("cancel disconnected asynchronous task failed", "taskID", taskID, "error", err)
}
} else if changed {
s.observeTaskAdmission("cancelled")
}
return true
}
func (s *Service) dispatchWaitingAsyncAdmissions(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.currentAdmissionWake():
case <-ticker.C:
}
taskIDs, err := s.store.ListWaitingAsyncAdmissionTaskIDs(ctx, 100)
if err != nil {
s.logger.Warn("list waiting async admissions failed", "error", err)
continue
}
for _, taskID := range taskIDs {
task, err := s.store.GetTask(ctx, taskID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
s.logger.Warn("load waiting async task failed", "taskID", taskID, "error", err)
}
continue
}
if task.Status != "queued" || task.RiverJobID > 0 {
continue
}
if err := s.SubmitAsyncTask(ctx, task); err != nil {
s.logger.Warn("dispatch waiting async admission failed", "taskID", taskID, "error", err)
}
}
}
}
func (s *Service) reapExpiredTaskAdmissions(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
result, err := s.store.ReapExpiredTaskAdmissions(ctx, 500)
if err != nil {
if s.logger != nil {
s.logger.Warn("reap expired task admissions failed", "error", err)
}
continue
}
for range result.ExpiredWaiters {
s.observeTaskAdmission("expired")
}
for range result.ExpiredDeadlines {
s.observeTaskAdmission("timeout")
}
}
}
@@ -0,0 +1,21 @@
package runner
import "testing"
func TestDistributedAdmissionModelTypeBoundary(t *testing.T) {
for _, modelType := range []string{
"image_generate", "image_edit", "image_analysis", "image_vectorize",
"video_generate", "video_enhance", "image_to_video", "text_to_video",
"video_edit", "video_reference", "video_first_last_frame", "video_understanding", "omni_video", "omni",
"audio_generate", "audio_understanding", "text_to_speech", "voice_clone",
} {
if !distributedAdmissionModelType(modelType) {
t.Errorf("%s should use distributed admission", modelType)
}
}
for _, modelType := range []string{"text_generate", "embedding", "rerank", "", "unknown"} {
if distributedAdmissionModelType(modelType) {
t.Errorf("%s should preserve immediate execution/rate-limit behavior", modelType)
}
}
}
+69 -1
View File
@@ -81,6 +81,74 @@ func (s *Service) rateLimitReservations(ctx context.Context, user *auth.User, ca
return out
}
func (s *Service) admissionScopes(ctx context.Context, user *auth.User, candidate store.RuntimeModelCandidate) ([]store.AdmissionScope, string) {
scopes := make([]store.AdmissionScope, 0, 2)
platformPolicy := effectiveRateLimitPolicy(candidate)
if scope, ok := admissionScopeFromPolicy(
"platform_model",
candidate.PlatformModelID,
firstNonEmptyString(candidate.DisplayName, candidate.ModelAlias, candidate.ModelName),
map[string]any{
"platformId": candidate.PlatformID,
"platformName": candidate.PlatformName,
"modelAlias": candidate.ModelAlias,
"modelName": candidate.ModelName,
},
platformPolicy,
); ok {
scopes = append(scopes, scope)
}
groupID := ""
if group, err := s.store.ResolveUserGroupPolicy(ctx, user); err == nil && group.ID != "" {
groupPolicy := store.NormalizeRateLimitPolicy(group.RateLimitPolicy)
if scope, ok := admissionScopeFromPolicy(
"user_group",
group.ID,
firstNonEmptyString(group.Name, group.GroupKey),
map[string]any{"groupKey": group.GroupKey, "name": group.Name},
groupPolicy,
); ok {
groupID = group.ID
scopes = append(scopes, scope)
}
}
return scopes, groupID
}
func admissionScopeFromPolicy(scopeType string, scopeKey string, scopeName string, metadata map[string]any, policy map[string]any) (store.AdmissionScope, bool) {
concurrentLimit, hasConcurrent := store.RateLimitPolicyMetric(policy, "concurrent")
queueRule, hasQueue := store.QueueRuleFromPolicy(policy)
if !hasConcurrent && !hasQueue {
return store.AdmissionScope{}, false
}
scope := store.AdmissionScope{
ScopeType: scopeType,
ScopeKey: scopeKey,
ScopeName: scopeName,
ScopeMetadata: metadata,
ConcurrentLimit: concurrentLimit,
Amount: 1,
LeaseTTLSeconds: 120,
Policy: policy,
}
if hasQueue {
scope.QueueLimit = queueRule.Limit
scope.MaxWaitSeconds = queueRule.MaxWaitSeconds
}
rules, _ := store.NormalizeRateLimitPolicy(policy)["rules"].([]any)
for _, rawRule := range rules {
rule, _ := rawRule.(map[string]any)
if strings.TrimSpace(stringFromMap(rule, "metric")) != "concurrent" {
continue
}
if ttl := int(floatFromAny(rule["leaseTtlSeconds"])); ttl > 0 {
scope.LeaseTTLSeconds = ttl
}
break
}
return scope, true
}
func effectiveRateLimitPolicy(candidate store.RuntimeModelCandidate) map[string]any {
return store.EffectiveRateLimitPolicy(store.EffectiveRateLimitPolicyInput{
BasePolicy: candidate.BaseRateLimitPolicy,
@@ -118,7 +186,7 @@ func reservationsFromPolicy(scopeType string, scopeKey string, scopeName string,
rule, _ := rawRule.(map[string]any)
metric := strings.TrimSpace(stringFromMap(rule, "metric"))
limit := floatFromAny(rule["limit"])
if metric == "" || limit <= 0 {
if metric == "" || metric == "queue_size" || limit <= 0 {
continue
}
amount := 1.0
+105 -42
View File
@@ -102,7 +102,7 @@ func (s *Service) startRiverQueue(ctx context.Context, workerEnabled bool) error
}
controlClient, err := river.NewClient(driver, &river.Config{
ID: asyncWorkerID() + "-control",
ID: s.workerInstanceID + "-control",
Logger: s.logger,
TestOnly: s.cfg.AppEnv == "test",
})
@@ -130,15 +130,18 @@ func (s *Service) startRiverQueue(ctx context.Context, workerEnabled bool) error
if err != nil {
return fmt.Errorf("calculate initial async worker capacity: %w", err)
}
executionClient, err := s.makeAsyncExecutionClient(snapshot.Capacity)
if err != nil {
return err
}
if err := executionClient.Start(ctx); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = executionClient.StopAndCancel(cleanupCtx)
cancel()
return err
var executionClient asyncExecutionClient
if snapshot.Capacity > 0 {
executionClient, err = s.makeAsyncExecutionClient(snapshot.Capacity)
if err != nil {
return err
}
if err := executionClient.Start(ctx); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = executionClient.StopAndCancel(cleanupCtx)
cancel()
return err
}
}
s.riverMu.Lock()
s.riverControlClient = controlClient
@@ -161,11 +164,15 @@ func (s *Service) startRiverQueue(ctx context.Context, workerEnabled bool) error
)
if err := s.recoverAsyncRiverJobs(ctx); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = executionClient.StopAndCancel(cleanupCtx)
if executionClient != nil {
_ = executionClient.StopAndCancel(cleanupCtx)
}
cancel()
return err
}
go s.refreshAsyncWorkerCapacity(ctx)
go s.dispatchWaitingAsyncAdmissions(ctx)
go s.reapExpiredTaskAdmissions(ctx)
go s.stopAsyncWorkersOnShutdown(ctx)
return nil
}
@@ -176,7 +183,7 @@ func (s *Service) newRiverAsyncExecutionClient(capacity int) (*river.Client[pgx.
return nil, err
}
return river.NewClient(riverpgxv5.New(s.store.Pool()), &river.Config{
ID: fmt.Sprintf("%s-exec-%d-%d", asyncWorkerID(), capacity, time.Now().UnixNano()),
ID: fmt.Sprintf("%s-exec-%d-%d", s.workerInstanceID, capacity, time.Now().UnixNano()),
JobTimeout: -1,
Logger: s.logger,
CompletedJobRetentionPeriod: 24 * time.Hour,
@@ -203,7 +210,26 @@ func (s *Service) loadAsyncWorkerCapacity(ctx context.Context) (store.AsyncWorke
if s.asyncCapacityLoader != nil {
return s.asyncCapacityLoader(ctx, s.cfg.AsyncWorkerHardLimit)
}
return s.store.AsyncWorkerCapacity(ctx, s.cfg.AsyncWorkerHardLimit)
snapshot, err := s.store.AsyncWorkerCapacity(ctx, s.cfg.AsyncWorkerHardLimit)
if err != nil {
return store.AsyncWorkerCapacitySnapshot{}, err
}
allocation, err := s.store.RegisterWorkerInstance(ctx, store.WorkerRegistrationInput{
InstanceID: s.workerInstanceID,
PodUID: strings.TrimSpace(os.Getenv("POD_UID")),
PodName: strings.TrimSpace(os.Getenv("POD_NAME")),
Site: strings.TrimSpace(os.Getenv("EASYAI_SITE")),
Revision: strings.TrimSpace(os.Getenv("AI_GATEWAY_REVISION")),
DesiredCapacity: snapshot.Capacity,
})
if err != nil {
return store.AsyncWorkerCapacitySnapshot{}, err
}
snapshot.Capacity = allocation.Allocated
snapshot.GlobalCapacity = allocation.DesiredCapacity
snapshot.ActiveInstances = allocation.ActiveInstances
snapshot.InstanceID = allocation.InstanceID
return snapshot, nil
}
func (s *Service) refreshAsyncWorkerCapacity(ctx context.Context) {
@@ -233,25 +259,30 @@ func (s *Service) resizeAsyncWorkerCapacity(ctx context.Context) {
if snapshot.Capacity == currentCapacity {
return
}
newClient, err := s.makeAsyncExecutionClient(snapshot.Capacity)
if err != nil {
s.observeAsyncWorkerResize("create_failed")
s.logger.Warn("create replacement async worker client failed; keeping current client",
"error", err, "currentCapacity", currentCapacity, "desiredCapacity", snapshot.Capacity)
return
}
if err := newClient.Start(ctx); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = newClient.StopAndCancel(cleanupCtx)
cancel()
s.observeAsyncWorkerResize("start_failed")
s.logger.Warn("start replacement async worker client failed; keeping current client",
"error", err, "currentCapacity", currentCapacity, "desiredCapacity", snapshot.Capacity)
return
var newClient asyncExecutionClient
if snapshot.Capacity > 0 {
newClient, err = s.makeAsyncExecutionClient(snapshot.Capacity)
if err != nil {
s.observeAsyncWorkerResize("create_failed")
s.logger.Warn("create replacement async worker client failed; keeping current client",
"error", err, "currentCapacity", currentCapacity, "desiredCapacity", snapshot.Capacity)
return
}
if err := newClient.Start(ctx); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = newClient.StopAndCancel(cleanupCtx)
cancel()
s.observeAsyncWorkerResize("start_failed")
s.logger.Warn("start replacement async worker client failed; keeping current client",
"error", err, "currentCapacity", currentCapacity, "desiredCapacity", snapshot.Capacity)
return
}
}
if ctx.Err() != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = newClient.StopAndCancel(cleanupCtx)
if newClient != nil {
_ = newClient.StopAndCancel(cleanupCtx)
}
cancel()
return
}
@@ -271,6 +302,8 @@ func (s *Service) resizeAsyncWorkerCapacity(ctx context.Context) {
"previousCapacity", currentCapacity,
"capacity", snapshot.Capacity,
"desiredCapacity", snapshot.Desired,
"activeInstances", snapshot.ActiveInstances,
"instanceID", snapshot.InstanceID,
"hardLimit", snapshot.HardLimit,
"modelDesired", snapshot.ModelDesired,
"groupDesired", snapshot.GroupDesired,
@@ -294,6 +327,13 @@ func (s *Service) drainAsyncWorkerClient(client asyncExecutionClient) {
func (s *Service) stopAsyncWorkersOnShutdown(ctx context.Context) {
<-ctx.Done()
if s.store != nil && strings.TrimSpace(s.workerInstanceID) != "" {
drainCtx, drainCancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := s.store.MarkWorkerDraining(drainCtx, s.workerInstanceID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
s.logger.Warn("mark async worker draining failed", "error", err)
}
drainCancel()
}
s.riverMu.Lock()
s.riverControlClient = nil
clients := make([]asyncExecutionClient, 0, 1+len(s.riverDrainingClients))
@@ -328,6 +368,12 @@ func (s *Service) observeAsyncWorkerCapacity(snapshot store.AsyncWorkerCapacityS
if ok {
observer.SetAsyncWorkerCapacity(snapshot.Capacity, snapshot.Desired, snapshot.HardLimit, snapshot.Capped)
}
distributedObserver, ok := s.billingMetrics.(interface {
SetDistributedWorkerCapacity(activeInstances, globalCapacity, allocatedCapacity int)
})
if ok {
distributedObserver.SetDistributedWorkerCapacity(snapshot.ActiveInstances, snapshot.GlobalCapacity, snapshot.Capacity)
}
}
func (s *Service) observeAsyncWorkerResize(outcome string) {
@@ -340,16 +386,38 @@ func (s *Service) observeAsyncWorkerResize(outcome string) {
}
func (s *Service) EnqueueAsyncTask(ctx context.Context, task store.GatewayTask) error {
return s.enqueueAsyncTaskWithOptions(ctx, task.ID, asyncTaskInsertOpts(task))
}
func (s *Service) enqueueAsyncTaskWithOptions(ctx context.Context, taskID string, opts *river.InsertOpts) error {
tx, err := s.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := s.enqueueAsyncTaskTx(ctx, tx, taskID, opts); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) enqueueAsyncTaskTx(ctx context.Context, tx pgx.Tx, taskID string, opts *river.InsertOpts) error {
riverClient := s.asyncControlClient()
if riverClient == nil {
return errors.New("river async queue is not started")
}
result, err := riverClient.Insert(ctx, asyncTaskArgs{TaskID: task.ID}, asyncTaskInsertOpts(task))
result, err := riverClient.InsertTx(ctx, tx, asyncTaskArgs{TaskID: taskID}, opts)
if err != nil {
return err
}
if result.Job != nil {
return s.store.SetTaskRiverJobID(ctx, task.ID, result.Job.ID)
if _, err := tx.Exec(ctx, `
UPDATE gateway_tasks
SET river_job_id = $2,
updated_at = now()
WHERE id = $1::uuid`, taskID, result.Job.ID); err != nil {
return err
}
}
return nil
}
@@ -373,14 +441,13 @@ func (s *Service) recoverAsyncRiverJobs(ctx context.Context) error {
return err
}
for _, item := range items {
result, err := riverClient.Insert(ctx, asyncTaskArgs{TaskID: item.ID}, asyncTaskRecoveryInsertOpts(item, time.Now()))
if err != nil {
return err
if admission, admissionErr := s.store.GetTaskAdmission(ctx, item.ID); admissionErr == nil && admission.Status == "waiting" {
continue
} else if admissionErr != nil && !errors.Is(admissionErr, pgx.ErrNoRows) {
return admissionErr
}
if result.Job != nil {
if err := s.store.SetTaskRiverJobID(ctx, item.ID, result.Job.ID); err != nil {
return err
}
if err := s.enqueueAsyncTaskWithOptions(ctx, item.ID, asyncTaskRecoveryInsertOpts(item, time.Now())); err != nil {
return err
}
}
if len(items) > 0 {
@@ -418,10 +485,6 @@ func asyncTaskRecoveryInsertOpts(item store.AsyncTaskQueueItem, now time.Time) *
if item.NextRunAt.After(now) {
opts.ScheduledAt = item.NextRunAt
}
// A replacement process must not be blocked by a River row that the dead
// process left in running state. PostgreSQL execution leases still ensure
// that only one recovery job can call the upstream provider.
opts.UniqueOpts = river.UniqueOpts{}
return opts
}
@@ -0,0 +1,47 @@
package runner
import (
"context"
"sync/atomic"
)
type submissionCancellationGate struct {
submitted atomic.Bool
}
type submissionCancellationGateKey struct{}
func NewRequestExecutionContext(requestContext context.Context, serverContext context.Context) (context.Context, context.CancelFunc) {
base, cancel := context.WithCancel(context.WithoutCancel(requestContext))
gate := &submissionCancellationGate{}
ctx := context.WithValue(base, submissionCancellationGateKey{}, gate)
go func() {
select {
case <-serverContext.Done():
cancel()
case <-requestContext.Done():
if !gate.submitted.Load() {
cancel()
return
}
select {
case <-serverContext.Done():
cancel()
case <-base.Done():
}
case <-base.Done():
}
}()
return ctx, cancel
}
func markUpstreamSubmissionStarted(ctx context.Context) {
if gate, ok := ctx.Value(submissionCancellationGateKey{}).(*submissionCancellationGate); ok {
gate.submitted.Store(true)
}
}
func requestCancelledBeforeSubmission(ctx context.Context) bool {
gate, ok := ctx.Value(submissionCancellationGateKey{}).(*submissionCancellationGate)
return ok && ctx.Err() != nil && !gate.submitted.Load()
}
@@ -0,0 +1,55 @@
package runner
import (
"context"
"testing"
"time"
)
func TestRequestExecutionContextCancelsBeforeSubmission(t *testing.T) {
requestCtx, cancelRequest := context.WithCancel(context.Background())
serverCtx, cancelServer := context.WithCancel(context.Background())
defer cancelServer()
ctx, cancel := NewRequestExecutionContext(requestCtx, serverCtx)
defer cancel()
cancelRequest()
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("request context did not cancel before upstream submission")
}
}
func TestRequestExecutionContextSurvivesDisconnectAfterSubmission(t *testing.T) {
requestCtx, cancelRequest := context.WithCancel(context.Background())
serverCtx, cancelServer := context.WithCancel(context.Background())
ctx, cancel := NewRequestExecutionContext(requestCtx, serverCtx)
defer cancel()
markUpstreamSubmissionStarted(ctx)
cancelRequest()
select {
case <-ctx.Done():
t.Fatal("client disconnect propagated after upstream submission")
case <-time.After(50 * time.Millisecond):
}
cancelServer()
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("server shutdown did not cancel detached execution")
}
}
func TestRequestExecutionContextPreservesRequestValues(t *testing.T) {
type contextKey struct{}
requestCtx := context.WithValue(context.Background(), contextKey{}, "trace-value")
ctx, cancel := NewRequestExecutionContext(requestCtx, context.Background())
defer cancel()
if got := ctx.Value(contextKey{}); got != "trace-value" {
t.Fatalf("request context value = %v, want trace-value", got)
}
}
+203 -11
View File
@@ -36,7 +36,11 @@ type Service struct {
riverExecutionClient asyncExecutionClient
riverDrainingClients map[asyncExecutionClient]struct{}
riverWorkerCapacity int
workerInstanceID string
asyncCapacityLoader func(context.Context, int) (store.AsyncWorkerCapacitySnapshot, error)
admissionWakeMu sync.Mutex
admissionWake chan struct{}
admissionListener sync.Once
asyncClientFactory func(int) (asyncExecutionClient, error)
billingMetrics billingMetricsObserver
}
@@ -135,7 +139,9 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...b
"universal": clients.UniversalClient{HTTPClient: httpClients.none, ScriptExecutor: scriptExecutor},
"simulation": clients.SimulationClient{},
},
httpClients: httpClients,
httpClients: httpClients,
workerInstanceID: asyncWorkerID(),
admissionWake: make(chan struct{}),
}
if len(observers) > 0 {
service.billingMetrics = observers[0]
@@ -172,7 +178,15 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask, user *auth.User, onDelta clients.StreamDelta, executionToken string) (Result, error) {
wasRunning := task.Status == "running"
claimed, err := s.store.ClaimTaskExecution(ctx, task.ID, executionToken, taskExecutionLeaseTTL)
preliminaryModelType := modelTypeFromKind(task.Kind, normalizeRequest(task.Kind, task.Request))
distributedAdmission := distributedAdmissionModelType(preliminaryModelType)
var claimed store.GatewayTask
var err error
if distributedAdmission && !wasRunning {
claimed, err = s.store.ClaimTaskPreparation(ctx, task.ID, executionToken, taskExecutionLeaseTTL)
} else {
claimed, err = s.store.ClaimTaskExecution(ctx, task.ID, executionToken, taskExecutionLeaseTTL)
}
if err != nil {
return Result{}, err
}
@@ -181,6 +195,21 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
defer stopExecution()
go s.renewTaskExecutionLease(executionCtx, stopExecution, task.ID, task.ExecutionToken)
ctx = executionCtx
defer func() {
if !requestCancelledBeforeSubmission(ctx) {
return
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, _, cleanupErr := s.store.CancelTaskBeforeUpstreamSubmission(
cleanupCtx,
task.ID,
task.ExecutionToken,
"client disconnected before upstream submission",
); cleanupErr != nil && s.logger != nil {
s.logger.Warn("cancel disconnected task before upstream submission failed", "taskID", task.ID, "error", cleanupErr)
}
}()
executeStartedAt := time.Now()
restoredRequest, err := s.restoreTaskRequestReferences(ctx, task)
if err != nil {
@@ -189,13 +218,16 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
body := normalizeRequest(task.Kind, restoredRequest)
responseExecution := responseExecutionContext{}
modelType := modelTypeFromKind(task.Kind, body)
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
return Result{}, err
}
if !wasRunning {
if err := s.emit(ctx, task.ID, "task.running", "running", "starting", 0.12, "task pulled from queue and started", map[string]any{"modelType": modelType}, task.RunMode == "simulation"); err != nil {
distributedAdmission = distributedAdmissionModelType(modelType)
if !distributedAdmission {
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
return Result{}, err
}
if !wasRunning {
if err := s.emit(ctx, task.ID, "task.running", "running", "starting", 0.12, "task pulled from queue and started", map[string]any{"modelType": modelType}, task.RunMode == "simulation"); err != nil {
return Result{}, err
}
}
}
if err := validateRequest(task.Kind, body); err != nil {
validationErr := parameterPreprocessClientError(err)
@@ -477,6 +509,90 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
}
}
}
var admittedLeases []store.ConcurrencyLease
admittedPlatformModelID := ""
if distributedAdmission && len(candidates) > 0 {
admissionScopes, groupID := s.admissionScopes(ctx, user, candidates[0])
hasConcurrentLimit := false
for _, scope := range admissionScopes {
if scope.ConcurrentLimit > 0 {
hasConcurrentLimit = true
break
}
}
if hasConcurrentLimit {
if err := s.store.CheckRateLimits(ctx, s.rateLimitReservations(ctx, user, candidates[0], body)); err != nil {
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, clients.ErrorCode(err), err.Error(), task.RunMode == "simulation", err)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
}
plan := taskAdmissionPlan{
Candidate: candidates[0],
Body: body,
ModelType: modelType,
GroupID: groupID,
Scopes: admissionScopes,
Eligible: true,
}
var admissionResult store.TaskAdmissionResult
var admissionErr error
if task.AsyncMode {
admissionResult, admissionErr = s.tryTaskAdmission(ctx, task, plan, "")
if admissionErr == nil && !admissionResult.Admitted {
_ = s.store.ReleaseTaskPreparation(context.WithoutCancel(ctx), task.ID, task.ExecutionToken)
return Result{Task: task, Output: task.Result}, &TaskQueuedError{Delay: time.Second}
}
} else {
admissionResult, admissionErr = s.waitForSynchronousAdmission(ctx, task, plan)
}
if admissionErr != nil {
if requestCancelledBeforeSubmission(ctx) {
cancelled, changed, cancelErr := s.store.CancelTaskBeforeUpstreamSubmission(
context.WithoutCancel(ctx),
task.ID,
task.ExecutionToken,
"client disconnected before upstream submission",
)
if cancelErr != nil {
return Result{}, cancelErr
}
if changed {
return Result{Task: cancelled, Output: cancelled.Result}, admissionErr
}
}
code := clients.ErrorCode(admissionErr)
if errors.Is(admissionErr, context.Canceled) {
code = "client_disconnected"
}
failed, finishErr := s.failTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, code, admissionErr.Error(), task.RunMode == "simulation", admissionErr)
if finishErr != nil && !errors.Is(finishErr, store.ErrTaskExecutionLeaseLost) {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, admissionErr
}
admittedLeases = admissionResult.Leases
if len(admittedLeases) == 0 {
admittedLeases, err = s.store.ActiveTaskAdmissionLeases(ctx, task.ID)
if err != nil {
return Result{}, err
}
}
admittedPlatformModelID = admissionResult.Admission.PlatformModelID
}
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
return Result{}, err
}
if !wasRunning {
if err := s.emit(ctx, task.ID, "task.running", "running", "starting", 0.12, "task admitted and started", map[string]any{"modelType": modelType}, task.RunMode == "simulation"); err != nil {
return Result{}, err
}
}
}
if distributedAdmission {
defer s.store.DeleteTaskAdmission(context.WithoutCancel(ctx), task.ID)
}
firstCandidateBody := body
normalizedModelType := modelType
attemptNo := task.AttemptCount
@@ -579,6 +695,33 @@ candidatesLoop:
if !available {
continue
}
if distributedAdmission && candidate.PlatformModelID != admittedPlatformModelID {
admissionResult, candidateLimited, admissionErr := s.ensureCandidateAdmission(ctx, task, user, body, candidate)
if admissionErr != nil {
lastErr = admissionErr
lastCandidate = candidate
if errors.Is(admissionErr, store.ErrQueueTimeout) {
break
}
if candidateLimited && errors.Is(admissionErr, store.ErrRateLimited) {
continue
}
return Result{}, admissionErr
}
if candidateLimited {
admittedPlatformModelID = admissionResult.Admission.PlatformModelID
admittedLeases = admissionResult.Leases
if len(admittedLeases) == 0 {
admittedLeases, err = s.store.ActiveTaskAdmissionLeases(ctx, task.ID)
if err != nil {
return Result{}, err
}
}
} else {
admittedPlatformModelID = ""
admittedLeases = nil
}
}
platformsVisited++
lastCandidate = candidate
clientAttempts := clientAttemptsForCandidate(candidate)
@@ -614,7 +757,21 @@ candidatesLoop:
}
candidateBody := preprocessing.Body
candidatePricing := pricingByCandidate[pricingCandidateKey(candidate)]
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, candidatePricing, nextAttemptNo, candidateDelta, responseExecution, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, candidatePricing, nextAttemptNo, candidateDelta, responseExecution, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record, admittedPlatformModelID, admittedLeases)
if err != nil && requestCancelledBeforeSubmission(ctx) {
cancelled, changed, cancelErr := s.store.CancelTaskBeforeUpstreamSubmission(
context.WithoutCancel(ctx),
task.ID,
task.ExecutionToken,
"client disconnected before upstream submission",
)
if cancelErr != nil {
return Result{}, cancelErr
}
if changed {
return Result{Task: cancelled, Output: cancelled.Result}, err
}
}
if err != nil && isVolcesRemoteTaskCancellation(candidate, err) {
cancelled, changed, cancelErr := s.store.CancelSubmittedTask(ctx, task.ID, task.ExecutionToken, "任务已由火山引擎取消")
if cancelErr != nil {
@@ -981,7 +1138,22 @@ func billingItemsFixedTotal(items []any) fixedAmount {
return total
}
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, pricing resolvedPricing, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
func (s *Service) runCandidate(
ctx context.Context,
task store.GatewayTask,
user *auth.User,
body map[string]any,
preprocessing parameterPreprocessingLog,
candidate store.RuntimeModelCandidate,
pricing resolvedPricing,
attemptNo int,
onDelta clients.StreamDelta,
responseExecution responseExecutionContext,
cacheAffinityPolicy map[string]any,
cacheAffinityRecordKeys []string,
admittedPlatformModelID string,
admittedLeases []store.ConcurrencyLease,
) (clients.Response, error) {
var err error
candidate, err = candidateWithEnvironmentCredentials(candidate)
if err != nil {
@@ -990,12 +1162,24 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
reservations := s.rateLimitReservations(ctx, user, candidate, body)
if admittedPlatformModelID == candidate.PlatformModelID && len(admittedLeases) > 0 {
filtered := make([]store.RateLimitReservation, 0, len(reservations))
for _, reservation := range reservations {
if reservation.Metric != "concurrent" {
filtered = append(filtered, reservation)
}
}
reservations = filtered
}
limitResult, err := s.store.ReserveRateLimits(ctx, task.ID, "", reservations)
if err != nil {
retryable := store.RateLimitRetryable(err)
clientErr := &clients.ClientError{Code: "rate_limit", Message: err.Error(), Retryable: retryable}
return clients.Response{}, &localRateLimitError{clientErr: clientErr, cause: err, retryAfter: localRateLimitRetryAfter(err)}
}
if admittedPlatformModelID == candidate.PlatformModelID {
limitResult.Leases = append(limitResult.Leases, admittedLeases...)
}
rateReservationsFinalized := false
defer func() {
if !rateReservationsFinalized {
@@ -1124,6 +1308,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
submissionStatus := "not_submitted"
if strings.TrimSpace(task.RemoteTaskID) != "" {
submissionStatus = "response_received"
markUpstreamSubmissionStarted(ctx)
if err := s.store.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, submissionStatus); err != nil {
return clients.Response{}, fmt.Errorf("restore upstream submission status: %w", err)
}
@@ -1181,7 +1366,11 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return setSubmissionStatus("response_received")
},
OnUpstreamSubmissionStarted: func() error {
return setSubmissionStatus("submitting")
if err := setSubmissionStatus("submitting"); err != nil {
return err
}
markUpstreamSubmissionStarted(ctx)
return nil
},
OnUpstreamResponseReceived: func() error {
return setSubmissionStatus("response_received")
@@ -1907,7 +2096,10 @@ func canonicalModelType(value string) string {
func isKnownModelType(value string) bool {
switch value {
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "image_vectorize", "video_generate", "video_enhance", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone":
case "text_generate", "text_embedding", "text_rerank",
"image_generate", "image_edit", "image_analysis", "image_vectorize",
"video_generate", "video_enhance", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "video_understanding",
"omni_video", "omni", "audio_generate", "audio_understanding", "text_to_speech", "voice_clone":
return true
default:
return false