From 302d1bc7954247fb76bb6aab4d9c52e5c0a9cf7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 00:32:04 +0000 Subject: [PATCH 01/37] [skip ci] Update CHANGELOG.md (prepend template) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ae56bc08a..4e00985e08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### General +- + +### Client +- + +### Server +- + + ## 2026.5.4 ### General From 7fb540edb6dcb391960b89c17edcdde49c7ea411 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Thu, 21 May 2026 09:40:21 +0900 Subject: [PATCH 02/37] Remove empty Server section from CHANGELOG (#17464) --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e00985e08c..2d246953d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,6 @@ ### Client - Fix: ビルドに失敗することがある問題を修正 -### Server -- - ## 2026.5.3 From e7430057e6a6df65bebdbbd7c64853f78862ca3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Thu, 21 May 2026 22:00:18 +0900 Subject: [PATCH 03/37] =?UTF-8?q?fix(backend):=20=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E5=AF=BE=E8=B1=A1=E3=83=8E=E3=83=BC=E3=83=88=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=AE=E4=B8=80=E9=83=A8=E3=82=AF=E3=82=A8?= =?UTF-8?q?=E3=83=AA=E3=82=92=E7=B0=A1=E7=95=A5=E5=8C=96=20(#17422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update related tests * fix CHANGELOG.md * Update CHANGELOG.md Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../CleanRemoteNotesProcessorService.ts | 8 +- .../CleanRemoteNotesProcessorService.ts | 634 +++++++++++++++++- 3 files changed, 640 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d246953d91..eae13da4c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - ### Server -- +- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善 ## 2026.5.4 diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts index bc99dea0003..b4f7e41e488 100644 --- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts @@ -227,10 +227,16 @@ export class CleanRemoteNotesProcessorService { if (currentLimit <= minimumLimit) { job.log('Local note tree complexity is too high, finding next root note...'); + // This query is only used to advance the cursor past the offending range; + // it intentionally omits the heavy NOT EXISTS subqueries in `removalCriteria` + // (user_note_pining / note_favorite / note_reaction) which would otherwise + // hit the same statement_timeout that triggered this fallback path (#17057). + // Strict removability is re-evaluated by the next iteration's CTE query. const idWindow = await this.notesRepository.createQueryBuilder('note') .select('id') .where('note.id > :cursorLeft') - .andWhere(removalCriteria) + .andWhere('note."id" < :newestLimit') + .andWhere('note."userHost" IS NOT NULL') .andWhere({ replyId: IsNull(), renoteId: IsNull() }) .orderBy('note.id', 'ASC') .limit(minimumLimit + 1) diff --git a/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts index b773857bea1..77833328b4c 100644 --- a/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts @@ -11,6 +11,7 @@ import { type MiUser, type NotesRepository, type NoteFavoritesRepository, + type NoteReactionsRepository, type UserNotePiningsRepository, type UsersRepository, type UserProfilesRepository, @@ -29,6 +30,7 @@ describe('CleanRemoteNotesProcessorService', () => { let idService: IdService; let notesRepository: NotesRepository; let noteFavoritesRepository: NoteFavoritesRepository; + let noteReactionsRepository: NoteReactionsRepository; let userNotePiningsRepository: UserNotePiningsRepository; let usersRepository: UsersRepository; let userProfilesRepository: UserProfilesRepository; @@ -112,6 +114,7 @@ describe('CleanRemoteNotesProcessorService', () => { idService = app.get(IdService); notesRepository = app.get(DI.notesRepository); noteFavoritesRepository = app.get(DI.noteFavoritesRepository); + noteReactionsRepository = app.get(DI.noteReactionsRepository); userNotePiningsRepository = app.get(DI.userNotePiningsRepository); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); @@ -139,6 +142,7 @@ describe('CleanRemoteNotesProcessorService', () => { notesRepository.createQueryBuilder().delete().execute(), userNotePiningsRepository.createQueryBuilder().delete().execute(), noteFavoritesRepository.createQueryBuilder().delete().execute(), + noteReactionsRepository.createQueryBuilder().delete().execute(), ]); }, 60 * 1000); @@ -196,10 +200,12 @@ describe('CleanRemoteNotesProcessorService', () => { const result = await service.process(job as any); + // remoteNotes[2] is newer of the two deleted (older-than-expiry by 1000ms), + // remoteNotes[3] is older of the two deleted (older-than-expiry by 2000ms) expect(result).toEqual({ deletedCount: 2, - oldest: expect.any(Number), - newest: expect.any(Number), + oldest: idService.parse(remoteNotes[3].id).date.getTime(), + newest: idService.parse(remoteNotes[2].id).date.getTime(), skipped: false, transientErrors: 0, }); @@ -647,6 +653,630 @@ describe('CleanRemoteNotesProcessorService', () => { expect(result.newest).toBe(idService.parse(note1.id).date.getTime()); expect(result.oldest).toBe(idService.parse(note3.id).date.getTime()); expect(result.skipped).toBe(false); + + // All three notes must be physically removed from the database + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.some(n => n.id === note1.id)).toBe(false); + expect(remainingNotes.some(n => n.id === note2.id)).toBe(false); + expect(remainingNotes.some(n => n.id === note3.id)).toBe(false); + }); + }); + + // region cursor / iteration (CleanRemoteNotesProcessorService.ts:99-107 minId, :302 cursor advance) + // Verifies: + // 1. The minId scan finds the oldest deletable note among irrelevant ones + // 2. The main loop iterates multiple times when notes exceed currentLimit + // 3. The cursor (cursorLeft) advances past unremovable trees and reaches deletables + // on the other side of the ID space + describe('advanced - cursor and iteration', () => { + const BATCH_LOG_RE = /^Deleted (\d+) notes;/; + + // Helper: count batch log entries ("Deleted N notes; Xms") + const countBatchLogs = (job: ReturnType) => + (job.log as ReturnType).mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && BATCH_LOG_RE.test(call[0] as string), + ).length; + + // 初期 currentLimit (100) を超えるノートでループが複数回回ることを検証 + test('should iterate multiple times when notes exceed currentLimit', async () => { + const AMOUNT = 350; + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + for (let i = 0; i < AMOUNT; i++) { + await createNote({}, bob, oldTime - i); + } + + const job = createMockJob(); + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(AMOUNT); + expect(result.skipped).toBe(false); + + // バッチログが少なくとも 2 回(最低 currentLimit * 2 = 200 < 350 なので2回以上) + expect(countBatchLogs(job)).toBeGreaterThanOrEqual(2); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.length).toBe(0); + }, 30 * 1000); + + // カーソルが「base には乗るが anti-join で残る」ブロックを通過して先の削除対象に到達することを検証 + // 注: clippedCount > 0 のような「base クエリで除外」される条件だと base に乗らずカーソルへの影響が見えないため、 + // ここでは「base 自体は removalCriteria を満たすが、その子孫が unremovable のためツリーごと anti-join」を作る + test('should advance cursor past anti-joined trees to reach deletables beyond', async () => { + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 100000; + + // ブロック A: 削除対象 50 件(最古 ID 群) + const blockA: MiNote[] = []; + for (let i = 0; i < 50; i++) { + blockA.push(await createNote({}, bob, oldTime - 50000 - i)); + } + + // ブロック B: anti-join される 50 ツリー(base 自体は removalCriteria を満たす root + clip 済み reply) + // root は base 候補になるためカーソル前進に効くが、ツリーは isRemovable=FALSE で削除されない + const blockBRoots: MiNote[] = []; + const blockBReplies: MiNote[] = []; + for (let i = 0; i < 50; i++) { + const root = await createNote({}, bob, oldTime - 30000 - i * 2); + const reply = await createNote({ replyId: root.id, clippedCount: 1 }, carol, oldTime - 30000 - i * 2 - 1); + blockBRoots.push(root); + blockBReplies.push(reply); + } + + // ブロック C: 削除対象 50 件(最新 ID 群) + const blockC: MiNote[] = []; + for (let i = 0; i < 50; i++) { + blockC.push(await createNote({}, carol, oldTime - 10000 - i)); + } + + const job = createMockJob(); + const result = await service.process(job as any); + + // A + C の 100 件が削除され、B のツリー(root + reply 計 100 件)は保持される + expect(result.deletedCount).toBe(100); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + blockA.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(false); + }); + blockBRoots.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(true); + }); + blockBReplies.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(true); + }); + blockC.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(false); + }); + + // カーソルが B の root 群を越えて C に到達した証として、複数回ループが回る + expect(countBatchLogs(job)).toBeGreaterThanOrEqual(2); + }, 30 * 1000); + + // minId が「削除対象ではないノートに埋もれた1件の削除対象」を正しく見つける + test('should locate a single deletable note buried in irrelevant ones via minId scan', async () => { + const newTime = Date.now(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + // 新しいリモートノート 50 件(newestLimit より新しいので minId 対象外) + for (let i = 0; i < 50; i++) { + await createNote({}, bob, newTime - i); + } + + // 古いローカルノート 50 件(userHost IS NULL なので minId 対象外) + for (let i = 0; i < 50; i++) { + await createNote({}, alice, oldTime - i); + } + + // 古いリプライ 50 件(replyId IS NOT NULL なので minId 対象外) + // 親は新しいので、これら自体は CTE の base にもならない + const parent = await createNote({}, bob); + for (let i = 0; i < 50; i++) { + await createNote({ replyId: parent.id }, bob, oldTime - 100 - i); + } + + // 唯一の削除対象(古いリモート root ノート) + const target = await createNote({}, bob, oldTime - 5000); + + const job = createMockJob(); + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(1); + expect(result.skipped).toBe(false); + expect(result.oldest).toBe(idService.parse(target.id).date.getTime()); + expect(result.newest).toBe(idService.parse(target.id).date.getTime()); + + const remainingTarget = await notesRepository.findOneBy({ id: target.id }); + expect(remainingTarget).toBeNull(); + }, 30 * 1000); + + // 削除対象 → 保護 → 削除対象 → 保護 → ... が交互に並んでもカーソルが正しく進む + test('should advance cursor correctly across interleaved deletable/protected notes', async () => { + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 10000; + + const deletables: MiNote[] = []; + const protectedNotes: MiNote[] = []; + + // 250 件を 1ms 間隔で交互生成(カーソルが小刻みに進むことを期待) + for (let i = 0; i < 250; i++) { + if (i % 2 === 0) { + deletables.push(await createNote({}, bob, oldTime - i)); + } else { + protectedNotes.push(await createNote({ clippedCount: 1 }, bob, oldTime - i)); + } + } + + const job = createMockJob(); + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(deletables.length); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.length).toBe(protectedNotes.length); + deletables.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(false); + }); + protectedNotes.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(true); + }); + + // 反復しないと交互配置をすべて捌けない(初期 currentLimit=100 < 250) + expect(countBatchLogs(job)).toBeGreaterThanOrEqual(2); + }, 30 * 1000); + + // 各バッチ内の最大 isBase ID が次バッチの cursorLeft になることを確認 + test('should not reprocess notes from a previous batch (cursor monotonicity)', async () => { + const AMOUNT = 250; + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + for (let i = 0; i < AMOUNT; i++) { + await createNote({}, bob, oldTime - i); + } + + const job = createMockJob(); + const result = await service.process(job as any); + + // 同じノートが二度カウントされないこと(厳密一致 = 単調増加カーソルが機能している) + expect(result.deletedCount).toBe(AMOUNT); + + // バッチごとのログから削除数を合計してもズレがないこと + const batchSizes = (job.log as ReturnType).mock.calls + .map((call: unknown[]) => (typeof call[0] === 'string' ? call[0] as string : '')) + .map((s: string) => s.match(BATCH_LOG_RE)?.[1]) + .filter((m): m is string => Boolean(m)) + .map((n: string) => Number(n)); + expect(batchSizes.length).toBeGreaterThanOrEqual(2); + expect(batchSizes.reduce((a, b) => a + b, 0)).toBe(AMOUNT); + }, 30 * 1000); + }); + + // region note_reaction protection (NOT EXISTS subquery with INNER JOIN on user.host IS NULL) + // The removalCriteria condition is: + // NOT EXISTS (SELECT 1 FROM note_reaction INNER JOIN "user" + // ON note_reaction."userId" = "user".id + // WHERE note_reaction."noteId" = note."id" + // AND "user"."host" IS NULL) + // i.e. only reactions from local users (host IS NULL) prevent deletion. + describe('advanced - note_reaction', () => { + // ローカルユーザーがリアクションしたノートは削除されない + test('should not delete note that is reacted by a local user', async () => { + const job = createMockJob(); + + const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000); + + // alice (local) がリアクション + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: olderRemoteNote.id, + reaction: '👍', + }); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id }); + expect(remainingNote).not.toBeNull(); + }); + + // リモートユーザーだけがリアクションしたノートは削除される(保護対象ではない) + test('should delete note that is reacted only by remote users', async () => { + const job = createMockJob(); + + const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000); + + // carol (remote) がリアクション + await noteReactionsRepository.save({ + id: idService.gen(), + userId: carol.id, + noteId: olderRemoteNote.id, + reaction: '👍', + }); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(1); + expect(result.skipped).toBe(false); + + const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id }); + expect(remainingNote).toBeNull(); + }); + + // ローカル+リモートが両方リアクション → ローカルがいる時点で削除されない + test('should not delete note that has both local and remote reactions', async () => { + const job = createMockJob(); + + const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000); + + await noteReactionsRepository.save([ + { + id: idService.gen(), + userId: alice.id, // local + noteId: olderRemoteNote.id, + reaction: '👍', + }, + { + id: idService.gen(), + userId: carol.id, // remote + noteId: olderRemoteNote.id, + reaction: '❤️', + }, + ]); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id }); + expect(remainingNote).not.toBeNull(); + }); + + // ルートにローカルリアクション → そもそも base に乗らずツリー全体が残る + test('should retain whole tree when root has local user reaction', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + const root = await createNote({}, bob, oldTime); + const reply = await createNote({ replyId: root.id }, carol, oldTime - 1000); + + // root にローカルユーザーがリアクション + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: root.id, + reaction: '👍', + }); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.some(n => n.id === root.id)).toBe(true); + expect(remainingNotes.some(n => n.id === reply.id)).toBe(true); + }); + + // リプライにローカルリアクション → 親(root)も anti-join により残る + test('should retain whole tree when a reply has a local user reaction', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + const root = await createNote({}, bob, oldTime); + const reply = await createNote({ replyId: root.id }, carol, oldTime - 1000); + + // reply にローカルユーザーがリアクション + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: reply.id, + reaction: '🎉', + }); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.some(n => n.id === root.id)).toBe(true); + expect(remainingNotes.some(n => n.id === reply.id)).toBe(true); + }); + + // リプライのリアクションがリモートのみ → ツリー全体が削除される + test('should delete whole tree when reactions are only from remote users', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + const root = await createNote({}, bob, oldTime); + const reply = await createNote({ replyId: root.id }, carol, oldTime - 1000); + + // root と reply それぞれに リモートユーザーがリアクション + await noteReactionsRepository.save([ + { + id: idService.gen(), + userId: carol.id, // remote + noteId: root.id, + reaction: '👍', + }, + { + id: idService.gen(), + userId: bob.id, // remote + noteId: reply.id, + reaction: '❤️', + }, + ]); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(2); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.some(n => n.id === root.id)).toBe(false); + expect(remainingNotes.some(n => n.id === reply.id)).toBe(false); + }); + + // 深いリプライツリーの末端にローカルリアクション → ツリー全体保護 + test('should retain a deep reply tree when the deepest reply has a local reaction', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + const root = await createNote({}, bob, oldTime); + const r1 = await createNote({ replyId: root.id }, carol, oldTime - 1000); + const r2 = await createNote({ replyId: r1.id }, bob, oldTime - 2000); + const r3 = await createNote({ replyId: r2.id }, carol, oldTime - 3000); + + // 末端 r3 にローカルリアクション + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: r3.id, + reaction: '👍', + }); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.length).toBe(4); + }); + + // 削除可能ノートとローカルリアクションで保護されたノートが混在 + test('should keep reacted notes and delete others in a mixed batch', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + const deletables: MiNote[] = []; + const protectedByLocalReaction: MiNote[] = []; + for (let i = 0; i < 5; i++) { + deletables.push(await createNote({}, bob, oldTime - i)); + } + for (let i = 0; i < 5; i++) { + const n = await createNote({}, carol, oldTime - 100 - i); + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, // local + noteId: n.id, + reaction: '👍', + }); + protectedByLocalReaction.push(n); + } + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(5); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + deletables.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(false); + }); + protectedByLocalReaction.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(true); + }); + }); + }); + + // region maxDuration cutoff (CleanRemoteNotesProcessorService.ts:200-204) + describe('advanced - maxDuration', () => { + test('should stop processing when maxDuration is reached', async () => { + // 6ms 程度。初回バッチ後にループ先頭の経過時間チェックで必ず超過する + meta.remoteNotesCleaningMaxProcessingDurationInMinutes = 0.0001; + + const AMOUNT = 250; + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + for (let i = 0; i < AMOUNT; i++) { + await createNote({}, bob, oldTime - i); + } + + const job = createMockJob(); + const result = await service.process(job as any); + + // maxDuration が極小なので、少なくとも 1 回はループ先頭の時間チェックで break する。 + // (削除件数の上限はバッチサイズ・DB の速さに依存して timing-fragile になりやすいため、 + // ここでは "Reached maximum duration" ログが出ていることのみを functional な保証とする) + expect(result.skipped).toBe(false); + expect(job.log).toHaveBeenCalledWith(expect.stringContaining('Reached maximum duration')); + expect(job.updateProgress).toHaveBeenCalledWith(100); + + // 削除件数と残件数の総和が AMOUNT と一致 + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.length + result.deletedCount).toBe(AMOUNT); + }, 30 * 1000); + }); + + // region early return / dynamic config (CleanRemoteNotesProcessorService.ts:99-117 / 188-192) + describe('advanced - early return / dynamic config', () => { + // minId クエリが対象ノートを見つけられない → 即 early return + test('should early-return when no old root remote note exists (only new root with old descendants)', async () => { + const job = createMockJob(); + const newTime = Date.now(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + // 新しい root(残る) + const newRoot = await createNote({}, bob, newTime); + // 古い reply(root が新しいので CTE に乗らない) + const oldReply = await createNote({ replyId: newRoot.id }, carol, oldTime); + + const result = await service.process(job as any); + + expect(result).toEqual({ + deletedCount: 0, + oldest: null, + newest: null, + skipped: false, + transientErrors: 0, + }); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.some(n => n.id === newRoot.id)).toBe(true); + expect(remainingNotes.some(n => n.id === oldReply.id)).toBe(true); + }); + + // 古い root の親が全部 unremovable(クリップ済み) → minId は見つかるが CTE 空、ループ即終了 + test('should exit loop when all old root notes are unremovable', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + // 全部 clip 済み(unremovable) + for (let i = 0; i < 5; i++) { + await createNote({ clippedCount: 1 }, bob, oldTime - i); + } + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + expect(remainingNotes.length).toBe(5); + }); + + // 処理中に enableRemoteNotesCleaning が false にされたらループを抜ける + test('should stop processing when enableRemoteNotesCleaning is disabled mid-process', async () => { + const AMOUNT = 250; + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + for (let i = 0; i < AMOUNT; i++) { + await createNote({}, bob, oldTime - i); + } + + const job = createMockJob(); + // updateProgress は各ループ先頭で呼ばれるので、初回呼び出しで disable する + let disabledAt = -1; + job.updateProgress = vi.fn(() => { + if (disabledAt === -1) { + meta.enableRemoteNotesCleaning = false; + disabledAt = Date.now(); + } + }); + + const result = await service.process(job as any); + + expect(result.skipped).toBe(false); + expect(result.deletedCount).toBeGreaterThan(0); + expect(result.deletedCount).toBeLessThan(AMOUNT); + }, 30 * 1000); + }); + + // region chaos / all protection conditions combined + describe('advanced - chaos', () => { + test('should respect every protection condition simultaneously', async () => { + const job = createMockJob(); + const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000; + + // 1) 単純な古いリモートノート → 削除される + const plainDeletable = await createNote({}, bob, oldTime); + + // 2) お気に入り保護 + const favorited = await createNote({}, bob, oldTime - 1); + await noteFavoritesRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: favorited.id, + }); + + // 3) ピン留め保護 + const pinned = await createNote({}, bob, oldTime - 2); + await userNotePiningsRepository.save({ + id: idService.gen(), + userId: bob.id, + noteId: pinned.id, + }); + + // 4) クリップ保護 + const clipped = await createNote({ clippedCount: 1 }, bob, oldTime - 3); + + // 5) ページ保護 + const paged = await createNote({ pageCount: 1 }, bob, oldTime - 4); + + // 6) ローカルリアクション保護 + const reactedByLocal = await createNote({}, bob, oldTime - 5); + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: reactedByLocal.id, + reaction: '👍', + }); + + // 7) リモートのみリアクション → 保護されない(削除される) + const reactedByRemote = await createNote({}, bob, oldTime - 6); + await noteReactionsRepository.save({ + id: idService.gen(), + userId: carol.id, // remote + noteId: reactedByRemote.id, + reaction: '👍', + }); + + // 8) ローカルノート → 削除されない + const localOldNote = await createNote({}, alice, oldTime - 7); + + // 9) 新しいリモートノート → 削除されない + const newRemoteNote = await createNote({}, bob); + + // 10) 古い root に新しい reply が付いた → ツリー全体残る + const oldRootWithNewReply = await createNote({}, carol, oldTime - 8); + const newReplyOfOldRoot = await createNote({ replyId: oldRootWithNewReply.id }, bob); + + // 11) 古い root に古い reply(両方削除対象) → ツリーごと削除 + const oldTreeRoot = await createNote({}, bob, oldTime - 9); + const oldTreeReply = await createNote({ replyId: oldTreeRoot.id }, carol, oldTime - 10); + + // 12) 古い root + 古い reply, reply にローカルリアクション → ツリー保護 + const protectedTreeRoot = await createNote({}, bob, oldTime - 11); + const protectedTreeReply = await createNote({ replyId: protectedTreeRoot.id }, carol, oldTime - 12); + await noteReactionsRepository.save({ + id: idService.gen(), + userId: alice.id, + noteId: protectedTreeReply.id, + reaction: '✨', + }); + + const result = await service.process(job as any); + + // 削除されるべき: plainDeletable, reactedByRemote, oldTreeRoot, oldTreeReply → 4 件 + expect(result.deletedCount).toBe(4); + expect(result.skipped).toBe(false); + + const remainingNotes = await notesRepository.find(); + + const expectDeleted = [plainDeletable, reactedByRemote, oldTreeRoot, oldTreeReply]; + const expectKept = [ + favorited, pinned, clipped, paged, reactedByLocal, localOldNote, + newRemoteNote, oldRootWithNewReply, newReplyOfOldRoot, + protectedTreeRoot, protectedTreeReply, + ]; + + expectDeleted.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(false); + }); + expectKept.forEach(n => { + expect(remainingNotes.some(r => r.id === n.id)).toBe(true); + }); }); }); }); From c02fe955ccb5da147a4c5989fe9ddeb59d38ef42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 22 May 2026 08:55:55 +0900 Subject: [PATCH 04/37] =?UTF-8?q?fix(frontend):=20=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=82=B0=E3=83=8F=E3=83=B3=E3=83=89=E3=83=AB=E3=81=8C?= =?UTF-8?q?=E3=81=82=E3=82=8B=E3=81=AE=E3=81=AB=E8=A8=AD=E5=AE=9A=E3=81=95?= =?UTF-8?q?=E3=82=8C=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=82=B0UI=E3=82=92=E4=BF=AE=E6=AD=A3=20(#17472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/pages/channel-editor.vue | 5 +++-- .../pages/page-editor/els/page-editor.el.image.vue | 3 ++- .../pages/page-editor/els/page-editor.el.note.vue | 3 ++- .../page-editor/els/page-editor.el.section.vue | 3 ++- .../pages/page-editor/els/page-editor.el.text.vue | 3 ++- .../src/pages/page-editor/page-editor.blocks.vue | 14 +++++++++++--- .../pages/page-editor/page-editor.container.vue | 3 ++- packages/frontend/src/pages/settings/navbar.vue | 5 +++-- 8 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 4b73b6c6b32..fce69aad796 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -44,11 +44,12 @@ SPDX-License-Identifier: AGPL-3.0-only -