1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-06-23 18:14:52 +02:00

Compare commits

..

15 Commits

Author SHA1 Message Date
syuilo
2b59956092 Update backend-memory-report.mjs 2026-06-23 18:45:56 +09:00
syuilo
713b9dc40c wip 2026-06-23 13:07:33 +09:00
syuilo
6e4380f11d enhance(dev): tweak report-backend-memory 2026-06-23 12:37:47 +09:00
syuilo
cb1d1d651a enhance(dev): tweak report-backend-memory 2026-06-23 12:18:47 +09:00
syuilo
c899aafeef enhance(dev): tweak report-backend-memory 2026-06-23 11:54:16 +09:00
syuilo
72d91ce3da refactor(dev): report-backend-memoryのmarkdown生成ロジックを分離 2026-06-23 11:29:45 +09:00
syuilo
09b761e4d1 enhance(dev): tweak report-backend-memory 2026-06-23 11:17:03 +09:00
syuilo
6d11f572b3 enhance(dev): improve backend memory usage comparison workflow (#17591)
* wip

* Update get-backend-memory.yml

* [ci skip] tweak table
2026-06-23 11:01:27 +09:00
syuilo
d54b948085 enhance(dev): tweak Frontend Chunk Report 2026-06-22 22:22:36 +09:00
syuilo
f5806a0560 enhance(dev): tweak Frontend Chunk Report 2026-06-22 22:15:32 +09:00
syuilo
5d8c31b6e5 fix(dev): tweak frontend-bundle-report-comment 2026-06-22 22:11:46 +09:00
syuilo
fff87f6604 enhance(dev): tweak Frontend Chunk Report 2026-06-22 21:59:54 +09:00
syuilo
7a3e03411f Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-06-22 20:49:16 +09:00
syuilo
6d89d479e2 fix(dev): tweak frontend-bundle-report 2026-06-22 20:49:03 +09:00
github-actions[bot]
ab73b8abe3 [skip ci] Update CHANGELOG.md (prepend template) 2026-06-22 11:47:30 +00:00
14 changed files with 1676 additions and 280 deletions

View 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;
}

View 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;
};

530
.github/scripts/backend-js-footprint.mjs vendored Normal file
View File

