From 5c5e9651519b944740944c32046e1a0c7bdafba8 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:51:38 +0900 Subject: fix(ci): dockleのciをより安定して動かせるようにする (#16987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dockle.yml | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index 45b8d23dda..ec7073c9fd 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -11,6 +11,7 @@ on: jobs: dockle: runs-on: ubuntu-latest + env: DOCKER_CONTENT_TRUST: 1 DOCKLE_VERSION: 0.4.15 @@ -20,29 +21,33 @@ jobs: - name: Download and install dockle v${{ env.DOCKLE_VERSION }} run: | + set -eux curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.deb" sudo dpkg -i dockle.deb - - run: | - cp .config/docker_example.env .config/docker.env - cp ./compose_example.yml ./compose.yml - - - run: | - docker compose up -d web - IMAGE_ID=$(docker compose images --format json web | jq -r '.[0].ID') - docker tag "${IMAGE_ID}" misskey-web:latest - - - name: Prune docker junk (optional but recommended) + - name: Build web image (docker build) run: | - docker system prune -af - docker volume prune -f + set -eux + docker build -t "misskey-web:ci" . + docker image ls - - name: Save image for Dockle + - name: Mount tmpfs for Dockle tar + env: + TMPFS_SIZE: 8G run: | - docker save misskey-web:latest -o ./misskey-web.tar - ls -lh ./misskey-web.tar + set -eux + sudo mkdir -p /mnt/dockle-tmp + sudo mount -t tmpfs -o size=${{ env.TMPFS_SIZE }} tmpfs /mnt/dockle-tmp + free -h + df -h - - name: Run Dockle with tar input + - name: Save image tar into tmpfs run: | - dockle --exit-code 1 --input ./misskey-web.tar + set -eux + docker save misskey-web:ci -o /mnt/dockle-tmp/misskey-web.tar + ls -lh /mnt/dockle-tmp/misskey-web.tar + - name: Run Dockle Scan (tar input) + run: | + set -eux + dockle --exit-code 1 --input /mnt/dockle-tmp/misskey-web.tar -- cgit v1.2.3-freya From b69b0acf59527a024798d3415ac179fd1a0b0c00 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:57:18 +0900 Subject: chore: SearchServiceのunit-test追加 (#17035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add serach service test * add meili test * CIの修正が足りなかった * テストの追加 * fix --- .github/workflows/test-backend.yml | 7 + packages/backend/test/compose.yml | 8 + packages/backend/test/unit/SearchService.ts | 483 ++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 packages/backend/test/unit/SearchService.ts (limited to '.github/workflows') diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 562ec76b85..77bbdb2b0a 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -48,6 +48,13 @@ jobs: image: redis:7 ports: - 56312:6379 + meilisearch: + image: getmeili/meilisearch:v1.3.4 + ports: + - 57712:7700 + env: + MEILI_NO_ANALYTICS: true + MEILI_ENV: development steps: - uses: actions/checkout@v6.0.1 diff --git a/packages/backend/test/compose.yml b/packages/backend/test/compose.yml index fe96616fc0..4f1dba6428 100644 --- a/packages/backend/test/compose.yml +++ b/packages/backend/test/compose.yml @@ -11,3 +11,11 @@ services: environment: POSTGRES_DB: "test-misskey" POSTGRES_HOST_AUTH_METHOD: trust + + meilisearchtest: + image: getmeili/meilisearch:v1.3.4 + ports: + - "127.0.0.1:57712:7700" + environment: + - MEILI_NO_ANALYTICS=true + - MEILI_ENV=development diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts new file mode 100644 index 0000000000..6e17bef1c3 --- /dev/null +++ b/packages/backend/test/unit/SearchService.ts @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Index, MeiliSearch } from 'meilisearch'; +import { type Config, loadConfig } from '@/config.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { SearchService } from '@/core/SearchService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { + type BlockingsRepository, + type ChannelsRepository, + type FollowingsRepository, + type MutingsRepository, + type NotesRepository, + type UserProfilesRepository, + type UsersRepository, + type MiChannel, + type MiNote, + type MiUser, +} from '@/models/_.js'; + +describe('SearchService', () => { + type TestContext = { + app: TestingModule; + service: SearchService; + cacheService: CacheService; + idService: IdService; + mutingsRepository: MutingsRepository; + blockingsRepository: BlockingsRepository; + usersRepository: UsersRepository; + userProfilesRepository: UserProfilesRepository; + notesRepository: NotesRepository; + channelsRepository: ChannelsRepository; + followingsRepository: FollowingsRepository; + indexer?: (note: MiNote) => Promise; + }; + + const meilisearchSettings = { + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + 'tags', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }; + + async function buildContext(configOverride?: Config): Promise { + const builder = Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + }); + + if (configOverride) { + builder.overrideProvider(DI.config).useValue(configOverride); + } + + const app = await builder.compile(); + + app.enableShutdownHooks(); + + return { + app, + service: app.get(SearchService), + cacheService: app.get(CacheService), + idService: app.get(IdService), + mutingsRepository: app.get(DI.mutingsRepository), + blockingsRepository: app.get(DI.blockingsRepository), + usersRepository: app.get(DI.usersRepository), + userProfilesRepository: app.get(DI.userProfilesRepository), + notesRepository: app.get(DI.notesRepository), + channelsRepository: app.get(DI.channelsRepository), + followingsRepository: app.get(DI.followingsRepository), + }; + } + + async function cleanupContext(ctx: TestContext) { + await ctx.notesRepository.createQueryBuilder().delete().execute(); + await ctx.mutingsRepository.createQueryBuilder().delete().execute(); + await ctx.blockingsRepository.createQueryBuilder().delete().execute(); + await ctx.followingsRepository.createQueryBuilder().delete().execute(); + await ctx.channelsRepository.createQueryBuilder().delete().execute(); + await ctx.userProfilesRepository.createQueryBuilder().delete().execute(); + await ctx.usersRepository.createQueryBuilder().delete().execute(); + } + + async function createUser(ctx: TestContext, data: Partial = {}) { + const id = ctx.idService.gen(); + const username = data.username ?? `user_${id}`; + const usernameLower = data.usernameLower ?? username.toLowerCase(); + + const user = await ctx.usersRepository + .insert({ + id, + username, + usernameLower, + ...data, + }) + .then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0])); + + await ctx.userProfilesRepository.insert({ + userId: id, + }); + + return user; + } + + async function createChannel(ctx: TestContext, user: MiUser, data: Partial = {}) { + const id = ctx.idService.gen(); + const channel = await ctx.channelsRepository + .insert({ + id, + userId: user.id, + name: data.name ?? `channel_${id}`, + ...data, + }) + .then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0])); + + return channel; + } + + async function createNote(ctx: TestContext, user: MiUser, data: Partial = {}, time?: number) { + const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time); + const note = await ctx.notesRepository + .insert({ + id, + text: 'hello', + userId: user.id, + userHost: user.host, + visibility: 'public', + tags: [], + ...data, + }) + .then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0])); + + if (ctx.indexer) { + await ctx.indexer(note); + } + + return note; + } + + async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) { + await ctx.followingsRepository.insert({ + id: ctx.idService.gen(), + followerId: follower.id, + followeeId: followee.id, + followerHost: follower.host, + followeeHost: followee.host, + }); + } + + function clearUserCaches(ctx: TestContext, userId: MiUser['id']) { + ctx.cacheService.userMutingsCache.delete(userId); + ctx.cacheService.userBlockedCache.delete(userId); + ctx.cacheService.userBlockingCache.delete(userId); + } + + async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) { + await ctx.mutingsRepository.insert({ + id: ctx.idService.gen(), + muterId: muter.id, + muteeId: mutee.id, + }); + clearUserCaches(ctx, muter.id); + } + + async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) { + await ctx.blockingsRepository.insert({ + id: ctx.idService.gen(), + blockerId: blocker.id, + blockeeId: blockee.id, + }); + clearUserCaches(ctx, blocker.id); + clearUserCaches(ctx, blockee.id); + } + + function defineSearchNoteTests( + getCtx: () => TestContext, + { + supportsFollowersVisibility, + sinceIdOrder, + }: { + supportsFollowersVisibility: boolean; + sinceIdOrder: 'asc' | 'desc'; + }, + ) { + describe('searchNote', () => { + test('filters notes by visibility (followers only visible to followers)', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' }); + const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' }); + + const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]); + + await createFollowing(ctx, me, author); + + const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + const expectedIds = supportsFollowersVisibility + ? [followersNote.id, publicNote.id] + : [publicNote.id]; + expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort()); + }); + + test('filters out suspended users via base note filtering', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null }); + const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true }); + + const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' }); + await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([activeNote.id]); + }); + + test('filters by userId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null }); + const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null }); + + const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' }); + await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([aliceNote.id]); + }); + + test('filters by channelId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + const channelA = await createChannel(ctx, author, { name: 'channel-a' }); + const channelB = await createChannel(ctx, author, { name: 'channel-b' }); + + const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' }); + await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([channelNote.id]); + }); + + test('filters by host', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null }); + const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' }); + + const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' }); + const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' }); + + const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 }); + expect(localResult.map(note => note.id)).toEqual([localNote.id]); + + const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 }); + expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]); + }); + + describe('muting and blocking', () => { + test('filters out muted users', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createMuting(ctx, me, muted); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters out users who block me', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, blocker, me); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters no out users I block', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, me, blocked); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort()); + }); + }); + + describe('pagination', () => { + test('paginates with sinceId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id }); + + const expected = sinceIdOrder === 'asc' + ? [note2.id, note3.id] + : [note3.id, note2.id]; + expect(result.map(note => note.id)).toEqual(expected); + }); + + test('paginates with untilId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id }); + + expect(result.map(note => note.id)).toEqual([note2.id, note1.id]); + }); + + test('paginates with sinceId and untilId together', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 4000; + const t2 = Date.now() - 3000; + const t3 = Date.now() - 2000; + const t4 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + const note4 = await createNote(ctx, author, { text: 'hello' }, t4); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id }); + + expect(result.map(note => note.id)).toEqual([note3.id, note2.id]); + }); + }); + }); + } + + describe('sqlLike', () => { + let ctx: TestContext; + + beforeAll(async () => { + ctx = await buildContext(); + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' }); + }); + + describe('meilisearch', () => { + let ctx: TestContext; + let meilisearch: MeiliSearch; + let meilisearchIndex: Index; + let meiliConfig: Config; + + beforeAll(async () => { + const baseConfig = loadConfig(); + meiliConfig = { + ...baseConfig, + fulltextSearch: { + provider: 'meilisearch', + }, + meilisearch: { + host: '127.0.0.1', + port: '57712', + apiKey: '', + index: 'test-search-service', + scope: 'global', + ssl: false, + }, + }; + + ctx = await buildContext(meiliConfig); + meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch; + meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`); + + const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings); + await meilisearch.tasks.waitForTask(settingsTask.taskUid); + + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + + ctx.indexer = async (note: MiNote) => { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return; + + const task = await meilisearchIndex.addDocuments([{ + id: note.id, + createdAt: ctx.idService.parse(note.id).date.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + tags: note.tags, + }], { + primaryKey: 'id', + }); + await meilisearch.tasks.waitForTask(task.taskUid); + }; + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' }); + }); +}); -- cgit v1.2.3-freya From f744b5711f12a76a7be98bba41d6552737593a79 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:14:05 +0900 Subject: enhance(dev): improve mem report (#17117) * wip * Update report-backend-memory.yml --- .github/workflows/report-backend-memory.yml | 67 +++++++++++------------ packages/backend/scripts/measure-memory.mjs | 82 +++++++++++++---------------- 2 files changed, 69 insertions(+), 80 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index c339ca49b4..47ec652cfd 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -54,55 +54,50 @@ jobs: BASE_MEMORY=$(cat ./artifacts/memory-base.json) HEAD_MEMORY=$(cat ./artifacts/memory-head.json) - BASE_RSS=$(echo "$BASE_MEMORY" | jq -r '.memory.rss // 0') - HEAD_RSS=$(echo "$HEAD_MEMORY" | jq -r '.memory.rss // 0') + calc() { + BASE=$(echo "$BASE_MEMORY" | jq -r '.memory.'"$1"' // 0') + HEAD=$(echo "$HEAD_MEMORY" | jq -r '.memory.'"$1"' // 0') - # Calculate difference - if [ "$BASE_RSS" -gt 0 ] && [ "$HEAD_RSS" -gt 0 ]; then - DIFF=$((HEAD_RSS - BASE_RSS)) - DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE_RSS" | bc) + DIFF=$((HEAD - BASE)) + if [ "$BASE" -gt 0 ]; then + DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc) + else + DIFF_PERCENT=0 + fi - # Convert to MB for readability - BASE_MB=$(echo "scale=2; $BASE_RSS / 1048576" | bc) - HEAD_MB=$(echo "scale=2; $HEAD_RSS / 1048576" | bc) - DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc) + # 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) - echo "base_mb=$BASE_MB" >> "$GITHUB_OUTPUT" - echo "head_mb=$HEAD_MB" >> "$GITHUB_OUTPUT" - echo "diff_mb=$DIFF_MB" >> "$GITHUB_OUTPUT" - echo "diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT" - echo "has_data=true" >> "$GITHUB_OUTPUT" + echo "$1-base=$BASE_MB" >> "$GITHUB_OUTPUT" + echo "$1-head=$HEAD_MB" >> "$GITHUB_OUTPUT" + echo "$1-diff=$DIFF_MB" >> "$GITHUB_OUTPUT" + echo "$1-diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT" + } - # Determine if this is a significant change (more than 5% increase) - if [ "$(echo "$DIFF_PERCENT > 5" | bc)" -eq 1 ]; then - echo "significant_increase=true" >> "$GITHUB_OUTPUT" - else - echo "significant_increase=false" >> "$GITHUB_OUTPUT" - fi - else - echo "has_data=false" >> "$GITHUB_OUTPUT" - fi + calc VmRSS + calc VmHWM + calc VmSize - id: build-comment name: Build memory comment run: | - HEADER="## Backend Memory Usage Comparison" + 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 - if [ "${{ steps.compare.outputs.has_data }}" == "true" ]; then - echo "| Metric | base | head | Diff |" >> ./output.md - echo "|--------|------|------|------|" >> ./output.md - echo "| RSS | ${{ steps.compare.outputs.base_mb }} MB | ${{ steps.compare.outputs.head_mb }} MB | ${{ steps.compare.outputs.diff_mb }} MB (${{ steps.compare.outputs.diff_percent }}%) |" >> ./output.md - echo >> ./output.md + echo "| Metric | base | head | Diff |" >> ./output.md + echo "|--------|------|------|------|" >> ./output.md + echo "| RSS | ${{ steps.compare.outputs.VmRSS-base }} MB | ${{ steps.compare.outputs.VmRSS-head }} MB | ${{ steps.compare.outputs.VmRSS-diff }} MB (${{ steps.compare.outputs.VmRSS-diff_percent }}%) |" >> ./output.md + echo "| HWM | ${{ steps.compare.outputs.VmHWM-base }} MB | ${{ steps.compare.outputs.VmHWM-head }} MB | ${{ steps.compare.outputs.VmHWM-diff }} MB (${{ steps.compare.outputs.VmHWM-diff_percent }}%) |" >> ./output.md + echo "| VMS | ${{ steps.compare.outputs.VmSize-base }} MB | ${{ steps.compare.outputs.VmSize-head }} MB | ${{ steps.compare.outputs.VmSize-diff }} MB (${{ steps.compare.outputs.VmSize-diff_percent }}%) |" >> ./output.md + echo >> ./output.md - if [ "${{ steps.compare.outputs.significant_increase }}" == "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 - else - echo "Could not retrieve memory usage data." >> ./output.md + # Determine if this is a significant change (more than 5% increase) + if [ "$(echo "${{ steps.compare.outputs.VmRSS-diff_percent }} > 5" | bc)" -eq 1 ]; 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 diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs index 4358bfee5b..82a5a0bf0b 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mjs @@ -14,6 +14,7 @@ import { fork } from 'node:child_process'; import { setTimeout } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import * as fs from 'node:fs/promises'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -22,6 +23,35 @@ 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 keys = { + VmPeak: 0, + VmSize: 0, + VmHWM: 0, + VmRSS: 0, + VmData: 0, + VmStk: 0, + VmExe: 0, + VmLib: 0, + VmPTE: 0, + VmSwap: 0, +}; + +async function getMemoryUsage(pid) { + const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8'); + + const result = {}; + for (const key of Object.keys(keys)) { + const match = status.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`); + } + } + + return result; +} + async function measureMemory() { // Start the Misskey backend server using fork to enable IPC const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], { @@ -76,39 +106,7 @@ async function measureMemory() { // Get memory usage from the server process via /proc const pid = serverProcess.pid; - let memoryInfo; - - try { - const fs = await import('node:fs/promises'); - - // Read /proc/[pid]/status for detailed memory info - const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8'); - const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/); - const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/); - const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/); - - memoryInfo = { - rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null, - heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null, - vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null, - }; - } catch (err) { - // Fallback: use ps command - process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`); - - const { execSync } = await import('node:child_process'); - try { - const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' }); - const rssKb = parseInt(ps.trim(), 10); - memoryInfo = { - rss: rssKb * 1024, - heapUsed: null, - vmSize: null, - }; - } catch { - throw new Error('Failed to get memory usage via ps command'); - } - } + const memoryInfo = await getMemoryUsage(pid); // Stop the server serverProcess.kill('SIGTERM'); @@ -146,19 +144,15 @@ async function main() { } // Calculate averages - const avgMemory = { - rss: 0, - heapUsed: 0, - vmSize: 0, - }; + const avgMemory = structuredClone(keys); for (const res of results) { - avgMemory.rss += res.memory.rss ?? 0; - avgMemory.heapUsed += res.memory.heapUsed ?? 0; - avgMemory.vmSize += res.memory.vmSize ?? 0; + for (const key of Object.keys(avgMemory)) { + avgMemory[key] += res.memory[key]; + } + } + for (const key of Object.keys(avgMemory)) { + avgMemory[key] = Math.round(avgMemory[key] / SAMPLE_COUNT); } - avgMemory.rss = Math.round(avgMemory.rss / SAMPLE_COUNT); - avgMemory.heapUsed = Math.round(avgMemory.heapUsed / SAMPLE_COUNT); - avgMemory.vmSize = Math.round(avgMemory.vmSize / SAMPLE_COUNT); const result = { timestamp: new Date().toISOString(), -- cgit v1.2.3-freya From 2fa6ecc7efaaf9b9d189cdd3a3ebbb9171c86078 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:32:57 +0900 Subject: enhance(dev): improve mem report (#17118) * wip * wip * Update report-backend-memory.yml * Update report-backend-memory.yml * Update .github/workflows/report-backend-memory.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/report-backend-memory.yml | 93 ++++++++++++++++++++--------- packages/backend/scripts/measure-memory.mjs | 37 ++++++++---- packages/backend/src/boot/entry.ts | 14 ++++- packages/backend/src/env.ts | 1 - 4 files changed, 102 insertions(+), 43 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index 47ec652cfd..451a8cf9e6 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -54,31 +54,48 @@ jobs: BASE_MEMORY=$(cat ./artifacts/memory-base.json) HEAD_MEMORY=$(cat ./artifacts/memory-head.json) - calc() { - BASE=$(echo "$BASE_MEMORY" | jq -r '.memory.'"$1"' // 0') - HEAD=$(echo "$HEAD_MEMORY" | jq -r '.memory.'"$1"' // 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) - - echo "$1-base=$BASE_MB" >> "$GITHUB_OUTPUT" - echo "$1-head=$HEAD_MB" >> "$GITHUB_OUTPUT" - echo "$1-diff=$DIFF_MB" >> "$GITHUB_OUTPUT" - echo "$1-diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT" + 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 \ + --arg base "$BASE_MB" \ + --arg head "$HEAD_MB" \ + --arg diff "$DIFF_MB" \ + --arg 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)" \ + '{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize}') + + echo "$JSON" } - calc VmRSS - calc VmHWM - calc VmSize + JSON=$(jq -c -n \ + --argjson beforeGc "$(variation beforeGc)" \ + --argjson afterGc "$(variation afterGc)" \ + '{beforeGc: $beforeGc, afterGc: $afterGc}') + + echo "res=$JSON" >> "$GITHUB_OUTPUT" - id: build-comment name: Build memory comment run: | @@ -88,15 +105,33 @@ jobs: echo "$HEADER" > ./output.md echo >> ./output.md - echo "| Metric | base | head | Diff |" >> ./output.md - echo "|--------|------|------|------|" >> ./output.md - echo "| RSS | ${{ steps.compare.outputs.VmRSS-base }} MB | ${{ steps.compare.outputs.VmRSS-head }} MB | ${{ steps.compare.outputs.VmRSS-diff }} MB (${{ steps.compare.outputs.VmRSS-diff_percent }}%) |" >> ./output.md - echo "| HWM | ${{ steps.compare.outputs.VmHWM-base }} MB | ${{ steps.compare.outputs.VmHWM-head }} MB | ${{ steps.compare.outputs.VmHWM-diff }} MB (${{ steps.compare.outputs.VmHWM-diff_percent }}%) |" >> ./output.md - echo "| VMS | ${{ steps.compare.outputs.VmSize-base }} MB | ${{ steps.compare.outputs.VmSize-head }} MB | ${{ steps.compare.outputs.VmSize-diff }} MB (${{ steps.compare.outputs.VmSize-diff_percent }}%) |" >> ./output.md + table() { + line() { + BASE=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.base") + HEAD=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.head") + DIFF=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.diff") + DIFF_PERCENT=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.diff_percent") + + echo "| ${2} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB (${DIFF_PERCENT}%) |" >> ./output.md + } + + echo "| Metric | base | head | Diff |" >> ./output.md + echo "|--------|------|------|------|" >> ./output.md + line $1 VmRSS + line $1 VmHWM + line $1 VmSize + } + + echo "### Before GC" >> ./output.md + table beforeGc + echo >> ./output.md + + echo "### After GC" >> ./output.md + table afterGc echo >> ./output.md # Determine if this is a significant change (more than 5% increase) - if [ "$(echo "${{ steps.compare.outputs.VmRSS-diff_percent }} > 5" | bc)" -eq 1 ]; then + if [ "$(echo "${{ steps.compare.outputs.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 diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs index 82a5a0bf0b..749f550bcc 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mjs @@ -60,9 +60,9 @@ async function measureMemory() { ...process.env, NODE_ENV: 'production', MK_DISABLE_CLUSTERING: '1', - MK_FORCE_GC: '1', }, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + execArgv: [...process.execArgv, '--expose-gc'], }); let serverReady = false; @@ -104,9 +104,21 @@ async function measureMemory() { // Wait for memory to settle await setTimeout(MEMORY_SETTLE_TIME); - // Get memory usage from the server process via /proc const pid = serverProcess.pid; - const memoryInfo = await getMemoryUsage(pid); + + const beforeGc = await getMemoryUsage(pid); + + serverProcess.send('gc'); + + await new Promise((resolve) => { + serverProcess.once('message', (message) => { + if (message === 'gc ok') resolve(); + }); + }); + + await setTimeout(1000); + + const afterGc = await getMemoryUsage(pid); // Stop the server serverProcess.kill('SIGTERM'); @@ -129,7 +141,8 @@ async function measureMemory() { const result = { timestamp: new Date().toISOString(), - memory: memoryInfo, + beforeGc, + afterGc, }; return result; @@ -144,19 +157,23 @@ async function main() { } // Calculate averages - const avgMemory = structuredClone(keys); + const beforeGc = structuredClone(keys); + const afterGc = structuredClone(keys); for (const res of results) { - for (const key of Object.keys(avgMemory)) { - avgMemory[key] += res.memory[key]; + for (const key of Object.keys(keys)) { + beforeGc[key] += res.beforeGc[key]; + afterGc[key] += res.afterGc[key]; } } - for (const key of Object.keys(avgMemory)) { - avgMemory[key] = Math.round(avgMemory[key] / SAMPLE_COUNT); + for (const key of Object.keys(keys)) { + beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT); + afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT); } const result = { timestamp: new Date().toISOString(), - memory: avgMemory, + beforeGc, + afterGc, }; // Output as JSON to stdout diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 56b339b6aa..3a33d198a5 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -86,9 +86,17 @@ if (!envOption.disableClustering) { ev.mount(); } -if (envOption.forceGc && global.gc != null) { - global.gc(); -} +process.on('message', msg => { + if (msg === 'gc') { + if (global.gc != null) { + logger.info('Manual GC triggered'); + 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.'); + } + } +}); readyRef.value = true; diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index 9957938467..ba44cfa2e6 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -11,7 +11,6 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, - forceGc: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { -- cgit v1.2.3-freya From b6e737dc7635b5f1b7c97c5aca8771f6e73561cf Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:47:05 +0900 Subject: Update report-backend-memory.yml --- .github/workflows/report-backend-memory.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index 451a8cf9e6..a668bb27a1 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -72,10 +72,10 @@ jobs: DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc) JSON=$(jq -c -n \ - --arg base "$BASE_MB" \ - --arg head "$HEAD_MB" \ - --arg diff "$DIFF_MB" \ - --arg diff_percent "$DIFF_PERCENT" \ + --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" -- cgit v1.2.3-freya From 1adcb03b93823886007f7f0ad09cbb517b90679b Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:01:38 +0900 Subject: Update report-backend-memory.yml --- .github/workflows/report-backend-memory.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index a668bb27a1..30918f44db 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -98,6 +98,8 @@ jobs: echo "res=$JSON" >> "$GITHUB_OUTPUT" - 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})" @@ -107,10 +109,10 @@ jobs: table() { line() { - BASE=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.base") - HEAD=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.head") - DIFF=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.diff") - DIFF_PERCENT=$(echo "${{ steps.compare.outputs.res }}" | jq -r ".${1}.${2}.diff_percent") + 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") echo "| ${2} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB (${DIFF_PERCENT}%) |" >> ./output.md } @@ -131,7 +133,7 @@ jobs: echo >> ./output.md # Determine if this is a significant change (more than 5% increase) - if [ "$(echo "${{ steps.compare.outputs.res }}" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then + 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 -- cgit v1.2.3-freya From a168e7b6486013d05ba836d9a1d084a7708c19fb Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:53:53 +0900 Subject: enhance(dev): Improve mem report (#17119) * wip * Update report-backend-memory.yml * Update report-backend-memory.yml * Update measure-memory.mjs * Update report-backend-memory.yml --- .github/workflows/report-backend-memory.yml | 33 +++++++++++++--- packages/backend/scripts/measure-memory.mjs | 58 +++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 13 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index 30918f44db..bf2e311c83 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -85,7 +85,8 @@ jobs: --argjson VmRSS "$(calc $1 VmRSS)" \ --argjson VmHWM "$(calc $1 VmHWM)" \ --argjson VmSize "$(calc $1 VmSize)" \ - '{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize}') + --argjson VmData "$(calc $1 VmData)" \ + '{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}') echo "$JSON" } @@ -93,7 +94,8 @@ jobs: JSON=$(jq -c -n \ --argjson beforeGc "$(variation beforeGc)" \ --argjson afterGc "$(variation afterGc)" \ - '{beforeGc: $beforeGc, afterGc: $afterGc}') + --argjson afterRequest "$(variation afterRequest)" \ + '{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}') echo "res=$JSON" >> "$GITHUB_OUTPUT" - id: build-comment @@ -108,20 +110,37 @@ jobs: 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") - echo "| ${2} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB (${DIFF_PERCENT}%) |" >> ./output.md + 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 } - echo "| Metric | base | head | Diff |" >> ./output.md - echo "|--------|------|------|------|" >> ./output.md line $1 VmRSS line $1 VmHWM line $1 VmSize + line $1 VmData } echo "### Before GC" >> ./output.md @@ -132,6 +151,10 @@ jobs: 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 diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs index 749f550bcc..3f30e24fb4 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mjs @@ -14,6 +14,7 @@ import { fork } from 'node:child_process'; import { setTimeout } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import * as http from 'node:http'; import * as fs from 'node:fs/promises'; const __filename = fileURLToPath(import.meta.url); @@ -88,6 +89,40 @@ async function measureMemory() { process.stderr.write(`[server error] ${err}\n`); }); + async function triggerGc() { + const ok = new Promise((resolve) => { + serverProcess.once('message', (message) => { + if (message === 'gc ok') resolve(); + }); + }); + + serverProcess.send('gc'); + + await ok; + + await setTimeout(1000); + } + + function createRequest() { + return new Promise((resolve, reject) => { + const req = http.request({ + host: 'localhost', + port: 61812, + path: '/api/meta', + method: 'POST', + }, (res) => { + res.on('data', () => { }); + res.on('end', () => { + resolve(); + }); + }); + req.on('error', (err) => { + reject(err); + }); + req.end(); + }); + } + // Wait for server to be ready or timeout const startupStartTime = Date.now(); while (!serverReady) { @@ -108,17 +143,19 @@ async function measureMemory() { const beforeGc = await getMemoryUsage(pid); - serverProcess.send('gc'); + await triggerGc(); - await new Promise((resolve) => { - serverProcess.once('message', (message) => { - if (message === 'gc ok') resolve(); - }); - }); + const afterGc = await getMemoryUsage(pid); - await setTimeout(1000); + // create some http requests to simulate load + const REQUEST_COUNT = 10; + await Promise.all( + Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), + ); - const afterGc = await getMemoryUsage(pid); + await triggerGc(); + + const afterRequest = await getMemoryUsage(pid); // Stop the server serverProcess.kill('SIGTERM'); @@ -143,6 +180,7 @@ async function measureMemory() { timestamp: new Date().toISOString(), beforeGc, afterGc, + afterRequest, }; return result; @@ -159,21 +197,25 @@ async function main() { // 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(), beforeGc, afterGc, + afterRequest, }; // Output as JSON to stdout -- cgit v1.2.3-freya