🚧 Add concurrent edition flow

This commit is contained in:
Andrey Antukh 2026-06-15 11:01:18 +02:00
parent 603b81d2d1
commit 0a3de75cc0
4 changed files with 477 additions and 30 deletions

1
.gitignore vendored
View File

@ -100,3 +100,4 @@
/opencode.json
/.codex/
/tools/__pycache__
/performance/results/

View File

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

View File

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

View File

@ -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 (23 VUs per file, measure conflict rate).
3. ~~Phase 4: Concurrent editing test (23 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 (0500 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 13 complete. Phase 2 performance optimization done (pbkdf2+sha256, ~700x faster). Scripts ready for 1000 VU scale. Phase 45 remain.
**Status:** Phase 14 complete. Concurrent editing implemented (same-file + multi-file modes). File size matrix and Phase 5 remain.