/** * AI Software Factory — k6: 100k-record database performance. * * Scenario (PRODUCT-SPEC §Performance: "Databases with 100k+ records"): * - a pre-seeded database with 100,000 records (see * helpers/seed-100k.mjs — run it once before this suite; it creates the * DB via the API and bulk-inserts records via Prisma for speed) * * Workloads (mixed per iteration, arrival-rate ramp to 200 rps read + a * separate 100 rps write scenario): * - table page: GET /databases/:id/records?limit=50 p95 < 500 ms * - board group: GET .../records?group=[{propertyId,dir}] p95 < 500 ms * - filtered: GET .../records?filter={...} p95 < 500 ms * - record create: POST .../records (100 rps hold) p95 < 300 ms * * Thresholds below are the pass criteria (fail the run when violated). */ import http from "k6/http"; import { check } from "k6"; import { Trend } from "k6/metrics"; const tableMs = new Trend("table_page_ms", true); const groupMs = new Trend("board_group_ms", true); const filteredMs = new Trend("filtered_query_ms", true); const createMs = new Trend("record_create_ms", true); const API_URL = __ENV.K6_API_URL || "http://localhost:4000"; const EMAIL = __ENV.K6_EMAIL || "demo@factory.local"; const PASSWORD = __ENV.K6_PASSWORD || "factory-demo-123"; const DB_ID = __ENV.K6_DB_ID || ""; // required; set by helpers/seed-100k.mjs const READ_DURATION = parseInt(__ENV.K6_DURATION || "120", 10); export const options = { scenarios: { reads: { executor: "ramping-arrival-rate", startRate: 20, timeUnit: "1s", preAllocatedVUs: 20, maxVUs: 400, exec: "readWorkload", tags: { workload: "read" }, stages: [ { target: 100, duration: "30s" }, { target: 200, duration: `${READ_DURATION}s` }, ], }, writes: { executor: "constant-arrival-rate", rate: 100, timeUnit: "1s", preAllocatedVUs: 20, maxVUs: 200, exec: "writeWorkload", tags: { workload: "write" }, duration: "60s", }, }, thresholds: { table_page_ms: ["p(95)<500"], board_group_ms: ["p(95)<500"], filtered_query_ms: ["p(95)<500"], record_create_ms: ["p(95)<300"], http_req_failed: ["rate<0.001"], }, }; export function setup() { if (!DB_ID) { throw new Error( "K6_DB_ID is required — run `node helpers/seed-100k.mjs` first " + "(it prints the database id to seed).", ); } let login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); if (login.status !== 200) { const reg = http.post(`${API_URL}/api/v1/auth/register`, JSON.stringify({ email: EMAIL, password: PASSWORD, name: "K6 DB" }), { headers: { "Content-Type": "application/json" }, }); if (reg.status === 201 || reg.status === 200) { login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); } } if (login.status !== 200) throw new Error(`setup: login failed (${login.status})`); const body = login.json(); const token = body.accessToken || body.access_token || body.token; if (!token) throw new Error("setup: no access token in login response"); // Sanity: the database must exist and be owned by this user (the seed // helper creates it as the demo user). const probe = http.get(`${API_URL}/api/v1/databases/${DB_ID}/records?limit=1`, { headers: { Authorization: `Bearer ${token}` }, }); if (probe.status !== 200) { throw new Error(`setup: database ${DB_ID} not readable (${probe.status})`); } const probeBody = probe.json(); const total = (probeBody && (probeBody.total ?? probeBody.count)) || 0; if (total < 100_000) { throw new Error(`setup: expected >= 100000 records, found ${total} — re-run helpers/seed-100k.mjs`); } // The group/filter workloads need a property id with discrete values; // the seed helper creates a "Status" SELECT property and returns its id. const db = http.get(`${API_URL}/api/v1/databases/${DB_ID}`, { headers: { Authorization: `Bearer ${token}` }, }); if (db.status !== 200) throw new Error(`setup: cannot read database (${db.status})`); const dbBody = db.json(); const props = (dbBody.database && dbBody.database.properties) || dbBody.properties || []; const statusProp = props.find((p) => p.name === "Status"); if (!statusProp) throw new Error("setup: seeded database lacks a 'Status' property"); return { token, dbId: DB_ID, statusPropId: statusProp.id, total }; } const H = (data) => ({ "Content-Type": "application/json", Authorization: `Bearer ${data.token}` }); export function readWorkload(data) { const roll = Math.random(); if (roll < 0.4) { // Table page load (one virtualized page of rows). const started = Date.now(); const res = http.get(`${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&offset=0`, { headers: H(data), }); tableMs.add(Date.now() - started); check(res, { "table: 200": (r) => r.status === 200 }); } else if (roll < 0.7) { // Board view: group by the Status select. const started = Date.now(); const res = http.get( `${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&group=${encodeURIComponent( JSON.stringify([{ propertyId: data.statusPropId, direction: "asc" }]), )}`, { headers: H(data) }, ); groupMs.add(Date.now() - started); check(res, { "group: 200": (r) => r.status === 200 }); } else { // Filtered query (an equality on a select value — index-friendly). const started = Date.now(); const filter = JSON.stringify({ op: "and", children: [{ propertyId: data.statusPropId, op: "=", value: "doing" }], }); const res = http.get( `${API_URL}/api/v1/databases/${data.dbId}/records?limit=50&filter=${encodeURIComponent(filter)}`, { headers: H(data) }, ); filteredMs.add(Date.now() - started); check(res, { "filtered: 200": (r) => r.status === 200 }); } } export function writeWorkload(data) { const started = Date.now(); const res = http.post( `${API_URL}/api/v1/databases/${data.dbId}/records`, JSON.stringify({ properties: { Title: `k6 write ${Date.now()}-${Math.random().toString(36).slice(2)}`, Status: "todo", }, }), { headers: H(data) }, ); createMs.add(Date.now() - started); check(res, { "create: 2xx": (r) => r.status === 200 || r.status === 201 }); }