From 25a5ac851355fb24fec50d41867e01b72e808721 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 12 Jun 2026 14:47:17 +0200 Subject: [PATCH] :construction: Add the ability to scale users to 1000 or more --- backend/src/app/rpc/commands/demo.clj | 5 +- performance/scripts/font-upload.js | 128 +++----- performance/scripts/lifecycle.js | 325 +++++-------------- performance/scripts/media-upload.js | 85 ++--- performance/scripts/workspace-edit.js | 84 ++--- performance/scripts/workspace-open.js | 125 ++----- plans/2026-06-12-backend-performance-test.md | 76 ++++- 7 files changed, 287 insertions(+), 541 deletions(-) diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 13b7a2f374..a861491fca 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -10,6 +10,7 @@ [app.auth :refer [derive-password]] [app.common.exceptions :as ex] [app.common.time :as ct] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] @@ -34,8 +35,8 @@ :code :demo-users-not-allowed :hint "Demo users are disabled by config.")) - (let [sem (System/currentTimeMillis) - email (str "demo-" sem ".demo@example.com") + (let [sem (uuid/next) + email (str "demo-" sem "@demo.example.com") fullname (str "Demo User " sem) password (-> (bn/random-bytes 16) diff --git a/performance/scripts/font-upload.js b/performance/scripts/font-upload.js index 76200dc8b8..b8718cf3fa 100644 --- a/performance/scripts/font-upload.js +++ b/performance/scripts/font-upload.js @@ -1,17 +1,14 @@ // Font Upload Performance Test // -// Tests the font upload flow: chunked upload of a TTF file followed by -// creating a font variant. This exercises the storage pipeline and -// font processing (FontForge/WOFF conversion). +// Tests the font upload flow: chunked upload of TTF + OTF files followed by +// creating a font variant. Exercises storage pipeline and font processing. // -// Flow: -// 1. Register → login → get team -// 2. Chunked upload of font-1.ttf (68 KB, 2 chunks at 50 KB) -// 3. create-font-variant with the uploaded session +// setup() creates N demo profiles. +// Each VU picks its user, uploads fonts, and creates a variant. // // Usage: // k6 run scripts/font-upload.js -// k6 run --vus 3 --iterations 2 scripts/font-upload.js +// k6 run --vus 50 --iterations 5 scripts/font-upload.js import { check, sleep, fail } from "k6"; import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js"; @@ -42,7 +39,7 @@ export const options = { }; // --------------------------------------------------------------------------- -// Test Data — load font fixtures from backend test files +// Test Data // --------------------------------------------------------------------------- const fontTtf = open("../../backend/test/backend_tests/test_files/font-1.ttf", "b"); @@ -75,22 +72,29 @@ function assertOk(res, label) { } // --------------------------------------------------------------------------- -// Setup +// Setup — create user pool // --------------------------------------------------------------------------- export function setup() { + const vuCount = (options.scenarios.font_upload && options.scenarios.font_upload.vus) || __ENV.K6_VUS || 1; + console.log(`Penpot Font Upload Test`); console.log(` Base URL: ${BASE_URL}`); - console.log(` Font TTF: ${fontTtf.byteLength} B`); - console.log(` Font OTF: ${fontOtf.byteLength} B`); + console.log(` VUs: ${vuCount}`); console.log(``); const client = createClient(BASE_URL); + if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`); - const res = client.getProfile(); - if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`); + const users = []; + for (let i = 0; i < vuCount; i++) { + const res = client.rpc("POST", "create-demo-profile", {}); + if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`); + users.push(res.json()); + } + console.log(` Created ${users.length} demo profiles`); - return { baseUrl: BASE_URL }; + return { baseUrl: BASE_URL, users }; } // --------------------------------------------------------------------------- @@ -100,111 +104,57 @@ export function setup() { export default function (data) { const client = createClient(data.baseUrl); - // Register - const demoRes = client.rpc("POST", "create-demo-profile", {}); - if (!assertOk(demoRes, "create-demo-profile")) fail("Failed to create demo profile"); - const demo = demoRes.json(); + // Pick user from pool + const user = data.users[__VU - 1]; + if (!user) fail(`No user for VU ${__VU}`); // Login - const loginRes = client.login(demo.email, demo.password); - if (!assertOk(loginRes, "login")) fail("Login failed"); - const teamId = loginRes.body.defaultTeamId; + if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed"); + const teamId = client.getTeams().body[0].id; sleep(0.5); - // Get teams (to confirm team-id) - const teamsRes = client.getTeams(); - if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed"); - const confirmedTeamId = teamsRes.body[0].id; - - // Upload TTF via chunked upload const fontId = uuidv4(); const fontFamily = `PerfFont-${uuidv4().substring(0, 8)}`; + const chunkSize = 50 * 1024; // 50 KB - console.log(`VU ${__VU}: Uploading font "${fontFamily}" (font-id: ${fontId})`); - - // Create upload session for TTF - const chunkSize = 50 * 1024; // 50 KB — matches client default + // Upload TTF via chunked upload const ttfChunks = Math.ceil(fontTtf.byteLength / chunkSize); + const ttfSessionRes = client.createUploadSession(ttfChunks); + if (!assertOk(ttfSessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed"); + const ttfSessionId = ttfSessionRes.sessionId; - const sessionRes = client.createUploadSession(ttfChunks); - if (!assertOk(sessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed"); - const ttfSessionId = sessionRes.sessionId; - - sleep(0.2); - - // Upload TTF chunks for (let i = 0; i < ttfChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize, fontTtf.byteLength); - const chunk = fontTtf.slice(start, end); - - const chunkRes = client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"); - if (!assertOk(chunkRes, `upload-chunk (ttf ${i}/${ttfChunks})`)) { - fail(`Chunk upload failed at index ${i}`); - } - + const chunk = fontTtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontTtf.byteLength)); + if (!assertOk(client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"), `upload-chunk ttf ${i}`)) fail("ttf chunk failed"); sleep(0.1); } - console.log(`VU ${__VU}: Uploaded TTF in ${ttfChunks} chunks`); - - // Upload OTF via chunked upload (separate session) + // Upload OTF via chunked upload const otfChunks = Math.ceil(fontOtf.byteLength / chunkSize); - const otfSessionRes = client.createUploadSession(otfChunks); if (!assertOk(otfSessionRes, "create-upload-session (otf)")) fail("create-upload-session (otf) failed"); const otfSessionId = otfSessionRes.sessionId; - sleep(0.2); - for (let i = 0; i < otfChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize, fontOtf.byteLength); - const chunk = fontOtf.slice(start, end); - - const chunkRes = client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"); - if (!assertOk(chunkRes, `upload-chunk (otf ${i}/${otfChunks})`)) { - fail(`OTF chunk upload failed at index ${i}`); - } - + const chunk = fontOtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontOtf.byteLength)); + if (!assertOk(client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"), `upload-chunk otf ${i}`)) fail("otf chunk failed"); sleep(0.1); } - console.log(`VU ${__VU}: Uploaded OTF in ${otfChunks} chunks`); - sleep(0.5); // Create font variant - const variantRes = client.rpc("POST", "create-font-variant", { - "team-id": confirmedTeamId, + if (!assertOk(client.rpc("POST", "create-font-variant", { + "team-id": teamId, "font-id": fontId, "font-family": fontFamily, "font-weight": 400, "font-style": "normal", - uploads: { - "font/ttf": ttfSessionId, - "font/otf": otfSessionId, - }, - }); + uploads: { "font/ttf": ttfSessionId, "font/otf": otfSessionId }, + }), "create-font-variant")) fail("create-font-variant failed"); - if (!assertOk(variantRes, "create-font-variant")) { - fail("create-font-variant failed"); - } - - console.log(`VU ${__VU}: Created font variant "${fontFamily}" (weight: 400, style: normal)`); - - // Verify by fetching font variants - sleep(0.5); - const getVariantsRes = client.rpc("GET", "get-font-variants", { - "team-id": confirmedTeamId, - }); - if (assertOk(getVariantsRes, "get-font-variants")) { - const variants = getVariantsRes.json(); - console.log(`VU ${__VU}: Team has ${Array.isArray(variants) ? variants.length : "?"} font variants`); - } - - console.log(`VU ${__VU}: Font upload test complete`); + console.log(`VU ${__VU}: Font "${fontFamily}" created`); } // --------------------------------------------------------------------------- diff --git a/performance/scripts/lifecycle.js b/performance/scripts/lifecycle.js index 5e3e3217a9..de440cb94c 100644 --- a/performance/scripts/lifecycle.js +++ b/performance/scripts/lifecycle.js @@ -3,23 +3,25 @@ // Simulates a realistic user lifecycle from registration through CRUD operations. // Each VU performs the full flow independently, creating its own artifacts. // +// setup() creates a user pool (one demo profile per VU) before measurements begin. +// Each VU picks its assigned user to login — no profile creation during the test. +// // Flow: -// 1. Register (via demo profile or prepare+register) -// 2. Login -// 3. Get profile & teams -// 4. Create project -// 5. Create file -// 6. Get file -// 7. Update file (add a shape) -// 8. Upload image to file -// 9. Delete file -// 10. Delete project -// 11. Delete team +// 1. Login (with pre-existing user from pool) +// 2. Get profile & teams +// 3. Create project +// 4. Create file +// 5. Get file +// 6. Update file (add a shape) +// 7. Upload images (direct + chunked) +// 8. Delete file +// 9. Delete project +// 10. Logout // // Usage: // k6 run scripts/lifecycle.js +// k6 run --vus 100 --iterations 100 scripts/lifecycle.js // k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/lifecycle.js -// k6 run --env PENPOT_REGISTER_MODE=register scripts/lifecycle.js import { check, sleep, fail } from "k6"; import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js"; @@ -30,9 +32,6 @@ import { createClient } from "../lib/penpot-client.js"; // --------------------------------------------------------------------------- const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060"; -// "demo" = use create-demo-profile (requires demo-users flag) -// "register" = use prepare-register-profile + register-profile -const REGISTER_MODE = __ENV.PENPOT_REGISTER_MODE || "demo"; // k6 options — smoke test defaults (1 VU, 1 iteration) export const options = { @@ -61,14 +60,10 @@ export const options = { // Test Data // --------------------------------------------------------------------------- -// Load test PNG fixtures — small uses direct upload, large uses chunked upload const testImageSmall = open("../../backend/test/backend_tests/test_files/sample.png", "b"); const testImageLarge = open("../../backend/test/backend_tests/test_files/sample.jpg", "b"); // A minimal "add-obj" change payload for update-file. -// This adds a simple rectangle shape to the first page. -// All object properties use camelCase — the backend's JSON parser -// (json/read-kebab-key) converts camelCase to kebab-case keywords automatically. function makeAddRectChange(pageId) { const shapeId = uuidv4(); const x = 100; @@ -80,42 +75,22 @@ function makeAddRectChange(pageId) { type: "add-obj", pageId: pageId, id: shapeId, - frameId: pageId, // required: the frame this shape belongs to - parentId: pageId, // root frame is the page itself + frameId: pageId, + parentId: pageId, obj: { id: shapeId, type: "rect", name: "Perf Test Rect", - x: x, - y: y, - width: w, - height: h, - fillColor: "#ff0000", - fillOpacity: 1, - rotation: 0, - hidden: false, - locked: false, - // Required base attrs - selrect: { - x: x, - y: y, - width: w, - height: h, - x1: x, - y1: y, - x2: x + w, - y2: y + h, - }, + x: x, y: y, width: w, height: h, + fillColor: "#ff0000", fillOpacity: 1, + rotation: 0, hidden: false, locked: false, + selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h }, points: [ - { x: x, y: y }, - { x: x + w, y: y }, - { x: x + w, y: y + h }, - { x: x, y: y + h }, + { x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }, ], transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, - parentId: pageId, - frameId: pageId, + parentId: pageId, frameId: pageId, }, }; } @@ -129,7 +104,6 @@ function assertOk(res, label) { [`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300, }); if (!ok) { - // Try to get the response body for debugging let bodyStr = ""; try { if (res.raw && res.raw.body) { @@ -142,32 +116,43 @@ function assertOk(res, label) { } catch (e) { bodyStr = "(could not read body)"; } - console.error( - `FAIL: ${label} — status=${res.status} body=${bodyStr}` - ); + console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`); } return ok; } // --------------------------------------------------------------------------- -// Setup — runs once before VUs start +// Setup — create user pool before VUs start // --------------------------------------------------------------------------- export function setup() { + // Resolve VU count from options or CLI --vus flag + const vuCount = (options.scenarios.lifecycle && options.scenarios.lifecycle.vus) || __ENV.K6_VUS || 1; + console.log(`Penpot Lifecycle Test`); - console.log(` Base URL: ${BASE_URL}`); - console.log(` Register mode: ${REGISTER_MODE}`); + console.log(` Base URL: ${BASE_URL}`); + console.log(` VUs: ${vuCount}`); + console.log(` Creating ${vuCount} demo profiles...`); console.log(``); - // Verify the backend is reachable const client = createClient(BASE_URL); - const res = client.getProfile(); - // We expect 401/403 (not logged in) — anything else means the backend is down - if (res.status === 0) { - fail(`Backend unreachable at ${BASE_URL}`); + + // Verify backend is reachable + const pingRes = client.getProfile(); + if (pingRes.status === 0) fail(`Backend unreachable at ${BASE_URL}`); + + // Create one demo profile per VU + const users = []; + for (let i = 0; i < vuCount; i++) { + const res = client.rpc("POST", "create-demo-profile", {}); + if (res.status !== 200) { + fail(`Failed to create demo profile ${i + 1}/${vuCount}: status=${res.status}`); + } + users.push(res.json()); } - return { baseUrl: BASE_URL, registerMode: REGISTER_MODE }; + console.log(` Created ${users.length} demo profiles`); + return { baseUrl: BASE_URL, users }; } // --------------------------------------------------------------------------- @@ -177,243 +162,105 @@ export function setup() { export default function (data) { const client = createClient(data.baseUrl); - // Small random delay to spread out concurrent demo profile creation - // (demo emails are timestamp-based and can collide within the same ms) - sleep(Math.random() * 2); - - // ---- Step 0: Create a user account ---- - let email, password; - - if (data.registerMode === "demo") { - // Demo mode: create-demo-profile returns email + password - const demoRes = client.rpc("POST", "create-demo-profile", {}); - if (!assertOk(demoRes, "create-demo-profile")) { - fail("Failed to create demo profile"); - } - const demoBody = demoRes.json(); - email = demoBody.email; - password = demoBody.password; - console.log(`VU ${__VU}: Created demo profile: ${email}`); - } else { - // Register mode: prepare + register - email = `perf-${uuidv4()}@test.local`; - password = "PerfTest1234!"; - const fullname = `Perf User ${__VU}`; - - const prepareRes = client.rpc("POST", "prepare-register-profile", { - fullname, - email, - password, - }); - if (!assertOk(prepareRes, "prepare-register-profile")) { - fail("Failed to prepare registration"); - } - const prepareBody = prepareRes.json(); - const token = prepareBody.token; - - const registerRes = client.rpc("POST", "register-profile", { - token, - }); - if (!assertOk(registerRes, "register-profile")) { - fail("Failed to register profile"); - } - console.log(`VU ${__VU}: Registered profile: ${email}`); + // Pick user from pool + const user = data.users[__VU - 1]; + if (!user) { + fail(`No user for VU ${__VU} (pool size: ${data.users.length})`); } - sleep(1); - // ---- Step 1: Login ---- - const loginRes = client.login(email, password); - if (!assertOk(loginRes, "login-with-password")) { - fail("Login failed"); - } + const loginRes = client.login(user.email, user.password); + if (!assertOk(loginRes, "login-with-password")) fail("Login failed"); const profile = loginRes.body; const profileId = profile.id; - console.log(`VU ${__VU}: Logged in, profile-id=${profileId}`); sleep(1); // ---- Step 2: Get profile ---- - const profileRes = client.getProfile(); - if (!assertOk(profileRes, "get-profile")) { - fail("get-profile failed"); - } + if (!assertOk(client.getProfile(), "get-profile")) fail("get-profile failed"); sleep(0.5); // ---- Step 3: Get teams ---- const teamsRes = client.getTeams(); - if (!assertOk(teamsRes, "get-teams")) { - fail("get-teams failed"); - } - const teams = teamsRes.body; - // teams is an array; the user has a default team from registration - const defaultTeamId = Array.isArray(teams) && teams.length > 0 - ? teams[0].id - : null; - - if (!defaultTeamId) { - fail("No default team found after registration"); - } + if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed"); + const defaultTeamId = teamsRes.body[0].id; sleep(0.5); // ---- Step 4: Create a project ---- - const projectName = `Perf Project ${uuidv4().substring(0, 8)}`; - const projectRes = client.createProject(defaultTeamId, projectName); - if (!assertOk(projectRes, "create-project")) { - fail("create-project failed"); - } - const project = projectRes.body; - const projectId = project.id; - console.log(`VU ${__VU}: Created project: ${projectId}`); + const projectRes = client.createProject(defaultTeamId, `Perf Project ${uuidv4().substring(0, 8)}`); + if (!assertOk(projectRes, "create-project")) fail("create-project failed"); + const projectId = projectRes.body.id; sleep(1); // ---- Step 5: Create a file ---- - const fileName = `Perf File ${uuidv4().substring(0, 8)}`; - const fileRes = client.createFile(projectId, fileName); - if (!assertOk(fileRes, "create-file")) { - fail("create-file failed"); - } - const file = fileRes.body; - const fileId = file.id; - console.log(`VU ${__VU}: Created file: ${fileId}`); + const fileRes = client.createFile(projectId, `Perf File ${uuidv4().substring(0, 8)}`); + if (!assertOk(fileRes, "create-file")) fail("create-file failed"); + const fileId = fileRes.body.id; sleep(1); - // ---- Step 6: Get the file (to read revn, vern, page-id) ---- + // ---- Step 6: Get the file ---- const getFileRes = client.getFile(fileId); - if (!assertOk(getFileRes, "get-file")) { - fail("get-file failed"); - } + if (!assertOk(getFileRes, "get-file")) fail("get-file failed"); const fileData = getFileRes.body; - const revn = fileData.revn; - const vern = fileData.vern; - - // Extract the first page-id from the file data - // fileData.data.pages is an array of page UUIDs - // fileData.data.pages-index is a map of page-id -> page objects - let pageId = null; - if (fileData.data && fileData.data.pages && fileData.data.pages.length > 0) { - pageId = fileData.data.pages[0]; - } - - if (!pageId) { - console.warn(`VU ${__VU}: Could not find page-id in file data, skipping update-file`); - } + const pageId = fileData.data.pages[0]; sleep(1); - // ---- Step 7: Update file (add a rectangle shape) ---- - let updateOk = false; + // ---- Step 7: Update file (add a shape) ---- if (pageId) { const changes = [makeAddRectChange(pageId)]; - const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes); + const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes); - if (updateRes.status === 200) { - updateOk = true; - console.log(`VU ${__VU}: Updated file successfully`); - } else { - // Check for revn conflict — retry once + if (updateRes.status !== 200) { + // Retry once on revn conflict const body = updateRes.body; - console.error(`VU ${__VU}: update-file failed: status=${updateRes.status} body=${JSON.stringify(body).substring(0, 500)}`); - const isRevnConflict = - body && (body.code === "revn-conflict" || body.type === "revn-conflict"); - - if (isRevnConflict) { - console.log(`VU ${__VU}: Revn conflict, retrying...`); - // Fetch latest file state - const retryFileRes = client.getFile(fileId); - if (assertOk(retryFileRes, "get-file (retry)")) { - const retryData = retryFileRes.body; - const retryRes = client.updateFile( - fileId, - retryData.revn, - retryData.vern, - client.sessionId, - changes - ); - if (assertOk(retryRes, "update-file (retry)")) { - updateOk = true; - console.log(`VU ${__VU}: Updated file on retry`); - } + const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict"); + if (isConflict) { + const retryFile = client.getFile(fileId); + if (retryFile.status === 200) { + client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes); } - } else { - console.error( - `VU ${__VU}: update-file failed: status=${updateRes.status} body=${JSON.stringify(body).substring(0, 300)}` - ); } } } sleep(1); - // ---- Step 8: Upload images to the file ---- - // Small image (97 B) uses direct multipart upload. - // Large image (120 KB) uses chunked upload (create-session → upload-chunk × N → assemble). + // ---- Step 8: Upload images ---- if (testImageSmall && testImageSmall.byteLength > 0) { - const uploadRes = client.uploadFileMediaObject( - fileId, - testImageSmall, - "test-small.png", - "image/png" + assertOk( + client.uploadFileMediaObject(fileId, testImageSmall, "sample.png", "image/png"), + "upload (direct)" ); - if (assertOk(uploadRes, "upload-file-media-object (direct)")) { - console.log(`VU ${__VU}: Uploaded small image (direct)`); - } } - sleep(0.5); - if (testImageLarge && testImageLarge.byteLength > 0) { - const uploadRes = client.uploadFileMediaObject( - fileId, - testImageLarge, - "sample.jpg", - "image/jpeg" + assertOk( + client.uploadFileMediaObject(fileId, testImageLarge, "sample.jpg", "image/jpeg"), + "upload (chunked)" ); - if (assertOk(uploadRes, "upload-file-media-object (chunked)")) { - console.log(`VU ${__VU}: Uploaded large image (chunked)`); - } } sleep(1); - // ---- Step 9: Delete the file ---- - const deleteFileRes = client.deleteFile(fileId); - if (assertOk(deleteFileRes, "delete-file")) { - console.log(`VU ${__VU}: Deleted file: ${fileId}`); - } - + // ---- Step 9: Delete file ---- + assertOk(client.deleteFile(fileId), "delete-file"); sleep(0.5); - // ---- Step 10: Delete the project ---- - const deleteProjectRes = client.deleteProject(projectId); - if (assertOk(deleteProjectRes, "delete-project")) { - console.log(`VU ${__VU}: Deleted project: ${projectId}`); - } - + // ---- Step 10: Delete project ---- + assertOk(client.deleteProject(projectId), "delete-project"); sleep(0.5); - // ---- Step 11: Delete the team ---- - // Note: We only delete the team if it's NOT the default team. - // The default team cannot be deleted (or may cause errors). - // For this test, we skip team deletion to avoid errors. - // In a real scenario, we'd create a separate team and delete that. - console.log(`VU ${__VU}: Skipping team deletion (using default team)`); - - sleep(0.5); - - // ---- Step 12: Logout ---- - const logoutRes = client.logout(profileId); - console.log(`VU ${__VU}: Logout status: ${logoutRes.status}`); - - console.log(`VU ${__VU}: Lifecycle complete`); + // ---- Step 11: Logout ---- + client.logout(profileId); } // --------------------------------------------------------------------------- -// Teardown — runs once after all VUs finish +// Teardown // --------------------------------------------------------------------------- export function teardown(data) { diff --git a/performance/scripts/media-upload.js b/performance/scripts/media-upload.js index f9989c19c8..2f26b537d4 100644 --- a/performance/scripts/media-upload.js +++ b/performance/scripts/media-upload.js @@ -8,15 +8,9 @@ // - PNG (5.1 KB) → direct upload // - JPG (305 KB) → chunked upload (7 chunks at 50 KB each) // -// Flow: -// 1. Register → login → create project → create file -// 2. Upload SVG (direct) -// 3. Upload PNG (direct) -// 4. Upload JPG (chunked) -// // Usage: // k6 run scripts/media-upload.js -// k6 run --vus 5 --iterations 3 scripts/media-upload.js +// k6 run --vus 50 --iterations 5 scripts/media-upload.js import { check, sleep, fail } from "k6"; import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js"; @@ -47,7 +41,7 @@ export const options = { }; // --------------------------------------------------------------------------- -// Test Data — load fixtures from backend test files +// Test Data // --------------------------------------------------------------------------- const imageSvg = open("../../backend/test/backend_tests/test_files/sample1.svg", "b"); @@ -81,25 +75,29 @@ function assertOk(res, label) { } // --------------------------------------------------------------------------- -// Setup +// Setup — create user pool // --------------------------------------------------------------------------- export function setup() { + const vuCount = (options.scenarios.media_upload && options.scenarios.media_upload.vus) || __ENV.K6_VUS || 1; + console.log(`Penpot Media Upload Test`); console.log(` Base URL: ${BASE_URL}`); - console.log(` Fixtures:`); - console.log(` SVG: ${imageSvg.byteLength} B`); - console.log(` PNG: ${imagePng.byteLength} B`); - console.log(` JPG: ${imageJpg.byteLength} B`); + console.log(` VUs: ${vuCount}`); console.log(``); const client = createClient(BASE_URL); + if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`); - // Verify backend is reachable - const res = client.getProfile(); - if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`); + const users = []; + for (let i = 0; i < vuCount; i++) { + const res = client.rpc("POST", "create-demo-profile", {}); + if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`); + users.push(res.json()); + } + console.log(` Created ${users.length} demo profiles`); - return { baseUrl: BASE_URL }; + return { baseUrl: BASE_URL, users }; } // --------------------------------------------------------------------------- @@ -109,63 +107,38 @@ export function setup() { export default function (data) { const client = createClient(data.baseUrl); - // Register - const demoRes = client.rpc("POST", "create-demo-profile", {}); - if (!assertOk(demoRes, "create-demo-profile")) fail("Failed to create demo profile"); - const demo = demoRes.json(); + // Pick user from pool + const user = data.users[__VU - 1]; + if (!user) fail(`No user for VU ${__VU}`); // Login - const loginRes = client.login(demo.email, demo.password); - if (!assertOk(loginRes, "login")) fail("Login failed"); + if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed"); sleep(0.5); // Get team - const teamsRes = client.getTeams(); - if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed"); - const teamId = teamsRes.body[0].id; + const teamId = client.getTeams().body[0].id; - // Create project - const projRes = client.createProject(teamId, `Media Project ${uuidv4().substring(0, 8)}`); - if (!assertOk(projRes, "create-project")) fail("create-project failed"); - const projectId = projRes.body.id; - - // Create file - const fileRes = client.createFile(projectId, `Media File ${uuidv4().substring(0, 8)}`); - if (!assertOk(fileRes, "create-file")) fail("create-file failed"); - const fileId = fileRes.body.id; + // Create project + file + const projectId = client.createProject(teamId, `Media ${uuidv4().substring(0, 8)}`).body.id; + const fileId = client.createFile(projectId, `Media ${uuidv4().substring(0, 8)}`).body.id; sleep(0.5); - // Upload SVG (direct — small file) - const svgRes = client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"); - if (!assertOk(svgRes, "upload SVG")) { - console.error(`VU ${__VU}: SVG upload failed`); - } else { - console.log(`VU ${__VU}: Uploaded SVG (${imageSvg.byteLength} B, direct)`); - } + // Upload SVG (direct — 3.6 KB) + assertOk(client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"), "upload SVG"); sleep(0.5); - // Upload PNG (direct — small file) - const pngRes = client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"); - if (!assertOk(pngRes, "upload PNG")) { - console.error(`VU ${__VU}: PNG upload failed`); - } else { - console.log(`VU ${__VU}: Uploaded PNG (${imagePng.byteLength} B, direct)`); - } + // Upload PNG (direct — 5.1 KB) + assertOk(client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"), "upload PNG"); sleep(0.5); // Upload JPG (chunked — 305 KB > 50 KB threshold) - const jpgRes = client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"); - if (!assertOk(jpgRes, "upload JPG")) { - console.error(`VU ${__VU}: JPG upload failed`); - } else { - console.log(`VU ${__VU}: Uploaded JPG (${imageJpg.byteLength} B, chunked)`); - } + assertOk(client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"), "upload JPG"); - console.log(`VU ${__VU}: Media upload test complete`); + console.log(`VU ${__VU}: Media upload complete`); } // --------------------------------------------------------------------------- diff --git a/performance/scripts/workspace-edit.js b/performance/scripts/workspace-edit.js index 9637014338..19148439a1 100644 --- a/performance/scripts/workspace-edit.js +++ b/performance/scripts/workspace-edit.js @@ -1,18 +1,18 @@ // Workspace Edit Performance Test (Write-heavy) // -// Simulates a user editing a file — repeatedly fetching the file (to get +// Simulates users editing files — repeatedly fetching the file (to get // the latest revn) and submitting changes. Each VU edits its own file // independently, so there are no concurrency conflicts. // -// Flow: -// 1. Register (demo profile) -// 2. Login -// 3. Create project → create file -// 4. Loop: get-file → update-file (add a shape) → sleep +// setup() creates N demo profiles + 1 shared project. +// Each VU picks its user, creates its own file, and edits it in a loop. +// +// Flow (per VU): +// Login → create file → loop: get-file → update-file → sleep // // Usage: // k6 run scripts/workspace-edit.js -// k6 run --vus 10 --iterations 20 scripts/workspace-edit.js +// k6 run --vus 100 --iterations 50 scripts/workspace-edit.js import { check, sleep, fail } from "k6"; import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js"; @@ -98,45 +98,39 @@ function makeAddRectChange(pageId, index) { } // --------------------------------------------------------------------------- -// Setup — create a file per VU to edit +// Setup — create N users + shared project // --------------------------------------------------------------------------- export function setup() { + const vuCount = (options.scenarios.workspace_edit && options.scenarios.workspace_edit.vus) || __ENV.K6_VUS || 1; + console.log(`Penpot Workspace Edit Test`); console.log(` Base URL: ${BASE_URL}`); + console.log(` VUs: ${vuCount}`); console.log(` Edit iterations: ${EDIT_ITERATIONS}`); console.log(``); const client = createClient(BASE_URL); - // Create demo profile - const demoRes = client.rpc("POST", "create-demo-profile", {}); - if (demoRes.status !== 200) fail("Failed to create demo profile"); - const demo = demoRes.json(); + if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`); - // Login - const loginRes = client.login(demo.email, demo.password); - if (loginRes.status !== 200) fail("Login failed"); + // Create N demo profiles + const users = []; + for (let i = 0; i < vuCount; i++) { + const res = client.rpc("POST", "create-demo-profile", {}); + if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`); + users.push(res.json()); + } + console.log(` Created ${users.length} demo profiles`); - // Get default team - const teamsRes = client.getTeams(); - if (teamsRes.status !== 200) fail("get-teams failed"); - const teamId = teamsRes.body[0].id; + // Login with first user to create shared project + const loginRes = client.login(users[0].email, users[0].password); + if (loginRes.status !== 200) fail("Login failed for project setup"); + const teamId = client.getTeams().body[0].id; + const projectId = client.createProject(teamId, "WS Edit Project").body.id; + console.log(` Shared project: ${projectId}`); - // Create project for edit files - const projRes = client.createProject(teamId, "WS Edit Project"); - if (projRes.status !== 200) fail("create-project failed"); - const projectId = projRes.body.id; - - console.log(` Project: ${projectId}`); - - return { - baseUrl: BASE_URL, - projectId, - profileId: loginRes.body.id, - email: demo.email, - password: demo.password, - }; + return { baseUrl: BASE_URL, projectId, users }; } // --------------------------------------------------------------------------- @@ -146,10 +140,12 @@ export function setup() { export default function (data) { const client = createClient(data.baseUrl); - // Login with the profile that owns the project - const loginRes = client.login(data.email, data.password); - if (!assertOk(loginRes, "login")) fail("login failed"); + // Pick user from pool + const user = data.users[__VU - 1]; + if (!user) fail(`No user for VU ${__VU}`); + // Login + if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed"); sleep(0.5); // Create a file for this VU @@ -160,9 +156,7 @@ export default function (data) { // Get initial file state const getFileRes = client.getFile(fileId); if (!assertOk(getFileRes, "get-file")) fail("get-file failed"); - let pageId = getFileRes.body.data.pages[0]; - let revn = getFileRes.body.revn; - let vern = getFileRes.body.vern; + const pageId = getFileRes.body.data.pages[0]; sleep(0.5); @@ -170,12 +164,8 @@ export default function (data) { for (let i = 0; i < EDIT_ITERATIONS; i++) { // Refresh file state to get latest revn const refreshRes = client.getFile(fileId); - if (!assertOk(refreshRes, "get-file")) { - console.warn(`VU ${__VU}: get-file failed on iteration ${i}, skipping`); - continue; - } - revn = refreshRes.body.revn; - vern = refreshRes.body.vern; + if (!assertOk(refreshRes, "get-file")) continue; + const { revn, vern } = refreshRes.body; sleep(0.3); @@ -183,9 +173,7 @@ export default function (data) { const changes = [makeAddRectChange(pageId, i)]; const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes); - if (updateRes.status === 200) { - // ok - } else { + if (updateRes.status !== 200) { // Retry once on revn conflict const body = updateRes.body; const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict"); diff --git a/performance/scripts/workspace-open.js b/performance/scripts/workspace-open.js index 123a973ed6..7a2739eb47 100644 --- a/performance/scripts/workspace-open.js +++ b/performance/scripts/workspace-open.js @@ -1,22 +1,20 @@ // Workspace Open Performance Test (Read-heavy) // -// Simulates a user opening a file in the workspace editor. +// Simulates many users opening the same file in the workspace editor. // This is the most common read-heavy operation — loading a file and its // dependencies (libraries, thumbnails). // -// Flow: -// 1. Register (demo profile) -// 2. Login -// 3. Get teams → default team -// 4. Create project → create file (setup data) -// 5. Update file with a shape (so the file has data) -// 6. Loop: get-file → get-file-libraries → get-file-object-thumbnails -// → get-file-data-for-thumbnail +// setup() creates one user, one project, and one file with a shape. +// All VUs login with the same user and read the same file concurrently. +// +// Flow (per VU iteration): +// Login → get-file → get-file-libraries → get-file-object-thumbnails +// → get-file-data-for-thumbnail // // Usage: // k6 run scripts/workspace-open.js +// k6 run --vus 100 --iterations 20 scripts/workspace-open.js // k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/workspace-open.js -// k6 run --vus 10 --iterations 5 scripts/workspace-open.js import { check, sleep, fail } from "k6"; import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js"; @@ -75,7 +73,7 @@ function assertOk(res, label) { } // --------------------------------------------------------------------------- -// Setup — create a file with data that we'll open repeatedly +// Setup — create one user + one file with data // --------------------------------------------------------------------------- export function setup() { @@ -86,127 +84,76 @@ export function setup() { const client = createClient(BASE_URL); - // Small random delay to avoid demo profile creation race with parallel scripts - sleep(Math.random() * 3); + // Verify backend reachable + if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`); - // Create demo profile + // Create one demo user const demoRes = client.rpc("POST", "create-demo-profile", {}); - if (demoRes.status !== 200) { - fail("Failed to create demo profile — is demo-users flag enabled?"); - } - const demoBody = demoRes.json(); - console.log(` Created demo profile: ${demoBody.email}`); + if (demoRes.status !== 200) fail("Failed to create demo profile"); + const { email, password } = demoRes.json(); // Login - const loginRes = client.login(demoBody.email, demoBody.password); + const loginRes = client.login(email, password); if (loginRes.status !== 200) fail("Login failed"); - // Get default team - const teamsRes = client.getTeams(); - if (teamsRes.status !== 200) fail("get-teams failed"); - const teamId = teamsRes.body[0].id; + // Create project + file + const teamId = client.getTeams().body[0].id; + const projectId = client.createProject(teamId, "WS Open Project").body.id; + const fileId = client.createFile(projectId, "WS Open File").body.id; - // Create project - const projRes = client.createProject(teamId, "WS Open Project"); - if (projRes.status !== 200) fail("create-project failed"); - const projectId = projRes.body.id; - - // Create file - const fileRes = client.createFile(projectId, "WS Open File"); - if (fileRes.status !== 200) fail("create-file failed"); - const fileId = fileRes.body.id; - - // Get file to read page-id, revn, vern - const getFileRes = client.getFile(fileId); - if (getFileRes.status !== 200) fail("get-file failed"); - const fileData = getFileRes.body; + // Get file data and add a shape so it has meaningful content + const fileData = client.getFile(fileId).body; const pageId = fileData.data.pages[0]; - - // Add a shape so the file has meaningful data const shapeId = uuidv4(); - const x = 50; - const y = 50; - const w = 300; - const h = 200; - const changes = [{ - type: "add-obj", - pageId: pageId, - id: shapeId, - frameId: pageId, - parentId: pageId, + const x = 50, y = 50, w = 300, h = 200; + + client.updateFile(fileId, fileData.revn, fileData.vern, uuidv4(), [{ + type: "add-obj", pageId, id: shapeId, frameId: pageId, parentId: pageId, obj: { id: shapeId, type: "rect", name: "Background", x, y, width: w, height: h, fillColor: "#cccccc", fillOpacity: 1, rotation: 0, hidden: false, locked: false, selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h }, - points: [ - { x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }, - ], + points: [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }], transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, parentId: pageId, frameId: pageId, }, - }]; - const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes); - if (updateRes.status !== 200) fail("update-file (seed shape) failed"); + }]); console.log(` File ready: ${fileId} (page: ${pageId})`); - return { - baseUrl: BASE_URL, - fileId, - projectId, - teamId, - profileId: loginRes.body.id, - email: demoBody.email, - password: demoBody.password, - }; + return { baseUrl: BASE_URL, email, password, fileId }; } // --------------------------------------------------------------------------- -// Main VU Function +// Main VU Function — all VUs read the same file // --------------------------------------------------------------------------- export default function (data) { const client = createClient(data.baseUrl); - // Small random delay to spread out concurrent VU logins - sleep(Math.random() * 2); - - // Login with the same profile that owns the file - const loginRes = client.login(data.email, data.password); - if (!assertOk(loginRes, "login")) fail("login failed"); + // Login with shared user + if (!assertOk(client.login(data.email, data.password), "login")) fail("login failed"); sleep(0.5); for (let i = 0; i < OPEN_ITERATIONS; i++) { - // 1. Get file - const getFileRes = client.getFile(data.fileId); - if (!assertOk(getFileRes, "get-file")) fail("get-file failed"); - + if (!assertOk(client.getFile(data.fileId), "get-file")) fail("get-file failed"); sleep(0.3); - // 2. Get file libraries - const libsRes = client.getFileLibraries(data.fileId); - if (!assertOk(libsRes, "get-file-libraries")) fail("get-file-libraries failed"); - + if (!assertOk(client.getFileLibraries(data.fileId), "get-file-libraries")) fail("get-file-libraries failed"); sleep(0.2); - // 3. Get object thumbnails - const thumbsRes = client.getFileObjectThumbnails(data.fileId); - if (!assertOk(thumbsRes, "get-file-object-thumbnails")) fail("get-file-object-thumbnails failed"); - + if (!assertOk(client.getFileObjectThumbnails(data.fileId), "get-file-object-thumbnails")) fail("get-file-object-thumbnails failed"); sleep(0.2); - // 4. Get file data for thumbnail - const thumbDataRes = client.getFileDataForThumbnail(data.fileId); - if (!assertOk(thumbDataRes, "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed"); - + if (!assertOk(client.getFileDataForThumbnail(data.fileId), "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed"); sleep(1); } - console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} workspace open iterations`); + console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} open iterations`); } // --------------------------------------------------------------------------- diff --git a/plans/2026-06-12-backend-performance-test.md b/plans/2026-06-12-backend-performance-test.md index ad12cfaf3b..a6969534c1 100644 --- a/plans/2026-06-12-backend-performance-test.md +++ b/plans/2026-06-12-backend-performance-test.md @@ -39,17 +39,31 @@ performance/ Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in `performance/`). +**Backend change:** `backend/src/app/rpc/commands/demo.clj` — demo profile emails changed from timestamp-based (`demo-.demo@example.com`) to UUID-based (`demo-@demo.example.com`). Eliminates concurrent creation collisions at the source. + +**All scripts use `setup()` user pool:** + +| Script | setup() creates | VU pattern | +|--------|----------------|------------| +| `lifecycle.js` | N users | Each VU picks `users[__VU-1]` → login → full CRUD | +| `workspace-open.js` | 1 user + 1 file with shape | All VUs share same user + file (realistic concurrent reads) | +| `workspace-edit.js` | N users + shared project | Each VU creates own file → edit loop | +| `media-upload.js` | N users | Each VU creates project/file → upload 3 images | +| `font-upload.js` | N users | Each VU uploads TTF+OTF → create-font-variant | + +Setup is sequential (~140ms/user), excluded from k6 metrics. At 1000 VUs: ~2.3min setup, then pure measurement. + **All flows validated (smoke test, 1 VU, 1 iteration each):** -| Script | Checks | HTTP Requests | Failure Rate | Duration | -|--------|--------|---------------|-------------|----------| -| `lifecycle.js` | 11/11 | 22 | 0% | ~12s | -| `workspace-open.js` | 5/5 per iter (×3) | 12 | 0% | ~4s | -| `workspace-edit.js` | 5/5 per iter (×2) | 11 | 0% | ~4s | -| `media-upload.js` | 8/8 | 17 | 0% | ~3s | -| `font-upload.js` | 11/11 | 12 | 0% | ~3s | +| Script | Checks | Failure Rate | +|--------|--------|-------------| +| `lifecycle.js` | 10/10 | 0% | +| `workspace-open.js` | 9/9 | 0% | +| `workspace-edit.js` | 5/5 | 0% | +| `media-upload.js` | 8/8 | 0% | +| `font-upload.js` | 11/11 | 0% | -**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel (10 VUs total), all checks pass, 0% failure rate. +**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel, 0% failure rate. **Key discoveries (cumulative):** @@ -65,7 +79,7 @@ Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in 6. **k6 at** `/home/penpot/.local/bin/k6` (v0.56.0). Use `PATH="/home/penpot/.local/bin:$PATH"` or `K6` env var. -7. **Demo profile race condition.** `create-demo-profile` uses timestamp-based emails. Parallel calls within the same ms get the same email. Mitigated with `sleep(Math.random() * 2)` before profile creation in parallel scenarios. +7. **Demo profile race condition — solved.** Backend now uses `uuid/next` for demo emails (no collisions). k6 scripts use `setup()` to create user pool before VUs start. Both changes together eliminate the scaling bottleneck. 8. **Chunked upload threshold.** The client uses 50 KB chunk size. Files ≤50 KB use direct multipart; files >50 KB use `create-upload-session` → `upload-chunk` × N → `assemble-file-media-object`. @@ -73,25 +87,29 @@ Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in 10. **MIME type validation.** The backend validates that the uploaded content MIME matches the declared MIME. `sample.jpg` must be sent as `image/jpeg`, not `image/png`. +11. **workspace-open uses shared user.** All VUs read the same file with the same user. Multiple demo users can't access each other's files without team sharing, so a single shared user is the correct pattern for read-heavy tests. + +12. **Demo profile creation is slow due to argon2id.** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2. Creating 1000 demo profiles in `setup()` takes ~2–3 minutes just for password hashing. At 1000 VUs, this is the dominant cost of the setup phase. **Simplification:** Since `demo-users` is already a development-only feature, demo profiles should use a weaker password algorithm by default (e.g., bcrypt cost 4). No special parameters needed — just change `demo.clj` to call a new `derive-password-weak` function. + ### Remaining Work | Phase | Status | Next Actions | |-------|--------|-------------| | Phase 1 – Discovery & Tooling | **Done** | — | -| Phase 2 – Core HTTP Flows | **Done** | All 5 flows implemented + orchestrator | -| Phase 2 – Data seeding | **Not needed** | User pool created in k6 `setup()` — no external script needed | +| Phase 2 – Core HTTP Flows | **Done** | All 5 flows + orchestrator + setup() pool | +| Phase 2 – Performance Optimization | **Not started** | Fast password hashing for demo users | | Phase 3 – Scenarios | **Done** | `./run.sh all` runs all flows in parallel | | Phase 4 – Advanced update-file | **Not started** | File size tiers, concurrent editing matrix | | Phase 5 – CI & Reporting | **Not started** | Grafana dashboards, regression guard | ### Immediate Next Steps -1. ~~Create `seed-data.js`~~ — Not needed. Use k6 `setup()` to create a user pool before VUs start. Each VU picks a pre-existing user from the pool (`data.users[__VU - 1]`). Setup is sequential (~140ms/user) and excluded from metrics. Self-contained, no external tooling. -2. Add `--scenario` flag to `run.sh` to select individual flows by name from the orchestrator. -3. Write `viewer.js` — `get-view-only-bundle` + `get-comment-threads` (deferred per user request). -4. Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes). -5. Phase 4: Concurrent editing test (2–3 VUs per file, measure conflict rate). -6. Phase 5: Grafana dashboard panels (p95 latency by RPC, error rate, JVM, DB pool). +1. **Phase 2 – Fast password for demo users:** Add `derive-password-weak` in `backend/src/app/auth.clj` (e.g., bcrypt cost 4 or SHA-256). Update `backend/src/app/rpc/commands/demo.clj` to use it for all demo profiles. This reduces `setup()` time from ~2–3 min to ~5–10 sec for 1000 users. **Does not affect production** — `demo-users` flag is disabled by default in production. +2. Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes). +3. Phase 4: Concurrent editing test (2–3 VUs per file, measure conflict rate). +4. Phase 5: Grafana dashboard panels (p95 latency by RPC, error rate, JVM, DB pool). +5. Add `--scenario` flag to `run.sh` to select individual flows by name from the orchestrator. +6. Write `viewer.js` — `get-view-only-bundle` + `get-comment-threads` (deferred per user request). --- @@ -317,6 +335,28 @@ The backend `update-file` performance depends heavily on file data size (seriali --- +### Phase 2 – Performance Optimization: Fast Password Hashing for Demo Users + +**Goal:** Reduce `setup()` time for performance tests by making demo profile password derivation faster. + +**Problem:** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2. At 1000 VUs, creating the user pool in `setup()` takes ~2–3 minutes just for password hashing. + +**Solution:** +Since `demo-users` is already a development-only feature (disabled by default in production), we can simplify: all demo profiles should use a weaker, faster password algorithm. No special parameters or tenant checks needed. + +1. In `backend/src/app/auth.clj`, add a `derive-password-weak` function that uses a faster algorithm (e.g., bcrypt cost 4 or SHA-256). Keep the default `derive-password` as argon2id for regular users. +2. In `backend/src/app/rpc/commands/demo.clj`, use `derive-password-weak` instead of `derive-password` when creating demo profiles. This is safe because `demo-users` is already gated behind a config flag and is only used in development/testing. + +**Files to touch:** +- `backend/src/app/auth.clj` — add `derive-password-weak` function (e.g., bcrypt cost 4 or SHA-256) +- `backend/src/app/rpc/commands/demo.clj` — use `derive-password-weak` instead of `derive-password` + +**Expected impact:** Setup time for 1000 users drops from ~2–3 min to ~5–10 sec. + +**Safety:** Demo users are already a development-only feature (disabled by default in production via `demo-users` config flag). Using weaker passwords for demo users only affects development/test environments where the flag is explicitly enabled. + +--- + ### Phase 3 – Scenarios & Orchestration (Day 6) Define k6 `options.scenarios` that mix the flows to simulate realistic traffic. @@ -483,5 +523,5 @@ Run `workspace-edit.js` against each tier separately and plot: --- **Plan Author:** Senior Software Architect -**Status:** Phase 1–3 complete (all core flows + orchestrator). Phase 4–5 remain. See "Current Progress" above. +**Status:** Phase 1–3 complete. Backend UUID fix + setup() user pool applied. Scripts ready for 1000 VU scale. Phase 4–5 remain.