Skip to content

Commit 678b742

Browse files
mizchiclaude
andcommitted
chore: add benchmark setup and scaling docs
Add k6 bench scripts, justfile tasks for running benchmarks, ignore bench results directory, and add scaling documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d0de69 commit 678b742

13 files changed

Lines changed: 843 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules
22
.wrangler
3+
bench/results/

bench/config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Shared configuration for k6 benchmark tests
2+
3+
export const BASE_URL = __ENV.BASE_URL || "http://localhost:8788";
4+
export const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
5+
export const RUN_ID = __ENV.RUN_ID || `run-${Date.now()}`;
6+
7+
// WebSocket URL derived from BASE_URL
8+
const wsScheme = BASE_URL.startsWith("https") ? "wss" : "ws";
9+
const wsHost = BASE_URL.replace(/^https?:\/\//, "");
10+
export const WS_URL = `${wsScheme}://${wsHost}`;
11+
12+
export function authHeaders() {
13+
const headers = { "Content-Type": "application/json" };
14+
if (AUTH_TOKEN) {
15+
headers["Authorization"] = `Bearer ${AUTH_TOKEN}`;
16+
}
17+
return headers;
18+
}

bench/helpers.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Trend, Counter } from "k6/metrics";
2+
import { RUN_ID } from "./config.js";
3+
4+
// Generate a unique room name per VU and scenario
5+
export function roomName(scenario, vuId) {
6+
return `bench-${RUN_ID}-${scenario}-vu${vuId}`;
7+
}
8+
9+
// Generate a unique sender name per VU and scenario
10+
export function senderName(scenario, vuId) {
11+
return `bench-sender-${scenario}-vu${vuId}`;
12+
}
13+
14+
// Simple UUID v4 generator (good enough for bench IDs)
15+
export function uuid() {
16+
const hex = "0123456789abcdef";
17+
let id = "";
18+
for (let i = 0; i < 36; i++) {
19+
if (i === 8 || i === 13 || i === 18 || i === 23) {
20+
id += "-";
21+
} else if (i === 14) {
22+
id += "4";
23+
} else if (i === 19) {
24+
id += hex[(Math.random() * 4) | 8];
25+
} else {
26+
id += hex[(Math.random() * 16) | 0];
27+
}
28+
}
29+
return id;
30+
}
31+
32+
// Custom metric factories
33+
export function createTrend(name) {
34+
return new Trend(name, true);
35+
}
36+
37+
export function createCounter(name) {
38+
return new Counter(name);
39+
}

bench/run-all.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import healthFn from "./scenarios/health.js";
2+
import publishPollFn from "./scenarios/publish-poll.js";
3+
import inboxFn from "./scenarios/inbox.js";
4+
import presenceFn from "./scenarios/presence.js";
5+
import websocketFn from "./scenarios/websocket.js";
6+
import gitServeFn from "./scenarios/git-serve.js";
7+
8+
// Combined runner: each scenario starts at a staggered offset.
9+
// Individual scenario files also work standalone with their own `options`.
10+
11+
export const options = {
12+
scenarios: {
13+
health: {
14+
executor: "ramping-vus",
15+
startVUs: 1,
16+
stages: [
17+
{ duration: "5s", target: 10 },
18+
{ duration: "5s", target: 50 },
19+
{ duration: "5s", target: 0 },
20+
],
21+
startTime: "0s",
22+
exec: "health",
23+
},
24+
publish_poll: {
25+
executor: "ramping-vus",
26+
startVUs: 1,
27+
stages: [
28+
{ duration: "10s", target: 10 },
29+
{ duration: "10s", target: 50 },
30+
{ duration: "10s", target: 0 },
31+
],
32+
startTime: "10s",
33+
exec: "publishPoll",
34+
},
35+
inbox: {
36+
executor: "ramping-vus",
37+
startVUs: 1,
38+
stages: [
39+
{ duration: "10s", target: 10 },
40+
{ duration: "10s", target: 30 },
41+
{ duration: "5s", target: 0 },
42+
],
43+
startTime: "40s",
44+
exec: "inbox",
45+
},
46+
presence: {
47+
executor: "ramping-vus",
48+
startVUs: 1,
49+
stages: [
50+
{ duration: "10s", target: 10 },
51+
{ duration: "10s", target: 50 },
52+
{ duration: "5s", target: 0 },
53+
],
54+
startTime: "40s",
55+
exec: "presence",
56+
},
57+
websocket: {
58+
executor: "ramping-vus",
59+
startVUs: 1,
60+
stages: [
61+
{ duration: "10s", target: 10 },
62+
{ duration: "10s", target: 50 },
63+
{ duration: "5s", target: 0 },
64+
],
65+
startTime: "70s",
66+
exec: "websocket",
67+
},
68+
git_serve: {
69+
executor: "constant-vus",
70+
vus: 5,
71+
duration: "30s",
72+
startTime: "100s",
73+
exec: "gitServe",
74+
},
75+
},
76+
thresholds: {
77+
http_req_duration: ["p(95)<500", "p(99)<1000"],
78+
http_req_failed: ["rate<0.05"],
79+
},
80+
};
81+
82+
export function health() {
83+
healthFn();
84+
}
85+
86+
export function publishPoll() {
87+
publishPollFn();
88+
}
89+
90+
export function inbox() {
91+
inboxFn();
92+
}
93+
94+
export function presence() {
95+
presenceFn();
96+
}
97+
98+
export function websocket() {
99+
websocketFn();
100+
}
101+
102+
export function gitServe() {
103+
gitServeFn();
104+
}

