fix(sched): 额度闸门的接管探测退避 + 拦截日志限频 + 同邮箱多身份标注

Merged
HE
11d ago
+1184-54

问题

用户报: 账号选择很慢, 而且额度不满足的账号也会被轮询(请求 + 日志).

复核后的真实成因(与第一轮判断不同, 见 note 的 ## Correction):

  1. 排序不是瓶颈. 额度耗尽/买不起的账号是排到最后而非剔除(candidates.ts),
    排序本身是本地 Map 读, 纳秒级.
  2. 真正的代价是探测. 额度闸门命中时会为发现[别的部署建的已付费会话]发一次
    真实只读探测(GET /session). 那次探测是每请求每账号一次: state 是每请求
    一份的, 而 refresh() 在有在途请求时自跳过 ---- 于是[正在用]时几乎每次都白跑.
    池内 N 个额度不足的账号 x M 个在途请求 = N x M 次串行上游往返, 全部发生在首字节之前.
  3. 日志刷屏来自同一条路径: 每个被额度拦下的账号每个请求各记一条 skip 行.
  4. 同邮箱账号被连着轮询: key 用 Freebuff 用户 id(同邮箱 GitHub/Google 是两个独立
    账号), 而 sticky 的平局规则含 _rr 索引, 索引相邻的同邮箱账号会被连续选中.
  5. 附带发现: readAccountUser 的兜底扫描命中多个同邮箱文件时返回 null,
    调用方于是报 401 ---- 账号在列表里却用不了, 表现就是[账号被隐藏了].

改动

  • 探测退避(src/context/sched/account-gates.ts + account-runtime.ts):
    探测成功或失败都按 60s 窗口延后下一次, 窗口挂 runtime(跨请求生效);
    窗口内 makePaidUpstreamChecker 返回空串, 调用方视为[没有可接管会话] -> 零往返.
    探测失败从抛错改为降级(按闸门结论继续 + 告警).
  • 拦截日志限频(src/util/log.ts): skipLogOnce 按(账号, 拦截码)60s 只记一条.
  • 凭据解析确定化(src/auth-store/accounts.ts): 命中多个同邮箱文件时按
    key 精确 > 小写 key > 邮箱, 同档按文件名序 ---- 不再返回 null.
  • 控制台标注(dashboard): 同邮箱多身份挂[同邮箱]徽章, 消除[账号重复/被覆盖]的观感.

测试(均已实测可证伪)

用例断言破坏实现后的表现
probe-backoff.ts首轮每账号 1 次探测; 窗口内 5 个请求 0 新增探测; 4 个请求只留 2 条 skip 日志去掉窗口早退 -> got 12; 换回 logger.info -> got 8
duplicate-email-resolve.ts同邮箱多身份解析确定且精确命中改回 return null -> 红在[兜底扫描必须给出确定结果]
same-email-badge.ts同邮箱行挂徽章, 唯一邮箱不挂计数换成空 Map -> got [0,0,0]

本地已验证: pre-commit 门禁全绿; smoke 全量通过; 三个 tsconfig 类型检查无新增错误;
受影响的 5 个 verify 套件通过.

决策记录

.agents/notes/implemented/feature/2026-10-09-quota-gate-probe-throttle.md
(含 Alternatives considered: 为什么不直接剔除候选)


Summary by cubic

Fixes slow account selection by backing off paid-session takeover probes, throttling quota skip logs, and resolving same-email credential ambiguity, with a dashboard badge for shared emails.

When an account's quota gate blocked a new purchase, the pool issued a real read-only probe (GET /session) per account per request — N quota-ineligible accounts × M in-flight requests meant N×M serial upstream round-trips before the first byte. Backoff now lives on the runtime (cross-request, resets on runtime rebuild) and only blocks re-asking upstream; known paid sessions from local snapshots still work inside the window. Concurrent probes on the same runtime share a single in-flight refresh, so parallel requests don't duplicate upstream calls. Skipped refreshes (while a request is in flight) don't open the window, so a real probe can fire immediately after. Probe failures degrade to the gate's conclusion with a warning instead of throwing.

Skip logs for blocked accounts are capped at one line per 60s window, and the throttle window only opens when the line is actually emitted — a level filter that suppresses the log leaves the window closed. When the rate-limit table hits capacity it only reclaims expired entries and never evicts an unexpired one.

Credential resolution previously returned null when the fallback scan matched multiple same-email files (GitHub/Google logins use separate account keys), surfacing as 401 "Account not found". It now picks deterministically (exact key > lowercase key > email, account key tiebreak), and the dashboard marks rows sharing an email with a "Same email" badge. The ambiguity warning is not covered by the quota-skip throttle and repeats per scan.

All changes are covered by tests whose assertions fail if the change is reverted.