From 4a0a1f96e46f2a8c6c3e8de8115b500900ec2cbd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 07:27:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(quota):=20hostOf=20=E7=94=A8=E4=BA=86=20gma?= =?UTF-8?q?tch=20=E8=80=8C=E4=B8=8D=E6=98=AF=20match=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20memo=20=E8=A1=A8=E4=BB=8E=E6=9C=AA=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E8=BF=87=E4=BB=BB=E4=BD=95=E4=B8=9C=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 这是退避失效的真正原因,而且比 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。 --- internal/lua/plugins/quota.lua | 17 +++++++- internal/lua/quota_backoff_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 0b5ca6d..3a22d09 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -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 diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go index 447f0fe..3a95ab6 100644 --- a/internal/lua/quota_backoff_test.go +++ b/internal/lua/quota_backoff_test.go @@ -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'")