@@ -0,0 +1,530 @@
/*
* 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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const [repoDirArg, outputFileArg] = process.argv.slice(2);
if (repoDirArg == null || outputFileArg == null) {
console.error('Usage: node .github/scripts/backend-js-footprint.mjs <repo-dir> <output.json>');
process.exit(1);
}
const STARTUP_TIMEOUT = readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
const SETTLE_TIME = readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
const REQUEST_COUNT = readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
const MAX_TABLE_ITEMS = 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 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 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);
}
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() {
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 {
phase,
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 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,
},
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`);

View File

@@ -0,0 +1,424 @@
import { readFile, writeFile } from 'node:fs/promises';
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = 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> [base-js-footprint.json head-js-footprint.json]');
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 formatBytes(value) {
if (!Number.isFinite(value)) return '-';
if (value < 1024) return `${formatNumber(value)} B`;
if (value < 1024 * 1024) return `${formatNumber(value / 1024)} KiB`;
return `${formatNumber(value / 1024 / 1024)} MiB`;
}
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._`;
}
function formatPlainDiff(baseValue, headValue, formatter = formatNumber) {
const diff = headValue - baseValue;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return `${sign}${formatter(Math.abs(diff))}`;
}
function formatPlainDiffPercent(baseValue, headValue) {
const diff = headValue - baseValue;
if (diff === 0) return '0%';
if (baseValue <= 0) return '-';
const sign = diff > 0 ? '+' : '-';
return `${sign}${formatPercent(Math.abs((diff * 100) / baseValue))}`;
}
function getJsFootprintValue(report, phase, key) {
const value = report?.[phase]?.totals?.[key];
return Number.isFinite(value) ? value : null;
}
function renderJsFootprintMetricTable(base, head) {
const metricRows = [
['Loaded JS modules', 'loadedJsModules', formatNumber],
['Loaded JS source', 'loadedJsSourceBytes', formatBytes],
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', formatBytes],
//['AST nodes', 'astNodeCount', formatNumber],
//['Functions', 'functionCount', formatNumber],
//['Classes', 'classCount', formatNumber],
//['String literals', 'stringLiteralBytes', formatBytes],
['External packages loaded', 'externalPackageCount', formatNumber],
['Native addon packages', 'nativeAddonPackageCount', formatNumber],
];
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)} | ${formatPlainDiff(baseValue, headValue, formatter)} | ${formatPlainDiffPercent(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} | ${formatNumber(baseModules)} | ${formatNumber(headModules)} | ${formatPlainDiff(baseModules, headModules)} | ${formatBytes(baseSource)} | ${formatBytes(headSource)} | ${formatPlainDiff(baseSource, headSource, formatBytes)} |`);
}
return lines.join('\n');
}
function packageMap(report) {
const map = new Map();
for (const packageSummary of report?.afterRequest?.packages ?? []) {
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
map.set(packageSummary.name, packageSummary);
}
return map;
}
function packageDisplayName(packageSummary) {
if (packageSummary.version == null) return packageSummary.name;
return `${packageSummary.name} ${packageSummary.version}`;
}
function renderNewExternalPackages(base, head) {
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)} | ${formatBytes(packageSummary.sourceBytes)} | ${formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
}
return lines.join('\n');
}
function renderLargestPackageIncreases(base, head) {
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)} | ${formatBytes(packageSummary.baseSourceBytes)} | ${formatBytes(packageSummary.sourceBytes)} | ${formatPlainDiff(packageSummary.baseSourceBytes, packageSummary.sourceBytes, formatBytes)} | ${formatPlainDiff(packageSummary.baseModules, packageSummary.modules)} |`);
}
return lines.join('\n');
}
function moduleMap(report) {
const map = new Map();
for (const moduleSummary of report?.afterRequest?.modules ?? []) {
if (typeof moduleSummary.path !== 'string') continue;
map.set(moduleSummary.path, moduleSummary);
}
return map;
}
function renderNewLoadedModules(base, head) {
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} | ${formatBytes(moduleSummary.sourceBytes)} |`);
}
return lines.join('\n');
}
function renderJsFootprintSection(base, head) {
if (base == null || head == null) return null;
const lines = [
'### Runtime Loaded JS Footprint',
'',
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('');
}
return lines.join('\n');
}
const base = JSON.parse(await readFile(baseFile, 'utf8'));
const head = JSON.parse(await readFile(headFile, 'utf8'));
const baseJsFootprint = baseJsFootprintFile == null ? null : JSON.parse(await readFile(baseJsFootprintFile, 'utf8'));
const headJsFootprint = headJsFootprintFile == null ? null : JSON.parse(await readFile(headJsFootprintFile, '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 jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
if (jsFootprintSection != null) {
lines.push(jsFootprintSection);
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`);

View File

@@ -189,6 +189,13 @@ function removedKeys(before, after) {
.filter((key) => after.chunks[key] == null);
}
function rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize) {
if (beforeEntry == null) return 'added';
if (afterEntry == null) return 'removed';
if (beforeSize !== afterSize) return 'updated';
return 'unchanged';
}
function getChunkComparisonRows(keys, before, after) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
@@ -201,11 +208,31 @@ function getChunkComparisonRows(keys, before, after) {
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
changeType: rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize),
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function summarizeChanges(rows) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
function formatChangeSummary(label, summary) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareComparisonRows(a, b) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function markdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
@@ -218,32 +245,13 @@ function markdownTable(rows, total) {
lines.push('| | | | | |');
}
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
}
return lines.join('\n');
}
function chunkRows(keys, report) {
return keys.map((key) => {
const entry = report.chunks[key];
return {
key,
name: entryDisplayName(entry),
chunkFile: entry.file,
size: entry.size,
};
});
}
function markdownChunkTable(rows) {
if (rows.length === 0) return '_No data_';
const lines = [
'| Chunk | Size |',
'| --- | ---: |',
];
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.size)} |`);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
}
}
return lines.join('\n');
}
@@ -258,26 +266,21 @@ const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const diffRows = comparisonRows
.filter((row) => row.beforeSize !== row.afterSize)
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name))
.slice(0, 30);
const allChunkKeys = [
...commonChunkKeys,
...addedKeys(before, after),
...removedKeys(before, after),
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChanges(changedRows);
const diffTotal = {
beforeSize: comparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: comparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const addedRows = chunkRows(addedKeys(before, after), after)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const removedRows = chunkRows(removedKeys(before, after), before)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const diffRows = changedRows.sort(compareComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
@@ -285,46 +288,30 @@ const startupKeys = new Set([
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name));
.sort(compareComparisonRows);
const startupSummary = summarizeChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const largeRows = comparisonRows
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
.slice(0, 30);
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
const body = [
marker,
`## Frontend chunk report (${locale})`,
`## Frontend Chunk Report`,
'',
'<details open>',
`<summary>Diffs</summary>`,
`<summary>${formatChangeSummary('Diffs', diffSummary)}</summary>`,
'',
markdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>Added (${addedRows.length})</summary>`,
'',
markdownChunkTable(addedRows),
'',
'</details>',
'',
'<details>',
`<summary>Removed (${removedRows.length})</summary>`,
'',
markdownChunkTable(removedRows),
'',
'</details>',
'',
'<details>',
`<summary>Startup</summary>`,
`<summary>${formatChangeSummary('Startup', startupSummary)}</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
@@ -332,13 +319,13 @@ const body = [
'',
'</details>',
'',
'<details>',
`<summary>Largest</summary>`,
'',
markdownTable(largeRows),
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
].join('\n');
await fs.writeFile(outFile, body);

View File

@@ -0,0 +1,224 @@
/*
* 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);
});

View File

@@ -104,6 +104,9 @@ jobs:
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) return { done: true, run };
core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`);
return { done: true, run: null };
}
return { done: false, run: null };
@@ -126,8 +129,30 @@ jobs:
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
- name: Download bundle report from workflow_run
- name: Find bundle report artifact
if: github.event_name == 'workflow_run'
id: find-report-artifact
uses: actions/github-script@v9
with:
script: |
const artifactName = 'frontend-bundle-report';
const { owner, repo } = context.repo;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: context.payload.workflow_run.id,
per_page: 100,
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) {
core.setOutput('exists', 'true');
} else {
core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`);
core.setOutput('exists', 'false');
}
- name: Download bundle report from workflow_run
if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true'
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
@@ -147,7 +172,7 @@ jobs:
run-id: ${{ steps.find-report-run.outputs.run-id }}
- name: Comment on pull request
if: github.event_name == 'workflow_run' || steps.find-report-run.outputs.run-id != ''
if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != ''
uses: actions/github-script@v9
with:
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
@@ -165,7 +190,7 @@ jobs:
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
const pullRequest = context.payload.pull_request;
const headSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const { owner, repo } = context.repo;
if (!fs.existsSync(jsSizeReportPath)) {
@@ -180,10 +205,10 @@ jobs:
const artifactHeadSha = fs.existsSync(headShaPath)
? fs.readFileSync(headShaPath, 'utf8').trim()
: null;
if (headSha != null && artifactHeadSha != null && artifactHeadSha !== headSha) {
core.setFailed(`The artifact head SHA (${artifactHeadSha}) does not match the workflow head SHA (${headSha}).`);
return;
if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) {
core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`);
}
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
const artifactPrNumber = fs.existsSync(prNumberPath)
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
@@ -203,25 +228,29 @@ jobs:
}
}
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: headSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
if (reportHeadSha != null) {
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: reportHeadSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
}
}
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
issue_number = artifactPrNumber;
} else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) {
issue_number = artifactPrNumber;
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
issue_number = [...associatedPullRequests.keys()][0];
} else if (Number.isInteger(artifactPrNumber)) {
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${headSha}.`);
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`);
return;
} else {
core.setFailed(`Could not determine the pull request associated with ${headSha}.`);
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
return;
}
} else {
@@ -229,6 +258,17 @@ jobs:
return;
}
const currentPullRequest = await github.rest.pulls.get({
owner,
repo,
pull_number: issue_number,
});
const currentHeadSha = currentPullRequest.data.head?.sha;
if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) {
core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`);
return;
}
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
if (!jsSizeReport.includes(jsSizeMarker)) {
core.setFailed('The frontend JS size report is missing the expected marker.');

View File

@@ -56,21 +56,25 @@ jobs:
path: after
submodules: true
- name: Backport visualizer tooling to base if needed
- name: Check base visualizer support
id: check-base-visualizer
shell: bash
run: |
if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
cp after/packages/frontend/package.json before/packages/frontend/package.json
cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts
cp after/pnpm-lock.yaml before/pnpm-lock.yaml
if grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
echo 'supported=true' >> "$GITHUB_OUTPUT"
else
echo 'supported=false' >> "$GITHUB_OUTPUT"
echo 'Base commit does not support frontend bundle visualizer. Skipping frontend bundle report.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Setup pnpm
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
@@ -80,17 +84,21 @@ jobs:
after/pnpm-lock.yaml
- name: Install dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
if: steps.check-base-visualizer.outputs.supported == 'true'
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
@@ -99,14 +107,17 @@ jobs:
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend report for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
@@ -115,6 +126,7 @@ jobs:
run: pnpm --filter frontend run build
- name: Generate report markdown
if: steps.check-base-visualizer.outputs.supported == 'true'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -130,6 +142,7 @@ jobs:
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check report
if: steps.check-base-visualizer.outputs.supported == 'true'
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
test -s "$REPORT_DIR/before-stats.json"
@@ -141,6 +154,7 @@ jobs:
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report

View File

@@ -9,7 +9,13 @@ on:
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/scripts/backend-memory-report.mjs
- .github/scripts/measure-backend-memory-comparison.mjs
- .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/report-backend-memory.yml
jobs:
get-memory-usage:
@@ -17,15 +23,6 @@ jobs:
permissions:
contents: read
strategy:
matrix:
memory-json-name: [memory-base.json, memory-head.json]
include:
- memory-json-name: memory-base.json
ref: ${{ github.base_ref }}
- memory-json-name: memory-head.json
ref: refs/pull/${{ github.event.number }}/merge
services:
postgres:
image: postgres:18
@@ -40,37 +37,76 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
ref: ${{ matrix.ref }}
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: head/package.json
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
node-version-file: 'head/.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
cache-dependency-path: |
base/pnpm-lock.yaml
head/pnpm-lock.yaml
- name: Install base dependencies
working-directory: base
run: pnpm i --frozen-lockfile
- name: Check base pnpm-lock.yaml
working-directory: base
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .github/misskey/test.yml .config/default.yml
- name: Compile Configure
run: pnpm compile-config
- name: Build
run: pnpm build
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
- name: Configure base
working-directory: base
run: |
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build base
working-directory: base
run: pnpm build
- name: Install head dependencies
working-directory: head
run: pnpm i --frozen-lockfile
- name: Check head pnpm-lock.yaml
working-directory: head
run: git diff --exit-code pnpm-lock.yaml
- name: Configure head
working-directory: head
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build head
working-directory: head
run: pnpm build
- name: Measure backend memory usage
env:
MK_MEMORY_COMPARE_ROUNDS: 5
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
run: node head/.github/scripts/measure-backend-memory-comparison.mjs 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
uses: actions/upload-artifact@v7
with:
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
name: memory-artifact-results
path: |
memory-base.json
memory-head.json
js-footprint-base.json
js-footprint-head.json
save-pr-number:
runs-on: ubuntu-latest

View File

@@ -11,9 +11,14 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Download artifact
uses: actions/github-script@v9
with:
@@ -48,120 +53,13 @@ jobs:
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Compare memory usage
id: compare
run: |
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
variation() {
calc() {
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
DIFF=$((HEAD - BASE))
if [ "$BASE" -gt 0 ]; then
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
else
DIFF_PERCENT=0
fi
# Convert KB to MB for readability
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
JSON=$(jq -c -n \
--argjson base "$BASE_MB" \
--argjson head "$HEAD_MB" \
--argjson diff "$DIFF_MB" \
--argjson diff_percent "$DIFF_PERCENT" \
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson VmRSS "$(calc $1 VmRSS)" \
--argjson VmHWM "$(calc $1 VmHWM)" \
--argjson VmSize "$(calc $1 VmSize)" \
--argjson VmData "$(calc $1 VmData)" \
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson beforeGc "$(variation beforeGc)" \
--argjson afterGc "$(variation afterGc)" \
--argjson afterRequest "$(variation afterRequest)" \
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
echo "res=$JSON" >> "$GITHUB_OUTPUT"
- 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
name: Build memory comment
env:
RES: ${{ steps.compare.outputs.res }}
run: |
HEADER="## Backend memory usage comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
table() {
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
echo "|--------|------:|------:|------:|------:|" >> ./output.md
line() {
METRIC=$2
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
DIFF="+$DIFF"
DIFF_PERCENT="+$DIFF_PERCENT"
fi
# highlight VmRSS
if [ "$2" = "VmRSS" ]; then
METRIC="**${METRIC}**"
BASE="**${BASE}**"
HEAD="**${HEAD}**"
DIFF="**${DIFF}**"
DIFF_PERCENT="**${DIFF_PERCENT}**"
fi
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
}
line $1 VmRSS
line $1 VmHWM
line $1 VmSize
line $1 VmData
}
echo "### Before GC" >> ./output.md
table beforeGc
echo >> ./output.md
echo "### After GC" >> ./output.md
table afterGc
echo >> ./output.md
echo "### After Request" >> ./output.md
table afterRequest
echo >> ./output.md
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
echo "$FOOTER" >> ./output.md
run: node .github/scripts/backend-memory-report.mjs ./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
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}

View File

@@ -1,3 +1,15 @@
## Unreleased
### General
-
### Client
-
### Server
-
## 2026.6.0
### General

View File

@@ -20,11 +20,23 @@ import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SAMPLE_COUNT = 3; // Number of samples to measure
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
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 keys = {
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,
@@ -37,30 +49,152 @@ const keys = {
VmSwap: 0,
};
async function getMemoryUsage(pid) {
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
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 = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else {
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
} 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 result;
return summary;
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
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'],
@@ -90,15 +224,18 @@ async function measureMemory() {
});
async function triggerGc() {
const ok = new Promise((resolve) => {
serverProcess.once('message', (message) => {
if (message === 'gc ok') resolve();
});
});
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
'GC completion',
);
serverProcess.send('gc');
await ok;
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);
}
@@ -139,23 +276,20 @@ async function measureMemory() {
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
const pid = serverProcess.pid;
const beforeGc = await getMemoryUsage(pid);
const beforeGc = await getAllMemoryUsage(serverProcess);
await triggerGc();
const afterGc = await getMemoryUsage(pid);
const afterGc = await getAllMemoryUsage(serverProcess);
// create some http requests to simulate load
const REQUEST_COUNT = 10;
await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
);
await triggerGc();
const afterRequest = await getMemoryUsage(pid);
const afterRequest = await getAllMemoryUsage(serverProcess);
// Stop the server
serverProcess.kill('SIGTERM');
@@ -187,35 +321,27 @@ async function 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);
}
// Calculate averages
const beforeGc = structuredClone(keys);
const afterGc = structuredClone(keys);
const afterRequest = structuredClone(keys);
for (const res of results) {
for (const key of Object.keys(keys)) {
beforeGc[key] += res.beforeGc[key];
afterGc[key] += res.afterGc[key];
afterRequest[key] += res.afterRequest[key];
}
}
for (const key of Object.keys(keys)) {
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
}
const summary = summarizeResults(results);
const result = {
timestamp: new Date().toISOString(),
beforeGc,
afterGc,
afterRequest,
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

View File

@@ -6,6 +6,7 @@
import { NestFactory } from '@nestjs/core';
import { init } from 'slacc';
import { NestLogger } from '@/NestLogger.js';
import { envOption } from '@/env.js';
import type { Config } from '@/config.js';
let slaccInitialized = false;
@@ -31,7 +32,7 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
@@ -54,7 +55,9 @@ export async function jobQueue() {
});
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
if (!envOption.noDaemons) {
jobQueue.get(ChartManagementService).start();
}
return jobQueue;
}

View File

@@ -91,10 +91,20 @@ process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
global.gc();
for (let i = 0; i < 3; i++) {
global.gc();
}
if (process.send != null) process.send('gc ok');
} else {
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
if (process.send != null) process.send('gc unavailable');
}
} else if (msg === 'memory usage') {
if (process.send != null) {
process.send({
type: 'memory usage',
value: process.memoryUsage(),
});
}
}
});