Skip to content

bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772

Description

@os-zhuang

TL;DR

pnpm dev(showcase)每次启动都报一条"没有 cache 服务,限流退化成进程内内存计数器,多节点部署需要 Redis"。但 CacheServicePlugin 就在 21 毫秒后注册好了,而且它本来就在已加载插件列表里。

和 #4771 是同一类问题(校验/告警跑在提供方注册之前),但在另一个子系统,修法也不同,所以单开。

现场

WARN [auth] no cache service registered — rate-limit counters use a per-process in-memory
     store; a multi-node deployment needs a shared cache (Redis) to enforce limits
     globally (ADR-0069 D2)

启动摘要里的已加载插件:

Plugins: 47 loaded
         ObjectQL, SqlDriver, ..., QueueServicePlugin, CacheServicePlugin, SettingsServicePlugin, ...

证据

--log-level info 的时间戳:

事件 时刻
WARN [auth] no cache service registered 04:28:04.527
INFO Service 'cache' registered {"service":"cache"} 04:28:04.548
INFO CacheServicePlugin: registered memory cache adapter 04:28:04.548

差 21ms。

为什么值得修

告警本身的建议("多节点需要 Redis")在这个语境下是错的指路:这个部署确实配了 cache 插件,缺的不是 Redis,是"auth 在 cache 注册完之前就问了"。照着这条日志去接 Redis,接完还是会看到同一条 warn。

同 #4771:真正没配 cache 服务的部署会得到一模一样的一条 warn,两种情况无法区分。

修法(择一)

  1. 把这次探测推迟到 kernel:ready,那时服务注册表才是完整的 —— 与 bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 同一思路。
  2. auth 改成惰性解析 cache:不要在 start() 时一次性拍板并缓存"没有 cache",而是在真正用限流计数器时再取一次服务。这样也顺带修掉真正的功能退化 —— 目前即使 cache 后来注册上了,auth 这一侧是不是还捏着启动时那个进程内存储?这一点我没验证,但如果是,那就不只是日志误报,而是限流真的没用上共享 cache。值得在修的时候一并确认。

复现

rm -rf examples/app-showcase/.objectstack && pnpm dev

看时序:objectstack dev --seed-admin --log-level info,对比上表两个时间戳。

