feat(worker): 实现集群限流与自适应负载

保留平台模型 RPM、TPM 和并发策略语义,增加 PostgreSQL 集群级租约、饱和候选重选和多平台自动负载,避免突发任务固定等待首个平台。\n\n新增 Worker 实时负载采样、自适应 active/heavy 容量、心跳与管理端指标,并扩展本地 acceptance runner,覆盖三 Worker、同模型三平台 2/4/6 并发和 48 个带图视频突发任务。\n\n验证:go test ./...、go vet ./...、PostgreSQL 跨 Store 集成测试、gofmt、bash -n、ShellCheck 及本地集群 provider-burst 验收通过;48/48 成功,无越限、重复提交、重复计费、重复回调或终态资源泄漏。
This commit is contained in:
2026-08-03 00:13:46 +08:00
parent 9a01fd4657
commit c28bf74230
52 changed files with 3700 additions and 272 deletions
+32 -19
View File
@@ -205,23 +205,12 @@ func (s *Service) taskAdmissionScopes(
}
func acceptanceAdmissionScopes(task store.GatewayTask, scopes []store.AdmissionScope) []store.AdmissionScope {
if task.RunMode != "acceptance" && task.RunMode != "acceptance_canary" {
if task.RunMode != "acceptance" {
return scopes
}
out := append([]store.AdmissionScope(nil), scopes...)
for index := range out {
// Protocol-emulated acceptance measures Gateway and Worker capacity, so
// the isolated Run is bounded by the worker_capacity scope instead of a
// production supplier quota. The real acceptance_canary path deliberately
// retains the production platform-model concurrency limit.
if task.RunMode == "acceptance" && out[index].ScopeType == "platform_model" {
out[index].ConcurrentLimit = 0
}
if out[index].ConcurrentLimit <= 0 {
if task.RunMode != "acceptance" || out[index].ScopeType != "platform_model" {
continue
}
}
out[index].ScopeKey = acceptanceScopeKey(task, out[index].ScopeKey)
out[index].QueueLimit = acceptanceQueueLimit
out[index].MaxWaitSeconds = acceptanceQueueMaxWait
}
@@ -235,16 +224,21 @@ func acceptanceInfrastructureReservations(
if task.RunMode != "acceptance" {
return reservations
}
out := make([]store.RateLimitReservation, 0, len(reservations))
for _, reservation := range reservations {
if reservation.ScopeType == "platform_model" && reservation.Metric == "concurrent" {
continue
}
out = append(out, reservation)
out := append([]store.RateLimitReservation(nil), reservations...)
for index := range out {
out[index].ScopeKey = acceptanceScopeKey(task, out[index].ScopeKey)
}
return out
}
func acceptanceScopeKey(task store.GatewayTask, scopeKey string) string {
runID := strings.TrimSpace(task.AcceptanceRunID)
if runID == "" {
runID = "unbound"
}
return "acceptance:" + runID + ":" + scopeKey
}
func (s *Service) loadAsyncTaskAdmission(ctx context.Context, task store.GatewayTask) (*store.TaskAdmission, error) {
if !task.AsyncMode {
return nil, nil
@@ -265,6 +259,7 @@ func pinCandidatesToTaskAdmission(
) ([]store.RuntimeModelCandidate, bool) {
if admission == nil ||
(admission.Status != "waiting" && admission.Status != "admitted") ||
!admission.ReselectRequestedAt.IsZero() ||
len(candidates) < 2 {
return candidates, false
}
@@ -785,6 +780,24 @@ func (s *Service) dispatchWaitingAsyncTasks(ctx context.Context, admissions []st
return false, outcome.Err
}
if !outcome.Result.Admitted {
platformModelID := outcome.Result.Admission.PlatformModelID
if platformModelID == "" {
for _, input := range inputs {
if input.TaskID == outcome.TaskID {
platformModelID = input.PlatformModelID
break
}
}
}
if platformModelID != "" {
marked, markErr := s.store.RequestWaitingTaskAdmissionReselect(ctx, platformModelID)
if markErr != nil {
return false, markErr
}
if marked > 0 {
s.observeTaskAdmission("candidate_reselect_requested")
}
}
return true, nil
}
}
+33 -11
View File
@@ -57,7 +57,7 @@ func TestDistributedAdmissionModelTypeBoundary(t *testing.T) {
}
}
func TestAcceptanceAdmissionScopesUseWorkerCapacityInsteadOfSupplierConcurrency(t *testing.T) {
func TestAcceptanceAdmissionScopesIsolateRunWithoutChangingLimits(t *testing.T) {
input := []store.AdmissionScope{{
ScopeType: "platform_model",
ScopeKey: "model-1",
@@ -70,15 +70,15 @@ func TestAcceptanceAdmissionScopesUseWorkerCapacityInsteadOfSupplierConcurrency(
ConcurrentLimit: 48,
}}
got := acceptanceAdmissionScopes(store.GatewayTask{RunMode: "acceptance"}, input)
got := acceptanceAdmissionScopes(store.GatewayTask{RunMode: "acceptance", AcceptanceRunID: "run-1"}, input)
if got[0].ConcurrentLimit != 0 {
t.Fatalf("protocol-emulated acceptance must defer to worker capacity, got %+v", got[0])
if got[0].ConcurrentLimit != 10 || got[0].ScopeKey != "acceptance:run-1:model-1" {
t.Fatalf("protocol-emulated acceptance must preserve the limit in an isolated scope, got %+v", got[0])
}
if got[0].QueueLimit != acceptanceQueueLimit || got[0].MaxWaitSeconds != acceptanceQueueMaxWait {
t.Fatalf("acceptance must enable a bounded queue, got %+v", got[0])
}
if got[1].ConcurrentLimit != 48 {
if got[1].ConcurrentLimit != 48 || got[1].ScopeKey != "acceptance:run-1:global" {
t.Fatalf("acceptance worker capacity changed, got %+v", got[1])
}
if input[0].QueueLimit != 0 || input[0].MaxWaitSeconds != 0 {
@@ -100,15 +100,15 @@ func TestAcceptanceCanaryPreservesProductionConcurrency(t *testing.T) {
}
}
func TestAcceptanceInfrastructureReservationsOnlyRemoveSupplierConcurrency(t *testing.T) {
func TestAcceptanceInfrastructureReservationsIsolateEveryMetric(t *testing.T) {
input := []store.RateLimitReservation{
{ScopeType: "platform_model", Metric: "concurrent", Limit: 10},
{ScopeType: "platform_model", Metric: "rpm", Limit: 600},
{ScopeType: "user_group", Metric: "concurrent", Limit: 20},
{ScopeType: "platform_model", ScopeKey: "model-1", Metric: "concurrent", Limit: 10},
{ScopeType: "platform_model", ScopeKey: "model-1", Metric: "rpm", Limit: 600},
{ScopeType: "user_group", ScopeKey: "group-1", Metric: "concurrent", Limit: 20},
}
got := acceptanceInfrastructureReservations(store.GatewayTask{RunMode: "acceptance"}, input)
if len(got) != 2 || got[0].Metric != "rpm" || got[1].ScopeType != "user_group" {
got := acceptanceInfrastructureReservations(store.GatewayTask{RunMode: "acceptance", AcceptanceRunID: "run-1"}, input)
if len(got) != 3 || got[0].ScopeKey != "acceptance:run-1:model-1" || got[1].ScopeKey != "acceptance:run-1:model-1" || got[2].ScopeKey != "acceptance:run-1:group-1" {
t.Fatalf("unexpected acceptance reservations: %+v", got)
}
canary := acceptanceInfrastructureReservations(store.GatewayTask{RunMode: "acceptance_canary"}, input)
@@ -192,6 +192,28 @@ func TestPinCandidatesToTaskAdmissionPreservesWaitingCandidate(t *testing.T) {
}
}
func TestPinCandidatesToTaskAdmissionAllowsRequestedReselection(t *testing.T) {
input := []store.RuntimeModelCandidate{
{PlatformID: "platform-a", PlatformModelID: "model-a"},
{PlatformID: "platform-b", PlatformModelID: "model-b"},
}
admission := &store.TaskAdmission{
Status: "waiting",
PlatformID: "platform-b",
PlatformModelID: "model-b",
ReselectRequestedAt: time.Now(),
}
got, pinned := pinCandidatesToTaskAdmission(input, admission)
if pinned {
t.Fatalf("reselection request unexpectedly pinned the old candidate: %+v", got)
}
if got[0].PlatformModelID != "model-a" {
t.Fatalf("reselection changed the sorted candidate order: %+v", got)
}
}
func TestPinCandidatesToTaskAdmissionIgnoresMissingCandidate(t *testing.T) {
input := []store.RuntimeModelCandidate{
{PlatformID: "platform-a", PlatformModelID: "model-a"},
+103 -9
View File
@@ -10,8 +10,10 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/workerload"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivermigrate"
@@ -45,6 +47,10 @@ type asyncTaskWorker struct {
service *Service
}
type workerLoadSampler interface {
Sample(databaseConnections, databaseMax int32) workerload.ResourceSample
}
func (w *asyncTaskWorker) Work(ctx context.Context, job *river.Job[asyncTaskArgs]) error {
task, err := w.service.store.GetTask(ctx, job.Args.TaskID)
if err != nil {
@@ -53,6 +59,12 @@ func (w *asyncTaskWorker) Work(ctx context.Context, job *river.Job[asyncTaskArgs
if task.Status == "succeeded" || task.Status == "failed" || task.Status == "cancelled" {
return nil
}
loadLease, admitted := w.service.tryStartWorkerTask()
if !admitted {
return river.JobSnooze(workerLoadRetryDelay(task.ID))
}
defer loadLease.Release()
ctx = context.WithValue(ctx, workerLoadLeaseContextKey{}, loadLease)
executionToken := uuid.NewString()
result, runErr := w.service.executeWithToken(ctx, task, authUserFromTask(task), nil, executionToken)
if runErr == nil {
@@ -323,21 +335,36 @@ func (s *Service) makeAsyncExecutionClient(capacity int) (asyncExecutionClient,
func (s *Service) loadAsyncWorkerCapacity(ctx context.Context) (store.AsyncWorkerCapacitySnapshot, error) {
if s.asyncCapacityLoader != nil {
return s.asyncCapacityLoader(ctx, s.cfg.AsyncWorkerHardLimit)
snapshot, err := s.asyncCapacityLoader(ctx, s.cfg.AsyncWorkerHardLimit)
if err == nil && s.workerLoad != nil {
s.workerLoad.SetClaimLimit(snapshot.Capacity)
}
return snapshot, err
}
snapshot, err := s.coordinationStore.AsyncWorkerCapacity(ctx, s.cfg.AsyncWorkerHardLimit)
if err != nil {
return store.AsyncWorkerCapacitySnapshot{}, err
}
loadSnapshot := s.sampleWorkerLoad()
allocation, err := s.coordinationStore.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,
CapacityLimit: s.cfg.AsyncWorkerInstanceHardLimit,
HeartbeatStaleAfter: time.Duration(s.cfg.AsyncWorkerRefreshIntervalSeconds) * 6 * time.Second,
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,
CapacityLimit: s.cfg.AsyncWorkerInstanceHardLimit,
LoadMode: loadSnapshot.Mode,
SafeCapacity: loadSnapshot.SafeCapacity,
HeavyCapacity: loadSnapshot.HeavyLimit,
ActiveTasks: loadSnapshot.ActiveTasks,
PreparingTasks: loadSnapshot.PreparingTasks,
WaitingUpstreamTasks: loadSnapshot.WaitingUpstreamTasks,
FinalizingTasks: loadSnapshot.FinalizingTasks,
PressureState: string(loadSnapshot.PressureState),
PressureReason: loadSnapshot.PressureReason,
LoadSampledAt: loadSnapshot.SampledAt,
HeartbeatStaleAfter: time.Duration(s.cfg.AsyncWorkerRefreshIntervalSeconds) * 6 * time.Second,
})
if err != nil {
return store.AsyncWorkerCapacitySnapshot{}, err
@@ -346,9 +373,60 @@ func (s *Service) loadAsyncWorkerCapacity(ctx context.Context) (store.AsyncWorke
snapshot.GlobalCapacity = allocation.GlobalAllocated
snapshot.ActiveInstances = allocation.ActiveInstances
snapshot.InstanceID = allocation.InstanceID
loadSnapshot = s.workerLoad.SetClaimLimit(allocation.Allocated)
snapshot.LoadMode = loadSnapshot.Mode
snapshot.LocalSafeCapacity = loadSnapshot.SafeCapacity
snapshot.LocalHeavyCapacity = loadSnapshot.HeavyLimit
snapshot.LocalActiveTasks = loadSnapshot.ActiveTasks
snapshot.LocalPreparingTasks = loadSnapshot.PreparingTasks
snapshot.LocalWaitingTasks = loadSnapshot.WaitingUpstreamTasks
snapshot.LocalFinalizingTasks = loadSnapshot.FinalizingTasks
snapshot.LocalPressureState = string(loadSnapshot.PressureState)
snapshot.LocalPressureReason = loadSnapshot.PressureReason
return snapshot, nil
}
func (s *Service) sampleWorkerLoad() workerload.Snapshot {
if s.workerLoad == nil {
return workerload.Snapshot{Mode: workerload.ModeLegacy, SafeCapacity: s.cfg.AsyncWorkerInstanceHardLimit, HeavyLimit: s.cfg.AsyncWorkerInstanceHardLimit}
}
if s.workerLoadSampler == nil {
return s.workerLoad.Snapshot()
}
var connections int32
var maximum int32
seen := make(map[*pgxpool.Pool]struct{}, 3)
for _, database := range []*store.Store{s.store, s.coordinationStore, s.riverStore} {
if database == nil || database.Pool() == nil {
continue
}
pool := database.Pool()
if _, ok := seen[pool]; ok {
continue
}
seen[pool] = struct{}{}
statistics := pool.Stat()
connections += statistics.AcquiredConns()
maximum += statistics.MaxConns()
}
return s.workerLoad.Observe(s.workerLoadSampler.Sample(connections, maximum))
}
func (s *Service) tryStartWorkerTask() (*workerload.Lease, bool) {
if s.workerLoad == nil {
return nil, true
}
return s.workerLoad.TryStart()
}
func workerLoadRetryDelay(taskID string) time.Duration {
var value uint32
for _, character := range []byte(taskID) {
value = value*33 + uint32(character)
}
return 250*time.Millisecond + time.Duration(value%501)*time.Millisecond
}
func (s *Service) refreshAsyncWorkerCapacity(ctx context.Context) {
ticker := time.NewTicker(time.Duration(s.cfg.AsyncWorkerRefreshIntervalSeconds) * time.Second)
defer ticker.Stop()
@@ -427,6 +505,12 @@ func (s *Service) resizeAsyncWorkerCapacity(ctx context.Context) {
"modelDesired", snapshot.ModelDesired,
"groupDesired", snapshot.GroupDesired,
"capped", snapshot.Capped,
"loadMode", snapshot.LoadMode,
"localSafeCapacity", snapshot.LocalSafeCapacity,
"localHeavyCapacity", snapshot.LocalHeavyCapacity,
"localActiveTasks", snapshot.LocalActiveTasks,
"pressureState", snapshot.LocalPressureState,
"pressureReason", snapshot.LocalPressureReason,
)
if oldClient != nil {
go s.drainAsyncWorkerClient(oldClient)
@@ -493,6 +577,16 @@ func (s *Service) observeAsyncWorkerCapacity(snapshot store.AsyncWorkerCapacityS
if ok {
distributedObserver.SetDistributedWorkerCapacity(snapshot.ActiveInstances, snapshot.GlobalCapacity, snapshot.Capacity)
}
loadObserver, ok := s.billingMetrics.(interface {
SetWorkerLoad(activeLimit, heavyLimit, active, preparing, waiting, finalizing int, pressure string)
})
if ok {
loadObserver.SetWorkerLoad(
snapshot.LocalSafeCapacity, snapshot.LocalHeavyCapacity, snapshot.LocalActiveTasks,
snapshot.LocalPreparingTasks, snapshot.LocalWaitingTasks, snapshot.LocalFinalizingTasks,
snapshot.LocalPressureState,
)
}
}
func (s *Service) observeAsyncWorkerResize(outcome string) {
+43 -7
View File
@@ -19,6 +19,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
scriptengine "github.com/easyai/easyai-ai-gateway/apps/api/internal/script"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/workerload"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
@@ -39,6 +40,8 @@ type Service struct {
riverDrainingClients map[asyncExecutionClient]struct{}
riverWorkerCapacity int
workerInstanceID string
workerLoad *workerload.Controller
workerLoadSampler workerLoadSampler
asyncCapacityLoader func(context.Context, int) (store.AsyncWorkerCapacitySnapshot, error)
admissionWakeMu sync.Mutex
admissionWake chan struct{}
@@ -129,6 +132,9 @@ func NewWithStores(
if cfg.AsyncWorkerRefreshIntervalSeconds == 0 {
cfg.AsyncWorkerRefreshIntervalSeconds = 5
}
if strings.TrimSpace(cfg.AsyncWorkerLoadMode) == "" {
cfg.AsyncWorkerLoadMode = workerload.ModeAdaptive
}
if cfg.MediaMaterializationConcurrency == 0 {
cfg.MediaMaterializationConcurrency = 8
}
@@ -184,8 +190,13 @@ func NewWithStores(
"universal": clients.UniversalClient{HTTPClient: httpClients.none, ScriptExecutor: scriptExecutor},
"simulation": clients.SimulationClient{},
},
httpClients: httpClients,
workerInstanceID: asyncWorkerID(),
httpClients: httpClients,
workerInstanceID: asyncWorkerID(),
workerLoad: workerload.New(workerload.Config{
Mode: cfg.AsyncWorkerLoadMode, HardLimit: cfg.AsyncWorkerInstanceHardLimit,
InitialActive: 4, InitialHeavy: 1, HealthySamples: 3,
}),
workerLoadSampler: workerload.NewSystemSampler(),
admissionWake: make(chan struct{}, 4096),
asyncAdmissionWake: make(chan struct{}, 1),
admissionTaskWaiters: map[string]*admissionTaskWaiter{},
@@ -1266,7 +1277,7 @@ func (s *Service) runCandidate(
}
simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
reservations := s.rateLimitReservations(ctx, user, candidate, body)
reservations := acceptanceInfrastructureReservations(task, s.rateLimitReservations(ctx, user, candidate, body))
if admittedPlatformModelID == candidate.PlatformModelID && len(admittedLeases) > 0 {
filtered := make([]store.RateLimitReservation, 0, len(reservations))
for _, reservation := range reservations {
@@ -1278,6 +1289,10 @@ func (s *Service) runCandidate(
}
limitResult, err := s.store.ReserveRateLimits(ctx, task.ID, "", reservations)
if err != nil {
var limitErr *store.RateLimitExceededError
if errors.As(err, &limitErr) {
s.observeProviderQuotaWait(limitErr.Metric)
}
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)}
@@ -1438,6 +1453,9 @@ func (s *Service) runCandidate(
); err != nil {
return clients.Response{}, fmt.Errorf("restore upstream submission status: %w", err)
}
if err := enterWorkerWaiting(ctx); err != nil {
return clients.Response{}, err
}
}
setSubmissionStatus := func(status string) error {
if submissionStatus == "response_received" && status != "response_received" {
@@ -1484,7 +1502,10 @@ func (s *Service) runCandidate(
if err := s.persistCompatibilitySubmission(context.WithoutCancel(ctx), task, candidate, remoteTaskID, checkpoint, submissionWire); err != nil {
return err
}
return setSubmissionStatus("response_received")
if err := setSubmissionStatus("response_received"); err != nil {
return err
}
return enterWorkerWaiting(ctx)
},
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
if strings.TrimSpace(remoteTaskID) == "" {
@@ -1496,18 +1517,21 @@ func (s *Service) runCandidate(
}
task.RemoteTaskID = remoteTaskID
task.RemoteTaskPayload = checkpoint
return setSubmissionStatus("response_received")
if err := setSubmissionStatus("response_received"); err != nil {
return err
}
return enterWorkerWaiting(ctx)
},
OnUpstreamSubmissionStarted: func() error {
if err := setSubmissionStatus("submitting"); err != nil {
return err
}
markUpstreamSubmissionStarted(ctx)
return nil
return enterWorkerWaiting(ctx)
},
OnUpstreamResponseReceived: func() error {
submissionStatus = "response_received"
return nil
return enterWorkerFinalizing(ctx)
},
OnUpstreamWireResponse: func(wire *clients.WireResponse) error {
submissionWire = wire
@@ -1521,6 +1545,9 @@ func (s *Service) runCandidate(
UpstreamPreviousResponseID: responseExecution.UpstreamPreviousResponseID,
PreviousResponseTurns: responseExecution.PreviousTurns,
})
if phaseErr := enterWorkerFinalizing(runCtx); err == nil && phaseErr != nil {
err = phaseErr
}
if leaseErr := stopLeaseRenewal(); leaseErr != nil {
err = &clients.ClientError{
Code: "concurrency_lease_lost",
@@ -1717,6 +1744,15 @@ func (s *Service) runCandidate(
return response, nil
}
func (s *Service) observeProviderQuotaWait(metric string) {
observer, ok := s.billingMetrics.(interface {
ObserveProviderQuotaWait(string)
})
if ok {
observer.ObserveProviderQuotaWait(metric)
}
}
func minimalRemoteTaskCheckpoint(provider string, specType string, payload map[string]any) map[string]any {
const maxBytes = 8192
provider = strings.ToLower(strings.TrimSpace(provider))
+28
View File
@@ -0,0 +1,28 @@
package runner
import (
"context"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/workerload"
)
type workerLoadLeaseContextKey struct{}
func workerLoadLease(ctx context.Context) *workerload.Lease {
lease, _ := ctx.Value(workerLoadLeaseContextKey{}).(*workerload.Lease)
return lease
}
func enterWorkerWaiting(ctx context.Context) error {
if lease := workerLoadLease(ctx); lease != nil {
return lease.EnterWaiting()
}
return nil
}
func enterWorkerFinalizing(ctx context.Context) error {
if lease := workerLoadLease(ctx); lease != nil {
return lease.EnterFinalizing(ctx)
}
return nil
}