Files
team-api/internal/dispatchadapter/catalog_test.go
T

296 lines
11 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 dispatchadapter
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qianfree/team-api/relaykit/dispatch"
)
func testCatalog(rows []catalogRow, keys map[int64][]int64, runtime runtimeReader) *Catalog {
pol := dispatch.DefaultRoutingPolicy()
c := NewCatalog(
func() *dispatch.RoutingPolicy { return pol },
func(_ context.Context) (*catalogData, error) {
return &catalogData{rows: rows, keysByChannel: keys}, nil
},
runtime,
)
c.Rebuild(context.Background())
return c
}
func TestCatalog_SnapshotBuildAndScopeFilter(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 0.8},
{ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary", CostRatio: 1.0},
{ChannelID: 2, ChannelName: "B", ModelName: "claude-sonnet", UpstreamModel: "claude-sonnet-4", Weight: 5, Tier: "secondary"},
}
keys := map[int64][]int64{1: {11, 12}, 2: {21}}
c := testCatalog(rows, keys, nil)
// 按模型取候选
got := c.Snapshot(context.Background(), 0, "gpt-4o", nil)
require.Len(t, got, 2)
assert.Equal(t, dispatch.TierPrimary, got[0].Tier)
assert.Equal(t, []int64{11, 12}, got[0].KeyIDs)
assert.Equal(t, 0.8, got[0].CostRatio)
assert.Equal(t, float64(10), got[0].BaseWeight)
// 租户渠道范围过滤
got = c.Snapshot(context.Background(), 0, "gpt-4o", []int64{2})
require.Len(t, got, 1)
assert.Equal(t, int64(2), got[0].ID)
// 未知模型返回空
assert.Empty(t, c.Snapshot(context.Background(), 0, "unknown", nil))
}
func TestCatalog_ForwardMeta(t *testing.T) {
rows := []catalogRow{
{ChannelID: 2, ChannelName: "B", ChannelType: 3, BaseURL: "https://up.example.com",
ModelName: "claude-sonnet", UpstreamModel: "claude-sonnet-4", Weight: 5, Tier: "secondary",
MaxConcurrency: 20, Settings: `{"x":1}`},
}
c := testCatalog(rows, nil, nil)
m, ok := c.ForwardMeta(2, "claude-sonnet")
require.True(t, ok)
assert.Equal(t, "claude-sonnet-4", m.UpstreamModel)
assert.True(t, m.IsModelMapped)
assert.Equal(t, "https://up.example.com", m.BaseURL)
assert.Equal(t, 20, m.MaxConcurrency)
// 未映射模型:上游模型名回退为原名
rows[0].UpstreamModel = ""
c = testCatalog(rows, nil, nil)
m, _ = c.ForwardMeta(2, "claude-sonnet")
assert.Equal(t, "claude-sonnet", m.UpstreamModel)
assert.False(t, m.IsModelMapped)
_, ok = c.ForwardMeta(99, "claude-sonnet")
assert.False(t, ok)
}
func TestCatalog_RuntimeReadoutMerge(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", MaxConcurrency: 50},
}
c := testCatalog(rows, nil, func(_ context.Context, channelID int64, model string) RuntimeReadout {
return RuntimeReadout{
SuccEwma: 0.85, LatEwmaMs: 1200, Inflight: 7,
Breaker: dispatch.BreakerHalfOpen, ModelBreaker: dispatch.BreakerClosed,
}
})
got := c.Snapshot(context.Background(), 0, "m", nil)
require.Len(t, got, 1)
assert.Equal(t, 0.85, got[0].SuccEwma)
assert.Equal(t, float64(1200), got[0].LatEwmaMs)
assert.Equal(t, 7, got[0].Inflight)
assert.Equal(t, 50, got[0].SoftLimit)
assert.Equal(t, dispatch.BreakerHalfOpen, got[0].Breaker)
}
func TestCatalog_StrictCapacityLookup(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", StrictCapacity: true, MaxConcurrency: 30},
{ChannelID: 2, ModelName: "m", Weight: 10, Tier: "primary"},
}
c := testCatalog(rows, nil, nil)
strict, maxConc := c.StrictLookup(1)
assert.True(t, strict)
assert.Equal(t, 30, maxConc)
strict, _ = c.StrictLookup(2)
assert.False(t, strict)
}
func TestCatalog_LoadFailureKeepsPreviousSnapshot(t *testing.T) {
pol := dispatch.DefaultRoutingPolicy()
calls := 0
c := NewCatalog(
func() *dispatch.RoutingPolicy { return pol },
func(_ context.Context) (*catalogData, error) {
calls++
if calls == 1 {
return &catalogData{rows: []catalogRow{{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary"}}}, nil
}
return nil, assert.AnError
},
nil,
)
ctx := context.Background()
c.Rebuild(ctx)
require.Len(t, c.Snapshot(ctx, 0, "m", nil), 1)
c.Rebuild(ctx) // 加载失败
assert.Len(t, c.Snapshot(ctx, 0, "m", nil), 1, "last-known 快照必须保留")
}
// TestCatalog_RedisDegradedCarriesForwardHealth Redis 读失败时若让乐观默认值 succ=1 落进
// 新快照,一次 Redis 抖动就会把全渠道的健康记忆清零、healthFactor 集体回满分,
// 与 Rebuild 声称的 last-known 语义相悖。
func TestCatalog_RedisDegradedCarriesForwardHealth(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
}
degraded := false
c := testCatalog(rows, nil, func(_ context.Context, _ int64, _ string) RuntimeReadout {
if degraded {
return RuntimeReadout{SuccEwma: 1, Degraded: true}
}
return RuntimeReadout{SuccEwma: 0.3, LatEwmaMs: 4000, HasHealth: true}
})
got := c.Snapshot(context.Background(), 0, "gpt-4o", nil)
require.Len(t, got, 1)
assert.Equal(t, 0.3, got[0].SuccEwma)
degraded = true
c.Rebuild(context.Background())
got = c.Snapshot(context.Background(), 0, "gpt-4o", nil)
require.Len(t, got, 1)
assert.Equal(t, 0.3, got[0].SuccEwma, "Redis 降级必须沿用 last-known,不得回落满分")
assert.Equal(t, 4000.0, got[0].LatEwmaMs, "延迟读值同样沿用")
}
// TestCatalog_DegradedOnFirstRefreshHasNoHistory 新实例启动时 Redis 就不可用:没有 last-known
// 可沿用,保持乐观默认满分(不能让新实例启动即把全渠道判死)。
func TestCatalog_DegradedOnFirstRefreshHasNoHistory(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
}
c := testCatalog(rows, nil, func(_ context.Context, _ int64, _ string) RuntimeReadout {
return RuntimeReadout{SuccEwma: 1, Degraded: true}
})
got := c.Snapshot(context.Background(), 0, "gpt-4o", nil)
require.Len(t, got, 1)
assert.Equal(t, 1.0, got[0].SuccEwma, "无历史时保持乐观默认")
}
func TestRampElapsed(t *testing.T) {
now := time.Now().UnixMilli()
window := int64(120_000)
// 熔断刚恢复 60s:爬坡中
assert.Equal(t, int64(60_000), rampElapsed(now, now-60_000, 0, window))
// 恢复已超窗口:不爬坡
assert.Equal(t, int64(-1), rampElapsed(now, now-130_000, 0, window))
// 新建渠道 30s:爬坡中
assert.Equal(t, int64(30_000), rampElapsed(now, 0, now-30_000, window))
// 老渠道:不爬坡
assert.Equal(t, int64(-1), rampElapsed(now, 0, now-time.Hour.Milliseconds(), window))
// 窗口未配置:不爬坡
assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0))
}
// TestCatalog_ChannelRuntimeStateAggregation 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
// 模型级熔断按模型计数。
func TestCatalog_ChannelRuntimeStateAggregation(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"},
{ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"},
{ChannelID: 3, ChannelName: "C", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"},
}
c := testCatalog(rows, nil, func(_ context.Context, channelID int64, _ string) RuntimeReadout {
switch channelID {
case 1:
return RuntimeReadout{Breaker: dispatch.BreakerOpen, ModelBreaker: dispatch.BreakerOpen}
case 2:
return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerHalfOpen}
default:
return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerClosed}
}
})
got := c.ChannelRuntimeStates()
require.Len(t, got, 3)
// 渠道 1:渠道级熔断 OPEN,两个模型均熔断
assert.Equal(t, dispatch.BreakerOpen, got[1].Breaker)
assert.Equal(t, 2, got[1].BreakerModels, "两个模型均熔断")
// 渠道 2:渠道级正常,仅 gpt-4o 模型半开
assert.Equal(t, dispatch.BreakerClosed, got[2].Breaker)
assert.Equal(t, 1, got[2].BreakerModels, "仅 gpt-4o 半开")
// 渠道 3:完全正常
assert.Equal(t, dispatch.BreakerClosed, got[3].Breaker)
assert.Equal(t, 0, got[3].BreakerModels)
}
// TestCatalog_RuntimeState_EmptyBeforeRebuild 验证目录未构建时返回空 map(而非 nil),
// 调用方遍历时安全。
func TestCatalog_RuntimeState_EmptyBeforeRebuild(t *testing.T) {
c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil)
assert.Empty(t, c.ChannelRuntimeStates())
}
// TestCatalog_WorstModel_SingleFailureNotDilutedByAverage 渠道健康分是均值,10 个健康模型 + 1 个
// 全挂仍能算出 90 分绿灯;最差项要如实报出那个挂掉的模型。
func TestCatalog_WorstModel_SingleFailureNotDilutedByAverage(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-a", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-b", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "broken", Weight: 10, Tier: "primary"},
}
c := testCatalog(rows, nil, func(_ context.Context, _ int64, model string) RuntimeReadout {
if model == "broken" {
return RuntimeReadout{SuccEwma: 0.1, HasHealth: true}
}
return RuntimeReadout{SuccEwma: 1.0, HasHealth: true}
})
got := c.ChannelRuntimeStates()
require.Len(t, got, 1)
assert.Equal(t, "broken", got[1].WorstModel)
// α 默认 2:0.1² × 100 = 1
assert.InDelta(t, 1.0, got[1].WorstModelScore, 1e-6)
}
// TestCatalog_WorstModel_ColdModelNotReported 无真实上报的模型读作满分 1.0,取最小值天然
// 不会把它选成最差项——这正是拖垮渠道均值的缺陷在 min 聚合下不存在的原因。
func TestCatalog_WorstModel_ColdModelNotReported(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "cold", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "warm", Weight: 10, Tier: "primary"},
}
c := testCatalog(rows, nil, func(_ context.Context, _ int64, model string) RuntimeReadout {
if model == "cold" {
return RuntimeReadout{SuccEwma: 1} // HasHealth=false,从未上报
}
return RuntimeReadout{SuccEwma: 0.8, HasHealth: true}
})
got := c.ChannelRuntimeStates()
assert.Equal(t, "warm", got[1].WorstModel, "冷模型读作满分,不应被选为最差")
assert.InDelta(t, 64.0, got[1].WorstModelScore, 1e-6) // 0.8² × 100
}
// TestCatalog_WorstModel_StableOnTiedScores byModel 是 map,遍历顺序随机;同分不做确定性
// 打破平局的话,全部模型满分时每次请求返回的模型名都会跳变。
func TestCatalog_WorstModel_StableOnTiedScores(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "zeta", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "alpha", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "mid", Weight: 10, Tier: "primary"},
}
c := testCatalog(rows, nil, func(_ context.Context, _ int64, _ string) RuntimeReadout {
return RuntimeReadout{SuccEwma: 1, HasHealth: true}
})
for range 20 {
got := c.ChannelRuntimeStates()
require.Equal(t, "alpha", got[1].WorstModel, "同分必须稳定取模型名最小者")
assert.InDelta(t, 100.0, got[1].WorstModelScore, 1e-6)
}
}