1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-06-20 16:44:47 +02:00

Compare commits

..

14 Commits

Author SHA1 Message Date
かっこかり
053e244582 fix(backend): summalyのバージョンを埋め込んでビルドするようにし、同一チャンクに巻き込まれないように (#17595)
* fix(backend): summalyのバージョンを埋め込んでビルドするようにし、同一チャンクに巻き込まれないように

* fix
2026-06-20 22:46:38 +09:00
かっこかり
c0a8c7f93a enhance(backend): SummalyのUser Agentを改善 (#17589)
* enhance(backend): SummalyのUser Agentを改善

* Update Changelog

* update summaly
2026-06-20 21:33:15 +09:00
syuilo
1d0b27b4c5 Update frontend-js-size.yml 2026-06-20 20:19:54 +09:00
syuilo
3c003d73c4 Update frontend-js-size.yml 2026-06-20 20:03:57 +09:00
syuilo
36d78a788d Update frontend-js-size.yml 2026-06-20 19:58:49 +09:00
syuilo
09f058f29a Update frontend-js-size.yml 2026-06-20 15:27:43 +09:00
syuilo
ad8b194643 Update frontend-js-size.yml 2026-06-20 15:10:59 +09:00
syuilo
4c9dd0e5ff Update frontend-js-size.yml 2026-06-20 14:55:54 +09:00
syuilo
0ced35ae6c Update frontend-js-size.yml 2026-06-20 14:33:47 +09:00
syuilo
dcced940af Update frontend-js-size.yml 2026-06-20 13:41:01 +09:00
syuilo
dc97a72fdb Update frontend-js-size.yml 2026-06-20 13:26:07 +09:00
syuilo
cc7f1e7366 enhance(dev/frontend-js-size): diffが大きい順にソートした上位10チャンクの表を追加 2026-06-20 11:49:15 +09:00
syuilo
77878256a8 fix(dev): follow up of 0956da49e9 2026-06-20 11:36:44 +09:00
syuilo
662129f414 fix(dev): follow up of 0956da49e9 2026-06-20 11:29:58 +09:00
15 changed files with 511 additions and 906 deletions

View File

@@ -6,60 +6,180 @@ on:
- Frontend JS size
types:
- completed
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/workflows/frontend-js-size.yml
- .github/workflows/frontend-js-size-comment.yml
permissions:
actions: read
contents: read
issues: write
pull-requests: read
pull-requests: write
jobs:
comment:
name: Comment frontend JS size
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
concurrency:
group: frontend-js-size-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download size report
- name: Find size report run
if: github.event_name == 'pull_request_target'
id: find-report-run
uses: actions/github-script@v9
with:
script: |
const workflow_id = 'frontend-js-size.yml';
const artifactName = 'frontend-js-size-report';
const headSha = context.payload.pull_request.head.sha;
const prNumber = context.payload.pull_request.number;
const pollIntervalMs = 30_000;
const timeoutMs = 90 * 60_000;
const startedAt = Date.now();
const { owner, repo } = context.repo;
async function listSizeWorkflowRuns() {
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id,
event: 'pull_request',
head_sha: headSha,
per_page: 100,
});
if (runsForHead.length > 0) {
return runsForHead;
}
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id,
event: 'pull_request',
per_page: 100,
});
return recentRuns.filter((run) =>
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
}
async function findReportRun() {
const runs = (await listSizeWorkflowRuns())
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
for (const run of runs) {
if (run.status !== 'completed') continue;
if (run.conclusion !== 'success') {
core.warning(`Frontend JS size run ${run.id} completed with conclusion: ${run.conclusion}`);
return { done: true, run: null };
}
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) return { done: true, run };
}
return { done: false, run: null };
}
while (Date.now() - startedAt < timeoutMs) {
const { done, run } = await findReportRun();
if (run) {
core.info(`Found frontend JS size report on workflow run ${run.id}.`);
core.setOutput('run-id', String(run.id));
return;
}
if (done) {
return;
}
core.info('Waiting for frontend JS size report artifact...');
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
- name: Download size report from workflow_run
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v8
with:
name: frontend-js-size-report
path: frontend-js-size-report
path: ${{ runner.temp }}/frontend-js-size-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Download size report from pull_request_target
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
uses: actions/download-artifact@v8
with:
name: frontend-js-size-report
path: ${{ runner.temp }}/frontend-js-size-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
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 != ''
uses: actions/github-script@v9
with:
github-token: ${{ secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || github.token }}
script: |
const fs = require('node:fs');
const path = require('node:path');
const marker = '<!-- misskey-frontend-js-size -->';
const body = fs.readFileSync('frontend-js-size-report/frontend-js-size-report.md', 'utf8');
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-js-size-report');
const reportPath = [
path.join(reportDir, 'report.md'),
path.join(reportDir, 'frontend-js-size-report.md'),
].find((file) => fs.existsSync(file));
if (reportPath == null) {
core.setFailed('The frontend JS size report artifact does not contain a report markdown file.');
return;
}
const body = fs.readFileSync(reportPath, 'utf8');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
let issue_number = fs.existsSync(prNumberPath)
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
: context.payload.pull_request?.number;
if (!body.includes(marker)) {
core.setFailed('The frontend JS size report is missing the expected marker.');
return;
}
const { owner, repo } = context.repo;
const workflowRun = context.payload.workflow_run;
let issue_number = workflowRun.pull_requests?.[0]?.number;
if (issue_number == null) {
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: workflowRun.head_sha,
});
issue_number = pullRequests.find((pr) => pr.head.sha === workflowRun.head_sha)?.number
?? pullRequests[0]?.number;
}
if (issue_number == null) {
core.info(`No pull request found for workflow run ${workflowRun.id}.`);
if (!Number.isInteger(issue_number)) {
core.setFailed('The frontend JS size report is missing a valid pull request number.');
return;
}
const { owner, repo } = context.repo;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,

View File

@@ -92,7 +92,6 @@ jobs:
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const topLimit = 10;
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
@@ -126,15 +125,38 @@ jobs:
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
const sign = diff > 0 ? '+' : '-';
return `${sign}${formatBytes(Math.abs(diff))}`;
const text = `${sign}${formatBytes(Math.abs(diff))}`;
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiffPercent(beforeSize, afterSize) {
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
const diff = afterSize - beforeSize;
if (diff === 0) return `0%`;
const percent = Math.round(diff / beforeSize * 100);
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%')}}}$`;
}
function escapeCell(value) {
@@ -143,9 +165,7 @@ jobs:
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName === entry.file
? entry.displayName
: `${entry.displayName} (${entry.file})`;
return entry.displayName || entry.file;
}
function findEntryKey(manifest) {
@@ -180,7 +200,6 @@ jobs:
}
async function resolveBuiltFile(outDir, file) {
const originalPath = path.join(outDir, file);
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
@@ -190,9 +209,11 @@ jobs:
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: originalPath,
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
@@ -219,18 +240,20 @@ jobs:
byFile.add(builtFile.relativePath);
}
for await (const fullPath of walk(outDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
if (relativePath.startsWith('scripts/') || relativePath.startsWith(`${locale}/`)) continue;
const size = await fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
displayName: relativePath,
file: relativePath,
size,
});
const localeDir = path.join(outDir, locale);
if (await exists(localeDir)) {
for await (const fullPath of walk(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
const size = await fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
displayName: relativePath,
file: relativePath,
size,
});
}
}
return {
@@ -240,47 +263,78 @@ jobs:
};
}
function compareRows(keys, before, after) {
function commonKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
}
function addedKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function removedKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
}
function getChunkComparisonRows(keys, before, after) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key];
const beforeSize = beforeEntry?.size ?? null;
const afterSize = afterEntry?.size ?? null;
const beforeSize = beforeEntry?.size ?? 0;
const afterSize = afterEntry?.size ?? 0;
return {
key,
file: entryDisplayName(afterEntry ?? beforeEntry),
name: entryDisplayName(beforeEntry ?? afterEntry),
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
diff: beforeSize == null || afterSize == null ? null : afterSize - beforeSize,
sortSize: Math.max(beforeSize ?? 0, afterSize ?? 0),
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function markdownTable(rows) {
if (rows.length === 0) {
return '_No JavaScript chunks found._';
}
function markdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
const lines = [
'| File | Size (before) | Size (after) | Size (diff) |',
'| --- | ---: | ---: | ---: |',
'| Chunk | Before | After | Diff | Diff (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
lines.push(`| ${escapeCell(row.file)} | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.diff)} |`);
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 unionTopKeys(before, after) {
const allKeys = new Set([
...Object.keys(before.chunks),
...Object.keys(after.chunks),
]);
return compareRows([...allKeys], before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file))
.slice(0, topLimit)
.map((row) => row.key);
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)} |`);
}
return lines.join('\n');
}
const beforeDir = process.argv[2];
@@ -292,33 +346,87 @@ jobs:
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const topRows = compareRows(unionTopKeys(before, after), before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file));
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 diffTotal = {
beforeSize: comparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: comparisonRows.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 startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupRows = compareRows([...startupKeys], before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file));
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));
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 body = [
marker,
'## Frontend JavaScript size',
`## Frontend chunk size report (${locale})`,
'',
`Compared locale: \`${locale}\``,
`Before: \`${beforeSha}\``,
`After: \`${afterSha}\``,
'<details open>',
`<summary>Diffs</summary>`,
'',
'### Top 10 largest JS chunks',
markdownTable(diffRows, diffTotal),
'',
markdownTable(topRows),
'</details>',
'',
'### Startup JS chunks',
'<details>',
`<summary>Added (${addedRows.length})</summary>`,
'',
markdownTable(startupRows),
markdownChunkTable(addedRows),
'',
'_Top 10 is sorted by max(before, after) size. Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._',
'</details>',
'',
'<details>',
`<summary>Removed (${removedRows.length})</summary>`,
'',
markdownChunkTable(removedRows),
'',
'</details>',
'',
'<details>',
`<summary>Startup</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
`_Only ${locale} localized chunks are reported. Size comparison tables include chunks that exist in both builds. Added and removed chunks are listed separately. Top 10 is sorted by max(before, after) size. Diff top 10 is sorted by absolute size diff. Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
'<details>',
`<summary>Largest</summary>`,
'',
markdownTable(largeRows),
'',
'</details>',
'',
].join('\n');
@@ -330,14 +438,18 @@ jobs:
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p frontend-js-size-report
node .github/tmp/frontend-js-size-report.mjs before after frontend-js-size-report.md
cat frontend-js-size-report.md >> "$GITHUB_STEP_SUMMARY"
mv frontend-js-size-report.md frontend-js-size-report/report.md
printf '%s\n' "$PR_NUMBER" > frontend-js-size-report/pr-number.txt
cat frontend-js-size-report/report.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload size report
uses: actions/upload-artifact@v7
with:
name: frontend-js-size-report
path: frontend-js-size-report.md
path: frontend-js-size-report/
if-no-files-found: error
retention-days: 1

