From f7712d69184587225a4d4b8a2ea347bdc0e03746 Mon Sep 17 00:00:00 2001 From: Naki-ym <81948866+ymnao@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:51:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20electron/main/ipc/ogp.ts=20cach?= =?UTF-8?q?e=20=E3=82=92=20LruCache=20=E3=81=AB=E7=BD=AE=E6=8F=9B=E3=81=97?= =?UTF-8?q?=20TTL=20sweep=20=E3=82=92=E6=92=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Map` + 手動 1-pass TTL sweep + oldest 探索 (22 行) を `src/lib/lru-cache.ts` の `LruCache(MAX_CACHE_SIZE=500)` に 置換。cap eviction は LruCache が O(1) で担う - 24h TTL sweep は撤去。freshness は cacheGet の read-time TTL check で substitute (invisible な stale は cap-based LRU で bounded) - main-side から `src/lib/lru-cache.ts` を import できるよう `tsconfig.node.json` の include に追加。renderer bundle と main bundle の 両方に含まれるが、lru-cache.ts は self-contained なので追加コストなし - 定数名 `MAX_CACHE_ENTRIES` → `MAX_CACHE_SIZE` で sibling cache (mermaid.ts / slide-render.ts / math.ts / link-cards.ts) と命名整合 - cacheGet は LruCache.get() を継続使用 (touch)。main-side ogp cache は set が 1 回、get が多数の pattern なので .peek() 化すると FIFO 相当に退化する --- electron/main/ipc/ogp.test.ts | 10 +++++----- electron/main/ipc/ogp.ts | 29 ++++++----------------------- tsconfig.node.json | 2 +- 3 files changed, 12 insertions(+), 29 deletions(-) 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..fe33450 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,9 @@ interface CacheEntry { fetchedAt: number; } -const cache = new Map(); +// TTL sweep は撤去し、freshness は cacheGet の read-time TTL check で担保する。 +// cap 超過時は LruCache が O(1) で最古 1 件を evict する。 +const cache = new LruCache(MAX_CACHE_SIZE); function cacheGet(url: string, now: number = Date.now()): OgpData | null { const entry = cache.get(url); @@ -38,26 +41,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 +168,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"] } From 644f7c13fb0282be09a849059e19ae4302a62b54 Mon Sep 17 00:00:00 2001 From: Naki-ym <81948866+ymnao@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:56:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20ogp.ts=20cache=20=E3=82=B3=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AB=20LRU=20=E6=84=8F=E5=91=B3=E8=AB=96?= =?UTF-8?q?=E3=82=92=E6=98=8E=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/ogp.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/electron/main/ipc/ogp.ts b/electron/main/ipc/ogp.ts index fe33450..da61a1c 100644 --- a/electron/main/ipc/ogp.ts +++ b/electron/main/ipc/ogp.ts @@ -27,7 +27,9 @@ interface CacheEntry { } // TTL sweep は撤去し、freshness は cacheGet の read-time TTL check で担保する。 -// cap 超過時は LruCache が O(1) で最古 1 件を evict する。 +// 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 {