bench/scenarios/git-serve.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import http from "k6/http";
2+
import { check, sleep } from "k6";
3+
import { Trend } from "k6/metrics";
4+
import { BASE_URL, authHeaders } from "../config.js";
5+
6+
const registerLatency = new Trend("git_register_latency", true);
7+
const infoLatency = new Trend("git_info_latency", true);
8+
const pollLatency = new Trend("git_poll_latency", true);
9+
10+
export const options = {
11+
scenarios: {
12+
git_serve: {
13+
executor: "constant-vus",
14+
vus: 5,
15+
duration: "60s",
16+
},
17+
},
18+
thresholds: {
19+
git_register_latency: ["p(95)<500"],
20+
git_info_latency: ["p(95)<300"],
21+
http_req_failed: ["rate<0.05"],
22+
},
23+
};
24+
25+
export default function () {
26+
const headers = authHeaders();
27+
28+
// Register a new session
29+
const registerRes = http.post(
30+
`${BASE_URL}/api/v1/serve/register`,
31+
null,
32+
{ headers },
33+
);
34+
35+
registerLatency.add(registerRes.timings.duration);
36+
37+
const registerOk = check(registerRes, {
38+
"register status 200": (r) => r.status === 200,
39+
"register has session": (r) => {
40+
const body = r.json();
41+
return body.ok === true && body.session_id;
42+
},
43+
});
44+
45+
if (!registerOk) {
46+
sleep(2);
47+
return;
48+
}
49+
50+
const { session_id, session_token } = registerRes.json();
51+
const sessionHeaders = Object.assign({}, headers, {
52+
"x-session-token": session_token,
53+
});
54+
55+
// Get session info
56+
const infoRes = http.get(
57+
`${BASE_URL}/api/v1/serve/info?session=${session_id}`,
58+
{ headers: sessionHeaders },
59+
);
60+
61+
infoLatency.add(infoRes.timings.duration);
62+
63+
check(infoRes, {
64+
"info status 200": (r) => r.status === 200,
65+
"info session active": (r) => {
66+
const body = r.json();
67+
return body.ok === true && body.active === true;
68+
},
69+
});
70+
71+
// Poll with short timeout (no pending requests expected)
72+
const pollRes = http.get(
73+
`${BASE_URL}/api/v1/serve/poll?session=${session_id}&timeout=1`,
74+
{ headers: sessionHeaders, timeout: "5s" },
75+
);
76+
77+
pollLatency.add(pollRes.timings.duration);
78+
79+
check(pollRes, {
80+
"poll status 200": (r) => r.status === 200,
81+
"poll ok": (r) => r.json().ok === true,
82+
});
83+
84+
sleep(2);
85+
}

