From 4c3485bc2352e8f9de097d81d9979092ee2bb280 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 14 Oct 2026 03:32:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=95=BF=E8=B7=91?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=A0=86=E7=A7=AF=E5=8F=96=E6=B6=88=E7=9B=91?= =?UTF-8?q?=E5=90=AC=EF=BC=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B0=91=E8=B5=B0?= =?UTF-8?q?=E5=87=A0=E8=B6=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报告工人的等待到点就摘掉取消监听。本地数据库缓存表结构,并把设置身份合成一条语句,对照通过后默认打开,写成 off 仍走原来的语句。交付、重算和限流的内存表会过期。登录读缓存默认关闭,要开再设 IDENTITY_SESSION_COOKIE_CACHE=on。 自测:tsc 通过,lint 0 error,点名测试 134 通过,test:db 开关关和开都只有已知的 13 条失败,往返 49 降到 32。 Co-Authored-By: Grok (agent loop) --- CHANGELOG.md | 4 + deploy/.env.staging.identity.example | 8 + deploy/README.md | 2 + docs/BUG_HISTORY.md | 60 ++++ ...ROGRESS-node-runtime-resources-20261013.md | 59 ++++ docs/tasks/README.md | 2 +- .../node-runtime-resources-20261013.md | 13 + .../node-runtime-resources-ab-20261013.md | 21 ++ frontend/scripts/bench-local-pg-roundtrips.ts | 224 ++++++++++++++ .../src/app/api/account/deletion/route.ts | 2 +- .../src/app/api/account/password/route.ts | 5 +- frontend/src/lib/admin/auth.ts | 1 + frontend/src/lib/birth-time-guided-polling.ts | 10 +- .../src/lib/db/local-postgres-client-core.ts | 212 ++++++++++++- .../src/lib/personal-report-worker-core.ts | 14 +- .../v9/block-scan-answer.ts | 40 ++- .../v9/delivery-turn-guard.ts | 21 ++ frontend/src/lib/request-rate-limit.ts | 27 ++ frontend/src/lib/supabase/server.ts | 5 +- frontend/src/modules/identity/auth-factory.ts | 22 +- frontend/src/modules/identity/session.ts | 21 +- ...abase-local-postgres-session-setup.test.ts | 64 ++++ .../tests/delivery-turn-guard-expiry.test.ts | 61 ++++ .../identity-session-cookie-cache.test.ts | 258 ++++++++++++++++ .../tests/local-postgres-roundtrips.test.ts | 286 ++++++++++++++++++ ...sonal-report-worker-sleep-listener.test.ts | 109 +++++++ frontend/tests/request-rate-limit.test.ts | 66 +++- frontend/tests/rescore-attempts-bound.test.ts | 31 ++ 28 files changed, 1614 insertions(+), 34 deletions(-) create mode 100644 docs/tasks/PROGRESS-node-runtime-resources-20261013.md create mode 100644 docs/testing/node-runtime-resources-20261013.md create mode 100644 docs/testing/node-runtime-resources-ab-20261013.md create mode 100644 frontend/scripts/bench-local-pg-roundtrips.ts create mode 100644 frontend/tests/database-local-postgres-session-setup.test.ts create mode 100644 frontend/tests/delivery-turn-guard-expiry.test.ts create mode 100644 frontend/tests/identity-session-cookie-cache.test.ts create mode 100644 frontend/tests/local-postgres-roundtrips.test.ts create mode 100644 frontend/tests/personal-report-worker-sleep-listener.test.ts create mode 100644 frontend/tests/rescore-attempts-bound.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2056bfc..a5d60b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging) + +- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。 + ## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging) - 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。 diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index 037b55c6..5b452e19 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -31,3 +31,11 @@ JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= # these server-side values through env_file; they are not NEXT_PUBLIC values. PERSONAL_REPORT_ENABLED=true PERSONAL_REPORT_DAILY_LIMIT=5 + +# Local Postgres adapter. Unset or on enables both. Exact off restores the +# previous per-statement sequence. Measured default is on (2026-10-14). +JYOTISHA_PG_METADATA_CACHE=on +JYOTISHA_PG_MERGED_SESSION_SETUP=on +# Ordinary session reads stay uncached unless this is exactly on. Password +# change, account deletion, and admin routes bypass the cache. maxAge is 300s. +#IDENTITY_SESSION_COOKIE_CACHE=off diff --git a/deploy/README.md b/deploy/README.md index b2966ce0..98ad21b9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -194,6 +194,8 @@ SITE_ADDRESS=https://staging.jyotisha.chat Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation. +The web service already loads its env file, so these server-only switches do not need a Compose change. `JYOTISHA_PG_METADATA_CACHE` and `JYOTISHA_PG_MERGED_SESSION_SETUP` default to on when unset; set either to `off` to restore the previous database roundtrip sequence. `IDENTITY_SESSION_COOKIE_CACHE` defaults to off; set it to `on` to cache ordinary user-session reads for 300 seconds. Names and defaults are also in `deploy/.env.staging.identity.example`. Do not put secrets in these three values. + After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful push gate for the exact SHA and must use the staging workflow ref. ### First-deploy sequence diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index add22027..ad0eaff1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17795,3 +17795,63 @@ - 复发自:无。 - 修复版本:未修复。 +## BUG-1326 | 个人报告工人空转时,取消监听一直留在进程上 + +- 状态:resolved(代码+回归测试),生产内存曲线待部署后观察 +- 首次发现 / 最近更新:2026-10-13 / 2026-10-14 +- 来源:issue #58。任务书 T1。 +- 现象:报告工人长时间没有活干时,进程内存逐日上涨。取消这次工人时,之前每一次等待都还挂着一个取消监听。 +- 触发条件:工人循环用进程级取消信号,两次领任务之间按间隔等待。等待到点之后监听不摘。 +- 根因:等待用了「只触发一次」的取消监听。这个选项只在真的取消时摘掉监听。计时器自己到点时,监听还留在信号上。信号活得和进程一样长,监听就一直积着。 +- 修复:计时器到点时先摘掉监听再结束等待;真的取消时清掉计时器并结束等待。已经取消的信号仍立刻返回。计时器仍不挡住进程退出。引导打分里同一写法的等待也改成到点摘监听。 +- 验证:`frontend/tests/personal-report-worker-sleep-listener.test.ts` 4 通过。空转和抛错各 200 次后,取消前监听数是 0,取消后循环马上结束。修之前的同形等待在第 200 次时留下 199 个监听(断言 `199 !== 0`)。 +- 防复发:上述测试锁住「200 次之后监听数为 0」。 +- 相关记录:BUG-1328。 +- 复发自:无。 +- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。 + +## BUG-1327 | 本地数据库每一问都重复查表结构和设置身份 + +- 状态:resolved(代码+回归测试)。2026-10-14 对照通过后,两个开关默认打开。 +- 首次发现 / 最近更新:2026-10-13 / 2026-10-14 +- 来源:issue #58。任务书 T2。 +- 现象:同一次页面操作里,本地数据库要来回很多趟。表有哪些列、主键是什么、函数怎么调用,每次都现查。设置身份也要单独走一趟。 +- 触发条件:网页通过本地 Postgres 适配层读写真业务表。 +- 根因:元数据没有进程内缓存。设置角色和两条身份参数各是一条语句。事务的开始和提交仍各走一趟,这是故意留下的。 +- 修复:`JYOTISHA_PG_METADATA_CACHE` 缓存列类型、主键和函数签名,300 秒过期;缓存键带连接串,列和函数再带角色。不缓存空结果,也不缓存「函数不存在」。业务语句报列、表、函数或类型不匹配时,清掉该连接串的全部元数据并照旧返回错误,不自动重试。关掉某个连接或全部连接时,对应元数据一起清掉。`JYOTISHA_PG_MERGED_SESSION_SETUP` 把设置角色和两条身份合成一条参数化语句。角色仍只允许既有的两个业务角色。开始和提交不并进这条语句。两个开关都写成 `off` 时,语句顺序与改前一致。未设置或 `on` 为打开。只读且没有身份时省掉事务的做法已否决:那会把角色留在连接上,还回池子后可能给下一个请求。 +- 验证:不连数据库的往返单测锁住关闭时 6 条、打开且命中缓存时 4 条。真库测试确认事务内身份生效,连接归还后再借出时身份已空。对照脚本第二次(带三组原始数)整段往返 49 降到 32,耗时中位数 75.84 毫秒降到 36.48 毫秒,返回内容逐字节一致。第一次同样是 49 降到 32。`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 Windows 已知清单逐条相同,没有多出来的。 +- 防复发:往返单测锁住关闭时的语句顺序,以及角色、参数名、报错、过期和关闭连接都会让缓存失效。 +- 相关记录:BUG-166、BUG-578、BUG-596、BUG-926。 +- 复发自:无。 +- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。 + +## BUG-1328 | 三张只进不出的内存表会随运行变大 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-13 / 2026-10-14 +- 来源:issue #58。任务书 T3。 +- 现象:校正交付去重、重算尝试和请求限流各有一张进程内的表。旧条目读的时候已经不算数,却不删除,进程活得越久表越大。 +- 触发条件:长时间有人做校正或调用限流接口。 +- 根因:写入只增加,没有过期清理,也没有条数上限。 +- 修复:交付记录超过 60 秒就删,最多留 1 万条。重算记录改存时间,超过 6 小时就删,最多留 5 千条。限流按本进程见过的最大窗口定期删掉已经出窗的键;1 小时窗口的键不会在 61 秒时被删。限流的对外类型不变,调用方仍可传入自己的表。 +- 验证:交付、重算、限流的新测试通过。窗口内的允许或拒绝与原来的断言一致,没有改旧断言。同一道校正题在 6 小时内仍不会重算第二次;超过 6 小时或被挤出后可能再算一次,这比「进程一重启就可能再算」更收敛。 +- 防复发:硬上限和过期都有测试。1 小时窗口的键在短窗口清理后必须还在。 +- 相关记录:BUG-1326。 +- 复发自:无。 +- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。 + +## BUG-1346 | 每次带登录的请求都重新读一次会话 + +- 状态:resolved(代码+回归测试)。开关默认关闭:查库次数已下降,真实身份库的耗时对照还没做,未达到默认打开的门槛。 +- 首次发现 / 最近更新:2026-10-14 / 2026-10-14 +- 来源:issue #58。产品负责人 2026-10-14 决定可以开。任务书 T6。 +- 现象:每个已登录请求都到身份库读一次会话。 +- 触发条件:用户端 Better Auth 配置没有会话读缓存。后台没有另一份登录配置。 +- 根因:用户端会话配置未启用 `cookieCache`。 +- 修复:只有环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 精确等于 `on` 时,用户端会话才缓存 300 秒。未设置或其它值与现在一样,每次读库。改密码、注销账户、以及全部后台入口每次都跳过缓存。改邮箱、两步验证和后台插件自带的敏感入口本来就会重新读库,这次没有改它们。咨询里的注销检查和后台的封禁检查仍每次查库。会话能活多久、改密码后是否作废、Cookie 的安全属性、表结构都没改。代价:缓存打开后,一台设备改了密码,其它设备上的旧登录最多还能再浏览 300 秒,这段时间做不了改密码和注销。 +- 验证:`frontend/tests/identity-session-cookie-cache.test.ts` 4 通过。缓存打开时连续 3 次普通读取只查会话 1 次,敏感读取每次都查;缓存关闭时 3 次都查。会话被清掉后,敏感读取立即无效,普通读取在 300 秒内仍有效,超过后无效。 +- 防复发:上述测试锁住开关关闭时不出现缓存配置,以及 300 秒后普通读取失效。 +- 相关记录:BUG-1327。 +- 复发自:无。 +- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。 + diff --git a/docs/tasks/PROGRESS-node-runtime-resources-20261013.md b/docs/tasks/PROGRESS-node-runtime-resources-20261013.md new file mode 100644 index 00000000..0ba46761 --- /dev/null +++ b/docs/tasks/PROGRESS-node-runtime-resources-20261013.md @@ -0,0 +1,59 @@ +# 进度 · 服务器内存与数据库往返(2026-10-13) + +issue #58。分支 `codex/node-runtime-resources-20261013`。 + +任务书基线写的是 `77057a819`,计划评论里的基线是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。本分支实际从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。没有因此停下来问产品,改动仍按任务书做。 + +## 做了什么 + +- 报告工人的等待:计时器到点就摘掉取消监听。引导打分里同一段等待也摘掉。空转不再往进程上堆监听。 +- 本地数据库:列、主键、函数签名缓存 300 秒;设置角色和两条身份合成一条语句。开始和提交仍分开。两个开关默认打开,写成 `off` 时语句顺序与改前一致。 +- 三张内存表:交付记录超过 60 秒删除,最多 1 万条;重算记录超过 6 小时删除,最多 5 千条;限流按见过的最大窗口清理已出窗的键。 +- 登录读缓存:只有 `IDENTITY_SESSION_COOKIE_CACHE=on` 才缓存 300 秒。默认关闭。 + +## 不做的 + +只读、又没有身份时省掉事务:不做。那种做法只能用会话级角色,归还连接前再清。往返数不降,而且一旦没清掉,下一次请求会带着上一次的角色。违反「连接还回池子时不能带着角色或身份」。 + +`begin` 没有并进一条多语句查询。 + +## 语义变化 + +同一道校正题、同一份证据,6 小时后或因为超过 5 千条被挤出后,可能再重算一次。这比「进程重启后才会再算」更收敛。60 秒内的交付去重、窗口内的限流允许或拒绝,都没有改。 + +## 基线失败 + +修之前的等待写法,在第 200 次空转时取消信号上还有 199 个监听。同形脚本输出:`listenersAt200=199`,`assertion failed: 199 !== 0`。修之后的测试断言是 0,并且通过。 + +## 登录读取的调用点 + +敏感,每次跳过缓存: + +- 改密码:`frontend/src/app/api/account/password/route.ts` +- 注销账户:`frontend/src/app/api/account/deletion/route.ts` +- 全部后台入口:`frontend/src/lib/admin/auth.ts` + +其余业务接口走 `createServerSupabaseClient()`,不带跳过参数。开关打开后,这些普通读取可以走 300 秒缓存。 + +改邮箱、两步验证、后台插件自带的敏感入口本来就会重新读库,这次没改。咨询里检查「正在注销」的那次调用,以及后台查封禁的语句,都没有改成缓存。 + +## 对照结论 + +详见 `docs/testing/node-runtime-resources-ab-20261013.md`。两个数据库开关一起:往返 49 → 32(低于 70%),耗时中位数 75.84 → 36.48 毫秒(下降 51.9%),返回内容一致。`npm run test:db` 开关关、开关开各一轮,都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条相同,没有多出来的。运行器因此退出码是 1,这 13 条是 Windows 上原有的。两个数据库开关默认打开。 + +登录读缓存的查库次数在测试里从 3 降到 1,但没有对真实身份库做耗时对照,默认保持关闭。要打开,把 `IDENTITY_SESSION_COOKIE_CACHE` 设成 `on`。 + +网页容器已经用 env 文件读环境变量,所以这三个名字不必改 Compose。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 | +| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 | +| 本单测试 | `tsx --test` 任务书点名的文件加登录缓存测试 | 134 通过,0 失败,0 取消 | +| 真库身份 | `database-local-postgres-session-setup.test.ts` | 1 通过。事务内角色和身份生效,连接归还后再借出时身份已空 | +| 数据库套件 | `npm run test:db`,两开关先关后开 | 各 81 / 通过 68 / 失败 13 / 取消 0,名称与已知清单一致 | +| 往返对照 | `scripts/bench-local-pg-roundtrips.ts` | 见上,`payloadMismatch` false | + +没有跑前端全量 `npm test`、Python 快速门、`next build`。生产内存曲线要等部署后看。真机两台设备改密码的步骤在 `docs/testing/node-runtime-resources-20261013.md`,登录缓存默认关着,那份清单的第 2 步要先把开关打开才看得到。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 58b326a6..ac1ce4fb 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -468,7 +468,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待领取 | `codex/rect-scoring-perf-20261013` | -| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` | +| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` | | `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` | | `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` | | `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` | diff --git a/docs/testing/node-runtime-resources-20261013.md b/docs/testing/node-runtime-resources-20261013.md new file mode 100644 index 00000000..f7c2a9c5 --- /dev/null +++ b/docs/testing/node-runtime-resources-20261013.md @@ -0,0 +1,13 @@ +# 真机清单 · 登录缓存与长时间运行(2026-10-13) + +对应 BUG-1326、BUG-1346。在 staging 上用两个浏览器(或一台电脑、一台手机)登录同一个测试账号。不要把账号、口令或页面原文记进仓库。 + +本轮代码里,登录读缓存默认关闭。要做下面第 2–4 步,服务器环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 必须是 `on`。未设置或其它值时,改密码后另一台设备会马上要求重新登录,第 2 步不会出现「还能浏览大约 5 分钟」。 + +| 步 | 怎么做 | 通过标准 | +| --- | --- | --- | +| 1 | 两台设备都登录,各自打开首页并翻一两页 | 都能正常看到自己的会话,不要求反复登录 | +| 2 | 设备 A 修改密码。5 分钟内用设备 B 只浏览首页、会话列表和星盘,不要改密码、不要注销 | 设备 B 在这 5 分钟里仍能浏览 | +| 3 | 仍在这 5 分钟内,用设备 B 尝试修改密码或申请注销账户 | 被拒绝,不能拿旧登录做这两件事 | +| 4 | 等到改密码已超过 5 分钟,再用设备 B 刷新 | 被要求重新登录 | +| 5 | 用新密码在设备 B 重新登录,再浏览一次 | 能进入,会话列表还在 | diff --git a/docs/testing/node-runtime-resources-ab-20261013.md b/docs/testing/node-runtime-resources-ab-20261013.md new file mode 100644 index 00000000..22d59c80 --- /dev/null +++ b/docs/testing/node-runtime-resources-ab-20261013.md @@ -0,0 +1,21 @@ +# 本地数据库往返对照(2026-10-14) + +环境:Windows,Docker 29.8.0,Node v22.23.2。脚本 `frontend/scripts/bench-local-pg-roundtrips.ts`。库是测试用的临时 Postgres,数据是虚构用户。每组 50 遍,重复 3 组,先各热身 1 遍。往返数是每组平均值的中位数。耗时中位数是三组中位数的中位数。p95 是全部 150 遍的单遍耗时。 + +判定用第二次运行(输出里带三组原始数)。第一次运行同样是 49 降到 32,中位数 89.51 毫秒降到 32.70 毫秒,内容一致,方向相同。 + +| 模式 | 开关 | 往返 | 中位数(毫秒) | p95(毫秒) | 三组(往返 / 中位数毫秒) | +| --- | --- | --- | --- | --- | --- | +| A | 两个都关 | 49 | 75.84 | 88.25 | 49 / 82.51;49 / 75.84;49 / 73.81 | +| B | 两个都开 | 32 | 36.48 | 39.47 | 32 / 36.66;32 / 36.48;32 / 35.71 | +| B1 | 只开元数据缓存 | 40 | 40.54 | 44.37 | 40 / 41.56;40 / 40.54;40 / 39.86 | +| B2 | 只开合并身份设置 | 41 | 67.83 | 72.75 | 41 / 68.46;41 / 66.47;41 / 67.83 | + +- 往返:32 ≤ 49 的 70%(34.3)。通过。 +- 耗时:下降 (75.84 − 36.48) / 75.84 = 51.9%,不低于 15%。通过。 +- 四种模式的业务返回逐字节一致(`payloadMismatch` 为 false)。通过。 +- 只开其中一个时,往返都还高于 70% 那一刀;只合并身份设置时,这一次耗时只降了约 10.6%。两个一起才过门槛,所以两个都默认打开。写成 `off` 仍走改前的语句顺序。 + +登录读缓存没有放进这次真库计时。内存里的登录测试看到普通读取的查库次数从 3 降到 1,但那不是身份库的耗时对照,所以 `IDENTITY_SESSION_COOKIE_CACHE` 保持默认关闭。 + +`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条逐条相同,没有多出来的。因此两个数据库开关默认打开。 diff --git a/frontend/scripts/bench-local-pg-roundtrips.ts b/frontend/scripts/bench-local-pg-roundtrips.ts new file mode 100644 index 00000000..51746bcd --- /dev/null +++ b/frontend/scripts/bench-local-pg-roundtrips.ts @@ -0,0 +1,224 @@ +/** + * Count local-Postgres adapter roundtrips for a fictional homepage plus consult write. + * Run from frontend/: npx tsx scripts/bench-local-pg-roundtrips.ts + * Prints JSON. Does not print connection strings. + */ +import { spawnSync } from "node:child_process"; + +import type { Pool, PoolClient } from "pg"; + +import { + closeLocalPostgresDataPools, + clearLocalPostgresMetadataCacheForTests, + createLocalPostgresDataClient, + localPostgresPoolForTests, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts"; + +const userId = "018f4e6d-7a11-7000-8000-0000000000bb"; +const sessionId = "018f4e6d-7a11-7000-8000-0000000000cc"; +const chartId = "018f4e6d-7a11-7000-8000-0000000000dd"; + +const schemaSql = ` +create table if not exists public.profiles ( + id uuid primary key, + display_name text +); +create table if not exists public.chart_profiles ( + id uuid primary key, + user_id uuid not null, + label text, + updated_at timestamptz not null default now() +); +create table if not exists public.chat_sessions ( + id uuid primary key, + user_id uuid not null, + title text, + updated_at timestamptz not null default now() +); +grant usage on schema public to authenticated, service_role; +grant select, insert, update, delete on public.profiles, public.chart_profiles, public.chat_sessions to authenticated, service_role; +insert into public.profiles (id, display_name) values ('${userId}', 'Fictional Person') + on conflict (id) do nothing; +insert into public.chart_profiles (id, user_id, label) values ('${chartId}', '${userId}', 'home') + on conflict (id) do nothing; +insert into public.chat_sessions (id, user_id, title) values ('${sessionId}', '${userId}', 'consult') + on conflict (id) do nothing; +create or replace function public.session_probe() +returns table(db_user text) +language sql stable +as $$ select current_user::text $$; +grant execute on function public.session_probe() to service_role, authenticated; +`; + +type Mode = { cache: "on" | "off"; merged: "on" | "off" }; + +function percentile(values: number[], ratio: number): number { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1)); + return sorted[index] ?? 0; +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)] ?? 0; +} + +const benchCounter = { roundtrips: 0 }; +const queryWrapped = new WeakSet(); + +async function withCountingPools( + urls: readonly string[], + run: () => Promise, +): Promise<{ roundtrips: number; elapsedMs: number; payload: string }> { + const restores: Array<() => void> = []; + benchCounter.roundtrips = 0; + for (const url of urls) { + const pool = localPostgresPoolForTests(url) as Pool & { + connect: () => Promise; + }; + const original = pool.connect.bind(pool); + pool.connect = async () => { + const client = await original(); + if (!queryWrapped.has(client)) { + queryWrapped.add(client); + const query = client.query.bind(client) as (input: unknown, values?: unknown) => Promise; + client.query = ((input: unknown, values?: unknown) => { + benchCounter.roundtrips += 1; + return values === undefined ? query(input) : query(input, values); + }) as PoolClient["query"]; + } + return client; + }; + restores.push(() => { + pool.connect = original; + }); + } + const started = performance.now(); + try { + const payload = await run(); + return { + roundtrips: benchCounter.roundtrips, + elapsedMs: performance.now() - started, + payload: JSON.stringify(payload), + }; + } finally { + for (const restore of restores) restore(); + } +} + +async function sequence(url: string, serviceUrl: string): Promise { + const user = createLocalPostgresDataClient(url, { id: userId, email: "person@example.com" }, "authenticated"); + const service = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + const profile = await user.from("profiles").select("id,display_name").eq("id", userId).maybeSingle(); + const charts = await user.from("chart_profiles").select("id,label").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40); + const sessions = await user.from("chat_sessions").select("id,title").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40); + const updated = await user.from("chat_sessions").update({ title: "consult-bench" }).eq("id", sessionId); + const upsertConflict = await user.from("chat_sessions").upsert( + { id: sessionId, user_id: userId, title: "consult-bench" }, + { onConflict: "id" }, + ); + const upsertKey = await user.from("chat_sessions").upsert({ id: sessionId, user_id: userId, title: "consult-bench" }); + const rpc = await service.rpc("session_probe"); + const serviceRead = await service.from("profiles").select("id").eq("id", userId).maybeSingle(); + return [profile, charts, sessions, updated, upsertConflict, upsertKey, rpc, serviceRead].map((result) => ({ + data: result.data ?? null, + error: result.error ?? null, + count: "count" in result ? result.count ?? null : null, + })); +} + +async function measure(url: string, serviceUrl: string, mode: Mode): Promise<{ + roundtrips: number; + medianMs: number; + p95Ms: number; + payload: string; + groups: Array<{ roundtrips: number; medianMs: number }>; +}> { + process.env.JYOTISHA_PG_METADATA_CACHE = mode.cache; + process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = mode.merged; + clearLocalPostgresMetadataCacheForTests(); + await sequence(url, serviceUrl); + const groupMedians: number[] = []; + const groupRoundtrips: number[] = []; + const allTimes: number[] = []; + let payload = ""; + for (let index = 0; index < 3; index += 1) { + const group: number[] = []; + let roundtrips = 0; + for (let repeat = 0; repeat < 50; repeat += 1) { + const sample = await withCountingPools([url, serviceUrl], () => sequence(url, serviceUrl)); + group.push(sample.elapsedMs); + allTimes.push(sample.elapsedMs); + roundtrips += sample.roundtrips; + payload = sample.payload; + } + groupMedians.push(median(group)); + groupRoundtrips.push(Math.round(roundtrips / 50)); + } + return { + roundtrips: median(groupRoundtrips), + medianMs: median(groupMedians), + p95Ms: percentile(allTimes, 0.95), + payload, + groups: groupRoundtrips.map((roundtrips, index) => ({ + roundtrips, + medianMs: Number((groupMedians[index] ?? 0).toFixed(2)), + })), + }; +} + +async function main(): Promise { + const docker = spawnSync("docker", ["info"], { encoding: "utf8" }); + if (docker.status !== 0) { + console.log(JSON.stringify({ ok: false, reason: "docker-unavailable" })); + process.exit(2); + } + const fixture = startPostgresFixture(); + const appUrl = fixture.connectionUrl("app_runtime", "app-runtime-test-password"); + const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + try { + fixture.psqlScriptAs("postgres", "postgres-test-password", schemaSql); + const modes: Record = { + A: { cache: "off", merged: "off" }, + B: { cache: "on", merged: "on" }, + B1: { cache: "on", merged: "off" }, + B2: { cache: "off", merged: "on" }, + }; + const results: Record }> = {}; + let payloadA = ""; + let payloadMismatch = false; + for (const [name, mode] of Object.entries(modes)) { + const measured = await measure(appUrl, serviceUrl, mode); + results[name] = { + roundtrips: measured.roundtrips, + medianMs: Number(measured.medianMs.toFixed(2)), + p95Ms: Number(measured.p95Ms.toFixed(2)), + groups: measured.groups, + }; + if (name === "A") payloadA = measured.payload; + else if (measured.payload !== payloadA) payloadMismatch = true; + clearLocalPostgresMetadataCacheForTests(); + await closeLocalPostgresDataPools(); + } + console.log(JSON.stringify({ + ok: true, + repeats: 3, + repsPerGroup: 50, + payloadMismatch, + results, + node: process.version, + })); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + delete process.env.JYOTISHA_PG_METADATA_CACHE; + delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP; + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "bench failed"; + console.log(JSON.stringify({ ok: false, reason: message })); + process.exit(1); +}); diff --git a/frontend/src/app/api/account/deletion/route.ts b/frontend/src/app/api/account/deletion/route.ts index 5fba4ad0..db955326 100644 --- a/frontend/src/app/api/account/deletion/route.ts +++ b/frontend/src/app/api/account/deletion/route.ts @@ -21,7 +21,7 @@ export const dynamic = "force-dynamic"; */ async function currentUserId(): Promise { - const supabase = await createServerSupabaseClient(); + const supabase = await createServerSupabaseClient({ disableCookieCache: true }); const { data: { user }, error } = await supabase.auth.getUser(); return error || !user ? null : user.id; } diff --git a/frontend/src/app/api/account/password/route.ts b/frontend/src/app/api/account/password/route.ts index 8a6c9d13..f0a95207 100644 --- a/frontend/src/app/api/account/password/route.ts +++ b/frontend/src/app/api/account/password/route.ts @@ -14,7 +14,10 @@ async function userSession(request: Request) { return null; } const services = getIdentityAuthServices(); - const session = await services.user.api.getSession({ headers: request.headers }); + const session = await services.user.api.getSession({ + headers: request.headers, + query: { disableCookieCache: true }, + }); return session ? { services, session } : null; } diff --git a/frontend/src/lib/admin/auth.ts b/frontend/src/lib/admin/auth.ts index a0363ac2..9d68abfd 100644 --- a/frontend/src/lib/admin/auth.ts +++ b/frontend/src/lib/admin/auth.ts @@ -60,6 +60,7 @@ export async function requirePermission( requireIdentityServerSession( getIdentityAuthServices().user.api, adminHeaders, + { disableCookieCache: true }, ), loadAdminSession, }); diff --git a/frontend/src/lib/birth-time-guided-polling.ts b/frontend/src/lib/birth-time-guided-polling.ts index 891ac002..7d160902 100644 --- a/frontend/src/lib/birth-time-guided-polling.ts +++ b/frontend/src/lib/birth-time-guided-polling.ts @@ -34,10 +34,14 @@ export async function runBirthTimeScoringPoll(input: PollInput): Promise { const duration = Math.min(400 * (2 ** attempt), 3_200); return new Promise((resolve) => { - const timer = window.setTimeout(resolve, duration); - signal.addEventListener("abort", () => { + const onAbort = () => { window.clearTimeout(timer); resolve(); - }, { once: true }); + }; + const timer = window.setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, duration); + signal.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index 9dd83eef..a0c2b5a9 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -169,7 +169,18 @@ export function upsertConflictColumns(options?: { onConflict?: string }): string .filter(Boolean); } -async function primaryKeyColumns(client: PoolClient, table: string): Promise { +async function primaryKeyColumns( + client: PoolClient, + table: string, + scope: MetadataScope, +): Promise { + const cacheKey = metadataCacheEnabled() + ? `${scope.connectionString}|${table}` + : null; + if (cacheKey) { + const cached = readTimed(metadataCache().primaryKeys, cacheKey); + if (cached) return [...cached]; + } identifier(table); const result = await client.query<{ column_name: string }>( ` @@ -182,7 +193,9 @@ async function primaryKeyColumns(client: PoolClient, table: string): Promise row.column_name); + const columns = result.rows.map((row) => row.column_name); + if (cacheKey && columns.length > 0) writeTimed(metadataCache().primaryKeys, cacheKey, [...columns]); + return columns; } function queryError(error: unknown): QueryError { @@ -220,7 +233,115 @@ function localDataPool(connectionString: string): Pool { return pool; } +const METADATA_TTL_MS = 300_000; +const METADATA_INVALIDATING_CODES = new Set(["42703", "42P01", "42883", "42804"]); + +type MetadataScope = Readonly<{ + connectionString: string; + role: LocalDatabaseRole | null; +}>; + +type TimedCacheEntry = Readonly<{ value: T; storedAt: number }>; + +type LocalPostgresMetadataCache = { + columns: Map>>; + primaryKeys: Map>; + functions: Map>; +}; + +const metadataGlobal = globalThis as typeof globalThis & { + jyotishaLocalPostgresMetadata?: LocalPostgresMetadataCache; + jyotishaLocalPostgresMetadataNow?: () => number; +}; + +function metadataCache(): LocalPostgresMetadataCache { + metadataGlobal.jyotishaLocalPostgresMetadata ??= { + columns: new Map(), + primaryKeys: new Map(), + functions: new Map(), + }; + return metadataGlobal.jyotishaLocalPostgresMetadata; +} + +function metadataNow(): number { + return metadataGlobal.jyotishaLocalPostgresMetadataNow?.() ?? Date.now(); +} + +function metadataCacheEnabled(): boolean { + // 2026-10-14 A/B passed. Unset means on. Exact "off" keeps the old query sequence. + return process.env.JYOTISHA_PG_METADATA_CACHE !== "off"; +} + +function mergedSessionSetupEnabled(): boolean { + return process.env.JYOTISHA_PG_MERGED_SESSION_SETUP !== "off"; +} + +function roleCacheKey(role: LocalDatabaseRole | null): string { + return role ?? "owner"; +} + +function readTimed( + store: Map>, + key: string, +): T | undefined { + const entry = store.get(key); + if (!entry) return undefined; + if (metadataNow() - entry.storedAt >= METADATA_TTL_MS) { + store.delete(key); + return undefined; + } + return entry.value; +} + +function writeTimed( + store: Map>, + key: string, + value: T, +): void { + store.set(key, { value, storedAt: metadataNow() }); +} + +function dropMetadataForConnection(connectionString: string): void { + const cache = metadataGlobal.jyotishaLocalPostgresMetadata; + if (!cache) return; + const prefix = `${connectionString}|`; + for (const store of [cache.columns, cache.primaryKeys, cache.functions]) { + for (const key of store.keys()) { + if (key.startsWith(prefix)) store.delete(key); + } + } +} + +function postgresErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +export function clearLocalPostgresMetadataCacheForTests(): void { + metadataGlobal.jyotishaLocalPostgresMetadata = { + columns: new Map(), + primaryKeys: new Map(), + functions: new Map(), + }; +} + +export function setLocalPostgresMetadataClockForTests(now: (() => number) | null): void { + if (now) metadataGlobal.jyotishaLocalPostgresMetadataNow = now; + else delete metadataGlobal.jyotishaLocalPostgresMetadataNow; +} + +export function installLocalPostgresPoolForTests(connectionString: string, pool: Pool): void { + poolGlobal.jyotishaLocalDataPools ??= new Map(); + poolGlobal.jyotishaLocalDataPools.set(connectionString, pool); +} + +export function localPostgresPoolForTests(connectionString: string): Pool { + return localDataPool(connectionString); +} + export async function closeLocalPostgresDataPool(connectionString: string): Promise { + dropMetadataForConnection(connectionString); const pools = poolGlobal.jyotishaLocalDataPools; const pool = pools?.get(connectionString); if (!pool) return; @@ -229,6 +350,11 @@ export async function closeLocalPostgresDataPool(connectionString: string): Prom } export async function closeLocalPostgresDataPools(): Promise { + metadataGlobal.jyotishaLocalPostgresMetadata = { + columns: new Map(), + primaryKeys: new Map(), + functions: new Map(), + }; const pools = poolGlobal.jyotishaLocalDataPools; poolGlobal.jyotishaLocalDataPools = new Map(); if (!pools) return; @@ -237,6 +363,7 @@ export async function closeLocalPostgresDataPools(): Promise { async function inBusinessTransaction( pool: Pool, + connectionString: string, identity: LocalIdentity, role: LocalDatabaseRole | null, run: (client: PoolClient) => Promise, @@ -244,15 +371,32 @@ async function inBusinessTransaction( const client = await pool.connect(); try { await client.query("begin"); - if (role !== null) await client.query(`set local role ${role}`); - await client.query( - "select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)", - [identity?.id ?? "", identity?.email ?? ""], - ); + if (mergedSessionSetupEnabled()) { + if (role !== null) { + await client.query( + "select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)", + [role, identity?.id ?? "", identity?.email ?? ""], + ); + } else { + await client.query( + "select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)", + [identity?.id ?? "", identity?.email ?? ""], + ); + } + } else { + if (role !== null) await client.query(`set local role ${role}`); + await client.query( + "select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)", + [identity?.id ?? "", identity?.email ?? ""], + ); + } const result = await run(client); await client.query("commit"); return result; } catch (error) { + if (METADATA_INVALIDATING_CODES.has(postgresErrorCode(error) ?? "")) { + dropMetadataForConnection(connectionString); + } await client.query("rollback").catch(() => undefined); throw error; } finally { @@ -263,7 +407,15 @@ async function inBusinessTransaction( async function columnTypes( client: PoolClient, table: string, + scope: MetadataScope, ): Promise> { + const cacheKey = metadataCacheEnabled() + ? `${scope.connectionString}|${roleCacheKey(scope.role)}|${table}` + : null; + if (cacheKey) { + const cached = readTimed(metadataCache().columns, cacheKey); + if (cached) return new Map(cached); + } const result = await client.query<{ column_name: string; udt_name: string }>( ` select column_name, udt_name @@ -272,7 +424,9 @@ async function columnTypes( `, [table], ); - return new Map(result.rows.map((row) => [row.column_name, row.udt_name])); + const types = new Map(result.rows.map((row) => [row.column_name, row.udt_name])); + if (cacheKey && types.size > 0) writeTimed(metadataCache().columns, cacheKey, new Map(types)); + return types; } function databaseValue(type: string | undefined, value: unknown): unknown { @@ -314,6 +468,7 @@ class LocalPostgresQueryBuilder implements PromiseLike { constructor( private readonly pool: Pool, + private readonly connectionString: string, private readonly identity: LocalIdentity, private readonly role: LocalDatabaseRole | null, private readonly table: string, @@ -516,10 +671,14 @@ class LocalPostgresQueryBuilder implements PromiseLike { try { return await inBusinessTransaction( this.pool, + this.connectionString, this.identity, this.role, async (client) => { - const types = await columnTypes(client, this.table); + const types = await columnTypes(client, this.table, { + connectionString: this.connectionString, + role: this.role, + }); const parameters: unknown[] = []; let sql: string; @@ -563,7 +722,10 @@ class LocalPostgresQueryBuilder implements PromiseLike { if (this.mutation.kind === "upsert") { let conflict = this.mutation.conflict; if (conflict.length === 0) { - conflict = await primaryKeyColumns(client, this.table); + conflict = await primaryKeyColumns(client, this.table, { + connectionString: this.connectionString, + role: this.role, + }); } if (conflict.length === 0) { throw new Error("upsert requires a conflict target"); @@ -648,7 +810,21 @@ async function functionMetadata( client: PoolClient, functionName: string, argumentNames: readonly string[], + scope: MetadataScope, ): Promise { + const cacheKey = metadataCacheEnabled() + ? `${scope.connectionString}|${roleCacheKey(scope.role)}|${functionName}|${[...argumentNames].sort().join(",")}` + : null; + if (cacheKey) { + const cached = readTimed(metadataCache().functions, cacheKey); + if (cached) { + return { + ...cached, + argument_names: cached.argument_names ? [...cached.argument_names] : null, + argument_types: [...cached.argument_types], + }; + } + } const result = await client.query( ` select @@ -671,6 +847,13 @@ async function functionMetadata( ); const metadata = result.rows[0]; if (!metadata) throw new Error("database function not found"); + if (cacheKey) { + writeTimed(metadataCache().functions, cacheKey, { + ...metadata, + argument_names: metadata.argument_names ? [...metadata.argument_names] : null, + argument_types: [...metadata.argument_types], + }); + } return metadata; } @@ -691,12 +874,14 @@ export class LocalPostgresDataClient { }>; private readonly pool: Pool; + private readonly connectionString: string; constructor( connectionString: string, private readonly identity: LocalIdentity, private readonly role: LocalDatabaseRole | null, ) { + this.connectionString = connectionString; if (role !== null && role !== "authenticated" && role !== "service_role") { throw new Error("unsupported database role"); } @@ -709,6 +894,7 @@ export class LocalPostgresDataClient { from(table: string) { return new LocalPostgresQueryBuilder( this.pool, + this.connectionString, this.identity, this.role, table, @@ -723,12 +909,16 @@ export class LocalPostgresDataClient { identifier(functionName); return await inBusinessTransaction( this.pool, + this.connectionString, this.identity, this.role, async (client) => { const names = Object.keys(args); names.forEach(identifier); - const metadata = await functionMetadata(client, functionName, names); + const metadata = await functionMetadata(client, functionName, names, { + connectionString: this.connectionString, + role: this.role, + }); const typeByName = new Map( (metadata.argument_names ?? []).map((name, index) => [ name, diff --git a/frontend/src/lib/personal-report-worker-core.ts b/frontend/src/lib/personal-report-worker-core.ts index 650f5128..47df7562 100644 --- a/frontend/src/lib/personal-report-worker-core.ts +++ b/frontend/src/lib/personal-report-worker-core.ts @@ -173,13 +173,17 @@ function defaultRetryDelayMs(job: PersonalReportJobRecord): number { async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise { if (signal?.aborted) return; await new Promise((resolve) => { - const timer = globalThis.setTimeout(resolve, milliseconds); - const candidate = timer as ReturnType & { unref?: () => void }; - candidate.unref?.(); - signal?.addEventListener("abort", () => { + const onAbort = () => { globalThis.clearTimeout(timer); resolve(); - }, { once: true }); + }; + const timer = globalThis.setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const candidate = timer as ReturnType & { unref?: () => void }; + candidate.unref?.(); + signal?.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts index 9a42f1c6..c5545a8b 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts @@ -188,12 +188,47 @@ export async function rescoreMinuteAfterWindowChange( export { persistBlockScanPayload } from "./score-persist.ts"; -const rescoreAttempts = new Map(); +const RESCORE_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000; +const RESCORE_ATTEMPT_CAP = 5_000; + +const rescoreAttempts = new Map(); export function resetStaleMinuteRescoreAttemptsForTests(): void { rescoreAttempts.clear(); } +function pruneRescoreAttempts(now: number): void { + for (const [key, at] of rescoreAttempts) { + if (now - at > RESCORE_ATTEMPT_TTL_MS) rescoreAttempts.delete(key); + } + while (rescoreAttempts.size > RESCORE_ATTEMPT_CAP) { + const oldest = rescoreAttempts.keys().next().value; + if (oldest === undefined) return; + rescoreAttempts.delete(oldest); + } +} + +/** Returns true when this key should be scored now. A fresh key is remembered. */ +function claimRescoreAttempt(key: string, now: number): boolean { + pruneRescoreAttempts(now); + const existing = rescoreAttempts.get(key); + if (existing !== undefined && now - existing <= RESCORE_ATTEMPT_TTL_MS) return false; + if (!rescoreAttempts.has(key) && rescoreAttempts.size >= RESCORE_ATTEMPT_CAP) { + const oldest = rescoreAttempts.keys().next().value; + if (oldest !== undefined) rescoreAttempts.delete(oldest); + } + rescoreAttempts.set(key, now); + return true; +} + +export function noteRescoreAttemptForTests(key: string, at: number): void { + claimRescoreAttempt(key, at); +} + +export function rescoreAttemptCountForTests(): number { + return rescoreAttempts.size; +} + function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string { return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`; } @@ -219,10 +254,9 @@ export async function rescoreStaleMinuteSnapshotIfNeeded(input: { } const fingerprint = evidenceLedgerFingerprint(dossier.evidence); const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId); - if (rescoreAttempts.has(key)) { + if (!claimRescoreAttempt(key, Date.now())) { return { dossier, snapshotCurrent: false, rescoreAttempted: false }; } - rescoreAttempts.set(key, true); try { await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId, { keepAnswers: true, diff --git a/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts b/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts index 0de07fb1..93ce9d54 100644 --- a/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts +++ b/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts @@ -6,11 +6,30 @@ */ const WINDOW_MS = 60_000; +const DELIVERY_MARK_CAP = 10_000; type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>; const delivered = new Map(); +function pruneDelivered(now: number): void { + for (const [caseId, mark] of delivered) { + if (now - mark.at > WINDOW_MS) delivered.delete(caseId); + } +} + +function makeRoomForDeliveryMark(): void { + while (delivered.size >= DELIVERY_MARK_CAP) { + const oldest = delivered.keys().next().value; + if (oldest === undefined) return; + delivered.delete(oldest); + } +} + +export function deliveryTurnGuardSizeForTests(): number { + return delivered.size; +} + export type DeliveryTurnTrigger = | "finalizeSuccessfulTurnExit" | "persistExhaustionGateTurn" @@ -39,8 +58,10 @@ export function markDeliveryTurn(input: { }): void { if (!input.caseId || !input.resultId) return; const now = input.now ?? Date.now(); + pruneDelivered(now); const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined; const existing = delivered.get(input.caseId); + if (!existing) makeRoomForDeliveryMark(); if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) { if (existing.narration) return; delivered.set(input.caseId, { diff --git a/frontend/src/lib/request-rate-limit.ts b/frontend/src/lib/request-rate-limit.ts index 3f67e647..9165c939 100644 --- a/frontend/src/lib/request-rate-limit.ts +++ b/frontend/src/lib/request-rate-limit.ts @@ -32,9 +32,36 @@ function defaultStore(): RequestRateLimitStore { return state.jyotishaRequestRateLimits; } +let seenMaxWindowMs = 0; +let consumeCalls = 0; + +export function resetRequestRateLimitMaintenanceForTests(): void { + seenMaxWindowMs = 0; + consumeCalls = 0; +} + +function sweepExpiredRateLimitKeys( + store: RequestRateLimitStore, + now: number, + maxWindowMs: number, +): void { + for (const [key, stamps] of store) { + let newest = Number.NEGATIVE_INFINITY; + for (const stamp of stamps) { + if (stamp > newest) newest = stamp; + } + if (!Number.isFinite(newest) || newest <= now - maxWindowMs) store.delete(key); + } +} + export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision { + if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs; + consumeCalls += 1; const now = input.now ?? Date.now(); const store = input.store ?? defaultStore(); + if (consumeCalls % 1_000 === 0 || store.size > 5_000) { + sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs); + } const windowStart = now - input.windowMs; const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart); if (recent.length >= input.limit) { diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index 7d7b984d..351603bd 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -9,12 +9,15 @@ import { getIdentityAuthServices } from "@/modules/identity/auth"; import { readIdentitySession } from "@/modules/identity/session"; import { getSupabasePublicConfig } from "./config"; -export async function createServerSupabaseClient() { +export async function createServerSupabaseClient(options?: { + readonly disableCookieCache?: boolean; +}) { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const requestHeaders = new Headers(await headers()); const session = await readIdentitySession( getIdentityAuthServices().user.api, requestHeaders, + options?.disableCookieCache ? { disableCookieCache: true } : undefined, ); return createLocalPostgresDataClient( readDatabaseUrl(process.env, "APP_DATABASE_URL"), diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index 9814102f..458e0e1d 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -1,5 +1,4 @@ import { createHmac } from "node:crypto"; -import type { Pool } from "pg"; import type { BetterAuthOptions } from "better-auth"; import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins"; @@ -11,10 +10,19 @@ export type AdminUserAuthorizer = (userId: string) => Promise; interface BuildAuthOptionsInput { config: SelfHostedIdentityConfig; - database: Pool; + database: BetterAuthOptions["database"]; emailSender: EmailOtpSender; } +export const IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 300; + +/** Server-only. Unset or any value other than "on" keeps today's uncached session reads. */ +export function identitySessionCookieCacheEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.IDENTITY_SESSION_COOKIE_CACHE === "on"; +} + function otpIdempotencyKey( secret: string, email: string, @@ -86,7 +94,15 @@ export function buildAuthOptions({ trustedOrigins: [config.userOrigin, config.adminOrigin], telemetry: { enabled: false }, user: identityModelMapping.user, - session: identityModelMapping.session, + session: identitySessionCookieCacheEnabled() + ? { + ...identityModelMapping.session, + cookieCache: { + enabled: true, + maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, + }, + } + : identityModelMapping.session, account: identityModelMapping.account, verification: identityModelMapping.verification, rateLimit: { diff --git a/frontend/src/modules/identity/session.ts b/frontend/src/modules/identity/session.ts index 0ba8c87f..849aa187 100644 --- a/frontend/src/modules/identity/session.ts +++ b/frontend/src/modules/identity/session.ts @@ -18,8 +18,15 @@ export interface IdentityServerSession extends IdentitySession { sessionToken: string; } +export interface IdentitySessionQuery { + readonly disableCookieCache?: boolean; +} + export interface IdentitySessionReader { - getSession(input: { headers: Headers }): Promise; + getSession(input: { + headers: Headers; + query?: IdentitySessionQuery; + }): Promise; } export class IdentityAuthorizationError extends Error { @@ -43,8 +50,12 @@ function parseRoles(role: string | null | undefined): string[] { export async function readIdentityServerSession( reader: IdentitySessionReader, requestHeaders: Headers, + options?: IdentitySessionQuery, ): Promise { - const value = await reader.getSession({ headers: requestHeaders }); + const value = await reader.getSession({ + headers: requestHeaders, + ...(options?.disableCookieCache ? { query: { disableCookieCache: true } } : {}), + }); if (!value) return null; const expiresAt = new Date(value.session.expiresAt); @@ -71,16 +82,18 @@ export async function readIdentityServerSession( export async function readIdentitySession( reader: IdentitySessionReader, requestHeaders: Headers, + options?: IdentitySessionQuery, ): Promise { - const session = await readIdentityServerSession(reader, requestHeaders); + const session = await readIdentityServerSession(reader, requestHeaders, options); return session ? { user: session.user, expiresAt: session.expiresAt } : null; } export async function requireIdentityServerSession( reader: IdentitySessionReader, requestHeaders: Headers, + options?: IdentitySessionQuery, ): Promise { - const session = await readIdentityServerSession(reader, requestHeaders); + const session = await readIdentityServerSession(reader, requestHeaders, options); if (!session) throw new IdentityAuthorizationError("Authentication required", 401); return session; } diff --git a/frontend/tests/database-local-postgres-session-setup.test.ts b/frontend/tests/database-local-postgres-session-setup.test.ts new file mode 100644 index 00000000..923c7b45 --- /dev/null +++ b/frontend/tests/database-local-postgres-session-setup.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, + localPostgresPoolForTests, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const probeSql = ` +create or replace function public.session_probe() +returns table(db_user text, claim_sub text) +language sql +stable +as $$ + select current_user::text, current_setting('request.jwt.claim.sub', true); +$$; +grant usage on schema public to authenticated, service_role; +grant execute on function public.session_probe() to authenticated, service_role; +`; + +test("merged session setup applies the role only inside the transaction", async () => { + const previous = { + cache: process.env.JYOTISHA_PG_METADATA_CACHE, + merged: process.env.JYOTISHA_PG_MERGED_SESSION_SETUP, + }; + process.env.JYOTISHA_PG_METADATA_CACHE = "on"; + process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = "on"; + const fixture = startPostgresFixture(); + const identity = { + id: "018f4e6d-7a11-7000-8000-0000000000aa", + email: "person@example.com", + }; + try { + fixture.psqlScriptAs("postgres", "postgres-test-password", probeSql); + const url = fixture.connectionUrl("app_runtime", "app-runtime-test-password"); + const client = createLocalPostgresDataClient(url, identity, "authenticated"); + const probe = await client.rpc("session_probe"); + assert.equal(probe.error, null, probe.error?.message); + const row = Array.isArray(probe.data) ? probe.data[0] as { db_user?: string; claim_sub?: string } : null; + assert.equal(row?.db_user, "authenticated"); + assert.equal(row?.claim_sub, identity.id); + + const pool = localPostgresPoolForTests(url); + const borrowed = await pool.connect(); + try { + const after = await borrowed.query<{ db_user: string; claim_sub: string | null }>( + "select current_user as db_user, current_setting('request.jwt.claim.sub', true) as claim_sub", + ); + assert.equal(after.rows[0]?.db_user, "app_runtime"); + assert.equal(after.rows[0]?.claim_sub ?? "", ""); + } finally { + borrowed.release(); + } + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + if (previous.cache === undefined) delete process.env.JYOTISHA_PG_METADATA_CACHE; + else process.env.JYOTISHA_PG_METADATA_CACHE = previous.cache; + if (previous.merged === undefined) delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP; + else process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = previous.merged; + } +}); diff --git a/frontend/tests/delivery-turn-guard-expiry.test.ts b/frontend/tests/delivery-turn-guard-expiry.test.ts new file mode 100644 index 00000000..f18c506e --- /dev/null +++ b/frontend/tests/delivery-turn-guard-expiry.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + alreadyDelivered, + deliveryCarrierNarration, + deliveryTurnGuardSizeForTests, + markDeliveryTurn, + resetDeliveryTurnGuardForTests, +} from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; + +test("a delivery mark older than 60 seconds is removed on the next write", () => { + resetDeliveryTurnGuardForTests(); + markDeliveryTurn({ + caseId: "case-a", + resultId: "result-a", + now: 0, + narration: "已经交付这段说明", + }); + assert.equal(alreadyDelivered({ + caseId: "case-a", + resultId: "result-a", + hasUserMessage: false, + now: 60_000, + }), true); + assert.equal(deliveryCarrierNarration({ + caseId: "case-a", + resultId: "result-a", + now: 60_000, + }), "已经交付这段说明"); + + markDeliveryTurn({ caseId: "case-b", resultId: "result-b", now: 60_001 }); + assert.equal(deliveryTurnGuardSizeForTests(), 1); + assert.equal(alreadyDelivered({ + caseId: "case-a", + resultId: "result-a", + hasUserMessage: false, + now: 60_001, + }), false); + assert.equal(alreadyDelivered({ + caseId: "case-b", + resultId: "result-b", + hasUserMessage: false, + now: 70_000, + }), true); +}); + +test("delivery marks stay within the hard cap", () => { + resetDeliveryTurnGuardForTests(); + for (let index = 0; index < 10_001; index += 1) { + markDeliveryTurn({ caseId: `case-${index}`, resultId: "result", now: 1_000 }); + } + assert.ok(deliveryTurnGuardSizeForTests() <= 10_000); + assert.equal(alreadyDelivered({ + caseId: "case-10000", + resultId: "result", + hasUserMessage: false, + now: 1_000, + }), true); + resetDeliveryTurnGuardForTests(); +}); diff --git a/frontend/tests/identity-session-cookie-cache.test.ts b/frontend/tests/identity-session-cookie-cache.test.ts new file mode 100644 index 00000000..511391ee --- /dev/null +++ b/frontend/tests/identity-session-cookie-cache.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { betterAuth } from "better-auth"; +import { memoryAdapter, type MemoryDB } from "../node_modules/better-auth/node_modules/@better-auth/memory-adapter/dist/index.mjs"; +import type { BetterAuthOptions } from "better-auth"; + +import { + buildAuthOptions, + IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, +} from "../src/modules/identity/auth-factory.ts"; +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; +import { + readIdentitySession, + requireIdentityServerSession, + type IdentitySessionReader, +} from "../src/modules/identity/session.ts"; + +const config: SelfHostedIdentityConfig = { + provider: "self-hosted", + databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", + userOrigin: "https://staging.jyotisha.chat", + adminOrigin: "https://admin.staging.jyotisha.chat", + userSecret: "user-secret-that-is-at-least-32-bytes-long", + resendApiKey: "re_test", + resendFrom: "Jyotisha ", + testOtp: null, +}; + +function withCacheFlag(value: string | undefined, run: () => void): void { + const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE; + if (value === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE; + else process.env.IDENTITY_SESSION_COOKIE_CACHE = value; + try { + run(); + } finally { + if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE; + else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous; + } +} + +test("user auth omits cookie cache unless the server switch is on", () => { + withCacheFlag(undefined, () => { + const options = buildAuthOptions({ + config, + database: memoryAdapter({}), + emailSender: new FakeEmailOtpSender(), + }); + assert.equal(options.advanced?.cookiePrefix, "jyotisha-user"); + assert.equal(options.session && "cookieCache" in options.session, false); + }); + withCacheFlag("on", () => { + const options = buildAuthOptions({ + config, + database: memoryAdapter({}), + emailSender: new FakeEmailOtpSender(), + }); + assert.deepEqual(options.session?.cookieCache, { + enabled: true, + maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, + }); + }); +}); + +test("sensitive session readers pass disableCookieCache and ordinary readers do not", async () => { + const queries: Array<{ disableCookieCache?: boolean }> = []; + const reader: IdentitySessionReader = { + async getSession(input) { + queries.push({ disableCookieCache: input.query?.disableCookieCache }); + return { + session: { + id: "session-1", + token: "session-token", + expiresAt: new Date("2030-01-01T00:00:00.000Z"), + }, + user: { + id: "018f4e6d-7a11-7000-8000-000000000001", + email: "person@example.com", + emailVerified: true, + name: "Person", + image: null, + role: "user", + }, + }; + }, + }; + await readIdentitySession(reader, new Headers()); + await requireIdentityServerSession(reader, new Headers(), { disableCookieCache: true }); + assert.deepEqual(queries, [ + { disableCookieCache: undefined }, + { disableCookieCache: true }, + ]); +}); + +type SetCookieHeaders = Headers & { getSetCookie?: () => string[] }; + +function cookieHeaderFrom(response: Response, previous = ""): string { + const headers = response.headers as SetCookieHeaders; + const setCookies = headers.getSetCookie?.() + ?? (headers.get("set-cookie")?.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g) ?? []); + const fresh = setCookies + .map((value) => value.split(";", 1)[0] ?? "") + .filter((value) => value.includes("=") && value.slice(value.indexOf("=") + 1).length > 0); + const jar = new Map(); + for (const part of previous.split("; ").filter(Boolean)) { + jar.set(part.slice(0, part.indexOf("=")), part); + } + for (const part of fresh) jar.set(part.slice(0, part.indexOf("=")), part); + return [...jar.values()].join("; "); +} + +function createCacheAuth(enabled: boolean) { + const db: MemoryDB = { + users: [], + sessions: [], + accounts: [], + verifications: [], + two_factors: [], + otp_rate_limits: [], + }; + const sessionReads: string[] = []; + const factory = memoryAdapter(db); + const database = ((options: BetterAuthOptions) => { + const adapter = factory(options); + return { + ...adapter, + async findOne(data: { model: string }) { + if (data.model === "session" || data.model === "sessions") sessionReads.push(data.model); + return adapter.findOne(data as never); + }, + }; + }) as BetterAuthOptions["database"]; + const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE; + process.env.IDENTITY_SESSION_COOKIE_CACHE = enabled ? "on" : "off"; + try { + const built = buildAuthOptions({ + config, + database, + emailSender: new FakeEmailOtpSender(), + }); + const auth = betterAuth({ + ...built, + emailAndPassword: { + ...built.emailAndPassword, + enabled: true, + disableSignUp: false, + }, + }); + return { auth, db, sessionReads }; + } finally { + if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE; + else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous; + } +} + +type CacheAuth = ReturnType["auth"]; + +async function signIn(auth: CacheAuth): Promise { + const email = "person@example.com"; + const password = "long-enough-password"; + const origin = config.userOrigin; + const signedUp = await auth.api.signUpEmail({ + body: { email, password, name: "Person" }, + headers: new Headers({ origin }), + asResponse: true, + }); + assert.equal(signedUp.status, 200, await signedUp.clone().text()); + let cookie = cookieHeaderFrom(signedUp); + if (!cookie.includes("session_token")) { + const signedIn = await auth.api.signInEmail({ + body: { email, password }, + headers: new Headers({ origin }), + asResponse: true, + }); + assert.equal(signedIn.status, 200, await signedIn.clone().text()); + cookie = cookieHeaderFrom(signedIn, cookie); + } + return cookie; +} + +async function readSession( + auth: CacheAuth, + cookie: string, + disableCookieCache = false, +): Promise<{ cookie: string; status: number; userId: string | null }> { + const response = await auth.api.getSession({ + headers: new Headers({ cookie, origin: config.userOrigin }), + query: disableCookieCache ? { disableCookieCache: true } : undefined, + asResponse: true, + }); + const body = await response.json() as { user?: { id?: string } } | null; + return { + cookie: cookieHeaderFrom(response, cookie), + status: response.status, + userId: body?.user?.id ?? null, + }; +} + +test("cookie cache serves three ordinary reads from one database read", async () => { + const { auth, db, sessionReads } = createCacheAuth(true); + const cookie = await signIn(auth); + sessionReads.length = 0; + let current = cookie + .split("; ") + .filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data")) + .join("; "); + const reads: Array = []; + for (let index = 0; index < 3; index += 1) { + const result = await readSession(auth, current); + current = result.cookie; + reads.push(result.userId); + } + assert.deepEqual(reads, [reads[0], reads[0], reads[0]]); + assert.ok(reads[0]); + assert.equal(sessionReads.length, 1); + const cachedCookie = current; + + sessionReads.length = 0; + for (let index = 0; index < 3; index += 1) { + const result = await readSession(auth, cachedCookie, true); + assert.ok(result.userId); + } + assert.equal(sessionReads.length, 3); + + const sessions = db.sessions ?? db.session; + assert.ok(Array.isArray(sessions)); + sessions.length = 0; + const sensitive = await readSession(auth, cachedCookie, true); + assert.equal(sensitive.userId, null); + const ordinary = await readSession(auth, cachedCookie, false); + assert.equal(ordinary.userId, reads[0]); + + const realNow = Date.now; + const started = realNow(); + Date.now = () => started + (IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS + 1) * 1000; + try { + const expired = await readSession(auth, cachedCookie, false); + assert.equal(expired.userId, null); + } finally { + Date.now = realNow; + } +}); + +test("cookie cache off reads the database on every ordinary request", async () => { + const { auth, sessionReads } = createCacheAuth(false); + const cookie = await signIn(auth); + sessionReads.length = 0; + let current = cookie + .split("; ") + .filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data")) + .join("; "); + for (let index = 0; index < 3; index += 1) { + const result = await readSession(auth, current); + assert.ok(result.userId); + current = result.cookie; + } + assert.equal(sessionReads.length, 3); +}); diff --git a/frontend/tests/local-postgres-roundtrips.test.ts b/frontend/tests/local-postgres-roundtrips.test.ts new file mode 100644 index 00000000..55dcc8c6 --- /dev/null +++ b/frontend/tests/local-postgres-roundtrips.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Pool } from "pg"; + +import { + clearLocalPostgresMetadataCacheForTests, + closeLocalPostgresDataPool, + createLocalPostgresDataClient, + installLocalPostgresPoolForTests, + setLocalPostgresMetadataClockForTests, + type LocalDatabaseRole, +} from "../src/lib/db/local-postgres-client-core.ts"; + +type QueryCall = { sql: string; params: readonly unknown[] }; + +function tag(sql: string): string { + const compact = sql.replace(/\s+/g, " ").trim().toLowerCase(); + if (compact === "begin") return "begin"; + if (compact === "commit") return "commit"; + if (compact === "rollback") return "rollback"; + if (compact.startsWith("set local role")) return "set local role"; + if (compact.includes("set_config('role'")) return "set_config(role)"; + if (compact.includes("set_config('request.jwt.claim.sub'")) return "set_config"; + if (compact.includes("information_schema.columns")) return "information_schema"; + if (compact.includes("from pg_index")) return "primary_key"; + if (compact.includes("from pg_proc")) return "pg_proc"; + return "business"; +} + +function installFakePool( + url: string, + options: { + columns?: ReadonlyArray<{ column_name: string; udt_name: string }> | null; + primaryKeys?: readonly string[] | null; + functions?: ReadonlyArray> | null; + failBusinessCode?: () => string | null; + } = {}, +): QueryCall[] { + const calls: QueryCall[] = []; + const client = { + async query(sql: string, params: readonly unknown[] = []) { + calls.push({ sql, params }); + const label = tag(sql); + if (label === "information_schema") { + const rows = options.columns === null + ? [] + : [...(options.columns ?? [{ column_name: "id", udt_name: "uuid" }])]; + return { rows, rowCount: rows.length }; + } + if (label === "primary_key") { + const names = options.primaryKeys === null ? [] : [...(options.primaryKeys ?? ["id"])]; + const rows = names.map((column_name) => ({ column_name })); + return { rows, rowCount: rows.length }; + } + if (label === "pg_proc") { + const rows = options.functions === null + ? [] + : [...(options.functions ?? [{ + proretset: false, + return_type: "text", + argument_names: ["left_name", "right_name"], + argument_types: ["text", "text"], + }])]; + return { rows, rowCount: rows.length }; + } + if (label === "business") { + const code = options.failBusinessCode?.() ?? null; + if (code) { + const error = new Error("business sql failed") as Error & { code: string }; + error.code = code; + throw error; + } + if (sql.toLowerCase().includes(" as value")) return { rows: [{ value: "ok" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }, + release() {}, + }; + const pool = { + async connect() { + return client; + }, + async end() {}, + }; + installLocalPostgresPoolForTests(url, pool as unknown as Pool); + return calls; +} + +async function withSwitches( + values: Record, + run: () => Promise, +): Promise { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearLocalPostgresMetadataCacheForTests(); + setLocalPostgresMetadataClockForTests(null); + try { + await run(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearLocalPostgresMetadataCacheForTests(); + setLocalPostgresMetadataClockForTests(null); + } +} + +const identity = { id: "018f4e6d-7a11-7000-8000-0000000000aa", email: "person@example.com" }; + +function clientFor(url: string, role: LocalDatabaseRole | null = "authenticated") { + return createLocalPostgresDataClient(url, role === "service_role" ? null : identity, role); +} + +test("metadata cache and merged session setup stay off the baseline query sequence", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "off", + JYOTISHA_PG_MERGED_SESSION_SETUP: "off", + }, async () => { + const url = "postgresql://roundtrip-off@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url); + const client = clientFor(url); + const first = await client.from("profiles").select("id").eq("id", identity.id); + assert.equal(first.error, null); + assert.deepEqual(calls.map((call) => tag(call.sql)), [ + "begin", + "set local role", + "set_config", + "information_schema", + "business", + "commit", + ]); + await client.from("profiles").select("id").eq("id", identity.id); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 2); + }); +}); + +test("cached metadata and merged session setup drop a repeat select and rpc to four roundtrips", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "on", + }, async () => { + const url = "postgresql://roundtrip-on@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url); + const client = clientFor(url); + await client.from("profiles").select("id").eq("id", identity.id); + calls.length = 0; + await client.from("profiles").select("id").eq("id", identity.id); + assert.deepEqual(calls.map((call) => tag(call.sql)), [ + "begin", + "set_config(role)", + "business", + "commit", + ]); + + await client.rpc("sample_probe", { left_name: "a", right_name: "b" }); + calls.length = 0; + const rpc = await client.rpc("sample_probe", { left_name: "a", right_name: "b" }); + assert.equal(rpc.error, null); + assert.deepEqual(calls.map((call) => tag(call.sql)), [ + "begin", + "set_config(role)", + "business", + "commit", + ]); + + await client.from("profiles").upsert({ id: identity.id }); + calls.length = 0; + await client.from("profiles").upsert({ id: identity.id }); + assert.deepEqual(calls.map((call) => tag(call.sql)), [ + "begin", + "set_config(role)", + "business", + "commit", + ]); + }); +}); + +test("column cache is per role and function cache is per argument names", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "on", + }, async () => { + const url = "postgresql://roundtrip-keys@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url); + const authenticated = clientFor(url, "authenticated"); + const service = clientFor(url, "service_role"); + await authenticated.from("profiles").select("id"); + calls.length = 0; + await service.from("profiles").select("id"); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1); + + await authenticated.rpc("sample_probe", { left_name: "a" }); + calls.length = 0; + await authenticated.rpc("sample_probe", { right_name: "b" }); + assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1); + }); +}); + +test("undefined column, expiry, pool close, and empty metadata all miss the cache", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "off", + }, async () => { + const url = "postgresql://roundtrip-invalidate@127.0.0.1:1/jyotisha"; + let failuresLeft = 1; + const calls = installFakePool(url, { + failBusinessCode: () => { + if (failuresLeft <= 0) return null; + failuresLeft -= 1; + return "42703"; + }, + }); + const client = clientFor(url); + const failed = await client.from("profiles").select("id"); + assert.equal(failed.error?.code, "42703"); + calls.length = 0; + const retried = await client.from("profiles").select("id"); + assert.equal(retried.error, null); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1); + }); +}); + +test("metadata older than 300 seconds is queried again", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "off", + }, async () => { + const url = "postgresql://roundtrip-ttl@127.0.0.1:1/jyotisha"; + let now = 10_000; + setLocalPostgresMetadataClockForTests(() => now); + const calls = installFakePool(url); + const client = clientFor(url); + await client.from("profiles").select("id"); + calls.length = 0; + await client.from("profiles").select("id"); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 0); + now += 300_000; + calls.length = 0; + await client.from("profiles").select("id"); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1); + }); +}); + +test("an empty column list and a missing function are not cached", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "off", + }, async () => { + const url = "postgresql://roundtrip-empty@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url, { columns: null, functions: null, primaryKeys: null }); + const client = clientFor(url); + await client.from("profiles").select("id"); + calls.length = 0; + await client.from("profiles").select("id"); + assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1); + + const missing = await client.rpc("missing_probe", { left_name: "a" }); + assert.match(missing.error?.message ?? "", /not found/); + calls.length = 0; + await client.rpc("missing_probe", { left_name: "a" }); + assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1); + }); +}); + +test("closing the pool drops that url from the metadata cache", async () => { + await withSwitches({ + JYOTISHA_PG_METADATA_CACHE: "on", + JYOTISHA_PG_MERGED_SESSION_SETUP: "off", + }, async () => { + const url = "postgresql://roundtrip-close@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url); + const client = clientFor(url); + await client.from("profiles").select("id"); + await closeLocalPostgresDataPool(url); + const reopenedCalls = installFakePool(url); + const reopened = clientFor(url); + await reopened.from("profiles").select("id"); + assert.equal(reopenedCalls.filter((call) => tag(call.sql) === "information_schema").length, 1); + assert.ok(calls.length > 0); + }); +}); diff --git a/frontend/tests/personal-report-worker-sleep-listener.test.ts b/frontend/tests/personal-report-worker-sleep-listener.test.ts new file mode 100644 index 00000000..55aed461 --- /dev/null +++ b/frontend/tests/personal-report-worker-sleep-listener.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { getEventListeners } from "node:events"; +import test from "node:test"; + +import { scoringPollDelay } from "../src/lib/birth-time-guided-polling.ts"; +import { runPersonalReportWorkerLoop } from "../src/lib/personal-report-worker-core.ts"; + +const idle = { + recovery: { retryingCount: 0, failedCount: 0 }, + outcome: "idle" as const, + jobId: null, +}; + +function keepAlive(): () => void { + const timer = setInterval(() => {}, 1_000); + return () => clearInterval(timer); +} + +test("idle report-worker sleeps do not leave abort listeners", async () => { + const stop = keepAlive(); + const controller = new AbortController(); + let ticks = 0; + let listenersAt200 = -1; + let abortedAt = 0; + try { + await runPersonalReportWorkerLoop( + { + async tick() { + ticks += 1; + if (ticks === 200) { + listenersAt200 = getEventListeners(controller.signal, "abort").length; + abortedAt = Date.now(); + controller.abort(); + } + return idle; + }, + }, + { pollIntervalMs: 1, signal: controller.signal }, + ); + assert.equal(listenersAt200, 0); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + assert.ok(Date.now() - abortedAt < 250); + } finally { + stop(); + } +}); + +test("thrown report-worker ticks do not leave abort listeners", async () => { + const stop = keepAlive(); + const controller = new AbortController(); + let ticks = 0; + let listenersAt200 = -1; + let abortedAt = 0; + try { + await runPersonalReportWorkerLoop( + { + async tick() { + ticks += 1; + if (ticks === 200) { + listenersAt200 = getEventListeners(controller.signal, "abort").length; + abortedAt = Date.now(); + controller.abort(); + } + throw new Error("tick failed"); + }, + }, + { pollIntervalMs: 1, signal: controller.signal }, + ); + assert.equal(listenersAt200, 0); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + assert.ok(Date.now() - abortedAt < 250); + } finally { + stop(); + } +}); + +test("scoring poll delay removes its abort listener when the timer fires", async () => { + const previousWindow = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }; + try { + const controller = new AbortController(); + await scoringPollDelay(0, controller.signal); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + } finally { + (globalThis as { window?: unknown }).window = previousWindow; + } +}); + +test("scoring poll delay resolves as soon as the signal aborts", async () => { + const previousWindow = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }; + try { + const controller = new AbortController(); + const started = Date.now(); + const pending = scoringPollDelay(0, controller.signal); + controller.abort(); + await pending; + assert.ok(Date.now() - started < 200); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + } finally { + (globalThis as { window?: unknown }).window = previousWindow; + } +}); diff --git a/frontend/tests/request-rate-limit.test.ts b/frontend/tests/request-rate-limit.test.ts index f5e9f2bd..d9c30d29 100644 --- a/frontend/tests/request-rate-limit.test.ts +++ b/frontend/tests/request-rate-limit.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { consumeRequestRateLimit } from "../src/lib/request-rate-limit.ts"; +import { + consumeRequestRateLimit, + resetRequestRateLimitMaintenanceForTests, +} from "../src/lib/request-rate-limit.ts"; test("a user is allowed up to the window limit and then must wait", () => { const store = new Map(); @@ -46,3 +49,64 @@ test("a user is allowed up to the window limit and then must wait", () => { assert.deepEqual(otherUser, { ok: true }); assert.deepEqual(afterWindow, { ok: true }); }); + +test("keys past the longest seen window are swept, and a one-hour key survives 61 seconds", () => { + resetRequestRateLimitMaintenanceForTests(); + const shortWindow = new Map(); + consumeRequestRateLimit({ + key: "old", + limit: 5, + windowMs: 60_000, + now: 0, + store: shortWindow, + }); + for (let index = 0; index < 998; index += 1) { + consumeRequestRateLimit({ + key: `pad:${index}`, + limit: 5, + windowMs: 60_000, + now: 0, + store: shortWindow, + }); + } + consumeRequestRateLimit({ + key: "trigger", + limit: 5, + windowMs: 60_000, + now: 61_000, + store: shortWindow, + }); + assert.equal(shortWindow.has("old"), false); + assert.equal(shortWindow.get("old"), undefined); + assert.equal(shortWindow.has("trigger"), true); + assert.equal(shortWindow.size, 1); + + resetRequestRateLimitMaintenanceForTests(); + const mixed = new Map(); + consumeRequestRateLimit({ + key: "synastry:person", + limit: 8, + windowMs: 60 * 60_000, + now: 0, + store: mixed, + }); + consumeRequestRateLimit({ + key: "ephemeris:person", + limit: 30, + windowMs: 60_000, + now: 0, + store: mixed, + }); + for (let index = 0; index < 998; index += 1) { + consumeRequestRateLimit({ + key: `later:${index}`, + limit: 5, + windowMs: 60_000, + now: 61_000, + store: mixed, + }); + } + assert.equal(mixed.has("synastry:person"), true); + assert.equal(mixed.has("ephemeris:person"), true); + resetRequestRateLimitMaintenanceForTests(); +}); diff --git a/frontend/tests/rescore-attempts-bound.test.ts b/frontend/tests/rescore-attempts-bound.test.ts new file mode 100644 index 00000000..7e9412c5 --- /dev/null +++ b/frontend/tests/rescore-attempts-bound.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + noteRescoreAttemptForTests, + rescoreAttemptCountForTests, + resetStaleMinuteRescoreAttemptsForTests, +} from "../src/lib/rectification-agentic/v9/block-scan-answer.ts"; + +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; + +test("rescore attempts stay within 5000 entries", () => { + resetStaleMinuteRescoreAttemptsForTests(); + for (let index = 0; index < 5_001; index += 1) { + noteRescoreAttemptForTests(`case-${index}:turn:fingerprint`, 1_000); + } + assert.ok(rescoreAttemptCountForTests() <= 5_000); + resetStaleMinuteRescoreAttemptsForTests(); +}); + +test("a rescore attempt older than six hours is dropped on the next write", () => { + resetStaleMinuteRescoreAttemptsForTests(); + noteRescoreAttemptForTests("case-old:turn:fingerprint", 0); + noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS); + assert.equal(rescoreAttemptCountForTests(), 2); + noteRescoreAttemptForTests("case-new:turn:fingerprint", SIX_HOURS_MS + 1); + assert.equal(rescoreAttemptCountForTests(), 2); + noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS + 1); + assert.equal(rescoreAttemptCountForTests(), 2); + resetStaleMinuteRescoreAttemptsForTests(); +});