From a306d3aa075cc2ca167b2508eb4892dae20b6904 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 3 May 2026 17:25:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?enhance(backend):=20summaly=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=82=92=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A5=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/misc/cache.ts | 32 +++++++- .../src/server/web/UrlPreviewService.ts | 74 ++++++++++++------- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f9692ce5d5f..5eb1fceb4aa 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,32 @@ 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); + } + 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(); + } } From 7a9e7a15ba81e5fa61084cb5836c8ba1fc786d3a Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 9 May 2026 14:06:00 +0900 Subject: [PATCH 2/2] fix --- packages/backend/src/misc/cache.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 5eb1fceb4aa..b9e5a7a3535 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -338,6 +338,8 @@ export class MemoryLRUKVCache extends MemoryKVCache { } this.cache.delete(oldestKey); } + // 挿入順を更新するために一度削除してから再挿入する + this.cache.delete(key); super.set(key, value); } }