Why: collaboration storage U7 (proposal sections 3/5/7) requires two-process HTTP-level acceptance over shared PG, a reproducible load-test entry honoring the load contract, and an auditable pre-release checklist; capacity windows and dual-host runs need user-scheduled resources, so the deliverable here is the verifiable entry, not capacity claims. Evidence: make test green (composite); make test-multi-instance green locally against an isolated PG (five scenarios incl. crash-takeover and restart invariants); run_load.sh --profile smoke end-to-end green (SSE probe p95~18ms, zero seq duplicates, stack auto-recycled); backend pinned to feature branch with the three cross-instance fixes. Impact: new tests/integration/multi-instance (CI test-pg job step), new tests/load/collaboration (k6 + SSE probe + run_load.sh), release checklist at docs/release-multi-instance.md; no runtime behavior change in Dev repo itself. Refs: backend PR #164; task card feature/u7-load-fault-release.
138 lines
6.4 KiB
JavaScript
138 lines
6.4 KiB
JavaScript
// 协作存储 U7 压测(方案 §3 负载合同 / §5 U7):k6 覆盖非模型普通 API
|
||
// 的吞吐与延迟阶梯;SSE 可见延迟 P95 由 Go 探针(../sse_probe)承担——
|
||
// k6 核心不支持 SSE 流式读取,两者分工记录在同一份结果目录。
|
||
//
|
||
// 目标实例:run_load.sh 起的隔离栈(mock 模式 backend + 隔离 PG),绝不打
|
||
// 生产服务。预期限流/冲突(409/429)单列计数,不计入失败。
|
||
//
|
||
// 阶梯与环境变量(由 run_load.sh 注入):
|
||
// BASE_URL 目标基址(默认 http://127.0.0.1:18980)
|
||
// ADMIN_* 引导管理员(run_load.sh 预建;setup 里登录后建压测用户)
|
||
// USERS 压测用户数(默认 30;每 VU 绑定一个用户)
|
||
// PROFILE smoke | ladder | sustained60m | sustained2h(默认 smoke)
|
||
//
|
||
// 阈值(负载合同,待实测后校准):
|
||
// 读路径 p(95)<300ms、全量 p(99)<1s、非预期失败率<0.1%。
|
||
|
||
import http from 'k6/http';
|
||
import { check, group, sleep } from 'k6';
|
||
import { Counter, Trend } from 'k6/metrics';
|
||
import exec from 'k6/execution';
|
||
|
||
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:18980';
|
||
const ADMIN_OWNER = __ENV.ADMIN_OWNER || 'load_admin';
|
||
const ADMIN_PASS = __ENV.ADMIN_PASS || 'load-admin-pass';
|
||
const USERS = parseInt(__ENV.USERS || '30', 10);
|
||
const PROFILE = __ENV.PROFILE || 'smoke';
|
||
|
||
// 预期拥塞(负载合同要求单列,不算失败):409 会话忙 / 429 队列满。
|
||
const expectedContention = new Counter('expected_contention');
|
||
const msgLatency = new Trend('message_roundtrip', true);
|
||
|
||
const stages = {
|
||
smoke: [{ duration: '30s', target: 10 }],
|
||
ladder: [
|
||
{ duration: '2m', target: 10 },
|
||
{ duration: '3m', target: 30 },
|
||
{ duration: '5m', target: 100 },
|
||
{ duration: '2m', target: 0 },
|
||
],
|
||
sustained60m: [
|
||
{ duration: '2m', target: 30 },
|
||
{ duration: '56m', target: 30 },
|
||
{ duration: '2m', target: 0 },
|
||
],
|
||
sustained2h: [
|
||
{ duration: '3m', target: 30 },
|
||
{ duration: '114m', target: 30 },
|
||
{ duration: '3m', target: 0 },
|
||
],
|
||
};
|
||
|
||
export const options = {
|
||
stages: stages[PROFILE] || stages.smoke,
|
||
thresholds: {
|
||
// 非预期失败:2xx 之外的(409/429 已在 expected_contention 单列)。
|
||
http_req_failed: ['rate<0.001'],
|
||
'http_req_duration{group:::read}': ['p(95)<300', 'p(99)<1000'],
|
||
http_req_duration: ['p(99)<1000'],
|
||
},
|
||
summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'],
|
||
};
|
||
|
||
// ---- setup:管理员建压测用户,各自建项目与会话(数据集随用户数扩展) ----
|
||
|
||
export function setup() {
|
||
// setup 上下文的 cookie jar 自动附着(k6 语义:jar 不作为请求器使用)。
|
||
const login = http.post(`${BASE}/api/auth/login`, JSON.stringify({ owner: ADMIN_OWNER, password: ADMIN_PASS }), { headers: { 'Content-Type': 'application/json' } });
|
||
if (login.status !== 200) throw new Error(`admin login: ${login.status} ${login.body}`);
|
||
// 首登强制改密由 run_load.sh 预处理(CLI 建号即带终态口令),此处直接可用。
|
||
|
||
const users = [];
|
||
for (let i = 0; i < USERS; i++) {
|
||
const owner = `load_u${i}`;
|
||
const password = `load-user-pass-${i}-Aa9`;
|
||
const created = http.post(`${BASE}/api/admin/users`, JSON.stringify({ owner, name: `压测用户${i}`, password, isAdmin: false }), { headers: { 'Content-Type': 'application/json' } });
|
||
// 409 = 已存在(重复压测同一栈),接受。
|
||
if (created.status !== 200 && created.status !== 409) throw new Error(`create user ${owner}: ${created.status} ${created.body}`);
|
||
users.push({ owner, password });
|
||
}
|
||
return { users };
|
||
}
|
||
|
||
// ---- default:每 VU 一个用户身份,混合读写 ----
|
||
|
||
export default function (data) {
|
||
const u = data.users[exec.scenario.iterationInTest % data.users.length];
|
||
// VU 上下文的 cookie jar 自动附着;每 VU 绑定一个用户身份。
|
||
const login = http.post(`${BASE}/api/auth/login`, JSON.stringify({ owner: u.owner, password: u.password }), { headers: { 'Content-Type': 'application/json' } });
|
||
if (login.status !== 200) {
|
||
check(null, { 'user login': () => false });
|
||
return;
|
||
}
|
||
// 会话/项目绑定 VU(VU 内多次迭代复用同一身份的会话,形成热点会话)。
|
||
if (!u.__ctx) {
|
||
const proj = http.post(`${BASE}/api/projects`, JSON.stringify({ name: `压测-${u.owner}` }), { headers: { 'Content-Type': 'application/json' } });
|
||
const conv = http.post(`${BASE}/api/projects/${proj.json().id}/conversations`, JSON.stringify({ title: '压测会话' }), { headers: { 'Content-Type': 'application/json' } });
|
||
u.__ctx = { projectID: proj.json().id, convID: conv.json().id };
|
||
}
|
||
const { projectID, convID } = u.__ctx;
|
||
|
||
group('read', () => {
|
||
const projs = http.get(`${BASE}/api/projects`);
|
||
check(projs, { 'list projects 2xx': (r) => r.status === 200 });
|
||
const detail = http.get(`${BASE}/api/conversations/${convID}`);
|
||
check(detail, { 'conversation detail 2xx': (r) => r.status === 200 });
|
||
const cards = http.get(`${BASE}/api/conversations/${convID}/cards`);
|
||
check(cards, { 'cards 2xx': (r) => r.status === 200 });
|
||
const settings = http.get(`${BASE}/api/settings`);
|
||
check(settings, { 'settings 2xx': (r) => r.status === 200 });
|
||
});
|
||
|
||
group('write', () => {
|
||
const requestId = `req-${u.owner}-${exec.scenario.iterationInTest}`;
|
||
const body = JSON.stringify({ text: `压测消息 ${Date.now()}`, requestId });
|
||
const t0 = Date.now();
|
||
const sent = http.post(`${BASE}/api/conversations/${convID}/messages`, body, { headers: { 'Content-Type': 'application/json' } });
|
||
if (sent.status === 200) {
|
||
msgLatency.add(Date.now() - t0);
|
||
const id = sent.json().message && sent.json().message.id;
|
||
check(sent, { 'message has id': () => !!id });
|
||
// 幂等重放(矩阵行:同 requestId 返回同一逻辑结果):抽样 10%。
|
||
if (exec.scenario.iterationInTest % 10 === 0) {
|
||
const replay = http.post(`${BASE}/api/conversations/${convID}/messages`, body, { headers: { 'Content-Type': 'application/json' } });
|
||
check(replay, {
|
||
'requestId replay same message': (r) => r.status === 200 && r.json().message && r.json().message.id === id,
|
||
});
|
||
}
|
||
} else if (sent.status === 409 || sent.status === 429) {
|
||
// 热点会话忙/队列满:预期拥塞,单列。
|
||
expectedContention.add(1);
|
||
} else {
|
||
check(sent, { 'message send 2xx': () => false });
|
||
}
|
||
});
|
||
|
||
sleep(1);
|
||
}
|