# 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 ` → `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` | `caseId:askedTurnId:证据指纹` | 从不(只有测试用 `resetStaleMinuteRescoreAttemptsForTests`) | 语义是「同一证据指纹只重算一次」(BUG-578),每道题 / 每次证据变化一条,永久累积 | | `delivery-turn-guard.ts` 模块级 `delivered: Map` | caseId | 从不(只有 `resetDeliveryTurnGuardForTests`) | 所有读取方都先判 `now - at <= 60_000`,超过 60 s 的条目已无任何作用(BUG-596) | | `request-rate-limit.ts` 全局 `jyotishaRequestRateLimits: Map` | `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`; - 主键列:键 `连接串 | 表名`; - 函数签名:键 `连接串 | 角色 | 函数名 | 排序后的参数名`(`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 ` 与两条 `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)。