573 lines
18 KiB
Go
573 lines
18 KiB
Go
package dispatch
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
)
|
||
|
||
// Coordinator 调度协调器:handler 的唯一入口。
|
||
// 自身无状态(策略热更新用 atomic 指针),可安全并发使用。
|
||
type Coordinator struct {
|
||
catalog CatalogPort
|
||
state StatePort
|
||
policy atomic.Pointer[RoutingPolicy]
|
||
clock Clock
|
||
entropy Entropy
|
||
}
|
||
|
||
// NewCoordinator 构造协调器。policy 为 nil 时使用默认策略。
|
||
func NewCoordinator(catalog CatalogPort, state StatePort, policy *RoutingPolicy, clock Clock, entropy Entropy) *Coordinator {
|
||
if policy == nil {
|
||
policy = DefaultRoutingPolicy()
|
||
}
|
||
if clock == nil {
|
||
clock = SystemClock{}
|
||
}
|
||
co := &Coordinator{catalog: catalog, state: state, clock: clock, entropy: entropy}
|
||
co.policy.Store(policy)
|
||
return co
|
||
}
|
||
|
||
// UpdatePolicy 热更新策略(调用方需先 Validate,非法配置不得传入)。
|
||
func (co *Coordinator) UpdatePolicy(p *RoutingPolicy) {
|
||
if p != nil {
|
||
co.policy.Store(p)
|
||
}
|
||
}
|
||
|
||
// Policy 返回当前生效策略。
|
||
func (co *Coordinator) Policy() *RoutingPolicy {
|
||
return co.policy.Load()
|
||
}
|
||
|
||
// Route 开启一次请求的调度会话。profile.Policy 非空时本会话使用该策略
|
||
// (租户级覆盖),否则捕获协调器当前全局策略(会话内保持一致,不受热更新影响)。
|
||
func (co *Coordinator) Route(ctx context.Context, profile RequestProfile) *RouteSession {
|
||
pol := profile.Policy
|
||
if pol == nil {
|
||
pol = co.policy.Load()
|
||
}
|
||
return &RouteSession{
|
||
co: co,
|
||
pol: pol,
|
||
profile: profile,
|
||
sessionKey: ResolveSessionKey(profile, pol.Session),
|
||
excludedChans: make(map[int64]struct{}),
|
||
excludedKeys: make(map[int64]map[int64]struct{}),
|
||
startedAt: co.clock.Now(),
|
||
}
|
||
}
|
||
|
||
// RouteSession 一次请求的调度会话。非并发安全(单请求内串行使用)。
|
||
type RouteSession struct {
|
||
co *Coordinator
|
||
pol *RoutingPolicy // 本会话生效的策略(全局或租户覆盖,Route 时捕获)
|
||
profile RequestProfile
|
||
sessionKey SessionKey
|
||
|
||
excludedChans map[int64]struct{} // 本请求已排除的渠道
|
||
excludedKeys map[int64]map[int64]struct{} // 渠道 → 本请求已排除的 KeyID
|
||
|
||
current *Decision
|
||
lastDecision RetryDecision
|
||
started bool // 是否已做过首次选择
|
||
finished bool
|
||
|
||
attempt AttemptState
|
||
startedAt time.Time
|
||
leaseChannelID int64 // 当前持有租约的渠道(0 = 未持有)
|
||
|
||
lastNoChannelDiag NoChannelDiag // 最近一次 selectChannel 的排除明细(无渠道诊断用)
|
||
}
|
||
|
||
// NoChannelDiag 无可用渠道诊断:候选快照规模与各排除原因的精确计数。
|
||
// 各计数相互独立(一个渠道只计入首个命中的排除原因)。
|
||
type NoChannelDiag struct {
|
||
Snapshot int // 目录快照中的候选渠道数(0 = 目录/权限层就没有该模型的渠道)
|
||
BreakerOpen int // 熔断 OPEN 硬排除
|
||
ProbeDenied int // HALF_OPEN 半开探测令牌拒绝(恢复期每窗口全局仅放行 1 个探测请求)
|
||
LeaseFull int // 容量租约满(渠道并发已达 softLimit / max_concurrency)
|
||
RequestExcluded int // 本请求内先前已失败而被排除的渠道
|
||
CredUnavailable int // 渠道全部 Key 处于冷却/已排除
|
||
// CredCooledKeys 上述渠道中不可用的 Key ID(去重、升序)。凭证冷却只存在于 Redis
|
||
// 且只靠 TTL 自愈,没有 keyID 就无从判断该等 TTL 还是该去解除,故直接打进日志。
|
||
CredCooledKeys []int64
|
||
}
|
||
|
||
// Summary 人类可读的原因摘要(仅列出非零项),供无可用渠道时的日志定位。
|
||
func (d NoChannelDiag) Summary() string {
|
||
if d.Snapshot == 0 {
|
||
return "目录快照为空:该租户+模型无候选渠道(检查渠道启用状态/模型能力/渠道范围)"
|
||
}
|
||
var parts []string
|
||
if d.BreakerOpen > 0 {
|
||
parts = append(parts, fmt.Sprintf("熔断OPEN×%d", d.BreakerOpen))
|
||
}
|
||
if d.ProbeDenied > 0 {
|
||
parts = append(parts, fmt.Sprintf("半开探测限流×%d", d.ProbeDenied))
|
||
}
|
||
if d.LeaseFull > 0 {
|
||
parts = append(parts, fmt.Sprintf("容量租约满×%d", d.LeaseFull))
|
||
}
|
||
if d.CredUnavailable > 0 {
|
||
part := fmt.Sprintf("凭证全部冷却×%d", d.CredUnavailable)
|
||
if len(d.CredCooledKeys) > 0 {
|
||
ids := make([]string, len(d.CredCooledKeys))
|
||
for i, id := range d.CredCooledKeys {
|
||
ids[i] = strconv.FormatInt(id, 10)
|
||
}
|
||
part += "(key=" + strings.Join(ids, ",") + ")"
|
||
}
|
||
parts = append(parts, part)
|
||
}
|
||
if d.RequestExcluded > 0 {
|
||
parts = append(parts, fmt.Sprintf("本请求已排除×%d", d.RequestExcluded))
|
||
}
|
||
if len(parts) == 0 {
|
||
// 快照非空且无任何排除计数:候选全部打分为非正权重(PickHRW 无可选)
|
||
return "候选渠道权重均为 0(健康/余量/层级因子坍缩)"
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
// SessionKey 返回本次请求解析出的会话键(供日志/指标)。
|
||
func (s *RouteSession) SessionKey() SessionKey { return s.sessionKey }
|
||
|
||
// Next 返回下一个应尝试的调度决定。首次调用 = 初始选择;此后行为由上一次
|
||
// Report 的决策驱动(原地重试 → 同渠道同 Key;凭证轮换 → 同渠道换 Key;
|
||
// failover → 排除后重新选择)。返回 nil 表示无渠道可用或已终止。
|
||
func (s *RouteSession) Next(ctx context.Context) *Decision {
|
||
if s.finished {
|
||
return nil
|
||
}
|
||
if s.started {
|
||
switch s.lastDecision {
|
||
case DecisionInPlaceRetry:
|
||
return s.current
|
||
|
||
case DecisionRotateCredential:
|
||
if d := s.rotateKey(ctx); d != nil {
|
||
return d
|
||
}
|
||
// 无可轮换 Key:排除当前渠道,落入重新选择
|
||
s.excludeCurrent(ctx)
|
||
|
||
case DecisionFailover:
|
||
// Report 已完成排除与租约释放
|
||
|
||
default: // Abort 或未 Report 就再次 Next
|
||
return nil
|
||
}
|
||
}
|
||
s.started = true
|
||
return s.selectChannel(ctx)
|
||
}
|
||
|
||
// Report 上报本次尝试结果,返回重试决策与退避时长(已含 jitter)。
|
||
func (s *RouteSession) Report(ctx context.Context, statusCode int, err error, delivery DeliveryState, latencyMs float64, retryAfter time.Duration) (RetryDecision, time.Duration) {
|
||
if s.finished || s.current == nil {
|
||
return DecisionAbort, 0
|
||
}
|
||
pol := s.pol
|
||
class := Classify(statusCode, err, delivery)
|
||
|
||
s.attempt.ElapsedMs = s.co.clock.Now().Sub(s.startedAt).Milliseconds()
|
||
s.attempt.HasAlternateKey = s.alternateKeyAvailable(ctx)
|
||
if delivery == DeliveryResponseStarted {
|
||
s.attempt.ResponseStarted = true
|
||
}
|
||
|
||
decision, backoff := Decide(class, delivery, s.profile.Replay, retryAfter.Milliseconds(), s.attempt, pol.Retry)
|
||
|
||
// 结果上报(CLIENT / CREDENTIAL 类不计渠道健康)
|
||
switch class {
|
||
case ErrClassCredential:
|
||
if s.current.KeyID > 0 {
|
||
s.co.state.CoolCredential(ctx, s.current.KeyID, time.Duration(pol.Retry.CredCooldownSeconds)*time.Second)
|
||
s.excludeKey(s.current.Channel.ID, s.current.KeyID)
|
||
}
|
||
case ErrClassClient, ErrClassNone:
|
||
// 不上报
|
||
default:
|
||
s.co.state.ReportOutcome(Outcome{
|
||
ChannelID: s.current.Channel.ID,
|
||
KeyID: s.current.KeyID,
|
||
Model: s.profile.Model,
|
||
Success: false,
|
||
Class: class,
|
||
LatencyMs: latencyMs,
|
||
})
|
||
}
|
||
|
||
// 按决策更新预算与排除状态。失败全程不删除绑定。
|
||
switch decision {
|
||
case DecisionInPlaceRetry:
|
||
s.attempt.InPlaceUsed++
|
||
case DecisionRotateCredential:
|
||
s.attempt.CredRotationsUsed++
|
||
case DecisionFailover:
|
||
s.attempt.FailoverUsed++
|
||
s.excludeCurrent(ctx)
|
||
}
|
||
s.lastDecision = decision
|
||
|
||
// 叠加 jitter(0~50%)
|
||
if backoff > 0 && s.co.entropy != nil {
|
||
backoff += int64(float64(backoff) * 0.5 * s.co.entropy())
|
||
}
|
||
return decision, time.Duration(backoff) * time.Millisecond
|
||
}
|
||
|
||
// Finish 请求结束(成功或最终失败):释放租约、绑定续期、上报健康。
|
||
func (s *RouteSession) Finish(ctx context.Context, success bool, latencyMs float64) {
|
||
if s.finished {
|
||
return
|
||
}
|
||
s.finished = true
|
||
s.releaseLease(ctx)
|
||
if success && s.current != nil {
|
||
pol := s.pol
|
||
s.co.state.TouchBinding(ctx, s.sessionKey.Key, time.Duration(pol.Binding.TTLSeconds)*time.Second)
|
||
s.co.state.ReportOutcome(Outcome{
|
||
ChannelID: s.current.Channel.ID,
|
||
KeyID: s.current.KeyID,
|
||
Model: s.profile.Model,
|
||
Success: true,
|
||
LatencyMs: latencyMs,
|
||
})
|
||
}
|
||
}
|
||
|
||
// RefreshLease 长流式请求续租(handler 在流转发期间周期调用)。
|
||
func (s *RouteSession) RefreshLease(ctx context.Context) {
|
||
if s.leaseChannelID > 0 {
|
||
s.co.state.RefreshLease(ctx, s.leaseChannelID, s.profile.RequestID)
|
||
}
|
||
}
|
||
|
||
// NoChannelDiagnosis 返回最近一次渠道选择的排除明细诊断。
|
||
// Next 返回 nil(无可用渠道)时供 handler 打出可定位原因的日志,
|
||
// 各字段含义见 NoChannelDiag,摘要文本用 Summary()。
|
||
func (s *RouteSession) NoChannelDiagnosis() NoChannelDiag {
|
||
return s.lastNoChannelDiag
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 内部:选择
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// selectChannel 选择流程:
|
||
// 快照 → 熔断硬排除 → 打分 → 绑定守卫 → HRW → 探测令牌 → 租约。
|
||
// 租约/探测失败只排除重选,不扣预算。
|
||
func (s *RouteSession) selectChannel(ctx context.Context) *Decision {
|
||
pol := s.pol
|
||
snapshot := s.co.catalog.Snapshot(ctx, s.profile.TenantID, s.profile.Model, s.profile.Scope)
|
||
s.lastNoChannelDiag = NoChannelDiag{Snapshot: len(snapshot)}
|
||
|
||
// 本轮选择中被探测令牌 / 租约 / 凭证拒绝的渠道(不进入 excludedChans,不影响绑定守卫语义)
|
||
roundExcluded := make(map[int64]struct{})
|
||
var probeDenied, leaseDenied, credDenied int
|
||
var credCooledKeys []int64
|
||
|
||
for range len(snapshot) + 3 { // 每轮至少排除一个渠道,防御性上限
|
||
scored, halfOpen, exclRound := s.buildCandidates(snapshot, roundExcluded, pol)
|
||
excl := ExclusionStats{
|
||
Breaker: exclRound.Breaker + probeDenied,
|
||
Lease: leaseDenied,
|
||
Request: exclRound.Request + credDenied,
|
||
}
|
||
// 每轮刷新诊断明细(各原因独立计数):返回 nil 时调用方通过 NoChannelDiagnosis 定位原因
|
||
s.lastNoChannelDiag = NoChannelDiag{
|
||
Snapshot: len(snapshot),
|
||
BreakerOpen: exclRound.Breaker,
|
||
ProbeDenied: probeDenied,
|
||
LeaseFull: leaseDenied,
|
||
RequestExcluded: exclRound.Request,
|
||
CredUnavailable: credDenied,
|
||
CredCooledKeys: credCooledKeys,
|
||
}
|
||
|
||
if len(scored) == 0 {
|
||
return nil
|
||
}
|
||
|
||
chosen, reason := s.chooseFrom(ctx, scored, pol)
|
||
if chosen == nil {
|
||
return nil
|
||
}
|
||
|
||
// HALF_OPEN 渠道被选中:按熔断级别取探测令牌,每窗口全局只放行一个。
|
||
// 先取更具体的模型级令牌(被拒不消耗渠道级令牌),再取渠道级;任一被拒即排除重选。
|
||
if need, isHalfOpen := halfOpen[chosen.ID]; isHalfOpen {
|
||
if (need[1] && !s.co.state.TryProbeToken(ctx, chosen.ID, s.profile.Model)) ||
|
||
(need[0] && !s.co.state.TryProbeToken(ctx, chosen.ID, "")) {
|
||
roundExcluded[chosen.ID] = struct{}{}
|
||
probeDenied++
|
||
continue
|
||
}
|
||
reason = ReasonProbe
|
||
}
|
||
|
||
// 凭证选取:全部 Key 冷却/排除 → 该渠道本请求不可用
|
||
keyID, keyOK := s.pickKey(ctx, chosen.Channel)
|
||
if !keyOK {
|
||
roundExcluded[chosen.ID] = struct{}{}
|
||
credDenied++
|
||
credCooledKeys = appendMissingIDs(credCooledKeys, chosen.Channel.KeyIDs)
|
||
continue
|
||
}
|
||
|
||
// 容量租约:满 → 排除重选,不扣预算
|
||
if !s.co.state.AcquireLease(ctx, chosen.ID, chosen.SoftLimit, s.profile.RequestID) {
|
||
roundExcluded[chosen.ID] = struct{}{}
|
||
leaseDenied++
|
||
continue
|
||
}
|
||
s.leaseChannelID = chosen.ID
|
||
|
||
// 新渠道:原地与凭证轮换计数清零(预算按渠道独立计)
|
||
s.attempt.InPlaceUsed = 0
|
||
s.attempt.CredRotationsUsed = 0
|
||
|
||
s.current = &Decision{
|
||
Channel: chosen.Channel,
|
||
KeyID: keyID,
|
||
SessionKey: s.sessionKey,
|
||
Reason: reason,
|
||
Breakdown: chosen.Breakdown,
|
||
CandidateCount: len(scored),
|
||
Excluded: excl,
|
||
}
|
||
return s.current
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// buildCandidates 构建打分候选集:排除本请求已失败渠道与熔断 OPEN 渠道;
|
||
// HALF_OPEN 渠道进入候选(选中时再取探测令牌);tierFactor=0 的层级在
|
||
// 无候选时按层级顺序扩组兜底。
|
||
//
|
||
// 注意:Channel.Breaker / ModelBreaker 由目录适配层提供,已做过冷却期的
|
||
// 惰性 OPEN→HALF_OPEN 判定(EffectiveBreakerState)。
|
||
func (s *RouteSession) buildCandidates(snapshot []Channel, roundExcluded map[int64]struct{}, pol *RoutingPolicy) ([]ScoredChannel, map[int64][2]bool, ExclusionStats) {
|
||
halfOpen := make(map[int64][2]bool)
|
||
excl := ExclusionStats{}
|
||
var scored []ScoredChannel
|
||
var zeroTier []Channel // 因 tierFactor=0 被排除的渠道,供扩组兜底
|
||
|
||
for _, ch := range snapshot {
|
||
if _, ok := s.excludedChans[ch.ID]; ok {
|
||
excl.Request++
|
||
continue
|
||
}
|
||
if _, ok := roundExcluded[ch.ID]; ok {
|
||
continue // 已计数
|
||
}
|
||
if ch.Breaker == BreakerOpen || ch.ModelBreaker == BreakerOpen {
|
||
excl.Breaker++
|
||
continue
|
||
}
|
||
if ch.Breaker == BreakerHalfOpen || ch.ModelBreaker == BreakerHalfOpen {
|
||
// [0]=渠道级半开、[1]=模型级半开(快照按模型行展开,每渠道至多一行)
|
||
halfOpen[ch.ID] = [2]bool{ch.Breaker == BreakerHalfOpen, ch.ModelBreaker == BreakerHalfOpen}
|
||
}
|
||
if tierFactor(ch.Tier, pol) <= 0 {
|
||
zeroTier = append(zeroTier, ch)
|
||
continue
|
||
}
|
||
w, bd := EffectiveWeight(ch, pol, s.profile.Proto)
|
||
scored = append(scored, ScoredChannel{Channel: ch, Weight: w, Breakdown: bd})
|
||
}
|
||
|
||
if !hasPositiveWeight(scored) && len(zeroTier) > 0 {
|
||
// 硬性扩组兜底:按 primary → secondary → reserve 顺序启用 tierFactor=0 的层级
|
||
const epsilonTier = 0.01
|
||
for _, t := range tierOrder {
|
||
var added bool
|
||
for _, ch := range zeroTier {
|
||
if ch.Tier != t {
|
||
continue
|
||
}
|
||
w, bd := EffectiveWeight(ch, pol, s.profile.Proto)
|
||
// tierFactor 为 0,用 epsilon 重算
|
||
bd.Tier = epsilonTier
|
||
bd.Effective = bd.Base * bd.Tier * bd.Health * bd.Headroom * bd.Cost * bd.Ramp * bd.Proto
|
||
w = bd.Effective
|
||
if w > 0 {
|
||
added = true
|
||
}
|
||
scored = append(scored, ScoredChannel{Channel: ch, Weight: w, Breakdown: bd})
|
||
}
|
||
if added {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return scored, halfOpen, excl
|
||
}
|
||
|
||
// chooseFrom 绑定守卫 + HRW。
|
||
// 守卫判据是被绑渠道自身状态(健康因子、原始余量),与 tier/cost 解耦——
|
||
// 溢出到 secondary 的绑定在守卫下保持稳定。
|
||
func (s *RouteSession) chooseFrom(ctx context.Context, scored []ScoredChannel, pol *RoutingPolicy) (*ScoredChannel, DecisionReason) {
|
||
if boundID, ok := s.co.state.GetBinding(ctx, s.sessionKey.Key); ok {
|
||
for i := range scored {
|
||
sc := &scored[i]
|
||
if sc.ID != boundID {
|
||
continue
|
||
}
|
||
if sc.Weight > 0 &&
|
||
sc.Breakdown.Health >= pol.Binding.KeepHealthMin &&
|
||
rawHeadroom(sc.Channel) >= pol.Binding.KeepHeadroomMin {
|
||
return sc, ReasonBind
|
||
}
|
||
break // 被绑渠道不合格 → 重跑 HRW 重绑
|
||
}
|
||
}
|
||
|
||
pick := PickHRW(scored, s.sessionKey.Key)
|
||
if pick == nil {
|
||
return nil, ReasonHRW
|
||
}
|
||
s.co.state.SetBinding(ctx, s.sessionKey.Key, pick.ID, time.Duration(pol.Binding.TTLSeconds)*time.Second)
|
||
|
||
reason := ReasonHRW
|
||
if best := bestAvailableTier(scored); best != "" && pick.Tier != best {
|
||
reason = ReasonOverflow
|
||
}
|
||
return pick, reason
|
||
}
|
||
|
||
// rotateKey 凭证轮换:同渠道选下一个可用 Key。无可用 Key 返回 nil。
|
||
func (s *RouteSession) rotateKey(ctx context.Context) *Decision {
|
||
if s.current == nil {
|
||
return nil
|
||
}
|
||
keyID, ok := s.pickKey(ctx, s.current.Channel)
|
||
if !ok || keyID == 0 || keyID == s.current.KeyID {
|
||
return nil
|
||
}
|
||
next := *s.current
|
||
next.KeyID = keyID
|
||
next.Reason = ReasonCredRotate
|
||
s.current = &next
|
||
return s.current
|
||
}
|
||
|
||
// pickKey 选取渠道当前可用的 Key:跳过本请求已排除与全局冷却中的 Key。
|
||
// KeyIDs 为空(目录未提供 Key 快照)时返回 (0, true),由适配层按旧逻辑自行取。
|
||
func (s *RouteSession) pickKey(ctx context.Context, ch Channel) (int64, bool) {
|
||
if len(ch.KeyIDs) == 0 {
|
||
return 0, true
|
||
}
|
||
excluded := s.excludedKeys[ch.ID]
|
||
for _, id := range ch.KeyIDs {
|
||
if _, ok := excluded[id]; ok {
|
||
continue
|
||
}
|
||
if s.co.state.IsCredentialCooled(ctx, id) {
|
||
continue
|
||
}
|
||
return id, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// alternateKeyAvailable 当前渠道除当前 Key 外是否还有可用 Key(FSM 轮换判据)。
|
||
func (s *RouteSession) alternateKeyAvailable(ctx context.Context) bool {
|
||
if s.current == nil || s.current.KeyID == 0 {
|
||
return false
|
||
}
|
||
ch := s.current.Channel
|
||
excluded := s.excludedKeys[ch.ID]
|
||
for _, id := range ch.KeyIDs {
|
||
if id == s.current.KeyID {
|
||
continue
|
||
}
|
||
if _, ok := excluded[id]; ok {
|
||
continue
|
||
}
|
||
if s.co.state.IsCredentialCooled(ctx, id) {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// appendMissingIDs 把 src 中尚未出现的 ID 并入 dst 并保持升序(诊断用,规模仅候选渠道的 Key 数)。
|
||
func appendMissingIDs(dst, src []int64) []int64 {
|
||
for _, id := range src {
|
||
if !slices.Contains(dst, id) {
|
||
dst = append(dst, id)
|
||
}
|
||
}
|
||
slices.Sort(dst)
|
||
return dst
|
||
}
|
||
|
||
func (s *RouteSession) excludeCurrent(ctx context.Context) {
|
||
if s.current != nil {
|
||
s.excludedChans[s.current.Channel.ID] = struct{}{}
|
||
}
|
||
s.releaseLease(ctx)
|
||
}
|
||
|
||
func (s *RouteSession) excludeKey(channelID, keyID int64) {
|
||
m, ok := s.excludedKeys[channelID]
|
||
if !ok {
|
||
m = make(map[int64]struct{})
|
||
s.excludedKeys[channelID] = m
|
||
}
|
||
m[keyID] = struct{}{}
|
||
}
|
||
|
||
func (s *RouteSession) releaseLease(ctx context.Context) {
|
||
if s.leaseChannelID > 0 {
|
||
s.co.state.ReleaseLease(ctx, s.leaseChannelID, s.profile.RequestID)
|
||
s.leaseChannelID = 0
|
||
}
|
||
}
|
||
|
||
// rawHeadroom 原始负载余量(未经 γ 指数),用于绑定守卫判据。
|
||
func rawHeadroom(c Channel) float64 {
|
||
if c.SoftLimit <= 0 {
|
||
return 1
|
||
}
|
||
h := 1 - float64(c.Inflight)/float64(c.SoftLimit)
|
||
if h < 0 {
|
||
return 0
|
||
}
|
||
return h
|
||
}
|
||
|
||
// bestAvailableTier 候选集中存在(未被熔断/排除)的最高层级,用于溢出判定:
|
||
// 高层级渠道仍在候选集但因饱和/降权未被选中(如 headroom 坍缩为 0)→ 记为 overflow;
|
||
// 高层级渠道整体不在候选集(全部熔断/排除)→ 是 failover 而非溢出,记为 hrw。
|
||
func bestAvailableTier(scored []ScoredChannel) Tier {
|
||
present := make(map[Tier]bool)
|
||
for i := range scored {
|
||
present[scored[i].Tier] = true
|
||
}
|
||
for _, t := range tierOrder {
|
||
if present[t] {
|
||
return t
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func hasPositiveWeight(scored []ScoredChannel) bool {
|
||
for i := range scored {
|
||
if scored[i].Weight > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|