mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
test(keys): per-key AUTO 链的并发与压力验证
AutoChainFor 在请求热路径上无锁读 keyAutoChains,而 SaveKeyAuto 与
rebuildRegistry 会整表替换它。顺序调用测不到任何交错,所以补并发判据。
- 8 读者 + 2 写者并发改链,断言读到的链必属于 {自身链, 全局链} 这个合法
集合,不断言固定值(写者本来就在合法地来回切换,钉死值会在正确代码上
失败)。
- 源增删期间读者保持可用链。churn 源用 "churn" 前缀,避免与链依赖的 s1
重名——删掉链引用的源会合法地清空该链,拿它当断言等于测虚构。
- 500 个 key 的查表不退化。
变异验证:把 rebuildKeyAutoChains 改成原地清空重填(破坏原子替换),
-race 报 DATA RACE,判据有效。
真实压力(20 key 网关 + 并发 AUTO + 后台持续改链):
- 40 并发 25s:9616 请求 / 382 QPS / 零 panic / 零 DATA RACE
- 出现 111 次 503,根因是 mock 上游被打爆后 "no free slot within 2s",
属预期降级而非缺陷:并发降到 6 时 1395/1395 全 200。
- 每次压测都断言"未改链的用户始终命中自己该走的模型",未改链的 19 个
key 全程零错分。
This commit is contained in:
189
internal/core/key_auto_concurrency_test.go
Normal file
189
internal/core/key_auto_concurrency_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
|
||||
// lock, while SaveKeyAuto / rebuildRegistry replace that map from the admin
|
||||
// path. A "call it twice" test never interleaves those, so it would pass even
|
||||
// if the map were mutated in place. These tests run both sides at once under
|
||||
// -race and assert what readers actually observe.
|
||||
//
|
||||
// The property: a reader sees a chain that is either the key's own or the
|
||||
// global one — never a torn or empty one. If rebuildKeyAutoChains built the
|
||||
// map in place instead of swapping a fresh pointer, readers would sometimes
|
||||
// see a partially-filled map and silently fall back to the global chain, i.e.
|
||||
// a user quietly losing their configured chain under load.
|
||||
|
||||
// concurrentChainConfig builds N keys, half with their own chain pointing at a
|
||||
// distinct model so a fallback to the global chain is distinguishable.
|
||||
func concurrentChainConfig(t *testing.T, n int) *config.Config {
|
||||
t.Helper()
|
||||
cfg := perKeyTestConfig(t)
|
||||
cfg.Keys = nil
|
||||
cfg.GatewayKeys = []string{"sk-gw-admin"}
|
||||
cfg.Auto = []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}}
|
||||
for i := 0; i < n; i++ {
|
||||
k := config.GWKey{
|
||||
Key: fmt.Sprintf("sk-gw-k%03d", i),
|
||||
Role: "user",
|
||||
Name: fmt.Sprintf("user%d", i),
|
||||
}
|
||||
if i%2 == 0 {
|
||||
k.Auto = []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}
|
||||
}
|
||||
cfg.Keys = append(cfg.Keys, k)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAutoChainForConcurrentWithSaveKeyAuto(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 40)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 40; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, ok := c.AutoChainFor(key)
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
// Legal values are this key's own chain model (either
|
||||
// side of the writer's flip) or the global chain's. The
|
||||
// assertion is about the legal set, not a fixed value:
|
||||
// the writers legitimately change the chain underneath.
|
||||
got := ch.Tiers[0].Slots[0].Model
|
||||
if got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for w := 0; w < 2; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
model := "gpt-4o-mini"
|
||||
for i := 0; i < 25 && !stop.Load(); i++ {
|
||||
for j := 0; j < 40; j += 2 {
|
||||
if err := c.SaveKeyAuto(fmt.Sprintf("sk-gw-k%03d", j), []config.ModelScope{
|
||||
{Model: model, Source: "s1", Tier: 1},
|
||||
}); err != nil {
|
||||
bad.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model == "gpt-4o-mini" {
|
||||
model = "gpt-4o"
|
||||
} else {
|
||||
model = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads saw a chain outside {own, global} — the cache is "+
|
||||
"being swapped in place instead of atomically", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The registry rebuild path (AddSource / RemoveSource) also replaces the
|
||||
// cache, so readers must keep a usable chain throughout.
|
||||
//
|
||||
// Churned sources use a "churn" prefix on purpose: deleting a source a chain
|
||||
// references legitimately empties that chain (BuildChain drops unresolvable
|
||||
// slots), and asserting otherwise would be testing a fiction rather than the
|
||||
// code.
|
||||
func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 20)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 6; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 20; i++ {
|
||||
ch, ok := c.AutoChainFor(fmt.Sprintf("sk-gw-k%03d", i))
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 30 && !stop.Load(); n++ {
|
||||
name := fmt.Sprintf("churn%d", n)
|
||||
_ = c.AddSource(config.Source{
|
||||
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
|
||||
)
|
||||
_ = c.RemoveSource(name)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads lost the chain while unrelated sources were churned", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Many distinct keys: the lookup must stay a map hit and must not degrade into
|
||||
// returning the global chain as the key count grows.
|
||||
func TestAutoChainForManyKeys(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 500)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
for i := 0; i < 500; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, _ := c.AutoChainFor(key)
|
||||
if ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("key %s lost its chain with 500 keys configured", key)
|
||||
}
|
||||
want := "gpt-4o"
|
||||
if i%2 == 0 {
|
||||
want = "gpt-4o-mini"
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != want {
|
||||
t.Fatalf("key %s got %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user