- 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
266 lines
28 KiB
Markdown
266 lines
28 KiB
Markdown
# 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)。
|