mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-04 06:16:11 +00:00
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:
@@ -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;
|
||||
})[];
|
||||
};
|
||||
Reference in New Issue
Block a user