View File

@@ -10,7 +10,6 @@ on:
- packages/backend/**
- packages/misskey-js/**
- .github/workflows/get-backend-memory.yml
- .github/workflows/report-backend-memory.yml
jobs:
get-memory-usage:
@@ -18,6 +17,15 @@ 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
@@ -32,113 +40,37 @@ jobs:
- 56312:6379
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.2
with:
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
ref: ${{ matrix.ref }}
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: 'head/.node-version'
node-version-file: '.node-version'
cache: 'pnpm'
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: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Configure base
working-directory: base
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build base
working-directory: base
- 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: 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
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build head
working-directory: head
run: pnpm build
- name: Measure base memory usage
working-directory: base
run: |
node --input-type=module <<'EOF'
import pg from 'pg';
import Redis from 'ioredis';
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
await postgres.end();
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
await redis.flushall();
redis.disconnect();
EOF
pnpm --filter backend migrate
node packages/backend/scripts/measure-memory.mjs > ../memory-base.json
- name: Measure head memory usage
working-directory: head
run: |
node --input-type=module <<'EOF'
import pg from 'pg';
import Redis from 'ioredis';
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
await postgres.end();
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
await redis.flushall();
redis.disconnect();
EOF
pnpm --filter backend migrate
node packages/backend/scripts/measure-memory.mjs > ../memory-head.json
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: memory-artifact-results
path: |
memory-base.json
memory-head.json
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
save-pr-number:
runs-on: ubuntu-latest

View File

@@ -56,25 +56,20 @@ jobs:
variation() {
calc() {
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // empty")
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // empty")
if [ -z "$BASE" ] || [ -z "$HEAD" ]; then
echo "null"
return
fi
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=$(awk -v diff="$DIFF" -v base="$BASE" 'BEGIN { printf "%.2f", (diff * 100) / base }')
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
else
DIFF_PERCENT=0.00
DIFF_PERCENT=0
fi
# Convert KB to MB for readability
BASE_MB=$(awk -v value="$BASE" 'BEGIN { printf "%.2f", value / 1024 }')
HEAD_MB=$(awk -v value="$HEAD" 'BEGIN { printf "%.2f", value / 1024 }')
DIFF_MB=$(awk -v value="$DIFF" 'BEGIN { printf "%.2f", value / 1024 }')
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" \
@@ -87,20 +82,11 @@ jobs:
}
JSON=$(jq -c -n \
--argjson HeapUsed "$(calc $1 HeapUsed)" \
--argjson HeapTotal "$(calc $1 HeapTotal)" \
--argjson External "$(calc $1 External)" \
--argjson ArrayBuffers "$(calc $1 ArrayBuffers)" \
--argjson Pss "$(calc $1 Pss)" \
--argjson Private_Dirty "$(calc $1 Private_Dirty)" \
--argjson Private_Clean "$(calc $1 Private_Clean)" \
--argjson Shared_Dirty "$(calc $1 Shared_Dirty)" \
--argjson Shared_Clean "$(calc $1 Shared_Clean)" \
--argjson VmRSS "$(calc $1 VmRSS)" \
--argjson VmHWM "$(calc $1 VmHWM)" \
--argjson VmSize "$(calc $1 VmSize)" \
--argjson VmData "$(calc $1 VmData)" \
'{HeapUsed: $HeapUsed, HeapTotal: $HeapTotal, External: $External, ArrayBuffers: $ArrayBuffers, Pss: $Pss, Private_Dirty: $Private_Dirty, Private_Clean: $Private_Clean, Shared_Dirty: $Shared_Dirty, Shared_Clean: $Shared_Clean, VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
echo "$JSON"
}
@@ -128,10 +114,6 @@ jobs:
echo "|--------|------:|------:|------:|------:|" >> ./output.md
line() {
if [ "$(echo "$RES" | jq -r ".${1}.${2} == null")" = "true" ]; then
return
fi
METRIC=$2
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
@@ -143,8 +125,8 @@ jobs:
DIFF_PERCENT="+$DIFF_PERCENT"
fi
# highlight the most useful process and OS memory metrics
if [ "$2" = "HeapUsed" ] || [ "$2" = "Pss" ]; then
# highlight VmRSS
if [ "$2" = "VmRSS" ]; then
METRIC="**${METRIC}**"
BASE="**${BASE}**"
HEAD="**${HEAD}**"
@@ -155,15 +137,6 @@ jobs:
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
}
line $1 HeapUsed
line $1 HeapTotal
line $1 External
line $1 ArrayBuffers
line $1 Pss
line $1 Private_Dirty
line $1 Private_Clean
line $1 Shared_Dirty
line $1 Shared_Clean
line $1 VmRSS
line $1 VmHWM
line $1 VmSize
@@ -183,9 +156,8 @@ jobs:
echo >> ./output.md
# Determine if this is a significant change (more than 5% increase)
WARNING_METRIC=$(echo "$RES" | jq -r 'if .afterGc.Pss != null then "Pss" elif .afterGc.VmRSS != null then "VmRSS" else empty end')
if [ -n "$WARNING_METRIC" ] && [ "$(echo "$RES" | jq -r ".afterGc.${WARNING_METRIC}.diff_percent | tonumber > 5")" = "true" ]; then
echo "⚠️ **Warning**: Memory usage (${WARNING_METRIC}) has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
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

View File

@@ -25,6 +25,7 @@
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
- Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善
- Enhance: ActivityPub の画像添付に width/height を含めるように
- Enhance: URLプレビューのデフォルトの User Agent に Misskey サーバーのURLを含めるように
- Fix: backend バンドルで `@tensorflow/tfjs-node` を external に含めず、起動時に `@mapbox/node-pre-gyp``find()` が backend の package.json を誤検出して `is not node-pre-gyp ready` エラーを永続的に吐く問題を修正
- Fix: MemoryKVCacheのキャッシュGC処理において、更新されたキャッシュが期限切れにならないことがある問題を修正
- Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498)

View File

@@ -64,7 +64,7 @@
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@nestjs/common": "11.1.26",
"@nestjs/core": "11.1.26",

View File

@@ -1,4 +1,5 @@
import { defineConfig } from 'rolldown';
import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
@@ -84,6 +85,11 @@ export default defineConfig((args) => {
'file-type',
];
const define: Record<string, string> = {
// Summalyのバージョンを埋め込む
'_SUMMALY_VERSION_': JSON.stringify(summalyVersion),
};
if (isE2E) {
return {
input: './test-server/entry.ts',
@@ -92,6 +98,9 @@ export default defineConfig((args) => {
plugins: [
esmShim(),
],
transform: {
define,
},
output: {
keepNames: true,
sourcemap: true,
@@ -116,6 +125,9 @@ export default defineConfig((args) => {
esmShim(),
(isWatchMode ? backendDevServerPlugin() : undefined),
],
transform: {
define,
},
output: {
keepNames: true,
minify: !isWatchMode,

View File

@@ -23,9 +23,8 @@ 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
const IPC_TIMEOUT = 30000; // 30 seconds timeout for IPC responses
const procStatusKeys = {
const keys = {
VmPeak: 0,
VmSize: 0,
VmHWM: 0,
@@ -38,152 +37,30 @@ const procStatusKeys = {
VmSwap: 0,
};
const smapsRollupKeys = {
Pss: 0,
Shared_Clean: 0,
Shared_Dirty: 0,
Private_Clean: 0,
Private_Dirty: 0,
Swap: 0,
SwapPss: 0,
};
async function getMemoryUsage(pid) {
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
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`));
const match = status.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}`);
} else {
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
}
}
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'), [], {
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
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'],
@@ -213,18 +90,15 @@ async function measureMemory() {
});
async function triggerGc() {
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
'GC completion',
);
const ok = new Promise((resolve) => {
serverProcess.once('message', (message) => {
if (message === 'gc ok') resolve();
});
});
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 ok;
await setTimeout(1000);
}
@@ -265,11 +139,13 @@ async function measureMemory() {
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
const beforeGc = await getAllMemoryUsage(serverProcess);
const pid = serverProcess.pid;
const beforeGc = await getMemoryUsage(pid);
await triggerGc();
const afterGc = await getAllMemoryUsage(serverProcess);
const afterGc = await getMemoryUsage(pid);
// create some http requests to simulate load
const REQUEST_COUNT = 10;
@@ -279,7 +155,7 @@ async function measureMemory() {
await triggerGc();
const afterRequest = await getAllMemoryUsage(serverProcess);
const afterRequest = await getMemoryUsage(pid);
// Stop the server
serverProcess.kill('SIGTERM');
@@ -311,21 +187,35 @@ 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);
}
const summary = summarizeResults(results);
// 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 result = {
timestamp: new Date().toISOString(),
sampleCount: SAMPLE_COUNT,
aggregation: 'median',
...summary,
samples: results,
beforeGc,
afterGc,
afterRequest,
};
// Output as JSON to stdout

View File

@@ -0,0 +1,6 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare const _SUMMALY_VERSION_: string;

View File

@@ -6,7 +6,6 @@
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;
@@ -32,7 +31,7 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
if (process.env.NODE_ENV !== 'test') {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
@@ -55,9 +54,7 @@ export async function jobQueue() {
});
jobQueue.get(QueueProcessorService).start();
if (!envOption.noDaemons) {
jobQueue.get(ChartManagementService).start();
}
jobQueue.get(ChartManagementService).start();
return jobQueue;
}

View File

@@ -91,20 +91,10 @@ process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
for (let i = 0; i < 3; i++) {
global.gc();
}
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(),
});
}
}
});

View File

@@ -19,6 +19,7 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
export class UrlPreviewService {
private logger: Logger;
private readonly summalyDefaultUserAgent: string;
constructor(
@Inject(DI.config)
@@ -31,6 +32,7 @@ export class UrlPreviewService {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`;
}
@bindThis
@@ -126,7 +128,7 @@ export class UrlPreviewService {
followRedirects: this.meta.urlPreviewAllowRedirect,
lang: lang ?? 'ja-JP',
agent: agent,
userAgent: meta.urlPreviewUserAgent ?? undefined,
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,
@@ -139,7 +141,7 @@ export class UrlPreviewService {
url: url,
lang: lang ?? 'ja-JP',
followRedirects: this.meta.urlPreviewAllowRedirect,
userAgent: meta.urlPreviewUserAgent ?? undefined,
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,

View File

@@ -29,7 +29,7 @@
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.9",

View File

@@ -72,7 +72,7 @@
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@storybook/addon-essentials": "8.6.18",

615
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff