From 7ad772116b8d7364c8a42e07e0d75d994e65230b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:21:04 -0400 Subject: [PATCH 1/8] delete user reactions before notes --- .../DeleteAccountProcessorService.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 46cee096cf..8922370104 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -65,6 +65,37 @@ export class DeleteAccountProcessorService { return; } + { // Delete reactions + let cursor: MiNoteReaction['id'] | null = null; + + while (true) { + const reactions = await this.noteReactionsRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as MiNoteReaction[]; + + if (reactions.length === 0) { + break; + } + + cursor = reactions.at(-1)?.id ?? null; + + for (const reaction of reactions) { + const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; + + await this.reactionService.delete(user, note); + } + } + + this.logger.succ('All reactions have been deleted'); + } + { // Delete scheduled notes const scheduledNotes = await this.noteScheduleRepository.findBy({ userId: user.id, @@ -119,37 +150,6 @@ export class DeleteAccountProcessorService { this.logger.succ('All of notes deleted'); } - { // Delete reactions - let cursor: MiNoteReaction['id'] | null = null; - - while (true) { - const reactions = await this.noteReactionsRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNoteReaction[]; - - if (reactions.length === 0) { - break; - } - - cursor = reactions.at(-1)?.id ?? null; - - for (const reaction of reactions) { - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; - - await this.reactionService.delete(user, note); - } - } - - this.logger.succ('All reactions have been deleted'); - } - { // Delete files let cursor: MiDriveFile['id'] | null = null; From 077096d04ee558c9fc883cde221ecf35ed938777 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:30:29 -0400 Subject: [PATCH 2/8] use deliverMany to reduce overhead of account deletion queue --- packages/backend/src/core/DeleteAccountService.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 48f27d558e..7d64c6f2ad 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -63,8 +63,6 @@ export class DeleteAccountService { // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - const queue: string[] = []; - const followings = await this.followingsRepository.find({ where: [ { followerSharedInbox: Not(IsNull()) }, @@ -73,15 +71,10 @@ export class DeleteAccountService { select: ['followerSharedInbox', 'followeeSharedInbox'], }); - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const); + const queue = new Map(inboxes); - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); this.queueService.createDeleteAccountJob(user, { soft: false, From fe5def9de0c7d1f35bc895d2fe9b030496f95e27 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:31:06 -0400 Subject: [PATCH 3/8] await delete account in queue in case of errors --- packages/backend/src/core/DeleteAccountService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 7d64c6f2ad..efbe6a2d59 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -76,12 +76,12 @@ export class DeleteAccountService { await this.queueService.deliverMany(user, content, queue); - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: false, }); } else { // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: true, }); } From a8a8c41a9b7db8b1c0f7a3b7ce4ffb56cf66d981 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:05:52 -0400 Subject: [PATCH 4/8] allow caller to pass in existing reaction hint to ReactionService.delete --- packages/backend/src/core/ReactionService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 0179b0680f..776dec7490 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -298,9 +298,9 @@ export class ReactionService { } @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { + public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) { // if already unreacted - const exist = await this.noteReactionsRepository.findOneBy({ + exist ??= await this.noteReactionsRepository.findOneBy({ noteId: note.id, userId: user.id, }); From 7cf293de94c9ad5afa616e6f6ce967c7fbb94ab9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:06:53 -0400 Subject: [PATCH 5/8] add more manual steps to process account deletion in smaller chunks --- .../DeleteAccountProcessorService.ts | 176 +++++++++++++++++- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 8922370104..f7643a3beb 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -4,9 +4,9 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; +import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -17,10 +17,10 @@ import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { ReactionService } from '@/core/ReactionService.js'; +import { QueueService } from '@/core/QueueService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; -import { QueueService } from '@/core/QueueService.js'; @Injectable() export class DeleteAccountProcessorService { @@ -45,6 +45,45 @@ export class DeleteAccountProcessorService { @Inject(DI.noteScheduleRepository) private noteScheduleRepository: NoteScheduleRepository, + @Inject(DI.followingsRepository) + private readonly followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private readonly followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private readonly blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private readonly mutingsRepository: MutingsRepository, + + @Inject(DI.clipsRepository) + private readonly clipsRepository: ClipsRepository, + + @Inject(DI.clipNotesRepository) + private readonly clipNotesRepository: ClipNotesRepository, + + @Inject(DI.latestNotesRepository) + private readonly latestNotesRepository: LatestNotesRepository, + + @Inject(DI.noteEditRepository) + private readonly noteEditRepository: NoteEditRepository, + + @Inject(DI.noteFavoritesRepository) + private readonly noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private readonly pollVotesRepository: PollVotesRepository, + + @Inject(DI.pollsRepository) + private readonly pollsRepository: PollsRepository, + + @Inject(DI.signinsRepository) + private readonly signinsRepository: SigninsRepository, + + @Inject(DI.userIpsRepository) + private readonly userIpsRepository: UserIpsRepository, + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, @@ -65,6 +104,74 @@ export class DeleteAccountProcessorService { return; } + { // Delete user clips + const userClips = await this.clipsRepository.find({ + select: { + id: true, + }, + where: { + userId: user.id, + }, + }) as { id: string }[]; + + // Delete one-at-a-time because there can be a lot + for (const clip of userClips) { + await this.clipNotesRepository.delete({ + id: clip.id, + }); + } + + await this.clipsRepository.delete({ + userId: user.id, + }); + + this.logger.succ('All clips have been deleted.'); + } + + { // Delete favorites + await this.noteFavoritesRepository.delete({ + userId: user.id, + }); + + this.logger.succ('All favorites have been deleted.'); + } + + { // Delete user relations + await this.followingsRepository.delete({ + followerId: user.id, + }); + + await this.followingsRepository.delete({ + followeeId: user.id, + }); + + await this.followRequestsRepository.delete({ + followerId: user.id, + }); + + await this.followRequestsRepository.delete({ + followeeId: user.id, + }); + + await this.blockingsRepository.delete({ + blockerId: user.id, + }); + + await this.blockingsRepository.delete({ + blockeeId: user.id, + }); + + await this.mutingsRepository.delete({ + muterId: user.id, + }); + + await this.mutingsRepository.delete({ + muteeId: user.id, + }); + + this.logger.succ('All user relations have been deleted.'); + } + { // Delete reactions let cursor: MiNoteReaction['id'] | null = null; @@ -78,6 +185,9 @@ export class DeleteAccountProcessorService { order: { id: 1, }, + relations: { + note: true, + }, }) as MiNoteReaction[]; if (reactions.length === 0) { @@ -87,15 +197,47 @@ export class DeleteAccountProcessorService { cursor = reactions.at(-1)?.id ?? null; for (const reaction of reactions) { - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; - - await this.reactionService.delete(user, note); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const note = reaction.note!; + await this.reactionService.delete(user, note, reaction); } } this.logger.succ('All reactions have been deleted'); } + { // Poll votes + let cursor: MiNoteReaction['id'] | null = null; + + while (true) { + const votes = await this.pollVotesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + select: { + id: true, + }, + take: 100, + order: { + id: 1, + }, + }) as { id: string }[]; + + if (votes.length === 0) { + break; + } + + cursor = votes.at(-1)?.id ?? null; + + await this.pollVotesRepository.delete({ + id: In(votes.map(v => v.id)), + }); + } + + this.logger.succ('All poll votes have been deleted'); + } + { // Delete scheduled notes const scheduledNotes = await this.noteScheduleRepository.findBy({ userId: user.id, @@ -113,6 +255,10 @@ export class DeleteAccountProcessorService { } { // Delete notes + await this.latestNotesRepository.delete({ + userId: user.id, + }); + let cursor: MiNote['id'] | null = null; while (true) { @@ -133,7 +279,23 @@ export class DeleteAccountProcessorService { cursor = notes.at(-1)?.id ?? null; - await this.notesRepository.delete(notes.map(note => note.id)); + // Delete associated polls one-at-a-time, since it can cascade to a LOT of vote entries + for (const note of notes) { + if (note.hasPoll) { + await this.pollsRepository.delete({ + noteId: note.id, + }); + } + } + + const ids = notes.map(note => note.id); + + await this.noteEditRepository.delete({ + noteId: In(ids), + }); + await this.notesRepository.delete({ + id: In(ids), + }); for (const note of notes) { await this.searchService.unindexNote(note); From fdf67f6fc759f0d049011c20263a977b6d507a28 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:07:21 -0400 Subject: [PATCH 6/8] don't sent account deletion notice until after it actually completes --- .../DeleteAccountProcessorService.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index f7643a3beb..1591946b18 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -353,20 +353,38 @@ export class DeleteAccountProcessorService { this.logger.succ('All AP logs deleted'); } - { // Send email notification - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.email && profile.emailVerified) { - this.emailService.sendEmail(profile.email, 'Account deleted', - 'Your account has been deleted.', - 'Your account has been deleted.'); + // Do this BEFORE deleting the account! + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + { // Delete the actual account + await this.userIpsRepository.delete({ + userId: user.id, + }); + + await this.signinsRepository.delete({ + userId: user.id, + }); + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await this.usersRepository.delete(user.id); } + + this.logger.succ('Account data deleted'); } - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop - } else { - await this.usersRepository.delete(job.data.user.id); + { // Send email notification + if (profile.email && profile.emailVerified) { + try { + await this.emailService.sendEmail(profile.email, 'Account deleted', + 'Your account has been deleted.', + 'Your account has been deleted.'); + } catch (e) { + this.logger.warn('Failed to send account deletion message:', { e }); + } + } } return 'Account deleted'; From 0a7ef89a172c4dff2a97a246d9bd4c7789580920 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:11:44 -0400 Subject: [PATCH 7/8] delete user registry items --- .../queue/processors/DeleteAccountProcessorService.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 1591946b18..0ca4945948 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository, RegistryItemsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -84,6 +84,9 @@ export class DeleteAccountProcessorService { @Inject(DI.userIpsRepository) private readonly userIpsRepository: UserIpsRepository, + @Inject(DI.registryItemsRepository) + private readonly registryItemsRepository: RegistryItemsRepository, + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, @@ -365,6 +368,10 @@ export class DeleteAccountProcessorService { userId: user.id, }); + await this.registryItemsRepository.delete({ + userId: user.id, + }); + // soft指定されている場合は物理削除しない if (job.data.soft) { // nop From 7b54a3ca48318fd3cae69e8c67e6294b0d8978f7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:12:43 -0400 Subject: [PATCH 8/8] allow user to be deleted if profile is missing --- .../src/queue/processors/DeleteAccountProcessorService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 0ca4945948..4e9779a41b 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -357,7 +357,7 @@ export class DeleteAccountProcessorService { } // Do this BEFORE deleting the account! - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); { // Delete the actual account await this.userIpsRepository.delete({ @@ -383,7 +383,7 @@ export class DeleteAccountProcessorService { } { // Send email notification - if (profile.email && profile.emailVerified) { + if (profile && profile.email && profile.emailVerified) { try { await this.emailService.sendEmail(profile.email, 'Account deleted', 'Your account has been deleted.',