From a9b3721d8553770a9fbafb15e42acfaf13296909 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Jun 2026 11:01:18 +0200 Subject: [PATCH] :construction: Add concurrent edition flow --- .gitignore | 1 + performance/run.sh | 80 ++++- .../scripts/workspace-edit-concurrent.js | 321 ++++++++++++++++++ plans/2026-06-12-backend-performance-test.md | 105 ++++-- 4 files changed, 477 insertions(+), 30 deletions(-) create mode 100644 performance/scripts/workspace-edit-concurrent.js diff --git a/.gitignore b/.gitignore index 5bc58cd0b2..dba1675ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ /opencode.json /.codex/ /tools/__pycache__ +/performance/results/ \ No newline at end of file diff --git a/performance/run.sh b/performance/run.sh index 337b1466bd..96266dcfef 100755 --- a/performance/run.sh +++ b/performance/run.sh @@ -15,6 +15,7 @@ # ./run.sh workspace-edit # Write-heavy file edit loop # ./run.sh media-upload # Direct + chunked image uploads # ./run.sh font-upload # Chunked font upload + variant creation +# ./run.sh concurrent-edit # Concurrent editing (same-file or multi-file) # ./run.sh all # Run all scenarios together (orchestrator) # ./run.sh clean # Remove test results @@ -31,6 +32,9 @@ VUS="" ITER="" REGISTER_MODE="${PENPOT_REGISTER_MODE:-demo}" K6="${K6:-k6}" +EDIT_MODE="${PENPOT_EDIT_MODE:-same-file}" +FILE_COUNT="${PENPOT_FILE_COUNT:-1}" +VUS_PER_FILE="${PENPOT_VUS_PER_FILE:-1}" # --------------------------------------------------------------------------- # Helpers @@ -50,6 +54,7 @@ Commands: workspace-edit Write-heavy: repeatedly edit a file (get-file + update-file loop) media-upload Upload images of varying sizes (direct + chunked) font-upload Upload fonts via chunked upload + create-font-variant + concurrent-edit Concurrent editing: same-file or multi-file mode all Run all scenarios together (orchestrator) clean Remove test results help Show this help @@ -61,9 +66,17 @@ Options: -m MODE Register mode: 'demo' or 'register' (default: $REGISTER_MODE) -k PATH Path to k6 binary (default: $K6) +Concurrent-edit options: + --mode MODE 'same-file' or 'multi-file' (default: $EDIT_MODE) + --files NUM Number of files for multi-file mode (default: $FILE_COUNT) + --vus-per-file NUM VUs per file for multi-file mode (default: $VUS_PER_FILE) + Environment variables: PENPOT_BASE_URL Same as -u PENPOT_REGISTER_MODE Same as -m + PENPOT_EDIT_MODE Same as --mode + PENPOT_FILE_COUNT Same as --files + PENPOT_VUS_PER_FILE Same as --vus-per-file K6 Same as -k Examples: @@ -71,6 +84,8 @@ Examples: $(basename "$0") lifecycle -v 10 -n 5 $(basename "$0") workspace-edit -v 20 -n 50 $(basename "$0") media-upload -u https://penpot.example.com + $(basename "$0") concurrent-edit --mode same-file -v 5 -n 10 + $(basename "$0") concurrent-edit --mode multi-file --files 3 --vus-per-file 2 -n 10 $(basename "$0") all -v 50 EOF } @@ -85,7 +100,11 @@ check_k6() { # Build k6 env flags k6_env_flags() { - echo "--env PENPOT_BASE_URL=$BASE_URL --env PENPOT_REGISTER_MODE=$REGISTER_MODE" + local flags="--env PENPOT_BASE_URL=$BASE_URL --env PENPOT_REGISTER_MODE=$REGISTER_MODE --env PENPOT_EDIT_MODE=$EDIT_MODE --env PENPOT_FILE_COUNT=$FILE_COUNT --env PENPOT_VUS_PER_FILE=$VUS_PER_FILE" + if [[ -n "${PENPOT_TOTAL_VUS:-}" ]]; then + flags="$flags --env PENPOT_TOTAL_VUS=$PENPOT_TOTAL_VUS" + fi + echo "$flags" } # Build k6 VU/iteration flags (only if explicitly set) @@ -225,6 +244,36 @@ cmd_media_upload() { check_k6; run_script "media-upload.js" "media-upload"; } cmd_font_upload() { check_k6; run_script "font-upload.js" "font-upload"; } cmd_all() { check_k6; run_all; } +cmd_concurrent_edit() { + check_k6 + + local label="concurrent-edit-${EDIT_MODE}" + if [[ "$EDIT_MODE" == "multi-file" ]]; then + label="${label}-${FILE_COUNT}files-${VUS_PER_FILE}vpu" + fi + + echo "" + echo "=== Concurrent Edit ($EDIT_MODE) ===" + echo " Mode: $EDIT_MODE" + if [[ "$EDIT_MODE" == "multi-file" ]]; then + echo " Files: $FILE_COUNT" + echo " VUs per file: $VUS_PER_FILE" + local total_vus=$((FILE_COUNT * VUS_PER_FILE)) + echo " Total VUs: $total_vus" + else + [[ -n "$VUS" ]] && echo " VUs: $VUS" + fi + [[ -n "$ITER" ]] && echo " Iterations: $ITER" + echo "" + + # For same-file mode, pass VUS as PENPOT_TOTAL_VUS so setup() knows how many pages to create + if [[ "$EDIT_MODE" == "same-file" && -n "$VUS" ]]; then + export PENPOT_TOTAL_VUS="$VUS" + fi + + run_script "workspace-edit-concurrent.js" "$label" +} + cmd_clean() { local results_dir="$SCRIPT_DIR/results" if [[ -d "$results_dir" ]]; then @@ -241,6 +290,32 @@ cmd_clean() { # Parse global options first (before command) parse_opts() { + # First, extract long options (--mode, --files, --vus-per-file) + local args=() + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + EDIT_MODE="$2" + shift 2 + ;; + --files) + FILE_COUNT="$2" + shift 2 + ;; + --vus-per-file) + VUS_PER_FILE="$2" + shift 2 + ;; + *) + args+=("$1") + shift + ;; + esac + done + + # Now parse short options with getopts + set -- "${args[@]}" + OPTIND=1 while getopts "u:v:n:m:k:h" opt; do case "$opt" in u) BASE_URL="$OPTARG" ;; @@ -268,8 +343,6 @@ case "$command" in ;; *) parse_opts "$@" - # Consume parsed opts - while getopts "u:v:n:m:k:h" _ 2>/dev/null; do shift $((OPTIND - 1)); done 2>/dev/null || true ;; esac @@ -280,6 +353,7 @@ case "$command" in workspace-edit) cmd_workspace_edit ;; media-upload) cmd_media_upload ;; font-upload) cmd_font_upload ;; + concurrent-edit) cmd_concurrent_edit ;; all) cmd_all ;; clean) cmd_clean ;; help|-h|--help) usage ;; diff --git a/performance/scripts/workspace-edit-concurrent.js b/performance/scripts/workspace-edit-concurrent.js new file mode 100644 index 0000000000..a54ed4cabc --- /dev/null +++ b/performance/scripts/workspace-edit-concurrent.js @@ -0,0 +1,321 @@ +// 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}).`); +} diff --git a/plans/2026-06-12-backend-performance-test.md b/plans/2026-06-12-backend-performance-test.md index f2fa01836c..74887a1640 100644 --- a/plans/2026-06-12-backend-performance-test.md +++ b/plans/2026-06-12-backend-performance-test.md @@ -17,7 +17,7 @@ ### Completed (2026-06-12) -Phase 1 done. Phase 2 done (all core flows + performance optimization). Phase 3 done (orchestrator). Phase 4 and 5 remain. +Phase 1 done. Phase 2 done (all core flows + performance optimization). Phase 3 done (orchestrator). Phase 4 concurrent editing done. Phase 4 file size matrix and Phase 5 remain. **What was built:** @@ -28,11 +28,12 @@ performance/ ├── lib/ │ └── penpot-client.js # ~590 lines — shared k6 HTTP client module ├── scripts/ -│ ├── lifecycle.js # Full user lifecycle (register → CRUD → delete) -│ ├── workspace-open.js # Read-heavy: file open loop (get-file, libraries, thumbnails) -│ ├── workspace-edit.js # Write-heavy: file edit loop (get-file + update-file) -│ ├── media-upload.js # Image uploads: SVG/PNG direct, JPG chunked -│ └── font-upload.js # Font uploads: TTF+OTF chunked, create-font-variant +│ ├── lifecycle.js # Full user lifecycle (register → CRUD → delete) +│ ├── workspace-open.js # Read-heavy: file open loop (get-file, libraries, thumbnails) +│ ├── workspace-edit.js # Write-heavy: file edit loop (get-file + update-file) +│ ├── workspace-edit-concurrent.js # Concurrent editing: same-file or multi-file mode +│ ├── media-upload.js # Image uploads: SVG/PNG direct, JPG chunked +│ └── font-upload.js # Font uploads: TTF+OTF chunked, create-font-variant ├── results/ # k6 JSON output (gitignored) └── baselines/ # for regression baselines ``` @@ -95,6 +96,8 @@ Setup is sequential (~0.13ms/user with `derive-password-weak`), excluded from k6 13. **bcrypt minimum cost factor is 4.** Can't go below 4 for bcrypt. pbkdf2+sha256 with 100 iterations is even faster (~0.13ms/hash vs ~2.7ms for bcrypt cost 4) and was chosen instead. Benchmark: argon2id ~94ms/hash, bcrypt cost 4 ~2.7ms/hash, pbkdf2+sha256 100 iter ~0.13ms/hash. +14. **revn conflicts don't happen in normal concurrent editing.** The conflict check in `files_update.clj` is `(> incoming stored)` — only fires when incoming revn is *greater* than stored. If two VUs both read revn=5 and VU A saves first (revn becomes 6), VU B saves with revn=5 → `5 > 6?` → false → no conflict. The real contention point is the **file-level advisory lock** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file. More VUs = more lock queuing = higher latency. + ### Remaining Work | Phase | Status | Next Actions | @@ -103,14 +106,15 @@ Setup is sequential (~0.13ms/user with `derive-password-weak`), excluded from k6 | Phase 2 – Core HTTP Flows | **Done** | All 5 flows + orchestrator + setup() pool | | Phase 2 – Performance Optimization | **Done** | `derive-password-weak` using pbkdf2+sha256 (100 iter) — ~700x faster than argon2id | | 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 4 – Concurrent Editing | **Done** | `workspace-edit-concurrent.js` with same-file and multi-file modes | +| Phase 4 – File Size Matrix | **Not started** | `update-file` latency vs shape count: 10, 100, 500, 1000 shapes | | Phase 5 – CI & Reporting | **Not started** | Grafana dashboards, regression guard | ### Immediate Next Steps 1. ~~Phase 2 – Fast password for demo users~~ ✅ Done 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). +3. ~~Phase 4: Concurrent editing test (2–3 VUs per file, measure conflict rate).~~ ✅ Done — `workspace-edit-concurrent.js` with same-file and multi-file modes 4. Phase 5: Grafana dashboard panels (p95 latency by RPC, error rate, JVM, DB pool). 5. ~~Add `--scenario` flag to `run.sh`~~ ✅ Done 6. Write `viewer.js` — `get-view-only-bundle` + `get-comment-threads` (deferred per user request). @@ -420,28 +424,75 @@ Run `workspace-edit.js` against each tier separately and plot: - `get-file` latency vs file size. - Backend CPU and DB time vs file size. -#### 4.2. Concurrent Editing Strategy +#### 4.2. Concurrent Editing — Two Modes -**Problem:** `update-file` uses optimistic concurrency control (`revn`). If two users submit the same `revn`, the second gets a conflict. +**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** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file. -**Backend behavior:** -- `update-file` acquires an advisory lock (`db/xact-lock! conn id`) on the file ID. -- It checks `revn` and `vern` conflicts. -- It processes changes, validates, updates the file, and sends notifications via `msgbus`. -- The transaction duration is what we want to measure. +**Mode 1: Same-file** — N VUs edit different pages in 1 file +- Measures lock contention on a single popular file +- Bottleneck: advisory lock serialization -**Test design for concurrent editing:** -- **Shared file pool:** Pre-create 50 files of `MEDIUM` size. -- **VU grouping:** Use k6 `scenarios` with `per-vu-iterations` or `shared-iterations`. Assign groups of 3 VUs to the same file ID. -- **Conflict measurement:** Do *not* synchronize `revn` between VUs. Let them race. - - Track `http_req_failed{code:revn-conflict}`. - - Track retry latency (if a VU retries after fetching the latest `revn`). - - This gives us the **natural conflict rate** under load, which is a realistic product metric. -- **If the natural conflict rate is too high (>20%):** - - Add a small `sleep()` jitter (0–500 ms) between `get-file` and `update-file` to spread out the requests. - - Or, use a tiny shared counter (e.g., a small HTTP endpoint or Redis) that VUs read to get the "next" `revn`. This is less realistic but gives a cleaner latency measurement. +**Mode 2: Multi-file** — G groups × M VUs per file, each group edits its own file +- Measures whole system responsiveness under parallel edit sessions +- Bottleneck: DB connection pool, CPU, memory +- More realistic: real usage has many files being edited concurrently -**Action:** Create `workspace-edit-concurrent.js` with the grouping logic and conflict-rate thresholds. +**Script:** `workspace-edit-concurrent.js` + +**Configuration via env vars:** +- `PENPOT_EDIT_MODE=same-file | multi-file` (default: `same-file`) +- `PENPOT_FILE_COUNT=1` — number of files (for multi-file mode) +- `PENPOT_VUS_PER_FILE=3` — VUs per file (for multi-file mode) + +**Setup logic:** +- `same-file`: create 1 file, add N pages (N = total VUs) +- `multi-file`: create G files, each with M pages (G = FILE_COUNT, M = VUS_PER_FILE) + +**VU loop:** +1. Login with assigned user +2. Get file → pick assigned page +3. Loop (10 iterations): + - `get-file` → get latest `revn` + - `sleep(0.3)` (think time) + - `update-file` with change to assigned page (add rectangle) + - Track: success on first try (should always succeed) + - `sleep(1)` (edit pacing) + +**Scenario ladder — same-file mode:** + +| Run | VUs | Iterations | What we measure | +|-----|-----|-----------|-----------------| +| 1 | 3 | 10 | Baseline lock contention | +| 2 | 5 | 10 | Moderate contention | +| 3 | 10 | 10 | Higher contention | +| 4 | 20 | 10 | Stress level | + +**Scenario ladder — multi-file mode:** + +| Run | Files | VUs/file | Total VUs | What we measure | +|-----|-------|----------|-----------|-----------------| +| 1 | 3 | 2 | 6 | Light load | +| 2 | 5 | 3 | 15 | Moderate | +| 3 | 10 | 3 | 30 | Heavy | +| 4 | 10 | 5 | 50 | Stress | + +**Metrics to track:** +- `http_req_duration{rpc_command:update-file}` — p50, p95, p99 at each VU level +- `http_req_duration{rpc_command:get-file}` — should be unaffected +- `http_req_failed` — should be 0% +- Latency growth curve: how much does p95 increase per additional VU? + +**Expected results:** +- `get-file` latency: constant (no lock, read-only) +- `update-file` p95: grows with VU count in same-file mode (lock queuing) +- `update-file` p95: stable in multi-file mode (independent locks) +- Failure rate: 0% (no revn conflicts in this scenario) + +**Files to create:** +- `performance/scripts/workspace-edit-concurrent.js` + +**Files to modify:** +- `performance/run.sh` — add `concurrent-edit` command --- @@ -527,5 +578,5 @@ Run `workspace-edit.js` against each tier separately and plot: --- **Plan Author:** Senior Software Architect -**Status:** Phase 1–3 complete. Phase 2 performance optimization done (pbkdf2+sha256, ~700x faster). Scripts ready for 1000 VU scale. Phase 4–5 remain. +**Status:** Phase 1–4 complete. Concurrent editing implemented (same-file + multi-file modes). File size matrix and Phase 5 remain.