test(load): 1분 페이지 동시 부하 계측을 추가한다

국가 권한 사용자 10명과 800 NPC fixture를 만들고 Chromium 화면 순환, SSE, wall-clock 턴을 같은 시각에 측정한다.
This commit is contained in:
2026-08-21 23:17:24 +00:00
parent 7066d13c5b
commit e67a40e51d
8 changed files with 601 additions and 15 deletions
+53 -4
View File
@@ -105,6 +105,7 @@ const includeSubMillisecondGameTick = (turnTime: Date): Date =>
export const measureTurnFlush = async (options: {
config: LoadConfig;
confirmation: string;
paced?: boolean;
env?: NodeJS.ProcessEnv;
}) => {
const env = options.env ?? process.env;
@@ -153,9 +154,8 @@ export const measureTurnFlush = async (options: {
const activityPromise = sampleActivity();
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
const cpuStart = process.cpuUsage();
const wallStartNs = process.hrtime.bigint();
let cpuStart: NodeJS.CpuUsage | null = null;
let wallStartNs: bigint | null = null;
let maxRssBytes = process.memoryUsage().rss;
const generalTransactionMs: number[] = [];
const monthlyTransactionMs: number[] = [];
@@ -167,6 +167,10 @@ export const measureTurnFlush = async (options: {
let endYearMonth: string | null = null;
let initialGeneralCount: number | null = null;
let finalGeneralCount: number | null = null;
let scheduledStartAtEpochMs: number | null = null;
let actualStartAtEpochMs: number | null = null;
const scheduleLagMs: number[] = [];
let backdatedGeneralTurns = 0;
let runError: unknown;
try {
@@ -189,6 +193,21 @@ export const measureTurnFlush = async (options: {
const boundary = getNextTickTime(initialState.lastTurnTime, tickMinutes);
let checkpoint: TurnCheckpoint | undefined = await runtime.stateStore.loadCheckpoint();
if (env.LOAD_TEST_START_AT_EPOCH_MS) {
scheduledStartAtEpochMs = Number(env.LOAD_TEST_START_AT_EPOCH_MS);
if (!Number.isSafeInteger(scheduledStartAtEpochMs)) {
throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be an integer epoch in milliseconds');
}
const waitMs = scheduledStartAtEpochMs - Date.now();
if (waitMs > 120_000) throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be within the next two minutes');
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
}
actualStartAtEpochMs = Date.now();
const pacingStarted = performance.now();
histogram.enable();
cpuStart = process.cpuUsage();
wallStartNs = process.hrtime.bigint();
const execute = async (target: Date, maxGenerals: number): Promise<TurnRunResult> => {
const started = performance.now();
const result = await runtime!.stateManager.transaction(async () => {
@@ -219,6 +238,16 @@ export const measureTurnFlush = async (options: {
while (true) {
const nextGeneral = await runtime.stateStore.loadNextGeneralTurnTime();
if (!nextGeneral || nextGeneral.getTime() >= boundary.getTime()) break;
if (options.paced) {
const targetElapsedMs = nextGeneral.getTime() - initialState.lastTurnTime.getTime();
if (targetElapsedMs < 0) {
backdatedGeneralTurns += 1;
} else {
const remainingMs = targetElapsedMs - (performance.now() - pacingStarted);
if (remainingMs > 0) await new Promise((resolve) => setTimeout(resolve, remainingMs));
scheduleLagMs.push(Math.max(0, performance.now() - pacingStarted - targetElapsedMs));
}
}
const result = await execute(includeSubMillisecondGameTick(nextGeneral), 1);
if (result.processedGenerals !== 1 || result.processedTurns !== 0) {
throw new Error(
@@ -227,6 +256,11 @@ export const measureTurnFlush = async (options: {
}
}
if (options.paced) {
const remainingMs =
options.config.capacity.turnIntervalMs - (performance.now() - pacingStarted);
if (remainingMs > 0) await new Promise((resolve) => setTimeout(resolve, remainingMs));
}
const monthly = await execute(boundary, 200);
if (monthly.processedTurns !== 1) {
throw new Error('turn-flush measurement did not cross exactly one monthly boundary');
@@ -275,6 +309,9 @@ export const measureTurnFlush = async (options: {
}
await observer.disconnect();
histogram.disable();
if (cpuStart === null || wallStartNs === null || actualStartAtEpochMs === null) {
throw new Error('turn-flush measurement did not start its measured interval');
}
const elapsedMs = Number(process.hrtime.bigint() - wallStartNs) / 1_000_000;
const cpu = process.cpuUsage(cpuStart);
const cpuMs = (cpu.user + cpu.system) / 1_000;
@@ -287,7 +324,9 @@ export const measureTurnFlush = async (options: {
fixtureSha256: fixture.fixtureSha256,
capacity: options.config.capacity,
},
mode: 'chronological-one-general-per-transaction-plus-month-boundary',
mode: options.paced
? 'wall-clock-paced-one-general-per-transaction-plus-month-boundary'
: 'chronological-one-general-per-transaction-plus-month-boundary',
startYearMonth,
endYearMonth,
elapsedMs: round(elapsedMs),
@@ -306,6 +345,16 @@ export const measureTurnFlush = async (options: {
monthlyTransaction: summarizeDistribution(monthlyTransactionMs),
redisPublication: summarizeDistribution(publicationMs),
},
scheduling: {
paced: options.paced === true,
configuredTurnIntervalMs: options.config.capacity.turnIntervalMs,
scheduledStartAtEpochMs,
actualStartAtEpochMs,
startDelayMs:
scheduledStartAtEpochMs === null ? null : Math.max(0, actualStartAtEpochMs - scheduledStartAtEpochMs),
backdatedGeneralTurns,
generalScheduleLagMs: summarizeDistribution(scheduleLagMs),
},
postgres: {
statsScope: 'database-wide-including-observer-sampler',
statsDelta: subtractDatabaseStats(beforeStats, afterStats),