fix(acceptance): 按隔离额度执行平台降级路由
让 acceptance 候选负载读取同一 Run ID 下的并发、RPM 和 TPM 计数器,并在单个平台模型额度不足时继续尝试下一候选,生产与 canary 额度作用域保持不变。\n\n补齐本地容量控制器对宁波和香港测试 Worker 的最小 RBAC,避免初次应用生产反亲和配置时发生滚动死锁,并将突发负载对齐到完整限流窗口。\n\n验证:\n- go test ./internal/runner ./internal/store -count=1\n- go vet ./internal/runner ./internal/store\n- bash -n scripts/acceptance/local-cluster.sh scripts/acceptance/provider-burst.sh scripts/acceptance/run-local-acceptance.sh\n- shellcheck -x scripts/acceptance/local-cluster.sh scripts/acceptance/provider-burst.sh scripts/acceptance/run-local-acceptance.sh\n- kubectl apply --dry-run=client -f deploy/kubernetes/local-acceptance/capacity-controller-rbac.yaml
This commit is contained in:
@@ -103,6 +103,7 @@ func (s *Service) buildTaskAdmissionPlanForCurrentBinding(
|
||||
CacheAffinityKey: cacheAffinityKeys.Primary,
|
||||
CacheAffinityKeys: cacheAffinityKeys.Lookup,
|
||||
CacheAffinityPolicy: runnerPolicy.CacheAffinityPolicy,
|
||||
QuotaScopePrefix: acceptanceQuotaScopePrefix(task),
|
||||
})
|
||||
if err == nil {
|
||||
candidates, err = filterCandidatesByRequestedPlatform(candidates, body)
|
||||
@@ -120,6 +121,7 @@ func (s *Service) buildTaskAdmissionPlanForCurrentBinding(
|
||||
return taskAdmissionPlan{}, err
|
||||
}
|
||||
candidates, _ = pinCandidatesToTaskAdmission(candidates, admission)
|
||||
var candidateRateLimitErr error
|
||||
for _, candidate := range candidates {
|
||||
available, availabilityErr := s.store.RuntimeCandidateAvailable(ctx, candidate.PlatformID, candidate.PlatformModelID)
|
||||
if availabilityErr != nil {
|
||||
@@ -151,6 +153,13 @@ func (s *Service) buildTaskAdmissionPlanForCurrentBinding(
|
||||
s.rateLimitReservations(ctx, user, candidate, body),
|
||||
)
|
||||
if err := s.store.CheckRateLimits(ctx, reservations); err != nil {
|
||||
var limitErr *store.RateLimitExceededError
|
||||
if errors.As(err, &limitErr) && limitErr.ScopeType == "platform_model" {
|
||||
if candidateRateLimitErr == nil {
|
||||
candidateRateLimitErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return taskAdmissionPlan{}, err
|
||||
}
|
||||
return taskAdmissionPlan{
|
||||
@@ -162,6 +171,9 @@ func (s *Service) buildTaskAdmissionPlanForCurrentBinding(
|
||||
Eligible: true,
|
||||
}, nil
|
||||
}
|
||||
if candidateRateLimitErr != nil {
|
||||
return taskAdmissionPlan{}, candidateRateLimitErr
|
||||
}
|
||||
return taskAdmissionPlan{}, store.ErrNoModelCandidate
|
||||
}
|
||||
|
||||
@@ -239,6 +251,13 @@ func acceptanceScopeKey(task store.GatewayTask, scopeKey string) string {
|
||||
return "acceptance:" + runID + ":" + scopeKey
|
||||
}
|
||||
|
||||
func acceptanceQuotaScopePrefix(task store.GatewayTask) string {
|
||||
if task.RunMode != "acceptance" {
|
||||
return ""
|
||||
}
|
||||
return acceptanceScopeKey(task, "")
|
||||
}
|
||||
|
||||
func (s *Service) loadAsyncTaskAdmission(ctx context.Context, task store.GatewayTask) (*store.TaskAdmission, error) {
|
||||
if !task.AsyncMode {
|
||||
return nil, nil
|
||||
|
||||
@@ -117,6 +117,19 @@ func TestAcceptanceInfrastructureReservationsIsolateEveryMetric(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceQuotaScopePrefixMatchesIsolatedCounters(t *testing.T) {
|
||||
acceptanceTask := store.GatewayTask{RunMode: "acceptance", AcceptanceRunID: "run-1"}
|
||||
if got := acceptanceQuotaScopePrefix(acceptanceTask); got != "acceptance:run-1:" {
|
||||
t.Fatalf("acceptance quota scope prefix=%q", got)
|
||||
}
|
||||
if got := acceptanceQuotaScopePrefix(store.GatewayTask{RunMode: "acceptance_canary", AcceptanceRunID: "run-1"}); got != "" {
|
||||
t.Fatalf("canary must use production quota scope, got %q", got)
|
||||
}
|
||||
if got := acceptanceQuotaScopePrefix(store.GatewayTask{RunMode: "production"}); got != "" {
|
||||
t.Fatalf("production quota scope changed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceAdmissionScopesLeaveProductionPolicyUnchanged(t *testing.T) {
|
||||
input := []store.AdmissionScope{{
|
||||
ScopeType: "platform_model",
|
||||
|
||||
@@ -372,6 +372,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
CacheAffinityKey: cacheAffinityKeys.Primary,
|
||||
CacheAffinityKeys: cacheAffinityKeys.Lookup,
|
||||
CacheAffinityPolicy: runnerPolicy.CacheAffinityPolicy,
|
||||
QuotaScopePrefix: acceptanceQuotaScopePrefix(task),
|
||||
})
|
||||
if err == nil {
|
||||
candidates, err = filterCandidatesByRequestedPlatform(candidates, body)
|
||||
|
||||
@@ -17,6 +17,7 @@ type ListModelCandidatesOptions struct {
|
||||
CacheAffinityKey string
|
||||
CacheAffinityKeys []string
|
||||
CacheAffinityPolicy map[string]any
|
||||
QuotaScopePrefix string
|
||||
}
|
||||
|
||||
func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string, user *auth.User, options ...ListModelCandidatesOptions) ([]RuntimeModelCandidate, error) {
|
||||
@@ -102,7 +103,7 @@ LEFT JOIN (
|
||||
AND released_at IS NULL
|
||||
AND expires_at > statement_timestamp()
|
||||
GROUP BY scope_key
|
||||
) con ON con.scope_key = m.id::text
|
||||
) con ON con.scope_key = $5::text || m.id::text
|
||||
LEFT JOIN (
|
||||
SELECT queued_sources.platform_model_id, COUNT(DISTINCT queued_sources.task_id) AS waiting
|
||||
FROM (
|
||||
@@ -137,7 +138,7 @@ LEFT JOIN (
|
||||
AND metric = 'rpm'
|
||||
AND reset_at > now()
|
||||
ORDER BY scope_key, window_start DESC
|
||||
) rpm ON rpm.scope_key = m.id::text
|
||||
) rpm ON rpm.scope_key = $5::text || m.id::text
|
||||
LEFT JOIN (
|
||||
SELECT scope_key, SUM(used_value) AS used_value, SUM(reserved_value) AS reserved_value
|
||||
FROM gateway_rate_limit_counters
|
||||
@@ -145,7 +146,7 @@ LEFT JOIN (
|
||||
AND metric LIKE 'tpm%'
|
||||
AND reset_at > now()
|
||||
GROUP BY scope_key
|
||||
) tpm ON tpm.scope_key = m.id::text
|
||||
) tpm ON tpm.scope_key = $5::text || m.id::text
|
||||
WHERE p.status = 'enabled'
|
||||
AND p.deleted_at IS NULL
|
||||
AND m.enabled = true
|
||||
@@ -170,7 +171,7 @@ WHERE p.status = 'enabled'
|
||||
COALESCE(s.running_count, 0) ASC,
|
||||
COALESCE(s.waiting_count, 0) ASC,
|
||||
COALESCE(s.last_assigned_at, to_timestamp(0)) ASC,
|
||||
m.created_at ASC`, exactModel, modelType, listOptions.CacheAffinityKeys, cacheAffinityStaleAfterSeconds(listOptions.CacheAffinityPolicy))
|
||||
m.created_at ASC`, exactModel, modelType, listOptions.CacheAffinityKeys, cacheAffinityStaleAfterSeconds(listOptions.CacheAffinityPolicy), listOptions.QuotaScopePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -472,6 +473,7 @@ func normalizeListModelCandidatesOptions(modelType string, options ...ListModelC
|
||||
}
|
||||
out := options[0]
|
||||
out.CacheAffinityKey = strings.TrimSpace(out.CacheAffinityKey)
|
||||
out.QuotaScopePrefix = strings.TrimSpace(out.QuotaScopePrefix)
|
||||
out.CacheAffinityKeys = normalizedCacheAffinityKeys(out.CacheAffinityKey, out.CacheAffinityKeys)
|
||||
if len(out.CacheAffinityKeys) > 0 {
|
||||
out.CacheAffinityKey = out.CacheAffinityKeys[0]
|
||||
|
||||
Reference in New Issue
Block a user