refactor(gh): CI用スクリプトをpackageとして整理 (#17727)

* refactor(gh): CI用スクリプトをpackageとして整理

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* remove old scripts

* migrate

* refactor 1

* refactor 2

* fix comment

* fix

* fix

* fix

* fix

* remove vite-node from changelog-checker

* fix lint

* fix

* refactor

* update deps

* fix

* spec: rename packages
This commit is contained in:
かっこかり
2026-07-20 20:09:22 +09:00
committed by GitHub
parent 7157f37011
commit ab369784fb
101 changed files with 8111 additions and 6707 deletions
@@ -0,0 +1,197 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { copyFile, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { execa } from 'execa';
import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { median } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { resetState } from './db';
import { measureBackendMemory } from './measure';
import { memoryPhases, type MemoryReport } from './types';
const heapSnapshotLabels = ['base', 'head'] as const;
type HeapSnapshotLabel = typeof heapSnapshotLabels[number];
export type CompareOptions = {
baseDir: string;
headDir: string;
baseOutput: string;
headOutput: string;
};
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す
const HEAP_SNAPSHOT_OUTPUT_DIR = resolve(readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd());
const HEAP_SNAPSHOT_WORK_DIRS = {
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'),
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'),
};
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'),
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshot.heapsnapshot'),
};
export function summarizeSamples(samples: MemoryReport['samples']) {
const summary = {} as MemoryReport['summary'];
for (const phase of memoryPhases) {
summary[phase] = {
memoryUsage: {},
};
const metricKeys = new Set<string>();
for (const sample of samples) {
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
metricKeys.add(key);
}
}
for (const key of metricKeys) {
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
summary[phase].memoryUsage[key] = median(values);
}
const heapSnapshot = summarizeHeapSnapshotDataSamples(
samples,
sample => sample.phases[phase].heapSnapshot,
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
);
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
}
return summary;
}
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState();
process.stderr.write(`[${label}] Running migrations\n`);
// 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる)
await execa('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
stdout: ['pipe', process.stderr],
stderr: ['pipe', process.stderr],
});
process.stderr.write(`[${label}] Measuring memory\n`);
return await measureBackendMemory(resolve(repoDir, 'packages/backend'), {
// warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない
...(round <= 0 ? { heapSnapshot: false } : {}),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
});
}
function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
}
/**
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
*/
function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const medianTotal = summary.afterGc.heapSnapshot?.categories.total;
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = sample.phases.afterGc.heapSnapshot?.categories.total;
if (total == null || !Number.isFinite(total)) continue;
const distance = Math.abs(total - medianTotal);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
selected = {
round: sample.round,
distance,
};
}
}
return selected?.round ?? null;
}
async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
if (round == null) return;
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`);
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
}
/**
* base / head を交互に計測してJSONレポートを書き出す。
* 交互にするのは、実行順やマシンの状態による偏りを両者に均等に載せるため。
*/
export async function compareBackendMemory(options: CompareOptions) {
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
for (const label of heapSnapshotLabels) {
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
}
const reports = {
base: {
dir: options.baseDir,
samples: [] as MemoryReport['samples'],
},
head: {
dir: options.headDir,
samples: [] as MemoryReport['samples'],
},
};
for (let round = 1; round <= warmupRounds; round++) {
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
for (const label of heapSnapshotLabels) {
await genSample(label, reports[label].dir, -round);
}
}
for (let round = 1; round <= rounds; round++) {
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const label of order) {
const sample = await genSample(label, reports[label].dir, round, { heapSnapshotSavePath: heapSnapshotPath(label, round) });
reports[label].samples.push({
...sample,
round,
});
}
}
const summaries = {
base: summarizeSamples(reports.base.samples),
head: summarizeSamples(reports.head.samples),
};
for (const label of heapSnapshotLabels) {
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
}
for (const label of heapSnapshotLabels) {
const report: MemoryReport = {
timestamp: new Date().toISOString(),
sampleCount: reports[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
summary: summaries[label],
samples: reports[label].samples,
};
await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Redis from 'ioredis';
import pg from 'pg';
/**
* 計測ラウンド間で状態を持ち越さないよう、テスト用DBを作り直しRedisを空にする。
* 接続先はCIのテスト用サービス (.github/misskey/test.yml) と揃えてある。
*/
export async function resetState() {
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resolve } from 'node:path';
import { compareBackendMemory } from './compare';
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
console.error('Usage: inspect <baseDir> <headDir> <baseOutput> <headOutput>');
process.exit(1);
}
try {
await compareBackendMemory({
baseDir: resolve(baseDirArg),
headDir: resolve(headDirArg),
baseOutput: resolve(baseOutputArg),
headOutput: resolve(headOutputArg),
});
} catch (err) {
console.error(err);
process.exit(1);
}
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resolve } from 'node:path';
import { readOptionalEnv } from 'diagnostics-shared/env';
import { measureBackendMemory } from './measure';
// ローカルデバッグ用: バックエンド1回分の計測結果をJSONで出力する
const [backendDirArg] = process.argv.slice(2);
if (backendDirArg == null) {
console.error('Usage: measure <backendDir>');
process.exit(1);
}
try {
const sample = await measureBackendMemory(resolve(backendDirArg), {
heapSnapshotSavePath: readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH'),
});
console.log(JSON.stringify(sample, null, 2));
} catch (err) {
console.error(JSON.stringify({
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
}));
process.exit(1);
}
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { readBooleanEnv, readIntegerEnv } from 'diagnostics-shared/env';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
import { getMemoryUsage, getSmapsRollupMemoryUsage } from './proc';
import {
forkBackendServer,
getRuntimeMemoryUsage,
requestHeapSnapshot,
shutdownBackendServer,
triggerGc,
waitForServerReady,
} from './server';
import { measureMemoryUntilStable } from './stability';
import type { MemorySample } from '../types';
export type MeasureBackendMemoryOptions = {
/** heap snapshotを取得するか (既定: MK_MEMORY_HEAP_SNAPSHOT) */
heapSnapshot?: boolean;
/** 取得したheap snapshotの保存先。未指定なら解析後に破棄する */
heapSnapshotSavePath?: string | null;
heapSnapshotBreakdownTopN?: number;
heapSnapshotTimeoutMs?: number;
startupTimeoutMs?: number;
ipcTimeoutMs?: number;
};
function resolveOptions(options: MeasureBackendMemoryOptions) {
return {
heapSnapshot: options.heapSnapshot ?? readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
heapSnapshotBreakdownTopN: options.heapSnapshotBreakdownTopN ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1),
heapSnapshotTimeoutMs: options.heapSnapshotTimeoutMs ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1),
startupTimeoutMs: options.startupTimeoutMs ?? readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1),
ipcTimeoutMs: options.ipcTimeoutMs ?? readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1),
};
}
/**
* バックエンドを1回起動し、GC後のメモリ使用量を計測して1サンプル分の結果を返す。
*/
export async function measureBackendMemory(backendDir: string, options: MeasureBackendMemoryOptions = {}): Promise<MemorySample> {
const settings = resolveOptions(options);
const serverProcess = forkBackendServer(backendDir);
// 起動完了メッセージを取りこぼさないよう、他のハンドラより先に待ち受ける
const serverReady = waitForServerReady(serverProcess, settings.startupTimeoutMs);
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
// 途中で失敗しても子プロセスを残さない。残すと次のラウンドがポート衝突で落ちる
try {
const startupStartTime = Date.now();
await serverReady;
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
await triggerGc(serverProcess, settings.ipcTimeoutMs);
const pid = serverProcess.pid!;
const stableSmapsRollup = await measureMemoryUntilStable(() => getSmapsRollupMemoryUsage(pid));
const afterGc = {
memoryUsage: {
...await getMemoryUsage(pid),
...stableSmapsRollup.memoryUsage,
...await getRuntimeMemoryUsage(serverProcess, settings.ipcTimeoutMs),
},
stability: stableSmapsRollup.stability,
};
process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`);
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess, settings);
return {
timestamp: new Date().toISOString(),
phases: {
afterGc: {
memoryUsage: afterGc.memoryUsage,
memoryStability: afterGc.stability,
heapSnapshot: heapSnapshotAfterGc,
},
},
};
} finally {
await shutdownBackendServer(serverProcess);
}
}
async function getHeapSnapshotStatistics(
serverProcess: ReturnType<typeof forkBackendServer>,
settings: ReturnType<typeof resolveOptions>,
): Promise<HeapSnapshotData | null> {
if (!settings.heapSnapshot) return null;
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
const writtenPath = await requestHeapSnapshot(serverProcess, snapshotPath, settings.heapSnapshotTimeoutMs);
try {
if (settings.heapSnapshotSavePath != null && settings.heapSnapshotSavePath !== '') {
await fs.mkdir(dirname(settings.heapSnapshotSavePath), { recursive: true });
await fs.copyFile(writtenPath, settings.heapSnapshotSavePath);
}
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot, { breakdownTopN: settings.heapSnapshotBreakdownTopN });
} finally {
// 数百MBになることがあるため、解析後は必ず消す
await fs.unlink(writtenPath).catch(err => {
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
});
}
}
@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs/promises';
export const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
export const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
/**
* `/proc` 配下の `Key: 1234 kB` 形式のファイルから指定キーを取り出す。
* 1つでも欠けていると以降の集計が静かに壊れるため、見つからなければ例外にする。
*/
export function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string): Record<KS[number], number> {
const result = {} as Record<KS[number], number>;
for (const _key of keys) {
const key = _key as KS[number];
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else {
throw new Error(`Failed to parse ${key} from ${path}`);
}
}
return result;
}
export function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
export async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path);
}
export async function getSmapsRollupMemoryUsage(pid: number) {
const path = `/proc/${pid}/smaps_rollup`;
const smapsRollup = await fs.readFile(path, 'utf-8');
return parseMemoryFile(smapsRollup, smapsRollupKeys, path);
}
@@ -0,0 +1,199 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { fork, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { bytesToKiB } from './proc';
type GcMessage = 'gc ok' | 'gc unavailable';
type RuntimeMemoryUsageMessage = {
type: 'memory usage';
value: NodeJS.MemoryUsage;
};
type HeapSnapshotMessage = {
type: 'heap snapshot';
path?: string;
};
type HeapSnapshotErrorMessage = {
type: 'heap snapshot error';
message: string;
};
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object';
}
function isGcMessage(message: unknown): message is GcMessage {
return message === 'gc ok' || message === 'gc unavailable';
}
function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
}
function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
if (!isRecord(message)) return false;
if (message.type === 'heap snapshot') return true;
return message.type === 'heap snapshot error' && typeof message.message === 'string';
}
export function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) {
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
serverProcess.off('exit', onExit);
serverProcess.off('error', onError);
serverProcess.off('disconnect', onDisconnect);
};
const timer = globalThis.setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message: unknown) => {
if (!predicate(message)) return;
cleanup();
resolve(message);
};
// 子が死んだ場合、待ち続けてもメッセージは来ない。
// タイムアウトまで待って誤解を招くエラーを出すより、理由を添えて即座に失敗させる
const onExit = (code: number | null, signal: string | null) => {
cleanup();
reject(new Error(`Server exited (code=${code}, signal=${signal}) while waiting for ${description}`));
};
const onError = (err: Error) => {
cleanup();
reject(new Error(`Server errored while waiting for ${description}: ${err.message}`));
};
const onDisconnect = () => {
cleanup();
reject(new Error(`Server IPC channel closed while waiting for ${description}`));
};
serverProcess.on('message', onMessage);
serverProcess.once('exit', onExit);
serverProcess.once('error', onError);
serverProcess.once('disconnect', onDisconnect);
});
}
/**
*
* execArgv `--expose-gc` のみを渡す: 親は tsx
* TSローダーが載ってしまいメモリ量が歪む
*/
export function forkBackendServer(backendDir: string) {
return fork(join(backendDir, 'built/entry.js'), [], {
cwd: backendDir,
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_ONLY_SERVER: '1',
MK_NO_DAEMONS: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: ['--expose-gc'],
});
}
export function waitForServerReady(serverProcess: ChildProcess, timeout: number) {
return waitForMessage(
serverProcess,
(message): message is 'ok' => message === 'ok',
'server startup',
timeout,
);
}
export async function triggerGc(serverProcess: ChildProcess, timeout: number) {
// 送信前にlistenerを張らないと、応答を取りこぼす可能性がある
const ok = waitForMessage(serverProcess, isGcMessage, 'GC completion', timeout);
serverProcess.send('gc');
const message = await ok;
if (message === 'gc unavailable') {
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
}
}
export async function getRuntimeMemoryUsage(serverProcess: ChildProcess, timeout: number) {
const response = waitForMessage(
serverProcess,
isRuntimeMemoryUsageMessage,
'memory usage',
timeout,
);
serverProcess.send('memory usage');
const message = await response;
const memoryUsage = message.value;
// /proc 由来の値と単位を揃える
return {
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
External: bytesToKiB(memoryUsage.external),
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
};
}
/**
* heap snapshotの書き出しを依頼し
*/
export async function requestHeapSnapshot(serverProcess: ChildProcess, snapshotPath: string, timeout: number) {
const response = waitForMessage(
serverProcess,
isHeapSnapshotResponseMessage,
'heap snapshot',
timeout,
);
serverProcess.send({
type: 'heap snapshot',
path: snapshotPath,
});
const message = await response;
if (message.type === 'heap snapshot error') {
throw new Error(`Failed to write heap snapshot: ${message.message}`);
}
return typeof message.path === 'string' ? message.path : snapshotPath;
}
/**
* SIGTERMで終了を促しSIGKILLする
*/
export async function shutdownBackendServer(serverProcess: ChildProcess) {
// 既に終了しているなら 'exit' はもう発火しないので、待つと無駄に10秒止まる
if (serverProcess.exitCode != null || serverProcess.signalCode != null) return;
await new Promise<void>(resolve => {
let forceTimer: NodeJS.Timeout | undefined;
const termTimer = globalThis.setTimeout(() => {
serverProcess.kill('SIGKILL');
// SIGKILLは無視できないので通常はここで 'exit' が来る。
// D状態などで落ちない場合に計測全体を止めないよう、待ち時間には上限を設ける
forceTimer = globalThis.setTimeout(resolve, 5000);
}, 10000);
serverProcess.once('exit', () => {
globalThis.clearTimeout(termTimer);
if (forceTimer != null) globalThis.clearTimeout(forceTimer);
resolve();
});
serverProcess.kill('SIGTERM');
});
}
@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setTimeout } from 'node:timers/promises';
type MemoryStabilityTimer = {
now: () => number;
wait: (durationMs: number) => Promise<void>;
};
const intervalMs = 2000;
const maxWaitMs = 10000;
const windowSize = 3;
const slopeThresholdKiBPerSecond = 256;
const stabilityMetrics = ['Pss', 'Private_Dirty'] as const;
const defaultTimer: MemoryStabilityTimer = {
now: () => performance.now(),
wait: durationMs => setTimeout(durationMs),
};
function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elapsedMs: number; memoryUsage: T }[]) {
const result = {} as Record<typeof stabilityMetrics[number], number>;
for (const metric of stabilityMetrics) {
let maxAbsoluteSlope = 0;
for (let i = 1; i < readings.length; i++) {
const previous = readings[i - 1];
const current = readings[i];
const durationSeconds = (current.elapsedMs - previous.elapsedMs) / 1000;
maxAbsoluteSlope = Math.max(maxAbsoluteSlope, Math.abs(current.memoryUsage[metric] - previous.memoryUsage[metric]) / durationSeconds);
}
result[metric] = maxAbsoluteSlope;
}
return result;
}
/**
* 使
* `windowSize`
*/
export async function measureMemoryUntilStable<T extends Record<string, number>>(
readMemoryUsage: () => Promise<T>,
timer: MemoryStabilityTimer = defaultTimer,
) {
const startedAt = timer.now();
const readings: { elapsedMs: number; memoryUsage: T }[] = [];
let maxAbsoluteSlopesKiBPerSecond: Record<typeof stabilityMetrics[number], number> | null = null;
while (true) {
const memoryUsage = await readMemoryUsage();
const elapsedMs = timer.now() - startedAt;
readings.push({ elapsedMs, memoryUsage });
let converged = false;
if (readings.length >= windowSize) {
const latestSlopes = getMaxAbsoluteSlopes(readings.slice(-windowSize));
maxAbsoluteSlopesKiBPerSecond = latestSlopes;
converged = stabilityMetrics.every(metric => latestSlopes[metric] <= slopeThresholdKiBPerSecond);
}
if (converged || elapsedMs >= maxWaitMs) {
return {
memoryUsage,
stability: {
converged,
readingCount: readings.length,
elapsedMs,
maxAbsoluteSlopesKiBPerSecond,
},
};
}
await timer.wait(Math.min(intervalMs, maxWaitMs - elapsedMs));
}
}
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { readRequiredEnv } from 'diagnostics-shared/env';
import { renderMemoryReportMarkdown } from './report/markdown';
import type { MemoryReport } from './types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-md <baseReport.json> <headReport.json> <output.md>');
}
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as MemoryReport;
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as MemoryReport;
await writeFile(resolve(outputFileArg), renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE'),
headHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD'),
}));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format';
import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats';
import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot';
import type { MemoryPhase, MemoryReport } from '../types';
export type RenderMemoryReportOptions = {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
};
const memoryReportPhases = [
{
key: 'afterGc',
title: 'After GC',
},
] as const satisfies readonly { key: MemoryPhase; title: string }[];
const memoryMetrics = [
'HeapUsed',
'Pss',
'USS',
'External',
] as const;
type MemoryMetric = typeof memoryMetrics[number];
function formatMemoryMetricName(metric: MemoryMetric) {
return metric === 'Pss' ? 'PSS' : metric;
}
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) {
const memoryUsage = sample.phases[phase].memoryUsage;
// USSは直接取れないのでPrivateの合算で近似する
if (metric !== 'USS') return memoryUsage[metric];
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
}
function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
}
function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
return median(getSampleValues(report, phase, metric));
}
function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return sampleSpread(getSampleValues(report, phase, metric));
}
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
function formatDeltaMemory(deltaKiB: number) {
return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold
}
for (const metric of memoryMetrics) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
const summary = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
const percent = summary.median * 100 / baseValue;
const deltaMedian = `${formatDeltaMemory(summary.median)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)} <br> ± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)} <br> ± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
}
return lines.join('\n');
}
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const baseHeapSnapshotReport = {
summary: base.summary.afterGc.heapSnapshot!,
samples: base.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const headHeapSnapshotReport = {
summary: head.summary.afterGc.heapSnapshot!,
samples: head.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
if (table == null) return null;
const lines = [
'### V8 Heap Snapshot Statistics',
'',
table,
'',
];
// Sankeyはノイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する
for (const graph of [
//renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
//renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
] as (string | null)[]) {
if (graph == null) continue;
lines.push(graph);
lines.push('');
}
return lines.join('\n');
}
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
return ((headValue - baseValue) * 100) / baseValue;
}
/**
*
* 3
*/
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const delta = headValue - baseValue;
if (delta <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return delta > combinedSpread * 3;
}
export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) {
const lines = [
'## ⚙️ Backend Diagnostics Report',
'',
];
//const summary = measurementSummary(base, head);
//if (summary != null) {
// lines.push(summary);
// lines.push('');
//}
for (const phase of memoryReportPhases) {
lines.push(`### Memory: ${phase.title}`);
lines.push(renderMainTableForPhase(base, head, phase.key));
lines.push('');
}
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
if (heapSnapshotSection != null) {
lines.push(heapSnapshotSection);
lines.push('');
}
lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`);
lines.push('');
const warningMetric = 'Pss';
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
lines.push('');
}
return `${lines.join('\n')}\n`;
}
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
/** 計測フェーズ。将来的に増やせるようリスト化してある */
export const memoryPhases = ['afterGc'] as const;
export type MemoryPhase = typeof memoryPhases[number];
export type MemoryStability = {
converged: boolean;
readingCount: number;
elapsedMs: number;
maxAbsoluteSlopesKiBPerSecond: Record<string, number> | null;
};
/** バックエンドを1回起動して得られる計測結果 */
export type MemorySample = {
timestamp: string;
phases: Record<MemoryPhase, {
/** /proc 由来の値はKiB、ランタイム由来の値もKiBに揃えてある */
memoryUsage: Record<string, number>;
memoryStability: MemoryStability;
heapSnapshot: HeapSnapshotData | null;
}>;
};
/** base / head それぞれについて出力されるJSONレポート */
export type MemoryReport = {
timestamp: string;
sampleCount: number;
aggregation: string;
comparison?: {
strategy: string;
rounds: number;
warmupRounds: number;
startedAt: string;
};
summary: Record<MemoryPhase, {
memoryUsage: Record<string, number>;
heapSnapshot?: HeapSnapshotData;
}>;
samples: (MemorySample & {
round: number;
})[];
};