forked from wangziqi/gongxue-base
feat: add docker capacity benchmark mode
This commit is contained in:
@@ -557,6 +557,15 @@ docs/refactor/postgresql-4c16g-tuning.md
|
|||||||
docs/refactor/performance-benchmark-runbook.md
|
docs/refactor/performance-benchmark-runbook.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本地 Docker Desktop 可用时,可以先用受限 API 容器做 4 核 16G shared-host 风格的预演。该入口默认把 API 容器限制为 2 CPU/4G、关闭 legacy `x-user-id`,并用 `Authorization: Bearer <tk_session>` 跑 30 只读、50/100 混合读写矩阵:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
|
||||||
|
npm run perf:api:docker-4c16g
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:这个入口只限制 API 容器资源,本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。
|
||||||
|
|
||||||
PostgreSQL 调参与运行证据采集:
|
PostgreSQL 调参与运行证据采集:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN npm ci --workspaces --include-workspace-root
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY package.json package-lock.json tsconfig.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY apps/api ./apps/api
|
COPY apps/api ./apps/api
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
RUN npm run build:api
|
RUN npm run build:api
|
||||||
|
|||||||
15
docker-compose.api.benchmark.yml
Normal file
15
docker-compose.api.benchmark.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: tiku-saas-benchmark
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
ALLOW_LEGACY_AUTH_HEADERS: "false"
|
||||||
|
ALLOW_PLATFORM_ADMIN_KEY: "false"
|
||||||
|
AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET:-development-session-secret-change-me}
|
||||||
|
DB_POOL_MAX: ${DB_POOL_MAX:-10}
|
||||||
|
MAX_JSON_BODY_BYTES: "1048576"
|
||||||
|
MAX_IMPORT_JSON_BODY_BYTES: "10485760"
|
||||||
|
cpus: "${BENCHMARK_API_CPUS:-2.0}"
|
||||||
|
mem_limit: "${BENCHMARK_API_MEMORY:-4g}"
|
||||||
|
memswap_limit: "${BENCHMARK_API_MEMORY:-4g}"
|
||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://postgres:postgres@host.docker.internal:54322/postgres
|
DATABASE_URL: postgresql://postgres:postgres@host.docker.internal:54322/postgres
|
||||||
DEFAULT_TENANT_SLUG: master
|
DEFAULT_TENANT_SLUG: master
|
||||||
CORS_ORIGIN: http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174
|
CORS_ORIGIN: http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174
|
||||||
|
DB_POOL_MAX: 10
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- `apps/api`:新业务 API 服务,内部按 `src/core` 和 `src/features` 分层。
|
- `apps/api`:新业务 API 服务,内部按 `src/core` 和 `src/features` 分层。
|
||||||
- `packages/config`、`packages/db`、`packages/domain`:新系统共享基础包。
|
- `packages/config`、`packages/db`、`packages/domain`:新系统共享基础包。
|
||||||
- `scripts/import-pocketbase`:PocketBase schema/数据导入工具。
|
- `scripts/import-pocketbase`:PocketBase schema/数据导入工具。
|
||||||
- `docker-compose.api.yml`、`apps/api/Dockerfile`:本地 Docker API 运行入口。
|
- `docker-compose.api.yml`、`docker-compose.api.benchmark.yml`、`apps/api/Dockerfile`:本地 Docker API 和受限资源压测入口。
|
||||||
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
|
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
|
||||||
- `docs/refactor/ai-development-guardrails.md`:后续 AI/开发者必须遵守的 Supabase-first 架构和安全守则。
|
- `docs/refactor/ai-development-guardrails.md`:后续 AI/开发者必须遵守的 Supabase-first 架构和安全守则。
|
||||||
- `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。
|
- `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
- `docs/refactor/taro-frontend-integration.md`:Taro/H5/小程序启动、请求封装、页面/API 映射。
|
- `docs/refactor/taro-frontend-integration.md`:Taro/H5/小程序启动、请求封装、页面/API 映射。
|
||||||
- `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。
|
- `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。
|
||||||
- `docs/refactor/postgresql-4c16g-tuning.md`:4 核 16G 自托管 PostgreSQL 起步调参、观察 SQL 和回滚方式。
|
- `docs/refactor/postgresql-4c16g-tuning.md`:4 核 16G 自托管 PostgreSQL 起步调参、观察 SQL 和回滚方式。
|
||||||
- `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵和报告归档方式。
|
- `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵、Docker 受限资源预演和报告归档方式。
|
||||||
- `docs/refactor/performance-benchmark-summary-20260630.md`:真实迁移数据压测脱敏摘要。
|
- `docs/refactor/performance-benchmark-summary-20260630.md`:真实迁移数据压测脱敏摘要。
|
||||||
- `docs/refactor/backend-open-items-and-capacity-20260701.md`:后端剩余功能、已定稿产品口径和最新在线容量估算。
|
- `docs/refactor/backend-open-items-and-capacity-20260701.md`:后端剩余功能、已定稿产品口径和最新在线容量估算。
|
||||||
- `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。
|
- `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ npm run docker:api:up
|
|||||||
|
|
||||||
如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`。
|
如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`。
|
||||||
|
|
||||||
|
本地容量预演可以使用专门的 benchmark override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run perf:api:docker-4c16g
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会使用 `docker-compose.api.yml` + `docker-compose.api.benchmark.yml` 启动受限 API 容器,默认限制 API 为 2 CPU/4G、`DB_POOL_MAX=10`,关闭 `ALLOW_LEGACY_AUTH_HEADERS`,并用 Bearer `tk_` 会话跑压测。它只用于本地模拟和跑分,不是生产 compose 文件。
|
||||||
|
|
||||||
验证 API:
|
验证 API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,6 +89,7 @@ npm run pb:import:validate
|
|||||||
|
|
||||||
- Docker Desktop 可用。
|
- Docker Desktop 可用。
|
||||||
- Supabase 本地容器可启动。
|
- Supabase 本地容器可启动。
|
||||||
|
- API Dockerfile 已验证可构建;benchmark override 可启动受限 API 容器并通过短压测 smoke。
|
||||||
- `supabase db reset` 可完整执行三份 migration 和 seed。
|
- `supabase db reset` 可完整执行三份 migration 和 seed。
|
||||||
- `supabase db reset` 可完整执行全部 migration 和 seed。
|
- `supabase db reset` 可完整执行全部 migration 和 seed。
|
||||||
- `npm run db:smoke-seed` 可恢复最小业务烟测数据。
|
- `npm run db:smoke-seed` 可恢复最小业务烟测数据。
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ npm run perf:api:local
|
|||||||
- 默认不创建练习 session,不写业务数据。
|
- 默认不创建练习 session,不写业务数据。
|
||||||
- 输出 JSON 和 Markdown 报告到 `docs/refactor/performance-reports/`。该目录已被 `.gitignore` 忽略,不应提交。
|
- 输出 JSON 和 Markdown 报告到 `docs/refactor/performance-reports/`。该目录已被 `.gitignore` 忽略,不应提交。
|
||||||
|
|
||||||
|
默认本地兼容模式使用 `x-user-id` 作为压测身份,只适合开发机。更接近 Taro/Supabase 接入方式的压测应启用迁移期 Bearer session:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PERF_AUTH_MODE="app_session"
|
||||||
|
npm run perf:api:local
|
||||||
|
Remove-Item Env:\PERF_AUTH_MODE
|
||||||
|
```
|
||||||
|
|
||||||
|
`app_session` 模式会在测试库中为压测学生创建 2 小时 `tk_` 会话,然后用 `Authorization: Bearer <tk_...>` 请求业务接口;API 可以关闭 `ALLOW_LEGACY_AUTH_HEADERS`。生产远程压测如果已经有真实 Supabase access token,也可以用 `PERF_AUTH_MODE=bearer` 和 `PERF_BEARER_TOKEN`。
|
||||||
|
|
||||||
PostgreSQL 调参与运行证据:
|
PostgreSQL 调参与运行证据:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,6 +93,8 @@ npm run perf:api:local
|
|||||||
| `PERF_INCLUDE_LEADERBOARD` | `false` | 是否加入排行榜接口;排行榜租户默认关闭,仅在租户明确开启并需要专项压测时打开 |
|
| `PERF_INCLUDE_LEADERBOARD` | `false` | 是否加入排行榜接口;排行榜租户默认关闭,仅在租户明确开启并需要专项压测时打开 |
|
||||||
| `PERF_OUTPUT_DIR` | `docs/refactor/performance-reports` | 报告输出目录 |
|
| `PERF_OUTPUT_DIR` | `docs/refactor/performance-reports` | 报告输出目录 |
|
||||||
| `PERF_REQUEST_TIMEOUT_MS` | `15000` | 单请求超时 |
|
| `PERF_REQUEST_TIMEOUT_MS` | `15000` | 单请求超时 |
|
||||||
|
| `PERF_AUTH_MODE` | `legacy` | `legacy` 使用本地 `x-user-id`;`app_session` 创建 `tk_` Bearer session;`bearer` 使用 `PERF_BEARER_TOKEN`;`none` 只适合公开接口 |
|
||||||
|
| `PERF_BEARER_TOKEN` | 空 | `PERF_AUTH_MODE=bearer` 时使用 |
|
||||||
|
|
||||||
## 默认工作负载
|
## 默认工作负载
|
||||||
|
|
||||||
@@ -138,6 +150,37 @@ npm run perf:api:local
|
|||||||
|
|
||||||
本地 Docker Desktop 可以先用同一矩阵做跑分,但只能证明代码、索引和本机 Docker 环境的趋势。正式容量承诺必须以目标云服务器、生产 PostgreSQL 参数、生产 API/worker 连接池、对象存储/CDN 和真实网络重新跑。
|
本地 Docker Desktop 可以先用同一矩阵做跑分,但只能证明代码、索引和本机 Docker 环境的趋势。正式容量承诺必须以目标云服务器、生产 PostgreSQL 参数、生产 API/worker 连接池、对象存储/CDN 和真实网络重新跑。
|
||||||
|
|
||||||
|
## 本地 Docker 4c16g 模拟入口
|
||||||
|
|
||||||
|
仓库提供一个 Docker API 受限资源压测入口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
|
||||||
|
$env:BENCHMARK_API_CPUS="2.0"
|
||||||
|
$env:BENCHMARK_API_MEMORY="4g"
|
||||||
|
$env:DB_POOL_MAX="10"
|
||||||
|
npm run perf:api:docker-4c16g
|
||||||
|
Remove-Item Env:\BENCHMARK_API_CPUS
|
||||||
|
Remove-Item Env:\BENCHMARK_API_MEMORY
|
||||||
|
Remove-Item Env:\DB_POOL_MAX
|
||||||
|
```
|
||||||
|
|
||||||
|
它会使用 `docker-compose.api.yml` + `docker-compose.api.benchmark.yml` 构建并启动 API 容器,默认限制 API 容器为 2 CPU/4G,关闭 `ALLOW_LEGACY_AUTH_HEADERS`,然后用 `PERF_AUTH_MODE=app_session` 跑:
|
||||||
|
|
||||||
|
- 30 worker / 120s / 只读上线门禁。
|
||||||
|
- 50 worker / 60s / 10% 刷题闭环写入。
|
||||||
|
- 100 worker / 60s / 8% 刷题闭环写入。
|
||||||
|
|
||||||
|
这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。
|
||||||
|
|
||||||
|
如需保留 API 容器便于排查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PERF_KEEP_DOCKER_API="true"
|
||||||
|
npm run perf:api:docker-4c16g
|
||||||
|
Remove-Item Env:\PERF_KEEP_DOCKER_API
|
||||||
|
```
|
||||||
|
|
||||||
PowerShell 示例:
|
PowerShell 示例:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js",
|
"smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js",
|
||||||
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
||||||
"perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js",
|
"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:evidence": "node scripts/postgres-tuning-evidence.js",
|
||||||
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
"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:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ export interface DbPoolOptions {
|
|||||||
idleTimeoutMillis?: number;
|
idleTimeoutMillis?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function envPoolMax() {
|
||||||
|
const value = Number(process.env.DB_POOL_MAX || 10);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 10;
|
||||||
|
return Math.trunc(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function createPool(options: DbPoolOptions = {}) {
|
export function createPool(options: DbPoolOptions = {}) {
|
||||||
return new Pool({
|
return new Pool({
|
||||||
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||||
max: options.max || 10,
|
max: options.max || envPoolMax(),
|
||||||
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
|
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -23,6 +24,9 @@ const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master';
|
|||||||
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
|
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
|
||||||
const MAX_ERRORS_TO_KEEP = envNumber('PERF_MAX_ERRORS', 20);
|
const MAX_ERRORS_TO_KEEP = envNumber('PERF_MAX_ERRORS', 20);
|
||||||
const DEFAULT_TIMEOUT_MS = envNumber('PERF_REQUEST_TIMEOUT_MS', 15_000);
|
const DEFAULT_TIMEOUT_MS = envNumber('PERF_REQUEST_TIMEOUT_MS', 15_000);
|
||||||
|
const AUTH_MODE = normalizeAuthMode(process.env.PERF_AUTH_MODE || 'legacy');
|
||||||
|
const STATIC_BEARER_TOKEN = process.env.PERF_BEARER_TOKEN || '';
|
||||||
|
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me';
|
||||||
|
|
||||||
let apiBase = API_BASE_ENV || '';
|
let apiBase = API_BASE_ENV || '';
|
||||||
let serverProcess = null;
|
let serverProcess = null;
|
||||||
@@ -44,6 +48,14 @@ function envNumber(key, fallback, options = {}) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAuthMode(value) {
|
||||||
|
const normalized = String(value || '').toLowerCase().replace(/[-_]/g, '');
|
||||||
|
if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session';
|
||||||
|
if (normalized === 'bearer' || normalized === 'jwt' || normalized === 'token') return 'bearer';
|
||||||
|
if (normalized === 'none' || normalized === 'anonymous') return 'none';
|
||||||
|
return 'legacy';
|
||||||
|
}
|
||||||
|
|
||||||
function getFreePort() {
|
function getFreePort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -91,6 +103,7 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
const tenantId = context.tenantId || context.tenant?.id;
|
const tenantId = context.tenantId || context.tenant?.id;
|
||||||
const userId = context.userId || context.user?.id;
|
const userId = context.userId || context.user?.id;
|
||||||
|
const authToken = context.authToken || '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildUrl(apiBase, endpoint), {
|
const response = await fetch(buildUrl(apiBase, endpoint), {
|
||||||
method: endpoint.method || 'GET',
|
method: endpoint.method || 'GET',
|
||||||
@@ -98,7 +111,8 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...(endpoint.tenantHeader === false || !tenantId ? {} : { 'x-tenant-id': tenantId }),
|
...(endpoint.tenantHeader === false || !tenantId ? {} : { 'x-tenant-id': tenantId }),
|
||||||
...(endpoint.userHeader === false || !userId ? {} : { 'x-user-id': userId }),
|
...(AUTH_MODE === 'legacy' && endpoint.userHeader !== false && userId ? { 'x-user-id': userId } : {}),
|
||||||
|
...(endpoint.authHeader === false || !authToken ? {} : { authorization: `Bearer ${authToken}` }),
|
||||||
...(endpoint.headers || {}),
|
...(endpoint.headers || {}),
|
||||||
},
|
},
|
||||||
body: endpoint.body ? JSON.stringify(endpoint.body(context)) : undefined,
|
body: endpoint.body ? JSON.stringify(endpoint.body(context)) : undefined,
|
||||||
@@ -311,6 +325,7 @@ async function startServerIfNeeded() {
|
|||||||
...process.env,
|
...process.env,
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
|
AUTH_SESSION_SECRET,
|
||||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576',
|
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576',
|
||||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760',
|
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760',
|
||||||
},
|
},
|
||||||
@@ -342,6 +357,46 @@ async function many(pool, sql, params = []) {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSessionToken() {
|
||||||
|
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSessionToken(token) {
|
||||||
|
return crypto.createHmac('sha256', AUTH_SESSION_SECRET).update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBenchmarkAuthToken(context) {
|
||||||
|
if (AUTH_MODE === 'none' || AUTH_MODE === 'legacy') return '';
|
||||||
|
if (AUTH_MODE === 'bearer') {
|
||||||
|
if (!STATIC_BEARER_TOKEN) throw new Error('PERF_BEARER_TOKEN is required when PERF_AUTH_MODE=bearer.');
|
||||||
|
return STATIC_BEARER_TOKEN;
|
||||||
|
}
|
||||||
|
if (AUTH_MODE !== 'app_session') return '';
|
||||||
|
if (!context.tenant?.id || !context.user?.id) {
|
||||||
|
throw new Error('PERF_AUTH_MODE=app_session requires discovered tenant and user context.');
|
||||||
|
}
|
||||||
|
const token = createSessionToken();
|
||||||
|
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2 });
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into app_private.auth_sessions (
|
||||||
|
tenant_id, user_id, token_hash, provider, expires_at, metadata
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, 'performance_benchmark',
|
||||||
|
now() + interval '2 hours',
|
||||||
|
'{"createdBy":"api-performance-benchmark","authMode":"app_session"}'::jsonb
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[context.tenant.id, context.user.id, hashSessionToken(token)],
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function discoverBenchmarkContext() {
|
async function discoverBenchmarkContext() {
|
||||||
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 4 });
|
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 4 });
|
||||||
try {
|
try {
|
||||||
@@ -790,6 +845,7 @@ async function runBenchmark(context) {
|
|||||||
concurrency: CONCURRENCY,
|
concurrency: CONCURRENCY,
|
||||||
rampSeconds: RAMP_SECONDS,
|
rampSeconds: RAMP_SECONDS,
|
||||||
includeWrites: INCLUDE_WRITES,
|
includeWrites: INCLUDE_WRITES,
|
||||||
|
authMode: AUTH_MODE,
|
||||||
practiceFlowRatio: INCLUDE_WRITES ? PRACTICE_FLOW_RATIO : 0,
|
practiceFlowRatio: INCLUDE_WRITES ? PRACTICE_FLOW_RATIO : 0,
|
||||||
practiceFlowAnswers: INCLUDE_WRITES ? PRACTICE_FLOW_ANSWERS : 0,
|
practiceFlowAnswers: INCLUDE_WRITES ? PRACTICE_FLOW_ANSWERS : 0,
|
||||||
questionLimit: QUESTION_LIMIT,
|
questionLimit: QUESTION_LIMIT,
|
||||||
@@ -906,10 +962,11 @@ async function main() {
|
|||||||
throw new Error('PERF_API_BASE is required when PERF_START_SERVER=false.');
|
throw new Error('PERF_API_BASE is required when PERF_START_SERVER=false.');
|
||||||
}
|
}
|
||||||
const context = await discoverBenchmarkContext();
|
const context = await discoverBenchmarkContext();
|
||||||
|
context.authToken = await createBenchmarkAuthToken(context);
|
||||||
await startServerIfNeeded();
|
await startServerIfNeeded();
|
||||||
if (!apiBase) throw new Error('API base URL was not resolved.');
|
if (!apiBase) throw new Error('API base URL was not resolved.');
|
||||||
console.log(`[perf] target api=${apiBase} tenant=${context.tenant.slug} user=${context.user.id}`);
|
console.log(`[perf] target api=${apiBase} tenant=${context.tenant.slug} user=${context.user.id}`);
|
||||||
console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES}`);
|
console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES} authMode=${AUTH_MODE}`);
|
||||||
const report = await runBenchmark(context);
|
const report = await runBenchmark(context);
|
||||||
const files = await writeReport(report);
|
const files = await writeReport(report);
|
||||||
console.log(`[perf] requests=${report.summary.requests} ok=${report.summary.ok} errors=${report.summary.errors} rps=${report.summary.throughputRps} p95=${report.summary.latencyOk.p95Ms}ms`);
|
console.log(`[perf] requests=${report.summary.requests} ok=${report.summary.ok} errors=${report.summary.errors} rps=${report.summary.throughputRps} p95=${report.summary.latencyOk.p95Ms}ms`);
|
||||||
|
|||||||
95
scripts/run-docker-4c16g-benchmark.js
Normal file
95
scripts/run-docker-4c16g-benchmark.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||||
|
const apiBase = process.env.PERF_API_BASE || 'http://127.0.0.1:8787';
|
||||||
|
const matrix = [
|
||||||
|
{ name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 },
|
||||||
|
{ name: 'mixed-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 },
|
||||||
|
{ name: 'mixed-100', duration: 60, concurrency: 100, ramp: 15, writes: true, ratio: 0.08 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me',
|
||||||
|
DB_POOL_MAX: process.env.DB_POOL_MAX || '10',
|
||||||
|
BENCHMARK_API_CPUS: process.env.BENCHMARK_API_CPUS || '2.0',
|
||||||
|
BENCHMARK_API_MEMORY: process.env.BENCHMARK_API_MEMORY || '4g',
|
||||||
|
...options.env,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestBenchmarkJson(before) {
|
||||||
|
const dir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports');
|
||||||
|
const files = fs.existsSync(dir)
|
||||||
|
? fs.readdirSync(dir)
|
||||||
|
.filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name))
|
||||||
|
.map(name => path.join(dir, name))
|
||||||
|
.filter(file => fs.statSync(file).mtimeMs >= before)
|
||||||
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
||||||
|
: [];
|
||||||
|
if (!files[0]) throw new Error('Benchmark report JSON was not generated.');
|
||||||
|
return path.relative(repoRoot, files[0]).replaceAll('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForHealth(timeoutMs = 60_000) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
const result = spawnSync(process.execPath, ['-e', `fetch('${apiBase}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'ignore',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
if (result.status === 0) return;
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
|
||||||
|
}
|
||||||
|
throw new Error(`API did not become healthy at ${apiBase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const generated = [];
|
||||||
|
try {
|
||||||
|
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
|
||||||
|
waitForHealth();
|
||||||
|
for (const item of matrix) {
|
||||||
|
const before = Date.now();
|
||||||
|
run('node', ['scripts/api-performance-benchmark.js'], {
|
||||||
|
env: {
|
||||||
|
PERF_START_SERVER: 'false',
|
||||||
|
PERF_API_BASE: apiBase,
|
||||||
|
PERF_AUTH_MODE: 'app_session',
|
||||||
|
PERF_DURATION_SECONDS: String(item.duration),
|
||||||
|
PERF_CONCURRENCY: String(item.concurrency),
|
||||||
|
PERF_RAMP_SECONDS: String(item.ramp),
|
||||||
|
PERF_INCLUDE_WRITES: item.writes ? 'true' : 'false',
|
||||||
|
PERF_PRACTICE_FLOW_RATIO: String(item.ratio),
|
||||||
|
PERF_PRACTICE_FLOW_ANSWERS: '3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
generated.push({ scenario: item.name, report: latestBenchmarkJson(before) });
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ status: 'pass', apiBase, reports: generated }, null, 2));
|
||||||
|
} finally {
|
||||||
|
if (process.env.PERF_KEEP_DOCKER_API !== 'true') {
|
||||||
|
spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user