mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 20:32:04 +00:00
322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
// Workspace Edit Concurrent Performance Test
|
||
//
|
||
// Two modes for measuring concurrent file editing:
|
||
//
|
||
// Mode 1: same-file — N VUs edit different pages in 1 file
|
||
// Measures lock contention on a single popular file.
|
||
// Bottleneck: advisory lock serialization (db/xact-lock!).
|
||
//
|
||
// Mode 2: multi-file — G groups × M VUs per file
|
||
// Each group edits its own file on its own page.
|
||
// Measures whole system responsiveness under parallel edit sessions.
|
||
// Bottleneck: DB connection pool, CPU, memory.
|
||
//
|
||
// Key insight: revn conflicts only occur when incoming > stored (should
|
||
// never happen in normal usage). The real contention point is the file-level
|
||
// advisory lock that serializes all update-file calls on the same file.
|
||
//
|
||
// Usage:
|
||
// # Same-file mode (default): 5 VUs edit different pages in 1 file
|
||
// k6 run --vus 5 --iterations 10 scripts/workspace-edit-concurrent.js
|
||
//
|
||
// # Multi-file mode: 3 files × 2 VUs each = 6 VUs total
|
||
// PENPOT_EDIT_MODE=multi-file PENPOT_FILE_COUNT=3 PENPOT_VUS_PER_FILE=2 \
|
||
// k6 run --vus 6 --iterations 10 scripts/workspace-edit-concurrent.js
|
||
//
|
||
// # Via run.sh
|
||
// ./run.sh concurrent-edit --mode same-file --vus 5 --iterations 10
|
||
// ./run.sh concurrent-edit --mode multi-file --files 3 --vus-per-file 2 --iterations 10
|
||
|
||
import { check, sleep, fail } from "k6";
|
||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||
import { createClient } from "../lib/penpot-client.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Configuration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
|
||
const EDIT_MODE = __ENV.PENPOT_EDIT_MODE || "same-file"; // "same-file" or "multi-file"
|
||
const FILE_COUNT = parseInt(__ENV.PENPOT_FILE_COUNT || "1");
|
||
const VUS_PER_FILE = parseInt(__ENV.PENPOT_VUS_PER_FILE || "1");
|
||
const EDIT_ITERATIONS = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "10");
|
||
|
||
// Calculate total VUs based on mode
|
||
const TOTAL_VUS = EDIT_MODE === "multi-file"
|
||
? FILE_COUNT * VUS_PER_FILE
|
||
: parseInt(__ENV.PENPOT_TOTAL_VUS || "3");
|
||
|
||
export const options = {
|
||
scenarios: {
|
||
workspace_edit_concurrent: {
|
||
executor: "per-vu-iterations",
|
||
vus: TOTAL_VUS,
|
||
iterations: EDIT_ITERATIONS,
|
||
maxDuration: "10m",
|
||
},
|
||
},
|
||
thresholds: {
|
||
http_req_duration: ["p(95)<5000"],
|
||
http_req_failed: ["rate<0.01"],
|
||
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
|
||
"http_req_duration{rpc_command:update-file}": ["p(95)<3000"],
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function assertOk(res, label) {
|
||
const ok = check(res, {
|
||
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
|
||
});
|
||
if (!ok) {
|
||
let bodyStr = "";
|
||
try {
|
||
if (res.raw && res.raw.body) {
|
||
bodyStr = typeof res.raw.body === "string"
|
||
? res.raw.body.substring(0, 500)
|
||
: JSON.stringify(res.raw.body).substring(0, 500);
|
||
} else if (res.body) {
|
||
bodyStr = JSON.stringify(res.body).substring(0, 500);
|
||
}
|
||
} catch (e) {
|
||
bodyStr = "(could not read body)";
|
||
}
|
||
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
function makeAddRectChange(pageId, index) {
|
||
const shapeId = uuidv4();
|
||
const x = 50 + (index % 10) * 30;
|
||
const y = 50 + Math.floor(index / 10) * 30;
|
||
const w = 100;
|
||
const h = 80;
|
||
|
||
return {
|
||
type: "add-obj",
|
||
pageId: pageId,
|
||
id: shapeId,
|
||
frameId: pageId,
|
||
parentId: pageId,
|
||
obj: {
|
||
id: shapeId, type: "rect", name: `Shape ${index}`,
|
||
x, y, width: w, height: h,
|
||
fillColor: "#00ff00", fillOpacity: 0.8,
|
||
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 },
|
||
],
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Add a page to a file via update-file with add-page change
|
||
function addPage(client, fileId, revn, vern, pageId, pageName) {
|
||
const change = {
|
||
type: "add-page",
|
||
id: pageId,
|
||
name: pageName,
|
||
};
|
||
return client.updateFile(fileId, revn, vern, client.sessionId, [change]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Setup — create users, files, and pages based on mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function setup() {
|
||
console.log(`Penpot Concurrent Edit Test`);
|
||
console.log(` Base URL: ${BASE_URL}`);
|
||
console.log(` Mode: ${EDIT_MODE}`);
|
||
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
|
||
|
||
if (EDIT_MODE === "same-file") {
|
||
console.log(` Total VUs: ${TOTAL_VUS} (same file)`);
|
||
} else {
|
||
console.log(` Files: ${FILE_COUNT}`);
|
||
console.log(` VUs per file: ${VUS_PER_FILE}`);
|
||
console.log(` Total VUs: ${TOTAL_VUS}`);
|
||
}
|
||
console.log(``);
|
||
|
||
const client = createClient(BASE_URL);
|
||
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||
|
||
// Create demo profiles (one per VU)
|
||
const users = [];
|
||
for (let i = 0; i < TOTAL_VUS; i++) {
|
||
const res = client.rpc("POST", "create-demo-profile", {});
|
||
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${TOTAL_VUS}`);
|
||
users.push(res.json());
|
||
}
|
||
console.log(` Created ${users.length} demo profiles`);
|
||
|
||
// Login with first user to create project and files
|
||
const loginRes = client.login(users[0].email, users[0].password);
|
||
if (loginRes.status !== 200) fail("Login failed for setup");
|
||
const teamId = client.getTeams().body[0].id;
|
||
const projectId = client.createProject(teamId, "Concurrent Edit Project").body.id;
|
||
console.log(` Project: ${projectId}`);
|
||
|
||
// Build file/page assignments based on mode
|
||
const fileAssignments = []; // [{ fileId, pageIds[] }]
|
||
const vuAssignments = []; // [{ vuIndex, fileId, pageId }]
|
||
|
||
if (EDIT_MODE === "same-file") {
|
||
// One file, N pages (one per VU)
|
||
const fileRes = client.createFile(projectId, "Shared Edit File");
|
||
if (fileRes.status !== 200) fail("Failed to create shared file");
|
||
const fileId = fileRes.body.id;
|
||
console.log(` Created file: ${fileId}`);
|
||
|
||
// Get initial file state (has 1 default page)
|
||
const getFileRes = client.getFile(fileId);
|
||
if (getFileRes.status !== 200) fail("Failed to get initial file");
|
||
const defaultPageId = getFileRes.body.data.pages[0];
|
||
let revn = getFileRes.body.revn;
|
||
let vern = getFileRes.body.vern;
|
||
|
||
// First VU uses the default page
|
||
const pageIds = [defaultPageId];
|
||
|
||
// Add remaining pages
|
||
for (let i = 1; i < TOTAL_VUS; i++) {
|
||
const pageId = uuidv4();
|
||
const pageName = `Page ${i + 1}`;
|
||
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
|
||
if (addRes.status !== 200) fail(`Failed to add page ${i + 1}`);
|
||
revn = addRes.body.revn;
|
||
vern = addRes.body.vern;
|
||
pageIds.push(pageId);
|
||
}
|
||
console.log(` Added ${pageIds.length} pages to file`);
|
||
|
||
fileAssignments.push({ fileId, pageIds });
|
||
|
||
// Each VU gets its own page in the same file
|
||
for (let i = 0; i < TOTAL_VUS; i++) {
|
||
vuAssignments.push({ vuIndex: i, fileId, pageId: pageIds[i] });
|
||
}
|
||
|
||
} else {
|
||
// Multi-file mode: G files, each with M pages
|
||
for (let f = 0; f < FILE_COUNT; f++) {
|
||
const fileRes = client.createFile(projectId, `Edit File ${f + 1}`);
|
||
if (fileRes.status !== 200) fail(`Failed to create file ${f + 1}`);
|
||
const fileId = fileRes.body.id;
|
||
console.log(` Created file ${f + 1}: ${fileId}`);
|
||
|
||
// Get initial file state (has 1 default page)
|
||
const getFileRes = client.getFile(fileId);
|
||
if (getFileRes.status !== 200) fail(`Failed to get file ${f + 1}`);
|
||
const defaultPageId = getFileRes.body.data.pages[0];
|
||
let revn = getFileRes.body.revn;
|
||
let vern = getFileRes.body.vern;
|
||
|
||
// First VU of this file uses the default page
|
||
const pageIds = [defaultPageId];
|
||
|
||
// Add remaining pages for this file
|
||
for (let p = 1; p < VUS_PER_FILE; p++) {
|
||
const pageId = uuidv4();
|
||
const pageName = `Page ${p + 1}`;
|
||
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
|
||
if (addRes.status !== 200) fail(`Failed to add page ${p + 1} to file ${f + 1}`);
|
||
revn = addRes.body.revn;
|
||
vern = addRes.body.vern;
|
||
pageIds.push(pageId);
|
||
}
|
||
console.log(` Added ${pageIds.length} pages to file ${f + 1}`);
|
||
|
||
fileAssignments.push({ fileId, pageIds });
|
||
|
||
// Assign VUs to this file's pages
|
||
for (let p = 0; p < VUS_PER_FILE; p++) {
|
||
const vuIndex = f * VUS_PER_FILE + p;
|
||
vuAssignments.push({ vuIndex, fileId, pageId: pageIds[p] });
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(` Setup complete. ${vuAssignments.length} VU assignments.`);
|
||
console.log(``);
|
||
|
||
return {
|
||
baseUrl: BASE_URL,
|
||
editMode: EDIT_MODE,
|
||
users,
|
||
vuAssignments,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main VU Function — each VU edits its assigned page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export default function (data) {
|
||
const client = createClient(data.baseUrl);
|
||
|
||
// Pick user and assignment from pool
|
||
const vuIndex = __VU - 1;
|
||
const user = data.users[vuIndex];
|
||
const assignment = data.vuAssignments[vuIndex];
|
||
|
||
if (!user) fail(`No user for VU ${__VU} (index ${vuIndex})`);
|
||
if (!assignment) fail(`No assignment for VU ${__VU} (index ${vuIndex})`);
|
||
|
||
const { fileId, pageId } = assignment;
|
||
|
||
// Login
|
||
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
|
||
sleep(0.5);
|
||
|
||
// Edit loop
|
||
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")) continue;
|
||
const { revn, vern } = refreshRes.body;
|
||
|
||
sleep(0.3);
|
||
|
||
// Submit a change to our assigned page
|
||
const changes = [makeAddRectChange(pageId, i)];
|
||
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
|
||
|
||
if (updateRes.status !== 200) {
|
||
const body = updateRes.body;
|
||
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
|
||
if (isConflict) {
|
||
// This shouldn't happen in normal circumstances, but handle it gracefully
|
||
console.warn(`VU ${__VU}: revn conflict on iteration ${i} (unexpected)`);
|
||
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 on iteration ${i}: ${JSON.stringify(body)}`);
|
||
}
|
||
}
|
||
|
||
sleep(1);
|
||
}
|
||
|
||
console.log(`VU ${__VU}: Completed ${EDIT_ITERATIONS} edits on file ${fileId}, page ${pageId}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Teardown
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function teardown(data) {
|
||
console.log(`Concurrent edit test complete (${data.editMode}).`);
|
||
}
|