Why: Admin-created load users still have must_change; business APIs return 403 text and k6 json() crashes. Evidence: brew k6 v2.3.0 smoke --seed tiny (653/653 checks, fail=0); --instances 2 (765/765, cross-read 30, dup_seq=0). Impact: Load suite matches the real first-login set-password flow; no SQL backdoor for k6 users. Refs: server/auth.go enforcePasswordGate; U7 run_load.sh Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
10 KiB
JavaScript
234 lines
10 KiB
JavaScript
// 协作存储 U7 压测(方案 §3 负载合同 / §5 U7):k6 覆盖非模型普通 API
|
||
// 的吞吐与延迟阶梯;SSE 可见延迟 P95 由 Go 探针(../sse_probe)承担——
|
||
// k6 核心不支持 SSE 流式读取,两者分工记录在同一份结果目录。
|
||
//
|
||
// 目标实例:run_load.sh 起的隔离栈(mock 模式 backend + 隔离 PG),绝不打
|
||
// 生产服务。预期限流/冲突(409/429)单列计数,不计入失败。
|
||
// C2 双实例:BASE_URLS=a,b 时 VU 粘滞到实例;热点会话跨实例写触发 409;
|
||
// 另有跨实例读抽样(A 拓扑在 B 上 GET)。
|
||
//
|
||
// 阶梯与环境变量(由 run_load.sh 注入):
|
||
// BASE_URL / BASE_URLS 单址或逗号分隔双址
|
||
// 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_SINGLE = __ENV.BASE_URL || 'http://127.0.0.1:18980';
|
||
const BASE_URLS = (__ENV.BASE_URLS || BASE_SINGLE)
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
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 crossReadOk = new Counter('cross_instance_read_ok');
|
||
|
||
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 经 expectedStatuses 排除后进 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'],
|
||
};
|
||
|
||
// 会话忙 / 队列满为负载合同预期拥塞,不计入 http_req_failed。
|
||
http.setResponseCallback(http.expectedStatuses(200, 201, 202, 409, 429));
|
||
|
||
function baseForVU() {
|
||
// VU 粘滞:偶数 → 0,奇数 → 1(单实例时恒为 0)。
|
||
const idx = exec.vu.idInTest % BASE_URLS.length;
|
||
return BASE_URLS[idx];
|
||
}
|
||
|
||
function otherBase(mine) {
|
||
if (BASE_URLS.length < 2) return mine;
|
||
return BASE_URLS[0] === mine ? BASE_URLS[1] : BASE_URLS[0];
|
||
}
|
||
|
||
const jsonHeaders = { headers: { 'Content-Type': 'application/json' } };
|
||
|
||
function parseJSON(res) {
|
||
try {
|
||
return res.json();
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 与现网一致:管理员建号 → 首登 must_change → POST /api/auth/password → 再登录。
|
||
// 压测口令保持不变(next=同一密码),只走完设密门,不另造后门清标志。
|
||
function loginReady(base, owner, password) {
|
||
const login = http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders);
|
||
if (login.status !== 200) return login;
|
||
const body = parseJSON(login);
|
||
if (body && body.mustChangePassword) {
|
||
const ch = http.post(`${base}/api/auth/password`, JSON.stringify({ next: password }), jsonHeaders);
|
||
if (ch.status !== 200) return ch;
|
||
return http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders);
|
||
}
|
||
return login;
|
||
}
|
||
|
||
// ---- setup:管理员建压测用户;双实例时建共享热点会话 ----
|
||
|
||
export function setup() {
|
||
const adminBase = BASE_URLS[0];
|
||
const adminLogin = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS);
|
||
if (adminLogin.status !== 200) throw new Error(`admin login: ${adminLogin.status} ${adminLogin.body}`);
|
||
|
||
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(`${adminBase}/api/admin/users`, JSON.stringify({ owner, name: `压测用户${i}`, password, isAdmin: false }), jsonHeaders);
|
||
if (created.status !== 200 && created.status !== 409) throw new Error(`create user ${owner}: ${created.status} ${created.body}`);
|
||
users.push({ owner, password });
|
||
}
|
||
// 建号必须保持管理员会话;设密会换 cookie,只能在全员建完后逐个走门。
|
||
for (const u of users) {
|
||
const gated = loginReady(adminBase, u.owner, u.password);
|
||
if (gated.status !== 200) throw new Error(`activate ${u.owner}: ${gated.status} ${gated.body}`);
|
||
}
|
||
|
||
// 热点会话:多 VU 跨实例争抢同一会话(C2「互相不拖累」的争用源)。
|
||
// 建用户时 cookie 落在最后一位 load_u* 上,热点资源须用管理员会话。
|
||
const adminAgain = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS);
|
||
if (adminAgain.status !== 200) throw new Error(`admin re-login: ${adminAgain.status} ${adminAgain.body}`);
|
||
let hotspot = null;
|
||
if (BASE_URLS.length >= 2) {
|
||
const proj = http.post(`${adminBase}/api/projects`, JSON.stringify({ name: 'hotspot-shared' }), jsonHeaders);
|
||
if (proj.status !== 200) throw new Error(`hotspot project: ${proj.status} ${proj.body}`);
|
||
const projBody = parseJSON(proj);
|
||
const conv = http.post(`${adminBase}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '热点会话' }), jsonHeaders);
|
||
if (conv.status !== 200) throw new Error(`hotspot conv: ${conv.status} ${conv.body}`);
|
||
hotspot = { projectID: projBody.id, convID: parseJSON(conv).id, owner: ADMIN_OWNER, password: ADMIN_PASS };
|
||
}
|
||
|
||
return { users, hotspot, bases: BASE_URLS };
|
||
}
|
||
|
||
// ---- default:每 VU 一个用户身份,混合读写 ----
|
||
|
||
export default function (data) {
|
||
const base = baseForVU();
|
||
const u = data.users[exec.scenario.iterationInTest % data.users.length];
|
||
const login = loginReady(base, u.owner, u.password);
|
||
if (login.status !== 200) {
|
||
check(null, { 'user login': () => false });
|
||
return;
|
||
}
|
||
|
||
if (!u.__ctx) {
|
||
const proj = http.post(`${base}/api/projects`, JSON.stringify({ name: `压测-${u.owner}` }), jsonHeaders);
|
||
const projBody = parseJSON(proj);
|
||
if (proj.status !== 200 || !projBody || !projBody.id) {
|
||
check(proj, { 'create project 2xx': () => false });
|
||
return;
|
||
}
|
||
const conv = http.post(`${base}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '压测会话' }), jsonHeaders);
|
||
const convBody = parseJSON(conv);
|
||
if (conv.status !== 200 || !convBody || !convBody.id) {
|
||
check(conv, { 'create conversation 2xx': () => false });
|
||
return;
|
||
}
|
||
u.__ctx = { projectID: projBody.id, convID: convBody.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 });
|
||
});
|
||
|
||
// 跨实例读:本 VU 会话在另一实例上应立即可见(共享权威)。
|
||
if (BASE_URLS.length >= 2 && exec.scenario.iterationInTest % 5 === 0) {
|
||
const other = otherBase(base);
|
||
// 另一实例需同用户登录(cookie jar 按 VU;换 host 需重新登录)。
|
||
const loginB = loginReady(other, u.owner, u.password);
|
||
if (loginB.status === 200) {
|
||
const cross = http.get(`${other}/api/conversations/${convID}`);
|
||
if (cross.status === 200) crossReadOk.add(1);
|
||
check(cross, { 'cross-instance conversation 2xx': (r) => r.status === 200 });
|
||
}
|
||
}
|
||
|
||
group('write', () => {
|
||
// ~20% 迭代打热点会话(双实例时经本 VU 粘滞实例提交 → 预期 409 上升)。
|
||
let targetConv = convID;
|
||
let writeBase = base;
|
||
if (data.hotspot && exec.scenario.iterationInTest % 5 === 0) {
|
||
targetConv = data.hotspot.convID;
|
||
// 热点会话属 admin:先以 admin 登录再写。
|
||
const hl = loginReady(writeBase, data.hotspot.owner, data.hotspot.password);
|
||
if (hl.status !== 200) {
|
||
check(null, { 'hotspot admin login': () => false });
|
||
return;
|
||
}
|
||
}
|
||
|
||
const requestId = `req-${u.owner}-${exec.scenario.iterationInTest}-${exec.vu.idInTest}`;
|
||
const body = JSON.stringify({ text: `压测消息 ${Date.now()}`, requestId });
|
||
const t0 = Date.now();
|
||
const sent = http.post(`${writeBase}/api/conversations/${targetConv}/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 });
|
||
if (exec.scenario.iterationInTest % 10 === 0 && targetConv === convID) {
|
||
const replay = http.post(`${writeBase}/api/conversations/${targetConv}/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);
|
||
}
|