mirror of
https://github.com/penpot/penpot.git
synced 2026-06-17 04:42:03 +00:00
🚧 Add concurrent edition flow
This commit is contained in:
parent
603b81d2d1
commit
0a3de75cc0
1
.gitignore
vendored
1
.gitignore
vendored
@ -100,3 +100,4 @@
|
||||
/opencode.json
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
/performance/results/
|
||||
@ -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 ;;
|
||||
|
||||
321
performance/scripts/workspace-edit-concurrent.js
Normal file
321
performance/scripts/workspace-edit-concurrent.js
Normal 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}).`);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user