bench/scenarios/health.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import http from "k6/http";
2+
import { check, sleep } from "k6";
3+
import { BASE_URL, authHeaders } from "../config.js";
4+
5+
export const options = {
6+
scenarios: {
7+
health: {
8+
executor: "ramping-vus",
9+
startVUs: 1,
10+
stages: [
11+
{ duration: "10s", target: 10 },
12+
{ duration: "10s", target: 50 },
13+
{ duration: "10s", target: 100 },
14+
{ duration: "10s", target: 0 },
15+
],
16+
},
17+
},
18+
thresholds: {
19+
http_req_duration: ["p(95)<200", "p(99)<500"],
20+
http_req_failed: ["rate<0.01"],
21+
},
22+
};
23+
24+
export default function () {
25+
const headers = authHeaders();
26+
27+
const healthRes = http.get(`${BASE_URL}/health`, { headers });
28+
check(healthRes, {
29+
"health status 200": (r) => r.status === 200,
30+
"health body ok": (r) => {
31+
const body = r.json();
32+
return body.status === "ok";
33+
},
34+
});
35+
36+
const rootRes = http.get(`${BASE_URL}/`, { headers });
37+
check(rootRes, {
38+
"root status 200": (r) => r.status === 200,
39+
});
40+
41+
sleep(0.5);
42+
}

bench/scenarios/inbox.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import http from "k6/http";
2+
import { check, sleep } from "k6";
3+
import { Trend } from "k6/metrics";
4+
import { BASE_URL, authHeaders } from "../config.js";
5+
import { roomName, senderName, uuid } from "../helpers.js";
6+
7+
const inboxPendingLatency = new Trend("inbox_pending_latency", true);
8+
const inboxAckLatency = new Trend("inbox_ack_latency", true);
9+
10+
export const options = {
11+
scenarios: {
12+
inbox: {
13+
executor: "ramping-vus",
14+
startVUs: 1,
15+
stages: [
16+
{ duration: "10s", target: 10 },
17+
{ duration: "15s", target: 50 },
18+
{ duration: "10s", target: 0 },
19+
],
20+
},
21+
},
22+
thresholds: {
23+
inbox_pending_latency: ["p(95)<500"],
24+
inbox_ack_latency: ["p(95)<500"],
25+
http_req_failed: ["rate<0.05"],
26+
},
27+
};
28+
29+
export default function () {
30+
const vuId = __VU;
31+
const room = roomName("inbox", vuId);
32+
const sender = senderName("inbox", vuId);
33+
const consumer = `bench-consumer-vu${vuId}`;
34+
const headers = authHeaders();
35+
36+
// Publish a message first
37+
const msgId = uuid();
38+
const publishRes = http.post(
39+
`${BASE_URL}/api/v1/publish?room=${room}&sender=${sender}&id=${msgId}`,
40+
JSON.stringify({ kind: "bench.inbox", iteration: __ITER }),
41+
{ headers },
42+
);
43+
44+
check(publishRes, {
45+
"inbox publish 200": (r) => r.status === 200,
46+
});
47+
48+
// Get pending messages
49+
const pendingRes = http.get(
50+
`${BASE_URL}/api/v1/inbox/pending?room=${room}&consumer=${consumer}&limit=10`,
51+
{ headers },
52+
);
53+
54+
inboxPendingLatency.add(pendingRes.timings.duration);
55+
56+
const pendingOk = check(pendingRes, {
57+
"pending status 200": (r) => r.status === 200,
58+
"pending has envelopes": (r) => {
59+
const body = r.json();
60+
return body.ok === true && Array.isArray(body.envelopes);
61+
},
62+
});
63+
64+
// Ack the messages
65+
if (pendingOk) {
66+
const pending = pendingRes.json();
67+
if (pending.envelopes && pending.envelopes.length > 0) {
68+
const ids = pending.envelopes.map((e) => e.id);
69+
const ackRes = http.post(
70+
`${BASE_URL}/api/v1/inbox/ack?room=${room}&consumer=${consumer}`,
71+
JSON.stringify({ ids }),
72+
{ headers },
73+
);
74+
75+
inboxAckLatency.add(ackRes.timings.duration);
76+
77+
check(ackRes, {
78+
"ack status 200": (r) => r.status === 200,
79+
"ack ok": (r) => r.json().ok === true,
80+
});
81+
}
82+
}
83+
84+
// Verify pending is now empty (or reduced)
85+
const verifyRes = http.get(
86+
`${BASE_URL}/api/v1/inbox/pending?room=${room}&consumer=${consumer}&limit=10`,
87+
{ headers },
88+
);
89+
90+
check(verifyRes, {
91+
"verify pending 200": (r) => r.status === 200,
92+
});
93+
94+
sleep(2);
95+
}

0 commit comments

Comments
 (0)