mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(quota): hostOf 用了 gmatch 而不是 match —— memo 表从未命中过任何东西
## 这是退避失效的真正原因,而且比 TTL 严重
查生产 state 时发现的:19 条 memo,`hostOf(key)` 命中 **0** 条。
hostOf = function(u)
return (string.gmatch(u, "^https?://([^/]+)")()) or u
end
`string.gmatch` 返回**整个匹配**,不是捕获组;而带 `^` 锚定时迭代器不会前进,
所以第一次 `()` 调用返回 **nil**,函数走 `or u` 分支返回完整 URL。
后果是彻底的、而且完全静默:
- 每次查表都是 HOST_MEMO[nil],每次写入也是 HOST_MEMO[nil]
- memo 表**对任何 host 从未命中过**
- 负 memo、PROBE_MEMO_TTL、以及刚加的指数退避,**全是看起来活着的死代码**
- 生产那 19 条历史 memo(key 是完整 URL,来自 ccquota 时代)从来没有被读过
所以「两个死上游每 30 分钟重付一次探测」的真实原因不是 TTL 到期,
而是压根没命中过 memo。
## 为什么这么久没人发现
失败模式与「memo 里没有可记的东西」完全不可区分:页面照常显示,卡片照常
skipped,没有任何报错。唯一线索是 state 里那 19 条条目 —— 它们看起来像是
在正常工作留下的痕迹。
## 修法
string.match(u, "^https?://([^/]+)")
判据 `TestQuotaMemoKeyIsTheHost` 钉住它:读**实际发布的** quota.lua,检查
用 string.match,并在运行时验四个 URL 的 host 抽取,以及同一 host 的两个不同
路径必须产出同一个 key(这正是按 host 做 memo 的意义)。
变异把 match 改回 gmatch 立刻变红,并打印出实际返回的完整 URL。
This commit is contained in:
@ -63,8 +63,23 @@ end
|
||||
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
|
||||
-- about. Two sources on the same vendor (two Command Code keys) are
|
||||
-- indistinguishable without it.
|
||||
-- hostOf returns the host of a URL — the part memoisation is keyed on, so two
|
||||
-- sources pointing at the same host with different paths share one answer.
|
||||
--
|
||||
-- It used to be written with string.gmatch and a capture group. gmatch yields
|
||||
-- WHOLE matches, not captures, and with `^` it never advances, so the iterator
|
||||
-- returned nil on the first step and hostOf returned its `or u` fallback —
|
||||
-- the FULL URL.
|
||||
--
|
||||
-- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and
|
||||
-- every write was HOST_MEMO[nil], so:
|
||||
-- - the memo table never returned anything, for any host, ever
|
||||
-- - the negative memo, the TTL and (later) the backoff were all dead code
|
||||
-- - the 19 entries in this gateway's production state are unreachable: their
|
||||
-- keys are full URLs, while a lookup asks for nil
|
||||
-- Measured before the fix: hostOf(key) matched 0 of 19 production entries.
|
||||
local function hostOf(u)
|
||||
return (string.gmatch(u, "^https?://([^/]+)")()) or u
|
||||
return (string.match(u, "^https?://([^/]+)")) or u
|
||||
end
|
||||
|
||||
-- get walks a dotted path through nested tables, returning nil if any hop is
|
||||
|
||||
@ -210,6 +210,69 @@ func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under.
|
||||
//
|
||||
// This is the foundation the whole memoisation rests on, and it was broken in the
|
||||
// most total way possible: hostOf was written with string.gmatch and a capture
|
||||
// group, gmatch yields whole matches rather than captures, and with `^` the
|
||||
// iterator never advanced — so it returned nil and every lookup and every write
|
||||
// went to HOST_MEMO[nil]. The memo therefore never returned anything for any
|
||||
// host, and the negative memo, the TTL and the backoff on top of them were all
|
||||
// dead code that looked alive.
|
||||
//
|
||||
// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19
|
||||
// entries in production state.
|
||||
//
|
||||
// The check is on the shipped source plus its runtime behaviour, because the
|
||||
// failure mode is invisible from outside — a memo that never matches anything
|
||||
// behaves exactly like a memo that has nothing to remember.
|
||||
func TestQuotaMemoKeyIsTheHost(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "string.match(u,") {
|
||||
t.Error("hostOf does not use string.match — string.gmatch yields whole " +
|
||||
"matches, so the capture group is ignored and the function returns nil")
|
||||
}
|
||||
|
||||
ps := quotaWithDecision(t)
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"https://qijiar.top/v1", "qijiar.top"},
|
||||
{"https://api.deepseek.com", "api.deepseek.com"},
|
||||
{"http://127.0.0.1:19900", "127.0.0.1:19900"},
|
||||
{"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"},
|
||||
} {
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in})
|
||||
if err != nil {
|
||||
t.Fatalf("hostOf(%s): %v", c.in, err)
|
||||
}
|
||||
mm, _ := out.(map[string]interface{})
|
||||
got, _ := mm["h"].(string)
|
||||
if got != c.want {
|
||||
t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("hostOf(%q) returned nothing — every lookup would be "+
|
||||
"HOST_MEMO[nil]", c.in)
|
||||
}
|
||||
}
|
||||
|
||||
// The same host under two paths must produce the SAME key, which is the whole
|
||||
// reason the memo is keyed this way.
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m1, _ := out.(map[string]interface{})
|
||||
m2, _ := out2.(map[string]interface{})
|
||||
if m1["h"] != m2["h"] {
|
||||
t.Errorf("two paths on one host produced different keys: %v and %v — "+
|
||||
"they would never share a memo", m1["h"], m2["h"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// readShippedPlugin returns the bundled source, so the tests exercise the file
|
||||
@ -238,6 +301,8 @@ func quotaWithDecision(t *testing.T) *Plugins {
|
||||
// function took its "no streak" path and returned false for every input
|
||||
// — which looked exactly like a backoff that never fires.
|
||||
"PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+
|
||||
"PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+
|
||||
"PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+
|
||||
"return PLUGIN", 1)
|
||||
if shim == src {
|
||||
t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'")
|
||||
|
||||
Reference in New Issue
Block a user