Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(server): DriveFile related N+1 query when call note packMany #10133

Merged
merged 5 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/backend/src/core/entities/DriveFileEntityService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
Expand Down Expand Up @@ -255,10 +255,19 @@ export class DriveFileEntityService {

@bindThis
public async packMany(
files: (DriveFile['id'] | DriveFile)[],
files: DriveFile[],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null);
}

@bindThis
public async packManyByIds(
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここって fileIds の順序にソートしなくても良い?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あ〜〜、確かにした方がいいな

return await this.packMany(files, options);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title,
description: post.description,
fileIds: post.fileIds,
files: this.driveFileEntityService.packMany(post.fileIds),
// TODO: packMany causes N+1 queries
files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
Expand Down
8 changes: 7 additions & 1 deletion packages/backend/src/core/entities/NoteEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
Expand Down Expand Up @@ -257,6 +258,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean;
_hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'>>;
};
},
): Promise<Packed<'Note'>> {
Expand Down Expand Up @@ -284,6 +286,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
const packedFiles = options?._hint_?.packedFiles;

const packed: Packed<'Note'> = await awaitAll({
id: note.id,
Expand All @@ -304,7 +307,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds,
files: this.driveFileEntityService.packMany(note.fileIds),
files: packedFiles != null ? note.fileIds.map(fi => packedFiles.get(fi)).filter(isNotNull) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId ?? undefined,
Expand Down Expand Up @@ -388,11 +391,14 @@ export class NoteEntityService implements OnModuleInit {
}

await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
const fileIds = notes.flatMap(n => n.fileIds);
Copy link
Member

@syuilo syuilo Mar 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

多分ここでrenote(あとreply?)も考慮する必要がある

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

もしくは、_hint_に希望するファイルが含まれてなかったらスキップするのではなくて諦めてDBにfindしてくるようにするとか

const packedFiles = new Map(fileIds.length === 0 ? [] : (await this.driveFileEntityService.packManyByIds(fileIds)).map(r => [r.id, r]));

return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
_hint_: {
myReactions: myReactionsMap,
packedFiles,
},
})));
}
Expand Down
84 changes: 30 additions & 54 deletions packages/backend/src/core/entities/NotificationEntityService.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { Note } from '@/models/entities/Note.js';
import type { Packed } from '@/misc/schema.js';
import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { notificationTypes } from '@/types.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';

const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);

@Injectable()
export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
Expand Down Expand Up @@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit {
public async pack(
src: Notification['id'] | Notification,
options: {
_hintForEachNotes_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
_hint_?: {
packedNotes: Map<Note['id'], Packed<'Note'>>;
};
},
): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src });
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
options._hint_?.packedNotes != null
? options._hint_.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
})
) : undefined;

return await awaitAll({
id: notification.id,
Expand All @@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit {
isRead: notification.isRead,
userId: notification.notifierId,
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
...(notification.type === 'mention' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'reply' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'renote' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'quote' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
reaction: notification.reaction,
} : {}),
...(notification.type === 'pollEnded' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
Expand All @@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit {
});
}

/**
* @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
*/
@bindThis
public async packMany(
notifications: Notification[],
meId: User['id'],
) {
if (notifications.length === 0) return [];

const notes = notifications.filter(x => x.note != null).map(x => x.note!);
const noteIds = notes.map(n => n.id);
const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
const targets = [...noteIds, ...renoteIds];
const myReactions = await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(targets),
});

for (const target of targets) {
myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null);

for (const notification of notifications) {
if (meId !== notification.notifieeId) {
// because we call note packMany with meId, all notifieeId should be same as meId
throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
}
}

await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
const notes = notifications.map(x => x.note).filter(isNotNull);
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));

return await Promise.all(notifications.map(x => this.pack(x, {
_hintForEachNotes_: {
myReactions: myReactionsMap,
_hint_: {
packedNotes,
},
})));
}
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/misc/is-not-null.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// we are using {} as "any non-nullish value" as expected
// eslint-disable-next-line @typescript-eslint/ban-types
export function isNotNull<T extends {}>(input: T | undefined | null): input is T {
return input != null;
}