refactor(runner): 收敛上游失败决策与轮转策略
将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
This commit is contained in:
@@ -42,6 +42,29 @@ type failoverDecision struct {
|
||||
Info failureInfo
|
||||
}
|
||||
|
||||
type failureDecision struct {
|
||||
Route string
|
||||
Effect string
|
||||
Target string
|
||||
CooldownSeconds int
|
||||
DemoteSteps int
|
||||
Reason string
|
||||
Match policyRuleMatch
|
||||
Info failureInfo
|
||||
}
|
||||
|
||||
type resolveCandidateFailureInput struct {
|
||||
RunnerPolicy store.RunnerPolicy
|
||||
Candidate store.RuntimeModelCandidate
|
||||
Err error
|
||||
ClientAttempt int
|
||||
MaxClientAttempts int
|
||||
HasNextCandidate bool
|
||||
FailoverExpired bool
|
||||
Async bool
|
||||
DownstreamStarted bool
|
||||
}
|
||||
|
||||
type priorityDemoteDecision struct {
|
||||
Demote bool
|
||||
Reason string
|
||||
@@ -104,7 +127,7 @@ func failoverDecisionForCandidate(runnerPolicy store.RunnerPolicy, candidate sto
|
||||
if errors.Is(err, store.ErrRateLimited) {
|
||||
return failoverDecision{Retry: false, Action: "queue", Reason: "local_rate_limit_wait_queue", Match: policyRuleMatch{Source: "gateway_rate_limits", Policy: "rateLimitPolicy", Rule: "localCapacity", Value: "exceeded"}, Info: info}
|
||||
}
|
||||
if ruleDecision, ok := failoverActionRuleDecisionWithSources(runnerPolicy.FailoverPolicy, overridePolicy, info); ok {
|
||||
if ruleDecision, ok := candidateFailoverActionRuleDecision(runnerPolicy.FailoverPolicy, overridePolicy, candidate, info); ok {
|
||||
return ruleDecision
|
||||
}
|
||||
if match, ok := failoverDenyMatchWithSources(runnerPolicy.FailoverPolicy, overridePolicy, info); ok {
|
||||
@@ -125,6 +148,122 @@ func failoverDecisionForCandidate(runnerPolicy store.RunnerPolicy, candidate sto
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "client_non_retryable", Match: policyRuleMatch{Source: "provider_client", Policy: "ClientError", Rule: "Retryable", Value: "false"}, Info: info}
|
||||
}
|
||||
|
||||
func resolveCandidateFailure(input resolveCandidateFailureInput) failureDecision {
|
||||
info := failureInfoFromError(input.Err)
|
||||
if isResultPersistenceFailure(input.Err) {
|
||||
return failureDecision{
|
||||
Route: "stop",
|
||||
Effect: "none",
|
||||
Reason: "result_persistence_failed",
|
||||
Match: policyRuleMatch{Source: "gateway_result_storage", Policy: "persistence", Rule: "providerFailover", Value: "disabled"},
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
if errors.Is(input.Err, store.ErrRateLimited) {
|
||||
route := "stop"
|
||||
if input.Async && store.RateLimitRetryable(input.Err) {
|
||||
route = "requeue"
|
||||
}
|
||||
return failureDecision{
|
||||
Route: route,
|
||||
Effect: "none",
|
||||
Reason: "local_rate_limit_wait_queue",
|
||||
Match: policyRuleMatch{Source: "gateway_rate_limits", Policy: "rateLimitPolicy", Rule: "localCapacity", Value: "exceeded"},
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
|
||||
failover := failoverDecisionForCandidate(input.RunnerPolicy, input.Candidate, input.Err)
|
||||
effect := failoverEffect(failover.Action)
|
||||
if input.DownstreamStarted {
|
||||
return failureDecision{
|
||||
Route: "stop",
|
||||
Effect: effect,
|
||||
Target: failover.Target,
|
||||
CooldownSeconds: failover.CooldownSeconds,
|
||||
DemoteSteps: failover.DemoteSteps,
|
||||
Reason: "downstream_response_started",
|
||||
Match: policyRuleMatch{Source: "gateway_stream", Policy: "streamSafety", Rule: "downstreamStarted", Value: "true"},
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
explicitStop := failover.Action == "stop" && (failover.Reason == "failover_action_rule" ||
|
||||
failover.Reason == "hard_stop_policy" ||
|
||||
failover.Reason == "failover_deny_policy" ||
|
||||
failover.Reason == "failover_disabled" ||
|
||||
failover.Reason == "runner_policy_disabled")
|
||||
if explicitStop {
|
||||
return failureDecision{
|
||||
Route: "stop",
|
||||
Effect: effect,
|
||||
Target: failover.Target,
|
||||
CooldownSeconds: failover.CooldownSeconds,
|
||||
DemoteSteps: failover.DemoteSteps,
|
||||
Reason: failover.Reason,
|
||||
Match: failover.Match,
|
||||
Info: failover.Info,
|
||||
}
|
||||
}
|
||||
|
||||
immediateCandidateInvalidation := failover.Action == "cooldown_and_next" || failover.Action == "disable_and_next"
|
||||
retry := retryDecisionForCandidate(input.Candidate, input.Err)
|
||||
if !immediateCandidateInvalidation && retry.Retry && input.ClientAttempt < input.MaxClientAttempts {
|
||||
return failureDecision{
|
||||
Route: "retry_same",
|
||||
Effect: "none",
|
||||
Reason: retry.Reason,
|
||||
Match: retry.Match,
|
||||
Info: retry.Info,
|
||||
}
|
||||
}
|
||||
|
||||
route := "next"
|
||||
reason := failover.Reason
|
||||
match := failover.Match
|
||||
if input.FailoverExpired {
|
||||
route = "stop"
|
||||
reason = "failover_time_budget_exceeded"
|
||||
match = policyRuleMatch{
|
||||
Source: "gateway_runner_policies.failover_policy",
|
||||
Policy: "failoverPolicy",
|
||||
Rule: "maxDurationSeconds",
|
||||
Value: "exceeded",
|
||||
}
|
||||
} else if !input.HasNextCandidate {
|
||||
route = "stop"
|
||||
reason = "no_next_platform"
|
||||
match = policyRuleMatch{
|
||||
Source: "runner_candidates",
|
||||
Policy: "candidateSelection",
|
||||
Rule: "candidateCount",
|
||||
Value: "exhausted",
|
||||
}
|
||||
}
|
||||
return failureDecision{
|
||||
Route: route,
|
||||
Effect: effect,
|
||||
Target: failover.Target,
|
||||
CooldownSeconds: failover.CooldownSeconds,
|
||||
DemoteSteps: failover.DemoteSteps,
|
||||
Reason: reason,
|
||||
Match: match,
|
||||
Info: failover.Info,
|
||||
}
|
||||
}
|
||||
|
||||
func failoverEffect(action string) string {
|
||||
switch action {
|
||||
case "cooldown_and_next":
|
||||
return "cooldown"
|
||||
case "demote_and_next":
|
||||
return "demote"
|
||||
case "disable_and_next":
|
||||
return "disable"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func shouldDemoteCandidatePriority(runnerPolicy store.RunnerPolicy, err error) bool {
|
||||
decision := priorityDemoteDecisionForCandidate(runnerPolicy, err)
|
||||
return decision.Demote
|
||||
@@ -346,6 +485,46 @@ func failoverActionRuleDecisionWithSources(base map[string]any, override map[str
|
||||
return failoverActionRuleDecision(actionRulesFromPolicy(base), "gateway_runner_policies.failover_policy", info)
|
||||
}
|
||||
|
||||
func candidateFailoverActionRuleDecision(base map[string]any, override map[string]any, candidate store.RuntimeModelCandidate, info failureInfo) (failoverDecision, bool) {
|
||||
if rules := actionRulesFromPolicy(override); len(rules) > 0 {
|
||||
if decision, ok := failoverActionRuleDecision(rules, "runtime_policy_override.failoverPolicy", info); ok {
|
||||
return decision, true
|
||||
}
|
||||
}
|
||||
autoDisablePolicy := effectiveRuntimePolicy(candidate.AutoDisablePolicy, candidate.RuntimePolicyOverride, "autoDisablePolicy")
|
||||
if boolFromPolicy(autoDisablePolicy, "enabled", false) && intFromPolicy(autoDisablePolicy, "threshold") <= 1 {
|
||||
if value, ok := matchingKeywordValue(autoDisablePolicy, "keywords", info.Target); ok {
|
||||
return failoverDecision{
|
||||
Retry: true,
|
||||
Action: "disable_and_next",
|
||||
Target: "platform",
|
||||
Reason: "legacy_auto_disable_policy",
|
||||
Match: policyRuleMatch{Source: "model_runtime_policy_sets.auto_disable_policy", Policy: "autoDisablePolicy", Rule: "keywords", Value: value},
|
||||
Info: info,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
degradePolicy := effectiveRuntimePolicy(candidate.DegradePolicy, candidate.RuntimePolicyOverride, "degradePolicy")
|
||||
if boolFromPolicy(degradePolicy, "enabled", false) {
|
||||
if value, ok := matchingKeywordValue(degradePolicy, "keywords", info.Target); ok {
|
||||
cooldownSeconds := intFromPolicy(degradePolicy, "cooldownSeconds")
|
||||
if cooldownSeconds <= 0 {
|
||||
cooldownSeconds = 300
|
||||
}
|
||||
return failoverDecision{
|
||||
Retry: true,
|
||||
Action: "cooldown_and_next",
|
||||
Target: "model",
|
||||
Reason: "legacy_degrade_policy",
|
||||
CooldownSeconds: cooldownSeconds,
|
||||
Match: policyRuleMatch{Source: "model_runtime_policy_sets.degrade_policy", Policy: "degradePolicy", Rule: "keywords", Value: value},
|
||||
Info: info,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
return failoverActionRuleDecision(actionRulesFromPolicy(base), "gateway_runner_policies.failover_policy", info)
|
||||
}
|
||||
|
||||
func failoverActionRuleDecision(rules []map[string]any, source string, info failureInfo) (failoverDecision, bool) {
|
||||
for index, rule := range rules {
|
||||
if !boolFromPolicy(rule, "enabled", true) {
|
||||
|
||||
Reference in New Issue
Block a user