From 8b1f30ae527d677f6d5f681dbf9ed0fe2833b171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Mon, 26 Feb 2024 05:48:32 +0900 Subject: [PATCH] =?UTF-8?q?enhance(backend):=20EntityService=E3=81=AA?= =?UTF-8?q?=E3=81=A9=E3=81=A7DB=E7=85=A7=E4=BC=9A=E3=81=AE=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E3=82=92=E7=9F=AD=E6=99=82=E9=96=93=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=81=99=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package.json | 3 +- packages/backend/src/config.ts | 45 +++++++ packages/backend/src/core/CacheService.ts | 25 ++++ .../src/core/entities/ChannelEntityService.ts | 38 +++++- .../core/entities/DriveFileEntityService.ts | 24 +++- .../src/core/entities/NoteEntityService.ts | 124 ++++++++++++------ .../src/core/entities/PageEntityService.ts | 37 +++++- .../src/core/entities/RoleEntityService.ts | 23 +++- .../src/core/entities/UserEntityService.ts | 85 +++++++++--- packages/backend/src/misc/cache-fetch.ts | 32 +++++ packages/backend/src/postgres.ts | 31 ++--- pnpm-lock.yaml | 5 +- 12 files changed, 376 insertions(+), 96 deletions(-) create mode 100644 packages/backend/src/misc/cache-fetch.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 6bf20aa0c19a..dc70eb76fb40 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -129,6 +129,7 @@ "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", + "lru-cache": "10.2.0", "meilisearch": "0.37.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", @@ -197,7 +198,7 @@ "@types/color-convert": "2.0.3", "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.24", - "@types/htmlescape": "^1.1.3", + "@types/htmlescape": "1.1.3", "@types/http-link-header": "1.0.5", "@types/jest": "29.5.12", "@types/js-yaml": "4.0.9", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 17c76b2260b7..cc539a773e25 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -19,6 +19,11 @@ export type RedisOptionsSource = Partial & { prefix?: string; }; +export type MemoryCacheOptions = { + max?: number; + ttl?: number; +}; + /** * 設定ファイルの型 */ @@ -66,6 +71,20 @@ type Source = { scope?: 'local' | 'global' | string[]; }; + memoryCache?: { + database?: MemoryCacheOptions; + user?: MemoryCacheOptions; + userProfile?: MemoryCacheOptions; + notification?: MemoryCacheOptions; + relation?: MemoryCacheOptions; + role?: MemoryCacheOptions; + channel?: MemoryCacheOptions; + driveFile?: MemoryCacheOptions; + note?: MemoryCacheOptions; + poll?: MemoryCacheOptions; + page?: MemoryCacheOptions; + }; + proxy?: string; proxySmtp?: string; proxyBypassHosts?: string[]; @@ -137,6 +156,19 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + memoryCache: { + database: MemoryCacheOptions; + user: MemoryCacheOptions; + userProfile: MemoryCacheOptions; + notification: MemoryCacheOptions; + relation: MemoryCacheOptions; + role: MemoryCacheOptions; + channel: MemoryCacheOptions; + driveFile: MemoryCacheOptions; + note: MemoryCacheOptions; + poll: MemoryCacheOptions; + page: MemoryCacheOptions; + }; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; @@ -261,6 +293,19 @@ export function loadConfig(): Config { redisForObjectStorageQueue: config.redisForObjectStorageQueue ? convertRedisOptions(config.redisForObjectStorageQueue, host) : redisForJobQueue, redisForWebhookDeliverQueue: config.redisForWebhookDeliverQueue ? convertRedisOptions(config.redisForWebhookDeliverQueue, host) : redisForJobQueue, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, + memoryCache: { + database: config.memoryCache?.database ?? { max: undefined, ttl: undefined }, + user: config.memoryCache?.user ?? { max: undefined, ttl: undefined }, + userProfile: config.memoryCache?.userProfile ?? { max: undefined, ttl: undefined }, + notification: config.memoryCache?.notification ?? { max: undefined, ttl: undefined }, + relation: config.memoryCache?.relation ?? { max: undefined, ttl: undefined }, + role: config.memoryCache?.role ?? { max: undefined, ttl: undefined }, + channel: config.memoryCache?.channel ?? { max: undefined, ttl: undefined }, + driveFile: config.memoryCache?.driveFile ?? { max: undefined, ttl: undefined }, + note: config.memoryCache?.note ?? { max: undefined, ttl: undefined }, + poll: config.memoryCache?.poll ?? { max: undefined, ttl: undefined }, + page: config.memoryCache?.page ?? { max: undefined, ttl: undefined }, + }, id: config.id, proxy: config.proxy, proxySmtp: config.proxySmtp, diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d008e7ec52a3..43eea4277c43 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -131,6 +131,7 @@ export class CacheService implements OnApplicationShutdown { case 'userChangeDeletedState': case 'remoteUserUpdated': case 'localUserUpdated': { + this.userEntityService.purgeCache(body.id); const user = await this.usersRepository.findOneBy({ id: body.id }); if (user == null) { this.userByIdCache.delete(body.id); @@ -155,6 +156,7 @@ export class CacheService implements OnApplicationShutdown { break; } case 'userTokenRegenerated': { + this.userEntityService.purgeCache(body.id); const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as MiLocalUser; this.localUserByNativeTokenCache.delete(body.oldToken); this.localUserByNativeTokenCache.set(body.newToken, user); @@ -171,6 +173,29 @@ export class CacheService implements OnApplicationShutdown { default: break; } + } else if (obj.channel.startsWith('mainStream:')) { + const { type } = obj.message as GlobalEvents['main']['payload']; + switch (type) { + case 'meUpdated': + case 'readAllNotifications': + case 'unreadNotification': + case 'unreadMention': + case 'readAllUnreadMentions': + case 'unreadSpecifiedNote': + case 'readAllUnreadSpecifiedNotes': + case 'readAllAntennas': + case 'unreadAntenna': + case 'readAllAnnouncements': + case 'readAntenna': + case 'receiveFollowRequest': + case 'announcementCreated': { + const userId = obj.channel.slice(11); + this.userEntityService.purgeCache(userId); + break; + } + default: + break; + } } } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index e6f519d61b03..fd3e56913e93 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -4,20 +4,30 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import type { MiChannel } from '@/models/Channel.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { cacheFetch, cacheFetchOrFail } from '@/misc/cache-fetch.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; import { NoteEntityService } from './NoteEntityService.js'; @Injectable() export class ChannelEntityService { + private readonly channelCache: LRUCache; + private readonly bannerCache: LRUCache; + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, @@ -37,6 +47,27 @@ export class ChannelEntityService { private driveFileEntityService: DriveFileEntityService, private idService: IdService, ) { + this.channelCache = new LRUCache({ + max: this.config.memoryCache.channel.max ?? 1, + ttl: this.config.memoryCache.channel.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.channelsRepository.findOneByOrFail({ id }); + }, + }); + + this.bannerCache = new LRUCache({ + max: this.config.memoryCache.driveFile.max ?? 1, + ttl: this.config.memoryCache.driveFile.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.driveFilesRepository.findOneByOrFail({ id }); + }, + }); } @bindThis @@ -45,11 +76,10 @@ export class ChannelEntityService { me: { id: MiUser['id'] } | null | undefined, detailed?: boolean, ): Promise> { - const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); - const meId = me ? me.id : null; - - const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; + const channel = typeof src === 'object' ? src : await cacheFetchOrFail(this.channelCache, src); + const banner = channel.bannerId ? await cacheFetch(this.bannerCache, channel.bannerId) : null; + const meId = me ? me.id : null; const isFollowing = meId ? await this.channelFollowingsRepository.exists({ where: { followerId: meId, diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 583d1282bb14..bec722804194 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -5,11 +5,12 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import { LRUCache } from 'lru-cache'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { appendQuery, query } from '@/misc/prelude/url.js'; @@ -18,6 +19,7 @@ import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; +import { cacheFetch, cacheFetchOrFail } from '@/misc/cache-fetch.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; @@ -31,6 +33,8 @@ type PackOptions = { @Injectable() export class DriveFileEntityService { + private readonly driveFileCache: LRUCache; + constructor( @Inject(DI.config) private config: Config, @@ -47,6 +51,16 @@ export class DriveFileEntityService { private videoProcessingService: VideoProcessingService, private idService: IdService, ) { + this.driveFileCache = new LRUCache({ + max: this.config.memoryCache.driveFile.max ?? 1, + ttl: this.config.memoryCache.driveFile.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.driveFilesRepository.findOneByOrFail({ id }); + }, + }); } @bindThis @@ -195,7 +209,7 @@ export class DriveFileEntityService { self: false, }, options); - const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src }); + const file = typeof src === 'object' ? src : await cacheFetchOrFail(this.driveFileCache, src); return await awaitAll>({ id: file.id, @@ -230,7 +244,7 @@ export class DriveFileEntityService { self: false, }, options); - const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src }); + const file = typeof src === 'object' ? src : await cacheFetch(this.driveFileCache, src); if (file == null) return null; return await awaitAll>({ @@ -273,7 +287,7 @@ export class DriveFileEntityService { options?: PackOptions, ): Promise['id'], Packed<'DriveFile'> | null>> { if (fileIds.length === 0) return new Map(); - const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const files = await this.driveFilesRepository.find({ where: { id: In(fileIds) }, cache: this.config.memoryCache.driveFile.ttl }); const packedFiles = await this.packMany(files, me, options); const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); for (const id of fileIds) { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 500d7741a799..fde50810cfec 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -6,17 +6,20 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; +import { LRUCache } from 'lru-cache'; import { DI } from '@/di-symbols.js'; -import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; +import type { MiChannel } from '@/models/Channel.js'; +import type { MiPoll } from '@/models/Poll.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; -import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; +import { cacheFetch, cacheFetchOrFail } from '@/misc/cache-fetch.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -25,16 +28,24 @@ import type { DriveFileEntityService } from './DriveFileEntityService.js'; @Injectable() export class NoteEntityService implements OnModuleInit { + private readonly noteCache: LRUCache; + private readonly userCache: LRUCache; + private readonly channelCache: LRUCache; + private readonly followingsCache: LRUCache; + private readonly pollCache: LRUCache; + private userEntityService: UserEntityService; private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; private idService: IdService; - private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( private moduleRef: ModuleRef, + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -61,6 +72,68 @@ export class NoteEntityService implements OnModuleInit { //private customEmojiService: CustomEmojiService, //private reactionService: ReactionService, ) { + this.noteCache = new LRUCache({ + max: this.config.memoryCache.note.max ?? 1, + ttl: this.config.memoryCache.note.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.notesRepository.findOneOrFail({ + where: { id }, + relations: ['user'], + }); + }, + }); + + this.userCache = new LRUCache({ + max: this.config.memoryCache.user.max ?? 1, + ttl: this.config.memoryCache.user.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.usersRepository.findOneByOrFail({ id }); + }, + }); + + this.channelCache = new LRUCache({ + max: this.config.memoryCache.channel.max ?? 1, + ttl: this.config.memoryCache.channel.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.channelsRepository.findOneByOrFail({ id }); + }, + }); + + this.followingsCache = new LRUCache({ + max: this.config.memoryCache.relation.max ?? 1, + ttl: this.config.memoryCache.relation.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string, _staleValue, { context }) => { + return await this.followingsRepository.exists({ + where: { + followeeId: id, + followerId: context, + }, + }); + }, + }); + + this.pollCache = new LRUCache({ + max: this.config.memoryCache.poll.max ?? 1, + ttl: this.config.memoryCache.poll.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.pollsRepository.findOneByOrFail({ noteId: id }); + }, + }); } onModuleInit() { @@ -108,12 +181,7 @@ export class NoteEntityService implements OnModuleInit { hide = false; } else { // フォロワーかどうか - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: packedNote.userId, - followerId: meId, - }, - }); + const isFollowing = (await cacheFetch(this.followingsCache, packedNote.userId, meId)) ?? false; hide = !isFollowing; } @@ -132,7 +200,7 @@ export class NoteEntityService implements OnModuleInit { @bindThis private async populatePoll(note: MiNote, meId: MiUser['id'] | null) { - const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); + const poll = await cacheFetchOrFail(this.pollCache, note.id); const choices = poll.choices.map(c => ({ text: c, votes: poll.votes[poll.choices.indexOf(c)], @@ -239,25 +307,17 @@ export class NoteEntityService implements OnModuleInit { return true; } else { // フォロワーかどうか - const [following, user] = await Promise.all([ - this.followingsRepository.count({ - where: { - followeeId: note.userId, - followerId: meId, - }, - take: 1, - }), - this.usersRepository.findOneByOrFail({ id: meId }), - ]); - - /* If we know the following, everyhting is fine. + const user = await cacheFetchOrFail(this.userCache, meId); + const isFollowing = (await cacheFetch(this.followingsCache, note.userId, meId)) ?? false; + + /* If we know the following, everything is fine. But if we do not know the following, it might be that both the author of the note and the author of the like are remote users, in which case we can never know the following. Instead we have to assume that the users are following each other. */ - return following > 0 || (note.userHost != null && user.host != null); + return isFollowing || (note.userHost != null && user.host != null); } } @@ -304,7 +364,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.noteLoader.load(src); + const note = typeof src === 'object' ? src : await cacheFetchOrFail(this.noteCache, src); const host = note.userHost; let text = note.text; @@ -313,11 +373,7 @@ export class NoteEntityService implements OnModuleInit { text = `【${note.name}】\n${(note.text ?? '').trim()}\n\n${note.url ?? note.uri}`; } - const channel = note.channelId - ? note.channel - ? note.channel - : await this.channelsRepository.findOneBy({ id: note.channelId }) - : null; + const channel = note.channelId ? note.channel ?? await cacheFetch(this.channelCache, note.channelId) : null; const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ @@ -488,12 +544,4 @@ export class NoteEntityService implements OnModuleInit { } return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; } - - @bindThis - private findNoteOrFail(id: string): Promise { - return this.notesRepository.findOneOrFail({ - where: { id }, - relations: ['user'], - }); - } } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 5239f3158510..49796e515347 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -4,22 +4,31 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import type { MiPage } from '@/models/Page.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { cacheFetch, cacheFetchOrFail } from '@/misc/cache-fetch.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; @Injectable() export class PageEntityService { + private readonly pageCache: LRUCache; + private readonly driveFileCache: LRUCache; + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, @@ -33,6 +42,27 @@ export class PageEntityService { private driveFileEntityService: DriveFileEntityService, private idService: IdService, ) { + this.pageCache = new LRUCache({ + max: this.config.memoryCache.page.max ?? 1, + ttl: this.config.memoryCache.page.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.pagesRepository.findOneByOrFail({ id }); + }, + }); + + this.driveFileCache = new LRUCache({ + max: this.config.memoryCache.driveFile.max ?? 1, + ttl: this.config.memoryCache.driveFile.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string, _staleValue, { context }) => { + return await this.driveFilesRepository.findOneByOrFail({ id, userId: context }); + }, + }); } @bindThis @@ -41,16 +71,13 @@ export class PageEntityService { me: { id: MiUser['id'] } | null | undefined, ): Promise> { const meId = me ? me.id : null; - const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); + const page = typeof src === 'object' ? src : await cacheFetchOrFail(this.pageCache, src); const attachedFiles: Promise[] = []; const collectFile = (xs: any[]) => { for (const x of xs) { if (x.type === 'image') { - attachedFiles.push(this.driveFilesRepository.findOneBy({ - id: x.fileId, - userId: page.userId, - })); + attachedFiles.push(cacheFetch(this.driveFileCache, x.fileId, page.userId)); } if (x.children) { collectFile(x.children); diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 49dee1138d51..a660a00de2c5 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -5,19 +5,27 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; +import { LRUCache } from 'lru-cache'; import { DI } from '@/di-symbols.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; +import { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import type { MiRole } from '@/models/Role.js'; import { bindThis } from '@/decorators.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import { Packed } from '@/misc/json-schema.js'; +import { cacheFetchOrFail } from '@/misc/cache-fetch.js'; import { IdService } from '@/core/IdService.js'; @Injectable() export class RoleEntityService { + private readonly roleCache: LRUCache; + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, @@ -26,6 +34,16 @@ export class RoleEntityService { private idService: IdService, ) { + this.roleCache = new LRUCache({ + max: this.config.memoryCache.role.max ?? 1, + ttl: this.config.memoryCache.role.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.rolesRepository.findOneByOrFail({ id }); + }, + }); } @bindThis @@ -33,7 +51,7 @@ export class RoleEntityService { src: MiRole['id'] | MiRole, me: { id: MiUser['id'] } | null | undefined, ) : Promise> { - const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); + const role = typeof src === 'object' ? src : await cacheFetchOrFail(this.roleCache, src); const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') .where('assign.roleId = :roleId', { roleId: role.id }) @@ -42,6 +60,7 @@ export class RoleEntityService { .where('assign.expiresAt IS NULL') .orWhere('assign.expiresAt > :now', { now: new Date() }); })) + .cache(this.config.memoryCache.role.ttl ?? false) .getCount(); const policies = { ...role.policies }; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 3673646d1cd5..b52220baf964 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; +import { LRUCache } from 'lru-cache'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -15,7 +16,6 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js'; -import { MiNotification } from '@/models/Notification.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -27,6 +27,7 @@ import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { cacheFetchOrFail } from '@/misc/cache-fetch.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -49,6 +50,9 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { @Injectable() export class UserEntityService implements OnModuleInit { + private readonly userCache: LRUCache; + private readonly profileCache: LRUCache; + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; @@ -111,6 +115,27 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.userMemosRepository) private userMemosRepository: UserMemoRepository, ) { + this.userCache = new LRUCache({ + max: this.config.memoryCache.user.max ?? 1, + ttl: this.config.memoryCache.user.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.usersRepository.findOneByOrFail({ id }); + }, + }); + + this.profileCache = new LRUCache({ + max: this.config.memoryCache.userProfile.max ?? 1, + ttl: this.config.memoryCache.userProfile.ttl ?? 100, + + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + fetchMethod: async (id: string) => { + return await this.userProfilesRepository.findOneByOrFail({ userId: id }); + }, + }); } onModuleInit() { @@ -138,6 +163,13 @@ export class UserEntityService implements OnModuleInit { public isLocalUser = isLocalUser; public isRemoteUser = isRemoteUser; + @bindThis + public async purgeCache(userId: MiUser['id']) { + this.userCache.delete(userId); + this.profileCache.delete(userId); + await this.userSecurityKeysRepository.metadata.connection.queryResultCache?.remove([`securityKeysList:${userId}`]); + } + @bindThis public async getRelation(me: MiUser['id'], target: MiUser['id']) { const [ @@ -150,51 +182,61 @@ export class UserEntityService implements OnModuleInit { isMuted, isRenoteMuted, ] = await Promise.all([ - this.followingsRepository.findOneBy({ - followerId: me, - followeeId: target, + this.followingsRepository.findOne({ + where: { + followerId: me, + followeeId: target, + }, + cache: this.config.memoryCache.relation.ttl, }), this.followingsRepository.exists({ where: { followerId: target, followeeId: me, }, + cache: this.config.memoryCache.relation.ttl, }), this.followRequestsRepository.exists({ where: { followerId: me, followeeId: target, }, + cache: this.config.memoryCache.relation.ttl, }), this.followRequestsRepository.exists({ where: { followerId: target, followeeId: me, }, + cache: this.config.memoryCache.relation.ttl, }), this.blockingsRepository.exists({ where: { blockerId: me, blockeeId: target, }, + cache: this.config.memoryCache.relation.ttl, }), this.blockingsRepository.exists({ where: { blockerId: target, blockeeId: me, }, + cache: this.config.memoryCache.relation.ttl, }), this.mutingsRepository.exists({ where: { muterId: me, muteeId: target, }, + cache: this.config.memoryCache.relation.ttl, }), this.renoteMutingsRepository.exists({ where: { muterId: me, muteeId: target, }, + cache: this.config.memoryCache.relation.ttl, }), ]); @@ -311,7 +353,7 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); + const user = typeof src === 'object' ? src : await cacheFetchOrFail(this.userCache, src); const isDetailed = opts.schema !== 'UserLite'; const meId = me ? me.id : null; @@ -324,8 +366,9 @@ export class UserEntityService implements OnModuleInit { .where('pin.userId = :userId', { userId: user.id }) .innerJoinAndSelect('pin.note', 'note') .orderBy('pin.id', 'DESC') + .cache(this.config.memoryCache.userProfile.ttl ?? false) .getMany() : []; - const profile = isDetailed ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; + const profile = isDetailed ? (opts.userProfile ?? await cacheFetchOrFail(this.profileCache, user.id)) : null; const followingCount = profile == null ? null : (profile.followingVisibility === 'public') || isMe ? user.followingCount : @@ -432,11 +475,14 @@ export class UserEntityService implements OnModuleInit { isModerator: role.isModerator, isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, - })) + })), ), - memo: meId == null ? null : await this.userMemosRepository.findOneBy({ - userId: meId, - targetUserId: user.id, + memo: meId == null ? null : await this.userMemosRepository.findOne({ + where: { + userId: meId, + targetUserId: user.id, + }, + cache: this.config.memoryCache.relation.ttl, }).then(row => row?.memo ?? null), moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), @@ -458,14 +504,14 @@ export class UserEntityService implements OnModuleInit { isDeleted: user.isDeleted, twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 20 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ + hasUnreadSpecifiedNotes: this.noteUnreadsRepository.exists({ where: { userId: user.id, isSpecified: true }, - take: 1, - }).then(count => count > 0), - hasUnreadMentions: this.noteUnreadsRepository.count({ + cache: this.config.memoryCache.notification.ttl, + }), + hasUnreadMentions: this.noteUnreadsRepository.exists({ where: { userId: user.id, isMentioned: true }, - take: 1, - }).then(count => count > 0), + cache: this.config.memoryCache.notification.ttl, + }), hasUnreadAnnouncement: (unreadAnnouncements?.length ?? 0) > 0, unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), @@ -488,14 +534,15 @@ export class UserEntityService implements OnModuleInit { emailVerified: profile!.emailVerified, securityKeysList: profile!.twoFactorEnabled ? this.userSecurityKeysRepository.find({ - where: { - userId: user.id, - }, select: { id: true, name: true, lastUsed: true, }, + where: { + userId: user.id, + }, + cache: this.config.memoryCache.user.ttl ? { id: `securityKeysList:${user.id}`, milliseconds: this.config.memoryCache.user.ttl } : false, }) : [], } : {}), diff --git a/packages/backend/src/misc/cache-fetch.ts b/packages/backend/src/misc/cache-fetch.ts new file mode 100644 index 000000000000..cd6b409ad247 --- /dev/null +++ b/packages/backend/src/misc/cache-fetch.ts @@ -0,0 +1,32 @@ +import { LRUCache } from 'lru-cache'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export async function cacheFetch( + cache: LRUCache, + key: K, + context?: FC, + forceRefresh?: boolean, +): Promise { + const status: LRUCache.Status = {}; + // @ts-expect-error typescript doesn't understand what's going on here + const value = await cache.fetch(key, { forceRefresh, context, status }); + if (status.fetchRejected) throw new Error('fetchMethod promise was rejected!'); + + return value ?? null; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export async function cacheFetchOrFail( + cache: LRUCache, + key: K, + context?: FC, + forceRefresh?: boolean, +): Promise { + const status: LRUCache.Status = {}; + // @ts-expect-error typescript doesn't understand what's going on here + const value = await cache.fetch(key, { forceRefresh, context, status }); + if (status.fetchRejected) throw new Error('fetchMethod promise was rejected!'); + if (value === undefined) throw new Error('fetchMethod returned undefined value!'); + + return value; +} diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 67d33526a406..7fda3cfb86e3 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -11,6 +11,7 @@ import { DataSource, Logger, QueryRunner } from 'typeorm'; import { QueryResultCache } from 'typeorm/cache/QueryResultCache.js'; import { QueryResultCacheOptions } from 'typeorm/cache/QueryResultCacheOptions.js'; import * as highlight from 'cli-highlight'; +import { LRUCache } from 'lru-cache'; import { entities as charts } from '@/core/chart/entities.js'; import { MiAbuseReportResolver } from '@/models/AbuseReportResolver.js'; @@ -207,24 +208,21 @@ export const entities = [ ]; const log = process.env.NODE_ENV !== 'production'; -const timeoutFinalizationRegistry = new FinalizationRegistry((reference: { name: string; timeout: NodeJS.Timeout }) => { - dbLogger.info(`Finalizing timeout: ${reference.name}`); - clearInterval(reference.timeout); -}); class InMemoryQueryResultCache implements QueryResultCache { - private cache: Map; + private readonly cache: LRUCache; constructor( + private config: Config, private dataSource: DataSource, ) { - this.cache = new Map(); + this.cache = new LRUCache({ + max: this.config.memoryCache.database.max ?? 1, + ttl: this.config.memoryCache.database.ttl, - const gcIntervalHandle = setInterval(() => { - this.gc(); - }, 1000 * 60 * 3); - - timeoutFinalizationRegistry.register(this, { name: typeof this, timeout: gcIntervalHandle }); + ignoreFetchAbort: true, + ttlResolution: 1000 * 30, + }); } connect(): Promise { @@ -290,15 +288,6 @@ class InMemoryQueryResultCache implements QueryResultCache { ok(); }); } - - gc(): void { - const now = Date.now(); - for (const [key, { time, duration }] of this.cache.entries()) { - if ((time ?? 0) + duration < now) { - this.cache.delete(key); - } - } - } } export function createPostgresDataSource(config: Config) { @@ -335,7 +324,7 @@ export function createPostgresDataSource(config: Config) { dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { provider(dataSource) { - return new InMemoryQueryResultCache(dataSource); + return new InMemoryQueryResultCache(config, dataSource); }, } : false, logging: log, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa71ae995e6..e8dbece317f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: jsrsasign: specifier: 11.1.0 version: 11.1.0 + lru-cache: + specifier: 10.2.0 + version: 10.2.0 meilisearch: specifier: 0.37.0 version: 0.37.0 @@ -541,7 +544,7 @@ importers: specifier: 2.1.24 version: 2.1.24 '@types/htmlescape': - specifier: ^1.1.3 + specifier: 1.1.3 version: 1.1.3 '@types/http-link-header': specifier: 1.0.5