Activity

  1. self-assigned this
    on Aug 3, 2026
  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 3 轮
    会话:session_015Br2xsJsczFsTR9bvbh2Ny
    分支:claude/issue-4772-auth-lazy-cache-resolve
    Worktree:objectstack-issue-4772

    分诊:落地 objectstack(后端);PM 裁定取修法 2(惰性解析 cache),不取修法 1。

    这里我和 #4771 走了不同的方向,理由是两者的风险面不同:

    • 长远合理性 —— 修法 1(把探测推迟到 kernel:ready)只修日志。issue 自己指出了一个尚未证实的真降级:auth 在 start() 时一次性拍板「没有 cache」并缓存,那么即使 cache 随后注册上,限流这一侧是不是还捏着启动时那个进程内 store?如果是,这就不是误报,而是限流真的没用上共享 cache —— 多节点部署的限额从未被全局强制过,而日志还在告诉你「去接 Redis」。惰性解析同时关掉日志误报和这个功能洞;修法 1 只关掉前者,并且会让后者更难被发现(告警没了,人就不看了)。
    • 防 AI 写错 —— 「宽容消费端」正是 AI 批量犯错被掩盖的温床:启动期取不到服务就静默退化成本地实现、然后永久保持,是这条反模式的教科书形态。惰性解析把「取服务」放回真正用到的那一刻,让声明的能力(共享限流)在运行时真正兑现。

    要求 dev 先验证再修:确认 start() 缓存的 cache 句柄是否真的一直被后续限流计数使用。这是决定本 issue 是「日志误报」还是「安全相关的功能洞」的分水岭 —— 结论请写进 PR 描述与报告。若证实是功能洞,顺手在 issue 上补 security 标签的建议一并说明。

    保留一条告警:当限流计数真正要用 cache 而此刻确实没有时,才喊 —— 那时它才是真信号。

    维护者可否决本裁定;不阻塞派发。


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    交叉链接:本 issue 与 #4769、#4771 是同一个形状在三个互不相干子系统里的三次独立发作。已立决策单 #4776(是否收紧 kernel 注册表契约,让「尚未知道」在类型上不等于「没有」),附两轴分析。

    本 issue 的修复照常进行,不受阻塞。另:你被要求验证的那一点(auth 是否一直捏着启动时的进程内 store)对 #4776 的判断很关键 —— 如果证实是真功能洞,那它就是这一类缺陷「不止是日志噪音」的第二个实证(第一个是 #4769 把假证据写进了持久层)。请务必把结论写清楚。


    Generated by Claude Code

  4. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:ACCEPT(待 CI 全绿) —— PR #4788。已按 dev 的证实补上 security 标签。

    我要求先验证的那一点:答案是「是」,这是功能洞

    dev 给的是代码链条而不是推测,我逐环核过:

    1. AuthPlugin.init() 的 getServiceAsync('cache') 结果写进 authConfig.secondaryStorage → 存进 AuthManager.this.config;
    2. better-auth 实例虽然是懒创建的,但 createAuthInstance() 读的是 init 时那份 config —— 懒创建冻结的是结论,不是时机,这是整件事最反直觉的地方,也是它一直没被发现的原因;
    3. init() 早于 CacheServicePlugin 21ms,所以标准 serve 组合下永远走 else;
    4. 于是落到 better-auth 默认的 rateLimit.storage: 'memory' —— 模块级 Map,每进程一份。

    结论:多节点部署的限额从来没有被全局强制过,轮换节点即可把限额乘以节点数。而日志一直在告诉运维「去接 Redis」,接完仍是同一条 warn、同一个洞。这正是我在派发时判断「修法 1 只关掉告警会让问题更难被发现」的那个场景 —— 只是实际情况比我预期的更糟:洞是确凿的,不是「可能」。

    更重要的是它顺带挖出的 #4785,而 dev 没有替你决定

    issue 的直觉修法是「把 cache 真的接成 secondaryStorage」。dev 查了 better-auth 1.7.0-rc.2 的源码,发现那么做会在修一个安全洞的同时开另一个:createSession 设了 secondaryStorage 就不写 sys_session 行,findSession 命中缓存直接返回、根本不查库;而 ADR-0069 D4 的空闲超时 / 绝对上限 / 并发上限全靠写那一行来撤销会话。也就是说自动接上 cache 会静默废掉 D4 的三个管控,而且双写也救不了(读路径仍以缓存为准,撤销看起来生效、实际不生效)。

    这个冲突之所以长期隐形,恰恰因为那次探测从来没成功过。 一个 bug 把另一个更大的 bug 藏了起来 —— 这是本轮我见到的最有价值的发现。

    dev 的处理我完全认可:不自动派生 secondaryStorage(回归「宿主显式提供才生效」,与今天标准组合下的实际运行时行为完全一致),把 cacheSecondaryStorage() 改为包根导出并就地写清代价,然后把「会话的记录之处到底在数据库还是缓存」作为架构决策单独开 #4785(needs-user-decision,三个选项 + 代价 + 建议),不自行决定。这是正确的边界感。

    其余复核

    • 范围守住了:10 个文件全在 packages/plugins/plugin-auth(其中 3 个是新增的实现+测试),未碰 objectql、service-storage、service-automation、plugin-approvals。805 行里 502 行是新文件和 changeset,真正的既有代码改动很小。
    • ADR 改动我核了具体那一行:docs/adr/0069 的 P2 状态行此前写着 shared rate-limit + session store 已 landed —— 那是假的,而且假了很久。新行把它订正为事实并注明订正来源。这是修订状态行、不是推翻 D 行,docs/adr/ 也不是代码 PR 的禁区(禁的是 content/docs/releases/)。这条改动本身就有独立价值:仓库此前一直在携带一句关于自己安全能力的虚假声明。
    • 测试:632/632,typecheck 干净,另跑通 4 个仓内门禁。同样做了红→绿反向验证(stash 掉 auth-plugin.ts 后 4 条新测试失败)。
    • 一个我特别想表扬的判断:rateLimitStorage 被刻意放在 AuthManagerOptions 顶层而不是 rateLimit 里面,因为 bindAuthSettings 在管理员调限流参数时会整个替换 rateLimit 对象 —— 放进去就意味着「管理员一改设置,共享限流悄悄退回不共享」。这是个典型的、极难在事后发现的安全属性回退陷阱,dev 不但看见了,还用测试钉住了。

    一个我看过但决定不拦的点

    changeset 标 patch,而这次移除了「从 kernel cache 自动派生 secondaryStorage」。理论上,若某个宿主的组合顺序恰好让 cache 先于 auth init 注册,它今天是绑上的,这次会被摘掉。但按 #4785 的分析,那种绑上恰恰意味着 D4 三个会话管控在静默失效 —— 摘掉是安全修复而不是能力回退,且宿主显式提供的 secondaryStorage 行为不变。所以 patch 站得住,不要求改。

    CI 仍在跑,全绿后标 ready 并送合并队列。


    Generated by Claude Code

  5. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    验证结论:是功能洞,不只是日志误报 → 建议补 security 标签

    PR: #4788(draft)

    init() 时定下的「没有 cache」结论会被冻结整个进程:

    1. AuthPlugin.init() 的探测结果写进 authConfig.secondaryStorage → new AuthManager(authConfig) → this.config;
    2. better-auth 实例确实是懒创建的,但 createAuthInstance() 读的是 init 时那份 config(...(this.config.secondaryStorage ? { storage: 'secondary-storage' } : {}))—— 懒创建没有让探测重来一次,冻结的是结论;
    3. init() 早于 CacheServicePlugin 注册 cache(本 issue 现场早 21ms),所以标准 serve 组合下这个分支永远走 else;
    4. 结果 better-auth 落到默认 rateLimit.storage: 'memory' —— rate-limiter 模块里那个模块级 Map,每进程一份。

    即:多节点部署的限额从来没有被全局强制过(轮换节点可把限额乘以节点数),而日志还在指路「去接 Redis」,接完仍是同一条 warn、同一个洞。

    一个必须一并知道的连带发现

    「把 cache 真的接成 secondaryStorage」这条直觉修法不能用:better-auth 1.7 的 createSession 在有 secondaryStorage 时不写 sys_session 行,findSession 直接从缓存快照作答、不查库;而 ADR-0069 D4 的空闲超时 / 绝对上限 / 并发上限全靠写这一行来撤销会话。自动绑定会静默废掉这三个管控(即使 storeSessionInDatabase 双写也一样,读路径以缓存为准)。

    所以 PR 里限流计数器走 better-auth 的 rateLimit.customStorage(counters-only,惰性解析 cache),会话存储保持今天的实际行为不变,「会话的记录之处该在哪」作为架构决策单独开了 #4785(needs-user-decision),未自行决定。


    Generated by Claude Code

  6. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    CI 全绿(20/20),PR #4788 已标 ready 并送入合并队列。

    补一条 dev 报告里记录、但没有立单的发现,已由 PM 代立为 #4790:

    AuthManager.getOtpSendGuard() 实现的 #2780「每号码 OTP 发送预算」也只在宿主显式提供 secondaryStorage 时才跨节点共享,默认组合下同样是进程内计数。这与本 issue 修掉的洞是同一类,但是独立的一处 —— 本 PR 走的是新的 rateLimit.customStorage,OTP 预算是 AuthManager 自己的另一套计数,行为未被本 PR 改变。

    按 Prime Directive #10,超范围发现应当立成未认领 issue;这条只写在报告和 PR 描述里,没有落到 GitHub 上,那样它会随着这轮会话一起消失。已补立 #4790(pm:queue,security),要求接手者先独立核实代码路径再动手(这条是顺带观察,dev 和我都没有单独验证过 getOtpSendGuard() 的存储取自哪里),并明确要求不要用 secondaryStorage 修它 —— 那会踩 #4785 记录的 D4 地雷,必须走本 PR 建好的 cache 惰性解析路径。

    顺带指出它比限流洞更直接带成本:每次 OTP 发送是真金白银,预算按节点数倍增意味着 N 节点部署的实际上限是声明值的 N 倍。#2814(短信发送配额,成本总量闸)如果建立在同一套进程内计数上,上线即失效 —— 已在 #4790 里要求一并核对。


    Generated by Claude Code

  7. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    ✅ PR #4788 已合并。

    落地内容:限流计数器改为惰性解析 kernel cache(rateLimit.customStorage),计数器被消费时才取服务 —— 那一刻必然晚于 kernel:ready,因此与插件启动顺序无关。

    这次修的不只是那条误导性的启动日志。 复核确认的功能洞是真的:init() 时定下的「没有 cache」结论会被冻结整个进程(better-auth 实例懒创建,但读的是 init 时那份 config —— 冻结的是结论,不是时机),于是限流一直落在进程内的模块级 Map 上,多节点部署的限额从来没有被全局强制过。而 content/docs/permissions/authorization.mdx 早就写着这个能力「landed」—— 那句话从今天起才第一次为真。

    顺带落地的还有 ADR-0069 P2 状态行的事实订正(此前声称 shared session store 已 landed,实际从未生效)。

    两条衍生单待处理:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions