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'")