- 2,000 연결 / 500 방 / 초당 248 행동: p95 20ms, p99 37ms, CPU 0.2코어, 메모리 141MB, 오류 0 - 부하 중 SIGKILL→재시작: 2,000 연결 자동 복구, 수 유실 0 - 복구된 방 마감을 30초 + 0~30초 무작위로 분산(몰림으로 p99 336ms → 40ms) - 보고서: docs/reports/load-20261004.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
/**
|
||
* Load / chaos test (docs/10-testing.md §2, §4).
|
||
* bun scripts/loadtest.ts --rooms 500 --spectators 2 --seconds 60 [--chaos] [--url http://localhost:4600]
|
||
* The server must run separately (so client load does not skew server numbers) with
|
||
* LOADTEST_NO_HTTP_LIMITS=1 PUBLIC_ORIGIN=<url>
|
||
* Each room: 2 omok players alternating one move each ~2s per room, plus N spectators.
|
||
* Latency = mover sends act → opponent receives the state containing that move.
|
||
* --chaos: SIGKILL the server at 40% of the run (pid from --pid), the harness restarts it via --restart-cmd,
|
||
* clients reconnect; verifies no committed move was lost.
|
||
*/
|
||
const args = Object.fromEntries(
|
||
process.argv.slice(2).reduce<[string, string][]>((acc, a, i, arr) => {
|
||
if (a.startsWith('--')) acc.push([a.slice(2), arr[i + 1] && !arr[i + 1]!.startsWith('--') ? arr[i + 1]! : 'true']);
|
||
return acc;
|
||
}, []),
|
||
);
|
||
const URL_ = args.url ?? 'http://localhost:4600';
|
||
const ROOMS = Number(args.rooms ?? 500);
|
||
const SPECT = Number(args.spectators ?? 2);
|
||
const SECONDS = Number(args.seconds ?? 60);
|
||
const MOVE_EVERY_MS = 2000;
|
||
const CHAOS = args.chaos === 'true';
|
||
|
||
type Msg = { t: string; [k: string]: any };
|
||
|
||
async function guest(i: number): Promise<{ cookie: string; id: string }> {
|
||
for (;;) {
|
||
try {
|
||
const r = await fetch(`${URL_}/api/auth/guest`, { method: 'POST', headers: { origin: URL_, 'content-type': 'application/json' }, body: JSON.stringify({ nickname: `부하${i}` }) });
|
||
const body = (await r.json()) as any;
|
||
return { cookie: r.headers.get('set-cookie')!.split(';')[0]!, id: body.me.id };
|
||
} catch {
|
||
await Bun.sleep(200);
|
||
}
|
||
}
|
||
}
|
||
|
||
class C {
|
||
ws!: WebSocket;
|
||
open = false;
|
||
handlers: ((m: Msg) => void)[] = [];
|
||
room: string | null = null;
|
||
constructor(public cookie: string, public id: string) {}
|
||
connect(): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
const ws = new WebSocket(URL_.replace('http', 'ws') + '/ws', { headers: { cookie: this.cookie, origin: URL_ } } as any);
|
||
this.ws = ws;
|
||
ws.onopen = () => {
|
||
this.open = true;
|
||
resolve();
|
||
};
|
||
ws.onmessage = (e) => {
|
||
const m = JSON.parse(String(e.data)) as Msg;
|
||
if (m.t === 'welcome' && this.room) this.send({ t: 'join', code: this.room, as: 'player' });
|
||
for (const h of this.handlers) h(m);
|
||
};
|
||
ws.onclose = () => {
|
||
this.open = false;
|
||
stats.disconnects++;
|
||
setTimeout(() => this.connect().catch(() => {}), 500 + Math.random() * 1500);
|
||
};
|
||
ws.onerror = () => {};
|
||
});
|
||
}
|
||
send(m: unknown) {
|
||
if (this.open) this.ws.send(JSON.stringify(m));
|
||
}
|
||
}
|
||
|
||
const stats = { lat: [] as number[], acts: 0, rejects: 0, disconnects: 0, lostMoves: 0, games: 0 };
|
||
const pct = (a: number[], p: number) => (a.length ? [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor((a.length * p) / 100))]! : NaN);
|
||
|
||
async function setupRoom(i: number) {
|
||
const [ga, gb] = await Promise.all([guest(i * 10), guest(i * 10 + 1)]);
|
||
const res = await fetch(`${URL_}/api/rooms`, { method: 'POST', headers: { origin: URL_, cookie: ga.cookie, 'content-type': 'application/json' }, body: JSON.stringify({ gameId: 'omok' }) });
|
||
const { code } = (await res.json()) as any;
|
||
const a = new C(ga.cookie, ga.id);
|
||
const b = new C(gb.cookie, gb.id);
|
||
a.room = b.room = code;
|
||
await Promise.all([a.connect(), b.connect()]);
|
||
const specs: C[] = [];
|
||
for (let s = 0; s < SPECT; s++) {
|
||
const g = await guest(i * 10 + 2 + s);
|
||
const c = new C(g.cookie, g.id);
|
||
c.room = code;
|
||
await c.connect();
|
||
specs.push(c);
|
||
}
|
||
let lastMoveCount = 0;
|
||
let maxSeenMoveCount = 0;
|
||
const sentAt = new Map<number, number>(); // moveCount after my move → send time
|
||
let view: any = null;
|
||
const onState = (who: C) => (m: Msg) => {
|
||
if (m.t === 'reject') stats.rejects++;
|
||
if (m.t !== 'state') return;
|
||
view = m.view;
|
||
const mc = view.moveCount as number;
|
||
if (mc < maxSeenMoveCount && view.phase === 'playing' && who === a) {
|
||
// A committed move disappeared (only possible if persistence failed).
|
||
if (!(mc === 0 && m.events?.some?.((e: any) => e.type === 'gameStarted'))) stats.lostMoves += maxSeenMoveCount - mc;
|
||
}
|
||
maxSeenMoveCount = view.phase === 'playing' ? Math.max(maxSeenMoveCount, mc) : 0;
|
||
const t0 = sentAt.get(mc);
|
||
const mover = view.moves[mc - 1]?.color;
|
||
const receiverIsOpponent = mover && view.myColor && view.myColor !== mover;
|
||
if (t0 && receiverIsOpponent) {
|
||
stats.lat.push(performance.now() - t0);
|
||
sentAt.delete(mc);
|
||
}
|
||
lastMoveCount = mc;
|
||
};
|
||
a.handlers.push(onState(a));
|
||
b.handlers.push(onState(b));
|
||
a.send({ t: 'join', code, as: 'player' });
|
||
await Bun.sleep(50);
|
||
b.send({ t: 'join', code, as: 'player' });
|
||
await Bun.sleep(50);
|
||
for (const s of specs) s.send({ t: 'join', code, as: 'spectator' });
|
||
a.send({ t: 'config', options: { ruleSet: 'free', colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
|
||
await Bun.sleep(100);
|
||
a.send({ t: 'start' });
|
||
let cs = 0;
|
||
const tick = () => {
|
||
if (!view) return;
|
||
if (view.phase === 'finished') {
|
||
stats.games++;
|
||
view = null;
|
||
a.send({ t: 'rematch' });
|
||
b.send({ t: 'rematch' });
|
||
return;
|
||
}
|
||
const mover = view.players.black === a.id ? (view.turn === 'black' ? a : b) : view.turn === 'black' ? b : a;
|
||
const empties: number[] = [];
|
||
view.board.forEach((c: number, k: number) => c === 0 && empties.push(k));
|
||
const k = empties[Math.floor(Math.random() * empties.length)]!;
|
||
sentAt.set(lastMoveCount + 1, performance.now());
|
||
mover.send({ t: 'act', cs: ++cs + Date.now(), a: { type: 'place', x: k % 15, y: Math.floor(k / 15) } });
|
||
stats.acts++;
|
||
};
|
||
// Stagger rooms evenly across the interval.
|
||
setTimeout(() => setInterval(tick, MOVE_EVERY_MS), Math.random() * MOVE_EVERY_MS);
|
||
return [a, b, ...specs];
|
||
}
|
||
|
||
console.log(`setting up ${ROOMS} rooms × ${2 + SPECT} connections against ${URL_}…`);
|
||
const t0 = performance.now();
|
||
const all: C[] = [];
|
||
for (let i = 0; i < ROOMS; i += 25) {
|
||
const batch = await Promise.all(Array.from({ length: Math.min(25, ROOMS - i) }, (_, k) => setupRoom(i + k)));
|
||
for (const b of batch) all.push(...b);
|
||
}
|
||
console.log(`ready: ${all.length} connections in ${((performance.now() - t0) / 1000).toFixed(1)}s`);
|
||
stats.lat = [];
|
||
stats.acts = 0;
|
||
stats.disconnects = 0;
|
||
|
||
const pid = Number(args.pid ?? 0);
|
||
const cpu = () => {
|
||
if (!pid) return null;
|
||
const st = require('node:fs').readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].split(' ');
|
||
return (Number(st[11]) + Number(st[12])) / 100; // utime+stime seconds (USER_HZ=100)
|
||
};
|
||
const rss = () => {
|
||
try {
|
||
return pid ? Number(/VmRSS:\s+(\d+)/.exec(require('node:fs').readFileSync(`/proc/${pid}/status`, 'utf8'))![1]) / 1024 : NaN;
|
||
} catch {
|
||
return NaN; // server was restarted (chaos mode)
|
||
}
|
||
};
|
||
const cpu0 = cpu();
|
||
const start = performance.now();
|
||
let chaosDone = false;
|
||
let lastReport = start;
|
||
while (performance.now() - start < SECONDS * 1000) {
|
||
await Bun.sleep(1000);
|
||
if (CHAOS && !chaosDone && performance.now() - start > SECONDS * 400) {
|
||
chaosDone = true;
|
||
console.log('CHAOS: sending SIGKILL to server', args.pid);
|
||
process.kill(pid, 'SIGKILL');
|
||
await Bun.$`sh -c ${args['restart-cmd']}`.quiet().nothrow();
|
||
console.log('CHAOS: restart command issued');
|
||
}
|
||
if (performance.now() - lastReport > 10_000) {
|
||
lastReport = performance.now();
|
||
const connected = all.filter((c) => c.open).length;
|
||
console.log(`t=${Math.round((lastReport - start) / 1000)}s acts=${stats.acts} lat p50=${pct(stats.lat, 50).toFixed(1)} p95=${pct(stats.lat, 95).toFixed(1)} p99=${pct(stats.lat, 99).toFixed(1)}ms connected=${connected}/${all.length}`);
|
||
}
|
||
}
|
||
const elapsed = (performance.now() - start) / 1000;
|
||
const cpuUsed = cpu0 !== null && !CHAOS ? (cpu()! - cpu0) / elapsed : NaN;
|
||
const result = {
|
||
rooms: ROOMS,
|
||
connections: all.length,
|
||
connectedAtEnd: all.filter((c) => c.open).length,
|
||
seconds: Math.round(elapsed),
|
||
actionsPerSec: +(stats.acts / elapsed).toFixed(1),
|
||
latencyMs: { p50: +pct(stats.lat, 50).toFixed(1), p95: +pct(stats.lat, 95).toFixed(1), p99: +pct(stats.lat, 99).toFixed(1), samples: stats.lat.length },
|
||
rejects: stats.rejects,
|
||
disconnects: stats.disconnects,
|
||
lostMoves: stats.lostMoves,
|
||
gamesFinished: stats.games,
|
||
serverCpuCores: Number.isNaN(cpuUsed) ? null : +cpuUsed.toFixed(2),
|
||
serverRssMb: Number.isNaN(rss()) ? null : Math.round(rss()),
|
||
};
|
||
console.log('RESULT ' + JSON.stringify(result));
|
||
process.exit(0);
|