🚧 Add the ability to scale users to 1000 or more

This commit is contained in:
Andrey Antukh 2026-06-12 14:47:17 +02:00
parent c156387a9f
commit 25a5ac8513
7 changed files with 287 additions and 541 deletions

View File

@ -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)

View File

@ -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`);
}
// ---------------------------------------------------------------------------

View File

@ -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) {

View File

@ -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`);
}
// ---------------------------------------------------------------------------

View File

@ -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");

View File

@ -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`);
}
// ---------------------------------------------------------------------------

View File

@ -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-<millis>.demo@example.com`) to UUID-based (`demo-<uuid>@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 ~23 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 (23 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 ~23 min to ~510 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 (23 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 ~23 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 ~23 min to ~510 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 13 complete (all core flows + orchestrator). Phase 45 remain. See "Current Progress" above.
**Status:** Phase 13 complete. Backend UUID fix + setup() user pool applied. Scripts ready for 1000 VU scale. Phase 45 remain.