Files
Jyotisha/docs/tasks/TASK-node-runtime-resources-20261013.md
T
Jesse_ChenandClaude Opus 5.5 2994588ad4 docs(tasks): fold product decisions into audit briefs (BUG-1344..1346)
- dasha-unify: fix Ashtottari birth balance (BUG-1344)
- rect-scoring-perf: server-side candidate cap 241 (BUG-1345)
- node-runtime-resources: enable user-side session cookie cache, 300 s (BUG-1346)
- consult-prep-parallel: classifier/reserve concurrency declined
- answer-effort-ab: model key supplied by product at acceptance
- drop stricter-than-test line-count red lines (AGENTS.md section 6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:41:02 +08:00

266 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · Node 进程资源:报告 worker 监听泄漏、本地 Postgres 往返、无上限内存表(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」——P0「报告 worker 空转泄漏」、P1「数据库适配层往返」、小项「服务端无上限 Map」
- 执行方:**Grok(Windows + Docker)**,分支 `codex/node-runtime-resources-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**BUG-1326~1328、BUG-1346**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
- BUG-1326:报告 worker 空转每 2 秒挂一个 abort 监听,永不释放(P0)
- BUG-1327:本地 Postgres 适配层每条查询 6 次往返、元数据每次现查(P1,需 A/B)
- BUG-1328:三张服务端内存表只增不减(小)
- BUG-1346:用户端开启 Better Auth 会话读缓存(产品 10-14 决定,maxAge 300 s,敏感操作绕过)
- 开工前置:**无**(第一批立即派发)
## 1. 基线
- 基线 commit:`origin/staging` `77057a819`
- 涉及文件:
- `frontend/src/lib/personal-report-worker-core.ts`(`defaultSleep`、`runPersonalReportWorkerLoop`)
- `frontend/src/lib/personal-report-worker.ts`(`startPersonalReportWorker`,由 `frontend/src/instrumentation.ts::register` 在 Node 运行时启动)
- `frontend/src/lib/birth-time-guided-polling.ts`(`scoringPollDelay`,同型写法,见 T1)
- `frontend/src/lib/db/local-postgres-client-core.ts`(`inBusinessTransaction`、`columnTypes`、`primaryKeyColumns`、`functionMetadata`、`LocalPostgresQueryBuilder.execute`、`LocalPostgresDataClient.rpc`)
- `frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts`(`rescoreAttempts`)
- `frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts`(`delivered`)
- `frontend/src/lib/request-rate-limit.ts`(`consumeRequestRateLimit`)
## 2. 事故实证(`77057a819`)
### 2.1 报告 worker 监听泄漏(BUG-1326)
`personal-report-worker-core.ts::defaultSleep` 每次 sleep 都对传入的 `signal` 执行 `addEventListener("abort", …, { once: true })`。`{ once: true }` 只在 abort **触发时**摘除;计时器正常到点 resolve 后,监听留在 signal 上。
`runPersonalReportWorkerLoop` 空闲(`tick()` 返回 `idle`)或 `tick()` 抛错时都会 `await sleep(pollIntervalMs=2000, options.signal)`。`options.signal` 来自 `personal-report-worker.ts::startPersonalReportWorker` 里进程级的 `new AbortController()`,只有 `stop()` 才 abort,生产里从不调用。于是每个 Next.js Node 进程每 2 秒多挂一个闭包(闭包还引用已过期的 timer),一天 43,200 个。
复现(虚构负载,与源码同形;Node 22.14.0,`--expose-gc`):
```bash
node --expose-gc leak-fixed.mjs # 基线写法(脚本见文末附录)
node --expose-gc leak-fixed.mjs fixed # 到点即摘监听
```
| 写法 | sleep 次数 | 残留 abort 监听 | GC 后堆增长 |
| --- | --- | --- | --- |
| 基线 | 43,200(= 空闲一天) | 43,200 | **+22.25 MB** |
| 到点 `removeEventListener` | 43,200 | 0 | +0.19 MB |
(abort 之后基线堆回落到 +0.36 MB,证明增长全部挂在这个 signal 上。)即每个 Node 进程约 **22 MB/天**单调上涨,直到容器重启;生产 4 GB 主机上与 Python API、Postgres 共用内存。
全仓 `frontend/src` 同类写法排查(`addEventListener("abort"` 全部 18 处逐一读过):
| 位置 | 是否在循环里对长寿命 signal 挂监听不摘 | 结论 |
| --- | --- | --- |
| `personal-report-worker-core.ts::defaultSleep` | 是,signal 进程级 | **泄漏,T1 修** |
| `birth-time-guided-polling.ts::scoringPollDelay` | 是,同型写法;但 signal 是 `use-birth-time-automatic-journey-effects.ts` 里每次 effect 新建的 controller,`maxAttempts: 7`,最多残留 6 个,effect 卸载即随 controller 回收 | 有界、不泄漏;同一提交顺手改成到点摘除(同型写法不留第二份,T1b) |
| `session-title-agent.ts`、`session-context-summary.ts`、`adopt-narration-agent.ts`、`turn-intent-classifier.ts::classifyWithinTimeout`、`agent-run-attempt.ts`、`consultation-smalltalk.ts`、`home-bootstrap-run.ts::runHomeBootstrap`、`home-cloud-sync.ts::waitForUndoWindow` | 否,均在 `finally` / `dispose` / 到点时 `removeEventListener` | 干净 |
| `consultation-recovery-poll.ts::runConsultationRecoveryPoll` | 每次调用挂一个、不摘;signal 是 `page.tsx` 中每个 recovering effect 新建的 controller | 有界、不改 |
| `mastra/consultation-tools.ts::createConsultationRunClock` | 对本次请求新建的 `AbortSignal.timeout` 挂一次 | 有界、不改 |
另两个进程级 worker(`account-deletion-worker.ts`、`reply-quality-retention-worker.ts`)用 `setInterval`,不碰 signal,干净。
### 2.2 本地 Postgres 适配层往返(BUG-1327)
自托管模式下所有业务读写走 `LocalPostgresDataClient`(`lib/supabase/server.ts::createServerSupabaseClient` 用 `authenticated` + 会话身份;`lib/supabase/admin-client-core.ts::createAdminSupabaseClient` 用 `service_role` + 无身份)。按源码逐条数,一次调用的往返:
| 调用 | 往返序列 | 次数 |
| --- | --- | --- |
| `.from(t).select()/update()/delete()/insert()` | `begin` → `set local role <role>` → `select set_config(sub), set_config(email)` → `columnTypes`(查 `information_schema.columns`)→ 业务 SQL → `commit` | **6** |
| `.upsert()` 未传 `onConflict` | 上述 + `primaryKeyColumns`(查 `pg_index`) | **7** |
| `.rpc(fn, args)` | `begin` → `set local role` → `set_config` → `functionMetadata`(查 `pg_proc`)→ 调用 → `commit` | **6** |
真正干活的只有 1 次;表结构 / 函数签名每次现查,进程内无任何缓存。`information_schema.columns` 是带权限过滤的视图,单次比业务主键查询还重。一个首页启动(账户、人物、会话列表)或一轮咨询落库会串行打十几次这种调用。
**注意(影响缓存设计)**:`columnTypes` 在 `set local role` **之后**执行,`information_schema.columns` 只列当前角色有权限的列,所以 `authenticated` 与 `service_role` 看到的列集合可以不同——缓存键必须带角色。`pg_proc` 不按权限过滤。
### 2.3 无上限内存表(BUG-1328)
| 位置 | 键 | 何时删 | 实际有效期 |
| --- | --- | --- | --- |
| `block-scan-answer.ts` 模块级 `rescoreAttempts: Map<string, true>` | `caseId:askedTurnId:证据指纹` | 从不(只有测试用 `resetStaleMinuteRescoreAttemptsForTests`) | 语义是「同一证据指纹只重算一次」(BUG-578),每道题 / 每次证据变化一条,永久累积 |
| `delivery-turn-guard.ts` 模块级 `delivered: Map<caseId, DeliveryMark>` | caseId | 从不(只有 `resetDeliveryTurnGuardForTests`) | 所有读取方都先判 `now - at <= 60_000`,超过 60 s 的条目已无任何作用(BUG-596) |
| `request-rate-limit.ts` 全局 `jyotishaRequestRateLimits: Map<string, number[]>` | `bucket:userId` 等 | 从不;只在**同一个 key 再次被访问**时过滤旧时间戳 | 某用户不再访问,该 key 及其旧时间戳数组永久留存;key 数 ≈ 历史活跃用户数 × 桶数(六个具名桶 + `chartView`、`birthSky`) |
均为进程级,随进程寿命线性增长;单条小,但与 2.1 同属「长寿命进程只增不减」。
## 3. 根因
- **BUG-1326**:误以为 `{ once: true }` 会在 Promise 结束时自动摘监听;只有 abort 事件触发才摘。worker 自上线起即如此,**从未设计回收,不是回归**。`docs/BUG_HISTORY.md` 检索 `defaultSleep`、`runPersonalReportWorkerLoop`、`addEventListener`、`MaxListeners`、「内存泄漏」无记录。同一提交里已有正确示范(`home-cloud-sync.ts::waitForUndoWindow` 到点时 `removeEventListener`)。
- **BUG-1327**:适配层是 Supabase → 自托管 Postgres 迁移时按「每次调用一个完整事务 + 现查元数据」写的兼容层,追求正确、未做缓存。不是回归。相关记录:BUG-166(同文件连接池按 URL 关闭)、BUG-926(`order()`)、`local-postgres-not` / `local-postgres-or` 两条算子修复——这些防复发(按 URL 关池、算子语义)本单不得动。
- **BUG-1328**:三张表都只为「短时间内去重 / 限流」而建,写的人没考虑进程寿命。BUG-578 的「同一证据指纹只重算一次」、BUG-596 的「交付后 60 s 内无用户消息不再开计费轮」是语义来源,本单不得改变其在有效期内的行为。
## 4. 决策记录
- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行(本单含 P0 + P1 + 小项)。
- 产品追加要求:P1 改动必须 A/B 并做成开关,默认值按 A/B 结论定——本单 T2 照做;T1、T3 是缺陷修复且行为等价(只回收已无作用的条目 / 监听),不做开关。
- **只读无身份调用省掉事务:评估结论为不做**(理由见 T2「评估」),不是让步项。
- **Better Auth `cookieCache`(会话读缓存):产品负责人 2026-10-14 决定开启**(T6,BUG-1346)。代价已向产品说明:改密码 / 封号后,其它设备上的旧登录最多再有效 `maxAge` 这么久。主会话评估取 **`maxAge` = 300 秒**(Better Auth 默认值,与本仓 `twoFactorCookieMaxAge: 300` 同量级),且敏感操作强制绕过缓存(见 T6)。
- 不推翻任何既有红线。
## 5. 硬红线
1. 不改数据库结构、不加迁移、不动 `frontend/db/**`、`supabase/**`;不改 RLS 策略、角色与授权。
2. **事务语义不变**:每次 `.from()` / `.rpc()` 调用仍在自己的事务里、仍以同一角色(`set local` 等价物,事务结束即失效)和同一组 `request.jwt.claim.*` 执行;连接归还池子时不得带着角色或 claim。不得改成会话级 `set role`。
3. 缓存只缓存**元数据**(列类型、主键列、函数签名),绝不缓存业务数据;缓存键必须含连接串与角色;不缓存「查不到」(空列表、`database function not found`)。
4. BUG-578「同一证据指纹只重算一次」与 BUG-596「60 s 窗口内 `already_delivered`」在有效期内行为逐条不变;`request-rate-limit` 在窗口内的放行 / 拒绝与 `retryAfterSeconds` 逐条不变。
5. `closeLocalPostgresDataPool(s)` 的按 URL 关池行为(BUG-166)不变,并顺带清掉该 URL 的元数据缓存。
6. 冻结约束:`scripts/jyotish_api_server.py` 不碰(`tests/test_api_server_growth_contract.py`);不碰 `frontend/src/app/(app)/page.tsx`(`home-shell-growth-contract`);不改 `.gitea/workflows/**`;不升级依赖、不顺手修不在本单里的 warning。
7. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests`(已知:`admin-contracts.test.ts` 215 行匹配迁移文本里的 `set local role service_role`,那是迁移 SQL 不是适配层,不受影响)。
8. 开关只读服务端环境变量,不进 `NEXT_PUBLIC_*`,不入前端包。
## 6. 任务分解
### T1 · 报告 worker sleep 到点摘监听(BUG-1326,P0)
- `personal-report-worker-core.ts::defaultSleep` 改为:命名 `onAbort`;计时器到点时先 `signal?.removeEventListener("abort", onAbort)` 再 resolve;abort 时清计时器并 resolve。保持 `unref`、保持「已 abort 直接返回」。
- 不改 `runPersonalReportWorkerLoop` 的轮询间隔、错误处理与退出条件;不改 `startPersonalReportWorker`。
- **T1b**:`birth-time-guided-polling.ts::scoringPollDelay` 同样到点摘监听(有界,不是泄漏,只为全仓不留第二份错误写法)。
- 验收(新测试 `frontend/tests/personal-report-worker-sleep-listener.test.ts`):
- 用 `runPersonalReportWorkerLoop` + `pollIntervalMs: 1` + 一个连续返回 `idle` 的假 worker,第 200 次 `tick` 时用 `node:events` 的 `getEventListeners(controller.signal, "abort").length` 记录监听数,然后 abort。断言 **= 0**(基线为 199);另一条:`tick` 抛错路径同样 = 0。
- abort 后 loop 在一个轮询间隔内结束(`done` resolve)。
- `scoringPollDelay`:到点 resolve 后监听数 = 0;abort 时立即 resolve。
- 写进 PROGRESS:在基线代码上跑新测试的失败输出(证明能抓回归)。
- 命令:`npx tsx --test tests/personal-report-worker-sleep-listener.test.ts tests/personal-report-worker.test.ts`。Grok 自跑;Claude 复验,并另用 `leak-fixed.mjs` 同形脚本复测(目标:43,200 次 sleep 后监听 0、堆增长 < 1 MB)。
### T2 · 本地 Postgres 元数据缓存 + 合并会话设置(BUG-1327,P1,开关 + A/B)
**T2a 进程级元数据缓存(开关 `JYOTISHA_PG_METADATA_CACHE`,取值 `on` / `off`)**
- 在 `local-postgres-client-core.ts` 内新增模块级缓存(挂 `globalThis`,与 `jyotishaLocalDataPools` 同法,避免模块重复求值时分裂):
- 列类型:键 `连接串 | 角色(null→"owner") | 表名` → `Map<column, udt_name>`;
- 主键列:键 `连接串 | 表名`;
- 函数签名:键 `连接串 | 角色 | 函数名 | 排序后的参数名`(`functionMetadata` 的结果依赖传入的参数名集合,必须进键)。
- 失效:
1. TTL **300 秒**(迁移在旧容器仍运行时执行,再由部署重启换新进程;TTL 兜住「迁移后、重启前」的窗口)。
2. 业务 SQL 抛出 SQLSTATE `42703`(undefined_column)、`42P01`(undefined_table)、`42883`(undefined_function)、`42804`(datatype_mismatch)时,清掉**该连接串**的全部元数据缓存;本次照旧返回错误(**不自动重试**——事务已中止,重试会改变调用方看到的语义),下一次调用重新查。
3. `closeLocalPostgresDataPool(url)` 清该 URL;`closeLocalPostgresDataPools()` 全清;导出 `clearLocalPostgresMetadataCacheForTests()`。
4. 不缓存空结果与「function not found」。
- 元数据查询仍在**同一事务、同一角色**下执行(缓存未命中时),保证与现状看到的列集合一致。
- 开关 `off` 时代码路径与基线逐条一致(每次现查)。
**T2b 合并会话设置往返(开关 `JYOTISHA_PG_MERGED_SESSION_SETUP`,`on` / `off`)**
- `inBusinessTransaction` 把 `set local role <role>` 与两条 `set_config` 合成一条参数化语句:
`select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)`;`role === null` 时省掉第一项。
`set_config('role', x, true)` 与 `SET LOCAL ROLE x` 等价(PostgREST 用同一做法),角色仍只能是构造函数白名单里的 `authenticated` / `service_role`。
- `begin`、`commit` 保持独立语句;**不得**用拼字符串的多语句简单查询把 `begin` 也合进去。
- 结果:select / insert / update / delete / rpc 往返 6 → 4(缓存命中时);未传 `onConflict` 的 upsert 7 → 4。
**T2c 评估:只读、无身份时省事务——不做**
- `set local` / `set_config(..., true)` 只在事务块内生效;不开事务就只能用会话级 `set role`,归还连接前必须再 `reset role`,往返数不降(begin/commit 换成 set/reset),且一旦 reset 失败,连接会带着 `service_role` 回到池子给下一个请求——违反红线 2。无身份的 `authenticated` 读同理。结论:不做,写进 PROGRESS。
**T2 验收**
1. 新单测(不需 Docker)`frontend/tests/local-postgres-roundtrips.test.ts`:用假 `Pool`/`PoolClient` 记录每条 `client.query` 的 SQL 文本,断言:
- 两开关 `off`:select 序列 = `begin, set local role, set_config, information_schema, 业务, commit`(6)——锁住基线;
- 两开关 `on`、同一表第二次调用:`begin, set_config(含 role), 业务, commit`(4);rpc 同理 4;
- 角色不同(`authenticated` vs `service_role`)不共享列缓存;参数名集合不同不共享函数缓存;
- 业务 SQL 抛 `42703` 后下一次调用重新查 `information_schema`;TTL 过期(注入时钟)后重新查;`closeLocalPostgresDataPool(url)` 后重新查;
- 空列结果与 function-not-found 不入缓存。
2. **`npm run test:db`(Grok 必跑,以 Grok 为准)**:两开关 `on` 跑一遍、`off` 跑一遍。失败清单与 `tools/agent-loop/windows-known-failures.txt` **按测试名逐条比对,多出一条即未通过**;Docker 未开则回报「未跑」,不得写通过。另加一条真库测试(放进现有 `database-local-business.test.ts` 或新建 `database-local-postgres-session-setup.test.ts`):开关 `on` 时,在事务内 `select current_user, current_setting('request.jwt.claim.sub', true)` 分别等于角色与身份;**同一连接归还后再取出**,`current_user` 回到连接用户、claim 为空——证明没有泄漏到池子。
3. 现有 `local-postgres-not` / `or` / `order` / `query-value` / `pool-close` 单测全绿、无断言改动。
### T3 · 三张内存表加上限 / 过期(BUG-1328)
- `delivery-turn-guard.ts`:`markDeliveryTurn` 写入前清掉 `now - at > WINDOW_MS` 的条目(所有读取方本就把它们视为不存在,行为逐条等价)。为防极端情况,再加硬上限 10,000 条(超出删最旧插入)。
- `block-scan-answer.ts::rescoreAttempts`:值改为写入时间戳;写入时清掉超过 **6 小时**的条目,并设硬上限 5,000 条(超出删最旧插入)。语义变化仅限:同一 `(case, 题, 证据指纹)` 在 6 小时后或被挤出后可能再尝试重算一次——这比现状「进程重启后会再算」更收敛,写进 PROGRESS。
- `request-rate-limit.ts::consumeRequestRateLimit`:模块内记录本进程见过的最大 `windowMs`;每 1,000 次调用(或 `store.size > 5,000` 时)扫一遍 store,删除「最新时间戳 ≤ now − 最大窗口」的 key(这些 key 无论属于哪个桶都已出窗,删除不改变任何判定);`recent` 为空时不写回空数组。`RequestRateLimitStore` 类型与导出不变(测试会传自定义 store)。
- 验收(扩展 `frontend/tests/request-rate-limit.test.ts`,新建 `frontend/tests/delivery-turn-guard-expiry.test.ts`、`frontend/tests/rescore-attempts-bound.test.ts` 或并入现有对应测试):
- 窗口内判定与基线逐条相同(复用现有用例,不改断言);
- 注入 `now` 推进超过窗口后触发清理,`store.size` 回落;不同窗口的桶混用时,1 小时窗口的 `synastry` key 在 61 秒后**不**被删;
- `delivered` 超过 60 s 的条目在下一次 `markDeliveryTurn` 后不在表里;`alreadyDelivered` / `deliveryCarrierNarration` 在 60 s 内结果不变;
- `rescoreAttempts` 写入 5,001 条后 size ≤ 5,000;同一 key 6 小时内第二次调用仍不重算(沿用 `rectification-stale-compare-fix-20260907.test.ts` 等现有用例,全绿)。
- 现有 `rectification-delivery-ui-simplify-20260908.test.ts`、`rectification-answer-choice.test.ts`、`rectification-post-adopt-card-20261001.test.ts`、`ephemeris-route.test.ts`、`subject-route-birth.test.ts` 全绿、无断言改动。
### T4 · A/B 验证(T2 必做;T1 以 2.1 的数字复测代替)
- 脚本 `frontend/scripts/bench-local-pg-roundtrips.ts`(需 Docker 测试库,复用 `test:db` 的 Postgres fixture 建库与种子,**只用虚构用户**)。固定查询序列,模拟一次首页启动 + 一轮咨询落库:
1. `authenticated` + 虚构身份:`profiles` 按 id 读 1 行、`chart_profiles` 列表、`chat_sessions` 列表(limit 40, order updated_at)、`chat_sessions` update 一行、一次带 `onConflict` 的 upsert、一次不带 `onConflict` 的 upsert;
2. `service_role`:一次常用 rpc(从现有 `database-*.test.ts` 已覆盖的 rpc 里挑一个只读或可回滚的)、一次 select。
- 每次调用包一层计数 `client.query` 的代理,记**往返次数**与**端到端耗时**;整串跑 50 遍为一组。
- A = 两开关 `off`;B = 两开关 `on`;另跑 B1 = 只开 T2a、B2 = 只开 T2b 以便归因。每组重复 **3 次取中位数**,先各热身 1 遍丢弃。
- 度量:每串往返总数、每串耗时中位数与 p95、单调用平均耗时;Postgres 侧 `pg_stat_statements`(若 fixture 已启用)或 `pg_stat_database.xact_commit` 差值作旁证。
- 正确性:A 与 B 每一步返回的 `{ data, error, count }` 做 JSON 序列化后**逐字节一致**(同一进程内先 A 后 B,库在两组之间按 fixture 重置);写进脚本输出。
- 判定门槛:往返数 B ≤ A 的 70%,**且**耗时中位数降 ≥ 15%,**且**逐字节一致,**且** test:db 两种开关下失败清单都只含已知 13 条 → 两开关默认 `on`;任何一项不满足 → 对应开关默认 `off`,在 PROGRESS 写原因。
- 记录写 `docs/testing/node-runtime-resources-ab-20261013.md`:环境(Windows + Docker Desktop 版本、Node 版本)、三组原始数字、中位数表、差分结果、默认值结论。
- Claude 验收:读 A/B 记录、复跑不需 Docker 的往返计数单测;真库数字以 Grok 为准(本机无 Docker,记环境缺口)。
### T6 · 用户端开启会话读缓存(BUG-1346,产品 10-14 决定,开关 + A/B)
- 现状:`frontend/src/modules/identity/auth-factory.ts` 用户端配置没有 `session.cookieCache`,每个带登录的请求 `getSession` 都查一次库(`readIdentitySession` / `createServerSupabaseClient` 路径)。
- 改法:
- 只对**用户端**(`cookiePrefix: "jyotisha-user"` 那份配置)加 `session.cookieCache = { enabled, maxAge: 300 }`;**后台(admin 端)配置不加**。
- 开关:服务端环境变量(例如 `IDENTITY_SESSION_COOKIE_CACHE`,名字执行方定,登记进 deploy 示例),A/B 后定默认值。
- **敏感操作必须绕过缓存**(每次真查库):改密码、注销账户 / 撤销注销、改邮箱、两步验证设置、任何后台接口、`account-deletion` 相关路由。开工 grep `getSession(`、`readIdentitySession`、`requireIdentityServerSession` 的全部调用点,列表进 PROGRESS;敏感点用 Better Auth 的 `disableCookieCache` 查询参数(以当前锁定版本的文档 / 类型为准,先确认参数名)。
- 账户删除检查(consult 等路由里的注销 RPC)与后台 `banned` 授权检查(`auth.ts` 查 `banned` / `ban_expires`)保持现状,不得因缓存跳过。
- 不改:会话有效期、`revokeSessionsOnPasswordReset`、Cookie 属性(`secure` / `httpOnly` / `sameSite`)、表结构。
- 验收:
- 新测试:开关开时,同一会话连续 3 次普通请求只查库 1 次;敏感路由每次都查库;开关关时与现在完全一致。
- 新测试:撤销会话(模拟改密码)后,敏感路由立刻 401;普通路由在 `maxAge` 内仍可用、超过后 401(用假时钟)。
- A/B 并入 T4:同一组带登录的请求序列,比较「每请求查库次数」与耗时中位数;门槛:查库次数下降且耗时不升 → 默认开;否则默认关并在 PROGRESS 写原因。
- Grok 跑 `test:db` 两种开关各一次,按已知失败清单比对。
- `docs/testing/node-runtime-resources-20261013.md` 写真机步骤:两台设备登录 → 一台改密码 → 另一台 5 分钟内仍可浏览、5 分钟后被要求重新登录、期间无法执行改密码 / 注销等敏感操作。
### T5 · 记录
- `docs/BUG_HISTORY.md` 新增 BUG-1326、1327、1328、1346(1346 写明产品 10-14 决定与「最多 300 秒」的取舍;1327 状态按 A/B 结论写;部署后的内存曲线未观测前 1326 写 `resolved(代码+回归测试),生产内存曲线待部署后观察`)。
- `docs/tasks/PROGRESS-node-runtime-resources-20261013.md`:含 T2c 评估、T3 语义变化说明、基线跑新测试的失败输出、A/B 结论。
- `deploy/README.md` 或 `.env` 示例里登记两个开关(只写名字与默认值,不写任何密钥);若 compose 文件需要透传环境变量,改 `deploy/docker-compose.server.yml` 前先在回报里说明(不改 workflow)。
- `CHANGELOG.md`:一句「服务器长期运行不再逐日涨内存;数据库访问少走几趟」。Skill 版本不 bump。`frontend/DESIGN.md` 不改。
## 7. 让步顺序
T1(P0,必须交)→ T3 → T6 → T2a → T2b → T4。T6 是产品决定,不得砍。时间不够时:先砍 T2b(保留开关骨架与单测也可以不交),再砍 T2a;T2 一旦交付就必须带 T4 的 A/B 与 test:db 比对,否则开关默认 `off`。T1 不得因任何原因延后。
## 8. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | `./node_modules/.bin/tsc --noEmit`;`npm run lint`(0 error);`npx tsx --test` 跑本单新增 / 改到的测试单文件(`personal-report-worker-sleep-listener`、`personal-report-worker`、`local-postgres-roundtrips`、`local-postgres-not/or/order/query-value/pool-close`、`request-rate-limit`、`delivery-turn-guard-expiry`、`rescore-attempts-bound`、`rectification-delivery-ui-simplify-20260908`、`rectification-stale-compare-fix-20260907`、`rectification-answer-choice`、`ephemeris-route`、`subject-route-birth`),看 fail 与 cancelled 都为 0;**`npm run test:db` 两种开关各一次,按 `tools/agent-loop/windows-known-failures.txt` 逐名比对**;T4 A/B 脚本。前端全量 `npm test`、Python 快速门、`next build` 不跑 |
| Claude 验收 | 上述单文件全部复验 + 前端全量 `npm test` 与基线逐名比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%,本单不应有前端包变化)+ 泄漏脚本复测;`test:db` 本机无 Docker,记环境缺口,以 Grok 结果为准 |
## 9. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/node-runtime-resources-20261013 .worktrees/node-runtime-resources-20261013 origin/staging
cd .worktrees/node-runtime-resources-20261013
git log -1 --format=%h # 应为 77057a819 或其后代
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号;1326~1328 若被占用,顺延并在 PROGRESS 写明
grep -rn 'addEventListener("abort"' frontend/src | grep -v test # 应与 2.1 表一致(18 处)
grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests
cat tools/agent-loop/windows-known-failures.txt
```
## 10. 交付物清单
- 代码:`personal-report-worker-core.ts`、`birth-time-guided-polling.ts`、`db/local-postgres-client-core.ts`、`delivery-turn-guard.ts`、`block-scan-answer.ts`、`request-rate-limit.ts`
- 测试:§6 列出的新增 / 扩展测试
- 脚本:`frontend/scripts/bench-local-pg-roundtrips.ts`
- 文档:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1326~1328)、`CHANGELOG.md`;`frontend/DESIGN.md` 不改
## 11. 本单不做
- 只读无身份调用省事务(T2c 已论证不安全)。
- 把 `begin` 拼进多语句简单查询(需要手工转义字面量,收益一次往返,不值得冒注入风险)。
- `createServerSupabaseClient` 每次调用都读一次身份会话的往返:T6 开缓存后自然减少,不另做。
## 附录 · 2.1 复现脚本(`leak-fixed.mjs`,与源码同形,可放任意临时目录)
```js
import { getEventListeners } from "node:events";
const keep = setInterval(() => {}, 100000);
const FIXED = process.argv[2] === "fixed";
const N = Number(process.argv[3] ?? 43200);
async function sleepOld(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true }); }); }
async function sleepNew(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const onAbort = () => { clearTimeout(t); resolve(); }; const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); t.unref?.(); signal?.addEventListener("abort", onAbort, { once: true }); }); }
const sleep = FIXED ? sleepNew : sleepOld;
const c = new AbortController();
global.gc(); const b = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) await sleep(0, c.signal);
global.gc(); const a = process.memoryUsage().heapUsed;
console.log(FIXED ? "fixed" : "baseline", process.version, "sleeps", N, "listeners", getEventListeners(c.signal, "abort").length, "heapMB", ((a - b) / 1048576).toFixed(2));
clearInterval(keep);
```
运行:`node --expose-gc leak-fixed.mjs [fixed] [次数]`(43,200 次约 45 秒,`setTimeout(0)` 每次约 1 ms)。