/** * AI Software Factory — database load suite (TASK-0135). * * Targets the seeded 100k-record database (`seed-100k.mjs`, marker `databaseId`). * Four scenarios (the spec's k6 matrix): * * - table-first 50 VUs, 2 min — POST /databases/:id/query first page * (pageSize 50, no cursor) — the "fresh cache" table load. * - table-scroll 100 VUs, 2 min — POST /databases/:id/query pages 2..50 * (each carries the cursor from the prior page — a real * scroll). The cursor chain is precomputed in setup so a * scroll request is a single round-trip. * - record-open 50 VUs, 1 min — GET /records/:id (record → its pageId) * then GET /pages/:pageId/blocks (the record-open path the * acceptance criteria bound to ≤ 4 queries). * - record-create 100 rps, 2 min — POST /databases/:id/records, sustained. * * Thresholds (staging-hard): * table (first+scroll) p95 < 500 ms; record-open p95 < 500 ms; * creates p95 < 500 ms AND ≥ 100 rps sustained with no > 5 s stall * (a create slower than 5 s is counted as a stall); error rate < 0.1%. * p99 is reported (not blocking — this k6 build rejects the `warn:` soft- * threshold prefix; p99 is surfaced via the summary trend stats). * * Report-only vs hard: K6_STRICT=1 (default, staging) → hard (k6 exit 99 on * breach); K6_STRICT=0 (local + dev/PR) → trivially-true thresholds (the run * still prints real p95/p99/error for the PR artifact). * * Endpoints verified live: * - table view reads through POST /databases/:id/query ({pageSize,cursor}) * → {data:{records,totalCount,nextCursor}} (the web's real path, TASK-0124). * - record-open: GET /records/:id → {…pageId, properties} then * GET /pages/:pageId/blocks. * * Env: * K6_API_URL API base (default http://localhost:4000) * K6_EMAIL / K6_PASSWORD perf user (demo@factory.local / Factory!2345) * K6_WORKSPACE_ID seeded workspace (for resolve fallback) * K6_DB_ID seeded 100k database id (required — marker databaseId) * K6_STRICT "1" hard (default) / "0" report-only * * Run (full): pnpm --dir tests/performance database-load * Run (smoke): K6_PROFILE=smoke pnpm --dir tests/performance database-load */ import http from "k6/http"; import { check } from "k6"; import { Trend, Rate } from "k6/metrics"; const tableFirstMs = new Trend("table_first_ms", true); const tableScrollMs = new Trend("table_scroll_ms", true); const recordOpenMs = new Trend("record_open_ms", true); const recordCreateMs = new Trend("record_create_ms", true); const createStall = new Rate("create_stall_rate"); // a create slower than 5 s const API = __ENV.K6_API_URL || "http://localhost:4000"; const EMAIL = __ENV.K6_EMAIL || "demo@factory.local"; const PASSWORD = __ENV.K6_PASSWORD || "Factory!2345"; const STRICT = (__ENV.K6_STRICT || "1") !== "0"; const SMOKE = __ENV.K6_PROFILE === "smoke"; const DB_ID = __ENV.K6_DB_ID || ""; // Durations (seconds). Full: table 2m, create 2m, record-open 1m. // Smoke: 30 s — long enough for a full iteration to land on a slow dev API // (dev requests can take seconds; a 6 s window interrupted every iteration // before the Trend.add() call, leaving the summary with no samples). const TABLE_SECS = SMOKE ? 60 : 120; const CREATE_SECS = SMOKE ? 60 : 120; const OPEN_SECS = SMOKE ? 60 : 60; // Smoke: minimal concurrency (1 VU / 1 rps) — on a loaded shared dev machine // requests queue and balloon (15 s+ under contention), which drops iterations; // a smoke's job is to prove the execs + endpoints work, not to load. The real // load only makes sense on a dedicated staging instance (full profile). const CREATE_RPS = SMOKE ? 1 : 100; const TABLE_FIRST_VUS = SMOKE ? 1 : 50; const TABLE_SCROLL_VUS = SMOKE ? 1 : 100; const OPEN_VUS = SMOKE ? 1 : 50; const PAGE_SIZE = 50; // The table-scroll chain (pages 2..N). Full: 50 (the spec's pages 2..50). // Smoke: 2 pages (1 scroll cursor) — a long setup chain alone can eat the // scenario window on a loaded dev machine (each query can take several // seconds under contention). The full profile builds all 50. const CHAIN_PAGES = SMOKE ? 2 : 50; // ---- per-VU auth + cursor-chain/record pool -------------------------------- // k6's `setup()` return (`__DATA`) is only reachable from `default()` / // `teardown()`, NOT from scenario execs — so each VU resolves its own token + // precomputed cursor chain + record pool on first use and caches it in a // top-level `let` (k6 scopes top-level state per VU). The chain-building // queries are NOT added to any Trend, so they do not pollute the measured // table-scroll / record-open latencies. `setup()` still does a one-time // host-side validation (fail-fast) — its return is not consumed by the execs. let _DATA = null; function data() { if (!_DATA) _DATA = resolveData(); return _DATA; } function resolveData() { let res = http.post(`${API}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); if (res.status !== 200) { res = http.post(`${API}/api/v1/auth/register`, JSON.stringify({ email: EMAIL, password: PASSWORD, name: "Perf DbLoad" }), { headers: { "Content-Type": "application/json" }, }); res = http.post(`${API}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); } if (res.status !== 200) throw new Error(`auth failed: ${res.status} ${res.body.slice(0, 200)}`); const body = res.json(); const token = body.accessToken || body.access_token || body.token; if (!token) throw new Error(`no token in auth response`); if (!DB_ID) throw new Error("K6_DB_ID is empty — run seed-100k.mjs and export its databaseId."); const H = { "Content-Type": "application/json", Authorization: `Bearer ${token}` }; // Precompute the cursor chain for pages 1..CHAIN_PAGES (table-scroll) + // collect a pool of record ids (record-open). One cursor hop per page. const cursorChain = []; // index 0 = page 1 (null cursor), i = page i+1 const recordIds = []; let cursor = null; for (let page = 1; page <= CHAIN_PAGES; page++) { const q = { pageSize: PAGE_SIZE }; if (cursor !== null) q.cursor = cursor; const r = http.post(`${API}/api/v1/databases/${DB_ID}/query`, JSON.stringify(q), { headers: H }); if (r.status !== 200) throw new Error(`query setup page ${page} failed: ${r.status}`); const data = r.json().data || {}; const records = data.records || []; if (records.length === 0) break; cursorChain.push(cursor); for (const rec of records) if (rec.id) recordIds.push(rec.id); cursor = data.nextCursor ?? null; if (cursor === null) break; } if (recordIds.length === 0) throw new Error("no records found in the seeded DB — is K6_DB_ID correct?"); return { token, cursorChain, recordIds }; } export function setup() { // One-time host-side validation (fail-fast): login works, K6_DB_ID is set, // and the DB has records. Its return is not used by the execs (see the // per-VU data() note above) — it only surfaces a misconfiguration early. resolveData(); return { ok: true }; } function H(token) { return { "Content-Type": "application/json", Authorization: `Bearer ${token}` }; } export function tableFirst() { const res = http.post(`${API}/api/v1/databases/${DB_ID}/query`, JSON.stringify({ pageSize: PAGE_SIZE }), { headers: H(data().token), }); tableFirstMs.add(res.timings.duration); check(res, { "[db] table-first 2xx": (r) => r.status >= 200 && r.status < 300, "[db] table-first has records": (r) => (r.json().data?.records || []).length > 0, }); } export function tableScroll() { const d = data(); // Pick a page 2..CHAIN_PAGES (the chain stores the cursor that FETCHES page i+1). const idx = 1 + Math.floor(Math.random() * Math.max(1, d.cursorChain.length - 1)); const cursor = d.cursorChain[Math.min(idx, d.cursorChain.length - 1)]; const q = { pageSize: PAGE_SIZE }; if (cursor !== null && cursor !== undefined) q.cursor = cursor; const res = http.post(`${API}/api/v1/databases/${DB_ID}/query`, JSON.stringify(q), { headers: H(d.token), }); tableScrollMs.add(res.timings.duration); check(res, { "[db] table-scroll 2xx": (r) => r.status >= 200 && r.status < 300, "[db] table-scroll has records": (r) => (r.json().data?.records || []).length > 0, }); } export function recordOpen() { const t0 = Date.now(); const d = data(); const rid = d.recordIds[Math.floor(Math.random() * d.recordIds.length)]; const rec = http.get(`${API}/api/v1/records/${rid}`, { headers: H(d.token) }); if (rec.status >= 200 && rec.status < 300) { const pageId = rec.json().pageId; if (pageId) { const blocks = http.get(`${API}/api/v1/pages/${pageId}/blocks`, { headers: H(d.token) }); check(blocks, { "[db] record-open blocks 2xx": (r) => r.status >= 200 && r.status < 300 }); } } recordOpenMs.add(Date.now() - t0); check(rec, { "[db] record-open 2xx": (r) => r.status >= 200 && r.status < 300 }); } export function recordCreate() { const res = http.post( `${API}/api/v1/databases/${DB_ID}/records`, JSON.stringify({ title: `Perf DbLoad ${__VU}-${__ITER}` }), { headers: H(data().token) } ); recordCreateMs.add(res.timings.duration); if (res.timings.duration > 5000) createStall.add(true); else createStall.add(false); check(res, { "[db] record-create 2xx": (r) => r.status >= 200 && r.status < 300 }); } const HARD_THRESHOLDS = { table_first_ms: ["p(95)<500"], table_scroll_ms: ["p(95)<500"], record_open_ms: ["p(95)<500"], record_create_ms: ["p(95)<500"], // A create slower than 5 s = a stall; allow ~0 (≤ 0.1% of creates). create_stall_rate: ["rate<0.001"], http_req_failed: ["rate<0.001"], }; const SOFT_THRESHOLDS = { table_first_ms: ["avg<1e15"], table_scroll_ms: ["avg<1e15"], record_open_ms: ["avg<1e15"], record_create_ms: ["avg<1e15"], // `rate<=1` (not `<1`): k6 Rate thresholds are a FRACTION, so a true 100% // rate (e.g. every create stalls on a dead DB) is exactly 1.0 and would // red the report-only tier under `rate<1`. `<=` keeps it green. create_stall_rate: ["rate<=1"], http_req_failed: ["rate<=1"], }; export const options = { scenarios: { "table-first": { executor: "constant-vus", startTime: "0s", duration: `${TABLE_SECS}s`, vus: TABLE_FIRST_VUS, exec: "tableFirst", }, "table-scroll": { executor: "constant-vus", startTime: "0s", duration: `${TABLE_SECS}s`, vus: TABLE_SCROLL_VUS, exec: "tableScroll", }, "record-open": { executor: "constant-vus", startTime: "0s", duration: `${OPEN_SECS}s`, vus: OPEN_VUS, exec: "recordOpen", }, // Sustained 100 rps of creates (the spec's create-rate floor). "record-create": { executor: "constant-arrival-rate", startTime: "0s", duration: `${CREATE_SECS}s`, preAllocatedVUs: SMOKE ? 5 : 50, maxVUs: SMOKE ? 20 : 200, rate: CREATE_RPS, exec: "recordCreate", }, }, thresholds: STRICT ? HARD_THRESHOLDS : SOFT_THRESHOLDS, summaryTrendStats: ["avg", "min", "med", "p(90)", "p(95)", "p(99)", "max"], };