Files
Agent-System-Dev/tests/load/collaboration/k6/collab_api.js
T
weichen f705f99b8f Add U7 two-process acceptance, k6 load harness and release checklist
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.
2026-10-12 02:31:35 +00:00

138 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 协作存储 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);
}