mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-26 04:25:14 +02: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:
40
packages-private/diagnostics-shared/src/env.ts
Normal file
40
packages-private/diagnostics-shared/src/env.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readBooleanEnv(name: string, defaultValue: boolean) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (rawValue === '1' || rawValue === 'true') return true;
|
||||
if (rawValue === '0' || rawValue === 'false') return false;
|
||||
throw new Error(`${name} must be one of: 1, 0, true, false`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 必須の環境変数を読む。未設定・空文字ならエラーにする。
|
||||
*/
|
||||
export function readRequiredEnv(name: string) {
|
||||
const rawValue = process.env[name]?.trim();
|
||||
if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`);
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意の環境変数を読む。未設定・空文字なら null を返す。
|
||||
*/
|
||||
export function readOptionalEnv(name: string) {
|
||||
const rawValue = process.env[name]?.trim();
|
||||
if (rawValue == null || rawValue === '') return null;
|
||||
return rawValue;
|
||||
}
|
||||
116
packages-private/diagnostics-shared/src/format.ts
Normal file
116
packages-private/diagnostics-shared/src/format.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function escapeLatex(text: string) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
export function escapeMdTableCell(value: string) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
export function escapeHtml(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''');
|
||||
}
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (value === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。
|
||||
*/
|
||||
export function formatKiBAsMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null || !Number.isFinite(valueKiB)) return '-';
|
||||
return `${formatNumber(valueKiB / 1000)} MB`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
if (value >= 1_000) return `${formatNumber(value / 1_000)} s`;
|
||||
return `${formatNumber(value)} ms`;
|
||||
}
|
||||
|
||||
export function formatSecondsAsMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
return formatMs(value * 1_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。
|
||||
*/
|
||||
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
|
||||
if (delta === 0) return text(0);
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
|
||||
}
|
||||
|
||||
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaBytes, formatBytes, colorThreshold);
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaPercent, formatPercent, colorThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdownのテーブルセル内に置く差分パーセント。
|
||||
* LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。
|
||||
*/
|
||||
export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) {
|
||||
return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%');
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
return formatColoredDelta(after - before, formatNumber, colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
return formatDeltaBytes(after - before, colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
|
||||
if (before == null || before === 0 || after == null) return '-';
|
||||
return formatDeltaPercent((after - before) / before * 100, colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
|
||||
return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%');
|
||||
}
|
||||
198
packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts
Normal file
198
packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown';
|
||||
import {
|
||||
createEmptyHeapSnapshotData,
|
||||
heapSnapshotBreakdownCategories,
|
||||
type HeapSnapshotCategory,
|
||||
type HeapSnapshotData,
|
||||
} from './categories';
|
||||
|
||||
/**
|
||||
* `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。
|
||||
*/
|
||||
type HeapSnapshotView = {
|
||||
nodes: number[];
|
||||
edges: number[];
|
||||
strings: string[];
|
||||
nodeFieldCount: number;
|
||||
edgeFieldCount: number;
|
||||
nodeTypeNames: string[];
|
||||
edgeTypeNames: string[];
|
||||
typeOffset: number;
|
||||
nameOffset: number;
|
||||
selfSizeOffset: number;
|
||||
edgeCountOffset: number;
|
||||
edgeTypeOffset: number;
|
||||
edgeNameOffset: number;
|
||||
edgeToNodeOffset: number;
|
||||
extraNativeBytes: number;
|
||||
};
|
||||
|
||||
function requireOffsets(fields: string[], names: string[], what: string) {
|
||||
const offsets = names.map(name => fields.indexOf(name));
|
||||
if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function parseHeapSnapshot(snapshot: any): HeapSnapshotView {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const { nodes, edges, strings } = snapshot ?? {};
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
const edgeFields = meta.edge_fields;
|
||||
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
|
||||
|
||||
const [typeOffset, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node');
|
||||
const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge');
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
|
||||
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
strings,
|
||||
nodeFieldCount: nodeFields.length,
|
||||
edgeFieldCount: edgeFields.length,
|
||||
nodeTypeNames,
|
||||
edgeTypeNames,
|
||||
typeOffset,
|
||||
nameOffset,
|
||||
selfSizeOffset,
|
||||
edgeCountOffset,
|
||||
edgeTypeOffset,
|
||||
edgeNameOffset,
|
||||
edgeToNodeOffset,
|
||||
extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードごとのedge開始位置と、各ノードが何本のedgeから参照されているかを求める。
|
||||
* JS配列のelementsストアが専有されているか判定するために使う。
|
||||
*/
|
||||
function indexEdges(view: HeapSnapshotView) {
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
let edgeIndex = 0;
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) {
|
||||
edgeStartIndexes.set(nodeIndex, edgeIndex);
|
||||
const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0;
|
||||
for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) {
|
||||
const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset];
|
||||
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { edgeStartIndexes, retainerCounts };
|
||||
}
|
||||
|
||||
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
|
||||
const view = parseHeapSnapshot(snapshot);
|
||||
const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view;
|
||||
|
||||
const nativeType = nodeTypeNames.indexOf('native');
|
||||
const codeType = nodeTypeNames.indexOf('code');
|
||||
const hiddenType = nodeTypeNames.indexOf('hidden');
|
||||
const stringTypes = new Set([
|
||||
nodeTypeNames.indexOf('string'),
|
||||
nodeTypeNames.indexOf('concatenated string'),
|
||||
nodeTypeNames.indexOf('sliced string'),
|
||||
]);
|
||||
const internalEdgeType = edgeTypeNames.indexOf('internal');
|
||||
|
||||
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
|
||||
const breakdowns = {} as Record<HeapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of heapSnapshotBreakdownCategories) {
|
||||
breakdowns[category] = {};
|
||||
}
|
||||
|
||||
const { edgeStartIndexes, retainerCounts } = indexEdges(view);
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) {
|
||||
if (value <= 0) return;
|
||||
categories[category] += value;
|
||||
const label = classifyHeapSnapshotBreakdown(category, type, name);
|
||||
breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value;
|
||||
if (counted) nodeCounts[category]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、
|
||||
* その配列だけが参照しているelementsノードの分を JS arrays に加算する。
|
||||
*/
|
||||
function addJsArrayElementSize(nodeIndex: number) {
|
||||
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
|
||||
const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0;
|
||||
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
|
||||
if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue;
|
||||
if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue;
|
||||
|
||||
const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset];
|
||||
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
|
||||
addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements');
|
||||
jsArrayElementNodeIndexes.add(elementsNodeIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (view.extraNativeBytes > 0) {
|
||||
addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false);
|
||||
}
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
const typeId = nodes[nodeIndex + view.typeOffset];
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const name = strings[nodes[nodeIndex + view.nameOffset]] ?? '';
|
||||
const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0;
|
||||
categories.total += selfSize;
|
||||
nodeCounts.total++;
|
||||
|
||||
if (typeId === hiddenType) {
|
||||
addCategoryValue('systemObjects', selfSize, type, name);
|
||||
} else if (typeId === nativeType) {
|
||||
addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name);
|
||||
} else if (typeId === codeType) {
|
||||
addCategoryValue('code', selfSize, type, name);
|
||||
} else if (stringTypes.has(typeId)) {
|
||||
addCategoryValue('strings', selfSize, type, name);
|
||||
} else if (name === 'Array') {
|
||||
addCategoryValue('jsArrays', selfSize, type, name);
|
||||
addJsArrayElementSize(nodeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
categories.total += view.extraNativeBytes;
|
||||
|
||||
// 上のループで JS arrays に計上したelementsノードが確定してから、残りを Other JS objects に振り分ける
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
|
||||
|
||||
const typeId = nodes[nodeIndex + view.typeOffset];
|
||||
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
|
||||
|
||||
const name = strings[nodes[nodeIndex + view.nameOffset]] ?? '';
|
||||
if (name === 'Array') continue;
|
||||
|
||||
addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
defaultHeapSnapshotBreakdownTopN,
|
||||
heapSnapshotBreakdownCategories,
|
||||
type HeapSnapshotCategory,
|
||||
type HeapSnapshotData,
|
||||
} from './categories';
|
||||
|
||||
function sanitizeLabel(value: unknown, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。
|
||||
*/
|
||||
export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) {
|
||||
switch (category) {
|
||||
case 'strings':
|
||||
return type;
|
||||
|
||||
case 'jsArrays':
|
||||
if (type === 'array elements') return 'Array elements';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeLabel(`${type}: ${name}`);
|
||||
|
||||
case 'typedArrays':
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
return sanitizeLabel(`${type}: ${name}`);
|
||||
|
||||
case 'systemObjects':
|
||||
if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name);
|
||||
return sanitizeLabel(`${type}: ${name}`, type);
|
||||
|
||||
case 'otherJsObjects':
|
||||
if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
|
||||
case 'otherNonJsObjects':
|
||||
if (type === 'extra native bytes') return 'Extra native bytes';
|
||||
if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeLabel(`${type}: ${name}`, type);
|
||||
|
||||
case 'code': {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
default:
|
||||
return sanitizeLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内訳を上位N件に丸め、残りを `Other` にまとめる。
|
||||
*/
|
||||
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const collapsed = Object.fromEntries(entries.slice(0, topN));
|
||||
const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export function collapseHeapSnapshotBreakdowns(
|
||||
breakdowns: Partial<Record<HeapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed: NonNullable<HeapSnapshotData['breakdowns']> = {};
|
||||
for (const category of heapSnapshotBreakdownCategories) {
|
||||
const categoryBreakdown = breakdowns[category];
|
||||
if (categoryBreakdown == null) continue;
|
||||
|
||||
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
|
||||
if (Object.keys(collapsedCategory).length > 0) {
|
||||
collapsed[category] = collapsedCategory;
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。
|
||||
export const heapSnapshotCategory = {
|
||||
total: { label: 'Total', color: 'gray', colorHex: '#888888' },
|
||||
code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' },
|
||||
strings: { label: 'Strings', color: 'red', colorHex: '#e15759' },
|
||||
jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' },
|
||||
typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' },
|
||||
systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' },
|
||||
otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' },
|
||||
otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' },
|
||||
} as const satisfies Record<string, { label: string; color: string; colorHex: string }>;
|
||||
|
||||
export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory;
|
||||
|
||||
export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[];
|
||||
|
||||
/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */
|
||||
export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total');
|
||||
|
||||
export type HeapSnapshotData = {
|
||||
categories: Record<HeapSnapshotCategory, number>;
|
||||
nodeCounts: Record<HeapSnapshotCategory, number>;
|
||||
/** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */
|
||||
breakdowns?: Partial<Record<HeapSnapshotCategory, Record<string, number>>>;
|
||||
};
|
||||
|
||||
export type HeapSnapshotReport = {
|
||||
summary: HeapSnapshotData;
|
||||
samples: {
|
||||
round: number;
|
||||
data: HeapSnapshotData;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const defaultHeapSnapshotBreakdownTopN = 6;
|
||||
|
||||
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
|
||||
const categories = {} as HeapSnapshotData['categories'];
|
||||
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||
for (const category of heapSnapshotCategories) {
|
||||
categories[category] = 0;
|
||||
nodeCounts[category] = 0;
|
||||
}
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export * from './analyze';
|
||||
export * from './breakdown';
|
||||
export * from './categories';
|
||||
export * from './render';
|
||||
export * from './summarize';
|
||||
177
packages-private/diagnostics-shared/src/heap-snapshot/render.ts
Normal file
177
packages-private/diagnostics-shared/src/heap-snapshot/render.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
formatBytes,
|
||||
formatDeltaBytes,
|
||||
formatDeltaPercentInMdTable,
|
||||
formatPercent,
|
||||
} from '../format';
|
||||
import { mad, pairedDeltaSummary } from '../stats';
|
||||
import {
|
||||
heapSnapshotCategories,
|
||||
heapSnapshotCategory,
|
||||
type HeapSnapshotCategory,
|
||||
type HeapSnapshotReport,
|
||||
} from './categories';
|
||||
|
||||
/** これ未満のバイト差分には色を付けない (0.1 MB) */
|
||||
const byteColorThreshold = 100_000;
|
||||
|
||||
function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
|
||||
return report.summary.categories[category];
|
||||
}
|
||||
|
||||
function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
|
||||
const values = report.samples
|
||||
.map(sample => sample.data.categories[category])
|
||||
.filter(value => Number.isFinite(value));
|
||||
if (values.length < 2) throw new Error(`Not enough samples for category ${category}`);
|
||||
|
||||
return mad(values);
|
||||
}
|
||||
|
||||
function swatch(category: HeapSnapshotCategory) {
|
||||
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
|
||||
}
|
||||
|
||||
/**
|
||||
* base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。
|
||||
*/
|
||||
export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
const baseTotal = categoryValue(base, 'total');
|
||||
const headTotal = categoryValue(head, 'total');
|
||||
|
||||
for (const category of heapSnapshotCategories) {
|
||||
const baseValue = categoryValue(base, category);
|
||||
const headValue = categoryValue(head, category);
|
||||
const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]);
|
||||
const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`;
|
||||
|
||||
if (category === 'total') {
|
||||
// Totalだけはばらつきと変化率も併記する
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
|
||||
const baseText = `${formatBytes(baseValue)} <br> ± ${formatBytes(categorySampleSpread(base, category))}`;
|
||||
const headText = `${formatBytes(headValue)} <br> ± ${formatBytes(categorySampleSpread(head, category))}`;
|
||||
lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`);
|
||||
lines.push('| | | | | | | |');
|
||||
} else {
|
||||
// 各カテゴリはTotalに占める割合の推移をdetailsで見せる
|
||||
const basePercent = formatPercent((baseValue * 100) / baseTotal);
|
||||
const headPercent = formatPercent((headValue * 100) / headTotal);
|
||||
const metricText = `<details><summary>${swatch(category)}</summary>${basePercent} → ${headPercent}</details>`;
|
||||
lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 2) return null;
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const sankeyChildMinRatio = 0.3;
|
||||
const sankeyParentMinPercent = 10;
|
||||
|
||||
function escapeCsvValue(value: string) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function formatSankeyPercentValue(value: number) {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded === 0 && value > 0) return '0.01';
|
||||
if (Number.isInteger(rounded)) return String(rounded);
|
||||
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* heap snapshotの構成比をmermaidのsankey図として描画する。
|
||||
* 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。
|
||||
*/
|
||||
export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) {
|
||||
const total = categoryValue(report, 'total');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (total == null || total <= 0) return null;
|
||||
|
||||
const categories = heapSnapshotCategories
|
||||
.filter(category => category !== 'total')
|
||||
.map(category => {
|
||||
const value = categoryValue(report, category);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (value == null || value <= 0) return null;
|
||||
|
||||
const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {})
|
||||
.filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
|
||||
const percent = (value * 100) / total;
|
||||
const childEntries: [string, number][] = [];
|
||||
let otherPercent = 0;
|
||||
|
||||
if (breakdownTotal > 0 && percent > sankeyParentMinPercent) {
|
||||
for (const [childName, childValue] of breakdownEntries) {
|
||||
const childRatio = childValue / breakdownTotal;
|
||||
if (childRatio >= sankeyChildMinRatio) {
|
||||
childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]);
|
||||
} else {
|
||||
otherPercent += percent * childRatio;
|
||||
}
|
||||
}
|
||||
|
||||
if (childEntries.length > 0 && otherPercent > 0) {
|
||||
childEntries.push(['Other', otherPercent]);
|
||||
}
|
||||
}
|
||||
|
||||
return { category, percent, childEntries };
|
||||
})
|
||||
.filter(value => value != null);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const nodeColors: Record<string, string> = {
|
||||
[title]: heapSnapshotCategory.total.colorHex,
|
||||
Other: '#888888',
|
||||
};
|
||||
for (const { category, childEntries } of categories) {
|
||||
nodeColors[category] = heapSnapshotCategory[category].colorHex;
|
||||
for (const [childName] of childEntries) {
|
||||
nodeColors[childName] = heapSnapshotCategory[category].colorHex;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
sankey: {
|
||||
showValues: false,
|
||||
linkColor: 'target',
|
||||
labelStyle: 'outlined',
|
||||
nodeAlignment: 'center',
|
||||
nodePadding: 10,
|
||||
nodeColors,
|
||||
},
|
||||
})}}%%`,
|
||||
'sankey-beta',
|
||||
];
|
||||
|
||||
for (const { category, percent, childEntries } of categories) {
|
||||
const categoryLabel = heapSnapshotCategory[category].label;
|
||||
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`);
|
||||
|
||||
for (const [childName, childPercent] of childEntries) {
|
||||
lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('```', '', '</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { finiteMedian } from '../stats';
|
||||
import { collapseHeapSnapshotBreakdown } from './breakdown';
|
||||
import {
|
||||
heapSnapshotBreakdownCategories,
|
||||
heapSnapshotCategories,
|
||||
type HeapSnapshotCategory,
|
||||
type HeapSnapshotData,
|
||||
} from './categories';
|
||||
|
||||
function isComplete(values: Partial<Record<HeapSnapshotCategory, number>>): values is Record<HeapSnapshotCategory, number> {
|
||||
return heapSnapshotCategories.every(category => values[category] != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。
|
||||
* 全カテゴリ分の値が揃わなければ null を返す。
|
||||
*/
|
||||
export function summarizeHeapSnapshotDataSamples<T>(
|
||||
samples: T[],
|
||||
getData: (sample: T) => HeapSnapshotData | null | undefined,
|
||||
options: { breakdownTopN?: number } = {},
|
||||
) {
|
||||
const data = samples.map(getData);
|
||||
|
||||
const categories: Partial<HeapSnapshotData['categories']> = {};
|
||||
const nodeCounts: Partial<HeapSnapshotData['nodeCounts']> = {};
|
||||
for (const category of heapSnapshotCategories) {
|
||||
const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
|
||||
if (categoryValue != null) categories[category] = categoryValue;
|
||||
|
||||
const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
|
||||
if (nodeCountValue != null) nodeCounts[category] = nodeCountValue;
|
||||
}
|
||||
|
||||
// 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って
|
||||
// undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする
|
||||
if (!isComplete(categories) || !isComplete(nodeCounts)) return null;
|
||||
|
||||
const breakdowns: NonNullable<HeapSnapshotData['breakdowns']> = {};
|
||||
for (const category of heapSnapshotBreakdownCategories) {
|
||||
const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {})));
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
|
||||
if (value != null) categoryBreakdown[childKey] = value;
|
||||
}
|
||||
|
||||
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
|
||||
if (Object.keys(collapsed).length > 0) breakdowns[category] = collapsed;
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
|
||||
} satisfies HeapSnapshotData;
|
||||
}
|
||||
58
packages-private/diagnostics-shared/src/html.ts
Normal file
58
packages-private/diagnostics-shared/src/html.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { escapeHtml } from './format';
|
||||
|
||||
/**
|
||||
* エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。
|
||||
*
|
||||
* ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に
|
||||
* 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。
|
||||
* 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。
|
||||
*/
|
||||
export class Raw {
|
||||
constructor(private readonly value: string) {}
|
||||
|
||||
toString() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字列をエスケープせずそのまま埋め込む。
|
||||
* 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。
|
||||
*/
|
||||
export function raw(value: string) {
|
||||
return new Raw(value);
|
||||
}
|
||||
|
||||
function interpolate(value: unknown): string {
|
||||
if (value instanceof Raw) return value.toString();
|
||||
// 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する
|
||||
if (Array.isArray(value)) return value.map(interpolate).join('');
|
||||
if (value == null) return '';
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。
|
||||
* エスケープしたくない場合は `raw()` で包む必要があるため、
|
||||
* 「うっかり生のまま埋め込む」ことが起きない。
|
||||
*/
|
||||
export function html(strings: TemplateStringsArray, ...values: unknown[]) {
|
||||
let result = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
result += interpolate(values[i]) + strings[i + 1];
|
||||
}
|
||||
return new Raw(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断片を指定の区切り文字で連結する。
|
||||
* `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。
|
||||
*/
|
||||
export function joinHtml(parts: readonly Raw[], separator: string) {
|
||||
return new Raw(parts.map(part => part.toString()).join(separator));
|
||||
}
|
||||
84
packages-private/diagnostics-shared/src/stats.ts
Normal file
84
packages-private/diagnostics-shared/src/stats.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function median(values: number[]) {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const center = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[center];
|
||||
return Math.round((sorted[center - 1] + sorted[center]) / 2);
|
||||
}
|
||||
|
||||
export function mad(values: number[]) {
|
||||
if (values.length < 2) throw new Error('Not enough samples to calculate MAD');
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。
|
||||
*/
|
||||
export function finiteMedian(values: (number | null | undefined)[]): number | null;
|
||||
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number;
|
||||
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return defaultValue;
|
||||
return median(finiteValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。
|
||||
*/
|
||||
export function sampleSpread(values: (number | null | undefined)[]) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length < 2) return null;
|
||||
return mad(finiteValues);
|
||||
}
|
||||
|
||||
type RoundedSample = { round: number };
|
||||
|
||||
function indexByRound<T extends RoundedSample>(samples: T[]) {
|
||||
const samplesByRound = new Map<number, T>();
|
||||
for (const sample of samples) {
|
||||
// 負のroundはwarmupを表すため対象外
|
||||
if (sample.round <= 0) continue;
|
||||
samplesByRound.set(sample.round, sample);
|
||||
}
|
||||
return samplesByRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。
|
||||
* 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。
|
||||
*/
|
||||
export function pairedDeltaSummary<T extends RoundedSample>(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) {
|
||||
const baseSamplesByRound = indexByRound(baseSamples);
|
||||
const headSamplesByRound = indexByRound(headSamples);
|
||||
const values: number[] = [];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getValue(baseSample);
|
||||
const headValue = getValue(headSample);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
// 対応するroundが1つも無いと中央値も最小/最大も定義できない。
|
||||
// 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす
|
||||
if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common');
|
||||
|
||||
return {
|
||||
median: median(values),
|
||||
// 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する)
|
||||
mad: values.length < 2 ? 0 : mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user