mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 账号顺序的确定性 + reorder 真正改内核源顺序
## ① 真实缺陷:同秒创建的账号,顺序**不确定**
`poolList` 的排序键是 `priority` 降序 + `createdAt` **升序**,而 `createdAt` 是
**秒级**(`os.time()`)⇒ 同一秒建的两个账号比较**相等** ⇒ 相等时 `table.sort`
的结果取决于 `pairs()` 遍历顺序 —— **不确定**。
实测:按 a,b,c 顺序建三个账号,`poolList` 回的是 `[a,c,b]`。
后果不只是"顺序看着乱":**源注册顺序 = poolList 顺序 = 内核尝试顺序**
⇒ 同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下影响先用谁的积分),
且可能每次进程重启都变。修法:加 **id 作稳定兜底键**(唯一且不变)。
## ② reorder 只改字段、不影响路由
`account_reorder` 只设 `acct.priority`,**不重注册源**。而一个源一旦注册,
顺序就固定了 ⇒ 重排对实际路由**毫无影响**("优先账号"并不会先被试)。
修法:重排后按新顺序**整体重放一遍** `syncAccountSource`
(remove+add 会把源移到最后,故必须整体重放,只补被移动的那几个相对顺序仍不对)。
## 判据(2 条 + 变异 2/2)
internal/core/channels_reorder_effect_test.go
- reorder 必须真的改变**内核 list_sources 的顺序**(判据看内核里的真实顺序,
不是 pool 里的 priority 字段)
- 同优先级 + 同秒创建时,顺序必须由**稳定键**(id)决定(字典序)
★ 两个判据第一版**都不咬变异**,原因各异,值得记下:
1. reorder 判据:初始顺序恰好**已是**目标顺序 ⇒ 无法区分"重排生效"与"本来就对"。
改法:显式断言初始顺序、并让目标顺序与之**相反**。
2. 确定性判据:我直接断言 `account_add` 之后的顺序,而 `account_add` 是
**直接同步自己那一个账号**(源顺序 = add 顺序),**走不到排序逻辑** ⇒
测的其实是 add 顺序。另外**多轮 in-process 反复构造也测不出** ——
`pairs()` 顺序在同一次进程里是稳定的,只有跨进程/跨分配才变。
改法:走**批量重注册**路径(`__resync_sources_probe`)+ 显式把 priority 拉平
构造"排序键完全相同"的场景,再断言字典序。
## 顺带修的判据自身问题
- `sourceOrder` 原来顺带调了 `__sync_source_probe`(内部 remove+add)⇒
**"测之前先动了被测对象"**,顺序忽而两个忽而一个。改为**只读**。
- 探针取内核返回时按 `{sources:[...]}` 取,而 `actionListSources` 返回的是
**裸数组** ⇒ 恒得空列表。
- 夹具漏授 `add_source` 内核权限 ⇒ 源一个都不出现(内核会明确拒绝,只是我
没看返回值)。子测试才能拿到独立的 env 与目录(同函数内 `t.Setenv` 只生效一次)。
This commit is contained in:
251
internal/core/channels_reorder_effect_test.go
Normal file
251
internal/core/channels_reorder_effect_test.go
Normal file
@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ account_reorder 必须**真的改变内核里的源顺序**。
|
||||
//
|
||||
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
|
||||
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
|
||||
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
|
||||
// 顺序 remove+add 一遍。
|
||||
//
|
||||
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
||||
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
||||
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
||||
h := newReorderHarness(t)
|
||||
h.addAccount("acct-a")
|
||||
h.addAccount("acct-b")
|
||||
|
||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
||||
before := h.sourceOrder()
|
||||
if len(before) < 2 {
|
||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
||||
}
|
||||
if before[0] != "channels-buddy-acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
||||
}
|
||||
|
||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := h.sourceOrder()
|
||||
if len(after) < 2 {
|
||||
t.Fatalf("重排后源丢了:%v", after)
|
||||
}
|
||||
|
||||
idxOf := func(list []string, id string) int {
|
||||
for i, n := range list {
|
||||
if n == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
|
||||
if b < 0 || a < 0 {
|
||||
t.Fatalf("重排后找不到账号源:%v", after)
|
||||
}
|
||||
if b > a {
|
||||
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
|
||||
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
|
||||
"快到期积分仍不会优先消耗)", after)
|
||||
}
|
||||
t.Logf("✓ 源顺序已按重排生效: %v", after)
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type reorderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newReorderHarness(t *testing.T) *reorderHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
|
||||
// ("kernel action add_source is not granted"),源一个都不会出现 ——
|
||||
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
return &reorderHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *reorderHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
|
||||
//
|
||||
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
|
||||
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
|
||||
// (实测表现:顺序忽而两个忽而一个)。
|
||||
func (h *reorderHarness) sourceOrder() []string {
|
||||
h.t.Helper()
|
||||
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__list_sources_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, ls)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
|
||||
//
|
||||
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
|
||||
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
|
||||
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
|
||||
//
|
||||
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
||||
//
|
||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
||||
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
|
||||
h := newReorderHarness(t)
|
||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||
h.addAccount(id)
|
||||
}
|
||||
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
|
||||
order := h.sourceOrder()
|
||||
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
|
||||
}
|
||||
|
||||
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
|
||||
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
|
||||
h.clearPriorities()
|
||||
if !sameSecond(h.createdAts()) {
|
||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.clearPriorities()
|
||||
h.resync("buddy")
|
||||
order2 := h.sourceOrder()
|
||||
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
|
||||
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
|
||||
if got := joinStrings(order2, ","); got != want2 {
|
||||
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||
got, want2)
|
||||
}
|
||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
||||
}
|
||||
|
||||
func joinStrings(xs []string, sep string) string {
|
||||
out := ""
|
||||
for i, x := range xs {
|
||||
if i > 0 {
|
||||
out += sep
|
||||
}
|
||||
out += x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
|
||||
func (h *reorderHarness) createdAts() []int64 {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
var out []int64
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if v, ok := a["createdAt"].(float64); ok {
|
||||
out = append(out, int64(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
|
||||
func sameSecond(ts []int64) bool {
|
||||
if len(ts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range ts {
|
||||
if v != ts[0] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
|
||||
func (h *reorderHarness) clearPriorities() {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__clear_priorities_probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
|
||||
func (h *reorderHarness) resync(platform string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
|
||||
"platform": platform,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__resync_sources_probe: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user