Files
team-api/relaykit/dispatch/coordinator_test.go
T

730 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dispatch
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// 假端口
// ---------------------------------------------------------------------------
type fakeCatalog struct{ channels []Channel }
func (f *fakeCatalog) Snapshot(_ context.Context, _ int64, _ string, _ []int64) []Channel {
out := make([]Channel, len(f.channels))
copy(out, f.channels)
return out
}
// probeTokenKey fakeState 探测令牌的复合键:model 空串 = 渠道级,非空 = 渠道×模型级。
type probeTokenKey struct {
ch int64
model string
}
type fakeState struct {
bindings map[string]int64
setBinds int
touchBinds int
invalidated []int64
outcomes []Outcome
probeGrant map[probeTokenKey]bool // 默认拒绝
leaseDeny map[int64]bool
acquired map[int64]int
released map[int64]int
refreshed map[int64]int
cooled map[int64]bool
}
func newFakeState() *fakeState {
return &fakeState{
bindings: map[string]int64{},
probeGrant: map[probeTokenKey]bool{},
leaseDeny: map[int64]bool{},
acquired: map[int64]int{},
released: map[int64]int{},
refreshed: map[int64]int{},
cooled: map[int64]bool{},
}
}
func (f *fakeState) GetBinding(_ context.Context, key string) (int64, bool) {
id, ok := f.bindings[key]
return id, ok
}
func (f *fakeState) SetBinding(_ context.Context, key string, channelID int64, _ time.Duration) {
f.bindings[key] = channelID
f.setBinds++
}
func (f *fakeState) TouchBinding(_ context.Context, _ string, _ time.Duration) { f.touchBinds++ }
func (f *fakeState) InvalidateChannelBindings(_ context.Context, channelID int64) {
f.invalidated = append(f.invalidated, channelID)
}
func (f *fakeState) ReportOutcome(o Outcome) { f.outcomes = append(f.outcomes, o) }
func (f *fakeState) TryProbeToken(_ context.Context, id int64, model string) bool {
return f.probeGrant[probeTokenKey{id, model}]
}
func (f *fakeState) AcquireLease(_ context.Context, id int64, _ int, _ string) bool {
if f.leaseDeny[id] {
return false
}
f.acquired[id]++
return true
}
func (f *fakeState) RefreshLease(_ context.Context, id int64, _ string) { f.refreshed[id]++ }
func (f *fakeState) ReleaseLease(_ context.Context, id int64, _ string) { f.released[id]++ }
func (f *fakeState) IsCredentialCooled(_ context.Context, keyID int64) bool {
return f.cooled[keyID]
}
func (f *fakeState) CoolCredential(_ context.Context, keyID int64, _ time.Duration) {
f.cooled[keyID] = true
}
type fakeClock struct{ t time.Time }
func (c *fakeClock) Now() time.Time { return c.t }
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
func zeroEntropy() float64 { return 0 }
func newTestCoordinator(state *fakeState, channels ...Channel) (*Coordinator, *fakeClock) {
clock := &fakeClock{t: time.UnixMilli(1_700_000_000_000)}
co := NewCoordinator(&fakeCatalog{channels: channels}, state, nil, clock, zeroEntropy)
return co, clock
}
func testProfile() RequestProfile {
return RequestProfile{
RequestID: "req-1",
TenantID: 1, UserID: 2, APIKeyID: 3,
Model: "gpt-4o",
Replay: ReplayCostly,
Signals: SessionSignals{HeaderSessionID: "sess-1"},
}
}
// ---------------------------------------------------------------------------
// 场景测试
// ---------------------------------------------------------------------------
func TestCoordinator_FirstSelectionAndBindingHit(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierPrimary, 10),
)
// 首次请求:HRW 选择 + 写绑定
s1 := co.Route(ctx, testProfile())
d1 := s1.Next(ctx)
require.NotNil(t, d1)
assert.Equal(t, ReasonHRW, d1.Reason)
assert.Equal(t, 1, state.setBinds)
assert.Equal(t, d1.Channel.ID, state.bindings[s1.SessionKey().Key])
s1.Finish(ctx, true, 120)
assert.Equal(t, 1, state.touchBinds, "成功请求滑动续期绑定")
require.Len(t, state.outcomes, 1)
assert.True(t, state.outcomes[0].Success)
assert.Equal(t, 1, state.released[d1.Channel.ID], "结束释放租约")
// 第二个请求:绑定守卫命中,绝对稳定
s2 := co.Route(ctx, testProfile())
d2 := s2.Next(ctx)
require.NotNil(t, d2)
assert.Equal(t, ReasonBind, d2.Reason)
assert.Equal(t, d1.Channel.ID, d2.Channel.ID)
}
func TestCoordinator_Guard_RebindsOnHealthDrop(t *testing.T) {
ctx := context.Background()
state := newFakeState()
sick := healthyChannel(1, TierPrimary, 10)
sick.SuccEwma = 0.5 // healthFactor 0.25 < keepHealthMin 0.5
co, _ := newTestCoordinator(state, sick, healthyChannel(2, TierPrimary, 10))
s := co.Route(ctx, testProfile())
state.bindings[s.SessionKey().Key] = 1 // 预置绑定到病渠道
d := s.Next(ctx)
require.NotNil(t, d)
assert.NotEqual(t, ReasonBind, d.Reason, "被绑渠道健康跌破守卫下限必须重跑 HRW")
assert.Equal(t, 1, state.setBinds, "重绑写入新绑定")
}
func TestCoordinator_Guard_RebindsOnSaturatedChannel(t *testing.T) {
ctx := context.Background()
state := newFakeState()
full := healthyChannel(1, TierPrimary, 10)
full.SoftLimit = 10
full.Inflight = 10 // 原始余量 0 < keepHeadroomMin 0.1
co, _ := newTestCoordinator(state, full, healthyChannel(2, TierPrimary, 10))
s := co.Route(ctx, testProfile())
state.bindings[s.SessionKey().Key] = 1
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID, "饱和渠道权重为 0,必然重绑到渠道 2")
assert.NotEqual(t, ReasonBind, d.Reason)
}
// TestCoordinator_Guard_GLMDefectRegression 溢出到 secondary 建立的绑定必须保持稳定:
// 守卫判据与 tier/cost 解耦,不得因 tierFactor 低而每请求重绑(规避 keepThreshold 缺陷)。
func TestCoordinator_Guard_GLMDefectRegression(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierSecondary, 10), // tierFactor 0.15,健康与余量均充足
)
s := co.Route(ctx, testProfile())
state.bindings[s.SessionKey().Key] = 2 // 溢出期建立的 secondary 绑定
for range 20 {
s = co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID)
assert.Equal(t, ReasonBind, d.Reason, "secondary 绑定在健康充足时必须稳定命中")
}
assert.Zero(t, state.setBinds, "不得发生任何重绑")
}
func TestCoordinator_502InPlaceRetryUntilBudgetExhausted(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierPrimary, 10),
)
s := co.Route(ctx, testProfile())
d1 := s.Next(ctx)
require.NotNil(t, d1)
first := d1.Channel.ID
// 第一次 502 → 原地退避重试,Next 返回同渠道同 Key
dec, backoff := s.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionInPlaceRetry, dec)
assert.Equal(t, 100*time.Millisecond, backoff)
d2 := s.Next(ctx)
require.NotNil(t, d2)
assert.Equal(t, first, d2.Channel.ID)
// 第二次 502 → 仍原地,退避翻倍
dec, backoff = s.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionInPlaceRetry, dec)
assert.Equal(t, 200*time.Millisecond, backoff)
s.Next(ctx)
// 第三次 502 → 原地预算耗尽,failover 换渠道;绑定不删除
dec, _ = s.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionFailover, dec)
d3 := s.Next(ctx)
require.NotNil(t, d3)
assert.NotEqual(t, first, d3.Channel.ID)
assert.Equal(t, 1, state.released[first], "failover 释放原渠道租约")
assert.Contains(t, state.bindings, s.SessionKey().Key, "失败不删除绑定")
// 三次失败均计入健康上报
assert.Len(t, state.outcomes, 3)
for _, o := range state.outcomes {
assert.False(t, o.Success)
assert.Equal(t, ErrClassTransient, o.Class)
}
}
func TestCoordinator_401CredentialRotationPath(t *testing.T) {
ctx := context.Background()
state := newFakeState()
ch1 := healthyChannel(1, TierPrimary, 100) // 高权重确保先选中
ch1.KeyIDs = []int64{11, 12}
ch2 := healthyChannel(2, TierPrimary, 0.001)
ch2.KeyIDs = []int64{21}
co, _ := newTestCoordinator(state, ch1, ch2)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
require.Equal(t, int64(1), d.Channel.ID)
assert.Equal(t, int64(11), d.KeyID, "按序取第一个 active Key")
// 第一次 401 → 冷却 Key 11,轮换到 Key 12
dec, _ := s.Report(ctx, 401, nil, DeliveryResponseReceived, 30, 0)
assert.Equal(t, DecisionRotateCredential, dec)
assert.True(t, state.cooled[11], "失效 Key 必须冷却")
assert.Empty(t, state.outcomes, "凭证错误不计渠道健康")
d = s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(1), d.Channel.ID, "轮换保持同渠道")
assert.Equal(t, int64(12), d.KeyID)
assert.Equal(t, ReasonCredRotate, d.Reason)
// 第二次 401 → 轮换预算耗尽,failover 到渠道 2
dec, _ = s.Report(ctx, 401, nil, DeliveryResponseReceived, 30, 0)
assert.Equal(t, DecisionFailover, dec)
assert.True(t, state.cooled[12])
d = s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID)
assert.Equal(t, int64(21), d.KeyID)
}
func TestCoordinator_SingleKeyChannel401FailsOverDirectly(t *testing.T) {
ctx := context.Background()
state := newFakeState()
ch1 := healthyChannel(1, TierPrimary, 100)
ch1.KeyIDs = []int64{11}
co, _ := newTestCoordinator(state, ch1, healthyChannel(2, TierPrimary, 0.001))
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.Equal(t, int64(1), d.Channel.ID)
dec, _ := s.Report(ctx, 401, nil, DeliveryResponseReceived, 30, 0)
assert.Equal(t, DecisionFailover, dec, "唯一 Key 冷却后无可轮换的 Key,凭证错误直接 failover")
assert.True(t, state.cooled[11])
}
func TestCoordinator_SkipsChannelWithAllKeysCooled(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.cooled[11] = true
ch1 := healthyChannel(1, TierPrimary, 100)
ch1.KeyIDs = []int64{11}
ch2 := healthyChannel(2, TierPrimary, 0.001)
co, _ := newTestCoordinator(state, ch1, ch2)
s := co.Route(ctx, testProfile())
state.bindings[s.SessionKey().Key] = 1 // 即使绑定指向它
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID, "全 Key 冷却的渠道本请求不可用")
assert.Equal(t, 1, d.Excluded.Request)
}
// TestCoordinator_NoChannelDiagIncludesCooledKeyIDs 凭证冷却只存在于 Redis 且只靠 TTL 自愈,
// 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID,
// 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。
func TestCoordinator_NoChannelDiagIncludesCooledKeyIDs(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.cooled[11] = true
state.cooled[21] = true
ch1 := healthyChannel(1, TierPrimary, 100)
ch1.KeyIDs = []int64{11}
ch2 := healthyChannel(2, TierPrimary, 50)
ch2.KeyIDs = []int64{21}
co, _ := newTestCoordinator(state, ch1, ch2)
s := co.Route(ctx, testProfile())
require.Nil(t, s.Next(ctx), "两个渠道的 Key 全部冷却 → 无可用渠道")
diag := s.NoChannelDiagnosis()
assert.Equal(t, 2, diag.CredUnavailable)
assert.Equal(t, []int64{11, 21}, diag.CredCooledKeys, "去重升序")
assert.Contains(t, diag.Summary(), "凭证全部冷却×2(key=11,21)")
}
func TestCoordinator_LeaseDenialDoesNotConsumeBudget(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.leaseDeny[1] = true
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 100),
healthyChannel(2, TierPrimary, 0.001),
)
s := co.Route(ctx, testProfile())
state.bindings[s.SessionKey().Key] = 1
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID)
assert.Equal(t, 1, d.Excluded.Lease)
assert.Zero(t, s.attempt.FailoverUsed, "租约失败不扣 failover 预算")
}
func TestCoordinator_BreakerExclusionAndProbeAdmission(t *testing.T) {
ctx := context.Background()
open := healthyChannel(1, TierPrimary, 100)
open.Breaker = BreakerOpen
half := healthyChannel(2, TierPrimary, 100)
half.Breaker = BreakerHalfOpen
normal := healthyChannel(3, TierPrimary, 0.001)
// 探测令牌被拒:HALF_OPEN 渠道也不可用,落到普通渠道
state := newFakeState()
co, _ := newTestCoordinator(state, open, half, normal)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(3), d.Channel.ID)
assert.Equal(t, 2, d.Excluded.Breaker, "OPEN 排除 + 探测令牌拒绝")
// 探测令牌放行:HALF_OPEN 渠道作为探测请求
state2 := newFakeState()
state2.probeGrant[probeTokenKey{2, ""}] = true
co2, _ := newTestCoordinator(state2, open, half, normal)
s2 := co2.Route(ctx, testProfile())
d2 := s2.Next(ctx)
require.NotNil(t, d2)
assert.Equal(t, int64(2), d2.Channel.ID)
assert.Equal(t, ReasonProbe, d2.Reason)
}
func TestCoordinator_ModelBreakerExclusion(t *testing.T) {
ctx := context.Background()
state := newFakeState()
ch1 := healthyChannel(1, TierPrimary, 100)
ch1.ModelBreaker = BreakerOpen // 渠道级正常,仅该模型熔断
co, _ := newTestCoordinator(state, ch1, healthyChannel(2, TierPrimary, 0.001))
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID)
}
// TestCoordinator_ModelHalfOpenProbe 模型级熔断冷却期满(HALF_OPEN)走模型级探测令牌:
// 渠道级熔断 CLOSED 时不再被渠道级令牌误拒(修复模型级熔断无法靠流量恢复的死锁)。
func TestCoordinator_ModelHalfOpenProbe(t *testing.T) {
ctx := context.Background()
modelHalf := healthyChannel(1, TierPrimary, 100)
modelHalf.ModelBreaker = BreakerHalfOpen // 仅模型级半开,渠道级 CLOSED
normal := healthyChannel(2, TierPrimary, 0.001)
// 模型级令牌放行 → 作为探测请求选中
state := newFakeState()
state.probeGrant[probeTokenKey{1, "gpt-4o"}] = true
co, _ := newTestCoordinator(state, modelHalf, normal)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(1), d.Channel.ID)
assert.Equal(t, ReasonProbe, d.Reason)
// 模型级令牌被拒 → 排除落到普通渠道(每探测窗口只放行一个)
state2 := newFakeState()
co2, _ := newTestCoordinator(state2, modelHalf, normal)
s2 := co2.Route(ctx, testProfile())
d2 := s2.Next(ctx)
require.NotNil(t, d2)
assert.Equal(t, int64(2), d2.Channel.ID)
assert.Equal(t, 1, d2.Excluded.Breaker, "模型级探测拒绝计入 Breaker 排除")
}
// TestCoordinator_DualLevelHalfOpenCombination 渠道级与模型级同时 HALF_OPEN:两枚令牌都取,
// 任一被拒即排除;模型级令牌先取(被拒不消耗渠道级令牌)。
func TestCoordinator_DualLevelHalfOpenCombination(t *testing.T) {
ctx := context.Background()
both := healthyChannel(1, TierPrimary, 100)
both.Breaker = BreakerHalfOpen
both.ModelBreaker = BreakerHalfOpen
normal := healthyChannel(2, TierPrimary, 0.001)
// 模型级放行 + 渠道级拒绝 → 排除
state := newFakeState()
state.probeGrant[probeTokenKey{1, "gpt-4o"}] = true
co, _ := newTestCoordinator(state, both, normal)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID, "渠道级令牌被拒时整体排除")
// 双放行 → 探测请求
state2 := newFakeState()
state2.probeGrant[probeTokenKey{1, "gpt-4o"}] = true
state2.probeGrant[probeTokenKey{1, ""}] = true
co2, _ := newTestCoordinator(state2, both, normal)
s2 := co2.Route(ctx, testProfile())
d2 := s2.Next(ctx)
require.NotNil(t, d2)
assert.Equal(t, int64(1), d2.Channel.ID)
assert.Equal(t, ReasonProbe, d2.Reason)
}
func TestCoordinator_TierExpansionFallback(t *testing.T) {
ctx := context.Background()
state := newFakeState()
// 运营把 secondary 配成纯冷备(tierFactor=0)
pol := DefaultRoutingPolicy()
pol.TierFactors[TierSecondary] = 0
open := healthyChannel(1, TierPrimary, 10)
open.Breaker = BreakerOpen
cold := healthyChannel(2, TierSecondary, 10)
clock := &fakeClock{t: time.UnixMilli(1_700_000_000_000)}
co := NewCoordinator(&fakeCatalog{channels: []Channel{open, cold}}, state, pol, clock, zeroEntropy)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d, "primary 全灭时纯冷备必须被扩组启用")
assert.Equal(t, int64(2), d.Channel.ID)
}
func TestCoordinator_OverflowReason(t *testing.T) {
ctx := context.Background()
state := newFakeState()
full := healthyChannel(1, TierPrimary, 10)
full.SoftLimit = 10
full.Inflight = 10 // 饱和 → 权重坍缩为 0,但仍在候选集
co, _ := newTestCoordinator(state, full, healthyChannel(2, TierSecondary, 10))
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, int64(2), d.Channel.ID)
assert.Equal(t, ReasonOverflow, d.Reason, "primary 在场但饱和 → 溢出")
}
func TestCoordinator_ReplayUnsafe_MaybeSentMustAbort(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
p := testProfile()
p.Replay = ReplayUnsafe
s := co.Route(ctx, p)
require.NotNil(t, s.Next(ctx))
dec, _ := s.Report(ctx, 0, errors.New("unexpected EOF"), DeliveryMaybeSent, 100, 0)
assert.Equal(t, DecisionAbort, dec, "图片/视频/任务提交在可能已送达时禁止重放")
assert.Nil(t, s.Next(ctx))
}
func TestCoordinator_504NoInPlaceRetry(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 100),
healthyChannel(2, TierPrimary, 0.001),
)
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
first := d.Channel.ID
dec, _ := s.Report(ctx, 504, nil, DeliveryMaybeSent, 30_000, 0)
assert.Equal(t, DecisionFailover, dec, "504 默认不原地重试")
d = s.Next(ctx)
require.NotNil(t, d)
assert.NotEqual(t, first, d.Channel.ID)
}
func TestCoordinator_429RetryAfterWaitsInPlace(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
s := co.Route(ctx, testProfile())
require.NotNil(t, s.Next(ctx))
dec, backoff := s.Report(ctx, 429, nil, DeliveryResponseReceived, 20, 1500*time.Millisecond)
assert.Equal(t, DecisionInPlaceRetry, dec)
assert.Equal(t, 1500*time.Millisecond, backoff, "遵从 Retry-After(entropy=0 无 jitter)")
}
func TestCoordinator_ClientErrorAbortsWithoutReporting(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
s := co.Route(ctx, testProfile())
require.NotNil(t, s.Next(ctx))
dec, _ := s.Report(ctx, 400, nil, DeliveryResponseReceived, 10, 0)
assert.Equal(t, DecisionAbort, dec)
assert.Empty(t, state.outcomes, "客户端错误不计渠道健康")
}
func TestCoordinator_TotalDeadlineExhaustedAborts(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, clock := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierPrimary, 10),
)
s := co.Route(ctx, testProfile())
require.NotNil(t, s.Next(ctx))
clock.advance(31 * time.Second) // 超过 totalDeadline 30s
dec, _ := s.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionAbort, dec)
}
func TestCoordinator_ReturnsNilWhenAllChannelsExhausted(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierPrimary, 10),
healthyChannel(3, TierPrimary, 10),
)
s := co.Route(ctx, testProfile())
seen := map[int64]bool{}
for {
d := s.Next(ctx)
if d == nil {
break
}
assert.False(t, seen[d.Channel.ID], "排除后的渠道不得重复返回")
seen[d.Channel.ID] = true
// 渠道致命错误 → 持续 failover
s.Report(ctx, 402, nil, DeliveryResponseReceived, 10, 0)
}
assert.LessOrEqual(t, len(seen), 3, "failover 预算 2 → 最多尝试 3 个渠道")
s.Finish(ctx, false, 0)
}
func TestCoordinator_JitterApplied(t *testing.T) {
ctx := context.Background()
state := newFakeState()
clock := &fakeClock{t: time.UnixMilli(0)}
co := NewCoordinator(
&fakeCatalog{channels: []Channel{healthyChannel(1, TierPrimary, 10)}},
state, nil, clock,
func() float64 { return 1.0 }, // 最大熵 → jitter = 50%
)
s := co.Route(ctx, testProfile())
require.NotNil(t, s.Next(ctx))
_, backoff := s.Report(ctx, 502, nil, DeliveryResponseReceived, 10, 0)
assert.Equal(t, 150*time.Millisecond, backoff, "100ms + 50% jitter")
}
func TestCoordinator_RefreshLeasePassThrough(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
s.RefreshLease(ctx)
assert.Equal(t, 1, state.refreshed[d.Channel.ID])
}
func TestCoordinator_FinishIdempotentAndNoTouchOnFailure(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
s.Finish(ctx, false, 0)
s.Finish(ctx, false, 0) // 重复调用无副作用
assert.Equal(t, 1, state.released[d.Channel.ID])
assert.Zero(t, state.touchBinds, "失败不续期绑定")
assert.Empty(t, state.outcomes, "失败结果由 Report 上报,Finish 不重复上报")
assert.Nil(t, s.Next(ctx), "Finish 后不再返回决策")
}
func TestCoordinator_PolicyHotReload(t *testing.T) {
co, _ := newTestCoordinator(newFakeState(), healthyChannel(1, TierPrimary, 10))
p2 := DefaultRoutingPolicy()
p2.Version = 2
co.UpdatePolicy(p2)
assert.Equal(t, 2, co.Policy().Version)
co.UpdatePolicy(nil)
assert.Equal(t, 2, co.Policy().Version, "nil 策略不生效")
}
// TestCoordinator_Debounce_HeadroomJitter 绑定守卫防抖:被绑渠道余量在守卫线上方抖动时,
// 绑定必须保持稳定(重绑次数为 0)——纯 HRW 会横跳,守卫是防抖层。
func TestCoordinator_Debounce_HeadroomJitter(t *testing.T) {
ctx := context.Background()
state := newFakeState()
ch1 := healthyChannel(1, TierPrimary, 10)
ch1.SoftLimit = 100
ch2 := healthyChannel(2, TierPrimary, 10)
catalog := &fakeCatalog{channels: []Channel{ch1, ch2}}
clock := &fakeClock{t: time.UnixMilli(0)}
co := NewCoordinator(catalog, state, nil, clock, zeroEntropy)
// 建立绑定
s := co.Route(ctx, testProfile())
d := s.Next(ctx)
require.NotNil(t, d)
bound := d.Channel.ID
rebinds := state.setBinds
// 被绑渠道负载在 20%~85% 间抖动(原始余量始终 ≥ keepHeadroomMin 0.1)
loads := []int{20, 60, 85, 40, 80, 25, 70}
for _, inflight := range loads {
if bound == 1 {
catalog.channels[0].Inflight = inflight
} else {
catalog.channels[1].Inflight = inflight
catalog.channels[1].SoftLimit = 100
}
s = co.Route(ctx, testProfile())
d = s.Next(ctx)
require.NotNil(t, d)
assert.Equal(t, bound, d.Channel.ID, "inflight=%d 时绑定必须保持", inflight)
assert.Equal(t, ReasonBind, d.Reason)
}
assert.Equal(t, rebinds, state.setBinds, "抖动期间重绑次数必须为 0")
}
// TestCoordinator_TenantPolicyOverride profile.Policy 非空时本会话使用租户策略而非全局策略。
func TestCoordinator_TenantPolicyOverride(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
healthyChannel(1, TierPrimary, 10),
healthyChannel(2, TierPrimary, 10),
)
// 租户覆盖:原地重试预算 0 → 瞬时错误直接 failover(全局默认为 2 → 原地重试)
tenantPol := DefaultRoutingPolicy()
tenantPol.Retry.InPlaceBudget = 0
p := testProfile()
p.Policy = tenantPol
s := co.Route(ctx, p)
d := s.Next(ctx)
require.NotNil(t, d)
dec, _ := s.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionFailover, dec, "租户策略原地预算 0 应直接 failover")
// 对照组:无覆盖走全局策略 → 原地重试
s2 := co.Route(ctx, testProfile())
require.NotNil(t, s2.Next(ctx))
dec2, _ := s2.Report(ctx, 502, nil, DeliveryResponseReceived, 50, 0)
assert.Equal(t, DecisionInPlaceRetry, dec2, "无覆盖应使用全局策略原地重试")
}