From 69b4d3b62df9c942a46ff14b786afd45690cc9bc Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 04:27:19 +0800 Subject: [PATCH] chore: gate production postgres tuning and taro handoff --- README.md | 25 +- docs/refactor/frontend-handoff-index.md | 8 +- docs/refactor/next-development-todo.md | 6 +- .../refactor/performance-benchmark-runbook.md | 14 +- docs/refactor/postgresql-4c16g-tuning.md | 32 +- .../production-launch-evidence.template.json | 14 + .../taro-production-integration-checklist.md | 108 +++++++ .../web-launch-acceptance-checklist.md | 4 +- package.json | 3 +- scripts/lib/postgres-tuning-profile.js | 175 +++++++++++ scripts/postgres-tuning-evidence-test.js | 38 +++ scripts/postgres-tuning-evidence.js | 296 ++++++++++++++++-- scripts/production-launch-gate-test.js | 11 + scripts/production-launch-gate.js | 12 + 14 files changed, 702 insertions(+), 44 deletions(-) create mode 100644 docs/refactor/taro-production-integration-checklist.md create mode 100644 scripts/lib/postgres-tuning-profile.js create mode 100644 scripts/postgres-tuning-evidence-test.js diff --git a/README.md b/README.md index 34e4e594..217dea5c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ - `docs/refactor/legacy-feature-gap-matrix.md` - `docs/refactor/supabase-frontend-access-strategy.md` - `docs/refactor/taro-frontend-integration.md` +- `docs/refactor/taro-production-integration-checklist.md` - `docs/refactor/multitenant-auth-security-contract.md` - `docs/refactor/next-development-todo.md` - `docs/refactor/blueprint-coverage.md` @@ -460,7 +461,15 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json ``` -`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`audit:runtime`、真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 +`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过: + +- `readiness:production`、`readiness:production:db`、严格 `perf:postgres:evidence -- --strict`。 +- 真实 `smoke:auth:remote`、`test:rls`。 +- PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`。 +- 真实数据 API 读路径压测、API/worker/Taro 构建。 +- `smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`。 +- `audit:runtime`、真实 `@codex-security` 扫描。 +- 备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。 @@ -573,6 +582,20 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" npm run perf:postgres:evidence ``` +生产上线前必须用严格模式生成门禁摘要: + +```powershell +$env:PG_TUNING_PROFILE="shared-host" +npm run perf:postgres:evidence -- --strict --json +Remove-Item Env:\PG_TUNING_PROFILE +``` + +严格模式会检查 4 核 16G profile、`pending_restart=0`、`pg_stat_statements` 可用、`jit=off`,以及 API 请求相关超时不为 0。需要生成可人工复核的 `ALTER SYSTEM` SQL 时运行: + +```powershell +npm run perf:postgres:sql -- --profile=shared-host +``` + 上线前角色旅程烟测: ```powershell diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index cc808330..059404b4 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -20,11 +20,13 @@ - Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。 7. `docs/refactor/taro-h5-deployment.md` - H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。 -8. `docs/refactor/multitenant-auth-security-contract.md` +8. `docs/refactor/taro-production-integration-checklist.md` + - 正式接 Supabase Auth、三套 H5、真实 provider、runtime-config 和上线门禁时逐项对照。 +9. `docs/refactor/multitenant-auth-security-contract.md` - 多租户、鉴权、权限、资源签名和生产安全红线。 -9. `docs/refactor/content-import-contract.md` +10. `docs/refactor/content-import-contract.md` - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 -10. `docs/refactor/production-launch-evidence.template.json` +11. `docs/refactor/production-launch-evidence.template.json` - 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。 ## 当前可进入的前端工作 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 9d32c2d5..75a1e36f 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -91,11 +91,11 @@ - 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。 - Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。 - 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。 - - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 + - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、PostgreSQL 4c16g 严格调参证据、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 - 确认数据库迁移流程、备份恢复、日志、告警。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。 - - 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G,舒适观察区暂按 50 到 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。 - - 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 + - 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run perf:postgres:sql`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G,舒适观察区暂按 50 到 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。 + - 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数,执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json` 并通过后,再按 6/30/50/100 阶梯并发复跑容量报告并归档到本地上线证据。 - 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。 ### P1 商用功能完善 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index a6b74808..a40b3c8c 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -37,6 +37,18 @@ npm run perf:postgres:evidence 该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。 +生产上线前必须用严格模式跑一次,并把摘要填入 `production-launch-evidence.json` 的 `postgres.tuning-evidence`: + +```bash +PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json +``` + +严格模式要求 4c16g profile 范围内、无 `pending_restart`、`pg_stat_statements` 可用、`jit=off`,且 API 请求相关超时不为 0。需要生成 `ALTER SYSTEM` SQL 时使用: + +```bash +npm run perf:postgres:sql -- --profile=shared-host +``` + 角色旅程烟测: ```bash @@ -135,7 +147,7 @@ npm run perf:api:local ## 4 核 16G 阶梯压测建议 -在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。 +在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,执行 `perf:postgres:evidence -- --strict` 通过,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。 | 场景 | 并发 | 时长 | 写入 | 用途 | | --- | ---: | ---: | --- | --- | diff --git a/docs/refactor/postgresql-4c16g-tuning.md b/docs/refactor/postgresql-4c16g-tuning.md index 79d6f18e..af1bb9da 100644 --- a/docs/refactor/postgresql-4c16g-tuning.md +++ b/docs/refactor/postgresql-4c16g-tuning.md @@ -1,6 +1,6 @@ # PostgreSQL 4 核 16G 生产调参基线 -更新时间:2026-06-30 +更新时间:2026-07-01 这份文档用于后续把 Supabase/PostgreSQL 自托管到 4 核 16G 云服务器时做生产起步配置。目标是先给题库 SaaS 一个安全、可回滚、可观测的基线,而不是追求一次性压满硬件。 @@ -12,7 +12,7 @@ - PostgreSQL 官方 WAL/Checkpoint:https://www.postgresql.org/docs/current/runtime-config-wal.html - PostgreSQL 官方 Connections:https://www.postgresql.org/docs/current/runtime-config-connection.html -说明:postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。 +说明:postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。当前 Codex 环境访问 `https://postgresqlco.nf/tuning-guide` 会返回 403,因此落地值不直接抓取该站页面,而是把它作为导航来源,并以 PostgreSQL 官方文档和本项目真实压测结果共同校验。 ## 适用前提 @@ -65,6 +65,26 @@ npm run perf:postgres:evidence 上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。 +生产上线门禁使用严格模式: + +```bash +PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json +``` + +严格模式会按 `scripts/lib/postgres-tuning-profile.js` 检查 4 核 16G profile: + +- `jit=off`。 +- `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 不得为 0。 +- 所有 profile 参数必须在允许范围内。 +- `pending_restart` 必须为 0。 +- `pg_stat_statements` 必须可用。 + +如果 PostgreSQL 是独立数据库主机,可改用: + +```bash +PG_TUNING_PROFILE=dedicated-db npm run perf:postgres:evidence -- --strict --json +``` + ## 应用连接池边界 4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。 @@ -107,7 +127,13 @@ where name in ( order by name; ``` -同机部署推荐先执行: +同机部署推荐先生成 SQL 后人工复核再执行: + +```bash +npm run perf:postgres:sql -- --profile=shared-host +``` + +等价的 shared-host 起步 SQL 如下: ```sql alter system set max_connections = '80'; diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index 4330e536..0ac21fb9 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -29,6 +29,20 @@ "blocker": 0 } }, + { + "id": "postgres.tuning-evidence", + "status": "pass", + "command": "PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json > docs/refactor/launch-artifacts/postgres-tuning-evidence.json", + "completedAt": "2026-06-30T10:08:00+08:00", + "artifact": "launch-artifacts/postgres-tuning-evidence.json", + "summary": { + "status": "pass", + "profile": "shared-host", + "failures": 0, + "pgStatStatementsAvailable": true, + "pendingRestart": 0 + } + }, { "id": "auth.remote-smoke", "status": "pass", diff --git a/docs/refactor/taro-production-integration-checklist.md b/docs/refactor/taro-production-integration-checklist.md new file mode 100644 index 00000000..074fbc02 --- /dev/null +++ b/docs/refactor/taro-production-integration-checklist.md @@ -0,0 +1,108 @@ +# Taro 生产接入检查清单 + +更新时间:2026-07-01 + +这份清单给前端同事和后续 AI 使用。目标是让 `apps/taro` 的 H5 学生端、租户后台、平台后台按当前 Supabase/PostgreSQL 新后端上线,后续再扩展微信小程序。旧小程序前端文件在 `F:\project\参考\旧题库小程序前端文件`,只作为视觉、交互状态和微信平台能力参考,不继承旧 PocketBase 直连、旧 token、旧安全假设。 + +## 总原则 + +- Taro 负责 UI、路由、交互、公开 runtime config 和 Supabase Auth session。 +- 复杂业务默认调用 `apps/api`,不要让页面直接写 Supabase 业务表。 +- Supabase client 可用于 Auth session、可选 Realtime、公开只读 view/table 或经过 RLS/RPC 评审的低风险功能。 +- 支付、短信、微信/QQ OAuth、订单、权益、激活码、优惠券、CRM、AI、导入、私有对象存储签名必须走 `apps/api`、Edge Function 或 worker。 +- 页面代码不得覆盖 `Authorization`、`x-tenant-id`,不得发送 `x-user-id`。 +- 前端只做可见性优化,权限最终以后端/RLS/RPC 校验为准。 + +## 三套 H5 发布目录 + +建议三套 H5 分域部署,不共用 web root: + +| 门户 | 推荐域名 | 构建命令 | runtime-config | +| --- | --- | --- | --- | +| 学生端 | `https://www.example.com` 或租户自定义域名 | `npm run build:taro:h5:student` | `apps/taro/deploy/h5-student.runtime-config.example.json` | +| 租户后台 | `https://admin.example.com` | `npm run build:taro:h5:tenant` | `apps/taro/deploy/h5-tenant-admin.runtime-config.example.json` | +| 平台后台 | `https://console.example.com` | `npm run build:taro:h5:platform` | `apps/taro/deploy/h5-platform-admin.runtime-config.example.json` | + +每个发布目录根部必须放置独立的 `runtime-config.json`,只允许公开字段: + +```json +{ + "portal": "student", + "apiBaseUrl": "https://api.example.com", + "supabaseUrl": "https://supabase.example.com", + "supabasePublishableKey": "sb_publishable_xxx", + "tenantCode": "optional-tenant-slug" +} +``` + +禁止出现在 `runtime-config.json`、Taro 环境变量、源码和构建产物中的内容: + +- Supabase service role / secret key。 +- 数据库连接串。 +- 短信、OAuth、支付、对象存储、CRM、AI 的 secret/private key。 +- 真实用户 token、测试 access token、平台本地管理 key。 + +## 生产接入步骤 + +1. 配好三套 H5 的 `runtime-config.json`,确认 `apiBaseUrl`、`supabaseUrl` 都是 HTTPS。 +2. H5 登录优先使用 Supabase Auth access token;迁移期 `tk_` session 只用于本地或内网联调。 +3. 学生端先走完整路径:解析租户、登录、选择地区、进入题库、创建练习、答题、收藏、交卷、查看报告、错题/收藏复习、背单词、知识手册、资料预览/下载、视频授权、会员下单、订单轮询。 +4. 租户后台先走完整路径:权限加载、学生运营、题库导入/任务/问题行、公共题库采纳、营销中心、财务运营、主题发布、角色模板和成员绑定。 +5. 平台后台先走完整路径:平台权限加载、创建租户、租户详情、账务资料、订阅/账单/用量、公共题库授权、平台员工、审计和告警。 +6. 所有真实 provider 密钥只配置在后端 `.env`、`app_private.tenant_secrets`、`app_private.platform_secrets` 或生产 KMS/Vault,不进入 Taro。 +7. 对象存储私有资源必须通过 `content_assets` 台账和后端短签名;前端只展示签名 URL、过期时间、`watermark.traceId` 和水印容器。 +8. 支付页面只展示后端返回的支付参数、订单状态和权益结果;金额、套餐、优惠、权益最终以后端返回为准。 + +## 必跑检查 + +页面、API service、路由、runtime config 或发布目录有任何变化时,至少运行: + +```bash +npm run check:taro +npm run test:readiness +npm run smoke:taro:h5 +npm run smoke:taro:h5:interaction +node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config +``` + +后端、RLS、provider、对象存储或生产配置有变化时,还要运行: + +```bash +npm run readiness:production +npm run readiness:production:db +npm run smoke:auth:remote +npm run test:rls +npm run audit:runtime +``` + +正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行: + +```bash +npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json +``` + +## 小程序后续兼容重点 + +小程序不能直接照搬 H5 假设,进入真机前要单独验收: + +- `@supabase/supabase-js` 的 fetch、storage、URL、token refresh 兼容性。 +- 如果兼容成本高,小程序登录只调用 `apps/api/auth/*`,由后端换取可信 session。 +- 微信支付容器、订阅消息、分享参数、小程序码 tenant/referral 场景。 +- KaTeX/公式渲染替代方案、题图资源字段化、PDF 预览能力和下载限制。 +- 网络错误、弱网续练、本地缓存恢复、切后台/回前台 token 刷新。 + +## 旧前端参考边界 + +可以参考旧项目: + +- 学生端刷题流程、答题卡、题型展示、背单词卡片、知识手册阅读、个人中心视觉。 +- 微信小程序分享、支付、授权和登录交互经验。 +- 租户后台/运营后台字段含义和常用工作流。 + +不能继承旧项目: + +- PocketBase SDK 直连和旧 collection 命名。 +- 前端保存或拼接用户 id、租户 id 来绕过后端鉴权。 +- 前端直接写订单、权益、学习记录、错题、收藏、导入任务。 +- 旧头像上传/第三方头像同步;学生头像只保留男女预设。 +- 默认排行榜请求;排行榜只作为租户显式开启后的活动能力。 diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index a0d02d2d..8931458e 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -124,8 +124,8 @@ STORAGE_REQUIRE_TENANT_PREFIX=true 1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。 2. 按 `docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。 -3. 重启 PostgreSQL 后再次 `npm run perf:postgres:evidence`。 +3. 重启 PostgreSQL 后执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json`,确认 profile 达标、`pending_restart=0`、`pg_stat_statements` 可用。 4. 跑 `npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。 -5. 把摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`。 +5. 把 `postgres.tuning-evidence` 和 API 压测摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`。 容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS,再按成功 RPS 折算在线容量。 diff --git a/package.json b/package.json index a48c20ab..fb94ea91 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js", "perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js", "perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js", + "perf:postgres:sql": "node scripts/postgres-tuning-evidence.js --print-sql", "test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js", "test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js", "test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js", @@ -56,7 +57,7 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", diff --git a/scripts/lib/postgres-tuning-profile.js b/scripts/lib/postgres-tuning-profile.js new file mode 100644 index 00000000..92439c1e --- /dev/null +++ b/scripts/lib/postgres-tuning-profile.js @@ -0,0 +1,175 @@ +export const postgresTuningProfiles = { + 'shared-host': { + name: 'shared-host', + label: '4c16g shared-host', + description: '4 vCPU / 16GB RAM, PostgreSQL shares the host with API, worker, Nginx and monitoring agents.', + settings: { + max_connections: { + recommended: '80', + min: 40, + max: 120, + type: 'number', + rationale: '4 vCPU should use application/pooler limits instead of many direct active connections.', + }, + shared_buffers: { + recommended: '3GB', + min: '2GB', + max: '4GB', + type: 'bytes', + restart: true, + rationale: 'Keep PostgreSQL buffers large enough while leaving RAM for OS cache and sibling services.', + }, + effective_cache_size: { + recommended: '10GB', + min: '8GB', + max: '12GB', + type: 'bytes', + rationale: 'Planner estimate only; reflects shared buffers plus OS cache on a 16GB shared host.', + }, + work_mem: { + recommended: '16MB', + min: '8MB', + max: '32MB', + type: 'bytes', + rationale: 'This is per sort/hash operation, not a global cap.', + }, + maintenance_work_mem: { + recommended: '512MB', + min: '256MB', + max: '1GB', + type: 'bytes', + rationale: 'Helps index builds, vacuum and imports without overcommitting memory.', + }, + autovacuum_work_mem: { + recommended: '256MB', + min: '128MB', + max: '512MB', + type: 'bytes', + rationale: 'Avoid letting multiple autovacuum workers inherit an overly high maintenance_work_mem.', + }, + wal_buffers: { + recommended: '16MB', + min: '8MB', + max: '64MB', + type: 'bytes', + restart: true, + rationale: 'Enough WAL buffering for steady learning writes and import bursts.', + }, + min_wal_size: { + recommended: '1GB', + min: '1GB', + max: '4GB', + type: 'bytes', + rationale: 'Reserve WAL for short write spikes and batch jobs.', + }, + max_wal_size: { + recommended: '6GB', + min: '4GB', + max: '12GB', + type: 'bytes', + rationale: 'Reduce checkpoint pressure while keeping crash recovery bounded.', + }, + checkpoint_timeout: { + recommended: '10min', + min: '5min', + max: '15min', + type: 'duration', + rationale: 'Avoid overly frequent checkpoints on write bursts.', + }, + checkpoint_completion_target: { + recommended: '0.9', + min: 0.8, + max: 0.95, + type: 'number', + rationale: 'Spread checkpoint I/O without pushing completion too close to the next checkpoint.', + }, + effective_io_concurrency: { + recommended: '100', + min: 50, + max: 300, + type: 'number', + rationale: 'SSD cloud disk starting point; validate against the actual disk class.', + }, + random_page_cost: { + recommended: '1.1', + min: 1, + max: 1.5, + type: 'number', + rationale: 'Reflect SSD random I/O and encourage reasonable index scans.', + }, + jit: { + recommended: 'off', + exact: 'off', + type: 'text', + rationale: 'Question-bank API queries are mostly short OLTP requests where JIT planning cost is usually not worth it.', + }, + log_min_duration_statement: { + recommended: '500ms', + min: '100ms', + max: '1000ms', + type: 'duration', + rationale: 'Capture slow SQL during launch without logging every normal request.', + }, + idle_in_transaction_session_timeout: { + recommended: '60s', + min: '30s', + max: '120s', + type: 'duration', + rationale: 'Kill idle transactions before they block migrations and writes.', + }, + statement_timeout: { + recommended: '30s', + min: '5s', + max: '60s', + type: 'duration', + rationale: 'API requests should fail boundedly; import jobs can override per session.', + }, + lock_timeout: { + recommended: '5s', + min: '1s', + max: '10s', + type: 'duration', + rationale: 'Ordinary API requests should not wait a long time behind locks.', + }, + }, + }, + 'dedicated-db': { + name: 'dedicated-db', + label: '4c16g dedicated PostgreSQL', + description: '4 vCPU / 16GB RAM where PostgreSQL is the only heavy service on the host.', + settings: { + max_connections: { recommended: '120', min: 60, max: 150, type: 'number' }, + shared_buffers: { recommended: '4GB', min: '3GB', max: '5GB', type: 'bytes', restart: true }, + effective_cache_size: { recommended: '12GB', min: '10GB', max: '14GB', type: 'bytes' }, + work_mem: { recommended: '16MB', min: '8MB', max: '32MB', type: 'bytes' }, + maintenance_work_mem: { recommended: '768MB', min: '512MB', max: '1536MB', type: 'bytes' }, + autovacuum_work_mem: { recommended: '256MB', min: '128MB', max: '512MB', type: 'bytes' }, + wal_buffers: { recommended: '16MB', min: '8MB', max: '64MB', type: 'bytes', restart: true }, + min_wal_size: { recommended: '2GB', min: '1GB', max: '4GB', type: 'bytes' }, + max_wal_size: { recommended: '8GB', min: '4GB', max: '16GB', type: 'bytes' }, + checkpoint_timeout: { recommended: '15min', min: '5min', max: '20min', type: 'duration' }, + checkpoint_completion_target: { recommended: '0.9', min: 0.8, max: 0.95, type: 'number' }, + effective_io_concurrency: { recommended: '100', min: 50, max: 300, type: 'number' }, + random_page_cost: { recommended: '1.1', min: 1, max: 1.5, type: 'number' }, + jit: { recommended: 'off', exact: 'off', type: 'text' }, + log_min_duration_statement: { recommended: '500ms', min: '100ms', max: '1000ms', type: 'duration' }, + idle_in_transaction_session_timeout: { recommended: '60s', min: '30s', max: '120s', type: 'duration' }, + statement_timeout: { recommended: '30s', min: '5s', max: '60s', type: 'duration' }, + lock_timeout: { recommended: '5s', min: '1s', max: '10s', type: 'duration' }, + }, + }, +}; + +export const defaultPostgresTuningProfile = 'shared-host'; + +export function getPostgresTuningProfile(name = defaultPostgresTuningProfile) { + const profile = postgresTuningProfiles[name]; + if (!profile) { + throw new Error(`Unknown PostgreSQL tuning profile: ${name}`); + } + return profile; +} + +export function getPostgresTuningSettingNames(profileName = defaultPostgresTuningProfile) { + return Object.keys(getPostgresTuningProfile(profileName).settings); +} diff --git a/scripts/postgres-tuning-evidence-test.js b/scripts/postgres-tuning-evidence-test.js new file mode 100644 index 00000000..841e82f0 --- /dev/null +++ b/scripts/postgres-tuning-evidence-test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'postgres-tuning-evidence.js'); + +function run(args) { + return spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { + PATH: process.env.PATH || '', + Path: process.env.Path || '', + SystemRoot: process.env.SystemRoot || '', + ComSpec: process.env.ComSpec || '', + TEMP: process.env.TEMP || '', + TMP: process.env.TMP || '', + }, + }); +} + +const sharedSql = run(['--print-sql', '--profile=shared-host']); +assert.equal(sharedSql.status, 0, sharedSql.stderr); +assert.match(sharedSql.stdout, /alter system set shared_buffers = '3GB';/); +assert.match(sharedSql.stdout, /alter system set statement_timeout = '30s';/); +assert.match(sharedSql.stdout, /alter system set jit = 'off';/); + +const dedicatedSql = run(['--print-sql', '--profile=dedicated-db']); +assert.equal(dedicatedSql.status, 0, dedicatedSql.stderr); +assert.match(dedicatedSql.stdout, /alter system set shared_buffers = '4GB';/); +assert.match(dedicatedSql.stdout, /alter system set max_wal_size = '8GB';/); + +const invalidProfile = run(['--print-sql', '--profile=unknown']); +assert.notEqual(invalidProfile.status, 0, 'unknown profile should fail'); +assert.match(`${invalidProfile.stderr}${invalidProfile.stdout}`, /Unknown PostgreSQL tuning profile/); + +console.log('[PASS] PostgreSQL tuning evidence helpers'); diff --git a/scripts/postgres-tuning-evidence.js b/scripts/postgres-tuning-evidence.js index 7de5716c..7d35d401 100644 --- a/scripts/postgres-tuning-evidence.js +++ b/scripts/postgres-tuning-evidence.js @@ -1,30 +1,38 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import pg from 'pg'; +import { + defaultPostgresTuningProfile, + getPostgresTuningProfile, + getPostgresTuningSettingNames, +} from './lib/postgres-tuning-profile.js'; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts'; -const settingNames = [ - 'max_connections', - 'shared_buffers', - 'effective_cache_size', - 'work_mem', - 'maintenance_work_mem', - 'autovacuum_work_mem', - 'wal_buffers', - 'min_wal_size', - 'max_wal_size', - 'checkpoint_timeout', - 'checkpoint_completion_target', - 'effective_io_concurrency', - 'random_page_cost', - 'jit', - 'log_min_duration_statement', - 'idle_in_transaction_session_timeout', - 'statement_timeout', - 'lock_timeout', -]; +function parseArgs(argv) { + const options = { + profile: process.env.PG_TUNING_PROFILE || defaultPostgresTuningProfile, + strict: process.env.PG_TUNING_STRICT === 'true', + printSql: false, + json: false, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--strict') options.strict = true; + else if (arg === '--json') options.json = true; + else if (arg === '--print-sql') options.printSql = true; + else if (arg === '--profile') { + options.profile = argv[index + 1] || options.profile; + index += 1; + } else if (arg.startsWith('--profile=')) { + options.profile = arg.slice('--profile='.length); + } + } + options.profileDefinition = getPostgresTuningProfile(options.profile); + options.settingNames = getPostgresTuningSettingNames(options.profile); + return options; +} function shanghaiStamp(date = new Date()) { const parts = Object.fromEntries( @@ -59,19 +67,184 @@ function redactUrl(value) { return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'); } +function pgUnitMultiplier(unit) { + const text = String(unit || '').trim().toLowerCase(); + if (!text) return 1; + if (text === 'ms') return 1; + if (text === 's') return 1000; + if (text === 'min') return 60_000; + const memoryMatch = text.match(/^(\d+)?\s*(b|kb|mb|gb|tb)$/); + if (!memoryMatch) return 1; + const blockCount = Number(memoryMatch[1] || 1); + const unitName = memoryMatch[2]; + const factors = { + b: 1, + kb: 1024, + mb: 1024 ** 2, + gb: 1024 ** 3, + tb: 1024 ** 4, + }; + return blockCount * factors[unitName]; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const rounded = value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); + return `${rounded}${units[unitIndex]}`; +} + +function formatDuration(ms) { + if (!Number.isFinite(ms)) return ''; + if (ms === 0) return '0ms'; + if (ms % 60_000 === 0) return `${ms / 60_000}min`; + if (ms % 1000 === 0) return `${ms / 1000}s`; + return `${ms}ms`; +} + +function normalizedSettingValue(setting) { + const number = Number(setting.setting); + if (!Number.isFinite(number)) return `${setting.setting}${setting.unit || ''}`; + const unit = String(setting.unit || '').trim(); + if (!unit) return String(setting.setting); + if (/^(?:\d+)?\s*(?:b|kb|mb|gb|tb)$/i.test(unit)) { + return formatBytes(number * pgUnitMultiplier(unit)); + } + if (/^(?:ms|s|min)$/i.test(unit)) { + return formatDuration(number * pgUnitMultiplier(unit)); + } + return `${setting.setting}${unit}`; +} + function explainSetting(setting) { - const value = `${setting.setting}${setting.unit || ''}`; return { name: setting.name, - value, + value: normalizedSettingValue(setting), + rawSetting: setting.setting, + unit: setting.unit || null, source: setting.source, pendingRestart: setting.pending_restart, }; } -function evaluate(settingsRows) { +function parseSettingNumber(setting) { + if (!setting) return Number.NaN; + const number = Number(setting.setting); + return Number.isFinite(number) ? number : Number.NaN; +} + +function parseBytes(value) { + if (typeof value === 'number') return value; + const text = String(value || '').trim().toLowerCase(); + if (!text) return Number.NaN; + const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/); + if (!match) return Number.NaN; + const amount = Number(match[1]); + const unit = match[2] || 'b'; + const factors = { + b: 1, + byte: 1, + bytes: 1, + kb: 1024, + mb: 1024 ** 2, + gb: 1024 ** 3, + tb: 1024 ** 4, + }; + return amount * (factors[unit] || Number.NaN); +} + +function parseDurationMs(value) { + if (typeof value === 'number') return value; + const text = String(value || '').trim().toLowerCase(); + if (!text) return Number.NaN; + const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/); + if (!match) return Number.NaN; + const amount = Number(match[1]); + const unit = match[2] || 'ms'; + const factors = { + ms: 1, + s: 1000, + sec: 1000, + secs: 1000, + second: 1000, + seconds: 1000, + min: 60_000, + mins: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + }; + return amount * (factors[unit] || Number.NaN); +} + +function comparableValue(setting, rule) { + if (!setting) return Number.NaN; + if (rule.type === 'bytes') return Number(setting.setting) * pgUnitMultiplier(setting.unit); + if (rule.type === 'duration') return Number(setting.setting) * pgUnitMultiplier(setting.unit); + return parseSettingNumber(setting); +} + +function recommendedComparator(rule, key) { + if (rule.type === 'bytes') return parseBytes(rule[key]); + if (rule.type === 'duration') return parseDurationMs(rule[key]); + return Number(rule[key]); +} + +function evaluateProfile(settingsRows, profile) { + const byName = new Map(settingsRows.map(row => [row.name, row])); + const failures = []; + const warnings = []; + + for (const [name, rule] of Object.entries(profile.settings)) { + const setting = byName.get(name); + if (!setting) { + failures.push(`${name} is missing from pg_settings evidence.`); + continue; + } + if (setting.pending_restart) { + failures.push(`${name} has pending_restart=true; restart PostgreSQL before capacity testing.`); + } + if (rule.exact !== undefined) { + const actual = String(setting.setting || '').toLowerCase(); + if (actual !== String(rule.exact).toLowerCase()) { + failures.push(`${name} should be ${rule.exact} for ${profile.label}, got ${setting.setting}.`); + } + continue; + } + if (rule.min !== undefined) { + const actual = comparableValue(setting, rule); + const expected = recommendedComparator(rule, 'min'); + if (!Number.isFinite(actual) || actual < expected) { + failures.push(`${name} is below ${rule.min} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`); + } + } + if (rule.max !== undefined) { + const actual = comparableValue(setting, rule); + const expected = recommendedComparator(rule, 'max'); + if (!Number.isFinite(actual) || actual > expected) { + failures.push(`${name} is above ${rule.max} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`); + } + } + if (rule.recommended !== undefined && String(setting.source || '').toLowerCase() === 'default') { + warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`); + } + } + + return { failures, warnings }; +} + +function evaluate(settingsRows, profile, options, pgStatStatements) { const byName = new Map(settingsRows.map(row => [row.name, row])); const warnings = []; + const failures = []; const maxConnections = Number(byName.get('max_connections')?.setting || 0); const jit = String(byName.get('jit')?.setting || '').toLowerCase(); const statementTimeout = String(byName.get('statement_timeout')?.setting || ''); @@ -84,9 +257,19 @@ function evaluate(settingsRows) { if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.'); if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.'); if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.'); + if (pgStatStatements?.unavailable) warnings.push('pg_stat_statements is unavailable; enable it before production capacity acceptance.'); + + const profileEvaluation = evaluateProfile(settingsRows, profile); + warnings.push(...profileEvaluation.warnings); + if (options.strict) { + failures.push(...profileEvaluation.failures); + if (pgStatStatements?.unavailable) failures.push('pg_stat_statements is unavailable in strict mode.'); + } return { - status: warnings.length ? 'warn' : 'pass', + status: failures.length ? 'fail' : options.strict ? 'pass' : warnings.length ? 'warn' : 'pass', + profile: profile.name, + failures, warnings, }; } @@ -99,9 +282,17 @@ function markdown(report) { '', `数据库:${report.databaseUrl}`, '', + `调参 profile:${report.profile.label} (${report.profile.name})`, + '', `评估:${report.evaluation.status}`, '', ]; + if (report.evaluation.failures.length) { + lines.push('## 阻断项'); + lines.push(''); + for (const failure of report.evaluation.failures) lines.push(`- ${failure}`); + lines.push(''); + } if (report.evaluation.warnings.length) { lines.push('## 警告'); lines.push(''); @@ -158,7 +349,30 @@ function markdown(report) { return `${lines.join('\n')}\n`; } +function sqlQuote(value) { + return String(value).replaceAll("'", "''"); +} + +function alterSystemSql(profile) { + const lines = [ + `-- PostgreSQL tuning profile: ${profile.label}`, + '-- Review current values and take a snapshot before applying this file.', + '-- Run npm run perf:postgres:evidence -- --strict after reload/restart.', + '', + ]; + for (const [name, rule] of Object.entries(profile.settings)) { + lines.push(`alter system set ${name} = '${sqlQuote(rule.recommended)}';`); + } + lines.push('select pg_reload_conf();'); + return `${lines.join('\n')}\n`; +} + async function main() { + const options = parseArgs(process.argv); + if (options.printSql) { + console.log(alterSystemSql(options.profileDefinition)); + return; + } const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 }); try { const settingsRaw = await query( @@ -169,7 +383,7 @@ async function main() { where name = any($1::text[]) order by array_position($1::text[], name) `, - [settingNames], + [options.settingNames], ); const activityByWait = await query( pool, @@ -231,7 +445,12 @@ async function main() { const report = { generatedAt: new Date().toISOString(), databaseUrl: redactUrl(databaseUrl), - evaluation: evaluate(settingsRaw), + profile: { + name: options.profileDefinition.name, + label: options.profileDefinition.label, + description: options.profileDefinition.description, + }, + evaluation: evaluate(settingsRaw, options.profileDefinition, options, pgStatStatements), settings: settingsRaw.map(explainSetting), activityByWait, databaseStats, @@ -239,15 +458,32 @@ async function main() { largeRelations, pgStatStatements, }; + const pendingRestart = settingsRaw.filter(row => row.pending_restart).length; await fs.mkdir(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`); const mdPath = jsonPath.replace(/\.json$/, '.md'); await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); await fs.writeFile(mdPath, markdown(report), 'utf8'); - console.log(`[pg-evidence] status=${report.evaluation.status}`); - for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`); - console.log(`[pg-evidence] wrote ${jsonPath}`); - console.log(`[pg-evidence] wrote ${mdPath}`); + const payload = { + status: report.evaluation.status, + profile: report.profile.name, + warnings: report.evaluation.warnings.length, + failures: report.evaluation.failures.length, + pgStatStatementsAvailable: !pgStatStatements?.unavailable, + pendingRestart, + jsonPath, + mdPath, + }; + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log(`[pg-evidence] status=${report.evaluation.status}`); + for (const failure of report.evaluation.failures) console.log(`[pg-evidence] failure: ${failure}`); + for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`); + console.log(`[pg-evidence] wrote ${jsonPath}`); + console.log(`[pg-evidence] wrote ${mdPath}`); + } + if (options.strict && report.evaluation.failures.length > 0) process.exitCode = 1; } finally { await pool.end(); } diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index b6745c70..3cb0d606 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -130,6 +130,17 @@ assert.ok( 'migration profile mismatch should be reported as a blocker', ); +const missingPostgresTuning = runGate(tempDir => { + const evidence = createEvidence(tempDir); + evidence.checks = evidence.checks.filter(item => item.id !== 'postgres.tuning-evidence'); + return evidence; +}); +assert.notEqual(missingPostgresTuning.status, 0, 'missing PostgreSQL tuning evidence should fail launch gate'); +assert.ok( + missingPostgresTuning.payload.checks?.some(item => item.id === 'check.postgres.tuning-evidence' && item.status === 'blocker'), + 'missing PostgreSQL tuning evidence should be reported as a blocker', +); + const slowBenchmark = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js index f91367a5..f352c3a1 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -20,6 +20,18 @@ const gateChecks = [ commandIncludes: 'readiness:production:db', summary: { blocker: 0 }, }, + { + id: 'postgres.tuning-evidence', + label: 'PostgreSQL 4c16g tuning evidence', + commandIncludes: 'perf:postgres:evidence', + summary: { + status: 'pass', + profile: { oneOf: ['shared-host', 'dedicated-db'] }, + failures: 0, + pgStatStatementsAvailable: true, + pendingRestart: 0, + }, + }, { id: 'auth.remote-smoke', label: 'Remote Supabase Auth/JWKS smoke',