diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f9692ce5d5f..b9e5a7a3535 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -204,14 +204,12 @@ export class RedisSingleCache { } } -// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? - export class MemoryKVCache { - private readonly cache = new Map(); + protected readonly cache = new Map(); private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m constructor( - private readonly lifetime: number, + protected readonly lifetime: number, ) {} @bindThis @@ -318,6 +316,34 @@ export class MemoryKVCache { } } +export class MemoryLRUKVCache extends MemoryKVCache { + constructor( + lifetime: number, + private readonly limit: number, + ) { + if (limit <= 0) { + throw new Error('Limit must be greater than 0'); + } + + super(lifetime); + } + + @bindThis + public set(key: string, value: T): void { + while (this.cache.size >= this.limit) { + // 古い順から削除していく + const oldestKey = this.cache.keys().next().value; + if (oldestKey == null) { + throw new Error('Cache is empty but size exceeds the limit'); + } + this.cache.delete(oldestKey); + } + // 挿入順を更新するために一度削除してから再挿入する + this.cache.delete(key); + super.set(key, value); + } +} + export class MemorySingleCache { private cachedAt: number | null = null; private value: T | undefined; diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 5275a90ec34..1daa58b40da 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -3,13 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import type { SummalyResult } from '@misskey-dev/summaly'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; +import { MemoryLRUKVCache } from '@/misc/cache.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; @@ -17,8 +18,9 @@ import { MiMeta } from '@/models/Meta.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() -export class UrlPreviewService { +export class UrlPreviewService implements OnApplicationShutdown { private logger: Logger; + private summaryCache: MemoryLRUKVCache; constructor( @Inject(DI.config) @@ -31,6 +33,7 @@ export class UrlPreviewService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('url-preview'); + this.summaryCache = new MemoryLRUKVCache(1000 * 60 * 60, 100); // 1h, 100件 } @bindThis @@ -76,22 +79,29 @@ export class UrlPreviewService { : `Getting preview of ${url}@${lang} ...`); try { - const summary = this.meta.urlPreviewSummaryProxyUrl - ? await this.fetchSummaryFromProxy(url, this.meta, lang) - : await this.fetchSummary(url, this.meta, lang); - - this.logger.succ(`Got preview of ${url}: ${summary.title}`); - - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); + const summary = await this.summaryCache.fetchMaybe( + `${url}@${lang ?? '_DEFAULT_'}`, + async () => { + const result = await (this.meta.urlPreviewSummaryProxyUrl ? this.fetchSummaryFromProxy(url, lang) : this.fetchSummary(url, lang)); + if (!result.url.startsWith('http://') && !result.url.startsWith('https://')) { + return undefined; + } + if (result.player.url && !result.player.url.startsWith('http://') && !result.player.url.startsWith('https://')) { + return undefined; + } + + result.icon = this.wrap(result.icon); + result.thumbnail = this.wrap(result.thumbnail); + + return result; + }, + ); + + if (summary == null) { + throw new Error('Invalid summary'); } - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } - - summary.icon = this.wrap(summary.icon); - summary.thumbnail = this.wrap(summary.thumbnail); + this.logger.succ(`Got preview of ${url}: ${summary.title}`); // Cache 1day reply.header('Cache-Control', 'max-age=86400, immutable'); @@ -112,7 +122,7 @@ export class UrlPreviewService { } } - private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise { + private async fetchSummary(url: string, lang?: string): Promise { const agent = this.config.proxy ? { http: this.httpRequestService.httpAgent, @@ -126,25 +136,35 @@ export class UrlPreviewService { followRedirects: this.meta.urlPreviewAllowRedirect, lang: lang ?? 'ja-JP', agent: agent, - userAgent: meta.urlPreviewUserAgent ?? undefined, - operationTimeout: meta.urlPreviewTimeout, - contentLengthLimit: meta.urlPreviewMaximumContentLength, - contentLengthRequired: meta.urlPreviewRequireContentLength, + userAgent: this.meta.urlPreviewUserAgent ?? undefined, + operationTimeout: this.meta.urlPreviewTimeout, + contentLengthLimit: this.meta.urlPreviewMaximumContentLength, + contentLengthRequired: this.meta.urlPreviewRequireContentLength, }); } - private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise { - const proxy = meta.urlPreviewSummaryProxyUrl!; + private fetchSummaryFromProxy(url: string, lang?: string): Promise { + const proxy = this.meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ url: url, lang: lang ?? 'ja-JP', followRedirects: this.meta.urlPreviewAllowRedirect, - userAgent: meta.urlPreviewUserAgent ?? undefined, - operationTimeout: meta.urlPreviewTimeout, - contentLengthLimit: meta.urlPreviewMaximumContentLength, - contentLengthRequired: meta.urlPreviewRequireContentLength, + userAgent: this.meta.urlPreviewUserAgent ?? undefined, + operationTimeout: this.meta.urlPreviewTimeout, + contentLengthLimit: this.meta.urlPreviewMaximumContentLength, + contentLengthRequired: this.meta.urlPreviewRequireContentLength, }); return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); } + + @bindThis + public dispose(): void { + this.summaryCache.dispose(); + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } }