diff --git a/electron/main/ipc/ogp.test.ts b/electron/main/ipc/ogp.test.ts index e3a2315..d323e7e 100644 --- a/electron/main/ipc/ogp.test.ts +++ b/electron/main/ipc/ogp.test.ts @@ -14,7 +14,7 @@ const { clearCache, cancelOgpFetch, hasInFlight, - MAX_CACHE_ENTRIES, + MAX_CACHE_SIZE, } = __testing; // SSRF defense (pinSafeLookup) が public IP のみ許可するため、local HTTP サーバを @@ -141,9 +141,9 @@ describe("ogp cache behavior", () => { }); it("cacheSet evicts oldest when over capacity", () => { - // MAX_CACHE_ENTRIES + 5 を入れて、最初の 5 件が evict されることを確認。 + // MAX_CACHE_SIZE + 5 を入れて、最初の 5 件が evict されることを確認。 // fetchedAt は now を分散させて oldest 判定を安定させる。 - for (let i = 0; i < MAX_CACHE_ENTRIES + 5; i++) { + for (let i = 0; i < MAX_CACHE_SIZE + 5; i++) { cacheSet( `https://example.com/${i}`, { @@ -158,11 +158,11 @@ describe("ogp cache behavior", () => { } // 容量内に収まっている const stillIn: string[] = []; - for (let i = 0; i < MAX_CACHE_ENTRIES + 5; i++) { + for (let i = 0; i < MAX_CACHE_SIZE + 5; i++) { if (cacheGet(`https://example.com/${i}`) !== null) { stillIn.push(`${i}`); } } - expect(stillIn.length).toBeLessThanOrEqual(MAX_CACHE_ENTRIES); + expect(stillIn.length).toBeLessThanOrEqual(MAX_CACHE_SIZE); }); }); diff --git a/electron/main/ipc/ogp.ts b/electron/main/ipc/ogp.ts index 0c9c39e..da61a1c 100644 --- a/electron/main/ipc/ogp.ts +++ b/electron/main/ipc/ogp.ts @@ -1,4 +1,5 @@ import { URL } from "node:url"; +import { LruCache } from "../../../src/lib/lru-cache"; import type { OgpData } from "../../../src/types/ogp"; import { AbortError, httpFetch } from "../utils/http-fetch"; import { handle } from "../utils/ipc-handle"; @@ -15,7 +16,7 @@ import { StructuredError } from "../utils/structured-error"; // HTTP 層の Promise / timeout / body-limit boilerplate は utils/http-fetch.ts へ集約。 const CACHE_TTL_MS = 24 * 60 * 60 * 1000; -const MAX_CACHE_ENTRIES = 500; +const MAX_CACHE_SIZE = 500; const MAX_BODY_BYTES = 100 * 1024; const REQUEST_TIMEOUT_MS = 5_000; const MAX_REDIRECTS = 5; @@ -25,7 +26,11 @@ interface CacheEntry { fetchedAt: number; } -const cache = new Map(); +// TTL sweep は撤去し、freshness は cacheGet の read-time TTL check で担保する。 +// cap 超過時は LruCache が O(1) で最も長く未参照 (LRU) の 1 件を evict する。 +// cacheGet が hit ごとに LruCache.get で touch するため、頻繁に再描画される URL は +// 保持されやすく、放置された URL が優先的に押し出される。 +const cache = new LruCache(MAX_CACHE_SIZE); function cacheGet(url: string, now: number = Date.now()): OgpData | null { const entry = cache.get(url); @@ -38,26 +43,6 @@ function cacheGet(url: string, now: number = Date.now()): OgpData | null { } function cacheSet(url: string, data: OgpData, now: number = Date.now()): void { - // 期限切れの除去と最古エントリ探索を 1 パスに統合する。500 件規模で従来は - // 2 回イテレートしていたところを 1 回にし、cacheSet が link card 描画の - // hot path で呼ばれる前提で per-insert オーバーヘッドを半減させる。 - let oldestKey: string | null = null; - let oldestTime = Number.POSITIVE_INFINITY; - for (const [k, v] of cache) { - if (now - v.fetchedAt >= CACHE_TTL_MS) { - cache.delete(k); - continue; - } - if (v.fetchedAt < oldestTime) { - oldestTime = v.fetchedAt; - oldestKey = k; - } - } - // 期限切れ掃除後に容量超過していて、かつ新規キーなら最古を evict する。 - // 既存 url の上書きは「容量を増やさない」のでこの分岐に入らない。 - if (!cache.has(url) && cache.size >= MAX_CACHE_ENTRIES && oldestKey !== null) { - cache.delete(oldestKey); - } cache.set(url, { data, fetchedAt: now }); } @@ -185,5 +170,5 @@ export const __testing = { cancelOgpFetch, hasInFlight: (requestId: string) => inFlight.has(requestId), CACHE_TTL_MS, - MAX_CACHE_ENTRIES, + MAX_CACHE_SIZE, }; diff --git a/tsconfig.node.json b/tsconfig.node.json index 50fc598..456babd 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -20,5 +20,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["electron", "electron.vite.config.ts", "src/types"] + "include": ["electron", "electron.vite.config.ts", "src/types", "src/lib/lru-cache.ts"] }