mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): 抽通用件 TTL 缓存 + 串行锁(照参考 ttl-cache / serial-queue)
理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑 (远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,边界条件容易漏 —— 本轮就漏了一个(见下)。集中一处才好审。 已把余额缓存改用 ttlCacheGet(消掉手写的 60s 逻辑)。 ★ 抽出来立刻验证出价值 —— 实测踩到一个边界: **清缓存时必须一并清掉"在飞标记"**。否则「清缓存 → 同一秒内重载 → 被去重逻辑 挡住 → 返回 nil」,调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的。 (改动窗口后立即查余额就得到 nil,就是这个。)失效的语义是"忘掉这次加载", 在飞标记也是这次加载的一部分。 另外两条语义也写进注释与判据: - **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),但记在飞标记 防止同一瞬间重复打。这与"失败进冷却"是两件事(冷却由调用方决定)。 - **墙钟回拨防护**:`cached_at` 在未来时视为已过期,而不是"还没到" —— 回拨一天会让缓存永远命中、上游改了也自愈不了。 Lua 无协程 ⇒ 做不了"共享同一个 Promise"也做不了"排队等待"。取舍: - 去重用**时间戳**(in_flight_at 短窗口)等价实现; - 串行锁只能做到**互斥 + 让路**(抢不到立刻返回,由调用方跳过本次), 且必须带 **TTL 自解**(持有者崩了不能永久卡死该 namespace)。 判据 `TestTtlCacheSemantics` 四组:命中缓存不重载、**失效后同一秒内能重载**、 ttl=0 每次重载、串行锁"抢到→抢不到→释放后能再抢"。 回归:9 个包全绿。
This commit is contained in:
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -2406,6 +2406,8 @@ local PERSIST_KEYS = {
|
||||
-- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了,
|
||||
-- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。
|
||||
"deadModels",
|
||||
-- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。
|
||||
"ttlCache",
|
||||
-- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效,
|
||||
-- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。
|
||||
"catalogGate",
|
||||
@ -5051,6 +5053,106 @@ plugin.schedule = {
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- 通用件:TTL 缓存 + 串行队列(照参考 ttl-cache.ts / serial-queue.ts)
|
||||
-- ============================================================================
|
||||
-- 为什么抽出来:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑
|
||||
-- (远端目录闸门、余额 60s 缓存、登录轮询……)。手写版本各写各的,
|
||||
-- 边界条件(墙钟回拨、在飞去重、失败不缓存)容易漏 —— 集中一处才好审。
|
||||
--
|
||||
-- ⚠️ Lua 无协程,做不了"共享同一个 Promise"。这里的"在飞去重"用**时间戳**
|
||||
-- 表达:记录 in_flight_at,短窗口内的重复请求直接跳过。等价效果,机制不同。
|
||||
-- 这是刻意的取舍:能在单 VM 单锁里工作,且不引入任何 async 语义。
|
||||
|
||||
-- ttlCacheGet(namespace, key, ttl, loader):
|
||||
-- * 命中且未过期 → 直接返回缓存值
|
||||
-- * 在飞(短窗口内有同 key 的加载)→ 返回 nil(调用方自行降级,不要重复打)
|
||||
-- * 否则调 loader(),把非 nil 结果缓存 ttl 秒
|
||||
--
|
||||
-- ⚠️ **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),
|
||||
-- 但会记 in_flight_at 防止**同一瞬间**的重复打。这与"失败进冷却"是两件事 ——
|
||||
-- 冷却由调用方决定(如目录闸门的 30s),本助手只负责 TTL 与去重。
|
||||
--
|
||||
-- ⚠️ 墙钟回拨防护(照参考):`now < cached_at` 时视为已过期,而不是
|
||||
-- "还没到"。回拨一天会让 `now - cached_at` 变成巨大负数 ⇒ 缓存永远命中 ⇒
|
||||
-- 上游改了也自愈不了。
|
||||
local function ttlCacheGet(namespace, key, ttl, loader)
|
||||
plugin.state.ttlCache = plugin.state.ttlCache or {}
|
||||
local ns = plugin.state.ttlCache[namespace]
|
||||
if not ns then ns = {} plugin.state.ttlCache[namespace] = ns end
|
||||
local now = os.time and os.time() or 0
|
||||
local e = ns[key]
|
||||
if type(e) == "table" then
|
||||
-- 墙钟回拨:cached_at 在未来 ⇒ 视为已过期(否则永久命中)
|
||||
if e.at <= now and (now - e.at) < ttl then
|
||||
return e.value
|
||||
end
|
||||
ns[key] = nil
|
||||
end
|
||||
-- 在飞去重:同一瞬间的重复请求不重复打(Lua 无协程,用时间戳近似)
|
||||
local inflight = plugin.state.ttlInflight or {}
|
||||
plugin.state.ttlInflight = inflight
|
||||
local ik = namespace .. ":" .. tostring(key)
|
||||
local last = inflight[ik]
|
||||
if last and last <= now and (now - last) < 1 then
|
||||
return nil
|
||||
end
|
||||
inflight[ik] = now
|
||||
local ok, value = pcall(loader)
|
||||
if not ok or value == nil then return nil end
|
||||
ns[key] = { at = now, value = value }
|
||||
return value
|
||||
end
|
||||
|
||||
-- ttlCacheInvalidate(namespace, key?):失效缓存(key 省略则整个 namespace)。
|
||||
--
|
||||
-- ⚠️ **必须一并清掉在飞标记**。否则:清缓存 → 同一秒内重载 → 被"在飞去重"
|
||||
-- 挡住 → 返回 nil。调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的
|
||||
-- (实测踩到:改窗口后立即查余额得到 nil)。失效的语义是"忘掉这次加载",
|
||||
-- 在飞标记也是这次加载的一部分。
|
||||
local function ttlCacheInvalidate(namespace, key)
|
||||
plugin.state.ttlCache = plugin.state.ttlCache or {}
|
||||
if key == nil then
|
||||
plugin.state.ttlCache[namespace] = {}
|
||||
elseif plugin.state.ttlCache[namespace] then
|
||||
plugin.state.ttlCache[namespace][key] = nil
|
||||
end
|
||||
local inflight = plugin.state.ttlInflight
|
||||
if inflight then
|
||||
if key == nil then
|
||||
local prefix = namespace .. ":"
|
||||
for k in pairs(inflight) do
|
||||
if k:sub(1, #prefix) == prefix then inflight[k] = nil end
|
||||
end
|
||||
else
|
||||
inflight[namespace .. ":" .. tostring(key)] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- serialQueue(namespace):取一个串行队列的锁状态。
|
||||
--
|
||||
-- 语义(照参考 serial-queue):同一时刻只有一个任务在跑,其余**排队**。
|
||||
-- Lua 无协程 ⇒ 无法"排队等待",只能做到**互斥 + 让路**:抢不到就立刻返回 nil,
|
||||
-- 由调用方跳过本次(对定时任务与轮询是正确语义:下一次会重试)。
|
||||
--
|
||||
-- ⚠️ 必须带**过期时间**:持有者崩了/超时了,锁必须能自解,否则该 namespace
|
||||
-- 永久卡死(参考用 promise finally 兑现,我们只能用 TTL)。
|
||||
local function serialTryAcquire(namespace, ttl)
|
||||
plugin.state.serialLock = plugin.state.serialLock or {}
|
||||
local now = os.time and os.time() or 0
|
||||
local held = plugin.state.serialLock[namespace]
|
||||
if held and held <= now and (now - held) < (ttl or 120) then
|
||||
return false
|
||||
end
|
||||
plugin.state.serialLock[namespace] = now
|
||||
return true
|
||||
end
|
||||
|
||||
local function serialRelease(namespace)
|
||||
if plugin.state.serialLock then plugin.state.serialLock[namespace] = nil end
|
||||
end
|
||||
|
||||
-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)
|
||||
-- ============================================================================
|
||||
-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
|
||||
@ -5116,44 +5218,40 @@ end
|
||||
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
|
||||
local BALANCE_CACHE_TTL = 60
|
||||
|
||||
local function balanceCacheOf(provider, accountId)
|
||||
plugin.state.balanceCache = plugin.state.balanceCache or {}
|
||||
local k = provider .. ":" .. accountId
|
||||
return plugin.state.balanceCache, k
|
||||
end
|
||||
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
|
||||
|
||||
-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。
|
||||
-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。
|
||||
local function accountBalanceRaw(provider, accountId)
|
||||
local cache, k = balanceCacheOf(provider, accountId)
|
||||
local now = os.time and os.time() or 0
|
||||
local hit = cache[k]
|
||||
if hit and (now - (hit.at or 0)) < BALANCE_CACHE_TTL then
|
||||
return hit.raw
|
||||
end
|
||||
-- 用通用 TTL 缓存(照参考 ttl-cache):命中即返回、在飞去重、失败不缓存。
|
||||
-- ⚠️ 缓存的是**原料**(包列表明细),分桶每次都现算 —— 见本节的注释。
|
||||
local plat = platformById(provider)
|
||||
local acct = poolGet(provider, accountId)
|
||||
if not plat or not acct or not plat.balance then return nil end
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if not tok or tok == "" then return nil end
|
||||
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
|
||||
local rec, err = fetchAccountJSON(plat.balance, tok, credentialFull(acct.credentialRef), plat)
|
||||
local raw = nil
|
||||
if not err and type(rec) == "table" then
|
||||
if plat.balance.tier_fn then
|
||||
local plate = plat
|
||||
local act = acct
|
||||
return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function()
|
||||
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
|
||||
local rec, err = fetchAccountJSON(plate.balance, tok,
|
||||
credentialFull(act.credentialRef), plate)
|
||||
if err or type(rec) ~= "table" then return nil end
|
||||
if plate.balance.tier_fn then
|
||||
-- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算)
|
||||
local okf, t = pcall(plat.balance.tier_fn, rec)
|
||||
if okf and type(t) == "table" then raw = t end
|
||||
elseif plat.balance.total_fn then
|
||||
local okf, t = pcall(plate.balance.tier_fn, rec)
|
||||
if okf and type(t) == "table" then return t end
|
||||
return nil
|
||||
end
|
||||
if plate.balance.total_fn then
|
||||
-- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档)
|
||||
local okf, total = pcall(plat.balance.total_fn, rec)
|
||||
local okf, total = pcall(plate.balance.total_fn, rec)
|
||||
if okf and type(total) == "number" then
|
||||
raw = { permanent_balance = total, expiring_balance = 0 }
|
||||
return { permanent_balance = total, expiring_balance = 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
cache[k] = { at = now, raw = raw }
|
||||
return raw
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。
|
||||
@ -8590,7 +8688,7 @@ function plugin.__set_expiring_window_probe(args)
|
||||
-- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的,
|
||||
-- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。
|
||||
-- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。
|
||||
plugin.state.balanceCache = {}
|
||||
ttlCacheInvalidate("balance")
|
||||
return { json = { ok = true, days = expiringWindowDays() } }
|
||||
end
|
||||
|
||||
@ -8617,6 +8715,48 @@ function plugin.__clear_all_creds_probe()
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
-- 以下四个探针只给测试用:把通用件(TTL 缓存 / 串行锁)的语义逐条钉住。
|
||||
-- 边界条件(清缓存要连带清在飞标记、ttl=0 不该命中、锁要能自解)都是踩过的坑。
|
||||
plugin.state.ttlProbeLoads = plugin.state.ttlProbeLoads or {}
|
||||
|
||||
function plugin.__ttl_reset_probe()
|
||||
plugin.state.ttlProbeLoads = {}
|
||||
plugin.state.ttlCache = {}
|
||||
plugin.state.ttlInflight = {}
|
||||
plugin.state.serialLock = {}
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
function plugin.__ttl_probe(args)
|
||||
args = args or {}
|
||||
local ns, k = tostring(args.ns or ""), tostring(args.key or "")
|
||||
local ttl = tonumber(args.ttl) or 60
|
||||
local lk = ns .. ":" .. k
|
||||
local v = ttlCacheGet(ns, k, ttl, function()
|
||||
plugin.state.ttlProbeLoads[lk] = (plugin.state.ttlProbeLoads[lk] or 0) + 1
|
||||
return "v" .. tostring(plugin.state.ttlProbeLoads[lk])
|
||||
end)
|
||||
return { json = { value = v, loads = plugin.state.ttlProbeLoads[lk] or 0 } }
|
||||
end
|
||||
|
||||
function plugin.__ttl_invalidate_probe(args)
|
||||
args = args or {}
|
||||
ttlCacheInvalidate(tostring(args.ns or ""), args.key and tostring(args.key) or nil)
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
function plugin.__serial_probe(args)
|
||||
args = args or {}
|
||||
local ok = serialTryAcquire(tostring(args.ns or ""), tonumber(args.ttl) or 60)
|
||||
return { json = { acquired = ok } }
|
||||
end
|
||||
|
||||
function plugin.__serial_release_probe(args)
|
||||
args = args or {}
|
||||
serialRelease(tostring(args.ns or ""))
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。
|
||||
function plugin.__rank_accounts_probe(args)
|
||||
args = args or {}
|
||||
|
||||
Reference in New Issue
Block a user