mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
Compare commits
27 commits
clean-pref
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9785962539 | ||
|
|
8a479bd6ee | ||
|
|
faecaccab9 | ||
|
|
1c4bcd9b32 |
||
|
|
e90ef7eba2 | ||
|
|
1f4978b9e2 | ||
|
|
4d6ad90d3e | ||
|
|
2116213dc8 | ||
|
|
0904855001 | ||
|
|
453f38b6b6 | ||
|
|
079ec865e0 | ||
|
|
d0081035fc | ||
|
|
fdc2f79855 | ||
|
|
7b2790e46d | ||
|
|
351f878e2c | ||
|
|
83319d1cc2 | ||
|
|
8dfa900729 | ||
|
|
5ab352da11 | ||
|
|
4a056bc143 | ||
|
|
bfad097ede | ||
|
|
69ae0c154d | ||
|
|
59ae3801dc |
||
|
|
1173550784 | ||
|
|
e12f97b1d8 | ||
|
|
a27b155dcf | ||
|
|
3e1a090657 | ||
|
|
e77184ffdb |
27 changed files with 2996 additions and 1036 deletions
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
Normal file
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFileSync, statSync } from 'node:fs';
|
||||||
|
import { extname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||||
|
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||||
|
|
||||||
|
function recordLoadedFile(kind, url, format) {
|
||||||
|
if (traceFile == null || !url.startsWith('file:')) return;
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
try {
|
||||||
|
filePath = fileURLToPath(url);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extname(filePath);
|
||||||
|
if (!jsExtensions.has(extension)) return;
|
||||||
|
|
||||||
|
let size = null;
|
||||||
|
try {
|
||||||
|
size = statSync(filePath).size;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFileSync(traceFile, `${JSON.stringify({
|
||||||
|
kind,
|
||||||
|
format,
|
||||||
|
path: filePath,
|
||||||
|
size,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load(url, context, nextLoad) {
|
||||||
|
const result = await nextLoad(url, context);
|
||||||
|
recordLoadedFile('esm', url, result.format ?? context.format ?? null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
46
.github/scripts/backend-js-footprint-require.cjs
vendored
Normal file
46
.github/scripts/backend-js-footprint-require.cjs
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { appendFileSync, statSync } = require('node:fs');
|
||||||
|
const Module = require('node:module');
|
||||||
|
const { extname } = require('node:path');
|
||||||
|
|
||||||
|
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||||
|
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||||
|
|
||||||
|
function recordLoadedFile(kind, filePath, request) {
|
||||||
|
if (traceFile == null || typeof filePath !== 'string') return;
|
||||||
|
|
||||||
|
const extension = extname(filePath);
|
||||||
|
if (!jsExtensions.has(extension) && extension !== '.node') return;
|
||||||
|
|
||||||
|
let size = null;
|
||||||
|
try {
|
||||||
|
size = statSync(filePath).size;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFileSync(traceFile, `${JSON.stringify({
|
||||||
|
kind,
|
||||||
|
format: extension === '.node' ? 'native' : 'commonjs',
|
||||||
|
path: filePath,
|
||||||
|
request,
|
||||||
|
size,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const originalResolveFilename = Module._resolveFilename;
|
||||||
|
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
const resolved = originalResolveFilename.call(this, request, parent, isMain);
|
||||||
|
const result = originalLoad.apply(this, arguments);
|
||||||
|
recordLoadedFile('cjs', resolved, request);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
478
.github/scripts/backend-js-footprint.mjs
vendored
Normal file
478
.github/scripts/backend-js-footprint.mjs
vendored
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fork, spawn } from 'node:child_process';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { cpus, tmpdir } from 'node:os';
|
||||||
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||||
|
import { setTimeout } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as fsSync from 'node:fs';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import * as util from './utility.mts';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const [repoDirArg, outputFileArg] = process.argv.slice(2);
|
||||||
|
|
||||||
|
const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
|
||||||
|
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
|
||||||
|
const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
|
||||||
|
const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
|
||||||
|
|
||||||
|
const repoDir = resolve(repoDirArg);
|
||||||
|
const outputFile = resolve(outputFileArg);
|
||||||
|
const backendDir = join(repoDir, 'packages/backend');
|
||||||
|
const backendBuiltDir = join(backendDir, 'built');
|
||||||
|
const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`);
|
||||||
|
const require = createRequire(join(repoDir, 'package.json'));
|
||||||
|
const ts = require('typescript');
|
||||||
|
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||||
|
const fileMetricCache = new Map();
|
||||||
|
const packageInfoCache = new Map();
|
||||||
|
const nativePackageNames = new Set();
|
||||||
|
|
||||||
|
function isInside(parent, child) {
|
||||||
|
const rel = relative(parent, child);
|
||||||
|
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(filePath) {
|
||||||
|
return filePath.split(sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToKiB(value) {
|
||||||
|
return Math.round(value / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetState() {
|
||||||
|
const backendRequire = createRequire(join(backendDir, 'package.json'));
|
||||||
|
const pg = backendRequire('pg');
|
||||||
|
const Redis = backendRequire('ioredis');
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequest() {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const req = http.request({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 61812,
|
||||||
|
path: '/api/meta',
|
||||||
|
method: 'POST',
|
||||||
|
}, res => {
|
||||||
|
res.on('data', () => { });
|
||||||
|
res.on('end', () => resolvePromise());
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServerReady(serverProcess) {
|
||||||
|
let serverReady = false;
|
||||||
|
serverProcess.on('message', message => {
|
||||||
|
if (message === 'ok') serverReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startupStartTime = Date.now();
|
||||||
|
while (!serverReady) {
|
||||||
|
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||||
|
serverProcess.kill('SIGTERM');
|
||||||
|
throw new Error('Server startup timeout');
|
||||||
|
}
|
||||||
|
await setTimeout(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopServer(serverProcess) {
|
||||||
|
serverProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
let exited = false;
|
||||||
|
await new Promise(resolvePromise => {
|
||||||
|
serverProcess.on('exit', () => {
|
||||||
|
exited = true;
|
||||||
|
resolvePromise(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(10000).then(() => {
|
||||||
|
if (!exited) serverProcess.kill('SIGKILL');
|
||||||
|
resolvePromise(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageNameFromPath(filePath) {
|
||||||
|
const normalized = normalizePath(filePath);
|
||||||
|
const marker = '/node_modules/';
|
||||||
|
const index = normalized.lastIndexOf(marker);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
const rest = normalized.slice(index + marker.length).split('/');
|
||||||
|
if (rest[0] === '.pnpm') {
|
||||||
|
const nestedNodeModulesIndex = rest.indexOf('node_modules');
|
||||||
|
if (nestedNodeModulesIndex === -1) return null;
|
||||||
|
const packageParts = rest.slice(nestedNodeModulesIndex + 1);
|
||||||
|
if (packageParts.length === 0) return null;
|
||||||
|
return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPackageDir(filePath, packageName) {
|
||||||
|
const normalizedPackageName = packageName.split('/').join(sep);
|
||||||
|
let current = dirname(filePath);
|
||||||
|
|
||||||
|
while (current !== dirname(current)) {
|
||||||
|
if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = dirname(current);
|
||||||
|
if (parent === current) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageInfo(filePath) {
|
||||||
|
const externalPackageName = getPackageNameFromPath(filePath);
|
||||||
|
if (externalPackageName != null) {
|
||||||
|
const packageDir = findPackageDir(filePath, externalPackageName);
|
||||||
|
const cacheKey = packageDir ?? externalPackageName;
|
||||||
|
if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey);
|
||||||
|
|
||||||
|
let version = null;
|
||||||
|
if (packageDir != null) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
||||||
|
version = typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
category: 'external',
|
||||||
|
name: externalPackageName,
|
||||||
|
version,
|
||||||
|
dir: packageDir,
|
||||||
|
};
|
||||||
|
packageInfoCache.set(cacheKey, info);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInside(backendBuiltDir, filePath)) {
|
||||||
|
return {
|
||||||
|
category: 'internal',
|
||||||
|
name: 'backend',
|
||||||
|
version: null,
|
||||||
|
dir: backendDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: 'internal',
|
||||||
|
name: 'workspace',
|
||||||
|
version: null,
|
||||||
|
dir: repoDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeSource(filePath, source) {
|
||||||
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||||
|
const metrics = {
|
||||||
|
astNodeCount: 0,
|
||||||
|
functionCount: 0,
|
||||||
|
classCount: 0,
|
||||||
|
stringLiteralBytes: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function visit(node) {
|
||||||
|
metrics.astNodeCount += 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ts.isFunctionDeclaration(node) ||
|
||||||
|
ts.isFunctionExpression(node) ||
|
||||||
|
ts.isArrowFunction(node) ||
|
||||||
|
ts.isMethodDeclaration(node) ||
|
||||||
|
ts.isConstructorDeclaration(node) ||
|
||||||
|
ts.isGetAccessorDeclaration(node) ||
|
||||||
|
ts.isSetAccessorDeclaration(node)
|
||||||
|
) {
|
||||||
|
metrics.functionCount += 1;
|
||||||
|
} else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
||||||
|
metrics.classCount += 1;
|
||||||
|
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||||
|
metrics.stringLiteralBytes += Buffer.byteLength(node.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile);
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileMetrics(filePath) {
|
||||||
|
if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath);
|
||||||
|
|
||||||
|
const source = fsSync.readFileSync(filePath);
|
||||||
|
const sourceText = source.toString('utf8');
|
||||||
|
const astMetrics = analyzeSource(filePath, sourceText);
|
||||||
|
const packageInfo = readPackageInfo(filePath);
|
||||||
|
const metric = {
|
||||||
|
path: filePath,
|
||||||
|
displayPath: normalizePath(relative(repoDir, filePath)),
|
||||||
|
sourceBytes: source.byteLength,
|
||||||
|
gzipBytes: gzipSync(source).byteLength,
|
||||||
|
...astMetrics,
|
||||||
|
package: packageInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
fileMetricCache.set(filePath, metric);
|
||||||
|
return metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTraceRecords() {
|
||||||
|
let content = '';
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(traceFile, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
if (line.trim() === '') continue;
|
||||||
|
try {
|
||||||
|
records.push(JSON.parse(line));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTotals() {
|
||||||
|
return {
|
||||||
|
loadedJsModules: 0,
|
||||||
|
loadedJsSourceBytes: 0,
|
||||||
|
loadedJsGzipBytes: 0,
|
||||||
|
astNodeCount: 0,
|
||||||
|
functionCount: 0,
|
||||||
|
classCount: 0,
|
||||||
|
stringLiteralBytes: 0,
|
||||||
|
externalPackageCount: 0,
|
||||||
|
nativeAddonPackageCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFileMetrics(target, metric) {
|
||||||
|
target.loadedJsModules += 1;
|
||||||
|
target.loadedJsSourceBytes += metric.sourceBytes;
|
||||||
|
target.loadedJsGzipBytes += metric.gzipBytes;
|
||||||
|
target.astNodeCount += metric.astNodeCount;
|
||||||
|
target.functionCount += metric.functionCount;
|
||||||
|
target.classCount += metric.classCount;
|
||||||
|
target.stringLiteralBytes += metric.stringLiteralBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRecords(records, phase) {
|
||||||
|
const jsPaths = new Set();
|
||||||
|
const nativePaths = new Set();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (typeof record.path !== 'string') continue;
|
||||||
|
|
||||||
|
const extension = extname(record.path);
|
||||||
|
if (jsExtensions.has(extension)) {
|
||||||
|
jsPaths.add(resolve(record.path));
|
||||||
|
} else if (extension === '.node') {
|
||||||
|
nativePaths.add(resolve(record.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nativePath of nativePaths) {
|
||||||
|
const packageInfo = readPackageInfo(nativePath);
|
||||||
|
if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = emptyTotals();
|
||||||
|
const packages = new Map();
|
||||||
|
const modules = [];
|
||||||
|
|
||||||
|
for (const filePath of [...jsPaths].toSorted()) {
|
||||||
|
let metric;
|
||||||
|
try {
|
||||||
|
metric = readFileMetrics(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addFileMetrics(totals, metric);
|
||||||
|
|
||||||
|
const packageKey = metric.package.name;
|
||||||
|
if (!packages.has(packageKey)) {
|
||||||
|
packages.set(packageKey, {
|
||||||
|
name: metric.package.name,
|
||||||
|
version: metric.package.version,
|
||||||
|
category: metric.package.category,
|
||||||
|
sourceBytes: 0,
|
||||||
|
gzipBytes: 0,
|
||||||
|
modules: 0,
|
||||||
|
astNodeCount: 0,
|
||||||
|
functionCount: 0,
|
||||||
|
classCount: 0,
|
||||||
|
stringLiteralBytes: 0,
|
||||||
|
nativeAddon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSummary = packages.get(packageKey);
|
||||||
|
packageSummary.sourceBytes += metric.sourceBytes;
|
||||||
|
packageSummary.gzipBytes += metric.gzipBytes;
|
||||||
|
packageSummary.modules += 1;
|
||||||
|
packageSummary.astNodeCount += metric.astNodeCount;
|
||||||
|
packageSummary.functionCount += metric.functionCount;
|
||||||
|
packageSummary.classCount += metric.classCount;
|
||||||
|
packageSummary.stringLiteralBytes += metric.stringLiteralBytes;
|
||||||
|
|
||||||
|
modules.push({
|
||||||
|
path: metric.displayPath,
|
||||||
|
package: metric.package.name,
|
||||||
|
category: metric.package.category,
|
||||||
|
sourceBytes: metric.sourceBytes,
|
||||||
|
gzipBytes: metric.gzipBytes,
|
||||||
|
astNodeCount: metric.astNodeCount,
|
||||||
|
functionCount: metric.functionCount,
|
||||||
|
classCount: metric.classCount,
|
||||||
|
stringLiteralBytes: metric.stringLiteralBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const packageName of nativePackageNames) {
|
||||||
|
const packageSummary = packages.get(packageName);
|
||||||
|
if (packageSummary != null) packageSummary.nativeAddon = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external');
|
||||||
|
totals.externalPackageCount = externalPackages.length;
|
||||||
|
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
|
||||||
|
loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes),
|
||||||
|
stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes),
|
||||||
|
},
|
||||||
|
packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes),
|
||||||
|
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes).slice(0, MAX_TABLE_ITEMS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureFootprint() {
|
||||||
|
await fs.writeFile(traceFile, '');
|
||||||
|
|
||||||
|
process.stderr.write('Resetting database and Redis\n');
|
||||||
|
await resetState();
|
||||||
|
|
||||||
|
process.stderr.write('Running migrations\n');
|
||||||
|
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: process.env,
|
||||||
|
logStdout: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], {
|
||||||
|
cwd: backendDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
MK_DISABLE_CLUSTERING: '1',
|
||||||
|
MK_ONLY_SERVER: '1',
|
||||||
|
MK_NO_DAEMONS: '1',
|
||||||
|
MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile,
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||||
|
execArgv: [
|
||||||
|
'--require',
|
||||||
|
join(__dirname, 'backend-js-footprint-require.cjs'),
|
||||||
|
'--experimental-loader',
|
||||||
|
pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await waitForServerReady(serverProcess);
|
||||||
|
await setTimeout(SETTLE_TIME);
|
||||||
|
|
||||||
|
//const startup = summarizeRecords(await readTraceRecords(), 'startup');
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||||
|
);
|
||||||
|
await setTimeout(1000);
|
||||||
|
|
||||||
|
const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest');
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
measurement: {
|
||||||
|
strategy: 'runtime-loader-trace',
|
||||||
|
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||||
|
settleTimeMs: SETTLE_TIME,
|
||||||
|
requestCount: REQUEST_COUNT,
|
||||||
|
cpus: cpus().length,
|
||||||
|
},
|
||||||
|
phases: {
|
||||||
|
//startup,
|
||||||
|
afterRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await stopServer(serverProcess);
|
||||||
|
await fs.rm(traceFile, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await measureFootprint();
|
||||||
|
await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
205
.github/scripts/backend-memory-report.mjs
vendored
205
.github/scripts/backend-memory-report.mjs
vendored
|
|
@ -1,205 +0,0 @@
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
|
|
||||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (baseFile == null || headFile == null || outputFile == null) {
|
|
||||||
console.error('Usage: node .github/scripts/backend-memory-report.mjs <base-memory.json> <head-memory.json> <report.md>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const phases = [
|
|
||||||
{
|
|
||||||
key: 'afterGc',
|
|
||||||
title: 'After GC',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const metrics = [
|
|
||||||
'HeapUsed',
|
|
||||||
'Pss',
|
|
||||||
'Private_Dirty',
|
|
||||||
'VmRSS',
|
|
||||||
'External',
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
return numberFormatter.format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMemory(valueKiB) {
|
|
||||||
return `${formatNumber(valueKiB / 1024)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value) {
|
|
||||||
return `${formatNumber(value)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMathText(text) {
|
|
||||||
return text
|
|
||||||
.replaceAll('\\', '\\\\')
|
|
||||||
.replaceAll('{', '\\{')
|
|
||||||
.replaceAll('}', '\\}')
|
|
||||||
.replaceAll('%', '\\%');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatColoredDiff(text, diff) {
|
|
||||||
const color = diff > 0 ? 'orange' : 'green';
|
|
||||||
return `$\\color{${color}}{\\text{${formatMathText(text).replaceAll('\\%', '\\\\%')}}}$`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiff(baseKiB, headKiB) {
|
|
||||||
const diff = headKiB - baseKiB;
|
|
||||||
if (diff === 0) return formatMemory(0);
|
|
||||||
|
|
||||||
const sign = diff > 0 ? '+' : '-';
|
|
||||||
return formatColoredDiff(`${sign}${formatMemory(Math.abs(diff))}`, diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiffPercent(baseKiB, headKiB) {
|
|
||||||
const diff = headKiB - baseKiB;
|
|
||||||
if (diff === 0) return '0%';
|
|
||||||
if (baseKiB <= 0) return '-';
|
|
||||||
|
|
||||||
const sign = diff > 0 ? '+' : '-';
|
|
||||||
return formatColoredDiff(`${sign}${formatPercent(Math.abs((diff * 100) / baseKiB))}`, diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemoryValue(report, phase, metric) {
|
|
||||||
const value = report?.[phase]?.[metric];
|
|
||||||
return Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function median(values) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSampleValues(report, phase, metric) {
|
|
||||||
if (!Array.isArray(report?.samples)) return [];
|
|
||||||
|
|
||||||
return report.samples
|
|
||||||
.map(sample => getMemoryValue(sample, phase, metric))
|
|
||||||
.filter(value => Number.isFinite(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSampleSpread(report, phase, metric) {
|
|
||||||
const values = getSampleValues(report, phase, metric);
|
|
||||||
if (values.length < 2) return null;
|
|
||||||
|
|
||||||
const center = median(values);
|
|
||||||
return median(values.map(value => Math.abs(value - center)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable(base, head, phase) {
|
|
||||||
const lines = [
|
|
||||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
|
||||||
'| --- | ---: | ---: | ---: | ---: |',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const metric of metrics) {
|
|
||||||
const baseValue = getMemoryValue(base, phase, metric);
|
|
||||||
const headValue = getMemoryValue(head, phase, metric);
|
|
||||||
if (baseValue == null || headValue == null) continue;
|
|
||||||
|
|
||||||
const baseSpread = getSampleSpread(base, phase, metric);
|
|
||||||
const headSpread = getSampleSpread(head, phase, metric);
|
|
||||||
|
|
||||||
lines.push(`| ${metric} | ${formatMemory(baseValue)} <br> ± ${formatMemory(baseSpread)} | ${formatMemory(headValue)} <br> ± ${formatMemory(headSpread)} | ${formatDiff(baseValue, headValue)} | ${formatDiffPercent(baseValue, headValue)} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDiffPercent(base, head, phase, metric) {
|
|
||||||
const baseValue = getMemoryValue(base, phase, metric);
|
|
||||||
const headValue = getMemoryValue(head, phase, metric);
|
|
||||||
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
|
||||||
|
|
||||||
return ((headValue - baseValue) * 100) / baseValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWarningMetric(base, head) {
|
|
||||||
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS']) {
|
|
||||||
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
|
|
||||||
return metric;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBeyondSampleNoise(base, head, phase, metric) {
|
|
||||||
const baseValue = getMemoryValue(base, phase, metric);
|
|
||||||
const headValue = getMemoryValue(head, phase, metric);
|
|
||||||
if (baseValue == null || headValue == null) return false;
|
|
||||||
|
|
||||||
const diff = headValue - baseValue;
|
|
||||||
if (diff <= 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 diff > combinedSpread * 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowFooter() {
|
|
||||||
const repository = process.env.GITHUB_REPOSITORY;
|
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
|
||||||
if (repository == null || runId == null) {
|
|
||||||
return 'See workflow logs for details.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[See workflow logs for details](https://github.com/${repository}/actions/runs/${runId})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function measurementSummary(base, head) {
|
|
||||||
const baseCount = base?.sampleCount;
|
|
||||||
const headCount = head?.sampleCount;
|
|
||||||
const strategy = base?.comparison?.strategy;
|
|
||||||
if (baseCount == null || headCount == null) return null;
|
|
||||||
|
|
||||||
if (strategy === 'interleaved-pairs') {
|
|
||||||
const rounds = base?.comparison?.rounds ?? baseCount;
|
|
||||||
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
|
||||||
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = JSON.parse(await readFile(baseFile, 'utf8'));
|
|
||||||
const head = JSON.parse(await readFile(headFile, 'utf8'));
|
|
||||||
const lines = [
|
|
||||||
'## Backend Memory Usage Report',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
|
|
||||||
const summary = measurementSummary(base, head);
|
|
||||||
if (summary != null) {
|
|
||||||
lines.push(summary);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const phase of phases) {
|
|
||||||
lines.push(`### ${phase.title}`);
|
|
||||||
lines.push(renderTable(base, head, phase.key));
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const warningMetric = getWarningMetric(base, head);
|
|
||||||
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
|
|
||||||
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
|
||||||
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(workflowFooter());
|
|
||||||
|
|
||||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
|
||||||
673
.github/scripts/backend-memory-report.mts
vendored
Normal file
673
.github/scripts/backend-memory-report.mts
vendored
Normal file
|
|
@ -0,0 +1,673 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import * as util from './utility.mts';
|
||||||
|
import type { MemoryReport } from './measure-backend-memory-comparison.mts';
|
||||||
|
|
||||||
|
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
|
||||||
|
|
||||||
|
type RuntimeLoadedJsFootprintReport = {
|
||||||
|
phases: Record<'afterRequest', {
|
||||||
|
totals: {
|
||||||
|
loadedJsModules: number;
|
||||||
|
loadedJsSourceBytes: number;
|
||||||
|
loadedJsGzipBytes: number;
|
||||||
|
astNodeCount: number;
|
||||||
|
functionCount: number;
|
||||||
|
classCount: number;
|
||||||
|
stringLiteralBytes: number;
|
||||||
|
externalPackageCount: number;
|
||||||
|
nativeAddonPackageCount: number;
|
||||||
|
};
|
||||||
|
modules: {
|
||||||
|
path: string;
|
||||||
|
package: string;
|
||||||
|
category: string;
|
||||||
|
sourceBytes: number;
|
||||||
|
gzipBytes: number;
|
||||||
|
astNodeCount: number;
|
||||||
|
functionCount: number;
|
||||||
|
classCount: number;
|
||||||
|
stringLiteralBytes: number;
|
||||||
|
}[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryReportPhases = [
|
||||||
|
{
|
||||||
|
key: 'afterGc',
|
||||||
|
title: 'After GC',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
'HeapUsed',
|
||||||
|
'Pss',
|
||||||
|
'Private_Dirty',
|
||||||
|
'VmRSS',
|
||||||
|
'External',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const heapSnapshotCategoriesColors = {
|
||||||
|
'Total': 'gray',
|
||||||
|
'Code': 'orange',
|
||||||
|
'Strings': 'red',
|
||||||
|
'JS arrays': 'cyan',
|
||||||
|
'Typed arrays': 'green',
|
||||||
|
'System objects': 'yellow',
|
||||||
|
'Other JS objects': 'violet',
|
||||||
|
'Other non-JS objects': 'pink',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const heapSnapshotCategoriesColorsHex = {
|
||||||
|
'Total': '#888888',
|
||||||
|
'Code': '#f28e2c',
|
||||||
|
'Strings': '#e15759',
|
||||||
|
'JS arrays': '#76b7b2',
|
||||||
|
'Typed arrays': '#59a14f',
|
||||||
|
'System objects': '#edc949',
|
||||||
|
'Other JS objects': '#af7aa1',
|
||||||
|
'Other non-JS objects': '#ff9da7',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||||
|
if (valueKiB == null) return '-';
|
||||||
|
return `${util.formatNumber(valueKiB / 1024)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
return report.summary[phase].memoryUsage[metric];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
return sample.phases[phase].memoryUsage[metric];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||||
|
if (values.length < 2) return null;
|
||||||
|
|
||||||
|
const center = util.median(values);
|
||||||
|
return util.median(values.map(value => Math.abs(value - center)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSamplesByRound(report: MemoryReport) {
|
||||||
|
const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
|
||||||
|
if (!Array.isArray(report.samples)) return samplesByRound;
|
||||||
|
|
||||||
|
for (const sample of report.samples) {
|
||||||
|
if (sample.round <= 0) continue;
|
||||||
|
samplesByRound.set(sample.round, sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
return samplesByRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeltaMemory(diffKiB: number) {
|
||||||
|
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairedDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
const baseSamplesByRound = getSamplesByRound(base);
|
||||||
|
const headSamplesByRound = getSamplesByRound(head);
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
for (const [round, baseSample] of baseSamplesByRound) {
|
||||||
|
const headSample = headSamplesByRound.get(round);
|
||||||
|
if (headSample == null) continue;
|
||||||
|
|
||||||
|
const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
|
||||||
|
const headValue = getMemoryValueFromSample(headSample, phase, metric);
|
||||||
|
if (baseValue == null || headValue == null) continue;
|
||||||
|
|
||||||
|
values.push(headValue - baseValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
median: util.median(values),
|
||||||
|
mad: util.mad(values),
|
||||||
|
min: Math.min(...values),
|
||||||
|
max: Math.max(...values),
|
||||||
|
samples: values.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||||
|
const lines = [
|
||||||
|
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const metric of metrics) {
|
||||||
|
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, head, phase, metric);
|
||||||
|
const percent = summary.median * 100 / baseValue;
|
||||||
|
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||||
|
|
||||||
|
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
const baseValue = getMemoryValue(base, phase, metric);
|
||||||
|
const headValue = getMemoryValue(head, phase, metric);
|
||||||
|
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
||||||
|
|
||||||
|
return ((headValue - baseValue) * 100) / baseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
function measurementSummary(base, head) {
|
||||||
|
const baseCount = base?.sampleCount;
|
||||||
|
const headCount = head?.sampleCount;
|
||||||
|
const strategy = base?.comparison?.strategy;
|
||||||
|
if (baseCount == null || headCount == null) return null;
|
||||||
|
|
||||||
|
if (strategy === 'interleaved-pairs') {
|
||||||
|
const rounds = base?.comparison?.rounds ?? baseCount;
|
||||||
|
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
||||||
|
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
|
||||||
|
const delta = headValue - baseValue;
|
||||||
|
if (delta === 0) return formatter(0);
|
||||||
|
|
||||||
|
const sign = delta > 0 ? '+' : '-';
|
||||||
|
return `${sign}${formatter(Math.abs(delta))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||||
|
const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||||
|
const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heapSnapshotSankeyChildMinRatio = 0.3;
|
||||||
|
const heapSnapshotSankeyParentMinPercent = 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(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeapSnapshotSankeyChildLabel(label: string) {
|
||||||
|
return String(label).replace(/^[^:]+:\s*/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeapSnapshotSankey(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], title: string) {
|
||||||
|
const total = getHeapSnapshotCategoryValue(report, phase, 'Total');
|
||||||
|
if (total == null || total <= 0) return null;
|
||||||
|
|
||||||
|
function getHeapSnapshotBreakdownEntries(category: typeof util.heapSnapshotCategories[number]) {
|
||||||
|
const breakdown = report.summary[phase].heapSnapshot?.breakdowns?.[category];
|
||||||
|
if (breakdown == null || typeof breakdown !== 'object') return [];
|
||||||
|
|
||||||
|
return Object.entries(breakdown)
|
||||||
|
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||||
|
.toSorted((a, b) => b[1] - a[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = util.heapSnapshotCategories
|
||||||
|
.filter(category => category !== 'Total')
|
||||||
|
.map(category => {
|
||||||
|
const value = getHeapSnapshotCategoryValue(report, phase, category);
|
||||||
|
if (value == null || value <= 0) return null;
|
||||||
|
const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
|
||||||
|
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
|
||||||
|
const percent = (value * 100) / total;
|
||||||
|
const childEntries = [];
|
||||||
|
let otherPercent = 0;
|
||||||
|
|
||||||
|
if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) {
|
||||||
|
for (const [childName, childValue] of breakdownEntries) {
|
||||||
|
const childRatio = childValue / breakdownTotal;
|
||||||
|
const childPercent = percent * childRatio;
|
||||||
|
if (childRatio >= heapSnapshotSankeyChildMinRatio) {
|
||||||
|
childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]);
|
||||||
|
} else {
|
||||||
|
otherPercent += childPercent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
[title]: heapSnapshotCategoriesColorsHex.Total,
|
||||||
|
} as Record<string, string>;
|
||||||
|
for (const { category, childEntries } of categories) {
|
||||||
|
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
|
||||||
|
nodeColors[category] = categoryColor;
|
||||||
|
|
||||||
|
for (const [childName] of childEntries) {
|
||||||
|
nodeColors[childName] = categoryColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
...nodeColors,
|
||||||
|
'Other': '#888888',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}}%%`,
|
||||||
|
'sankey-beta',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { category, percent, childEntries } of categories) {
|
||||||
|
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(category)},${formatSankeyPercentValue(percent)}`);
|
||||||
|
|
||||||
|
for (const [childName, childPercent] of childEntries) {
|
||||||
|
lines.push(`${escapeCsvValue(category)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('</details>');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||||
|
const baseSamplesByRound = getSamplesByRound(base);
|
||||||
|
const headSamplesByRound = getSamplesByRound(head);
|
||||||
|
const values = [] as number[];
|
||||||
|
|
||||||
|
for (const [round, baseSample] of baseSamplesByRound) {
|
||||||
|
const headSample = headSamplesByRound.get(round);
|
||||||
|
if (headSample == null) continue;
|
||||||
|
|
||||||
|
const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
|
||||||
|
const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
|
||||||
|
if (baseValue == null || headValue == null) continue;
|
||||||
|
|
||||||
|
values.push(headValue - baseValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
median: util.median(values),
|
||||||
|
mad: util.mad(values),
|
||||||
|
min: Math.min(...values),
|
||||||
|
max: Math.max(...values),
|
||||||
|
samples: values.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||||
|
const lines = [
|
||||||
|
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total');
|
||||||
|
const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total');
|
||||||
|
|
||||||
|
function formatHeapSnapshotCategoryLabel(category: typeof heapSnapshotCategories[number], baseValue: number, headValue: number, baseTotal: number, headTotal: number) {
|
||||||
|
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
|
||||||
|
|
||||||
|
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
|
||||||
|
const headPercent = util.formatPercent((headValue * 100) / headTotal);
|
||||||
|
return `**${category}**<br>${basePercent} → ${headPercent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeapSnapshotSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||||
|
const values = report.samples
|
||||||
|
.map(sample => getHeapSnapshotCategoryValueFromSample(sample, phase, category))
|
||||||
|
.filter(value => Number.isFinite(value)) as number[];
|
||||||
|
if (values.length < 2) return null;
|
||||||
|
|
||||||
|
const center = util.median(values);
|
||||||
|
return util.median(values.map(value => Math.abs(value - center)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of util.heapSnapshotCategories) {
|
||||||
|
const baseValue = getHeapSnapshotCategoryValue(base, phase, category);
|
||||||
|
const headValue = getHeapSnapshotCategoryValue(head, phase, category);
|
||||||
|
if (baseValue == null || headValue == null) continue;
|
||||||
|
|
||||||
|
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
|
||||||
|
const headSpread = getHeapSnapshotSampleSpread(head, phase, category);
|
||||||
|
const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category);
|
||||||
|
const percent = summary.median * 100 / baseValue;
|
||||||
|
const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||||
|
const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal);
|
||||||
|
|
||||||
|
lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${util.formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : util.formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : util.formatBytes(summary.mad)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.min)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.max)} |`);
|
||||||
|
if (category === 'Total') {
|
||||||
|
lines.push('| | | | | | | |');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 2) return null;
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||||
|
const table = renderHeapSnapshotTable(base, head, 'afterGc');
|
||||||
|
if (table == null) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'### V8 Heap Snapshot Statistics',
|
||||||
|
'',
|
||||||
|
table,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const graph of [
|
||||||
|
renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
|
||||||
|
renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
|
||||||
|
]) {
|
||||||
|
if (graph == null) continue;
|
||||||
|
lines.push(graph);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
|
||||||
|
const value = report.phases[phase].totals[key];
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const metricRows = [
|
||||||
|
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
|
||||||
|
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
|
||||||
|
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
|
||||||
|
//['AST nodes', 'astNodeCount', util.formatNumber],
|
||||||
|
//['Functions', 'functionCount', util.formatNumber],
|
||||||
|
//['Classes', 'classCount', util.formatNumber],
|
||||||
|
//['String literals', 'stringLiteralBytes', util.formatBytes],
|
||||||
|
['External packages loaded', 'externalPackageCount', util.formatNumber],
|
||||||
|
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [title, key, formatter] of metricRows) {
|
||||||
|
const baseValue = getJsFootprintValue(base, 'afterRequest', key);
|
||||||
|
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||||
|
if (baseValue == null || headValue == null) continue;
|
||||||
|
|
||||||
|
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDelta(baseValue, headValue, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
function renderJsFootprintPhaseTable(base, head) {
|
||||||
|
const lines = [
|
||||||
|
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) {
|
||||||
|
const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules');
|
||||||
|
const headModules = getJsFootprintValue(head, phase, 'loadedJsModules');
|
||||||
|
const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes');
|
||||||
|
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
|
||||||
|
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
|
||||||
|
|
||||||
|
lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function packageMap(report: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const packageSummary of report.phases.afterRequest.packages) {
|
||||||
|
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
|
||||||
|
map.set(packageSummary.name, packageSummary);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
|
||||||
|
if (packageSummary.version == null) return packageSummary.name;
|
||||||
|
return `${packageSummary.name} ${packageSummary.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const basePackages = packageMap(base);
|
||||||
|
const headPackages = packageMap(head);
|
||||||
|
const newPackages = [...headPackages.values()]
|
||||||
|
.filter(packageSummary => !basePackages.has(packageSummary.name))
|
||||||
|
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (newPackages.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'#### Newly Loaded External Packages',
|
||||||
|
'',
|
||||||
|
'| Package | Loaded JS | Modules | Notes |',
|
||||||
|
'| --- | ---: | ---: | --- |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const packageSummary of newPackages) {
|
||||||
|
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const basePackages = packageMap(base);
|
||||||
|
const headPackages = packageMap(head);
|
||||||
|
const increases = [...headPackages.values()]
|
||||||
|
.map(headPackage => {
|
||||||
|
const basePackage = basePackages.get(headPackage.name);
|
||||||
|
const baseSourceBytes = basePackage?.sourceBytes ?? 0;
|
||||||
|
const baseModules = basePackage?.modules ?? 0;
|
||||||
|
return {
|
||||||
|
...headPackage,
|
||||||
|
baseSourceBytes,
|
||||||
|
baseModules,
|
||||||
|
sourceDiff: headPackage.sourceBytes - baseSourceBytes,
|
||||||
|
moduleDiff: headPackage.modules - baseModules,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(packageSummary => packageSummary.sourceDiff > 0)
|
||||||
|
.toSorted((a, b) => b.sourceDiff - a.sourceDiff)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (increases.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'#### Largest Package Increases',
|
||||||
|
'',
|
||||||
|
'| Package | Base | Head | Δ | Modules Δ |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const packageSummary of increases) {
|
||||||
|
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${formatPlainDelta(packageSummary.baseSourceBytes, packageSummary.sourceBytes, util.formatBytes)} | ${formatPlainDelta(packageSummary.baseModules, packageSummary.modules)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||||
|
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const moduleSummary of report.phases.afterRequest.modules) {
|
||||||
|
if (typeof moduleSummary.path !== 'string') continue;
|
||||||
|
map.set(moduleSummary.path, moduleSummary);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseModules = moduleMap(base);
|
||||||
|
const headModules = moduleMap(head);
|
||||||
|
const newModules = [...headModules.values()]
|
||||||
|
.filter(moduleSummary => !baseModules.has(moduleSummary.path))
|
||||||
|
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (newModules.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'#### Largest Newly Loaded Modules',
|
||||||
|
'',
|
||||||
|
'| Module | Package | Loaded JS |',
|
||||||
|
'| --- | --- | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const moduleSummary of newModules) {
|
||||||
|
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||||
|
const lines = [
|
||||||
|
'### Runtime Loaded JS Footprint',
|
||||||
|
'',
|
||||||
|
'<details><summary>Click to show</summary>',
|
||||||
|
'',
|
||||||
|
renderJsFootprintMetricTable(base, head),
|
||||||
|
'',
|
||||||
|
//'#### Load Phase Breakdown',
|
||||||
|
//'',
|
||||||
|
//renderJsFootprintPhaseTable(base, head),
|
||||||
|
//'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const block of [
|
||||||
|
renderNewExternalPackages(base, head),
|
||||||
|
renderLargestPackageIncreases(base, head),
|
||||||
|
renderNewLoadedModules(base, head),
|
||||||
|
]) {
|
||||||
|
if (block == null) continue;
|
||||||
|
lines.push(block);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('</details>');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||||
|
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||||
|
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||||
|
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||||
|
const lines = [
|
||||||
|
'## ⚙️ Backend Memory Usage Report',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
//const summary = measurementSummary(base, head);
|
||||||
|
//if (summary != null) {
|
||||||
|
// lines.push(summary);
|
||||||
|
// lines.push('');
|
||||||
|
//}
|
||||||
|
|
||||||
|
for (const phase of memoryReportPhases) {
|
||||||
|
lines.push(`### ${phase.title}`);
|
||||||
|
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||||
|
if (heapSnapshotSection != null) {
|
||||||
|
lines.push(heapSnapshotSection);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
|
||||||
|
if (jsFootprintSection != null) {
|
||||||
|
lines.push(jsFootprintSection);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWarningMetric(base: MemoryReport, head: MemoryReport) {
|
||||||
|
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) {
|
||||||
|
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
|
||||||
|
return metric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||||
|
const baseValue = getMemoryValue(base, phase, metric);
|
||||||
|
const headValue = getMemoryValue(head, phase, metric);
|
||||||
|
if (baseValue == null || headValue == null) return false;
|
||||||
|
|
||||||
|
const diff = headValue - baseValue;
|
||||||
|
if (diff <= 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 diff > combinedSpread * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningMetric = getWarningMetric(base, head);
|
||||||
|
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||||
|
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||||
|
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
|
||||||
|
|
||||||
|
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||||
|
|
@ -5,129 +5,54 @@
|
||||||
|
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import * as util from './utility.mts';
|
||||||
|
|
||||||
const marker = '<!-- misskey-frontend-js-size -->';
|
const marker = '<!-- misskey-frontend-js-size -->';
|
||||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
|
|
||||||
const byteFormatter = new Intl.NumberFormat('en-US');
|
|
||||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
|
||||||
|
|
||||||
function normalizePath(filePath) {
|
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
|
||||||
return filePath.split(path.sep).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
//function sharePercent(value, total) {
|
||||||
try {
|
// if (total === 0) return '0%';
|
||||||
await fs.access(filePath);
|
// return Math.round((value / total) * 100) + '%';
|
||||||
return true;
|
//}
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileSize(filePath) {
|
function escapeCell(value: string) {
|
||||||
const stat = await fs.stat(filePath);
|
|
||||||
return stat.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* walk(dir) {
|
|
||||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
yield* walk(fullPath);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
yield fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
return numberFormatter.format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value) {
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
|
||||||
|
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
|
||||||
let unitIndex = 0;
|
|
||||||
let size = value;
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
|
||||||
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeLatex(text) {
|
|
||||||
return text
|
|
||||||
.replaceAll('\\', '\\\\')
|
|
||||||
.replaceAll('{', '\\{')
|
|
||||||
.replaceAll('}', '\\}')
|
|
||||||
.replaceAll('%', '\\%');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatColoredDiff(text, diff) {
|
|
||||||
if (diff === 0) return text;
|
|
||||||
const color = diff > 0 ? 'orange' : 'green';
|
|
||||||
const sign = diff > 0 ? '+' : '-';
|
|
||||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumberDiff(before, after) {
|
|
||||||
if (before == null || after == null) return '-';
|
|
||||||
const diff = after - before;
|
|
||||||
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytesDiff(before, after) {
|
|
||||||
if (before == null || after == null) return '-';
|
|
||||||
const diff = after - before;
|
|
||||||
if (diff === 0) return '0 B';
|
|
||||||
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiffPercent(before, after) {
|
|
||||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
|
||||||
const diff = after - before;
|
|
||||||
if (diff === 0) return `0%`;
|
|
||||||
const percent = Math.round(diff / before * 100);
|
|
||||||
return formatColoredDiff(`${percent}%`, diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sharePercent(value, total) {
|
|
||||||
if (total === 0) return '0%';
|
|
||||||
return Math.round((value / total) * 100) + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeCell(value) {
|
|
||||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableCell(value) {
|
//function tableCell(value) {
|
||||||
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||||
}
|
//}
|
||||||
|
|
||||||
function code(value) {
|
//function code(value) {
|
||||||
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||||
const backtickRuns = sanitized.match(/`+/g) ?? [];
|
// const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||||
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||||
const fence = '`'.repeat(fenceLength);
|
// const fence = '`'.repeat(fenceLength);
|
||||||
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
// const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||||
|
//
|
||||||
|
// return `${fence}${padding}${sanitized}${padding}${fence}`;
|
||||||
|
//}
|
||||||
|
|
||||||
return `${fence}${padding}${sanitized}${padding}${fence}`;
|
//function tableCode(value) {
|
||||||
}
|
// return tableCell(code(value));
|
||||||
|
//}
|
||||||
|
|
||||||
function tableCode(value) {
|
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||||
return tableCell(code(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryDisplayName(entry) {
|
type FileEntry = {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
file: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function entryDisplayName(entry: FileEntry) {
|
||||||
if (!entry) return '';
|
if (!entry) return '';
|
||||||
return entry.displayName || entry.file;
|
return entry.displayName || entry.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findEntryKey(manifest) {
|
function findEntryKey(manifest: Manifest) {
|
||||||
const entries = Object.entries(manifest);
|
const entries = Object.entries(manifest);
|
||||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||||
|
|
@ -135,16 +60,16 @@ function findEntryKey(manifest) {
|
||||||
?? null;
|
?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableChunkKey(manifestKey, chunk) {
|
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
|
||||||
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
|
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectStartupKeys(manifest) {
|
function collectStartupKeys(manifest: Manifest) {
|
||||||
const entryKey = findEntryKey(manifest);
|
const entryKey = findEntryKey(manifest);
|
||||||
const keys = new Set();
|
const keys = new Set<string>();
|
||||||
if (entryKey == null) return keys;
|
if (entryKey == null) return keys;
|
||||||
|
|
||||||
function visit(key) {
|
function visit(key: string) {
|
||||||
if (keys.has(key)) return;
|
if (keys.has(key)) return;
|
||||||
const chunk = manifest[key];
|
const chunk = manifest[key];
|
||||||
if (!chunk || !chunk.file?.endsWith('.js')) return;
|
if (!chunk || !chunk.file?.endsWith('.js')) return;
|
||||||
|
|
@ -158,11 +83,11 @@ function collectStartupKeys(manifest) {
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBuiltFile(outDir, file) {
|
async function resolveBuiltFile(outDir: string, file: string) {
|
||||||
if (file.startsWith('scripts/')) {
|
if (file.startsWith('scripts/')) {
|
||||||
const localizedFile = file.slice('scripts/'.length);
|
const localizedFile = file.slice('scripts/'.length);
|
||||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||||
if (await exists(localizedPath)) {
|
if (await util.fileExists(localizedPath)) {
|
||||||
return {
|
return {
|
||||||
absolutePath: localizedPath,
|
absolutePath: localizedPath,
|
||||||
relativePath: `${locale}/${localizedFile}`,
|
relativePath: `${locale}/${localizedFile}`,
|
||||||
|
|
@ -177,17 +102,17 @@ async function resolveBuiltFile(outDir, file) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectReport(repoDir) {
|
async function collectReport(repoDir: string) {
|
||||||
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
||||||
const manifestPath = path.join(outDir, 'manifest.json');
|
const manifestPath = path.join(outDir, 'manifest.json');
|
||||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
|
||||||
const byKey = new Map();
|
const byKey = new Map<string, FileEntry>();
|
||||||
const byFile = new Set();
|
const byFile = new Set<string>();
|
||||||
|
|
||||||
for (const [key, chunk] of Object.entries(manifest)) {
|
for (const [key, chunk] of Object.entries(manifest)) {
|
||||||
if (!chunk.file?.endsWith('.js')) continue;
|
if (!chunk.file?.endsWith('.js')) continue;
|
||||||
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
||||||
const size = await fileSize(builtFile.absolutePath);
|
const size = await util.fileSize(builtFile.absolutePath);
|
||||||
const stableKey = stableChunkKey(key, chunk);
|
const stableKey = stableChunkKey(key, chunk);
|
||||||
const displayName = chunk.src ?? chunk.name ?? key;
|
const displayName = chunk.src ?? chunk.name ?? key;
|
||||||
byKey.set(stableKey, {
|
byKey.set(stableKey, {
|
||||||
|
|
@ -200,12 +125,12 @@ async function collectReport(repoDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeDir = path.join(outDir, locale);
|
const localeDir = path.join(outDir, locale);
|
||||||
if (await exists(localeDir)) {
|
if (await util.fileExists(localeDir)) {
|
||||||
for await (const fullPath of walk(localeDir)) {
|
for await (const fullPath of util.traverseDirectory(localeDir)) {
|
||||||
if (!fullPath.endsWith('.js')) continue;
|
if (!fullPath.endsWith('.js')) continue;
|
||||||
const relativePath = normalizePath(path.relative(outDir, fullPath));
|
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
|
||||||
if (byFile.has(relativePath)) continue;
|
if (byFile.has(relativePath)) continue;
|
||||||
const size = await fileSize(fullPath);
|
const size = await util.fileSize(fullPath);
|
||||||
byKey.set(relativePath, {
|
byKey.set(relativePath, {
|
||||||
key: relativePath,
|
key: relativePath,
|
||||||
displayName: relativePath,
|
displayName: relativePath,
|
||||||
|
|
@ -222,7 +147,28 @@ async function collectReport(repoDir) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectVisualizerReport(data) {
|
type VisualizerReport = {
|
||||||
|
nodeParts?: Record<string, {
|
||||||
|
renderedLength: number;
|
||||||
|
gzipLength: number;
|
||||||
|
brotliLength: number;
|
||||||
|
}>;
|
||||||
|
nodeMetas?: Record<string, {
|
||||||
|
id: string;
|
||||||
|
isEntry?: boolean;
|
||||||
|
isExternal?: boolean;
|
||||||
|
importedBy?: string[];
|
||||||
|
imported?: { id: string; dynamic?: boolean }[];
|
||||||
|
moduleParts?: Record<string, string>;
|
||||||
|
renderedLength: number;
|
||||||
|
gzipLength: number;
|
||||||
|
brotliLength: number;
|
||||||
|
}>;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function collectVisualizerReport(data: VisualizerReport) {
|
||||||
const nodeParts = data.nodeParts ?? {};
|
const nodeParts = data.nodeParts ?? {};
|
||||||
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||||
const moduleRows = [];
|
const moduleRows = [];
|
||||||
|
|
@ -304,7 +250,7 @@ function collectVisualizerReport(data) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVisualizerSummaryTable(before, after) {
|
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||||
const summary = [
|
const summary = [
|
||||||
'bundles',
|
'bundles',
|
||||||
'modules',
|
'modules',
|
||||||
|
|
@ -312,13 +258,13 @@ function renderVisualizerSummaryTable(before, after) {
|
||||||
//'externals',
|
//'externals',
|
||||||
'staticImports',
|
'staticImports',
|
||||||
'dynamicImports',
|
'dynamicImports',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
'renderedLength',
|
'renderedLength',
|
||||||
'gzipLength',
|
'gzipLength',
|
||||||
'brotliLength',
|
'brotliLength',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`<table>`,
|
`<table>`,
|
||||||
|
|
@ -342,31 +288,31 @@ function renderVisualizerSummaryTable(before, after) {
|
||||||
`<tbody>`,
|
`<tbody>`,
|
||||||
`<tr>`,
|
`<tr>`,
|
||||||
`<th><b>Before</b></th>`,
|
`<th><b>Before</b></th>`,
|
||||||
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
|
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||||
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
|
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||||
`</tr>`,
|
`</tr>`,
|
||||||
`<tr>`,
|
`<tr>`,
|
||||||
`<th><b>After</b></th>`,
|
`<th><b>After</b></th>`,
|
||||||
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
|
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||||
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
|
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||||
`</tr>`,
|
`</tr>`,
|
||||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||||
`<tr>`,
|
`<tr>`,
|
||||||
`<th><b>Δ</b></th>`,
|
`<th><b>Δ</b></th>`,
|
||||||
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
|
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
|
||||||
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
|
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
|
||||||
`</tr>`,
|
`</tr>`,
|
||||||
`<tr>`,
|
`<tr>`,
|
||||||
`<th><b>Δ (%)</b></th>`,
|
`<th><b>Δ (%)</b></th>`,
|
||||||
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
|
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
|
||||||
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
|
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||||
`</tr>`,
|
`</tr>`,
|
||||||
`</tbody>`,
|
`</tbody>`,
|
||||||
`</table>`,
|
`</table>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChunkComparisonRows(keys, before, after) {
|
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||||
return keys.map((key) => {
|
return keys.map((key) => {
|
||||||
const beforeEntry = before.chunks[key];
|
const beforeEntry = before.chunks[key];
|
||||||
const afterEntry = after.chunks[key];
|
const afterEntry = after.chunks[key];
|
||||||
|
|
@ -384,7 +330,7 @@ function getChunkComparisonRows(keys, before, after) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeChunkChanges(rows) {
|
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
|
||||||
return {
|
return {
|
||||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||||
added: rows.filter((row) => row.changeType === 'added').length,
|
added: rows.filter((row) => row.changeType === 'added').length,
|
||||||
|
|
@ -392,18 +338,18 @@ function summarizeChunkChanges(rows) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChunkChangeSummary(label, summary) {
|
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
|
||||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareChunkComparisonRows(a, b) {
|
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
|
||||||
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
||||||
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
||||||
|| b.sortSize - a.sortSize
|
|| b.sortSize - a.sortSize
|
||||||
|| a.name.localeCompare(b.name);
|
|| a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunkMarkdownTable(rows, total) {
|
function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
|
||||||
if (rows.length === 0) return '_No data_';
|
if (rows.length === 0) return '_No data_';
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|
@ -411,22 +357,22 @@ function chunkMarkdownTable(rows, total) {
|
||||||
'| --- | ---: | ---: | ---: | ---: |',
|
'| --- | ---: | ---: | ---: | ---: |',
|
||||||
];
|
];
|
||||||
if (total != null) {
|
if (total != null) {
|
||||||
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.beforeSize, total.afterSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||||
lines.push('| | | | | |');
|
lines.push('| | | | | |');
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.changeType === 'added') {
|
if (row.changeType === 'added') {
|
||||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
||||||
} else if (row.changeType === 'removed') {
|
} else if (row.changeType === 'removed') {
|
||||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFrontendChunkReport(before, after) {
|
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||||
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
|
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
|
||||||
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
|
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
|
||||||
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
|
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
|
||||||
|
|
@ -489,7 +435,7 @@ function renderFrontendChunkReport(before, after) {
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFrontendBundleReport(before, after) {
|
function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||||
const lines = [
|
const lines = [
|
||||||
...renderVisualizerSummaryTable(before, after),
|
...renderVisualizerSummaryTable(before, after),
|
||||||
'',
|
'',
|
||||||
|
|
@ -530,23 +476,93 @@ function renderFrontendBundleReport(before, after) {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visualizerTreemapLimit = 50;
|
||||||
|
|
||||||
|
function mermaidTreemapLabel(value: string) {
|
||||||
|
const label = String(value)
|
||||||
|
.replaceAll('\\', '/')
|
||||||
|
.replaceAll('"', "'")
|
||||||
|
.replaceAll('`', "'")
|
||||||
|
.replaceAll('\r', ' ')
|
||||||
|
.replaceAll('\n', ' ')
|
||||||
|
.trim();
|
||||||
|
return label === '' ? '(unknown)' : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mermaidTreemapModuleLabel(id: string) {
|
||||||
|
const normalizedId = String(id).replaceAll('\\', '/');
|
||||||
|
const filePath = normalizedId.split(/[?#]/, 1)[0];
|
||||||
|
const fileName = path.posix.basename(filePath);
|
||||||
|
return mermaidTreemapLabel(fileName || normalizedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
|
||||||
|
const rows = report.hotModules
|
||||||
|
.filter((row) => row.renderedLength > 0)
|
||||||
|
.slice(0, visualizerTreemapLimit);
|
||||||
|
const topRendered = rows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||||
|
const otherRendered = Math.max(0, report.metrics.renderedLength - topRendered);
|
||||||
|
const lines = [
|
||||||
|
'```mermaid',
|
||||||
|
`%%{init: ${JSON.stringify({
|
||||||
|
treemap: {
|
||||||
|
diagramPadding: 0,
|
||||||
|
padding: 0,
|
||||||
|
nodeHeight: 70,
|
||||||
|
},
|
||||||
|
})}}%%`,
|
||||||
|
'treemap-beta',
|
||||||
|
`"${mermaidTreemapLabel(label)}"`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(` "${mermaidTreemapModuleLabel(row.id)}": ${Math.round(row.renderedLength)}`);
|
||||||
|
}
|
||||||
|
if (otherRendered > 0) {
|
||||||
|
lines.push(` "Other": ${Math.round(otherRendered)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('```');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
|
||||||
|
return [
|
||||||
|
`<details${open ? ' open' : ''}>`,
|
||||||
|
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
|
||||||
|
'',
|
||||||
|
renderVisualizerTreemap(label, report),
|
||||||
|
'',
|
||||||
|
'</details>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
||||||
const before = await collectReport(beforeDir);
|
const before = await collectReport(beforeDir);
|
||||||
const after = await collectReport(afterDir);
|
const after = await collectReport(afterDir);
|
||||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
|
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
|
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||||
|
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||||
|
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||||
|
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||||
|
|
||||||
const body = [
|
const body = [
|
||||||
marker,
|
marker,
|
||||||
'',
|
'',
|
||||||
`## Frontend Bundle Report`,
|
`## 📦 Frontend Bundle Report`,
|
||||||
'',
|
'',
|
||||||
renderFrontendChunkReport(before, after),
|
renderFrontendChunkReport(before, after),
|
||||||
'',
|
'',
|
||||||
'## Bundle Stats',
|
'## Bundle Stats',
|
||||||
'',
|
'',
|
||||||
renderFrontendBundleReport(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
|
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
|
||||||
|
'',
|
||||||
|
renderVisualizerTreemapDetails('Before', beforeVisualizerReport),
|
||||||
|
'',
|
||||||
|
renderVisualizerTreemapDetails('After', afterVisualizerReport),
|
||||||
|
'',
|
||||||
|
visualizerArtifactLink,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
await fs.writeFile(outFile, body);
|
await fs.writeFile(outFile, body);
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { writeFile } from 'node:fs/promises';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
|
|
||||||
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
|
|
||||||
|
|
||||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
|
|
||||||
console.error('Usage: node .github/scripts/measure-backend-memory-comparison.mjs <base-dir> <head-dir> <base-output.json> <head-output.json>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIntegerEnv(name, defaultValue, min) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandName(command) {
|
|
||||||
if (process.platform !== 'win32') return command;
|
|
||||||
if (command === 'pnpm') return 'pnpm.cmd';
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
|
||||||
return new Promise((resolvePromise, reject) => {
|
|
||||||
const child = spawn(commandName(command), args, {
|
|
||||||
cwd: options.cwd,
|
|
||||||
env: options.env,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', data => {
|
|
||||||
stdout += data;
|
|
||||||
if (options.logStdout) process.stderr.write(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', data => {
|
|
||||||
stderr += data;
|
|
||||||
process.stderr.write(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', reject);
|
|
||||||
|
|
||||||
child.on('close', code => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolvePromise(stdout);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetState(repoDir) {
|
|
||||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
|
||||||
const pg = require('pg');
|
|
||||||
const Redis = require('ioredis');
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function median(values) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeSamples(samples) {
|
|
||||||
const summary = {};
|
|
||||||
|
|
||||||
for (const phase of phases) {
|
|
||||||
summary[phase] = {};
|
|
||||||
|
|
||||||
const metricKeys = new Set();
|
|
||||||
for (const sample of samples) {
|
|
||||||
for (const key of Object.keys(sample[phase] ?? {})) {
|
|
||||||
metricKeys.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of metricKeys) {
|
|
||||||
const values = samples
|
|
||||||
.map(sample => sample[phase]?.[key])
|
|
||||||
.filter(value => Number.isFinite(value));
|
|
||||||
|
|
||||||
if (values.length > 0) summary[phase][key] = median(values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureRepo(label, repoDir, round, orderIndex) {
|
|
||||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
|
||||||
await resetState(repoDir);
|
|
||||||
|
|
||||||
process.stderr.write(`[${label}] Running migrations\n`);
|
|
||||||
await run('pnpm', ['--filter', 'backend', 'migrate'], {
|
|
||||||
cwd: repoDir,
|
|
||||||
env: process.env,
|
|
||||||
logStdout: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
|
||||||
const stdout = await run('node', ['packages/backend/scripts/measure-memory.mjs'], {
|
|
||||||
cwd: repoDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
MK_MEMORY_SAMPLE_COUNT: '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const report = JSON.parse(stdout);
|
|
||||||
const sample = report.samples?.[0] ?? {
|
|
||||||
timestamp: report.timestamp,
|
|
||||||
beforeGc: report.beforeGc,
|
|
||||||
afterGc: report.afterGc,
|
|
||||||
afterRequest: report.afterRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...sample,
|
|
||||||
label,
|
|
||||||
round,
|
|
||||||
orderIndex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const baseDir = resolve(baseDirArg);
|
|
||||||
const headDir = resolve(headDirArg);
|
|
||||||
const baseOutput = resolve(baseOutputArg);
|
|
||||||
const headOutput = resolve(headOutputArg);
|
|
||||||
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
|
||||||
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
|
||||||
const startedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
const repos = {
|
|
||||||
base: {
|
|
||||||
dir: baseDir,
|
|
||||||
samples: [],
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
dir: headDir,
|
|
||||||
samples: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let round = 1; round <= warmupRounds; round++) {
|
|
||||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
|
||||||
for (const label of ['base', 'head']) {
|
|
||||||
await measureRepo(label, repos[label].dir, -round, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let round = 1; round <= rounds; round++) {
|
|
||||||
const order = round % 2 === 1 ? ['base', 'head'] : ['head', 'base'];
|
|
||||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
|
||||||
|
|
||||||
for (const [orderIndex, label] of order.entries()) {
|
|
||||||
const sample = await measureRepo(label, repos[label].dir, round, orderIndex);
|
|
||||||
repos[label].samples.push(sample);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const label of ['base', 'head']) {
|
|
||||||
const report = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
sampleCount: repos[label].samples.length,
|
|
||||||
aggregation: 'median',
|
|
||||||
comparison: {
|
|
||||||
strategy: 'interleaved-pairs',
|
|
||||||
rounds,
|
|
||||||
warmupRounds,
|
|
||||||
startedAt,
|
|
||||||
},
|
|
||||||
...summarizeSamples(repos[label].samples),
|
|
||||||
samples: repos[label].samples,
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
262
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal file
262
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import * as util from './utility.mts';
|
||||||
|
import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts';
|
||||||
|
|
||||||
|
const phases = ['afterGc'] as const;
|
||||||
|
|
||||||
|
export type MemoryReport = {
|
||||||
|
timestamp: string;
|
||||||
|
sampleCount: any;
|
||||||
|
aggregation: string;
|
||||||
|
measurement: {
|
||||||
|
startupTimeoutMs: any;
|
||||||
|
memorySettleTimeMs: any;
|
||||||
|
ipcTimeoutMs: any;
|
||||||
|
requestCount: any;
|
||||||
|
heapSnapshot: {
|
||||||
|
enabled: any;
|
||||||
|
timeoutMs: any;
|
||||||
|
breakdownTopN: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
summary: Record<typeof phases[number], {
|
||||||
|
memoryUsage: Record<string, number>;
|
||||||
|
heapSnapshot?: {
|
||||||
|
categories: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||||
|
nodeCounts: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||||
|
breakdowns?: Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
samples: (MemoryReportRaw['samples'][number] & {
|
||||||
|
round: number;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||||
|
|
||||||
|
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||||
|
|
||||||
|
async function resetState(repoDir: string) {
|
||||||
|
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||||
|
const pg = require('pg');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
|
||||||
|
const breakdowns = {} as Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||||
|
|
||||||
|
for (const category of util.heapSnapshotCategories) {
|
||||||
|
if (category === 'Total') continue;
|
||||||
|
|
||||||
|
const childKeys = new Set<string>();
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
|
||||||
|
childKeys.add(childKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryBreakdown = {} as Record<string, number>;
|
||||||
|
for (const childKey of childKeys) {
|
||||||
|
const values = samples
|
||||||
|
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
|
||||||
|
.filter(value => Number.isFinite(value));
|
||||||
|
|
||||||
|
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(categoryBreakdown).length > 0) {
|
||||||
|
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdowns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
|
||||||
|
const entries = Object.entries(breakdown)
|
||||||
|
.filter(([, value]) => value > 0)
|
||||||
|
.toSorted((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||||
|
const otherValue = entries
|
||||||
|
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||||
|
.reduce((sum, [, value]) => sum + value, 0);
|
||||||
|
|
||||||
|
const collapsed = Object.fromEntries(topEntries);
|
||||||
|
if (otherValue > 0) collapsed.Other = otherValue;
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSamples(samples: MemoryReport['samples']) {
|
||||||
|
const summary = {} as MemoryReport['summary'];
|
||||||
|
|
||||||
|
for (const phase of phases) {
|
||||||
|
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] = util.median(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heapSnapshotCategoryValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||||
|
for (const category of util.heapSnapshotCategories) {
|
||||||
|
const values = samples
|
||||||
|
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
|
||||||
|
.filter(value => Number.isFinite(value)) as number[];
|
||||||
|
|
||||||
|
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heapSnapshotNodeCountValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||||
|
for (const category of util.heapSnapshotCategories) {
|
||||||
|
const values = samples
|
||||||
|
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
|
||||||
|
.filter(value => Number.isFinite(value)) as number[];
|
||||||
|
|
||||||
|
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
|
||||||
|
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
|
||||||
|
|
||||||
|
summary[phase].heapSnapshot = {
|
||||||
|
categories: heapSnapshotCategoryValues,
|
||||||
|
nodeCounts: heapSnapshotNodeCountValues,
|
||||||
|
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureRepo(label: string, repoDir: string, round: number) {
|
||||||
|
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||||
|
await resetState(repoDir);
|
||||||
|
|
||||||
|
process.stderr.write(`[${label}] Running migrations\n`);
|
||||||
|
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: process.env,
|
||||||
|
logStdout: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||||
|
const measureEnv = {
|
||||||
|
...process.env,
|
||||||
|
MK_MEMORY_SAMPLE_COUNT: '1',
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
|
||||||
|
|
||||||
|
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: measureEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = JSON.parse(stdout) as MemoryReportRaw;
|
||||||
|
const sample = report.samples[0];
|
||||||
|
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const baseDir = resolve(baseDirArg);
|
||||||
|
const headDir = resolve(headDirArg);
|
||||||
|
const baseOutput = resolve(baseOutputArg);
|
||||||
|
const headOutput = resolve(headOutputArg);
|
||||||
|
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||||
|
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const reports = {
|
||||||
|
base: {
|
||||||
|
dir: baseDir,
|
||||||
|
samples: [] as MemoryReport['samples'],
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
dir: 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 ['base', 'head'] as const) {
|
||||||
|
await measureRepo(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 [orderIndex, label] of order.entries()) {
|
||||||
|
const sample = await measureRepo(label, reports[label].dir, round);
|
||||||
|
reports[label].samples.push({
|
||||||
|
...sample,
|
||||||
|
round,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of ['base', 'head'] as const) {
|
||||||
|
const report = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
sampleCount: reports[label].samples.length,
|
||||||
|
aggregation: 'median',
|
||||||
|
comparison: {
|
||||||
|
strategy: 'interleaved-pairs',
|
||||||
|
rounds,
|
||||||
|
warmupRounds,
|
||||||
|
startedAt,
|
||||||
|
},
|
||||||
|
summary: summarizeSamples(reports[label].samples),
|
||||||
|
samples: reports[label].samples,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
179
.github/scripts/utility.mts
vendored
Normal file
179
.github/scripts/utility.mts
vendored
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const heapSnapshotCategories = [
|
||||||
|
'Total',
|
||||||
|
'Code',
|
||||||
|
'Strings',
|
||||||
|
'JS arrays',
|
||||||
|
'Typed arrays',
|
||||||
|
'System objects',
|
||||||
|
'Other JS objects',
|
||||||
|
'Other non-JS objects',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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) return null;
|
||||||
|
|
||||||
|
const center = median(values);
|
||||||
|
return median(values.map(value => Math.abs(value - center)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePath(filePath: string) {
|
||||||
|
return filePath.split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileExists(filePath: string) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileSize(filePath: string) {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
return stat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||||
|
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* traverseDirectory(fullPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
yield fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeLatex(text: string) {
|
||||||
|
return text
|
||||||
|
.replaceAll('\\', '\\\\')
|
||||||
|
.replaceAll('{', '\\{')
|
||||||
|
.replaceAll('}', '\\}')
|
||||||
|
.replaceAll('%', '\\%');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatColoredDelta(text: string, delta: number) {
|
||||||
|
if (delta === 0) return text;
|
||||||
|
const color = delta > 0 ? 'orange' : 'green';
|
||||||
|
const sign = delta > 0 ? '+' : '-';
|
||||||
|
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatNumber(value: number) {
|
||||||
|
return numberFormatter.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(value: number) {
|
||||||
|
if (value === 0) return '0 B';
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||||
|
let unitIndex = 0;
|
||||||
|
let size = value;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||||
|
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcAndFormatDeltaNumber(before: number, after: number) {
|
||||||
|
if (before == null || after == null) return '-';
|
||||||
|
const delta = after - before;
|
||||||
|
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeltaBytes(deltaBytes: number) {
|
||||||
|
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcAndFormatDeltaBytes(before: number, after: number) {
|
||||||
|
if (before == null || after == null) return '-';
|
||||||
|
const delta = after - before;
|
||||||
|
return formatDeltaBytes(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number) {
|
||||||
|
return `${formatNumber(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeltaPercent(deltaPercent: number) {
|
||||||
|
if (deltaPercent === 0) return '0%';
|
||||||
|
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcAndFormatDeltaPercent(before: number, after: number) {
|
||||||
|
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||||
|
const delta = after - before;
|
||||||
|
return formatDeltaPercent(delta / before * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandName(command: string) {
|
||||||
|
if (process.platform !== 'win32') return command;
|
||||||
|
if (command === 'pnpm') return 'pnpm.cmd';
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
|
||||||
|
return new Promise<string>((resolvePromise, reject) => {
|
||||||
|
const child = spawn(commandName(command), args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', data => {
|
||||||
|
stdout += data;
|
||||||
|
if (options.logStdout) process.stderr.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', data => {
|
||||||
|
stderr += data;
|
||||||
|
process.stderr.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolvePromise(stdout);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,8 @@ on:
|
||||||
- pnpm-lock.yaml
|
- pnpm-lock.yaml
|
||||||
- pnpm-workspace.yaml
|
- pnpm-workspace.yaml
|
||||||
- .node-version
|
- .node-version
|
||||||
- .github/scripts/frontend-js-size.mjs
|
- .github/scripts/utility.mts
|
||||||
|
- .github/scripts/frontend-js-size.mts
|
||||||
- .github/workflows/frontend-bundle-report.yml
|
- .github/workflows/frontend-bundle-report.yml
|
||||||
- .github/workflows/frontend-bundle-report-comment.yml
|
- .github/workflows/frontend-bundle-report-comment.yml
|
||||||
|
|
||||||
|
|
|
||||||
20
.github/workflows/frontend-bundle-report.yml
vendored
20
.github/workflows/frontend-bundle-report.yml
vendored
|
|
@ -20,7 +20,8 @@ on:
|
||||||
- pnpm-lock.yaml
|
- pnpm-lock.yaml
|
||||||
- pnpm-workspace.yaml
|
- pnpm-workspace.yaml
|
||||||
- .node-version
|
- .node-version
|
||||||
- .github/scripts/frontend-js-size.mjs
|
- .github/scripts/utility.mts
|
||||||
|
- .github/scripts/frontend-js-size.mts
|
||||||
- .github/workflows/frontend-bundle-report.yml
|
- .github/workflows/frontend-bundle-report.yml
|
||||||
- .github/workflows/frontend-bundle-report-comment.yml
|
- .github/workflows/frontend-bundle-report-comment.yml
|
||||||
|
|
||||||
|
|
@ -101,7 +102,6 @@ jobs:
|
||||||
working-directory: before
|
working-directory: before
|
||||||
env:
|
env:
|
||||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
|
||||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||||
run: pnpm --filter frontend run build
|
run: pnpm --filter frontend run build
|
||||||
|
|
||||||
|
|
@ -120,10 +120,21 @@ jobs:
|
||||||
working-directory: after
|
working-directory: after
|
||||||
env:
|
env:
|
||||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
|
||||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||||
|
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||||
run: pnpm --filter frontend run build
|
run: pnpm --filter frontend run build
|
||||||
|
|
||||||
|
- name: Upload bundle visualizer
|
||||||
|
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||||
|
id: upload-bundle-visualizer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: frontend-bundle-visualizer
|
||||||
|
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||||
|
if-no-files-found: error
|
||||||
|
archive: false
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Generate report markdown
|
- name: Generate report markdown
|
||||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -131,9 +142,10 @@ jobs:
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||||
run: |
|
run: |
|
||||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||||
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||||
|
|
|
||||||
16
.github/workflows/get-backend-memory.yml
vendored
16
.github/workflows/get-backend-memory.yml
vendored
|
|
@ -9,7 +9,12 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- packages/backend/**
|
- packages/backend/**
|
||||||
- packages/misskey-js/**
|
- packages/misskey-js/**
|
||||||
- .github/scripts/backend-memory-report.mjs
|
- .github/scripts/utility.mts
|
||||||
|
- .github/scripts/backend-memory-report.mts
|
||||||
|
- .github/scripts/measure-backend-memory-comparison.mts
|
||||||
|
- .github/scripts/backend-js-footprint.mjs
|
||||||
|
- .github/scripts/backend-js-footprint-loader.mjs
|
||||||
|
- .github/scripts/backend-js-footprint-require.cjs
|
||||||
- .github/workflows/get-backend-memory.yml
|
- .github/workflows/get-backend-memory.yml
|
||||||
- .github/workflows/report-backend-memory.yml
|
- .github/workflows/report-backend-memory.yml
|
||||||
|
|
||||||
|
|
@ -89,7 +94,12 @@ jobs:
|
||||||
env:
|
env:
|
||||||
MK_MEMORY_COMPARE_ROUNDS: 5
|
MK_MEMORY_COMPARE_ROUNDS: 5
|
||||||
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
|
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
|
||||||
run: node head/.github/scripts/measure-backend-memory-comparison.mjs base head memory-base.json memory-head.json
|
MK_MEMORY_HEAP_SNAPSHOT: 1
|
||||||
|
run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
|
||||||
|
- name: Measure backend loaded JS footprint
|
||||||
|
run: |
|
||||||
|
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json
|
||||||
|
node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
|
|
@ -97,6 +107,8 @@ jobs:
|
||||||
path: |
|
path: |
|
||||||
memory-base.json
|
memory-base.json
|
||||||
memory-head.json
|
memory-head.json
|
||||||
|
js-footprint-base.json
|
||||||
|
js-footprint-head.json
|
||||||
|
|
||||||
save-pr-number:
|
save-pr-number:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
6
.github/workflows/report-backend-memory.yml
vendored
6
.github/workflows/report-backend-memory.yml
vendored
|
|
@ -53,9 +53,13 @@ jobs:
|
||||||
run: cat ./artifacts/memory-base.json
|
run: cat ./artifacts/memory-base.json
|
||||||
- name: Output head
|
- name: Output head
|
||||||
run: cat ./artifacts/memory-head.json
|
run: cat ./artifacts/memory-head.json
|
||||||
|
- name: Output base JS footprint
|
||||||
|
run: cat ./artifacts/js-footprint-base.json
|
||||||
|
- name: Output head JS footprint
|
||||||
|
run: cat ./artifacts/js-footprint-head.json
|
||||||
- id: build-comment
|
- id: build-comment
|
||||||
name: Build memory comment
|
name: Build memory comment
|
||||||
run: node .github/scripts/backend-memory-report.mjs ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md
|
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
|
||||||
- uses: thollander/actions-comment-pull-request@v3
|
- uses: thollander/actions-comment-pull-request@v3
|
||||||
with:
|
with:
|
||||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||||
|
|
|
||||||
35
.github/workflows/test-backend.yml
vendored
35
.github/workflows/test-backend.yml
vendored
|
|
@ -19,12 +19,6 @@ on:
|
||||||
- .github/workflows/test-backend.yml
|
- .github/workflows/test-backend.yml
|
||||||
- .github/misskey/test.yml
|
- .github/misskey/test.yml
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
force_ffmpeg_cache_update:
|
|
||||||
description: 'Force update ffmpeg cache'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
|
|
@ -62,36 +56,9 @@ jobs:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v6.0.3
|
uses: pnpm/action-setup@v6.0.3
|
||||||
- name: Get current date
|
|
||||||
id: current-date
|
|
||||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
|
||||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
|
||||||
id: cache-ffmpeg
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
/usr/local/bin/ffmpeg
|
|
||||||
/usr/local/bin/ffprobe
|
|
||||||
# daily cache
|
|
||||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
|
||||||
- name: Install FFmpeg
|
- name: Install FFmpeg
|
||||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
|
||||||
run: |
|
run: |
|
||||||
for i in {1..3}; do
|
sudo apt install -y ffmpeg
|
||||||
echo "Attempt $i: Installing FFmpeg..."
|
|
||||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
|
||||||
tar -xf ffmpeg.tar.xz && \
|
|
||||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
|
||||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
|
||||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
|
||||||
break || sleep 10
|
|
||||||
if [ $i -eq 3 ]; then
|
|
||||||
echo "Failed to install FFmpeg after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6.4.0
|
uses: actions/setup-node@v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
35
.github/workflows/test-federation.yml
vendored
35
.github/workflows/test-federation.yml
vendored
|
|
@ -15,12 +15,6 @@ on:
|
||||||
- packages/misskey-js/**
|
- packages/misskey-js/**
|
||||||
- .github/workflows/test-federation.yml
|
- .github/workflows/test-federation.yml
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
force_ffmpeg_cache_update:
|
|
||||||
description: 'Force update ffmpeg cache'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -37,36 +31,9 @@ jobs:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v6.0.3
|
uses: pnpm/action-setup@v6.0.3
|
||||||
- name: Get current date
|
|
||||||
id: current-date
|
|
||||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
|
||||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
|
||||||
id: cache-ffmpeg
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
/usr/local/bin/ffmpeg
|
|
||||||
/usr/local/bin/ffprobe
|
|
||||||
# daily cache
|
|
||||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
|
||||||
- name: Install FFmpeg
|
- name: Install FFmpeg
|
||||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
|
||||||
run: |
|
run: |
|
||||||
for i in {1..3}; do
|
sudo apt install -y ffmpeg
|
||||||
echo "Attempt $i: Installing FFmpeg..."
|
|
||||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
|
||||||
tar -xf ffmpeg.tar.xz && \
|
|
||||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
|
||||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
|
||||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
|
||||||
break || sleep 10
|
|
||||||
if [ $i -eq 3 ]; then
|
|
||||||
echo "Failed to install FFmpeg after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v6.4.0
|
uses: actions/setup-node@v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
22.15.0
|
22.18.0
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
-
|
-
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
-
|
||||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
-
|
||||||
|
|
|
||||||
|
|
@ -1361,11 +1361,14 @@ information: "情報"
|
||||||
chat: "チャット"
|
chat: "チャット"
|
||||||
directMessage: "ダイレクトメッセージ"
|
directMessage: "ダイレクトメッセージ"
|
||||||
directMessage_short: "メッセージ"
|
directMessage_short: "メッセージ"
|
||||||
|
migrateOldSettings: "旧設定情報を移行"
|
||||||
|
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
|
||||||
compress: "圧縮"
|
compress: "圧縮"
|
||||||
right: "右"
|
right: "右"
|
||||||
bottom: "下"
|
bottom: "下"
|
||||||
top: "上"
|
top: "上"
|
||||||
embed: "埋め込み"
|
embed: "埋め込み"
|
||||||
|
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
|
||||||
readonly: "読み取り専用"
|
readonly: "読み取り専用"
|
||||||
goToDeck: "デッキへ戻る"
|
goToDeck: "デッキへ戻る"
|
||||||
federationJobs: "連合ジョブ"
|
federationJobs: "連合ジョブ"
|
||||||
|
|
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This script starts the Misskey backend server, waits for it to be ready,
|
|
||||||
* measures memory usage, and outputs the result as JSON.
|
|
||||||
*
|
|
||||||
* Usage: node scripts/measure-memory.mjs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { fork } from 'node:child_process';
|
|
||||||
import { setTimeout } from 'node:timers/promises';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import * as http from 'node:http';
|
|
||||||
import * as fs from 'node:fs/promises';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
function readIntegerEnv(name, defaultValue, min) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
|
|
||||||
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
|
|
||||||
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
|
|
||||||
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
|
|
||||||
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
|
|
||||||
|
|
||||||
const procStatusKeys = {
|
|
||||||
VmPeak: 0,
|
|
||||||
VmSize: 0,
|
|
||||||
VmHWM: 0,
|
|
||||||
VmRSS: 0,
|
|
||||||
VmData: 0,
|
|
||||||
VmStk: 0,
|
|
||||||
VmExe: 0,
|
|
||||||
VmLib: 0,
|
|
||||||
VmPTE: 0,
|
|
||||||
VmSwap: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const smapsRollupKeys = {
|
|
||||||
Pss: 0,
|
|
||||||
Shared_Clean: 0,
|
|
||||||
Shared_Dirty: 0,
|
|
||||||
Private_Clean: 0,
|
|
||||||
Private_Dirty: 0,
|
|
||||||
Swap: 0,
|
|
||||||
SwapPss: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtimeKeys = {
|
|
||||||
HeapTotal: 0,
|
|
||||||
HeapUsed: 0,
|
|
||||||
External: 0,
|
|
||||||
ArrayBuffers: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const memoryKeys = {
|
|
||||||
...procStatusKeys,
|
|
||||||
...smapsRollupKeys,
|
|
||||||
...runtimeKeys,
|
|
||||||
};
|
|
||||||
|
|
||||||
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
|
|
||||||
|
|
||||||
function parseMemoryFile(content, keys, path, required) {
|
|
||||||
const result = {};
|
|
||||||
for (const key of Object.keys(keys)) {
|
|
||||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
|
||||||
if (match) {
|
|
||||||
result[key] = parseInt(match[1], 10);
|
|
||||||
} else if (required) {
|
|
||||||
throw new Error(`Failed to parse ${key} from ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToKiB(value) {
|
|
||||||
return Math.round(value / 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMemoryUsage(pid) {
|
|
||||||
const path = `/proc/${pid}/status`;
|
|
||||||
const status = await fs.readFile(path, 'utf-8');
|
|
||||||
|
|
||||||
return parseMemoryFile(status, procStatusKeys, path, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSmapsRollupMemoryUsage(pid) {
|
|
||||||
const path = `/proc/${pid}/smaps_rollup`;
|
|
||||||
try {
|
|
||||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
|
||||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === 'ENOENT' || err.code === 'EACCES') {
|
|
||||||
process.stderr.write(`Failed to read ${path}: ${err.message}\n`);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = globalThis.setTimeout(() => {
|
|
||||||
serverProcess.off('message', onMessage);
|
|
||||||
reject(new Error(`Timed out waiting for ${description}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
const onMessage = (message) => {
|
|
||||||
if (!predicate(message)) return;
|
|
||||||
globalThis.clearTimeout(timer);
|
|
||||||
serverProcess.off('message', onMessage);
|
|
||||||
resolve(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
serverProcess.on('message', onMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRuntimeMemoryUsage(serverProcess) {
|
|
||||||
const response = waitForMessage(
|
|
||||||
serverProcess,
|
|
||||||
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
|
||||||
'memory usage',
|
|
||||||
);
|
|
||||||
|
|
||||||
serverProcess.send('memory usage');
|
|
||||||
|
|
||||||
const message = await response;
|
|
||||||
const memoryUsage = message.value;
|
|
||||||
|
|
||||||
return {
|
|
||||||
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
|
||||||
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
|
||||||
External: bytesToKiB(memoryUsage.external),
|
|
||||||
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAllMemoryUsage(serverProcess) {
|
|
||||||
const pid = serverProcess.pid;
|
|
||||||
return {
|
|
||||||
...await getMemoryUsage(pid),
|
|
||||||
...await getSmapsRollupMemoryUsage(pid),
|
|
||||||
...await getRuntimeMemoryUsage(serverProcess),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function median(values) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeResults(results) {
|
|
||||||
const summary = {};
|
|
||||||
|
|
||||||
for (const phase of phases) {
|
|
||||||
summary[phase] = {};
|
|
||||||
for (const key of Object.keys(memoryKeys)) {
|
|
||||||
const values = results
|
|
||||||
.map(result => result[phase][key])
|
|
||||||
.filter(value => Number.isFinite(value));
|
|
||||||
|
|
||||||
if (values.length > 0) {
|
|
||||||
summary[phase][key] = median(values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureMemory() {
|
|
||||||
// Start the Misskey backend server using fork to enable IPC
|
|
||||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
|
||||||
cwd: join(__dirname, '..'),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
MK_DISABLE_CLUSTERING: '1',
|
|
||||||
MK_ONLY_SERVER: '1',
|
|
||||||
MK_NO_DAEMONS: '1',
|
|
||||||
},
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
||||||
execArgv: [...process.execArgv, '--expose-gc'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let serverReady = false;
|
|
||||||
|
|
||||||
// Listen for the 'ok' message from the server indicating it's ready
|
|
||||||
serverProcess.on('message', (message) => {
|
|
||||||
if (message === 'ok') {
|
|
||||||
serverReady = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle server output
|
|
||||||
serverProcess.stdout?.on('data', (data) => {
|
|
||||||
process.stderr.write(`[server stdout] ${data}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
serverProcess.stderr?.on('data', (data) => {
|
|
||||||
process.stderr.write(`[server stderr] ${data}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle server error
|
|
||||||
serverProcess.on('error', (err) => {
|
|
||||||
process.stderr.write(`[server error] ${err}\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function triggerGc() {
|
|
||||||
const ok = waitForMessage(
|
|
||||||
serverProcess,
|
|
||||||
message => message === 'gc ok' || message === 'gc unavailable',
|
|
||||||
'GC completion',
|
|
||||||
);
|
|
||||||
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await setTimeout(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRequest() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.request({
|
|
||||||
host: 'localhost',
|
|
||||||
port: 61812,
|
|
||||||
path: '/api/meta',
|
|
||||||
method: 'POST',
|
|
||||||
}, (res) => {
|
|
||||||
res.on('data', () => { });
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for server to be ready or timeout
|
|
||||||
const startupStartTime = Date.now();
|
|
||||||
while (!serverReady) {
|
|
||||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
|
||||||
serverProcess.kill('SIGTERM');
|
|
||||||
throw new Error('Server startup timeout');
|
|
||||||
}
|
|
||||||
await setTimeout(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startupTime = Date.now() - startupStartTime;
|
|
||||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
|
||||||
|
|
||||||
// Wait for memory to settle
|
|
||||||
await setTimeout(MEMORY_SETTLE_TIME);
|
|
||||||
|
|
||||||
const beforeGc = await getAllMemoryUsage(serverProcess);
|
|
||||||
|
|
||||||
await triggerGc();
|
|
||||||
|
|
||||||
const afterGc = await getAllMemoryUsage(serverProcess);
|
|
||||||
|
|
||||||
// create some http requests to simulate load
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
|
||||||
);
|
|
||||||
|
|
||||||
await triggerGc();
|
|
||||||
|
|
||||||
const afterRequest = await getAllMemoryUsage(serverProcess);
|
|
||||||
|
|
||||||
// Stop the server
|
|
||||||
serverProcess.kill('SIGTERM');
|
|
||||||
|
|
||||||
// Wait for process to exit
|
|
||||||
let exited = false;
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
serverProcess.on('exit', () => {
|
|
||||||
exited = true;
|
|
||||||
resolve(undefined);
|
|
||||||
});
|
|
||||||
// Force kill after 10 seconds if not exited
|
|
||||||
setTimeout(10000).then(() => {
|
|
||||||
if (!exited) {
|
|
||||||
serverProcess.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
resolve(undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
beforeGc,
|
|
||||||
afterGc,
|
|
||||||
afterRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
|
||||||
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
|
|
||||||
const res = await measureMemory();
|
|
||||||
results.push(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = summarizeResults(results);
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
sampleCount: SAMPLE_COUNT,
|
|
||||||
aggregation: 'median',
|
|
||||||
measurement: {
|
|
||||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
|
||||||
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
|
||||||
ipcTimeoutMs: IPC_TIMEOUT,
|
|
||||||
requestCount: REQUEST_COUNT,
|
|
||||||
},
|
|
||||||
...summary,
|
|
||||||
samples: results,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Output as JSON to stdout
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(JSON.stringify({
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
545
packages/backend/scripts/measure-memory.mts
Normal file
545
packages/backend/scripts/measure-memory.mts
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChildProcess, fork } from 'node:child_process';
|
||||||
|
import { setTimeout } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
//import * as http from 'node:http';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
function readIntegerEnv(name, defaultValue, min) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanEnv(name, defaultValue) {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
|
||||||
|
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
|
||||||
|
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
|
||||||
|
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
|
||||||
|
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
|
||||||
|
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||||
|
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
|
||||||
|
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||||
|
|
||||||
|
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||||
|
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||||
|
|
||||||
|
const heapSnapshotCategories = [
|
||||||
|
'Code',
|
||||||
|
'Strings',
|
||||||
|
'JS arrays',
|
||||||
|
'Typed arrays',
|
||||||
|
'System objects',
|
||||||
|
'Other JS objects',
|
||||||
|
'Other non-JS objects',
|
||||||
|
'Total',
|
||||||
|
];
|
||||||
|
|
||||||
|
const typedArrayNames = new Set([
|
||||||
|
'ArrayBuffer',
|
||||||
|
'SharedArrayBuffer',
|
||||||
|
'DataView',
|
||||||
|
'Int8Array',
|
||||||
|
'Uint8Array',
|
||||||
|
'Uint8ClampedArray',
|
||||||
|
'Int16Array',
|
||||||
|
'Uint16Array',
|
||||||
|
'Int32Array',
|
||||||
|
'Uint32Array',
|
||||||
|
'Float16Array',
|
||||||
|
'Float32Array',
|
||||||
|
'Float64Array',
|
||||||
|
'BigInt64Array',
|
||||||
|
'BigUint64Array',
|
||||||
|
'system / JSArrayBufferData',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const otherJsNodeTypes = new Set([
|
||||||
|
'object',
|
||||||
|
'closure',
|
||||||
|
'regexp',
|
||||||
|
'number',
|
||||||
|
'symbol',
|
||||||
|
'bigint',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): 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 if (required) {
|
||||||
|
throw new Error(`Failed to parse ${key} from ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToKiB(value: number) {
|
||||||
|
return Math.round(value / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyHeapSnapshotCategoryMap() {
|
||||||
|
return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypedArrayNode(type, name) {
|
||||||
|
return typedArrayNames.has(name) ||
|
||||||
|
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemNode(type, name) {
|
||||||
|
return type === 'hidden' ||
|
||||||
|
type === 'synthetic' ||
|
||||||
|
type === 'object shape' ||
|
||||||
|
name.startsWith('system /') ||
|
||||||
|
name.startsWith('(system ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyHeapSnapshotNode(type, name) {
|
||||||
|
if (type === 'code') return 'Code';
|
||||||
|
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings';
|
||||||
|
if (isTypedArrayNode(type, name)) return 'Typed arrays';
|
||||||
|
if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays';
|
||||||
|
if (isSystemNode(type, name)) return 'System objects';
|
||||||
|
if (otherJsNodeTypes.has(type)) return 'Other JS objects';
|
||||||
|
return 'Other non-JS objects';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHeapSnapshotBreakdownLabel(value, 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)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyHeapSnapshotBreakdown(category, type, name) {
|
||||||
|
if (category === 'Strings') return type;
|
||||||
|
|
||||||
|
if (category === 'JS arrays') {
|
||||||
|
if (type === 'array') return 'array nodes';
|
||||||
|
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||||
|
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'Typed arrays') {
|
||||||
|
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||||
|
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
|
||||||
|
if (typedArrayNames.has(name)) return name;
|
||||||
|
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
|
||||||
|
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
|
||||||
|
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'System objects') {
|
||||||
|
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||||
|
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||||
|
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'Other JS objects') {
|
||||||
|
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'Other non-JS objects') {
|
||||||
|
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||||
|
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === '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 sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseHeapSnapshotBreakdown(breakdowns) {
|
||||||
|
const collapsed = {};
|
||||||
|
|
||||||
|
for (const [category, children] of Object.entries(breakdowns)) {
|
||||||
|
const entries = Object.entries(children)
|
||||||
|
.filter(([, value]) => value > 0)
|
||||||
|
.toSorted((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||||
|
const otherValue = entries
|
||||||
|
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||||
|
.reduce((sum, [, value]) => sum + value, 0);
|
||||||
|
|
||||||
|
const categoryBreakdown = Object.fromEntries(topEntries);
|
||||||
|
if (otherValue > 0) categoryBreakdown.Other = otherValue;
|
||||||
|
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeHeapSnapshot(snapshot) {
|
||||||
|
const meta = snapshot?.snapshot?.meta;
|
||||||
|
const nodes = snapshot?.nodes;
|
||||||
|
const strings = snapshot?.strings;
|
||||||
|
if (meta == null || !Array.isArray(nodes) || !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 typeOffset = nodeFields.indexOf('type');
|
||||||
|
const nameOffset = nodeFields.indexOf('name');
|
||||||
|
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||||
|
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
|
||||||
|
throw new Error('Heap snapshot is missing required node fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||||
|
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||||
|
|
||||||
|
const fieldCount = nodeFields.length;
|
||||||
|
const categories = createEmptyHeapSnapshotCategoryMap();
|
||||||
|
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
|
||||||
|
const breakdowns = Object.fromEntries(
|
||||||
|
heapSnapshotCategories
|
||||||
|
.filter(category => category !== 'Total')
|
||||||
|
.map(category => [category, {}]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||||
|
map[key] = (map[key] ?? 0) + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||||
|
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||||
|
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||||
|
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||||
|
const category = classifyHeapSnapshotNode(type, name);
|
||||||
|
|
||||||
|
categories[category] += selfSize;
|
||||||
|
categories.Total += selfSize;
|
||||||
|
nodeCounts[category]++;
|
||||||
|
nodeCounts.Total++;
|
||||||
|
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
nodeCounts,
|
||||||
|
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMemoryUsage(pid: number) {
|
||||||
|
const path = `/proc/${pid}/status`;
|
||||||
|
const status = await fs.readFile(path, 'utf-8');
|
||||||
|
return parseMemoryFile(status, procStatusKeys, path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSmapsRollupMemoryUsage(pid: number) {
|
||||||
|
const path = `/proc/${pid}/smaps_rollup`;
|
||||||
|
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||||
|
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = globalThis.setTimeout(() => {
|
||||||
|
serverProcess.off('message', onMessage);
|
||||||
|
reject(new Error(`Timed out waiting for ${description}`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const onMessage = (message) => {
|
||||||
|
if (!predicate(message)) return;
|
||||||
|
globalThis.clearTimeout(timer);
|
||||||
|
serverProcess.off('message', onMessage);
|
||||||
|
resolve(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
serverProcess.on('message', onMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
|
||||||
|
const response = waitForMessage(
|
||||||
|
serverProcess,
|
||||||
|
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
||||||
|
'memory usage',
|
||||||
|
);
|
||||||
|
|
||||||
|
serverProcess.send('memory usage');
|
||||||
|
|
||||||
|
const message = await response;
|
||||||
|
const memoryUsage = message.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
||||||
|
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
||||||
|
External: bytesToKiB(memoryUsage.external),
|
||||||
|
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
|
||||||
|
if (!HEAP_SNAPSHOT) return null;
|
||||||
|
|
||||||
|
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
|
||||||
|
const response = waitForMessage(
|
||||||
|
serverProcess,
|
||||||
|
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
|
||||||
|
'heap snapshot',
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||||
|
return analyzeHeapSnapshot(snapshot);
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(writtenPath).catch(err => {
|
||||||
|
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMemoryUsage(serverProcess: ChildProcess) {
|
||||||
|
const pid = serverProcess.pid!;
|
||||||
|
return {
|
||||||
|
...await getMemoryUsage(pid),
|
||||||
|
...await getSmapsRollupMemoryUsage(pid),
|
||||||
|
...await getRuntimeMemoryUsage(serverProcess),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureMemory() {
|
||||||
|
// Start the Misskey backend server using fork to enable IPC
|
||||||
|
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
||||||
|
cwd: join(__dirname, '..'),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
MK_DISABLE_CLUSTERING: '1',
|
||||||
|
MK_ONLY_SERVER: '1',
|
||||||
|
MK_NO_DAEMONS: '1',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||||
|
execArgv: [...process.execArgv, '--expose-gc'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let serverReady = false;
|
||||||
|
|
||||||
|
// Listen for the 'ok' message from the server indicating it's ready
|
||||||
|
serverProcess.on('message', (message) => {
|
||||||
|
if (message === 'ok') {
|
||||||
|
serverReady = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle server output
|
||||||
|
serverProcess.stdout?.on('data', (data) => {
|
||||||
|
process.stderr.write(`[server stdout] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.stderr?.on('data', (data) => {
|
||||||
|
process.stderr.write(`[server stderr] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle server error
|
||||||
|
serverProcess.on('error', (err) => {
|
||||||
|
process.stderr.write(`[server error] ${err}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function triggerGc() {
|
||||||
|
const ok = waitForMessage(
|
||||||
|
serverProcess,
|
||||||
|
message => message === 'gc ok' || message === 'gc unavailable',
|
||||||
|
'GC completion',
|
||||||
|
);
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
//function createRequest() {
|
||||||
|
// return new Promise((resolve, reject) => {
|
||||||
|
// const req = http.request({
|
||||||
|
// host: 'localhost',
|
||||||
|
// port: 61812,
|
||||||
|
// path: '/api/meta',
|
||||||
|
// method: 'POST',
|
||||||
|
// }, (res) => {
|
||||||
|
// res.on('data', () => { });
|
||||||
|
// res.on('end', () => {
|
||||||
|
// resolve();
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// req.on('error', (err) => {
|
||||||
|
// reject(err);
|
||||||
|
// });
|
||||||
|
// req.end();
|
||||||
|
// });
|
||||||
|
//}
|
||||||
|
|
||||||
|
// Wait for server to be ready or timeout
|
||||||
|
const startupStartTime = Date.now();
|
||||||
|
while (!serverReady) {
|
||||||
|
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||||
|
serverProcess.kill('SIGTERM');
|
||||||
|
throw new Error('Server startup timeout');
|
||||||
|
}
|
||||||
|
await setTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startupTime = Date.now() - startupStartTime;
|
||||||
|
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||||
|
|
||||||
|
// Wait for memory to settle
|
||||||
|
await setTimeout(MEMORY_SETTLE_TIME);
|
||||||
|
|
||||||
|
//const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||||
|
|
||||||
|
await triggerGc();
|
||||||
|
|
||||||
|
const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
|
||||||
|
|
||||||
|
//// create some http requests to simulate load
|
||||||
|
//await Promise.all(
|
||||||
|
// Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||||
|
//);
|
||||||
|
|
||||||
|
//await triggerGc();
|
||||||
|
|
||||||
|
//const afterRequest = await getAllMemoryUsage(serverProcess);
|
||||||
|
|
||||||
|
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
|
||||||
|
|
||||||
|
// Stop the server
|
||||||
|
serverProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
let exited = false;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
serverProcess.on('exit', () => {
|
||||||
|
exited = true;
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
// Force kill after 10 seconds if not exited
|
||||||
|
setTimeout(10000).then(() => {
|
||||||
|
if (!exited) {
|
||||||
|
serverProcess.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
phases: {
|
||||||
|
//beforeGc,
|
||||||
|
afterGc: {
|
||||||
|
memoryUsage: memoryUsageAfterGC,
|
||||||
|
heapSnapshot: heapSnapshotAfterGc,
|
||||||
|
},
|
||||||
|
//afterRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemoryReportRaw = {
|
||||||
|
timestamp: string;
|
||||||
|
sampleCount: number;
|
||||||
|
measurement: {
|
||||||
|
startupTimeoutMs: number;
|
||||||
|
memorySettleTimeMs: number;
|
||||||
|
ipcTimeoutMs: number;
|
||||||
|
requestCount: number;
|
||||||
|
heapSnapshot: {
|
||||||
|
enabled: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
breakdownTopN: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
samples: Awaited<ReturnType<typeof measureMemory>>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||||
|
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
|
||||||
|
const res = await measureMemory();
|
||||||
|
results.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: MemoryReportRaw = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
sampleCount: SAMPLE_COUNT,
|
||||||
|
measurement: {
|
||||||
|
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||||
|
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
||||||
|
ipcTimeoutMs: IPC_TIMEOUT,
|
||||||
|
requestCount: REQUEST_COUNT,
|
||||||
|
heapSnapshot: {
|
||||||
|
enabled: HEAP_SNAPSHOT,
|
||||||
|
timeoutMs: HEAP_SNAPSHOT_TIMEOUT,
|
||||||
|
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
samples: results,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output as JSON to stdout
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import cluster from 'node:cluster';
|
import cluster from 'node:cluster';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { writeHeapSnapshot } from 'node:v8';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import Xev from 'xev';
|
import Xev from 'xev';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
|
|
@ -106,6 +107,21 @@ process.on('message', msg => {
|
||||||
value: process.memoryUsage(),
|
value: process.memoryUsage(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (msg != null && typeof msg === 'object' && 'type' in msg && msg.type === 'heap snapshot' && 'path' in msg && typeof msg.path === 'string') {
|
||||||
|
if (process.send != null) {
|
||||||
|
try {
|
||||||
|
const path = writeHeapSnapshot(msg.path);
|
||||||
|
process.send({
|
||||||
|
type: 'heap snapshot',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
process.send({
|
||||||
|
type: 'heap snapshot error',
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { makeHotkey } from '@/utility/hotkey.js';
|
||||||
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||||
|
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||||
import { unisonReload } from '@/utility/unison-reload.js';
|
import { unisonReload } from '@/utility/unison-reload.js';
|
||||||
import { isBirthday } from '@/utility/is-birthday.js';
|
import { isBirthday } from '@/utility/is-birthday.js';
|
||||||
|
|
||||||
|
|
@ -68,6 +69,14 @@ export async function mainBoot() {
|
||||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
|
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
|
||||||
closed: () => dispose(),
|
closed: () => dispose(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// prefereces migration
|
||||||
|
// TODO: そのうち消す
|
||||||
|
if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) {
|
||||||
|
console.log('Preferences migration');
|
||||||
|
|
||||||
|
migrateOldSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
||||||
|
|
||||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||||
|
|
||||||
|
<FormSlot>
|
||||||
|
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||||
|
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
||||||
|
</FormSlot>
|
||||||
</div>
|
</div>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -168,6 +173,7 @@ import FormSection from '@/components/form/section.vue';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||||
import { signout } from '@/signout.js';
|
import { signout } from '@/signout.js';
|
||||||
|
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||||
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
||||||
import { suggestReload } from '@/utility/reload-suggest.js';
|
import { suggestReload } from '@/utility/reload-suggest.js';
|
||||||
import { cloudBackup } from '@/preferences/utility.js';
|
import { cloudBackup } from '@/preferences/utility.js';
|
||||||
|
|
@ -213,6 +219,10 @@ async function deleteAccount() {
|
||||||
await signout();
|
await signout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrate() {
|
||||||
|
migrateOldSettings();
|
||||||
|
}
|
||||||
|
|
||||||
function resetAllTips() {
|
function resetAllTips() {
|
||||||
_resetAllTips();
|
_resetAllTips();
|
||||||
os.success();
|
os.success();
|
||||||
|
|
|
||||||
141
packages/frontend/src/pref-migrate.ts
Normal file
141
packages/frontend/src/pref-migrate.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DeckProfile } from '@/deck.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
import { store } from '@/store.js';
|
||||||
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||||
|
import { unisonReload } from '@/utility/unison-reload.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import type { SoundStore } from '@/preferences/def.js';
|
||||||
|
|
||||||
|
// TODO: そのうち消す
|
||||||
|
export function migrateOldSettings() {
|
||||||
|
os.waiting({ text: i18n.ts.settingsMigrating });
|
||||||
|
|
||||||
|
store.loaded.then(async () => {
|
||||||
|
misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: any) => {
|
||||||
|
if (themes.length > 0) {
|
||||||
|
prefer.commit('themes', themes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prefer.commit('deck.profile', deckStore.s.profile);
|
||||||
|
misskeyApi('i/registry/keys', {
|
||||||
|
scope: ['client', 'deck', 'profiles'],
|
||||||
|
}).then(async keys => {
|
||||||
|
const profiles: DeckProfile[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const deck = await misskeyApi('i/registry/get', {
|
||||||
|
scope: ['client', 'deck', 'profiles'],
|
||||||
|
key: key,
|
||||||
|
});
|
||||||
|
profiles.push({
|
||||||
|
id: genId(),
|
||||||
|
name: key,
|
||||||
|
columns: deck.columns,
|
||||||
|
layout: deck.layout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prefer.commit('deck.profiles', profiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
prefer.commit('emojiPalettes', [{
|
||||||
|
id: 'reactions',
|
||||||
|
name: '',
|
||||||
|
emojis: store.s.reactions,
|
||||||
|
}, {
|
||||||
|
id: 'pinnedEmojis',
|
||||||
|
name: '',
|
||||||
|
emojis: store.s.pinnedEmojis,
|
||||||
|
}]);
|
||||||
|
prefer.commit('emojiPaletteForMain', 'pinnedEmojis');
|
||||||
|
prefer.commit('emojiPaletteForReaction', 'reactions');
|
||||||
|
prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind);
|
||||||
|
prefer.commit('widgets', store.s.widgets);
|
||||||
|
prefer.commit('keepCw', store.s.keepCw);
|
||||||
|
prefer.commit('collapseRenotes', store.s.collapseRenotes);
|
||||||
|
prefer.commit('rememberNoteVisibility', store.s.rememberNoteVisibility);
|
||||||
|
prefer.commit('uploadFolder', store.s.uploadFolder);
|
||||||
|
prefer.commit('menu', [...store.s.menu, 'chat']);
|
||||||
|
prefer.commit('statusbars', store.s.statusbars);
|
||||||
|
prefer.commit('pinnedUserLists', store.s.pinnedUserLists);
|
||||||
|
prefer.commit('serverDisconnectedBehavior', store.s.serverDisconnectedBehavior);
|
||||||
|
prefer.commit('nsfw', store.s.nsfw);
|
||||||
|
prefer.commit('highlightSensitiveMedia', store.s.highlightSensitiveMedia);
|
||||||
|
prefer.commit('animation', store.s.animation);
|
||||||
|
prefer.commit('animatedMfm', store.s.animatedMfm);
|
||||||
|
prefer.commit('advancedMfm', store.s.advancedMfm);
|
||||||
|
prefer.commit('showReactionsCount', store.s.showReactionsCount);
|
||||||
|
prefer.commit('enableQuickAddMfmFunction', store.s.enableQuickAddMfmFunction);
|
||||||
|
prefer.commit('loadRawImages', store.s.loadRawImages);
|
||||||
|
prefer.commit('imageNewTab', store.s.imageNewTab);
|
||||||
|
prefer.commit('disableShowingAnimatedImages', store.s.disableShowingAnimatedImages);
|
||||||
|
prefer.commit('emojiStyle', store.s.emojiStyle);
|
||||||
|
prefer.commit('menuStyle', store.s.menuStyle);
|
||||||
|
prefer.commit('useBlurEffectForModal', store.s.useBlurEffectForModal);
|
||||||
|
prefer.commit('useBlurEffect', store.s.useBlurEffect);
|
||||||
|
prefer.commit('showFixedPostForm', store.s.showFixedPostForm);
|
||||||
|
prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel);
|
||||||
|
prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll);
|
||||||
|
prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu);
|
||||||
|
prefer.commit('instanceTicker', store.s.instanceTicker);
|
||||||
|
prefer.commit('emojiPickerScale', store.s.emojiPickerScale);
|
||||||
|
prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth);
|
||||||
|
prefer.commit('emojiPickerHeight', store.s.emojiPickerHeight);
|
||||||
|
prefer.commit('emojiPickerStyle', store.s.emojiPickerStyle);
|
||||||
|
prefer.commit('reportError', store.s.reportError);
|
||||||
|
prefer.commit('squareAvatars', store.s.squareAvatars);
|
||||||
|
prefer.commit('showAvatarDecorations', store.s.showAvatarDecorations);
|
||||||
|
prefer.commit('numberOfPageCache', store.s.numberOfPageCache);
|
||||||
|
prefer.commit('showNoteActionsOnlyHover', store.s.showNoteActionsOnlyHover);
|
||||||
|
prefer.commit('showClipButtonInNoteFooter', store.s.showClipButtonInNoteFooter);
|
||||||
|
prefer.commit('reactionsDisplaySize', store.s.reactionsDisplaySize);
|
||||||
|
prefer.commit('limitWidthOfReaction', store.s.limitWidthOfReaction);
|
||||||
|
prefer.commit('forceShowAds', store.s.forceShowAds);
|
||||||
|
prefer.commit('aiChanMode', store.s.aiChanMode);
|
||||||
|
prefer.commit('devMode', store.s.devMode);
|
||||||
|
prefer.commit('mediaListWithOneImageAppearance', store.s.mediaListWithOneImageAppearance);
|
||||||
|
prefer.commit('notificationPosition', store.s.notificationPosition);
|
||||||
|
prefer.commit('notificationStackAxis', store.s.notificationStackAxis);
|
||||||
|
prefer.commit('enableCondensedLine', store.s.enableCondensedLine);
|
||||||
|
prefer.commit('keepScreenOn', store.s.keepScreenOn);
|
||||||
|
prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications);
|
||||||
|
prefer.commit('dataSaver', {
|
||||||
|
...prefer.s.dataSaver,
|
||||||
|
media: store.s.dataSaver.media,
|
||||||
|
avatar: store.s.dataSaver.avatar,
|
||||||
|
urlPreviewThumbnail: store.s.dataSaver.urlPreview,
|
||||||
|
code: store.s.dataSaver.code,
|
||||||
|
});
|
||||||
|
prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect);
|
||||||
|
prefer.commit('enableHorizontalSwipe', store.s.enableHorizontalSwipe);
|
||||||
|
prefer.commit('useNativeUiForVideoAudioPlayer', store.s.useNativeUIForVideoAudioPlayer);
|
||||||
|
prefer.commit('keepOriginalFilename', store.s.keepOriginalFilename);
|
||||||
|
prefer.commit('alwaysConfirmFollow', store.s.alwaysConfirmFollow);
|
||||||
|
prefer.commit('confirmWhenRevealingSensitiveMedia', store.s.confirmWhenRevealingSensitiveMedia);
|
||||||
|
prefer.commit('contextMenu', store.s.contextMenu);
|
||||||
|
prefer.commit('skipNoteRender', store.s.skipNoteRender);
|
||||||
|
prefer.commit('showSoftWordMutedWord', store.s.showSoftWordMutedWord);
|
||||||
|
prefer.commit('confirmOnReact', store.s.confirmOnReact);
|
||||||
|
prefer.commit('defaultFollowWithReplies', store.s.defaultWithReplies);
|
||||||
|
prefer.commit('sound.masterVolume', store.s.sound_masterVolume);
|
||||||
|
prefer.commit('sound.notUseSound', store.s.sound_notUseSound);
|
||||||
|
prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive);
|
||||||
|
prefer.commit('sound.on.note', store.s.sound_note as SoundStore);
|
||||||
|
prefer.commit('sound.on.noteMy', store.s.sound_noteMy as SoundStore);
|
||||||
|
prefer.commit('sound.on.notification', store.s.sound_notification as SoundStore);
|
||||||
|
prefer.commit('sound.on.reaction', store.s.sound_reaction as SoundStore);
|
||||||
|
prefer.commit('defaultNoteVisibility', store.s.defaultNoteVisibility);
|
||||||
|
prefer.commit('defaultNoteLocalOnly', store.s.defaultNoteLocalOnly);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
unisonReload();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,352 @@ export const store = markRaw(new Pizzax('base', {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//#region TODO: そのうち消す (preferに移行済み)
|
||||||
|
defaultWithReplies: {
|
||||||
|
where: 'account',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
reactions: {
|
||||||
|
where: 'account',
|
||||||
|
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
|
||||||
|
},
|
||||||
|
pinnedEmojis: {
|
||||||
|
where: 'account',
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
widgets: {
|
||||||
|
where: 'account',
|
||||||
|
default: [] as {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
place: string | null;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}[],
|
||||||
|
},
|
||||||
|
overridedDeviceKind: {
|
||||||
|
where: 'device',
|
||||||
|
default: null as DeviceKind | null,
|
||||||
|
},
|
||||||
|
defaultSideView: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
defaultNoteVisibility: {
|
||||||
|
where: 'account',
|
||||||
|
default: 'public' as (typeof Misskey.noteVisibilities)[number],
|
||||||
|
},
|
||||||
|
defaultNoteLocalOnly: {
|
||||||
|
where: 'account',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
keepCw: {
|
||||||
|
where: 'account',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
collapseRenotes: {
|
||||||
|
where: 'account',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
rememberNoteVisibility: {
|
||||||
|
where: 'account',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
uploadFolder: {
|
||||||
|
where: 'account',
|
||||||
|
default: null as string | null,
|
||||||
|
},
|
||||||
|
keepOriginalUploading: {
|
||||||
|
where: 'account',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
where: 'deviceAccount',
|
||||||
|
default: [
|
||||||
|
'notifications',
|
||||||
|
'clips',
|
||||||
|
'drive',
|
||||||
|
'followRequests',
|
||||||
|
'-',
|
||||||
|
'explore',
|
||||||
|
'announcements',
|
||||||
|
'search',
|
||||||
|
'-',
|
||||||
|
'ui',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
statusbars: {
|
||||||
|
where: 'deviceAccount',
|
||||||
|
default: [] as {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
|
||||||
|
black: boolean;
|
||||||
|
props: Record<string, any>;
|
||||||
|
}[],
|
||||||
|
},
|
||||||
|
pinnedUserLists: {
|
||||||
|
where: 'deviceAccount',
|
||||||
|
default: [] as Misskey.entities.UserList[],
|
||||||
|
},
|
||||||
|
serverDisconnectedBehavior: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'quiet' as 'quiet' | 'reload' | 'dialog',
|
||||||
|
},
|
||||||
|
nsfw: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'respect' as 'respect' | 'force' | 'ignore',
|
||||||
|
},
|
||||||
|
highlightSensitiveMedia: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
where: 'device',
|
||||||
|
default: !prefersReducedMotion,
|
||||||
|
},
|
||||||
|
animatedMfm: {
|
||||||
|
where: 'device',
|
||||||
|
default: !prefersReducedMotion,
|
||||||
|
},
|
||||||
|
advancedMfm: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showReactionsCount: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableQuickAddMfmFunction: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadRawImages: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
imageNewTab: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disableShowingAnimatedImages: {
|
||||||
|
where: 'device',
|
||||||
|
default: prefersReducedMotion,
|
||||||
|
},
|
||||||
|
emojiStyle: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'twemoji' as 'twemoji' | 'fluentEmoji' | 'native',
|
||||||
|
},
|
||||||
|
menuStyle: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'auto' as 'auto' | 'popup' | 'drawer',
|
||||||
|
},
|
||||||
|
useBlurEffectForModal: {
|
||||||
|
where: 'device',
|
||||||
|
default: DEFAULT_DEVICE_KIND === 'desktop',
|
||||||
|
},
|
||||||
|
useBlurEffect: {
|
||||||
|
where: 'device',
|
||||||
|
default: DEFAULT_DEVICE_KIND === 'desktop',
|
||||||
|
},
|
||||||
|
showFixedPostForm: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showFixedPostFormInChannel: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableInfiniteScroll: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
useReactionPickerForContextMenu: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showGapBetweenNotesInTimeline: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
instanceTicker: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'remote' as 'none' | 'remote' | 'always',
|
||||||
|
},
|
||||||
|
emojiPickerScale: {
|
||||||
|
where: 'device',
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
emojiPickerWidth: {
|
||||||
|
where: 'device',
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
emojiPickerHeight: {
|
||||||
|
where: 'device',
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
emojiPickerStyle: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'auto' as 'auto' | 'popup' | 'drawer',
|
||||||
|
},
|
||||||
|
reportError: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
squareAvatars: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showAvatarDecorations: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
numberOfPageCache: {
|
||||||
|
where: 'device',
|
||||||
|
default: 3,
|
||||||
|
},
|
||||||
|
showNoteActionsOnlyHover: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showClipButtonInNoteFooter: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
reactionsDisplaySize: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'medium' as 'small' | 'medium' | 'large',
|
||||||
|
},
|
||||||
|
limitWidthOfReaction: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
forceShowAds: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
aiChanMode: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
devMode: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
mediaListWithOneImageAppearance: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3',
|
||||||
|
},
|
||||||
|
notificationPosition: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom',
|
||||||
|
},
|
||||||
|
notificationStackAxis: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'horizontal' as 'vertical' | 'horizontal',
|
||||||
|
},
|
||||||
|
enableCondensedLine: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
keepScreenOn: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
useGroupedNotifications: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
dataSaver: {
|
||||||
|
where: 'device',
|
||||||
|
default: {
|
||||||
|
media: false,
|
||||||
|
avatar: false,
|
||||||
|
urlPreview: false,
|
||||||
|
code: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enableSeasonalScreenEffect: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableHorizontalSwipe: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
useNativeUIForVideoAudioPlayer: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
keepOriginalFilename: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
alwaysConfirmFollow: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
confirmWhenRevealingSensitiveMedia: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
contextMenu: {
|
||||||
|
where: 'device',
|
||||||
|
default: 'app' as 'app' | 'appWithShift' | 'native',
|
||||||
|
},
|
||||||
|
skipNoteRender: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showSoftWordMutedWord: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
confirmOnReact: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hemisphere: {
|
||||||
|
where: 'device',
|
||||||
|
default: hemisphere as 'N' | 'S',
|
||||||
|
},
|
||||||
|
sound_masterVolume: {
|
||||||
|
where: 'device',
|
||||||
|
default: 0.3,
|
||||||
|
},
|
||||||
|
sound_notUseSound: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
sound_useSoundOnlyWhenActive: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
sound_note: {
|
||||||
|
where: 'device',
|
||||||
|
default: { type: 'syuilo/n-aec', volume: 1 },
|
||||||
|
},
|
||||||
|
sound_noteMy: {
|
||||||
|
where: 'device',
|
||||||
|
default: { type: 'syuilo/n-cea-4va', volume: 1 },
|
||||||
|
},
|
||||||
|
sound_notification: {
|
||||||
|
where: 'device',
|
||||||
|
default: { type: 'syuilo/n-ea', volume: 1 },
|
||||||
|
},
|
||||||
|
sound_reaction: {
|
||||||
|
where: 'device',
|
||||||
|
default: { type: 'syuilo/bubble2', volume: 1 },
|
||||||
|
},
|
||||||
|
dropAndFusion: {
|
||||||
|
where: 'device',
|
||||||
|
default: {
|
||||||
|
bgmVolume: 0.25,
|
||||||
|
sfxVolume: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
//#endregion
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// TODO: 他のタブと永続化されたstateを同期
|
// TODO: 他のタブと永続化されたstateを同期
|
||||||
|
|
|
||||||
|
|
@ -27,27 +27,29 @@ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.s
|
||||||
function getBundleVisualizerPlugin(): PluginOption[] {
|
function getBundleVisualizerPlugin(): PluginOption[] {
|
||||||
if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return [];
|
if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return [];
|
||||||
|
|
||||||
const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown'
|
const visualizerOptions = {
|
||||||
? 'markdown'
|
title: 'Misskey frontend bundle visualizer',
|
||||||
: process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'raw-data'
|
gzipSize: true,
|
||||||
? 'raw-data'
|
brotliSize: true,
|
||||||
: 'treemap';
|
projectRoot: path.resolve(__dirname, '../..'),
|
||||||
const defaultFilename = template === 'markdown'
|
};
|
||||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/report.md')
|
const plugins = [
|
||||||
: template === 'raw-data'
|
|
||||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.json')
|
|
||||||
: path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html');
|
|
||||||
|
|
||||||
return [
|
|
||||||
visualizer({
|
visualizer({
|
||||||
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE ?? defaultFilename,
|
...visualizerOptions,
|
||||||
title: 'Misskey frontend bundle visualizer',
|
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE,
|
||||||
template,
|
template: 'raw-data',
|
||||||
gzipSize: true,
|
|
||||||
brotliSize: true,
|
|
||||||
projectRoot: path.resolve(__dirname, '../..'),
|
|
||||||
}) as PluginOption,
|
}) as PluginOption,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE != null) {
|
||||||
|
plugins.push(visualizer({
|
||||||
|
...visualizerOptions,
|
||||||
|
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE,
|
||||||
|
template: 'treemap',
|
||||||
|
}) as PluginOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5456,6 +5456,14 @@ export interface Locale extends ILocale {
|
||||||
* メッセージ
|
* メッセージ
|
||||||
*/
|
*/
|
||||||
"directMessage_short": string;
|
"directMessage_short": string;
|
||||||
|
/**
|
||||||
|
* 旧設定情報を移行
|
||||||
|
*/
|
||||||
|
"migrateOldSettings": string;
|
||||||
|
/**
|
||||||
|
* 通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。
|
||||||
|
*/
|
||||||
|
"migrateOldSettings_description": string;
|
||||||
/**
|
/**
|
||||||
* 圧縮
|
* 圧縮
|
||||||
*/
|
*/
|
||||||
|
|
@ -5476,6 +5484,10 @@ export interface Locale extends ILocale {
|
||||||
* 埋め込み
|
* 埋め込み
|
||||||
*/
|
*/
|
||||||
"embed": string;
|
"embed": string;
|
||||||
|
/**
|
||||||
|
* 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)
|
||||||
|
*/
|
||||||
|
"settingsMigrating": string;
|
||||||
/**
|
/**
|
||||||
* 読み取り専用
|
* 読み取り専用
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue