From d1b0d00f6095104bee50428ef68b0ce366633d8e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 20 Jun 2026 13:49:18 +0000 Subject: [PATCH 1/7] feat(cache): add server-side caching for tasks, votes, and workbooks Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/server-cache.md | 84 ++ .../2026-06-17/phase4-server-cache/design.md | 136 +++ .../2026-06-17/phase4-server-cache/plan.md | 922 ++++++++++++++++++ .../2026-06-17/phase4-server-cache/survey.md | 73 ++ src/features/votes/server/cache.test.ts | 55 ++ src/features/votes/server/cache.ts | 21 + .../votes/services/vote_statistics.test.ts | 4 + .../votes/services/vote_statistics.ts | 11 +- src/features/workbooks/server/cache.test.ts | 79 ++ src/features/workbooks/server/cache.ts | 43 + .../workbooks/services/workbooks.test.ts | 10 + src/features/workbooks/services/workbooks.ts | 75 +- src/lib/clients/cache.test.ts | 71 ++ src/lib/clients/cache.ts | 13 + src/lib/server/tasks/cache.test.ts | 62 ++ src/lib/server/tasks/cache.ts | 33 + src/lib/services/tasks.ts | 39 +- src/test/lib/services/tasks.test.ts | 6 + 18 files changed, 1688 insertions(+), 49 deletions(-) create mode 100644 .claude/rules/server-cache.md create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/design.md create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan.md create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/survey.md create mode 100644 src/features/votes/server/cache.test.ts create mode 100644 src/features/votes/server/cache.ts create mode 100644 src/features/workbooks/server/cache.test.ts create mode 100644 src/features/workbooks/server/cache.ts create mode 100644 src/lib/server/tasks/cache.test.ts create mode 100644 src/lib/server/tasks/cache.ts diff --git a/.claude/rules/server-cache.md b/.claude/rules/server-cache.md new file mode 100644 index 000000000..07a31498f --- /dev/null +++ b/.claude/rules/server-cache.md @@ -0,0 +1,84 @@ +--- +description: Server-side caching rules +paths: + - 'src/lib/clients/cache.ts' + - 'src/lib/server/**/cache.ts' + - 'src/features/**/server/cache.ts' +--- + +# Server-Side Cache + +## Core Pattern: `Cache.getOrFetch()` + +Use `cache.getOrFetch(key, fetchFn)` for all get-or-fetch operations. Never inline the get/set/if pattern manually — it duplicates logic that `getOrFetch` already provides. + +```typescript +export function getCachedFoo(fetchFn: () => Promise): Promise { + return fooCache.getOrFetch(KEY, fetchFn); +} +``` + +## Domain Cache Module Structure + +Place cache modules at `server/cache.ts` within each domain: + +| Domain | Path | +| ------- | ------------------------------------- | +| Shared | `src/lib/server/{domain}/cache.ts` | +| Feature | `src/features/{name}/server/cache.ts` | + +Each module exports: + +- `getCached*()` — thin wrapper around `cache.getOrFetch()` +- `invalidate*Caches()` — clears all related cache instances +- `dispose*Caches()` — disposes all related cache instances (for test cleanup) + +## Invalidation Rules + +- Call `invalidate*Caches()` immediately after the DB write (`create`, `update`, `delete`) succeeds — not before, not in a `finally` block. +- Group related caches in a single invalidation function (e.g., `invalidateTaskCaches()` clears both `tasksCache` and `mergedTasksCache`). +- Never invalidate from route handlers — invalidation belongs in the service layer, co-located with the write operation. + +## TTL Guidelines + +| Data characteristics | TTL | +| ------------------------------ | ---------- | +| Rarely changes (tasks, grades) | 1 hour | +| Moderately changes (votes) | 10 minutes | + +Adjust based on production metrics after deployment. + +## Testing + +Core cache behavior (hit, miss, TTL, error propagation) is tested on `Cache.getOrFetch()` in `src/lib/clients/cache.test.ts`. Domain cache tests cover only domain-specific concerns: + +- **Wiring**: wrapper delegates correctly and returns cached value on subsequent calls +- **Key isolation**: different parameters produce different cache keys (e.g., `buildPlacementKey`) +- **Invalidation grouping**: `invalidate*()` clears all related caches + +Do not duplicate TTL or error propagation tests in domain cache test files. + +## Service Layer Integration + +Services call `getCached*()` with a `fetchFn` that performs the DB query. The service does not import `Cache` directly. + +```typescript +// In service file +import { getCachedTasksMap } from '$lib/server/tasks/cache'; + +export async function getTasksByTaskId(): Promise> { + return getCachedTasksMap(async () => { + const tasks = await db.task.findMany(); + return new Map(tasks.map((task) => [task.task_id, task])); + }); +} +``` + +Mock cache in service tests to bypass caching: + +```typescript +vi.mock('$lib/server/tasks/cache', () => ({ + getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), + invalidateTaskCaches: vi.fn(), +})); +``` diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/design.md b/docs/dev-notes/2026-06-17/phase4-server-cache/design.md new file mode 100644 index 000000000..3e6b50118 --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/design.md @@ -0,0 +1,136 @@ +# Phase 4:設計判断 + +> 本ドキュメントは [plan.md](./plan.md) の設計背景。調査・前提条件は [survey.md](./survey.md)、実装手順は plan.md を参照。 + +## 設計判断の記録 + +### 却下:案A — `src/lib/server/cache.ts` 集約 + +**発想:** `database.ts` と同じく、キャッシュもインフラとして `lib/server/` に集約する。 + +**却下理由:** + +- `database.ts` はドメイン型を一切持たない純粋なインフラ(Prisma 接続管理のみ)。一方、キャッシュは `WorkbooksWithAuthors`(features 型)など各ドメインの型を知る必要がある。`lib` が `features` に依存する逆転が生じる。 +- 無関係なドメイン(タスク・投票統計・問題集)が1ファイルに同居する積極的な理由がない。タスクの TTL を変更したいだけなのに、投票統計の定義が同じファイルにある。 +- `resetAllCaches()` はテスト専用コードが本番モジュールに漏れる形であり、かつ全ドメインを一括リセットする本番ユースケースが存在しない。 + +### 却下:案B — 各サービスファイル内にインスタンス + +**発想:** `tasks.ts` の先頭に `const tasksCache = new Cache<...>()` を置く。 + +**却下理由:** + +- シンプルだが `tasks.ts` はすでに `createTask`/`updateTask`/`getTasks`/`getTasksByTaskId`/`getMergedTasksMap` を持つ大きなファイル。DB アクセスにキャッシュ管理が混在する。 +- テスト隔離のために `_resetTaskCachesForTest()` などを export すると、テスト用コードが本番モジュールに入る点は案Aと変わらない。形を変えて同じ問題が残る。 + +### 採用:案C — ドメイン別 `server/cache.ts` + +各ドメインが自分専用のキャッシュモジュールを持つ: + +``` +src/lib/server/tasks/cache.ts ← lib ドメイン(tasks) +src/features/votes/server/cache.ts ← votes ドメイン +src/features/workbooks/server/cache.ts ← workbooks ドメイン +``` + +**採用理由:** + +- `features` 型は `features` 内で完結し、アーキテクチャ違反がない。 +- サービスファイルはクエリ+変換責務に集中できる。 +- TTL 設定・キャッシュキー・invalidate ロジックが同一ドメインファイルに閉じる。 +- `server/` という名前は SvelteKit の `+page.server.ts` と文脈が異なるが、「サーバー専用コード」の意味では一貫している。features 内に新規ディレクトリ規約を追加するコストはあるが、ドメイン分離の恩恵で正当化できる。 + +--- + +## HOF パターンと fetchFn の責務 + +**高階関数(Higher-order function)パターン:** + +```typescript +// src/features/votes/server/cache.ts +export async function getCachedVoteStats( + fetchFn: () => Promise>, +): Promise> { + const cached = cache.get(KEY); + + if (cached) { + return cached; + } + + const result = await fetchFn(); + cache.set(KEY, result); + + return result; +} +``` + +```typescript +// src/features/votes/services/vote_statistics.ts +export async function getVoteGradeStatistics(): Promise> { + return getCachedVoteStats(async () => { + const allStats = await prisma.votedGradeStatistics.findMany(); + return new Map(allStats.map((stat) => [stat.taskId, stat])); + }); +} +``` + +**fetchFn が返すのは変換済みの型(raw ではない):** + +既存コードを確認すると、5関数すべて「Prisma raw → ドメイン型」の変換を関数内部で完結させている。キャッシュに格納するのも変換済みの型が合理的(キャッシュヒット時に変換コストをスキップできる)。変換はCRUD側(fetchFn の中)の責務とする。 + +``` +getCachedVoteStats(fetchFn) +├── キャッシュヒット → fetchFn を呼ばずに Map を返す +└── キャッシュミス → fetchFn() を実行(DB + 変換)→ Map をキャッシュ → 返す +``` + +サービスファイルはキャッシュの存在・詳細を知らずに済む。 + +--- + +## 対象関数と TTL + +| 関数 | キャッシュモジュール | TTL | invalidate | +| --------------------------------------- | ---------------------------------------- | ----- | ------------------------------ | +| `getTasksByTaskId()` | `src/lib/server/tasks/cache.ts` | 1時間 | `createTask` / `updateTask` 後 | +| `getMergedTasksMap()`(引数なし時のみ) | `src/lib/server/tasks/cache.ts` | 1時間 | `createTask` / `updateTask` 後 | +| `getVoteGradeStatistics()` | `src/features/votes/server/cache.ts` | 10分 | TTL のみ(投票は高頻度 write) | +| `getWorkbooksByPlacement()` | `src/features/workbooks/server/cache.ts` | 1時間 | workbook 書き込み系3関数後 | +| `getWorkBooksCreatedByUsers()` | `src/features/workbooks/server/cache.ts` | 1時間 | workbook 書き込み系3関数後 | + +`getMergedTasksMap(tasks?: Tasks)` は `tasks` 引数あり(フィルタ済みリストを渡すケース)の場合はキャッシュしない。実際の呼び出し元はすべて引数なし。 + +--- + +## ファイル構成 + +| 操作 | パス | 内容 | +| -------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| 新規作成 | `src/lib/server/tasks/cache.ts` | タスク系 `getCached*()` + `invalidateTaskCaches()` | +| 新規作成 | `src/features/votes/server/cache.ts` | `getCachedVoteStats()` + `invalidateVoteCaches()` | +| 新規作成 | `src/features/workbooks/server/cache.ts` | `getCachedWorkbooksByPlacement()` + `getCachedWorkbooksByUser()` + `invalidateWorkbookCaches()` | +| 修正 | `src/lib/services/tasks.ts` | `getTasksByTaskId()`・`getMergedTasksMap()` を `getCached*()` 経由に。`createTask()`・`updateTask()` に `invalidateTaskCaches()` 追加 | +| 修正 | `src/features/votes/services/vote_statistics.ts` | `getVoteGradeStatistics()` を `getCachedVoteStats()` 経由に | +| 修正 | `src/features/workbooks/services/workbooks.ts` | getter 2関数を `getCached*()` 経由に。writer 3関数に `invalidateWorkbookCaches()` 追加 | +| 新規作成 | `src/lib/server/tasks/cache.test.ts` | キャッシュ挙動テスト(hit/miss/TTL/invalidate) | +| 新規作成 | `src/features/votes/server/cache.test.ts` | キャッシュ挙動テスト(hit/miss/TTL/invalidate) | +| 新規作成 | `src/features/workbooks/server/cache.test.ts` | キャッシュ挙動テスト(hit/miss/invalidate) | + +--- + +## テスト戦略 + +**キャッシュ挙動テスト** は `server/cache.test.ts` で行う(`Cache` インスタンスを直接使い、`vi.useFakeTimers()` で TTL を検証)。 + +**`setInterval` リーク防止:** `Cache` はコンストラクタで `setInterval()` を起動する。テスト終了後にタイマーが残るのを防ぐため、各キャッシュモジュールは `dispose*Caches()` を export し、`cache.test.ts` の `afterAll()` で呼ぶ。 + +**invalidate API の統一:** 3ドメインすべて `invalidate*Caches()` を public export する。votes は TTL のみで invalidate 不要だが、テストリセット用途として統一する(将来の write-through invalidation にも対応できる)。 + +**サービステスト** はキャッシュモジュールを `vi.mock()` で透過化する(`getCached*` が常に fetchFn を呼ぶよう差し替え)。サービステストはキャッシュを意識せず、DB クエリ・変換ロジックの正しさだけを検証する。 + +```typescript +// サービステストの例 +vi.mock('$features/votes/server/cache', () => ({ + getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), +})); +``` diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md new file mode 100644 index 000000000..0311ab0cd --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md @@ -0,0 +1,922 @@ +# Phase 4:共有データのサーバー側キャッシュ層 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** DB クエリ結果をプロセス内 `Map`+TTL でキャッシュし、warm インスタンスでの Function Duration と不要な DB スキャンを削減する。 + +**Architecture:** `Cache.getOrFetch(key, fetchFn)` で get-or-fetch パターンを汎用メソッドとして提供し、ドメインごとに `server/cache.ts`(Case C)で薄いラッパー関数 `getCached*(fetchFn)` を定義する。fetchFn は DB クエリ+変換済みの型を返す責務を持つ。サービス関数はキャッシュの詳細を知らずに `getCached*()` を呼ぶだけでよい。 + +**Tech Stack:** TypeScript, Vitest, `Cache` (src/lib/clients/cache.ts), Prisma/db singleton + +> 調査・前提条件・Fluid Compute 検討は [survey.md](./survey.md)、設計判断(案A/B/C・HOF パターン・対象関数・テスト戦略)は [design.md](./design.md) を参照。 + +--- + +## Task 0: `Cache.getOrFetch()` メソッドを追加する(テストから) + +**Files:** + +- Modify: `src/lib/clients/cache.ts` +- Modify: `src/lib/clients/cache.test.ts` + +- [ ] **Step 1: テストを書く** + +`src/lib/clients/cache.test.ts` の既存 `describe('Cache', ...)` 内に追加: + +```typescript +describe('getOrFetch', () => { + describe('successful case', () => { + test('calls fetchFn and caches result on first invocation', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + const result = await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result).toBe('fetched'); + }); + + test('returns cached value without calling fetchFn on subsequent calls', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('error cases', () => { + test('propagates fetchFn error without caching', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockRejectedValue(new Error('fetch failed')); + await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); + expect(cache.size).toBe(0); + }); + + test('retries fetchFn after a previous failure', async () => { + const cache = new Cache(); + const fetchFn = vi + .fn() + .mockRejectedValueOnce(new Error('fetch failed')) + .mockResolvedValue('retried'); + await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); + const result = await cache.getOrFetch('key', fetchFn); + expect(result).toBe('retried'); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('boundary cases', () => { + test('caches empty Map as valid value', async () => { + const cache = new Cache>(); + const fetchFn = vi.fn().mockResolvedValue(new Map()); + await cache.getOrFetch('key', fetchFn); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('returns cached value just before TTL expires', async () => { + const TTL = 1000; + const cache = new Cache(TTL); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + vi.advanceTimersByTime(TTL - 1); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('calls fetchFn again after TTL expires', async () => { + const TTL = 1000; + const cache = new Cache(TTL); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + vi.advanceTimersByTime(TTL + 1); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- src/lib/clients/cache.test.ts +``` + +Expected: `getOrFetch` is not a function でエラー + +- [ ] **Step 3: 実装を書く** + +`src/lib/clients/cache.ts` の `Cache` クラスに追加(`get()` メソッドの後): + +```typescript +async getOrFetch(key: string, fetchFn: () => Promise): Promise { + const cached = this.get(key); + if (cached !== undefined) { + return cached; + } + const result = await fetchFn(); + this.set(key, result); + return result; +} +``` + +> `if (cached)` ではなく `if (cached !== undefined)` を使用。`get()` は miss 時に `undefined` を返すため、`null` や空配列などの falsy な `T` も正しくキャッシュされる。 + +- [ ] **Step 4: テストを通す** + +```bash +pnpm test:unit -- src/lib/clients/cache.test.ts +``` + +Expected: 全 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/clients/cache.ts src/lib/clients/cache.test.ts +git commit -m "feat(cache): add getOrFetch method to Cache class" +``` + +--- + +## Task 1: キャッシュモジュール 3ファイルを作成する(テストから) + +### 1a. `src/lib/server/tasks/cache.ts` + +**Files:** + +- Create: `src/lib/server/tasks/cache.ts` +- Create: `src/lib/server/tasks/cache.test.ts` + +- [ ] **Step 1: テストを書く** + +```typescript +// src/lib/server/tasks/cache.test.ts +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import type { Task } from '$lib/types/task'; +import { + getCachedTasksMap, + getCachedMergedTasksMap, + invalidateTaskCaches, + disposeTaskCaches, +} from './cache'; + +const taskEntry = new Map([['abc422_a', { task_id: 'abc422_a' } as unknown as Task]]); +const mockFetchFn = (data: Map = new Map()) => vi.fn().mockResolvedValue(data); + +afterAll(() => disposeTaskCaches()); + +describe('getCachedTasksMap', () => { + beforeEach(() => invalidateTaskCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to cache and returns fetched value', async () => { + const fetchFn = mockFetchFn(taskEntry); + const result = await getCachedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result.get('abc422_a')?.task_id).toBe('abc422_a'); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(taskEntry); + await getCachedTasksMap(fetchFn); + await getCachedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('getCachedMergedTasksMap', () => { + beforeEach(() => invalidateTaskCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedMergedTasksMap(fetchFn); + await getCachedMergedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateTaskCaches', () => { + afterEach(() => vi.restoreAllMocks()); + + test('clears both tasks and mergedTasks caches', async () => { + const tasksFn = mockFetchFn(); + const mergedFn = mockFetchFn(); + await getCachedTasksMap(tasksFn); + await getCachedMergedTasksMap(mergedFn); + invalidateTaskCaches(); + await getCachedTasksMap(tasksFn); + await getCachedMergedTasksMap(mergedFn); + expect(tasksFn).toHaveBeenCalledTimes(2); + expect(mergedFn).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- src/lib/server/tasks/cache.test.ts +``` + +Expected: `tasks_cache` not found でエラー + +- [ ] **Step 3: 実装を書く** + +```typescript +// src/lib/server/tasks/cache.ts +import { Cache } from '$lib/clients/cache'; +import type { Task } from '$lib/types/task'; +import type { TaskMapByContestTaskPair } from '$lib/types/contest_task_pair'; + +const HOUR_MS = 60 * 60 * 1000; +const TASK_MAP_KEY = 'tasks_by_task_id'; +const MERGED_KEY = 'merged_tasks_map'; + +const tasksCache = new Cache>(HOUR_MS); +const mergedTasksCache = new Cache(HOUR_MS); + +export function getCachedTasksMap( + fetchFn: () => Promise>, +): Promise> { + return tasksCache.getOrFetch(TASK_MAP_KEY, fetchFn); +} + +export function getCachedMergedTasksMap( + fetchFn: () => Promise, +): Promise { + return mergedTasksCache.getOrFetch(MERGED_KEY, fetchFn); +} + +export function invalidateTaskCaches(): void { + tasksCache.delete(TASK_MAP_KEY); + mergedTasksCache.delete(MERGED_KEY); +} + +export function disposeTaskCaches(): void { + tasksCache.dispose(); + mergedTasksCache.dispose(); +} +``` + +> 実際の型 import パスは `$lib/types/` 内を確認して合わせること。`TaskMapByContestTaskPair` は `$lib/types/contest_task_pair` にある。 + +- [ ] **Step 4: テストを通す** + +```bash +pnpm test:unit -- src/lib/server/tasks/cache.test.ts +``` + +Expected: 全 PASS + +--- + +### 1b. `src/features/votes/server/cache.ts` + +**Files:** + +- Create: `src/features/votes/server/cache.ts` +- Create: `src/features/votes/server/cache.test.ts` + +- [ ] **Step 1: テストを書く** + +```typescript +// src/features/votes/server/cache.test.ts +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import type { VotedGradeStatistics } from '@prisma/client'; +import { TaskGrade } from '$lib/types/task'; +import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache'; + +const makeStats = (): Map => + new Map([ + [ + 'abc408_d', + { + id: '1', + taskId: 'abc408_d', + grade: TaskGrade.Q1, + isExperimental: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } as unknown as VotedGradeStatistics, + ], + ]); +const mockStatsFn = () => vi.fn().mockResolvedValue(makeStats()); + +afterAll(() => disposeVoteCaches()); + +describe('getCachedVoteStats', () => { + beforeEach(() => invalidateVoteCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to cache and returns fetched value', async () => { + const fetchFn = mockStatsFn(); + const result = await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result.get('abc408_d')?.grade).toBe(TaskGrade.Q1); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockStatsFn(); + await getCachedVoteStats(fetchFn); + await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateVoteCaches', () => { + afterEach(() => vi.restoreAllMocks()); + + test('clears vote stats cache', async () => { + const fetchFn = mockStatsFn(); + await getCachedVoteStats(fetchFn); + invalidateVoteCaches(); + await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- src/features/votes/server/cache.test.ts +``` + +- [ ] **Step 3: 実装を書く** + +```typescript +// src/features/votes/server/cache.ts +import { Cache } from '$lib/clients/cache'; +import type { VotedGradeStatistics } from '@prisma/client'; + +const VOTE_STATS_TTL_MS = 10 * 60 * 1000; +const KEY = 'vote_grade_statistics'; + +const cache = new Cache>(VOTE_STATS_TTL_MS); + +export function getCachedVoteStats( + fetchFn: () => Promise>, +): Promise> { + return cache.getOrFetch(KEY, fetchFn); +} + +export function invalidateVoteCaches(): void { + cache.delete(KEY); +} + +export function disposeVoteCaches(): void { + cache.dispose(); +} +``` + +- [ ] **Step 4: テストを通す** + +```bash +pnpm test:unit -- src/features/votes/server/cache.test.ts +``` + +Expected: 全 PASS + +--- + +### 1c. `src/features/workbooks/server/cache.ts` + +**Files:** + +- Create: `src/features/workbooks/server/cache.ts` +- Create: `src/features/workbooks/server/cache.test.ts` + +- [ ] **Step 1: テストを書く** + +```typescript +// src/features/workbooks/server/cache.test.ts +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; + +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { WorkBookType } from '$features/workbooks/types/workbook'; + +import { + getCachedWorkbooksByPlacement, + getCachedWorkbooksByUser, + invalidateWorkbookCaches, + disposeWorkbookCaches, +} from './cache'; + +const solutionQuery: PlacementQuery = { + workBookType: WorkBookType.SOLUTION, + solutionCategory: SolutionCategory.SEARCH_SIMULATION, +}; +const mockFetchFn = () => vi.fn().mockResolvedValue([]); + +afterAll(() => disposeWorkbookCaches()); + +describe('getCachedWorkbooksByPlacement', () => { + beforeEach(() => invalidateWorkbookCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('misses cache when solutionCategory differs', async () => { + const fetchFn = mockFetchFn(); + const otherQuery: PlacementQuery = { + ...solutionQuery, + solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING, + }; + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(otherQuery, false, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + test('misses cache when includeUnpublished differs', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(solutionQuery, true, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); +}); + +describe('getCachedWorkbooksByUser', () => { + beforeEach(() => invalidateWorkbookCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByUser(fetchFn); + await getCachedWorkbooksByUser(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateWorkbookCaches', () => { + afterEach(() => vi.restoreAllMocks()); + + test('clears both placement and user caches', async () => { + const placementFn = mockFetchFn(); + const userFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByUser(userFn); + invalidateWorkbookCaches(); + await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByUser(userFn); + expect(placementFn).toHaveBeenCalledTimes(2); + expect(userFn).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- src/features/workbooks/server/cache.test.ts +``` + +- [ ] **Step 3: 実装を書く** + +```typescript +// src/features/workbooks/server/cache.ts +import { Cache } from '$lib/clients/cache'; +import type { WorkbooksWithAuthors } from '$features/workbooks/types/workbook'; +import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; + +const HOUR_MS = 60 * 60 * 1000; +const BY_USER_KEY = 'workbooks_by_user'; + +const placementCache = new Cache(HOUR_MS); +const byUserCache = new Cache(HOUR_MS); + +function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { + if (query.workBookType === WorkBookTypeConst.CURRICULUM) { + return `CURRICULUM:${query.taskGrade}:${includeUnpublished}`; + } + + return `SOLUTION:${query.solutionCategory}:${includeUnpublished}`; +} + +export function getCachedWorkbooksByPlacement( + query: PlacementQuery, + includeUnpublished: boolean, + fetchFn: () => Promise, +): Promise { + const key = buildPlacementKey(query, includeUnpublished); + return placementCache.getOrFetch(key, fetchFn); +} + +export function getCachedWorkbooksByUser( + fetchFn: () => Promise, +): Promise { + return byUserCache.getOrFetch(BY_USER_KEY, fetchFn); +} + +export function invalidateWorkbookCaches(): void { + placementCache.clear(); + byUserCache.clear(); +} + +export function disposeWorkbookCaches(): void { + placementCache.dispose(); + byUserCache.dispose(); +} +``` + +- [ ] **Step 4: テストを通す** + +```bash +pnpm test:unit -- src/features/workbooks/server/cache.test.ts +``` + +Expected: 全 PASS + +- [ ] **Step 5: 全3ファイルまとめて Commit** + +```bash +git add \ + src/lib/server/tasks/cache.ts \ + src/lib/server/tasks/cache.test.ts \ + src/features/votes/server/cache.ts \ + src/features/votes/server/cache.test.ts \ + src/features/workbooks/server/cache.ts \ + src/features/workbooks/server/cache.test.ts +git commit -m "feat(cache): add per-domain server cache modules using getOrFetch" +``` + +--- + +## Task 2: `getTasksByTaskId()` / `getMergedTasksMap()` をキャッシュ経由に変更する + +**Files:** + +- Modify: `src/lib/services/tasks.ts` +- Modify: `src/test/lib/services/tasks.test.ts` + +- [ ] **Step 0: `getMergedTasksMap()` の本体ロジックを `buildMergedMap()` に抽出する** + +既存の `getMergedTasksMap()` 内のマージロジック(`baseTaskMap` 構築〜`additionalTaskMap` 生成〜`return new Map(...)` まで)をプライベート関数 `buildMergedMap(tasks: Tasks, contestTaskPairs: ContestTaskPair[]): TaskMapByContestTaskPair` に抽出する。`getMergedTasksMap()` は `buildMergedMap()` を呼ぶだけに簡素化する。この段階でテストが通ることを確認してからキャッシュ統合に進む。 + +```bash +pnpm test:unit -- src/test/lib/services/tasks.test.ts +``` + +- [ ] **Step 1: import を追加し、2関数を修正する** + +```typescript +// tasks.ts 先頭に追加: +import { getCachedTasksMap, getCachedMergedTasksMap } from '$lib/server/tasks/cache'; + +// getTasksByTaskId() を以下に置き換え: +export async function getTasksByTaskId(): Promise> { + return getCachedTasksMap(async () => { + const tasks = await db.task.findMany(); + return new Map(tasks.map((task) => [task.task_id, task])); + }); +} + +// getMergedTasksMap() を以下に置き換え: +export async function getMergedTasksMap(tasks?: Tasks): Promise { + if (tasks !== undefined) { + const contestTaskPairs = await getContestTaskPairs(); + return buildMergedMap(tasks, contestTaskPairs); + } + + return getCachedMergedTasksMap(async () => { + const [allTasks, contestTaskPairs] = await Promise.all([getTasks(), getContestTaskPairs()]); + return buildMergedMap(allTasks, contestTaskPairs); + }); +} +``` + +- [ ] **Step 2: 既存テストにキャッシュ mock を追加する** + +`src/test/lib/services/tasks.test.ts` の先頭(他の `vi.mock` の近く)に追加: + +```typescript +vi.mock('$lib/server/tasks/cache', () => ({ + getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), + getCachedMergedTasksMap: (fetchFn: () => Promise) => fetchFn(), + invalidateTaskCaches: vi.fn(), +})); +``` + +- [ ] **Step 3: 型チェック + テストを通す** + +```bash +pnpm check && pnpm test:unit -- src/test/lib/services/tasks.test.ts +``` + +Expected: 既存テストが全 PASS + +--- + +## Task 3: `createTask()` / `updateTask()` に invalidate を追加する + +**Files:** + +- Modify: `src/lib/services/tasks.ts` + +- [ ] **Step 1: import に `invalidateTaskCaches` を追加し、write 関数に invalidate を追記する** + +Task 2 で追加した import に `invalidateTaskCaches` を追加する: + +```typescript +import { + getCachedTasksMap, + getCachedMergedTasksMap, + invalidateTaskCaches, +} from '$lib/server/tasks/cache'; +``` + +`createTask()` は早期リターンあり(タスク既存 or `contest_type === null`)。`invalidateTaskCaches()` は `db.task.create()` の直後(=実際に DB 書き込みが行われた場合のみ)に追加する: + +```typescript +// createTask() — db.task.create() の直後に追加: +invalidateTaskCaches(); + +// updateTask() — db.task.update() の直後(try ブロック内、console.log の後)に追加: +invalidateTaskCaches(); +``` + +- [ ] **Step 2: テストを通す** + +```bash +pnpm test:unit -- src/test/lib/services/tasks.test.ts +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/services/tasks.ts src/test/lib/services/tasks.test.ts +git commit -m "feat(cache): wrap task getters with cache HOF, invalidate on writes" +``` + +--- + +## Task 4: `getVoteGradeStatistics()` をキャッシュ経由に変更する + +**Files:** + +- Modify: `src/features/votes/services/vote_statistics.ts` +- Modify: `src/features/votes/services/vote_statistics.test.ts` + +- [ ] **Step 1: import を追加し、関数を修正する** + +```typescript +// vote_statistics.ts 先頭に追加: +import { getCachedVoteStats } from '$features/votes/server/cache'; + +// getVoteGradeStatistics() を以下に置き換え: +export async function getVoteGradeStatistics(): Promise> { + return getCachedVoteStats(async () => { + const allStats = await prisma.votedGradeStatistics.findMany(); + return new Map(allStats.map((stat) => [stat.taskId, stat])); + }); +} +``` + +- [ ] **Step 2: 既存テストにキャッシュ mock を追加する** + +`src/features/votes/services/vote_statistics.test.ts` の先頭(他の `vi.mock` の近く)に追加: + +```typescript +vi.mock('$features/votes/server/cache', () => ({ + getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), +})); +``` + +- [ ] **Step 3: テストを通す** + +```bash +pnpm check && pnpm test:unit -- src/features/votes/services/vote_statistics.test.ts +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/features/votes/services/vote_statistics.ts src/features/votes/services/vote_statistics.test.ts +git commit -m "feat(cache): wrap getVoteGradeStatistics with cache HOF (10-min TTL)" +``` + +--- + +## Task 5: 問題集 getter をキャッシュ経由に変更し、writer に invalidate を追加する + +**Files:** + +- Modify: `src/features/workbooks/services/workbooks.ts` +- Modify: `src/features/workbooks/services/workbooks.test.ts` + +- [ ] **Step 1: import を追加する** + +```typescript +import { + getCachedWorkbooksByPlacement, + getCachedWorkbooksByUser, + invalidateWorkbookCaches, +} from '$features/workbooks/server/cache'; +``` + +- [ ] **Step 2: `getWorkbooksByPlacement()` を修正する** + +```typescript +export async function getWorkbooksByPlacement( + query: PlacementQuery, + includeUnpublished = false, +): Promise { + return getCachedWorkbooksByPlacement(query, includeUnpublished, async () => { + const placementFilter = buildPlacementFilter(query); + const workbooks = await db.workBook.findMany({ + where: { + workBookType: query.workBookType, + ...(includeUnpublished ? {} : { isPublished: true }), + placement: placementFilter, + }, + orderBy: { placement: { priority: 'asc' } }, + include: { + user: { select: { username: true } }, + workBookTasks: { orderBy: { priority: 'asc' } }, + }, + }); + return mapWithAuthorName(workbooks); + }); +} +``` + +- [ ] **Step 3: `getWorkBooksCreatedByUsers()` を修正する** + +```typescript +export async function getWorkBooksCreatedByUsers(): Promise { + return getCachedWorkbooksByUser(async () => { + const workbooks = await db.workBook.findMany({ + where: { workBookType: WorkBookTypeConst.CREATED_BY_USER }, + orderBy: { id: 'asc' }, + include: { + user: { select: { username: true } }, + workBookTasks: { orderBy: { priority: 'asc' } }, + }, + }); + return mapWithAuthorName(workbooks); + }); +} +``` + +- [ ] **Step 4: `createWorkBook()` / `updateWorkBook()` / `deleteWorkBook()` の末尾に追加する** + +```typescript +// 各関数の DB 書き込み完了後に追加: +invalidateWorkbookCaches(); +``` + +`updateWorkBook()` は `await db.$transaction(...)` の後に追加すること。 + +- [ ] **Step 5: 既存テストにキャッシュ mock を追加する** + +`src/features/workbooks/services/workbooks.test.ts` の先頭(他の `vi.mock` の近く)に追加: + +```typescript +vi.mock('$features/workbooks/server/cache', () => ({ + getCachedWorkbooksByPlacement: ( + _query: unknown, + _includeUnpublished: unknown, + fetchFn: () => Promise, + ) => fetchFn(), + getCachedWorkbooksByUser: (fetchFn: () => Promise) => fetchFn(), + invalidateWorkbookCaches: vi.fn(), +})); +``` + +- [ ] **Step 6: テストを通す** + +```bash +pnpm check && pnpm test:unit +``` + +Expected: 全 PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/features/workbooks/services/workbooks.ts src/features/workbooks/services/workbooks.test.ts +git commit -m "feat(cache): wrap workbook getters with cache HOF, invalidate on writes" +``` + +--- + +## 検証 + +```bash +pnpm test:unit # 全 PASS +pnpm check # 型エラーなし +pnpm lint # lint エラーなし +``` + +ローカル `pnpm dev` で `/workbooks`・`/problems` を2回アクセスし、2回目のサーバーログに DB クエリが出ないことを確認(開発環境は warm インスタンスが維持されないため参考程度)。 + +デプロイ後、Vercel ダッシュボードで Function Duration を 1〜2 週間観測。 + +--- + +## 残 TODO(本プランのスコープ外) + +- 投票統計 TTL 最終調整(計測後 5〜15分の範囲で) +- `getAllTasksWithVoteInfo()` への Phase 4 拡張(votes UI 改修完了後に別チケット) +- Phase 5:問題集一覧の遅延ロード(本番転送量再計測後に判断) + +--- + +## Draft: `.claude/rules/server-cache.md` + +実装完了後に以下のルールファイルを作成する。 + +````markdown +--- +description: Server-side caching rules +paths: + - 'src/lib/clients/cache.ts' + - 'src/lib/server/**/cache.ts' + - 'src/features/**/server/cache.ts' +--- + +# Server-Side Cache + +## Core Pattern: `Cache.getOrFetch()` + +Use `cache.getOrFetch(key, fetchFn)` for all get-or-fetch operations. Never inline the get/set/if pattern manually — it duplicates logic that `getOrFetch` already provides. + +```typescript +export function getCachedFoo(fetchFn: () => Promise): Promise { + return fooCache.getOrFetch(KEY, fetchFn); +} +``` +```` + +## Domain Cache Module Structure + +Place cache modules at `server/cache.ts` within each domain: + +| Domain | Path | +| ------- | ------------------------------------- | +| Shared | `src/lib/server/{domain}/cache.ts` | +| Feature | `src/features/{name}/server/cache.ts` | + +Each module exports: + +- `getCached*()` — thin wrapper around `cache.getOrFetch()` +- `invalidate*Caches()` — clears all related cache instances +- `dispose*Caches()` — disposes all related cache instances (for test cleanup) + +## Invalidation Rules + +- Call `invalidate*Caches()` immediately after the DB write (`create`, `update`, `delete`) succeeds — not before, not in a `finally` block. +- Group related caches in a single invalidation function (e.g., `invalidateTaskCaches()` clears both `tasksCache` and `mergedTasksCache`). +- Never invalidate from route handlers — invalidation belongs in the service layer, co-located with the write operation. + +## TTL Guidelines + +| Data characteristics | TTL | +| ------------------------------ | ---------- | +| Rarely changes (tasks, grades) | 1 hour | +| Moderately changes (votes) | 10 minutes | + +Adjust based on production metrics after deployment. + +## Testing + +Core cache behavior (hit, miss, TTL, error propagation) is tested on `Cache.getOrFetch()` in `src/lib/clients/cache.test.ts`. Domain cache tests cover only domain-specific concerns: + +- **Wiring**: wrapper delegates correctly and returns cached value on subsequent calls +- **Key isolation**: different parameters produce different cache keys (e.g., `buildPlacementKey`) +- **Invalidation grouping**: `invalidate*()` clears all related caches + +Do not duplicate TTL or error propagation tests in domain cache test files. + +## Service Layer Integration + +Services call `getCached*()` with a `fetchFn` that performs the DB query. The service does not import `Cache` directly. + +```typescript +// In service file +import { getCachedTasksMap } from '$lib/server/tasks/cache'; + +export async function getTasksByTaskId(): Promise> { + return getCachedTasksMap(async () => { + const tasks = await db.task.findMany(); + return new Map(tasks.map((task) => [task.task_id, task])); + }); +} +``` + +Mock cache in service tests to bypass caching: + +```typescript +vi.mock('$lib/server/tasks/cache', () => ({ + getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), + invalidateTaskCaches: vi.fn(), +})); +``` diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md b/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md new file mode 100644 index 000000000..7ef27fb02 --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md @@ -0,0 +1,73 @@ +# Phase 4:調査・前提条件・Fluid Compute 検討 + +> 本ドキュメントは [plan.md](./plan.md) の背景資料。実装手順は plan.md、設計判断は [design.md](./design.md) を参照。 + +## 背景 + +Phase 1〜3 で過剰取得・匿名キャッシュの最適化を完了。残る課題はサーバープロセス内での DB 再取得コスト。共有かつ低頻度更新のデータ(タスク全件、問題集一覧、投票統計)は warm インスタンス内でキャッシュが有効に働く。 + +## 前提条件と制約 + +**トラフィック規模(2026年5月実績):** + +- 月3.8万PV、日ユーザー200-300人 +- Function Invocations: 月236k(日約8k、特異日で54k/日を記録) +- Function Duration: 84GB hrs / Fast Origin Transfer: 35GB +- ピーク時間帯: 昼12:00、夕方18:00、夜20:00 に集中 + +**プロセス内キャッシュの特性:** + +- Vercel サーバーレス関数の warm インスタンス内でのみ有効。インスタンス間のメモリ共有はない +- コールドスタート時はキャッシュ空から開始。TTL はインスタンス生存時間(Vercel 側制御、通常数分〜十数分)が上限 +- ピーク時間帯はリクエスト間隔が短く warm 維持が見込める。オフピーク時はキャッシュヒット率が低下する + +**Phase 4 の効果範囲:** + +| route | 日リクエスト | Phase 4 対象関数 | 備考 | +| --------------------------------------------- | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `/problems` | ~2k | `getMergedTasksMap()` + `getVoteGradeStatistics()` | 匿名ユーザーは CDN (`s-maxage=300`) でカバー済み。Phase 4 はログインユーザーのみに効く | +| `/workbooks` (一覧) | 不明 | `getWorkbooksByPlacement()` + `getWorkBooksCreatedByUsers()` | | +| `/workbooks/create`, `/workbooks/edit/[slug]` | 低頻度 | `getTasksByTaskId()` | 管理者のみ | +| `/workbooks/[slug]` | ~2k | **対象外** | 個別取得(`getWorkbookWithAuthor` + `getVoteGradeStatisticsForTaskIds`)のため全件キャッシュは効かない | + +--- + +## Fluid Compute の有効化を推奨 + +現在レガシーサーバーレスモデル(1インスタンス1リクエスト)を使用しているが、Fluid Compute への切り替えを推奨する。 + +- **課金面:** レガシーは wall-clock time × メモリで課金され、DB クエリの I/O 待ち時間も全額課金される。Fluid Compute は Active CPU(実際のコード実行時間のみ)+ Provisioned Memory で課金され、I/O 待ち中は CPU 課金が止まる。DB クエリ中心のこのアプリは I/O 比率が高く、コスト削減効果が大きい([Fluid compute pricing](https://vercel.com/docs/functions/usage-and-pricing)、[Legacy pricing](https://vercel.com/docs/functions/usage-and-pricing/legacy-pricing)) +- **キャッシュ効果:** Fluid Compute の optimized concurrency により、複数リクエストが同一インスタンスを共有する。レガシーでは高トラフィック時にインスタンスが増えてキャッシュが分散するが、Fluid Compute ではインスタンス集約によりキャッシュヒット率が向上する([Fluid compute docs](https://vercel.com/docs/fluid-compute)) +- **並行性リスク:** モジュールスコープの変数が並行リクエスト間で共有されるため、リクエスト固有の値をグローバルに書き込むコードは危険。ただし Phase 4 のキャッシュは read-heavy かつ冪等(同じ fetchFn 結果の書き込み)なので並行性の問題は起きない([Vercel Fluid Compute Guide](https://getautonoma.com/blog/vercel-fluid-compute)) +- **切り替え:** プロジェクト設定 > Functions > Fluid Compute をトグル → Save → 再デプロイで反映。コード変更不要。2025年4月以降の新規プロジェクトではデフォルト有効([What is Compute?](https://vercel.com/docs/functions/concepts)) + +### コスト試算(2026年5月実績ベース) + +前提:Function Duration 84 GB-hrs、Invocations 236k、メモリ 3008MB(≒2.94GB)、リージョン hnd1(東京)。DB クエリ中心のため Active CPU 比率は wall-clock の 25-40% と推定。wall-clock = 84 GB-hrs ÷ 2.94GB ≒ 28.6時間。 + +| モデル | Function Duration | Invocations | 月額推定 | レガシー比 | +| ---------------- | ---------------------------------------------------- | ----------------------- | ---------- | ---------- | +| レガシー(現在) | 84 GB-hrs × $0.18 = $15.12 | 236k × $0.60/1M = $0.14 | **$15.26** | 100% | +| Fluid(CPU 40%) | CPU 11.4h × $0.202 + Mem 84 GB-hrs × $0.0167 = $3.71 | $0.14 | **$3.85** | 25% | +| Fluid(CPU 25%) | CPU 7.2h × $0.202 + Mem 84 GB-hrs × $0.0167 = $2.86 | $0.14 | **$3.00** | 20% | + +東京リージョン(hnd1)は Active CPU $0.202/hr、Provisioned Memory $0.0167/GB-hr。メモリ 3GB ではレガシーの GB-hrs が wall-clock の 3 倍で積まれるのに対し、Fluid Compute は Provisioned Memory 単価が大幅に安い($0.18 vs $0.0167/GB-hr)。**月 $11-12 程度(75-80%)の削減が見込める。** さらに optimized concurrency によるインスタンス集約で Provisioned Memory の実 GB-hrs も下がる可能性がある。 + +### Fluid Compute FAQ + +- **Q: warm インスタンスのキャッシュは同一ユーザーでのみ有効?** + A: ユーザー単位ではなくリクエスト単位。同じインスタンスに振られたリクエストはユーザーが異なっても同じモジュールスコープの変数を共有する。Vercel 公式もプロセス内キャッシュの活用を推奨している([What is Compute?](https://vercel.com/docs/functions/concepts)) +- **Q: コールドスタートは発生する?** + A: 発生する。ただし optimized concurrency でインスタンス数が減るため頻度は下がる。Pro プランの本番環境では pre-warmed instances により最低1インスタンスが warm 維持される([Fluid compute docs](https://vercel.com/docs/fluid-compute)) +- **Q: 1インスタンスにリクエストが集中してメモリが爆増しない?** + A: Vercel がインスタンスの capacity を監視し、余裕がある場合のみ同一インスタンスにルーティングする。容量不足時は自動で新インスタンスを起動する。Phase 4 のキャッシュはモジュールスコープに1つなので、並行リクエスト数が増えてもキャッシュのメモリ消費は増えない([Fluid compute docs](https://vercel.com/docs/fluid-compute)) +- **Q: リクエスト固有の値をグローバルに書き込むコードはある?(並行性安全性の調査)** + A: プロジェクト全体を調査済み。ユーザーID・セッション等をモジュールスコープに格納するコードはなく、リクエスト間のデータ汚染リスクはない。top-level await が2箇所あるが、いずれもマスタデータの読み取り専用で実害なし: + - `src/lib/services/task_results.ts:32-33` — `statusById` / `statusByName`(提出ステータス定義、変更頻度極低) + - `src/routes/problems/[slug]/+page.server.ts:9` — `buttons`(UI ボタン定義、変更頻度極低) + + これらはレガシーモデルでも warm インスタンス内で同じデータが使い回されており、Fluid Compute 固有の問題ではない。将来的にリクエストスコープへの移動を検討してもよいが Phase 4 のスコープ外。 + +### 結論 + +ユーザー数に比して Function Duration・転送量が大きく、削減の余地はある。ピーク集中型のトラフィックパターンから warm インスタンスでのキャッシュヒットが見込めるため、実装コストに見合う効果が期待できる。特異日(54k/日)のような突発的なトラフィック増加時は、キャッシュなしでは全リクエストが DB を叩くためプロセス内キャッシュの効果が最も大きくなる。Fluid Compute を有効にすることでキャッシュヒット率がさらに向上し、課金も最適化される。ただし `/workbooks/[slug]`(日2k)には効かない点に注意。 diff --git a/src/features/votes/server/cache.test.ts b/src/features/votes/server/cache.test.ts new file mode 100644 index 000000000..adf2a92f3 --- /dev/null +++ b/src/features/votes/server/cache.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; + +import type { VotedGradeStatistics } from '@prisma/client'; +import { TaskGrade } from '$lib/types/task'; +import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache'; + +const makeStats = (): Map => + new Map([ + [ + 'abc408_d', + { + id: '1', + taskId: 'abc408_d', + grade: TaskGrade.Q1, + isExperimental: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } as unknown as VotedGradeStatistics, + ], + ]); +const mockStatsFn = () => vi.fn().mockResolvedValue(makeStats()); + +afterAll(() => disposeVoteCaches()); + +describe('getCachedVoteStats', () => { + beforeEach(() => invalidateVoteCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to cache and returns fetched value', async () => { + const fetchFn = mockStatsFn(); + const result = await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result.get('abc408_d')?.grade).toBe(TaskGrade.Q1); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockStatsFn(); + await getCachedVoteStats(fetchFn); + await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateVoteCaches', () => { + beforeEach(() => invalidateVoteCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('clears vote stats cache', async () => { + const fetchFn = mockStatsFn(); + await getCachedVoteStats(fetchFn); + invalidateVoteCaches(); + await getCachedVoteStats(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/votes/server/cache.ts b/src/features/votes/server/cache.ts new file mode 100644 index 000000000..1b82831fe --- /dev/null +++ b/src/features/votes/server/cache.ts @@ -0,0 +1,21 @@ +import { Cache } from '$lib/clients/cache'; +import type { VotedGradeStatistics } from '@prisma/client'; + +const VOTE_STATS_TTL_MS = 10 * 60 * 1000; +const KEY = 'vote_grade_statistics'; + +const cache = new Cache>(VOTE_STATS_TTL_MS); + +export function getCachedVoteStats( + fetchFn: () => Promise>, +): Promise> { + return cache.getOrFetch(KEY, fetchFn); +} + +export function invalidateVoteCaches(): void { + cache.delete(KEY); +} + +export function disposeVoteCaches(): void { + cache.dispose(); +} diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index 89d32000d..22c6b51dd 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -30,6 +30,10 @@ vi.mock('$lib/server/database', () => ({ }, })); +vi.mock('$features/votes/server/cache', () => ({ + getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), +})); + import prisma from '$lib/server/database'; beforeEach(() => { diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index e84483c50..83898ca62 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -1,4 +1,6 @@ import { default as prisma } from '$lib/server/database'; +import { getCachedVoteStats } from '$features/votes/server/cache'; + import type { VotedGradeStatistics, VotedGradeCounter, TaskGrade } from '@prisma/client'; /** A task row enriched with estimated grade and total vote count. */ @@ -15,13 +17,10 @@ export type TaskWithVoteInfo = { }; export async function getVoteGradeStatistics(): Promise> { - const allStats = await prisma.votedGradeStatistics.findMany(); - const gradesMap = new Map(); - - allStats.forEach((stat) => { - gradesMap.set(stat.taskId, stat); + return getCachedVoteStats(async () => { + const allStats = await prisma.votedGradeStatistics.findMany(); + return new Map(allStats.map((stat) => [stat.taskId, stat])); }); - return gradesMap; } export async function getVoteGradeStatisticsForTaskIds( diff --git a/src/features/workbooks/server/cache.test.ts b/src/features/workbooks/server/cache.test.ts new file mode 100644 index 000000000..66edfbdca --- /dev/null +++ b/src/features/workbooks/server/cache.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; + +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { WorkBookType } from '$features/workbooks/types/workbook'; + +import { + getCachedWorkbooksByPlacement, + getCachedWorkbooksByUser, + invalidateWorkbookCaches, + disposeWorkbookCaches, +} from './cache'; + +const solutionQuery: PlacementQuery = { + workBookType: WorkBookType.SOLUTION, + solutionCategory: SolutionCategory.SEARCH_SIMULATION, +}; +const mockFetchFn = () => vi.fn().mockResolvedValue([]); + +afterAll(() => disposeWorkbookCaches()); + +describe('getCachedWorkbooksByPlacement', () => { + beforeEach(() => invalidateWorkbookCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('misses cache when solutionCategory differs', async () => { + const fetchFn = mockFetchFn(); + const otherQuery: PlacementQuery = { + ...solutionQuery, + solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING, + }; + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(otherQuery, false, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + test('misses cache when includeUnpublished differs', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement(solutionQuery, true, fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); +}); + +describe('getCachedWorkbooksByUser', () => { + beforeEach(() => invalidateWorkbookCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedWorkbooksByUser(fetchFn); + await getCachedWorkbooksByUser(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateWorkbookCaches', () => { + beforeEach(() => invalidateWorkbookCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('clears both placement and user caches', async () => { + const placementFn = mockFetchFn(); + const userFn = mockFetchFn(); + await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByUser(userFn); + invalidateWorkbookCaches(); + await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByUser(userFn); + expect(placementFn).toHaveBeenCalledTimes(2); + expect(userFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/workbooks/server/cache.ts b/src/features/workbooks/server/cache.ts new file mode 100644 index 000000000..ed639d182 --- /dev/null +++ b/src/features/workbooks/server/cache.ts @@ -0,0 +1,43 @@ +import { Cache } from '$lib/clients/cache'; +import type { WorkbooksWithAuthors } from '$features/workbooks/types/workbook'; +import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; + +const HOUR_MS = 60 * 60 * 1000; +const BY_USER_KEY = 'workbooks_by_user'; + +const placementCache = new Cache(HOUR_MS); +const byUserCache = new Cache(HOUR_MS); + +function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { + if (query.workBookType === WorkBookTypeConst.CURRICULUM) { + return `CURRICULUM:${query.taskGrade}:${includeUnpublished}`; + } + + return `SOLUTION:${query.solutionCategory}:${includeUnpublished}`; +} + +export function getCachedWorkbooksByPlacement( + query: PlacementQuery, + includeUnpublished: boolean, + fetchFn: () => Promise, +): Promise { + const key = buildPlacementKey(query, includeUnpublished); + return placementCache.getOrFetch(key, fetchFn); +} + +export function getCachedWorkbooksByUser( + fetchFn: () => Promise, +): Promise { + return byUserCache.getOrFetch(BY_USER_KEY, fetchFn); +} + +export function invalidateWorkbookCaches(): void { + placementCache.clear(); + byUserCache.clear(); +} + +export function disposeWorkbookCaches(): void { + placementCache.dispose(); + byUserCache.dispose(); +} diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index fe110f3ee..66715abbe 100644 --- a/src/features/workbooks/services/workbooks.test.ts +++ b/src/features/workbooks/services/workbooks.test.ts @@ -40,6 +40,16 @@ vi.mock('$lib/services/users', () => ({ getUserById: vi.fn(), })); +vi.mock('$features/workbooks/server/cache', () => ({ + getCachedWorkbooksByPlacement: ( + _query: unknown, + _includeUnpublished: unknown, + fetchFn: () => Promise, + ) => fetchFn(), + getCachedWorkbooksByUser: (fetchFn: () => Promise) => fetchFn(), + invalidateWorkbookCaches: vi.fn(), +})); + import prisma from '$lib/server/database'; import * as usersCrud from '$lib/services/users'; diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index d3c056cbc..a9dd3a208 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -21,6 +21,12 @@ import { } from '$features/workbooks/services/workbook_tasks'; import * as userCrud from '$lib/services/users'; +import { + getCachedWorkbooksByPlacement, + getCachedWorkbooksByUser, + invalidateWorkbookCaches, +} from '$features/workbooks/server/cache'; + import { sanitizeUrl } from '$lib/utils/url'; import { parseWorkBookId, parseWorkBookUrlSlug } from '$features/workbooks/utils/workbook'; @@ -72,28 +78,30 @@ export async function getWorkbooksByPlacement( query: PlacementQuery, includeUnpublished = false, ): Promise { - const placementFilter = buildPlacementFilter(query); + return getCachedWorkbooksByPlacement(query, includeUnpublished, async () => { + const placementFilter = buildPlacementFilter(query); - const workbooks = await db.workBook.findMany({ - where: { - workBookType: query.workBookType, - ...(includeUnpublished ? {} : { isPublished: true }), - placement: placementFilter, - }, - orderBy: { - placement: { priority: 'asc' }, - }, - include: { - user: { - select: { username: true }, + const workbooks = await db.workBook.findMany({ + where: { + workBookType: query.workBookType, + ...(includeUnpublished ? {} : { isPublished: true }), + placement: placementFilter, }, - workBookTasks: { - orderBy: { priority: 'asc' }, + orderBy: { + placement: { priority: 'asc' }, }, - }, - }); + include: { + user: { + select: { username: true }, + }, + workBookTasks: { + orderBy: { priority: 'asc' }, + }, + }, + }); - return mapWithAuthorName(workbooks); + return mapWithAuthorName(workbooks); + }); } /** @@ -101,20 +109,22 @@ export async function getWorkbooksByPlacement( * Intended for admin-only display on the workbooks list page. */ export async function getWorkBooksCreatedByUsers(): Promise { - const workbooks = await db.workBook.findMany({ - where: { workBookType: WorkBookTypeConst.CREATED_BY_USER }, - orderBy: { id: 'asc' }, - include: { - user: { - select: { username: true }, - }, - workBookTasks: { - orderBy: { priority: 'asc' }, + return getCachedWorkbooksByUser(async () => { + const workbooks = await db.workBook.findMany({ + where: { workBookType: WorkBookTypeConst.CREATED_BY_USER }, + orderBy: { id: 'asc' }, + include: { + user: { + select: { username: true }, + }, + workBookTasks: { + orderBy: { priority: 'asc' }, + }, }, - }, - }); + }); - return mapWithAuthorName(workbooks); + return mapWithAuthorName(workbooks); + }); } /** @@ -268,6 +278,7 @@ export async function createWorkBook(workBook: Omit): Promise { id: workBookId, }, }); + + invalidateWorkbookCaches(); } // ---- Private helpers ---- diff --git a/src/lib/clients/cache.test.ts b/src/lib/clients/cache.test.ts index 5b0c65d0a..afd3dff8f 100644 --- a/src/lib/clients/cache.test.ts +++ b/src/lib/clients/cache.test.ts @@ -343,4 +343,75 @@ describe('Cache', () => { expect(cache.size).toBe(0); }); }); + + describe('getOrFetch', () => { + describe('successful case', () => { + test('calls fetchFn and caches result on first invocation', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + const result = await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result).toBe('fetched'); + }); + + test('returns cached value without calling fetchFn on subsequent calls', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('error cases', () => { + test('propagates fetchFn error without caching', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockRejectedValue(new Error('fetch failed')); + await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); + expect(cache.size).toBe(0); + }); + + test('retries fetchFn after a previous failure', async () => { + const cache = new Cache(); + const fetchFn = vi + .fn() + .mockRejectedValueOnce(new Error('fetch failed')) + .mockResolvedValue('retried'); + await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); + const result = await cache.getOrFetch('key', fetchFn); + expect(result).toBe('retried'); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('boundary cases', () => { + test('caches empty Map as valid value', async () => { + const cache = new Cache>(); + const fetchFn = vi.fn().mockResolvedValue(new Map()); + await cache.getOrFetch('key', fetchFn); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('returns cached value just before TTL expires', async () => { + const TTL = 1000; + const cache = new Cache(TTL); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + vi.advanceTimersByTime(TTL - 1); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('calls fetchFn again after TTL expires', async () => { + const TTL = 1000; + const cache = new Cache(TTL); + const fetchFn = vi.fn().mockResolvedValue('fetched'); + await cache.getOrFetch('key', fetchFn); + vi.advanceTimersByTime(TTL + 1); + await cache.getOrFetch('key', fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); + }); }); diff --git a/src/lib/clients/cache.ts b/src/lib/clients/cache.ts index cafb54935..d8f6f30e5 100644 --- a/src/lib/clients/cache.ts +++ b/src/lib/clients/cache.ts @@ -126,6 +126,19 @@ export class Cache { return entry.data; } + async getOrFetch(key: string, fetchFn: () => Promise): Promise { + const cached = this.get(key); + + if (cached !== undefined) { + return cached; + } + + const result = await fetchFn(); + this.set(key, result); + + return result; + } + /** * Disposes of resources used by the cache instance. * diff --git a/src/lib/server/tasks/cache.test.ts b/src/lib/server/tasks/cache.test.ts new file mode 100644 index 000000000..5c687df88 --- /dev/null +++ b/src/lib/server/tasks/cache.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; + +import type { Task } from '$lib/types/task'; +import { + getCachedTasksMap, + getCachedMergedTasksMap, + invalidateTaskCaches, + disposeTaskCaches, +} from './cache'; + +const taskEntry = new Map([['abc422_a', { task_id: 'abc422_a' } as unknown as Task]]); +const mockFetchFn = (data: Map = new Map()) => vi.fn().mockResolvedValue(data); + +afterAll(() => disposeTaskCaches()); + +describe('getCachedTasksMap', () => { + beforeEach(() => invalidateTaskCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to cache and returns fetched value', async () => { + const fetchFn = mockFetchFn(taskEntry); + const result = await getCachedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result.get('abc422_a')?.task_id).toBe('abc422_a'); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(taskEntry); + await getCachedTasksMap(fetchFn); + await getCachedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('getCachedMergedTasksMap', () => { + beforeEach(() => invalidateTaskCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockFetchFn(); + await getCachedMergedTasksMap(fetchFn); + await getCachedMergedTasksMap(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('invalidateTaskCaches', () => { + beforeEach(() => invalidateTaskCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('clears both tasks and mergedTasks caches', async () => { + const tasksFn = mockFetchFn(); + const mergedFn = mockFetchFn(); + await getCachedTasksMap(tasksFn); + await getCachedMergedTasksMap(mergedFn); + invalidateTaskCaches(); + await getCachedTasksMap(tasksFn); + await getCachedMergedTasksMap(mergedFn); + expect(tasksFn).toHaveBeenCalledTimes(2); + expect(mergedFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/server/tasks/cache.ts b/src/lib/server/tasks/cache.ts new file mode 100644 index 000000000..ecc9e0993 --- /dev/null +++ b/src/lib/server/tasks/cache.ts @@ -0,0 +1,33 @@ +import { Cache } from '$lib/clients/cache'; + +import type { Task } from '$lib/types/task'; +import type { TaskMapByContestTaskPair } from '$lib/types/contest_task_pair'; + +const HOUR_MS = 60 * 60 * 1000; +const TASK_MAP_KEY = 'tasks_by_task_id'; +const MERGED_KEY = 'merged_tasks_map'; + +const tasksCache = new Cache>(HOUR_MS); +const mergedTasksCache = new Cache(HOUR_MS); + +export function getCachedTasksMap( + fetchFn: () => Promise>, +): Promise> { + return tasksCache.getOrFetch(TASK_MAP_KEY, fetchFn); +} + +export function getCachedMergedTasksMap( + fetchFn: () => Promise, +): Promise { + return mergedTasksCache.getOrFetch(MERGED_KEY, fetchFn); +} + +export function invalidateTaskCaches(): void { + tasksCache.delete(TASK_MAP_KEY); + mergedTasksCache.delete(MERGED_KEY); +} + +export function disposeTaskCaches(): void { + tasksCache.dispose(); + mergedTasksCache.dispose(); +} diff --git a/src/lib/services/tasks.ts b/src/lib/services/tasks.ts index dfbdcde5b..e4ddc99df 100644 --- a/src/lib/services/tasks.ts +++ b/src/lib/services/tasks.ts @@ -11,6 +11,11 @@ import type { TaskMapByContestTaskPair, } from '$lib/types/contest_task_pair'; +import { + getCachedTasksMap, + getCachedMergedTasksMap, + invalidateTaskCaches, +} from '$lib/server/tasks/cache'; import { classifyContest } from '$lib/utils/contest'; import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; @@ -40,16 +45,26 @@ export async function getTasks(): Promise { * const mergedTasksMap = await getMergedTasksMap(filteredTasks); */ export async function getMergedTasksMap(tasks?: Tasks): Promise { - const tasksToMerge = tasks ?? (await getTasks()); - const contestTaskPairs = await getContestTaskPairs(); + if (tasks !== undefined) { + const contestTaskPairs = await getContestTaskPairs(); + return buildMergedMap(tasks, contestTaskPairs); + } + + return getCachedMergedTasksMap(async () => { + const [allTasks, contestTaskPairs] = await Promise.all([getTasks(), getContestTaskPairs()]); + return buildMergedMap(allTasks, contestTaskPairs); + }); +} +function buildMergedMap( + tasks: Tasks, + contestTaskPairs: ContestTaskPair[], +): TaskMapByContestTaskPair { const baseTaskMap = new Map( - tasksToMerge.map((task) => [createContestTaskPairKey(task.contest_id, task.task_id), task]), + tasks.map((task) => [createContestTaskPairKey(task.contest_id, task.task_id), task]), ); - // Unique task_id in database - const taskMap = new Map(tasksToMerge.map((task) => [task.task_id, task])); + const taskMap = new Map(tasks.map((task) => [task.task_id, task])); - // Filter task(s) only the same task_id but different contest_id const additionalTaskMap = contestTaskPairs .filter((pair) => !baseTaskMap.has(createContestTaskPairKey(pair.contestId, pair.taskId))) .flatMap((pair) => { @@ -125,14 +140,10 @@ export async function getTasksWithSelectedTaskIds( } export async function getTasksByTaskId(): Promise> { - const tasks = await db.task.findMany(); - const tasksMap = new Map(); - - (await tasks).map((task) => { - tasksMap.set(task.task_id, task); + return getCachedTasksMap(async () => { + const tasks = await db.task.findMany(); + return new Map(tasks.map((task) => [task.task_id, task])); }); - - return tasksMap; } export async function getTask(task_id: string): Promise { @@ -177,6 +188,7 @@ export async function createTask( }, }); + invalidateTaskCaches(); console.log(task); } @@ -197,6 +209,7 @@ export async function updateTask(task_id: string, task_grade: TaskGrade): Promis }, }); + invalidateTaskCaches(); console.log(task); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { diff --git a/src/test/lib/services/tasks.test.ts b/src/test/lib/services/tasks.test.ts index b7fb6140f..62427f247 100644 --- a/src/test/lib/services/tasks.test.ts +++ b/src/test/lib/services/tasks.test.ts @@ -13,6 +13,12 @@ vi.mock('$lib/server/database', () => ({ }, })); +vi.mock('$lib/server/tasks/cache', () => ({ + getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), + getCachedMergedTasksMap: (fetchFn: () => Promise) => fetchFn(), + invalidateTaskCaches: vi.fn(), +})); + import db from '$lib/server/database'; describe('updateTask', () => { From 86dfac160429f8f3470379535674474f168dc870 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 20 Jun 2026 23:40:36 +0000 Subject: [PATCH 2/7] fix(cache): add stampede prevention, fix vote invalidation, strengthen type constraint Co-Authored-By: Claude Sonnet 4.6 --- .../phase4-server-cache/plan-2nd.md | 489 ++++++++++++++++++ .../2026-06-17/phase4-server-cache/review.md | 138 +++++ .../votes/services/vote_grade.test.ts | 27 + src/features/votes/services/vote_grade.ts | 15 +- src/features/workbooks/server/cache.ts | 2 +- src/lib/clients/cache.test.ts | 75 ++- src/lib/clients/cache.ts | 27 +- src/lib/clients/cache_strategy.ts | 21 +- 8 files changed, 760 insertions(+), 34 deletions(-) create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/review.md diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md new file mode 100644 index 000000000..ced890604 --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md @@ -0,0 +1,489 @@ +# Phase 4 Server Cache: Review 対応計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** review.md の優先順位まとめ #1〜#5 を修正し、キャッシュの信頼性・型安全性・重複排除を改善する + +**Architecture:** `Cache` クラスに in-flight dedup を追加(#1)、サービス層の invalidation 漏れ修正(#2)、設定値修正(#3)、型制約強化(#4)、`ContestTaskCache` の `getCachedOrFetch` を `Cache.getOrFetch` に委譲(#5) + +**Tech Stack:** TypeScript, Vitest, SvelteKit (server-only modules) + +## Global Constraints + +- TDD: テストを先に書き、失敗を確認してから実装 +- `pnpm test:unit` で全テストパス確認 +- `pnpm check` でエラー 0件確認 +- サービス層のテストでは `vi.mock('$lib/server/database', ...)` を使用 +- キャッシュモジュールのテストでは `afterAll(() => dispose*Caches())` でタイマークリーンアップ + +--- + +### Task 1: キャッシュスタンピード対策 — in-flight Promise の共有 + +**Files:** + +- Modify: `src/lib/clients/cache.ts:129-140` — `getOrFetch` に in-flight dedup 追加 +- Modify: `src/lib/clients/cache.test.ts` — stampede テスト追加 + +**Interfaces:** + +- Consumes: なし(既存 `Cache` クラスの内部変更) +- Produces: `getOrFetch(key, fetchFn)` のシグネチャは変更なし。並行呼び出し時に fetchFn が1回だけ実行されるようになる + +- [ ] **Step 1: 失敗するテストを書く** + +`src/lib/clients/cache.test.ts` の `describe('getOrFetch')` 内に以下を追加: + +```typescript +describe('stampede prevention', () => { + test('shares a single fetchFn call across concurrent requests for the same key', async () => { + const cache = new Cache(); + let resolvePromise: (value: string) => void; + const fetchFn = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const promise1 = cache.getOrFetch('key', fetchFn); + const promise2 = cache.getOrFetch('key', fetchFn); + + resolvePromise!('shared'); + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result1).toBe('shared'); + expect(result2).toBe('shared'); + }); + + test('cleans up inflight entry after fetchFn resolves', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('done'); + await cache.getOrFetch('key', fetchFn); + + const fetchFn2 = vi.fn().mockResolvedValue('done2'); + vi.advanceTimersByTime(cache['timeToLive'] + 1); + const result = await cache.getOrFetch('key', fetchFn2); + + expect(fetchFn2).toHaveBeenCalledTimes(1); + expect(result).toBe('done2'); + }); + + test('cleans up inflight entry and propagates error to all waiters on fetchFn failure', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockRejectedValue(new Error('boom')); + + const results = await Promise.allSettled([ + cache.getOrFetch('key', fetchFn), + cache.getOrFetch('key', fetchFn), + ]); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(results[0]).toEqual(expect.objectContaining({ status: 'rejected' })); + expect(results[1]).toEqual(expect.objectContaining({ status: 'rejected' })); + expect(cache.size).toBe(0); + }); + + test('does not share inflight between different keys', async () => { + const cache = new Cache(); + const fetchFnA = vi.fn().mockResolvedValue('a'); + const fetchFnB = vi.fn().mockResolvedValue('b'); + + const [resultA, resultB] = await Promise.all([ + cache.getOrFetch('keyA', fetchFnA), + cache.getOrFetch('keyB', fetchFnB), + ]); + + expect(fetchFnA).toHaveBeenCalledTimes(1); + expect(fetchFnB).toHaveBeenCalledTimes(1); + expect(resultA).toBe('a'); + expect(resultB).toBe('b'); + }); +}); +``` + +- [ ] **Step 2: テスト失敗を確認** + +Run: `pnpm test:unit -- src/lib/clients/cache.test.ts` +Expected: `stampede prevention` の 4テストが FAIL(最初のテストは `fetchFn` が2回呼ばれるため) + +- [ ] **Step 3: in-flight Map を追加して getOrFetch を修正** + +`src/lib/clients/cache.ts` を修正: + +クラスのプロパティに追加: + +```typescript +private inflight = new Map>(); +``` + +`getOrFetch` メソッドを差し替え: + +```typescript +async getOrFetch(key: string, fetchFn: () => Promise): Promise { + const cached = this.get(key); + + if (cached !== undefined) { + return cached; + } + + const pending = this.inflight.get(key); + + if (pending) { + return pending; + } + + const promise = fetchFn().then( + (result) => { + this.set(key, result); + this.inflight.delete(key); + return result; + }, + (error) => { + this.inflight.delete(key); + throw error; + }, + ); + this.inflight.set(key, promise); + + return promise; +} +``` + +`dispose` メソッドに `this.inflight.clear();` を追加: + +```typescript +dispose(): void { + clearInterval(this.cleanupInterval); + this.cache.clear(); + this.inflight.clear(); +} +``` + +- [ ] **Step 4: テストパスを確認** + +Run: `pnpm test:unit -- src/lib/clients/cache.test.ts` +Expected: 全テスト PASS + +- [ ] **Step 5: 全体テスト・型チェック** + +Run: `pnpm test:unit && pnpm check` +Expected: エラー 0件 + +--- + +### Task 2: Vote 書き込み時の `invalidateVoteCaches()` 追加 + +**Files:** + +- Modify: `src/features/votes/services/vote_grade.ts:1,72` — import 追加 + 呼び出し追加 +- Modify: `src/features/votes/services/vote_grade.test.ts` — invalidation テスト追加 + +**Interfaces:** + +- Consumes: `invalidateVoteCaches()` from `$features/votes/server/cache` +- Produces: `upsertVoteGradeTables()` が DB 書き込み成功後にキャッシュを無効化するようになる + +- [ ] **Step 1: 失敗するテストを書く** + +`src/features/votes/services/vote_grade.test.ts` に mock と テストを追加。 + +ファイル冒頭の `vi.mock('$lib/server/database', ...)` の後に追加: + +```typescript +vi.mock('$features/votes/server/cache', () => ({ + invalidateVoteCaches: vi.fn(), +})); + +import { invalidateVoteCaches } from '$features/votes/server/cache'; +``` + +`beforeEach` 内に追加: + +```typescript +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(invalidateVoteCaches).mockClear(); +}); +``` + +`describe('upsertVoteGradeTables')` 内に以下のテストを追加: + +```typescript +test('invalidates vote caches after successful write', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]); + tx.task.findUnique.mockResolvedValue({ grade: TaskGrade.Q3 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(invalidateVoteCaches).toHaveBeenCalledTimes(1); +}); + +test('does not invalidate vote caches when grade is unchanged (early return)', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(invalidateVoteCaches).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: テスト失敗を確認** + +Run: `pnpm test:unit -- src/features/votes/services/vote_grade.test.ts` +Expected: `invalidates vote caches after successful write` が FAIL(`invalidateVoteCaches` が呼ばれていない) + +- [ ] **Step 3: `upsertVoteGradeTables` に invalidation を追加** + +`src/features/votes/services/vote_grade.ts` を修正: + +import に追加: + +```typescript +import { invalidateVoteCaches } from '$features/votes/server/cache'; +``` + +`upsertVoteGradeTables` の `return { success: true };` の直前に追加: + +```typescript + }); + + invalidateVoteCaches(); + + return { success: true }; +``` + +注意: `invalidateVoteCaches()` は `$transaction` の **外側**、成功後に呼ぶ。トランザクション内で冪等リターン(`existing?.grade === grade`)した場合は `$transaction` のコールバックが早期リターンするだけで、外側の `invalidateVoteCaches()` は呼ばれてしまう。これを防ぐため、トランザクションの戻り値で判別する: + +```typescript +export async function upsertVoteGradeTables( + userId: string, + taskId: string, + grade: TaskGrade, +): Promise<{ success: true }> { + const isUpdated = await prisma.$transaction(async (tx) => { + const existing = await tx.voteGrade.findUnique({ + where: { userId_taskId: { userId, taskId } }, + }); + + if (existing?.grade === grade) { + return false; + } + + if (existing) { + await decrementOldGradeCounter(tx, taskId, existing.grade); + } + await upsertVoteRecord(tx, userId, taskId, grade); + await incrementNewGradeCounter(tx, taskId, grade); + + const latestCounters = await tx.votedGradeCounter.findMany({ + where: { taskId }, + orderBy: { grade: 'asc' }, + }); + + const total = latestCounters.reduce((sum, counter) => sum + counter.count, 0); + const taskRecord = await tx.task.findUnique({ + where: { task_id: taskId }, + select: { grade: true }, + }); + const minVotes = + taskRecord?.grade === TaskGrade.PENDING + ? MIN_VOTES_FOR_PROVISIONAL_GRADE + : MIN_VOTES_FOR_STATISTICS; + if (total < minVotes) { + return true; + } + + await updateVoteStatistics(tx, taskId, latestCounters, minVotes); + + return true; + }); + + if (isUpdated) { + invalidateVoteCaches(); + } + + return { success: true }; +} +``` + +- [ ] **Step 4: テストパスを確認** + +Run: `pnpm test:unit -- src/features/votes/services/vote_grade.test.ts` +Expected: 全テスト PASS + +- [ ] **Step 5: 全体テスト** + +Run: `pnpm test:unit` +Expected: 全テスト PASS + +--- + +### Task 3: `placementCache` の maxSize 引き上げ + +**Files:** + +- Modify: `src/features/workbooks/server/cache.ts:9` — maxSize パラメータ追加 +- Modify: `src/features/workbooks/server/cache.test.ts` — maxSize 超過のテスト追加(オプション) + +**Interfaces:** + +- Consumes: `Cache` コンストラクタの第2引数 `maxSize` +- Produces: 変更なし(挙動の正常化のみ) + +- [ ] **Step 1: 失敗するテスト(省略可)** + +maxSize 超過は `Cache` 本体でテスト済み。ここでの変更は設定値の修正のみなので、テスト追加は不要。 + +- [ ] **Step 2: `placementCache` の maxSize を 100 に変更** + +`src/features/workbooks/server/cache.ts` の9行目を修正: + +```typescript +// Before: +const placementCache = new Cache(HOUR_MS); + +// After: +const placementCache = new Cache(HOUR_MS, 100); +``` + +根拠: TaskGrade(19) × 2(published/unpublished) + SolutionCategory(15) × 2 = 68 キー。100 は十分な余裕を持つ。 + +- [ ] **Step 3: テスト確認** + +Run: `pnpm test:unit -- src/features/workbooks/server/cache.test.ts` +Expected: 全テスト PASS + +--- + +### Task 4: `getOrFetch` の型安全性改善 — `T extends {}` 制約 + +**Files:** + +- Modify: `src/lib/clients/cache.ts:8` — `Cache` → `Cache` +- Modify: `src/lib/clients/cache.test.ts:157` — `Cache` → `Cache>` に変更 + +**Interfaces:** + +- Consumes: なし +- Produces: `Cache` — `T = undefined` と `T = null` が型レベルで禁止される。既存の本番呼び出し元(`Cache`, `Cache>`, `Cache` 等)は全て `{}` を満たすため影響なし + +**設計判断:** + +- `return cached as T` の型アサーションは使わない。キャストは型チェッカーを黙らせるだけで `Cache` のような誤用を防げない +- `T extends {}` は `null` と `undefined` の両方を禁止する。本番コードで `Cache` や `Cache` のインスタンスはゼロなので問題なし +- テスト側の `Cache` は `unknown extends {}` が false のためコンパイルエラーになる → テストの意図(各種値型をキャッシュできること)に合った具体型 `Cache>` に変更する + +- [ ] **Step 1: `Cache` に型制約を追加** + +`src/lib/clients/cache.ts` のクラス宣言を修正: + +```typescript +// Before: +export class Cache { + +// After: +export class Cache { +``` + +- [ ] **Step 2: テストの `Cache` を修正** + +`src/lib/clients/cache.test.ts` の `edge cases` → `expects to handle different value types` を修正: + +```typescript +// Before: +const cache = new Cache(); + +// After: +const cache = new Cache>(); +``` + +テスト内の `cache.set('null', null)` 行を削除し、`expect(cache.get('null')).toBe(null)` も削除。`null` は `{}` を満たさないため `Cache>` に `null` を `set` できない。テストの目的は「各種値型をキャッシュできること」であり、`null` は `getOrFetch` の sentinel(`undefined`)と違って実用上キャッシュする必要がない。 + +修正後のテスト: + +```typescript +test('expects to handle different value types', () => { + const cache = new Cache>(); + + cache.set('string', { value: 'test' }); + cache.set('number', { value: 123 }); + cache.set('boolean', { value: true }); + cache.set('object', { a: 1, b: 2 }); + cache.set('array', { items: [1, 2, 3] }); + + expect(cache.get('string')).toEqual({ value: 'test' }); + expect(cache.get('number')).toEqual({ value: 123 }); + expect(cache.get('boolean')).toEqual({ value: true }); + expect(cache.get('object')).toEqual({ a: 1, b: 2 }); + expect(cache.get('array')).toEqual({ items: [1, 2, 3] }); +}); +``` + +- [ ] **Step 3: 型チェック・テスト確認** + +Run: `pnpm check && pnpm test:unit -- src/lib/clients/cache.test.ts` +Expected: エラー 0件、全テスト PASS + +--- + +### Task 5: `ContestTaskCache` のリファクタリング — `Cache.getOrFetch` への委譲 + +**Files:** + +- Modify: `src/lib/clients/cache_strategy.ts:37-60` — `getCachedOrFetch` を `Cache.getOrFetch` に委譲 +- Modify: `src/lib/clients/aizu_online_judge/clients.test.ts` — 既存テストがパスすることを確認(変更なし) + +**Interfaces:** + +- Consumes: `Cache.getOrFetch(key, fetchFn)` (Task 1 で改善済み) +- Produces: `ContestTaskCache.getCachedOrFetchContests` / `getCachedOrFetchTasks` のシグネチャは変更なし。内部の `getCachedOrFetch` が `Cache.getOrFetch` に委譲するようになる + +- [ ] **Step 1: 既存テストがパスすることを確認** + +Run: `pnpm test:unit -- src/lib/clients/aizu_online_judge/clients.test.ts` +Expected: 全テスト PASS(リファクタリング前のベースライン) + +- [ ] **Step 2: `getCachedOrFetch` を `Cache.getOrFetch` に委譲** + +`src/lib/clients/cache_strategy.ts` の `getCachedOrFetch` メソッドを修正: + +```typescript +async getCachedOrFetch( + key: string, + fetchFunction: () => Promise, + cache: Cache, +): Promise { + return cache.getOrFetch(key, fetchFunction); +} +``` + +これにより: + +- `console.log` のキャッシュヒット/ミスログが除去される(`Cache.getOrFetch` はログを出さない) +- エラー時の `[] as unknown as T` バグが修正される(`Cache.getOrFetch` はエラーを伝播する) + +- [ ] **Step 3: リファクタリング後にテストがパスすることを確認** + +Run: `pnpm test:unit -- src/lib/clients/aizu_online_judge/clients.test.ts` +Expected: 全テスト PASS + +- [ ] **Step 4: 全体テスト・型チェック** + +Run: `pnpm test:unit && pnpm check` +Expected: エラー 0件 + +--- + +## 最終確認 + +- [ ] `pnpm test:unit` — 全テストパス +- [ ] `pnpm check` — 型エラー 0件 +- [ ] `pnpm format` — フォーマット適用 +- [ ] `pnpm lint` — lint エラー 0件 diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/review.md b/docs/dev-notes/2026-06-17/phase4-server-cache/review.md new file mode 100644 index 000000000..baa728324 --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/review.md @@ -0,0 +1,138 @@ +# Phase 4 Server Cache: Review + Simplify 結果 + +staging vs #3706 ブランチの差分に対するレビュー結果。 + +## 概要 + +`Cache.getOrFetch()` メソッドを追加し、tasks / votes / workbooks の3ドメインでサーバーサイドキャッシュを導入。DB書き込み時の明示的キャッシュ無効化も実装。構造は `.claude/rules/server-cache.md` のパターンに忠実。 + +## テスト・型チェック結果 + +- `pnpm test:unit`: 全パス(既存の flaky テスト `test_helpers.test.ts` のパフォーマンステスト1件のみ失敗 — 本変更と無関係) +- `pnpm check`: エラー 0件 + +## Critical(要対応) + +### 1. キャッシュスタンピード + +- **箇所**: `src/lib/clients/cache.ts:129-140` +- **内容**: `getOrFetch` で同一キーへの並行リクエスト時、全てが `fetchFn` を呼ぶ。in-flight の Promise を共有する仕組みがない +- **修正案**: in-flight の `Promise` を `Map>` で保持し、同一キーの並行呼び出しで共有する + +```typescript +// "inflight" is a custom Map — not a JS/TS built-in. +// Stores pending Promises so concurrent callers share one fetchFn() instead of firing N duplicates. +private inflight = new Map>(); + +async getOrFetch(key: string, fetchFn: () => Promise): Promise { + const cached = this.get(key); + + if (cached !== undefined) {return cached}; + + if (this.inflight.has(key)) {return this.inflight.get(key)!}; + + const promise = fetchFn().then( + (result) => { this.set(key, result); this.inflight.delete(key); return result; }, + (err) => { this.inflight.delete(key); throw err; }, + ); + this.inflight.set(key, promise); + return promise; +} +``` + +### 2. Vote キャッシュの無効化漏れ + +- **箇所**: `src/features/votes/services/vote_grade.ts` — `upsertVoteGradeTables()` +- **内容**: DB に書き込むが `invalidateVoteCaches()` を呼んでいない。最大10分間古いデータを返す +- **修正案**: `upsertVoteGradeTables()` の DB 書き込み成功後に `invalidateVoteCaches()` を呼ぶ + +## Medium(検討推奨) + +### 3. `getOrFetch` が `undefined` をキャッシュできない + +- **箇所**: `src/lib/clients/cache.ts:132` +- **内容**: `if (cached !== undefined)` のため `T = undefined` の場合に毎回 refetch。現在の呼び出し元では問題ないが、型レベルで `T` が無制約なので潜在的バグ +- **修正案**: `Cache` と制約するか、`return cached as T` で型アサーション +- **補足**: `{}` は TS で `null` / `undefined` 以外の全型を受け入れる型。`T extends {}` とすると `T = undefined` が禁止され、`!== undefined` ナローイング後の `T & {}` が `T` に安全に代入できるようになる([TS Handbook: Generic Constraints](https://www.typescriptlang.org/docs/handbook/2/generics.html)) + +### 4. `ContestTaskCache.getCachedOrFetch()` との重複 + +- **箇所**: `src/lib/clients/cache_strategy.ts:37-60` +- **内容**: 既存の `getCachedOrFetch()` と新しい `Cache.getOrFetch()` が同等のロジック。さらに既存側はエラーを `[] as unknown as T` で握りつぶすバグ持ち +- **修正案**: `ContestTaskCache.getCachedOrFetch()` を `Cache.getOrFetch()` に委譲するようリファクタリング + +### 5. `getAllTasksWithVoteInfo()` がキャッシュをバイパス + +- **箇所**: `src/features/votes/services/vote_statistics.ts:40-44` +- **内容**: 直接 `prisma.task.findMany()` と `prisma.votedGradeStatistics.findMany()` を呼ぶ。admin ページの鮮度要件なら意図的だが、明示的な判断が必要 +- **備考**: `/tasks/grade` は admin ページのため鮮度優先でキャッシュ不要 +- **対応方針**: 別PRで対応。変更は小規模(4-5ファイル、既存パターンの機械的適用) + - `votes/server/cache.ts` — `Cache` 追加 + `invalidate`/`dispose` 拡張 + - `votes/services/vote_statistics.ts` — `getAllTasksWithVoteInfo()` を `getCached*` でラップ + - `votes/server/cache.test.ts` — 新キャッシュのテスト追加 + - `votes/services/vote_statistics.test.ts` — mock 更新 + - `src/lib/services/tasks.ts` — `updateTask()` に `invalidateVoteCaches()` 追加(grade 変更時の整合性) +- **参照**: `docs/dev-notes/2026-06-13/sveltekit-caching/plan.md`「votes 一覧」 + +### 6. `placementCache` の maxSize 不足 + +- **箇所**: `src/features/workbooks/server/cache.ts:9` +- **内容**: TaskGrade(~18) × 2(published/unpublished) + SolutionCategory(~15) × 2 = ~66 キーで、デフォルト maxSize=50 を超える可能性 +- **修正案**: `new Cache(HOUR_MS, 100)` のように maxSize を引き上げ + +## Low(許容範囲) + +### 7. 3ドメインのキャッシュモジュールが同一パターン + +- 各20-40行で小さく、共通ファクトリへの抽出は過剰抽象。現状で可 + +### 8. `invalidateWorkbookCaches()` が `clear()` で全消し + +- 動的複合キー(`CURRICULUM:Q7:false` 等)のため `delete(key)` で個別削除が不可能。正当な判断 + +### 9. 5つの `setInterval` タイマー + +- 軽量で低頻度(1時間 or 10分間隔)。テスト時は `dispose*Caches()` + `afterAll` で適切にクリーンアップ済み。実害なし + +### 10. キャッシュキーが文字列リテラル + +- 各 Cache インスタンスがスコープ限定されており、衝突リスクなし + +## `cache.test.ts` 型エラー調査 + +### 現象 + +- `pnpm check`(svelte-check)ではエラー 0件 +- `tsc --noEmit` や `vitest --typecheck` で発現する可能性あり + +### 最有力候補: `T & {}` 代入不可 + +- **箇所**: `src/lib/clients/cache.ts:133` — `return cached;` +- **原因**: TypeScript 5.4+ で `T | undefined` を `!== undefined` で絞ると `T & {}` になるが、無制約な `T` に対して `T & {}` は `T` に代入可能と証明できない + +``` +Type 'T & {}' is not assignable to type 'T'. + 'T & {}' is assignable to the constraint of type 'T', but 'T' could be instantiated + with a different subtype of constraint '{}'. +``` + +### 修正案 + +```typescript +// Option A: T を制約する +export class Cache { ... } + +// Option B: 型アサーション +if (cached !== undefined) { + return cached as T; +} +``` + +## 優先順位まとめ + +1. **キャッシュスタンピード対策** — in-flight Promise の共有(Critical #1) +2. **Vote 書き込み時の `invalidateVoteCaches()` 追加** — データ整合性(Critical #2) +3. **`placementCache` の maxSize 引き上げ** — 簡単な修正(Medium #6) +4. **`getOrFetch` の型安全性改善** — `T extends {}` 制約(Medium #3) +5. **`ContestTaskCache` のリファクタリング** — 重複排除 + バグ修正(Medium #4) +6. **`getAllTasksWithVoteInfo()` のキャッシュ方針決定** — 設計判断(Medium #5) diff --git a/src/features/votes/services/vote_grade.test.ts b/src/features/votes/services/vote_grade.test.ts index f792fb8a0..397c965c3 100644 --- a/src/features/votes/services/vote_grade.test.ts +++ b/src/features/votes/services/vote_grade.test.ts @@ -4,6 +4,10 @@ import { TaskGrade } from '@prisma/client'; import { getVoteGrade, upsertVoteGradeTables } from './vote_grade'; +vi.mock('$features/votes/server/cache', () => ({ + invalidateVoteCaches: vi.fn(), +})); + vi.mock('$lib/server/database', () => ({ default: { voteGrade: { @@ -26,6 +30,7 @@ vi.mock('$lib/server/database', () => ({ })); import prisma from '$lib/server/database'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; beforeEach(() => { vi.clearAllMocks(); @@ -220,4 +225,26 @@ describe('upsertVoteGradeTables', () => { }), ); }); + + test('invalidates vote caches after successful write', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]); + tx.task.findUnique.mockResolvedValue({ grade: TaskGrade.Q3 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(invalidateVoteCaches).toHaveBeenCalledTimes(1); + }); + + test('does not invalidate vote caches when grade is unchanged (early return)', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(invalidateVoteCaches).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/votes/services/vote_grade.ts b/src/features/votes/services/vote_grade.ts index aac78a565..dedea47e9 100644 --- a/src/features/votes/services/vote_grade.ts +++ b/src/features/votes/services/vote_grade.ts @@ -7,6 +7,7 @@ import { MIN_VOTES_FOR_STATISTICS, MIN_VOTES_FOR_PROVISIONAL_GRADE, } from '$features/votes/constants/statistics'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; export async function getVoteGrade(userId: string, taskId: string): Promise { const voteRecord = await prisma.voteGrade.findUnique({ @@ -32,14 +33,13 @@ export async function upsertVoteGradeTables( taskId: string, grade: TaskGrade, ): Promise<{ success: true }> { - await prisma.$transaction(async (tx) => { + const isUpdated = await prisma.$transaction(async (tx) => { const existing = await tx.voteGrade.findUnique({ where: { userId_taskId: { userId, taskId } }, }); - // 冪等性: 既に同じグレードなら何もしない if (existing?.grade === grade) { - return; + return false; } if (existing) { @@ -63,11 +63,18 @@ export async function upsertVoteGradeTables( ? MIN_VOTES_FOR_PROVISIONAL_GRADE : MIN_VOTES_FOR_STATISTICS; if (total < minVotes) { - return; + return true; } await updateVoteStatistics(tx, taskId, latestCounters, minVotes); + + return true; }); + + if (isUpdated) { + invalidateVoteCaches(); + } + return { success: true }; } diff --git a/src/features/workbooks/server/cache.ts b/src/features/workbooks/server/cache.ts index ed639d182..d9c6fea03 100644 --- a/src/features/workbooks/server/cache.ts +++ b/src/features/workbooks/server/cache.ts @@ -6,7 +6,7 @@ import type { PlacementQuery } from '$features/workbooks/types/workbook_placemen const HOUR_MS = 60 * 60 * 1000; const BY_USER_KEY = 'workbooks_by_user'; -const placementCache = new Cache(HOUR_MS); +const placementCache = new Cache(HOUR_MS, 100); const byUserCache = new Cache(HOUR_MS); function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { diff --git a/src/lib/clients/cache.test.ts b/src/lib/clients/cache.test.ts index afd3dff8f..cd0334c81 100644 --- a/src/lib/clients/cache.test.ts +++ b/src/lib/clients/cache.test.ts @@ -154,21 +154,17 @@ describe('Cache', () => { describe('edge cases', () => { test('expects to handle different value types', () => { - const cache = new Cache(); + const cache = new Cache(); - // Save various types of values. cache.set('string', 'test'); cache.set('number', 123); cache.set('boolean', true); - cache.set('null', null); cache.set('object', { a: 1, b: 2 }); cache.set('array', [1, 2, 3]); - // Validate the values are stored correctly. expect(cache.get('string')).toBe('test'); expect(cache.get('number')).toBe(123); - expect(cache.get('boolean')).toBeTruthy(); - expect(cache.get('null')).toBe(null); + expect(cache.get('boolean')).toBe(true); expect(cache.get('object')).toEqual({ a: 1, b: 2 }); expect(cache.get('array')).toEqual([1, 2, 3]); }); @@ -413,5 +409,72 @@ describe('Cache', () => { expect(fetchFn).toHaveBeenCalledTimes(2); }); }); + + describe('stampede prevention', () => { + test('shares a single fetchFn call across concurrent requests for the same key', async () => { + const cache = new Cache(); + let resolvePromise: (value: string) => void; + const fetchFn = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const promise1 = cache.getOrFetch('key', fetchFn); + const promise2 = cache.getOrFetch('key', fetchFn); + + resolvePromise!('shared'); + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result1).toBe('shared'); + expect(result2).toBe('shared'); + }); + + test('cleans up inflight entry after fetchFn resolves', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockResolvedValue('done'); + await cache.getOrFetch('key', fetchFn); + + const fetchFn2 = vi.fn().mockResolvedValue('done2'); + vi.advanceTimersByTime(cache['timeToLive'] + 1); + const result = await cache.getOrFetch('key', fetchFn2); + + expect(fetchFn2).toHaveBeenCalledTimes(1); + expect(result).toBe('done2'); + }); + + test('cleans up inflight entry and propagates error to all waiters on fetchFn failure', async () => { + const cache = new Cache(); + const fetchFn = vi.fn().mockRejectedValue(new Error('boom')); + + const results = await Promise.allSettled([ + cache.getOrFetch('key', fetchFn), + cache.getOrFetch('key', fetchFn), + ]); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(results[0]).toEqual(expect.objectContaining({ status: 'rejected' })); + expect(results[1]).toEqual(expect.objectContaining({ status: 'rejected' })); + expect(cache.size).toBe(0); + }); + + test('does not share inflight between different keys', async () => { + const cache = new Cache(); + const fetchFnA = vi.fn().mockResolvedValue('a'); + const fetchFnB = vi.fn().mockResolvedValue('b'); + + const [resultA, resultB] = await Promise.all([ + cache.getOrFetch('keyA', fetchFnA), + cache.getOrFetch('keyB', fetchFnB), + ]); + + expect(fetchFnA).toHaveBeenCalledTimes(1); + expect(fetchFnB).toHaveBeenCalledTimes(1); + expect(resultA).toBe('a'); + expect(resultB).toBe('b'); + }); + }); }); }); diff --git a/src/lib/clients/cache.ts b/src/lib/clients/cache.ts index d8f6f30e5..af68eea1d 100644 --- a/src/lib/clients/cache.ts +++ b/src/lib/clients/cache.ts @@ -5,8 +5,9 @@ * * @template T - The type of data to be stored in the cache. */ -export class Cache { +export class Cache { private cache: Map> = new Map(); + private inflight = new Map>(); private cleanupInterval: NodeJS.Timeout; /** @@ -133,10 +134,27 @@ export class Cache { return cached; } - const result = await fetchFn(); - this.set(key, result); + const pending = this.inflight.get(key); - return result; + if (pending) { + return pending; + } + + const promise = fetchFn().then( + (result) => { + this.set(key, result); + this.inflight.delete(key); + return result; + }, + (error) => { + this.inflight.delete(key); + throw error; + }, + ); + + this.inflight.set(key, promise); + + return promise; } /** @@ -148,6 +166,7 @@ export class Cache { dispose(): void { clearInterval(this.cleanupInterval); this.cache.clear(); + this.inflight.clear(); } /** diff --git a/src/lib/clients/cache_strategy.ts b/src/lib/clients/cache_strategy.ts index 71fef98b5..5add93b35 100644 --- a/src/lib/clients/cache_strategy.ts +++ b/src/lib/clients/cache_strategy.ts @@ -34,29 +34,12 @@ export class ContestTaskCache { * contestCache * ); */ - async getCachedOrFetch( + async getCachedOrFetch( key: string, fetchFunction: () => Promise, cache: Cache, ): Promise { - const cachedData = cache.get(key); - - if (cachedData) { - console.log(`Using cached data for ${key}`); - return cachedData; - } - - console.log(`Cache miss for ${key}, fetching...`); - - try { - const contestTasks = await fetchFunction(); - cache.set(key, contestTasks); - - return contestTasks; - } catch (error) { - console.error(`Failed to fetch contests and/or tasks for ${key}:`, error); - return [] as unknown as T; - } + return cache.getOrFetch(key, fetchFunction); } /** From 68c31ad4d7362322cd6bf9c597212f2c3d20a954 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 20 Jun 2026 23:43:44 +0000 Subject: [PATCH 3/7] docs(rules): add cache type constraint, stampede prevention, invalidation audit, and test patterns Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/server-cache.md | 14 ++++++++++++++ .claude/rules/testing.md | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.claude/rules/server-cache.md b/.claude/rules/server-cache.md index 07a31498f..f52599379 100644 --- a/.claude/rules/server-cache.md +++ b/.claude/rules/server-cache.md @@ -58,6 +58,20 @@ Core cache behavior (hit, miss, TTL, error propagation) is tested on `Cache.g Do not duplicate TTL or error propagation tests in domain cache test files. +Always call `afterAll(() => dispose*Caches())` to prevent timer leaks. Isolate tests with `beforeEach(() => invalidate*Caches())`. + +## Type Constraint + +`Cache` — never use `null` or `undefined` as `T`. The cache uses `=== undefined` to detect misses; storing `undefined` would silently bypass the cache on every call. + +## Stampede Prevention + +`Cache.getOrFetch()` shares a single in-flight `Promise` across concurrent callers for the same key. Do not implement manual dedup on top of it. + +## Invalidation Audit + +When adding a new DB write function, audit every write path in the same domain for missing `invalidate*Caches()` calls. Initial implementation of `upsertVoteGradeTables()` missed this and served stale data for up to 10 minutes. + ## Service Layer Integration Services call `getCached*()` with a `fetchFn` that performs the DB query. The service does not import `Cache` directly. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 044a12378..e479aede6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -78,6 +78,24 @@ const mockFindUnique = (data) => db.task.findUnique.mockResolvedValue(data); const mockFindMany = (data) => db.task.findMany.mockResolvedValue(data); ``` +### Cache Module Tests + +Prevent timer leaks and test isolation: + +```typescript +afterAll(() => dispose*Caches()); +beforeEach(() => invalidate*Caches()); +``` + +Mock cache modules in service tests so caching is bypassed: + +```typescript +vi.mock('$lib/server/tasks/cache', () => ({ + getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), + invalidateTaskCaches: vi.fn(), +})); +``` + ### HTTP Mocking (Nock) Extract setup into helpers, declare once at describe scope: From 798073a1e90e6b927e395774655fa80fdde708a8 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 20 Jun 2026 23:44:03 +0000 Subject: [PATCH 4/7] docs(rules): fix cache test pattern example to use concrete function name placeholders Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index e479aede6..75577c16d 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -83,8 +83,8 @@ const mockFindMany = (data) => db.task.findMany.mockResolvedValue(data); Prevent timer leaks and test isolation: ```typescript -afterAll(() => dispose*Caches()); -beforeEach(() => invalidate*Caches()); +afterAll(() => disposeDomainCaches()); +beforeEach(() => invalidateDomainCaches()); ``` Mock cache modules in service tests so caching is bypassed: From ae202d3e1b253f0c6f8e4bf95f71d76ee8c07a38 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 00:09:16 +0000 Subject: [PATCH 5/7] refactor(cache): simplify workbook cache interface, remove what-comments, eliminate dead wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move buildPlacementKey to workbooks utils (cache layer stays storage-only) - getCachedWorkbooksByPlacement now accepts string key instead of PlacementQuery - Remove getCachedOrFetch middle wrapper in ContestTaskCache (delegates directly to Cache.getOrFetch) - Export DEFAULT_CACHE_TTL to eliminate HOUR_MS duplication across domain cache modules - Drop what-only TSDoc from Cache (keep only non-obvious constraint on set()) - byUserCache.clear() → delete(BY_USER_KEY) for single-key intent clarity - Remove stray console.log from createWorkBook Co-Authored-By: Claude Sonnet 4.6 --- .../phase4-server-cache/plan-3rd.md | 63 +++++++++++ src/features/workbooks/server/cache.test.ts | 33 ++---- src/features/workbooks/server/cache.ts | 23 +--- .../workbooks/services/workbooks.test.ts | 6 +- src/features/workbooks/services/workbooks.ts | 6 +- .../workbooks/utils/workbooks.test.ts | 31 ++++++ src/features/workbooks/utils/workbooks.ts | 10 ++ src/lib/clients/cache.ts | 104 +----------------- src/lib/clients/cache_strategy.ts | 43 +------- src/lib/server/tasks/cache.ts | 9 +- 10 files changed, 131 insertions(+), 197 deletions(-) create mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md new file mode 100644 index 000000000..c7272ea4d --- /dev/null +++ b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md @@ -0,0 +1,63 @@ +# Phase 4 Server Cache: Review + Simplify 対応計画(3rd) + +**Goal:** `staging` vs `#3706` の review / simplify 指摘事項を修正する + +--- + +## 優先度 Medium + +### 1. `cache_strategy.ts` の `getCachedOrFetch` 不要ラッパーを削除 + +- **箇所**: `src/lib/clients/cache_strategy.ts:37-43` +- **内容**: `getCachedOrFetch()` が `cache.getOrFetch(key, fetchFunction)` への単純委譲のみ。`getCachedOrFetchContests()` / `getCachedOrFetchTasks()` が直接 `this.contestCache.getOrFetch()` / `this.taskCache.getOrFetch()` を呼べば足りる +- **修正**: `getCachedOrFetch` を削除し、呼び出し元を直接 `getOrFetch()` に向ける + +--- + +## 優先度 Low + +### 2. `HOUR_MS` 定数の重複を解消 + +- **箇所**: `src/lib/server/tasks/cache.ts:6`, `src/features/workbooks/server/cache.ts:6` +- **内容**: `const HOUR_MS = 60 * 60 * 1000` が2ファイルに独立定義。`cache.ts` の `DEFAULT_CACHE_TTL` が未 export +- **修正案A**: `src/lib/clients/cache.ts` の `DEFAULT_CACHE_TTL` を export し、両ファイルで import +- **修正案B**: `src/lib/constants/` に `CACHE_TTL` 定数ファイルを新設 +- **推奨**: 案A(既存定数を活用、ファイル追加なし) + +### 3. `buildPlacementKey` をキャッシュ層から分離 + +- **箇所**: `src/features/workbooks/server/cache.ts:3,12-18` +- **内容**: `buildPlacementKey()` が `WorkBookType` のドメイン判定ロジックを持つ。キャッシュ層はストレージのみを担うべきで、ドメイン型の分岐知識はサービス層の責務 +- **修正**: `buildPlacementKey()` を `src/features/workbooks/utils/` か `workbooks.ts` に移動し、`getCachedWorkbooksByPlacement()` はキー文字列を引数で受け取るシグネチャに変更 + +--- + +## 優先度 Nit + +### 4. `byUserCache.clear()` → `delete(BY_USER_KEY)` + +- **箇所**: `src/features/workbooks/server/cache.ts:36` +- **内容**: 単一キーのみなのに `clear()` を使用。`delete(BY_USER_KEY)` の方が意図が明確 +- **修正**: `byUserCache.clear()` → `byUserCache.delete(BY_USER_KEY)` + +### 5. what コメント削除 + +- **箇所**: `src/lib/clients/cache.ts:1-67`(TSDoc 各所) +- **内容**: `size` / `health` / `set` などのコードを言い換えただけのコメント。AGENTS.md の「why のみコメント」規約に違反 +- **修正**: メソッド名から自明な TSDoc を削除。非自明な制約(例: `set()` の key 長制限の `@throws`)は残す + +### 6. 不要な `console.log` 削除 + +- **箇所**: `src/features/workbooks/services/workbooks.ts:282` +- **内容**: `createWorkBook` 成功時のログ。`updateWorkBook` には同等ログなく一貫性がない +- **修正**: 削除 + +--- + +## 対応不要(確認済み) + +- **invalidate タイミング**: 全ドメインで DB 書き込み成功後のみ(`finally` 内ではない) +- **キャッシュキー衝突**: `buildPlacementKey` のプレフィックス区別、インスタンス分離とも問題なし +- **メモリリーク**: `inflight` Map はエラー時も必ず `delete`、`maxSize` による evictioneviction あり +- **並列化**: `getMergedTasksMap` では `Promise.all` で適切に並列化済み +- **ドメインモジュールの同一パターン**: `server-cache.md` ルールへの準拠であり抽象化不要 diff --git a/src/features/workbooks/server/cache.test.ts b/src/features/workbooks/server/cache.test.ts index 66edfbdca..ed58b8fe2 100644 --- a/src/features/workbooks/server/cache.test.ts +++ b/src/features/workbooks/server/cache.test.ts @@ -1,9 +1,5 @@ import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; -import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; -import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; -import { WorkBookType } from '$features/workbooks/types/workbook'; - import { getCachedWorkbooksByPlacement, getCachedWorkbooksByUser, @@ -11,10 +7,6 @@ import { disposeWorkbookCaches, } from './cache'; -const solutionQuery: PlacementQuery = { - workBookType: WorkBookType.SOLUTION, - solutionCategory: SolutionCategory.SEARCH_SIMULATION, -}; const mockFetchFn = () => vi.fn().mockResolvedValue([]); afterAll(() => disposeWorkbookCaches()); @@ -25,26 +17,15 @@ describe('getCachedWorkbooksByPlacement', () => { test('returns cached value on subsequent calls', async () => { const fetchFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); + await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn); + await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn); expect(fetchFn).toHaveBeenCalledTimes(1); }); - test('misses cache when solutionCategory differs', async () => { - const fetchFn = mockFetchFn(); - const otherQuery: PlacementQuery = { - ...solutionQuery, - solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING, - }; - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(otherQuery, false, fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); - - test('misses cache when includeUnpublished differs', async () => { + test('misses cache when key differs', async () => { const fetchFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(solutionQuery, true, fetchFn); + await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn); + await getCachedWorkbooksByPlacement('SOLUTION:DYNAMIC_PROGRAMMING:false', fetchFn); expect(fetchFn).toHaveBeenCalledTimes(2); }); }); @@ -68,10 +49,10 @@ describe('invalidateWorkbookCaches', () => { test('clears both placement and user caches', async () => { const placementFn = mockFetchFn(); const userFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', placementFn); await getCachedWorkbooksByUser(userFn); invalidateWorkbookCaches(); - await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); + await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', placementFn); await getCachedWorkbooksByUser(userFn); expect(placementFn).toHaveBeenCalledTimes(2); expect(userFn).toHaveBeenCalledTimes(2); diff --git a/src/features/workbooks/server/cache.ts b/src/features/workbooks/server/cache.ts index d9c6fea03..50bf5d546 100644 --- a/src/features/workbooks/server/cache.ts +++ b/src/features/workbooks/server/cache.ts @@ -1,28 +1,15 @@ -import { Cache } from '$lib/clients/cache'; +import { Cache, DEFAULT_CACHE_TTL } from '$lib/clients/cache'; import type { WorkbooksWithAuthors } from '$features/workbooks/types/workbook'; -import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; -import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; -const HOUR_MS = 60 * 60 * 1000; const BY_USER_KEY = 'workbooks_by_user'; -const placementCache = new Cache(HOUR_MS, 100); -const byUserCache = new Cache(HOUR_MS); - -function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { - if (query.workBookType === WorkBookTypeConst.CURRICULUM) { - return `CURRICULUM:${query.taskGrade}:${includeUnpublished}`; - } - - return `SOLUTION:${query.solutionCategory}:${includeUnpublished}`; -} +const placementCache = new Cache(DEFAULT_CACHE_TTL, 100); +const byUserCache = new Cache(DEFAULT_CACHE_TTL); export function getCachedWorkbooksByPlacement( - query: PlacementQuery, - includeUnpublished: boolean, + key: string, fetchFn: () => Promise, ): Promise { - const key = buildPlacementKey(query, includeUnpublished); return placementCache.getOrFetch(key, fetchFn); } @@ -34,7 +21,7 @@ export function getCachedWorkbooksByUser( export function invalidateWorkbookCaches(): void { placementCache.clear(); - byUserCache.clear(); + byUserCache.delete(BY_USER_KEY); } export function disposeWorkbookCaches(): void { diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index 66715abbe..eebdc1f41 100644 --- a/src/features/workbooks/services/workbooks.test.ts +++ b/src/features/workbooks/services/workbooks.test.ts @@ -41,11 +41,7 @@ vi.mock('$lib/services/users', () => ({ })); vi.mock('$features/workbooks/server/cache', () => ({ - getCachedWorkbooksByPlacement: ( - _query: unknown, - _includeUnpublished: unknown, - fetchFn: () => Promise, - ) => fetchFn(), + getCachedWorkbooksByPlacement: (_key: string, fetchFn: () => Promise) => fetchFn(), getCachedWorkbooksByUser: (fetchFn: () => Promise) => fetchFn(), invalidateWorkbookCaches: vi.fn(), })); diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index a9dd3a208..801f5085d 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -29,6 +29,7 @@ import { import { sanitizeUrl } from '$lib/utils/url'; import { parseWorkBookId, parseWorkBookUrlSlug } from '$features/workbooks/utils/workbook'; +import { buildPlacementKey } from '$features/workbooks/utils/workbooks'; export async function getWorkBooks(): Promise { const workbooks = await db.workBook.findMany({ @@ -78,7 +79,9 @@ export async function getWorkbooksByPlacement( query: PlacementQuery, includeUnpublished = false, ): Promise { - return getCachedWorkbooksByPlacement(query, includeUnpublished, async () => { + const cacheKey = buildPlacementKey(query, includeUnpublished); + + return getCachedWorkbooksByPlacement(cacheKey, async () => { const placementFilter = buildPlacementFilter(query); const workbooks = await db.workBook.findMany({ @@ -279,7 +282,6 @@ export async function createWorkBook(workBook: Omit): Promise { diff --git a/src/features/workbooks/utils/workbooks.test.ts b/src/features/workbooks/utils/workbooks.test.ts index c1e39a3fc..24e8fff41 100644 --- a/src/features/workbooks/utils/workbooks.test.ts +++ b/src/features/workbooks/utils/workbooks.test.ts @@ -3,8 +3,11 @@ import { describe, expect, test } from 'vitest'; import { Roles } from '$lib/types/user'; import { TaskGrade, type Task, type TaskResult } from '$lib/types/task'; import { type WorkbookList, WorkBookType } from '$features/workbooks/types/workbook'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; import { + buildPlacementKey, canViewWorkBook, getUrlSlugFrom, getWorkBooksByType, @@ -457,3 +460,31 @@ describe('Workbooks', () => { }); }); }); + +describe('buildPlacementKey', () => { + test('returns CURRICULUM key with taskGrade and includeUnpublished', () => { + const query: PlacementQuery = { + workBookType: WorkBookType.CURRICULUM, + taskGrade: TaskGrade.Q7, + }; + expect(buildPlacementKey(query, false)).toBe('CURRICULUM:Q7:false'); + expect(buildPlacementKey(query, true)).toBe('CURRICULUM:Q7:true'); + }); + + test('returns SOLUTION key with solutionCategory and includeUnpublished', () => { + const query: PlacementQuery = { + workBookType: WorkBookType.SOLUTION, + solutionCategory: SolutionCategory.SEARCH_SIMULATION, + }; + expect(buildPlacementKey(query, false)).toBe('SOLUTION:SEARCH_SIMULATION:false'); + expect(buildPlacementKey(query, true)).toBe('SOLUTION:SEARCH_SIMULATION:true'); + }); + + test('returns SOLUTION key with null solutionCategory', () => { + const query: PlacementQuery = { + workBookType: WorkBookType.SOLUTION, + solutionCategory: null, + }; + expect(buildPlacementKey(query, false)).toBe('SOLUTION:null:false'); + }); +}); diff --git a/src/features/workbooks/utils/workbooks.ts b/src/features/workbooks/utils/workbooks.ts index e8bc9ce24..a813a5e4c 100644 --- a/src/features/workbooks/utils/workbooks.ts +++ b/src/features/workbooks/utils/workbooks.ts @@ -13,10 +13,20 @@ import type { WorkBookTaskBase, WorkBookType, } from '$features/workbooks/types/workbook'; +import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; +import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; import { isAdmin, canRead } from '$lib/utils/authorship'; import { calcGradeMode } from '$lib/utils/task'; +export function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { + if (query.workBookType === WorkBookTypeConst.CURRICULUM) { + return `CURRICULUM:${query.taskGrade}:${includeUnpublished}`; + } + + return `SOLUTION:${query.solutionCategory}:${includeUnpublished}`; +} + /** Returns true when the user can view the workbook: admins always can; others only when published. */ export function canViewWorkBook(role: Roles, isPublished: boolean) { return isAdmin(role) || isPublished; diff --git a/src/lib/clients/cache.ts b/src/lib/clients/cache.ts index af68eea1d..6dfb3a880 100644 --- a/src/lib/clients/cache.ts +++ b/src/lib/clients/cache.ts @@ -1,21 +1,8 @@ -/** - * A generic cache class that stores data with a timestamp and provides methods to set, get, and delete cache entries. - * The cache automatically removes the oldest entry when the maximum cache size is reached. - * Entries are also automatically invalidated and removed if they exceed a specified time-to-live (TTL). - * - * @template T - The type of data to be stored in the cache. - */ export class Cache { private cache: Map> = new Map(); private inflight = new Map>(); private cleanupInterval: NodeJS.Timeout; - /** - * Constructs an instance of the class with the specified cache time-to-live (TTL) and maximum cache size. - * - * @param timeToLive - The time-to-live for the cache entries, in milliseconds. Defaults to `CACHE_TTL`. - * @param maxSize - The maximum number of entries the cache can hold. Defaults to `MAX_CACHE_SIZE`. - */ constructor( private readonly timeToLive: number = DEFAULT_CACHE_TTL, private readonly maxSize: number = DEFAULT_MAX_CACHE_SIZE, @@ -30,22 +17,10 @@ export class Cache { this.cleanupInterval = setInterval(() => this.cleanup(), this.timeToLive); } - /** - * Gets the size of the cache. - * - * @returns {number} The number of items in the cache. - */ get size(): number { return this.cache.size; } - /** - * Retrieves the health status of the cache. - * - * @returns An object containing the size of the cache and the timestamp of the oldest entry. - * @property {number} size - The number of entries in the cache. - * @property {number} oldestEntry - The timestamp of the oldest entry in the cache. - */ get health(): { size: number; oldestEntry: number } { if (this.cache.size === 0) { return { size: 0, oldestEntry: 0 }; @@ -57,21 +32,13 @@ export class Cache { return { size: this.cache.size, oldestEntry }; } - /** - * Sets a new entry in the cache with the specified key and data. - * If the cache size exceeds the maximum limit, the oldest entry is removed. - * - * @param key - The key associated with the data to be cached. - * @param data - The data to be cached. - * - * @throws {Error} If the key is empty, not a string, or longer than 255 characters. - */ + /** @throws {Error} If the key is empty, not a string, or longer than 255 characters. */ set(key: string, data: T): void { if (!key || typeof key !== 'string' || key.length > 255) { throw new Error('Invalid cache key'); } - // Note: Remove existing entry first to avoid counting it twice. + // Remove existing entry first to avoid counting it twice. this.cache.delete(key); if (this.cache.size >= this.maxSize) { @@ -85,12 +52,6 @@ export class Cache { this.cache.set(key, { data, timestamp: Date.now() }); } - /** - * Checks if a key exists in the cache without removing expired entries. - * - * @param key - The key to check. - * @returns True if the key exists in the cache, false otherwise. - */ has(key: string): boolean { const entry = this.cache.get(key); @@ -106,12 +67,6 @@ export class Cache { return true; } - /** - * Retrieves an entry from the cache. - * - * @param key - The key associated with the cache entry. - * @returns The cached data if it exists and is not expired, otherwise `undefined`. - */ get(key: string): T | undefined { const entry = this.cache.get(key); @@ -157,38 +112,21 @@ export class Cache { return promise; } - /** - * Disposes of resources used by the cache instance. - * - * This method clears the interval used for cleanup and clears the cache. - * It should be called when the cache instance is no longer needed to prevent memory leaks. - */ + /** Call when the cache instance is no longer needed to prevent timer leaks. */ dispose(): void { clearInterval(this.cleanupInterval); this.cache.clear(); this.inflight.clear(); } - /** - * Clears all entries from the cache. - */ clear(): void { this.cache.clear(); } - /** - * Deletes an entry from the cache. - * - * @param key - The key of the entry to delete. - */ delete(key: string): void { this.cache.delete(key); } - /** - * Removes expired entries from the cache. - * This method is called periodically by the cleanup interval. - */ private cleanup(): void { const now = Date.now(); @@ -199,11 +137,6 @@ export class Cache { } } - /** - * Finds the key of the oldest entry in the cache based on timestamp. - * - * @returns The key of the oldest entry, or undefined if the cache is empty. - */ private findOldestEntry(): string | undefined { let oldestKey: string | undefined; let oldestTime = Infinity; @@ -219,48 +152,19 @@ export class Cache { } } -/** - * Represents a cache entry with data and a timestamp. - * - * @template T - The type of the cached data. - * @property {T} data - The cached data. - * @property {number} timestamp - The timestamp when the data was cached. - */ type CacheEntry = { data: T; timestamp: number; }; -/** - * The time-to-live (TTL) for the cache, specified in milliseconds. - * This value represents 1 hour. - */ -const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds -/** - * The default maximum number of entries the cache can hold. - * This value represents 50 entries. - */ +export const DEFAULT_CACHE_TTL = 60 * 60 * 1000; const DEFAULT_MAX_CACHE_SIZE = 50; -/** - * Configuration options for caching. - * - * @property {number} [timeToLive] - The duration (in milliseconds) for which a cache entry should remain valid. - * @property {number} [maxSize] - The maximum number of entries that the cache can hold. - */ - export interface CacheConfig { timeToLive?: number; maxSize?: number; } -/** - * Configuration for the API client's caching behavior. - * - * @interface ApiClientConfig - * @property {CacheConfig} contestCache - Configuration for contest-related data caching. - * @property {CacheConfig} taskCache - Configuration for task-related data caching. - */ export interface ApiClientConfig { contestCache: CacheConfig; taskCache: CacheConfig; diff --git a/src/lib/clients/cache_strategy.ts b/src/lib/clients/cache_strategy.ts index 5add93b35..220343f74 100644 --- a/src/lib/clients/cache_strategy.ts +++ b/src/lib/clients/cache_strategy.ts @@ -3,62 +3,23 @@ import { Cache } from '$lib/clients/cache'; import type { ContestsForImport } from '$lib/types/contest'; import type { TasksForImport } from '$lib/types/task'; -/** - * A strategy for caching contest and task data. - * Separates the caching logic from the data fetching concerns. - */ export class ContestTaskCache { - /** - * Constructs a cache strategy with the specified contest and task caches. - * @param contestCache - Cache for storing contest import data - * @param taskCache - Cache for storing task import data - */ constructor( private readonly contestCache: Cache, private readonly taskCache: Cache, ) {} - /** - * Retrieves data from cache if available, otherwise fetches it using the provided function. - * - * @template T - The type of data being cached and returned - * @param {string} key - The unique identifier for the cached data - * @param {() => Promise} fetchFunction - Function that returns a Promise resolving to data of type T - * @param {Cache} cache - Cache object with get and set methods for type T - * @returns {Promise} - The cached data or newly fetched data - * - * @example - * const result = await cacheInstance.getCachedOrFetch( - * 'contests-123', - * () => api.fetchContests(), - * contestCache - * ); - */ - async getCachedOrFetch( - key: string, - fetchFunction: () => Promise, - cache: Cache, - ): Promise { - return cache.getOrFetch(key, fetchFunction); - } - - /** - * Gets contests from cache or fetches them. - */ async getCachedOrFetchContests( key: string, fetchFunction: () => Promise, ): Promise { - return this.getCachedOrFetch(key, fetchFunction, this.contestCache); + return this.contestCache.getOrFetch(key, fetchFunction); } - /** - * Gets tasks from cache or fetches them. - */ async getCachedOrFetchTasks( key: string, fetchFunction: () => Promise, ): Promise { - return this.getCachedOrFetch(key, fetchFunction, this.taskCache); + return this.taskCache.getOrFetch(key, fetchFunction); } } diff --git a/src/lib/server/tasks/cache.ts b/src/lib/server/tasks/cache.ts index ecc9e0993..ef676a749 100644 --- a/src/lib/server/tasks/cache.ts +++ b/src/lib/server/tasks/cache.ts @@ -1,14 +1,13 @@ -import { Cache } from '$lib/clients/cache'; - import type { Task } from '$lib/types/task'; import type { TaskMapByContestTaskPair } from '$lib/types/contest_task_pair'; -const HOUR_MS = 60 * 60 * 1000; +import { Cache, DEFAULT_CACHE_TTL } from '$lib/clients/cache'; + const TASK_MAP_KEY = 'tasks_by_task_id'; const MERGED_KEY = 'merged_tasks_map'; -const tasksCache = new Cache>(HOUR_MS); -const mergedTasksCache = new Cache(HOUR_MS); +const tasksCache = new Cache>(DEFAULT_CACHE_TTL); +const mergedTasksCache = new Cache(DEFAULT_CACHE_TTL); export function getCachedTasksMap( fetchFn: () => Promise>, From d0559cbaa620b75a31976ff8438a36720773b2c7 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 00:11:16 +0000 Subject: [PATCH 6/7] chore(dev-notes): remove phase4-server-cache planning docs after branch completion Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-17/phase4-server-cache/design.md | 136 --- .../phase4-server-cache/plan-2nd.md | 489 ---------- .../phase4-server-cache/plan-3rd.md | 63 -- .../2026-06-17/phase4-server-cache/plan.md | 922 ------------------ .../2026-06-17/phase4-server-cache/review.md | 138 --- .../2026-06-17/phase4-server-cache/survey.md | 73 -- 6 files changed, 1821 deletions(-) delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/design.md delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/plan.md delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/review.md delete mode 100644 docs/dev-notes/2026-06-17/phase4-server-cache/survey.md diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/design.md b/docs/dev-notes/2026-06-17/phase4-server-cache/design.md deleted file mode 100644 index 3e6b50118..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/design.md +++ /dev/null @@ -1,136 +0,0 @@ -# Phase 4:設計判断 - -> 本ドキュメントは [plan.md](./plan.md) の設計背景。調査・前提条件は [survey.md](./survey.md)、実装手順は plan.md を参照。 - -## 設計判断の記録 - -### 却下:案A — `src/lib/server/cache.ts` 集約 - -**発想:** `database.ts` と同じく、キャッシュもインフラとして `lib/server/` に集約する。 - -**却下理由:** - -- `database.ts` はドメイン型を一切持たない純粋なインフラ(Prisma 接続管理のみ)。一方、キャッシュは `WorkbooksWithAuthors`(features 型)など各ドメインの型を知る必要がある。`lib` が `features` に依存する逆転が生じる。 -- 無関係なドメイン(タスク・投票統計・問題集)が1ファイルに同居する積極的な理由がない。タスクの TTL を変更したいだけなのに、投票統計の定義が同じファイルにある。 -- `resetAllCaches()` はテスト専用コードが本番モジュールに漏れる形であり、かつ全ドメインを一括リセットする本番ユースケースが存在しない。 - -### 却下:案B — 各サービスファイル内にインスタンス - -**発想:** `tasks.ts` の先頭に `const tasksCache = new Cache<...>()` を置く。 - -**却下理由:** - -- シンプルだが `tasks.ts` はすでに `createTask`/`updateTask`/`getTasks`/`getTasksByTaskId`/`getMergedTasksMap` を持つ大きなファイル。DB アクセスにキャッシュ管理が混在する。 -- テスト隔離のために `_resetTaskCachesForTest()` などを export すると、テスト用コードが本番モジュールに入る点は案Aと変わらない。形を変えて同じ問題が残る。 - -### 採用:案C — ドメイン別 `server/cache.ts` - -各ドメインが自分専用のキャッシュモジュールを持つ: - -``` -src/lib/server/tasks/cache.ts ← lib ドメイン(tasks) -src/features/votes/server/cache.ts ← votes ドメイン -src/features/workbooks/server/cache.ts ← workbooks ドメイン -``` - -**採用理由:** - -- `features` 型は `features` 内で完結し、アーキテクチャ違反がない。 -- サービスファイルはクエリ+変換責務に集中できる。 -- TTL 設定・キャッシュキー・invalidate ロジックが同一ドメインファイルに閉じる。 -- `server/` という名前は SvelteKit の `+page.server.ts` と文脈が異なるが、「サーバー専用コード」の意味では一貫している。features 内に新規ディレクトリ規約を追加するコストはあるが、ドメイン分離の恩恵で正当化できる。 - ---- - -## HOF パターンと fetchFn の責務 - -**高階関数(Higher-order function)パターン:** - -```typescript -// src/features/votes/server/cache.ts -export async function getCachedVoteStats( - fetchFn: () => Promise>, -): Promise> { - const cached = cache.get(KEY); - - if (cached) { - return cached; - } - - const result = await fetchFn(); - cache.set(KEY, result); - - return result; -} -``` - -```typescript -// src/features/votes/services/vote_statistics.ts -export async function getVoteGradeStatistics(): Promise> { - return getCachedVoteStats(async () => { - const allStats = await prisma.votedGradeStatistics.findMany(); - return new Map(allStats.map((stat) => [stat.taskId, stat])); - }); -} -``` - -**fetchFn が返すのは変換済みの型(raw ではない):** - -既存コードを確認すると、5関数すべて「Prisma raw → ドメイン型」の変換を関数内部で完結させている。キャッシュに格納するのも変換済みの型が合理的(キャッシュヒット時に変換コストをスキップできる)。変換はCRUD側(fetchFn の中)の責務とする。 - -``` -getCachedVoteStats(fetchFn) -├── キャッシュヒット → fetchFn を呼ばずに Map を返す -└── キャッシュミス → fetchFn() を実行(DB + 変換)→ Map をキャッシュ → 返す -``` - -サービスファイルはキャッシュの存在・詳細を知らずに済む。 - ---- - -## 対象関数と TTL - -| 関数 | キャッシュモジュール | TTL | invalidate | -| --------------------------------------- | ---------------------------------------- | ----- | ------------------------------ | -| `getTasksByTaskId()` | `src/lib/server/tasks/cache.ts` | 1時間 | `createTask` / `updateTask` 後 | -| `getMergedTasksMap()`(引数なし時のみ) | `src/lib/server/tasks/cache.ts` | 1時間 | `createTask` / `updateTask` 後 | -| `getVoteGradeStatistics()` | `src/features/votes/server/cache.ts` | 10分 | TTL のみ(投票は高頻度 write) | -| `getWorkbooksByPlacement()` | `src/features/workbooks/server/cache.ts` | 1時間 | workbook 書き込み系3関数後 | -| `getWorkBooksCreatedByUsers()` | `src/features/workbooks/server/cache.ts` | 1時間 | workbook 書き込み系3関数後 | - -`getMergedTasksMap(tasks?: Tasks)` は `tasks` 引数あり(フィルタ済みリストを渡すケース)の場合はキャッシュしない。実際の呼び出し元はすべて引数なし。 - ---- - -## ファイル構成 - -| 操作 | パス | 内容 | -| -------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| 新規作成 | `src/lib/server/tasks/cache.ts` | タスク系 `getCached*()` + `invalidateTaskCaches()` | -| 新規作成 | `src/features/votes/server/cache.ts` | `getCachedVoteStats()` + `invalidateVoteCaches()` | -| 新規作成 | `src/features/workbooks/server/cache.ts` | `getCachedWorkbooksByPlacement()` + `getCachedWorkbooksByUser()` + `invalidateWorkbookCaches()` | -| 修正 | `src/lib/services/tasks.ts` | `getTasksByTaskId()`・`getMergedTasksMap()` を `getCached*()` 経由に。`createTask()`・`updateTask()` に `invalidateTaskCaches()` 追加 | -| 修正 | `src/features/votes/services/vote_statistics.ts` | `getVoteGradeStatistics()` を `getCachedVoteStats()` 経由に | -| 修正 | `src/features/workbooks/services/workbooks.ts` | getter 2関数を `getCached*()` 経由に。writer 3関数に `invalidateWorkbookCaches()` 追加 | -| 新規作成 | `src/lib/server/tasks/cache.test.ts` | キャッシュ挙動テスト(hit/miss/TTL/invalidate) | -| 新規作成 | `src/features/votes/server/cache.test.ts` | キャッシュ挙動テスト(hit/miss/TTL/invalidate) | -| 新規作成 | `src/features/workbooks/server/cache.test.ts` | キャッシュ挙動テスト(hit/miss/invalidate) | - ---- - -## テスト戦略 - -**キャッシュ挙動テスト** は `server/cache.test.ts` で行う(`Cache` インスタンスを直接使い、`vi.useFakeTimers()` で TTL を検証)。 - -**`setInterval` リーク防止:** `Cache` はコンストラクタで `setInterval()` を起動する。テスト終了後にタイマーが残るのを防ぐため、各キャッシュモジュールは `dispose*Caches()` を export し、`cache.test.ts` の `afterAll()` で呼ぶ。 - -**invalidate API の統一:** 3ドメインすべて `invalidate*Caches()` を public export する。votes は TTL のみで invalidate 不要だが、テストリセット用途として統一する(将来の write-through invalidation にも対応できる)。 - -**サービステスト** はキャッシュモジュールを `vi.mock()` で透過化する(`getCached*` が常に fetchFn を呼ぶよう差し替え)。サービステストはキャッシュを意識せず、DB クエリ・変換ロジックの正しさだけを検証する。 - -```typescript -// サービステストの例 -vi.mock('$features/votes/server/cache', () => ({ - getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), -})); -``` diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md deleted file mode 100644 index ced890604..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-2nd.md +++ /dev/null @@ -1,489 +0,0 @@ -# Phase 4 Server Cache: Review 対応計画 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** review.md の優先順位まとめ #1〜#5 を修正し、キャッシュの信頼性・型安全性・重複排除を改善する - -**Architecture:** `Cache` クラスに in-flight dedup を追加(#1)、サービス層の invalidation 漏れ修正(#2)、設定値修正(#3)、型制約強化(#4)、`ContestTaskCache` の `getCachedOrFetch` を `Cache.getOrFetch` に委譲(#5) - -**Tech Stack:** TypeScript, Vitest, SvelteKit (server-only modules) - -## Global Constraints - -- TDD: テストを先に書き、失敗を確認してから実装 -- `pnpm test:unit` で全テストパス確認 -- `pnpm check` でエラー 0件確認 -- サービス層のテストでは `vi.mock('$lib/server/database', ...)` を使用 -- キャッシュモジュールのテストでは `afterAll(() => dispose*Caches())` でタイマークリーンアップ - ---- - -### Task 1: キャッシュスタンピード対策 — in-flight Promise の共有 - -**Files:** - -- Modify: `src/lib/clients/cache.ts:129-140` — `getOrFetch` に in-flight dedup 追加 -- Modify: `src/lib/clients/cache.test.ts` — stampede テスト追加 - -**Interfaces:** - -- Consumes: なし(既存 `Cache` クラスの内部変更) -- Produces: `getOrFetch(key, fetchFn)` のシグネチャは変更なし。並行呼び出し時に fetchFn が1回だけ実行されるようになる - -- [ ] **Step 1: 失敗するテストを書く** - -`src/lib/clients/cache.test.ts` の `describe('getOrFetch')` 内に以下を追加: - -```typescript -describe('stampede prevention', () => { - test('shares a single fetchFn call across concurrent requests for the same key', async () => { - const cache = new Cache(); - let resolvePromise: (value: string) => void; - const fetchFn = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - resolvePromise = resolve; - }), - ); - - const promise1 = cache.getOrFetch('key', fetchFn); - const promise2 = cache.getOrFetch('key', fetchFn); - - resolvePromise!('shared'); - const [result1, result2] = await Promise.all([promise1, promise2]); - - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(result1).toBe('shared'); - expect(result2).toBe('shared'); - }); - - test('cleans up inflight entry after fetchFn resolves', async () => { - const cache = new Cache(); - const fetchFn = vi.fn().mockResolvedValue('done'); - await cache.getOrFetch('key', fetchFn); - - const fetchFn2 = vi.fn().mockResolvedValue('done2'); - vi.advanceTimersByTime(cache['timeToLive'] + 1); - const result = await cache.getOrFetch('key', fetchFn2); - - expect(fetchFn2).toHaveBeenCalledTimes(1); - expect(result).toBe('done2'); - }); - - test('cleans up inflight entry and propagates error to all waiters on fetchFn failure', async () => { - const cache = new Cache(); - const fetchFn = vi.fn().mockRejectedValue(new Error('boom')); - - const results = await Promise.allSettled([ - cache.getOrFetch('key', fetchFn), - cache.getOrFetch('key', fetchFn), - ]); - - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(results[0]).toEqual(expect.objectContaining({ status: 'rejected' })); - expect(results[1]).toEqual(expect.objectContaining({ status: 'rejected' })); - expect(cache.size).toBe(0); - }); - - test('does not share inflight between different keys', async () => { - const cache = new Cache(); - const fetchFnA = vi.fn().mockResolvedValue('a'); - const fetchFnB = vi.fn().mockResolvedValue('b'); - - const [resultA, resultB] = await Promise.all([ - cache.getOrFetch('keyA', fetchFnA), - cache.getOrFetch('keyB', fetchFnB), - ]); - - expect(fetchFnA).toHaveBeenCalledTimes(1); - expect(fetchFnB).toHaveBeenCalledTimes(1); - expect(resultA).toBe('a'); - expect(resultB).toBe('b'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗を確認** - -Run: `pnpm test:unit -- src/lib/clients/cache.test.ts` -Expected: `stampede prevention` の 4テストが FAIL(最初のテストは `fetchFn` が2回呼ばれるため) - -- [ ] **Step 3: in-flight Map を追加して getOrFetch を修正** - -`src/lib/clients/cache.ts` を修正: - -クラスのプロパティに追加: - -```typescript -private inflight = new Map>(); -``` - -`getOrFetch` メソッドを差し替え: - -```typescript -async getOrFetch(key: string, fetchFn: () => Promise): Promise { - const cached = this.get(key); - - if (cached !== undefined) { - return cached; - } - - const pending = this.inflight.get(key); - - if (pending) { - return pending; - } - - const promise = fetchFn().then( - (result) => { - this.set(key, result); - this.inflight.delete(key); - return result; - }, - (error) => { - this.inflight.delete(key); - throw error; - }, - ); - this.inflight.set(key, promise); - - return promise; -} -``` - -`dispose` メソッドに `this.inflight.clear();` を追加: - -```typescript -dispose(): void { - clearInterval(this.cleanupInterval); - this.cache.clear(); - this.inflight.clear(); -} -``` - -- [ ] **Step 4: テストパスを確認** - -Run: `pnpm test:unit -- src/lib/clients/cache.test.ts` -Expected: 全テスト PASS - -- [ ] **Step 5: 全体テスト・型チェック** - -Run: `pnpm test:unit && pnpm check` -Expected: エラー 0件 - ---- - -### Task 2: Vote 書き込み時の `invalidateVoteCaches()` 追加 - -**Files:** - -- Modify: `src/features/votes/services/vote_grade.ts:1,72` — import 追加 + 呼び出し追加 -- Modify: `src/features/votes/services/vote_grade.test.ts` — invalidation テスト追加 - -**Interfaces:** - -- Consumes: `invalidateVoteCaches()` from `$features/votes/server/cache` -- Produces: `upsertVoteGradeTables()` が DB 書き込み成功後にキャッシュを無効化するようになる - -- [ ] **Step 1: 失敗するテストを書く** - -`src/features/votes/services/vote_grade.test.ts` に mock と テストを追加。 - -ファイル冒頭の `vi.mock('$lib/server/database', ...)` の後に追加: - -```typescript -vi.mock('$features/votes/server/cache', () => ({ - invalidateVoteCaches: vi.fn(), -})); - -import { invalidateVoteCaches } from '$features/votes/server/cache'; -``` - -`beforeEach` 内に追加: - -```typescript -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(invalidateVoteCaches).mockClear(); -}); -``` - -`describe('upsertVoteGradeTables')` 内に以下のテストを追加: - -```typescript -test('invalidates vote caches after successful write', async () => { - const tx = setupTransaction(); - tx.voteGrade.findUnique.mockResolvedValue(null); - tx.voteGrade.upsert.mockResolvedValue({}); - tx.votedGradeCounter.upsert.mockResolvedValue({}); - tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]); - tx.task.findUnique.mockResolvedValue({ grade: TaskGrade.Q3 }); - - await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); - - expect(invalidateVoteCaches).toHaveBeenCalledTimes(1); -}); - -test('does not invalidate vote caches when grade is unchanged (early return)', async () => { - const tx = setupTransaction(); - tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 }); - - await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); - - expect(invalidateVoteCaches).not.toHaveBeenCalled(); -}); -``` - -- [ ] **Step 2: テスト失敗を確認** - -Run: `pnpm test:unit -- src/features/votes/services/vote_grade.test.ts` -Expected: `invalidates vote caches after successful write` が FAIL(`invalidateVoteCaches` が呼ばれていない) - -- [ ] **Step 3: `upsertVoteGradeTables` に invalidation を追加** - -`src/features/votes/services/vote_grade.ts` を修正: - -import に追加: - -```typescript -import { invalidateVoteCaches } from '$features/votes/server/cache'; -``` - -`upsertVoteGradeTables` の `return { success: true };` の直前に追加: - -```typescript - }); - - invalidateVoteCaches(); - - return { success: true }; -``` - -注意: `invalidateVoteCaches()` は `$transaction` の **外側**、成功後に呼ぶ。トランザクション内で冪等リターン(`existing?.grade === grade`)した場合は `$transaction` のコールバックが早期リターンするだけで、外側の `invalidateVoteCaches()` は呼ばれてしまう。これを防ぐため、トランザクションの戻り値で判別する: - -```typescript -export async function upsertVoteGradeTables( - userId: string, - taskId: string, - grade: TaskGrade, -): Promise<{ success: true }> { - const isUpdated = await prisma.$transaction(async (tx) => { - const existing = await tx.voteGrade.findUnique({ - where: { userId_taskId: { userId, taskId } }, - }); - - if (existing?.grade === grade) { - return false; - } - - if (existing) { - await decrementOldGradeCounter(tx, taskId, existing.grade); - } - await upsertVoteRecord(tx, userId, taskId, grade); - await incrementNewGradeCounter(tx, taskId, grade); - - const latestCounters = await tx.votedGradeCounter.findMany({ - where: { taskId }, - orderBy: { grade: 'asc' }, - }); - - const total = latestCounters.reduce((sum, counter) => sum + counter.count, 0); - const taskRecord = await tx.task.findUnique({ - where: { task_id: taskId }, - select: { grade: true }, - }); - const minVotes = - taskRecord?.grade === TaskGrade.PENDING - ? MIN_VOTES_FOR_PROVISIONAL_GRADE - : MIN_VOTES_FOR_STATISTICS; - if (total < minVotes) { - return true; - } - - await updateVoteStatistics(tx, taskId, latestCounters, minVotes); - - return true; - }); - - if (isUpdated) { - invalidateVoteCaches(); - } - - return { success: true }; -} -``` - -- [ ] **Step 4: テストパスを確認** - -Run: `pnpm test:unit -- src/features/votes/services/vote_grade.test.ts` -Expected: 全テスト PASS - -- [ ] **Step 5: 全体テスト** - -Run: `pnpm test:unit` -Expected: 全テスト PASS - ---- - -### Task 3: `placementCache` の maxSize 引き上げ - -**Files:** - -- Modify: `src/features/workbooks/server/cache.ts:9` — maxSize パラメータ追加 -- Modify: `src/features/workbooks/server/cache.test.ts` — maxSize 超過のテスト追加(オプション) - -**Interfaces:** - -- Consumes: `Cache` コンストラクタの第2引数 `maxSize` -- Produces: 変更なし(挙動の正常化のみ) - -- [ ] **Step 1: 失敗するテスト(省略可)** - -maxSize 超過は `Cache` 本体でテスト済み。ここでの変更は設定値の修正のみなので、テスト追加は不要。 - -- [ ] **Step 2: `placementCache` の maxSize を 100 に変更** - -`src/features/workbooks/server/cache.ts` の9行目を修正: - -```typescript -// Before: -const placementCache = new Cache(HOUR_MS); - -// After: -const placementCache = new Cache(HOUR_MS, 100); -``` - -根拠: TaskGrade(19) × 2(published/unpublished) + SolutionCategory(15) × 2 = 68 キー。100 は十分な余裕を持つ。 - -- [ ] **Step 3: テスト確認** - -Run: `pnpm test:unit -- src/features/workbooks/server/cache.test.ts` -Expected: 全テスト PASS - ---- - -### Task 4: `getOrFetch` の型安全性改善 — `T extends {}` 制約 - -**Files:** - -- Modify: `src/lib/clients/cache.ts:8` — `Cache` → `Cache` -- Modify: `src/lib/clients/cache.test.ts:157` — `Cache` → `Cache>` に変更 - -**Interfaces:** - -- Consumes: なし -- Produces: `Cache` — `T = undefined` と `T = null` が型レベルで禁止される。既存の本番呼び出し元(`Cache`, `Cache>`, `Cache` 等)は全て `{}` を満たすため影響なし - -**設計判断:** - -- `return cached as T` の型アサーションは使わない。キャストは型チェッカーを黙らせるだけで `Cache` のような誤用を防げない -- `T extends {}` は `null` と `undefined` の両方を禁止する。本番コードで `Cache` や `Cache` のインスタンスはゼロなので問題なし -- テスト側の `Cache` は `unknown extends {}` が false のためコンパイルエラーになる → テストの意図(各種値型をキャッシュできること)に合った具体型 `Cache>` に変更する - -- [ ] **Step 1: `Cache` に型制約を追加** - -`src/lib/clients/cache.ts` のクラス宣言を修正: - -```typescript -// Before: -export class Cache { - -// After: -export class Cache { -``` - -- [ ] **Step 2: テストの `Cache` を修正** - -`src/lib/clients/cache.test.ts` の `edge cases` → `expects to handle different value types` を修正: - -```typescript -// Before: -const cache = new Cache(); - -// After: -const cache = new Cache>(); -``` - -テスト内の `cache.set('null', null)` 行を削除し、`expect(cache.get('null')).toBe(null)` も削除。`null` は `{}` を満たさないため `Cache>` に `null` を `set` できない。テストの目的は「各種値型をキャッシュできること」であり、`null` は `getOrFetch` の sentinel(`undefined`)と違って実用上キャッシュする必要がない。 - -修正後のテスト: - -```typescript -test('expects to handle different value types', () => { - const cache = new Cache>(); - - cache.set('string', { value: 'test' }); - cache.set('number', { value: 123 }); - cache.set('boolean', { value: true }); - cache.set('object', { a: 1, b: 2 }); - cache.set('array', { items: [1, 2, 3] }); - - expect(cache.get('string')).toEqual({ value: 'test' }); - expect(cache.get('number')).toEqual({ value: 123 }); - expect(cache.get('boolean')).toEqual({ value: true }); - expect(cache.get('object')).toEqual({ a: 1, b: 2 }); - expect(cache.get('array')).toEqual({ items: [1, 2, 3] }); -}); -``` - -- [ ] **Step 3: 型チェック・テスト確認** - -Run: `pnpm check && pnpm test:unit -- src/lib/clients/cache.test.ts` -Expected: エラー 0件、全テスト PASS - ---- - -### Task 5: `ContestTaskCache` のリファクタリング — `Cache.getOrFetch` への委譲 - -**Files:** - -- Modify: `src/lib/clients/cache_strategy.ts:37-60` — `getCachedOrFetch` を `Cache.getOrFetch` に委譲 -- Modify: `src/lib/clients/aizu_online_judge/clients.test.ts` — 既存テストがパスすることを確認(変更なし) - -**Interfaces:** - -- Consumes: `Cache.getOrFetch(key, fetchFn)` (Task 1 で改善済み) -- Produces: `ContestTaskCache.getCachedOrFetchContests` / `getCachedOrFetchTasks` のシグネチャは変更なし。内部の `getCachedOrFetch` が `Cache.getOrFetch` に委譲するようになる - -- [ ] **Step 1: 既存テストがパスすることを確認** - -Run: `pnpm test:unit -- src/lib/clients/aizu_online_judge/clients.test.ts` -Expected: 全テスト PASS(リファクタリング前のベースライン) - -- [ ] **Step 2: `getCachedOrFetch` を `Cache.getOrFetch` に委譲** - -`src/lib/clients/cache_strategy.ts` の `getCachedOrFetch` メソッドを修正: - -```typescript -async getCachedOrFetch( - key: string, - fetchFunction: () => Promise, - cache: Cache, -): Promise { - return cache.getOrFetch(key, fetchFunction); -} -``` - -これにより: - -- `console.log` のキャッシュヒット/ミスログが除去される(`Cache.getOrFetch` はログを出さない) -- エラー時の `[] as unknown as T` バグが修正される(`Cache.getOrFetch` はエラーを伝播する) - -- [ ] **Step 3: リファクタリング後にテストがパスすることを確認** - -Run: `pnpm test:unit -- src/lib/clients/aizu_online_judge/clients.test.ts` -Expected: 全テスト PASS - -- [ ] **Step 4: 全体テスト・型チェック** - -Run: `pnpm test:unit && pnpm check` -Expected: エラー 0件 - ---- - -## 最終確認 - -- [ ] `pnpm test:unit` — 全テストパス -- [ ] `pnpm check` — 型エラー 0件 -- [ ] `pnpm format` — フォーマット適用 -- [ ] `pnpm lint` — lint エラー 0件 diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md deleted file mode 100644 index c7272ea4d..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/plan-3rd.md +++ /dev/null @@ -1,63 +0,0 @@ -# Phase 4 Server Cache: Review + Simplify 対応計画(3rd) - -**Goal:** `staging` vs `#3706` の review / simplify 指摘事項を修正する - ---- - -## 優先度 Medium - -### 1. `cache_strategy.ts` の `getCachedOrFetch` 不要ラッパーを削除 - -- **箇所**: `src/lib/clients/cache_strategy.ts:37-43` -- **内容**: `getCachedOrFetch()` が `cache.getOrFetch(key, fetchFunction)` への単純委譲のみ。`getCachedOrFetchContests()` / `getCachedOrFetchTasks()` が直接 `this.contestCache.getOrFetch()` / `this.taskCache.getOrFetch()` を呼べば足りる -- **修正**: `getCachedOrFetch` を削除し、呼び出し元を直接 `getOrFetch()` に向ける - ---- - -## 優先度 Low - -### 2. `HOUR_MS` 定数の重複を解消 - -- **箇所**: `src/lib/server/tasks/cache.ts:6`, `src/features/workbooks/server/cache.ts:6` -- **内容**: `const HOUR_MS = 60 * 60 * 1000` が2ファイルに独立定義。`cache.ts` の `DEFAULT_CACHE_TTL` が未 export -- **修正案A**: `src/lib/clients/cache.ts` の `DEFAULT_CACHE_TTL` を export し、両ファイルで import -- **修正案B**: `src/lib/constants/` に `CACHE_TTL` 定数ファイルを新設 -- **推奨**: 案A(既存定数を活用、ファイル追加なし) - -### 3. `buildPlacementKey` をキャッシュ層から分離 - -- **箇所**: `src/features/workbooks/server/cache.ts:3,12-18` -- **内容**: `buildPlacementKey()` が `WorkBookType` のドメイン判定ロジックを持つ。キャッシュ層はストレージのみを担うべきで、ドメイン型の分岐知識はサービス層の責務 -- **修正**: `buildPlacementKey()` を `src/features/workbooks/utils/` か `workbooks.ts` に移動し、`getCachedWorkbooksByPlacement()` はキー文字列を引数で受け取るシグネチャに変更 - ---- - -## 優先度 Nit - -### 4. `byUserCache.clear()` → `delete(BY_USER_KEY)` - -- **箇所**: `src/features/workbooks/server/cache.ts:36` -- **内容**: 単一キーのみなのに `clear()` を使用。`delete(BY_USER_KEY)` の方が意図が明確 -- **修正**: `byUserCache.clear()` → `byUserCache.delete(BY_USER_KEY)` - -### 5. what コメント削除 - -- **箇所**: `src/lib/clients/cache.ts:1-67`(TSDoc 各所) -- **内容**: `size` / `health` / `set` などのコードを言い換えただけのコメント。AGENTS.md の「why のみコメント」規約に違反 -- **修正**: メソッド名から自明な TSDoc を削除。非自明な制約(例: `set()` の key 長制限の `@throws`)は残す - -### 6. 不要な `console.log` 削除 - -- **箇所**: `src/features/workbooks/services/workbooks.ts:282` -- **内容**: `createWorkBook` 成功時のログ。`updateWorkBook` には同等ログなく一貫性がない -- **修正**: 削除 - ---- - -## 対応不要(確認済み) - -- **invalidate タイミング**: 全ドメインで DB 書き込み成功後のみ(`finally` 内ではない) -- **キャッシュキー衝突**: `buildPlacementKey` のプレフィックス区別、インスタンス分離とも問題なし -- **メモリリーク**: `inflight` Map はエラー時も必ず `delete`、`maxSize` による evictioneviction あり -- **並列化**: `getMergedTasksMap` では `Promise.all` で適切に並列化済み -- **ドメインモジュールの同一パターン**: `server-cache.md` ルールへの準拠であり抽象化不要 diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md b/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md deleted file mode 100644 index 0311ab0cd..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/plan.md +++ /dev/null @@ -1,922 +0,0 @@ -# Phase 4:共有データのサーバー側キャッシュ層 実装計画 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** DB クエリ結果をプロセス内 `Map`+TTL でキャッシュし、warm インスタンスでの Function Duration と不要な DB スキャンを削減する。 - -**Architecture:** `Cache.getOrFetch(key, fetchFn)` で get-or-fetch パターンを汎用メソッドとして提供し、ドメインごとに `server/cache.ts`(Case C)で薄いラッパー関数 `getCached*(fetchFn)` を定義する。fetchFn は DB クエリ+変換済みの型を返す責務を持つ。サービス関数はキャッシュの詳細を知らずに `getCached*()` を呼ぶだけでよい。 - -**Tech Stack:** TypeScript, Vitest, `Cache` (src/lib/clients/cache.ts), Prisma/db singleton - -> 調査・前提条件・Fluid Compute 検討は [survey.md](./survey.md)、設計判断(案A/B/C・HOF パターン・対象関数・テスト戦略)は [design.md](./design.md) を参照。 - ---- - -## Task 0: `Cache.getOrFetch()` メソッドを追加する(テストから) - -**Files:** - -- Modify: `src/lib/clients/cache.ts` -- Modify: `src/lib/clients/cache.test.ts` - -- [ ] **Step 1: テストを書く** - -`src/lib/clients/cache.test.ts` の既存 `describe('Cache', ...)` 内に追加: - -```typescript -describe('getOrFetch', () => { - describe('successful case', () => { - test('calls fetchFn and caches result on first invocation', async () => { - const cache = new Cache(); - const fetchFn = vi.fn().mockResolvedValue('fetched'); - const result = await cache.getOrFetch('key', fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(result).toBe('fetched'); - }); - - test('returns cached value without calling fetchFn on subsequent calls', async () => { - const cache = new Cache(); - const fetchFn = vi.fn().mockResolvedValue('fetched'); - await cache.getOrFetch('key', fetchFn); - await cache.getOrFetch('key', fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); - }); - - describe('error cases', () => { - test('propagates fetchFn error without caching', async () => { - const cache = new Cache(); - const fetchFn = vi.fn().mockRejectedValue(new Error('fetch failed')); - await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); - expect(cache.size).toBe(0); - }); - - test('retries fetchFn after a previous failure', async () => { - const cache = new Cache(); - const fetchFn = vi - .fn() - .mockRejectedValueOnce(new Error('fetch failed')) - .mockResolvedValue('retried'); - await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed'); - const result = await cache.getOrFetch('key', fetchFn); - expect(result).toBe('retried'); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); - }); - - describe('boundary cases', () => { - test('caches empty Map as valid value', async () => { - const cache = new Cache>(); - const fetchFn = vi.fn().mockResolvedValue(new Map()); - await cache.getOrFetch('key', fetchFn); - await cache.getOrFetch('key', fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); - - test('returns cached value just before TTL expires', async () => { - const TTL = 1000; - const cache = new Cache(TTL); - const fetchFn = vi.fn().mockResolvedValue('fetched'); - await cache.getOrFetch('key', fetchFn); - vi.advanceTimersByTime(TTL - 1); - await cache.getOrFetch('key', fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); - - test('calls fetchFn again after TTL expires', async () => { - const TTL = 1000; - const cache = new Cache(TTL); - const fetchFn = vi.fn().mockResolvedValue('fetched'); - await cache.getOrFetch('key', fetchFn); - vi.advanceTimersByTime(TTL + 1); - await cache.getOrFetch('key', fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); - }); -}); -``` - -- [ ] **Step 2: テストが失敗することを確認** - -```bash -pnpm test:unit -- src/lib/clients/cache.test.ts -``` - -Expected: `getOrFetch` is not a function でエラー - -- [ ] **Step 3: 実装を書く** - -`src/lib/clients/cache.ts` の `Cache` クラスに追加(`get()` メソッドの後): - -```typescript -async getOrFetch(key: string, fetchFn: () => Promise): Promise { - const cached = this.get(key); - if (cached !== undefined) { - return cached; - } - const result = await fetchFn(); - this.set(key, result); - return result; -} -``` - -> `if (cached)` ではなく `if (cached !== undefined)` を使用。`get()` は miss 時に `undefined` を返すため、`null` や空配列などの falsy な `T` も正しくキャッシュされる。 - -- [ ] **Step 4: テストを通す** - -```bash -pnpm test:unit -- src/lib/clients/cache.test.ts -``` - -Expected: 全 PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/clients/cache.ts src/lib/clients/cache.test.ts -git commit -m "feat(cache): add getOrFetch method to Cache class" -``` - ---- - -## Task 1: キャッシュモジュール 3ファイルを作成する(テストから) - -### 1a. `src/lib/server/tasks/cache.ts` - -**Files:** - -- Create: `src/lib/server/tasks/cache.ts` -- Create: `src/lib/server/tasks/cache.test.ts` - -- [ ] **Step 1: テストを書く** - -```typescript -// src/lib/server/tasks/cache.test.ts -import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; -import type { Task } from '$lib/types/task'; -import { - getCachedTasksMap, - getCachedMergedTasksMap, - invalidateTaskCaches, - disposeTaskCaches, -} from './cache'; - -const taskEntry = new Map([['abc422_a', { task_id: 'abc422_a' } as unknown as Task]]); -const mockFetchFn = (data: Map = new Map()) => vi.fn().mockResolvedValue(data); - -afterAll(() => disposeTaskCaches()); - -describe('getCachedTasksMap', () => { - beforeEach(() => invalidateTaskCaches()); - afterEach(() => vi.restoreAllMocks()); - - test('delegates to cache and returns fetched value', async () => { - const fetchFn = mockFetchFn(taskEntry); - const result = await getCachedTasksMap(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(result.get('abc422_a')?.task_id).toBe('abc422_a'); - }); - - test('returns cached value on subsequent calls', async () => { - const fetchFn = mockFetchFn(taskEntry); - await getCachedTasksMap(fetchFn); - await getCachedTasksMap(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); -}); - -describe('getCachedMergedTasksMap', () => { - beforeEach(() => invalidateTaskCaches()); - afterEach(() => vi.restoreAllMocks()); - - test('returns cached value on subsequent calls', async () => { - const fetchFn = mockFetchFn(); - await getCachedMergedTasksMap(fetchFn); - await getCachedMergedTasksMap(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); -}); - -describe('invalidateTaskCaches', () => { - afterEach(() => vi.restoreAllMocks()); - - test('clears both tasks and mergedTasks caches', async () => { - const tasksFn = mockFetchFn(); - const mergedFn = mockFetchFn(); - await getCachedTasksMap(tasksFn); - await getCachedMergedTasksMap(mergedFn); - invalidateTaskCaches(); - await getCachedTasksMap(tasksFn); - await getCachedMergedTasksMap(mergedFn); - expect(tasksFn).toHaveBeenCalledTimes(2); - expect(mergedFn).toHaveBeenCalledTimes(2); - }); -}); -``` - -- [ ] **Step 2: テストが失敗することを確認** - -```bash -pnpm test:unit -- src/lib/server/tasks/cache.test.ts -``` - -Expected: `tasks_cache` not found でエラー - -- [ ] **Step 3: 実装を書く** - -```typescript -// src/lib/server/tasks/cache.ts -import { Cache } from '$lib/clients/cache'; -import type { Task } from '$lib/types/task'; -import type { TaskMapByContestTaskPair } from '$lib/types/contest_task_pair'; - -const HOUR_MS = 60 * 60 * 1000; -const TASK_MAP_KEY = 'tasks_by_task_id'; -const MERGED_KEY = 'merged_tasks_map'; - -const tasksCache = new Cache>(HOUR_MS); -const mergedTasksCache = new Cache(HOUR_MS); - -export function getCachedTasksMap( - fetchFn: () => Promise>, -): Promise> { - return tasksCache.getOrFetch(TASK_MAP_KEY, fetchFn); -} - -export function getCachedMergedTasksMap( - fetchFn: () => Promise, -): Promise { - return mergedTasksCache.getOrFetch(MERGED_KEY, fetchFn); -} - -export function invalidateTaskCaches(): void { - tasksCache.delete(TASK_MAP_KEY); - mergedTasksCache.delete(MERGED_KEY); -} - -export function disposeTaskCaches(): void { - tasksCache.dispose(); - mergedTasksCache.dispose(); -} -``` - -> 実際の型 import パスは `$lib/types/` 内を確認して合わせること。`TaskMapByContestTaskPair` は `$lib/types/contest_task_pair` にある。 - -- [ ] **Step 4: テストを通す** - -```bash -pnpm test:unit -- src/lib/server/tasks/cache.test.ts -``` - -Expected: 全 PASS - ---- - -### 1b. `src/features/votes/server/cache.ts` - -**Files:** - -- Create: `src/features/votes/server/cache.ts` -- Create: `src/features/votes/server/cache.test.ts` - -- [ ] **Step 1: テストを書く** - -```typescript -// src/features/votes/server/cache.test.ts -import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; -import type { VotedGradeStatistics } from '@prisma/client'; -import { TaskGrade } from '$lib/types/task'; -import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache'; - -const makeStats = (): Map => - new Map([ - [ - 'abc408_d', - { - id: '1', - taskId: 'abc408_d', - grade: TaskGrade.Q1, - isExperimental: false, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - } as unknown as VotedGradeStatistics, - ], - ]); -const mockStatsFn = () => vi.fn().mockResolvedValue(makeStats()); - -afterAll(() => disposeVoteCaches()); - -describe('getCachedVoteStats', () => { - beforeEach(() => invalidateVoteCaches()); - afterEach(() => vi.restoreAllMocks()); - - test('delegates to cache and returns fetched value', async () => { - const fetchFn = mockStatsFn(); - const result = await getCachedVoteStats(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(result.get('abc408_d')?.grade).toBe(TaskGrade.Q1); - }); - - test('returns cached value on subsequent calls', async () => { - const fetchFn = mockStatsFn(); - await getCachedVoteStats(fetchFn); - await getCachedVoteStats(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); -}); - -describe('invalidateVoteCaches', () => { - afterEach(() => vi.restoreAllMocks()); - - test('clears vote stats cache', async () => { - const fetchFn = mockStatsFn(); - await getCachedVoteStats(fetchFn); - invalidateVoteCaches(); - await getCachedVoteStats(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); -}); -``` - -- [ ] **Step 2: テストが失敗することを確認** - -```bash -pnpm test:unit -- src/features/votes/server/cache.test.ts -``` - -- [ ] **Step 3: 実装を書く** - -```typescript -// src/features/votes/server/cache.ts -import { Cache } from '$lib/clients/cache'; -import type { VotedGradeStatistics } from '@prisma/client'; - -const VOTE_STATS_TTL_MS = 10 * 60 * 1000; -const KEY = 'vote_grade_statistics'; - -const cache = new Cache>(VOTE_STATS_TTL_MS); - -export function getCachedVoteStats( - fetchFn: () => Promise>, -): Promise> { - return cache.getOrFetch(KEY, fetchFn); -} - -export function invalidateVoteCaches(): void { - cache.delete(KEY); -} - -export function disposeVoteCaches(): void { - cache.dispose(); -} -``` - -- [ ] **Step 4: テストを通す** - -```bash -pnpm test:unit -- src/features/votes/server/cache.test.ts -``` - -Expected: 全 PASS - ---- - -### 1c. `src/features/workbooks/server/cache.ts` - -**Files:** - -- Create: `src/features/workbooks/server/cache.ts` -- Create: `src/features/workbooks/server/cache.test.ts` - -- [ ] **Step 1: テストを書く** - -```typescript -// src/features/workbooks/server/cache.test.ts -import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; - -import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; -import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; -import { WorkBookType } from '$features/workbooks/types/workbook'; - -import { - getCachedWorkbooksByPlacement, - getCachedWorkbooksByUser, - invalidateWorkbookCaches, - disposeWorkbookCaches, -} from './cache'; - -const solutionQuery: PlacementQuery = { - workBookType: WorkBookType.SOLUTION, - solutionCategory: SolutionCategory.SEARCH_SIMULATION, -}; -const mockFetchFn = () => vi.fn().mockResolvedValue([]); - -afterAll(() => disposeWorkbookCaches()); - -describe('getCachedWorkbooksByPlacement', () => { - beforeEach(() => invalidateWorkbookCaches()); - afterEach(() => vi.restoreAllMocks()); - - test('returns cached value on subsequent calls', async () => { - const fetchFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); - - test('misses cache when solutionCategory differs', async () => { - const fetchFn = mockFetchFn(); - const otherQuery: PlacementQuery = { - ...solutionQuery, - solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING, - }; - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(otherQuery, false, fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); - - test('misses cache when includeUnpublished differs', async () => { - const fetchFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, fetchFn); - await getCachedWorkbooksByPlacement(solutionQuery, true, fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(2); - }); -}); - -describe('getCachedWorkbooksByUser', () => { - beforeEach(() => invalidateWorkbookCaches()); - afterEach(() => vi.restoreAllMocks()); - - test('returns cached value on subsequent calls', async () => { - const fetchFn = mockFetchFn(); - await getCachedWorkbooksByUser(fetchFn); - await getCachedWorkbooksByUser(fetchFn); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); -}); - -describe('invalidateWorkbookCaches', () => { - afterEach(() => vi.restoreAllMocks()); - - test('clears both placement and user caches', async () => { - const placementFn = mockFetchFn(); - const userFn = mockFetchFn(); - await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); - await getCachedWorkbooksByUser(userFn); - invalidateWorkbookCaches(); - await getCachedWorkbooksByPlacement(solutionQuery, false, placementFn); - await getCachedWorkbooksByUser(userFn); - expect(placementFn).toHaveBeenCalledTimes(2); - expect(userFn).toHaveBeenCalledTimes(2); - }); -}); -``` - -- [ ] **Step 2: テストが失敗することを確認** - -```bash -pnpm test:unit -- src/features/workbooks/server/cache.test.ts -``` - -- [ ] **Step 3: 実装を書く** - -```typescript -// src/features/workbooks/server/cache.ts -import { Cache } from '$lib/clients/cache'; -import type { WorkbooksWithAuthors } from '$features/workbooks/types/workbook'; -import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; -import type { PlacementQuery } from '$features/workbooks/types/workbook_placement'; - -const HOUR_MS = 60 * 60 * 1000; -const BY_USER_KEY = 'workbooks_by_user'; - -const placementCache = new Cache(HOUR_MS); -const byUserCache = new Cache(HOUR_MS); - -function buildPlacementKey(query: PlacementQuery, includeUnpublished: boolean): string { - if (query.workBookType === WorkBookTypeConst.CURRICULUM) { - return `CURRICULUM:${query.taskGrade}:${includeUnpublished}`; - } - - return `SOLUTION:${query.solutionCategory}:${includeUnpublished}`; -} - -export function getCachedWorkbooksByPlacement( - query: PlacementQuery, - includeUnpublished: boolean, - fetchFn: () => Promise, -): Promise { - const key = buildPlacementKey(query, includeUnpublished); - return placementCache.getOrFetch(key, fetchFn); -} - -export function getCachedWorkbooksByUser( - fetchFn: () => Promise, -): Promise { - return byUserCache.getOrFetch(BY_USER_KEY, fetchFn); -} - -export function invalidateWorkbookCaches(): void { - placementCache.clear(); - byUserCache.clear(); -} - -export function disposeWorkbookCaches(): void { - placementCache.dispose(); - byUserCache.dispose(); -} -``` - -- [ ] **Step 4: テストを通す** - -```bash -pnpm test:unit -- src/features/workbooks/server/cache.test.ts -``` - -Expected: 全 PASS - -- [ ] **Step 5: 全3ファイルまとめて Commit** - -```bash -git add \ - src/lib/server/tasks/cache.ts \ - src/lib/server/tasks/cache.test.ts \ - src/features/votes/server/cache.ts \ - src/features/votes/server/cache.test.ts \ - src/features/workbooks/server/cache.ts \ - src/features/workbooks/server/cache.test.ts -git commit -m "feat(cache): add per-domain server cache modules using getOrFetch" -``` - ---- - -## Task 2: `getTasksByTaskId()` / `getMergedTasksMap()` をキャッシュ経由に変更する - -**Files:** - -- Modify: `src/lib/services/tasks.ts` -- Modify: `src/test/lib/services/tasks.test.ts` - -- [ ] **Step 0: `getMergedTasksMap()` の本体ロジックを `buildMergedMap()` に抽出する** - -既存の `getMergedTasksMap()` 内のマージロジック(`baseTaskMap` 構築〜`additionalTaskMap` 生成〜`return new Map(...)` まで)をプライベート関数 `buildMergedMap(tasks: Tasks, contestTaskPairs: ContestTaskPair[]): TaskMapByContestTaskPair` に抽出する。`getMergedTasksMap()` は `buildMergedMap()` を呼ぶだけに簡素化する。この段階でテストが通ることを確認してからキャッシュ統合に進む。 - -```bash -pnpm test:unit -- src/test/lib/services/tasks.test.ts -``` - -- [ ] **Step 1: import を追加し、2関数を修正する** - -```typescript -// tasks.ts 先頭に追加: -import { getCachedTasksMap, getCachedMergedTasksMap } from '$lib/server/tasks/cache'; - -// getTasksByTaskId() を以下に置き換え: -export async function getTasksByTaskId(): Promise> { - return getCachedTasksMap(async () => { - const tasks = await db.task.findMany(); - return new Map(tasks.map((task) => [task.task_id, task])); - }); -} - -// getMergedTasksMap() を以下に置き換え: -export async function getMergedTasksMap(tasks?: Tasks): Promise { - if (tasks !== undefined) { - const contestTaskPairs = await getContestTaskPairs(); - return buildMergedMap(tasks, contestTaskPairs); - } - - return getCachedMergedTasksMap(async () => { - const [allTasks, contestTaskPairs] = await Promise.all([getTasks(), getContestTaskPairs()]); - return buildMergedMap(allTasks, contestTaskPairs); - }); -} -``` - -- [ ] **Step 2: 既存テストにキャッシュ mock を追加する** - -`src/test/lib/services/tasks.test.ts` の先頭(他の `vi.mock` の近く)に追加: - -```typescript -vi.mock('$lib/server/tasks/cache', () => ({ - getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), - getCachedMergedTasksMap: (fetchFn: () => Promise) => fetchFn(), - invalidateTaskCaches: vi.fn(), -})); -``` - -- [ ] **Step 3: 型チェック + テストを通す** - -```bash -pnpm check && pnpm test:unit -- src/test/lib/services/tasks.test.ts -``` - -Expected: 既存テストが全 PASS - ---- - -## Task 3: `createTask()` / `updateTask()` に invalidate を追加する - -**Files:** - -- Modify: `src/lib/services/tasks.ts` - -- [ ] **Step 1: import に `invalidateTaskCaches` を追加し、write 関数に invalidate を追記する** - -Task 2 で追加した import に `invalidateTaskCaches` を追加する: - -```typescript -import { - getCachedTasksMap, - getCachedMergedTasksMap, - invalidateTaskCaches, -} from '$lib/server/tasks/cache'; -``` - -`createTask()` は早期リターンあり(タスク既存 or `contest_type === null`)。`invalidateTaskCaches()` は `db.task.create()` の直後(=実際に DB 書き込みが行われた場合のみ)に追加する: - -```typescript -// createTask() — db.task.create() の直後に追加: -invalidateTaskCaches(); - -// updateTask() — db.task.update() の直後(try ブロック内、console.log の後)に追加: -invalidateTaskCaches(); -``` - -- [ ] **Step 2: テストを通す** - -```bash -pnpm test:unit -- src/test/lib/services/tasks.test.ts -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/lib/services/tasks.ts src/test/lib/services/tasks.test.ts -git commit -m "feat(cache): wrap task getters with cache HOF, invalidate on writes" -``` - ---- - -## Task 4: `getVoteGradeStatistics()` をキャッシュ経由に変更する - -**Files:** - -- Modify: `src/features/votes/services/vote_statistics.ts` -- Modify: `src/features/votes/services/vote_statistics.test.ts` - -- [ ] **Step 1: import を追加し、関数を修正する** - -```typescript -// vote_statistics.ts 先頭に追加: -import { getCachedVoteStats } from '$features/votes/server/cache'; - -// getVoteGradeStatistics() を以下に置き換え: -export async function getVoteGradeStatistics(): Promise> { - return getCachedVoteStats(async () => { - const allStats = await prisma.votedGradeStatistics.findMany(); - return new Map(allStats.map((stat) => [stat.taskId, stat])); - }); -} -``` - -- [ ] **Step 2: 既存テストにキャッシュ mock を追加する** - -`src/features/votes/services/vote_statistics.test.ts` の先頭(他の `vi.mock` の近く)に追加: - -```typescript -vi.mock('$features/votes/server/cache', () => ({ - getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), -})); -``` - -- [ ] **Step 3: テストを通す** - -```bash -pnpm check && pnpm test:unit -- src/features/votes/services/vote_statistics.test.ts -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/features/votes/services/vote_statistics.ts src/features/votes/services/vote_statistics.test.ts -git commit -m "feat(cache): wrap getVoteGradeStatistics with cache HOF (10-min TTL)" -``` - ---- - -## Task 5: 問題集 getter をキャッシュ経由に変更し、writer に invalidate を追加する - -**Files:** - -- Modify: `src/features/workbooks/services/workbooks.ts` -- Modify: `src/features/workbooks/services/workbooks.test.ts` - -- [ ] **Step 1: import を追加する** - -```typescript -import { - getCachedWorkbooksByPlacement, - getCachedWorkbooksByUser, - invalidateWorkbookCaches, -} from '$features/workbooks/server/cache'; -``` - -- [ ] **Step 2: `getWorkbooksByPlacement()` を修正する** - -```typescript -export async function getWorkbooksByPlacement( - query: PlacementQuery, - includeUnpublished = false, -): Promise { - return getCachedWorkbooksByPlacement(query, includeUnpublished, async () => { - const placementFilter = buildPlacementFilter(query); - const workbooks = await db.workBook.findMany({ - where: { - workBookType: query.workBookType, - ...(includeUnpublished ? {} : { isPublished: true }), - placement: placementFilter, - }, - orderBy: { placement: { priority: 'asc' } }, - include: { - user: { select: { username: true } }, - workBookTasks: { orderBy: { priority: 'asc' } }, - }, - }); - return mapWithAuthorName(workbooks); - }); -} -``` - -- [ ] **Step 3: `getWorkBooksCreatedByUsers()` を修正する** - -```typescript -export async function getWorkBooksCreatedByUsers(): Promise { - return getCachedWorkbooksByUser(async () => { - const workbooks = await db.workBook.findMany({ - where: { workBookType: WorkBookTypeConst.CREATED_BY_USER }, - orderBy: { id: 'asc' }, - include: { - user: { select: { username: true } }, - workBookTasks: { orderBy: { priority: 'asc' } }, - }, - }); - return mapWithAuthorName(workbooks); - }); -} -``` - -- [ ] **Step 4: `createWorkBook()` / `updateWorkBook()` / `deleteWorkBook()` の末尾に追加する** - -```typescript -// 各関数の DB 書き込み完了後に追加: -invalidateWorkbookCaches(); -``` - -`updateWorkBook()` は `await db.$transaction(...)` の後に追加すること。 - -- [ ] **Step 5: 既存テストにキャッシュ mock を追加する** - -`src/features/workbooks/services/workbooks.test.ts` の先頭(他の `vi.mock` の近く)に追加: - -```typescript -vi.mock('$features/workbooks/server/cache', () => ({ - getCachedWorkbooksByPlacement: ( - _query: unknown, - _includeUnpublished: unknown, - fetchFn: () => Promise, - ) => fetchFn(), - getCachedWorkbooksByUser: (fetchFn: () => Promise) => fetchFn(), - invalidateWorkbookCaches: vi.fn(), -})); -``` - -- [ ] **Step 6: テストを通す** - -```bash -pnpm check && pnpm test:unit -``` - -Expected: 全 PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/features/workbooks/services/workbooks.ts src/features/workbooks/services/workbooks.test.ts -git commit -m "feat(cache): wrap workbook getters with cache HOF, invalidate on writes" -``` - ---- - -## 検証 - -```bash -pnpm test:unit # 全 PASS -pnpm check # 型エラーなし -pnpm lint # lint エラーなし -``` - -ローカル `pnpm dev` で `/workbooks`・`/problems` を2回アクセスし、2回目のサーバーログに DB クエリが出ないことを確認(開発環境は warm インスタンスが維持されないため参考程度)。 - -デプロイ後、Vercel ダッシュボードで Function Duration を 1〜2 週間観測。 - ---- - -## 残 TODO(本プランのスコープ外) - -- 投票統計 TTL 最終調整(計測後 5〜15分の範囲で) -- `getAllTasksWithVoteInfo()` への Phase 4 拡張(votes UI 改修完了後に別チケット) -- Phase 5:問題集一覧の遅延ロード(本番転送量再計測後に判断) - ---- - -## Draft: `.claude/rules/server-cache.md` - -実装完了後に以下のルールファイルを作成する。 - -````markdown ---- -description: Server-side caching rules -paths: - - 'src/lib/clients/cache.ts' - - 'src/lib/server/**/cache.ts' - - 'src/features/**/server/cache.ts' ---- - -# Server-Side Cache - -## Core Pattern: `Cache.getOrFetch()` - -Use `cache.getOrFetch(key, fetchFn)` for all get-or-fetch operations. Never inline the get/set/if pattern manually — it duplicates logic that `getOrFetch` already provides. - -```typescript -export function getCachedFoo(fetchFn: () => Promise): Promise { - return fooCache.getOrFetch(KEY, fetchFn); -} -``` -```` - -## Domain Cache Module Structure - -Place cache modules at `server/cache.ts` within each domain: - -| Domain | Path | -| ------- | ------------------------------------- | -| Shared | `src/lib/server/{domain}/cache.ts` | -| Feature | `src/features/{name}/server/cache.ts` | - -Each module exports: - -- `getCached*()` — thin wrapper around `cache.getOrFetch()` -- `invalidate*Caches()` — clears all related cache instances -- `dispose*Caches()` — disposes all related cache instances (for test cleanup) - -## Invalidation Rules - -- Call `invalidate*Caches()` immediately after the DB write (`create`, `update`, `delete`) succeeds — not before, not in a `finally` block. -- Group related caches in a single invalidation function (e.g., `invalidateTaskCaches()` clears both `tasksCache` and `mergedTasksCache`). -- Never invalidate from route handlers — invalidation belongs in the service layer, co-located with the write operation. - -## TTL Guidelines - -| Data characteristics | TTL | -| ------------------------------ | ---------- | -| Rarely changes (tasks, grades) | 1 hour | -| Moderately changes (votes) | 10 minutes | - -Adjust based on production metrics after deployment. - -## Testing - -Core cache behavior (hit, miss, TTL, error propagation) is tested on `Cache.getOrFetch()` in `src/lib/clients/cache.test.ts`. Domain cache tests cover only domain-specific concerns: - -- **Wiring**: wrapper delegates correctly and returns cached value on subsequent calls -- **Key isolation**: different parameters produce different cache keys (e.g., `buildPlacementKey`) -- **Invalidation grouping**: `invalidate*()` clears all related caches - -Do not duplicate TTL or error propagation tests in domain cache test files. - -## Service Layer Integration - -Services call `getCached*()` with a `fetchFn` that performs the DB query. The service does not import `Cache` directly. - -```typescript -// In service file -import { getCachedTasksMap } from '$lib/server/tasks/cache'; - -export async function getTasksByTaskId(): Promise> { - return getCachedTasksMap(async () => { - const tasks = await db.task.findMany(); - return new Map(tasks.map((task) => [task.task_id, task])); - }); -} -``` - -Mock cache in service tests to bypass caching: - -```typescript -vi.mock('$lib/server/tasks/cache', () => ({ - getCachedTasksMap: (fetchFn: () => Promise) => fetchFn(), - invalidateTaskCaches: vi.fn(), -})); -``` diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/review.md b/docs/dev-notes/2026-06-17/phase4-server-cache/review.md deleted file mode 100644 index baa728324..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/review.md +++ /dev/null @@ -1,138 +0,0 @@ -# Phase 4 Server Cache: Review + Simplify 結果 - -staging vs #3706 ブランチの差分に対するレビュー結果。 - -## 概要 - -`Cache.getOrFetch()` メソッドを追加し、tasks / votes / workbooks の3ドメインでサーバーサイドキャッシュを導入。DB書き込み時の明示的キャッシュ無効化も実装。構造は `.claude/rules/server-cache.md` のパターンに忠実。 - -## テスト・型チェック結果 - -- `pnpm test:unit`: 全パス(既存の flaky テスト `test_helpers.test.ts` のパフォーマンステスト1件のみ失敗 — 本変更と無関係) -- `pnpm check`: エラー 0件 - -## Critical(要対応) - -### 1. キャッシュスタンピード - -- **箇所**: `src/lib/clients/cache.ts:129-140` -- **内容**: `getOrFetch` で同一キーへの並行リクエスト時、全てが `fetchFn` を呼ぶ。in-flight の Promise を共有する仕組みがない -- **修正案**: in-flight の `Promise` を `Map>` で保持し、同一キーの並行呼び出しで共有する - -```typescript -// "inflight" is a custom Map — not a JS/TS built-in. -// Stores pending Promises so concurrent callers share one fetchFn() instead of firing N duplicates. -private inflight = new Map>(); - -async getOrFetch(key: string, fetchFn: () => Promise): Promise { - const cached = this.get(key); - - if (cached !== undefined) {return cached}; - - if (this.inflight.has(key)) {return this.inflight.get(key)!}; - - const promise = fetchFn().then( - (result) => { this.set(key, result); this.inflight.delete(key); return result; }, - (err) => { this.inflight.delete(key); throw err; }, - ); - this.inflight.set(key, promise); - return promise; -} -``` - -### 2. Vote キャッシュの無効化漏れ - -- **箇所**: `src/features/votes/services/vote_grade.ts` — `upsertVoteGradeTables()` -- **内容**: DB に書き込むが `invalidateVoteCaches()` を呼んでいない。最大10分間古いデータを返す -- **修正案**: `upsertVoteGradeTables()` の DB 書き込み成功後に `invalidateVoteCaches()` を呼ぶ - -## Medium(検討推奨) - -### 3. `getOrFetch` が `undefined` をキャッシュできない - -- **箇所**: `src/lib/clients/cache.ts:132` -- **内容**: `if (cached !== undefined)` のため `T = undefined` の場合に毎回 refetch。現在の呼び出し元では問題ないが、型レベルで `T` が無制約なので潜在的バグ -- **修正案**: `Cache` と制約するか、`return cached as T` で型アサーション -- **補足**: `{}` は TS で `null` / `undefined` 以外の全型を受け入れる型。`T extends {}` とすると `T = undefined` が禁止され、`!== undefined` ナローイング後の `T & {}` が `T` に安全に代入できるようになる([TS Handbook: Generic Constraints](https://www.typescriptlang.org/docs/handbook/2/generics.html)) - -### 4. `ContestTaskCache.getCachedOrFetch()` との重複 - -- **箇所**: `src/lib/clients/cache_strategy.ts:37-60` -- **内容**: 既存の `getCachedOrFetch()` と新しい `Cache.getOrFetch()` が同等のロジック。さらに既存側はエラーを `[] as unknown as T` で握りつぶすバグ持ち -- **修正案**: `ContestTaskCache.getCachedOrFetch()` を `Cache.getOrFetch()` に委譲するようリファクタリング - -### 5. `getAllTasksWithVoteInfo()` がキャッシュをバイパス - -- **箇所**: `src/features/votes/services/vote_statistics.ts:40-44` -- **内容**: 直接 `prisma.task.findMany()` と `prisma.votedGradeStatistics.findMany()` を呼ぶ。admin ページの鮮度要件なら意図的だが、明示的な判断が必要 -- **備考**: `/tasks/grade` は admin ページのため鮮度優先でキャッシュ不要 -- **対応方針**: 別PRで対応。変更は小規模(4-5ファイル、既存パターンの機械的適用) - - `votes/server/cache.ts` — `Cache` 追加 + `invalidate`/`dispose` 拡張 - - `votes/services/vote_statistics.ts` — `getAllTasksWithVoteInfo()` を `getCached*` でラップ - - `votes/server/cache.test.ts` — 新キャッシュのテスト追加 - - `votes/services/vote_statistics.test.ts` — mock 更新 - - `src/lib/services/tasks.ts` — `updateTask()` に `invalidateVoteCaches()` 追加(grade 変更時の整合性) -- **参照**: `docs/dev-notes/2026-06-13/sveltekit-caching/plan.md`「votes 一覧」 - -### 6. `placementCache` の maxSize 不足 - -- **箇所**: `src/features/workbooks/server/cache.ts:9` -- **内容**: TaskGrade(~18) × 2(published/unpublished) + SolutionCategory(~15) × 2 = ~66 キーで、デフォルト maxSize=50 を超える可能性 -- **修正案**: `new Cache(HOUR_MS, 100)` のように maxSize を引き上げ - -## Low(許容範囲) - -### 7. 3ドメインのキャッシュモジュールが同一パターン - -- 各20-40行で小さく、共通ファクトリへの抽出は過剰抽象。現状で可 - -### 8. `invalidateWorkbookCaches()` が `clear()` で全消し - -- 動的複合キー(`CURRICULUM:Q7:false` 等)のため `delete(key)` で個別削除が不可能。正当な判断 - -### 9. 5つの `setInterval` タイマー - -- 軽量で低頻度(1時間 or 10分間隔)。テスト時は `dispose*Caches()` + `afterAll` で適切にクリーンアップ済み。実害なし - -### 10. キャッシュキーが文字列リテラル - -- 各 Cache インスタンスがスコープ限定されており、衝突リスクなし - -## `cache.test.ts` 型エラー調査 - -### 現象 - -- `pnpm check`(svelte-check)ではエラー 0件 -- `tsc --noEmit` や `vitest --typecheck` で発現する可能性あり - -### 最有力候補: `T & {}` 代入不可 - -- **箇所**: `src/lib/clients/cache.ts:133` — `return cached;` -- **原因**: TypeScript 5.4+ で `T | undefined` を `!== undefined` で絞ると `T & {}` になるが、無制約な `T` に対して `T & {}` は `T` に代入可能と証明できない - -``` -Type 'T & {}' is not assignable to type 'T'. - 'T & {}' is assignable to the constraint of type 'T', but 'T' could be instantiated - with a different subtype of constraint '{}'. -``` - -### 修正案 - -```typescript -// Option A: T を制約する -export class Cache { ... } - -// Option B: 型アサーション -if (cached !== undefined) { - return cached as T; -} -``` - -## 優先順位まとめ - -1. **キャッシュスタンピード対策** — in-flight Promise の共有(Critical #1) -2. **Vote 書き込み時の `invalidateVoteCaches()` 追加** — データ整合性(Critical #2) -3. **`placementCache` の maxSize 引き上げ** — 簡単な修正(Medium #6) -4. **`getOrFetch` の型安全性改善** — `T extends {}` 制約(Medium #3) -5. **`ContestTaskCache` のリファクタリング** — 重複排除 + バグ修正(Medium #4) -6. **`getAllTasksWithVoteInfo()` のキャッシュ方針決定** — 設計判断(Medium #5) diff --git a/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md b/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md deleted file mode 100644 index 7ef27fb02..000000000 --- a/docs/dev-notes/2026-06-17/phase4-server-cache/survey.md +++ /dev/null @@ -1,73 +0,0 @@ -# Phase 4:調査・前提条件・Fluid Compute 検討 - -> 本ドキュメントは [plan.md](./plan.md) の背景資料。実装手順は plan.md、設計判断は [design.md](./design.md) を参照。 - -## 背景 - -Phase 1〜3 で過剰取得・匿名キャッシュの最適化を完了。残る課題はサーバープロセス内での DB 再取得コスト。共有かつ低頻度更新のデータ(タスク全件、問題集一覧、投票統計)は warm インスタンス内でキャッシュが有効に働く。 - -## 前提条件と制約 - -**トラフィック規模(2026年5月実績):** - -- 月3.8万PV、日ユーザー200-300人 -- Function Invocations: 月236k(日約8k、特異日で54k/日を記録) -- Function Duration: 84GB hrs / Fast Origin Transfer: 35GB -- ピーク時間帯: 昼12:00、夕方18:00、夜20:00 に集中 - -**プロセス内キャッシュの特性:** - -- Vercel サーバーレス関数の warm インスタンス内でのみ有効。インスタンス間のメモリ共有はない -- コールドスタート時はキャッシュ空から開始。TTL はインスタンス生存時間(Vercel 側制御、通常数分〜十数分)が上限 -- ピーク時間帯はリクエスト間隔が短く warm 維持が見込める。オフピーク時はキャッシュヒット率が低下する - -**Phase 4 の効果範囲:** - -| route | 日リクエスト | Phase 4 対象関数 | 備考 | -| --------------------------------------------- | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `/problems` | ~2k | `getMergedTasksMap()` + `getVoteGradeStatistics()` | 匿名ユーザーは CDN (`s-maxage=300`) でカバー済み。Phase 4 はログインユーザーのみに効く | -| `/workbooks` (一覧) | 不明 | `getWorkbooksByPlacement()` + `getWorkBooksCreatedByUsers()` | | -| `/workbooks/create`, `/workbooks/edit/[slug]` | 低頻度 | `getTasksByTaskId()` | 管理者のみ | -| `/workbooks/[slug]` | ~2k | **対象外** | 個別取得(`getWorkbookWithAuthor` + `getVoteGradeStatisticsForTaskIds`)のため全件キャッシュは効かない | - ---- - -## Fluid Compute の有効化を推奨 - -現在レガシーサーバーレスモデル(1インスタンス1リクエスト)を使用しているが、Fluid Compute への切り替えを推奨する。 - -- **課金面:** レガシーは wall-clock time × メモリで課金され、DB クエリの I/O 待ち時間も全額課金される。Fluid Compute は Active CPU(実際のコード実行時間のみ)+ Provisioned Memory で課金され、I/O 待ち中は CPU 課金が止まる。DB クエリ中心のこのアプリは I/O 比率が高く、コスト削減効果が大きい([Fluid compute pricing](https://vercel.com/docs/functions/usage-and-pricing)、[Legacy pricing](https://vercel.com/docs/functions/usage-and-pricing/legacy-pricing)) -- **キャッシュ効果:** Fluid Compute の optimized concurrency により、複数リクエストが同一インスタンスを共有する。レガシーでは高トラフィック時にインスタンスが増えてキャッシュが分散するが、Fluid Compute ではインスタンス集約によりキャッシュヒット率が向上する([Fluid compute docs](https://vercel.com/docs/fluid-compute)) -- **並行性リスク:** モジュールスコープの変数が並行リクエスト間で共有されるため、リクエスト固有の値をグローバルに書き込むコードは危険。ただし Phase 4 のキャッシュは read-heavy かつ冪等(同じ fetchFn 結果の書き込み)なので並行性の問題は起きない([Vercel Fluid Compute Guide](https://getautonoma.com/blog/vercel-fluid-compute)) -- **切り替え:** プロジェクト設定 > Functions > Fluid Compute をトグル → Save → 再デプロイで反映。コード変更不要。2025年4月以降の新規プロジェクトではデフォルト有効([What is Compute?](https://vercel.com/docs/functions/concepts)) - -### コスト試算(2026年5月実績ベース) - -前提:Function Duration 84 GB-hrs、Invocations 236k、メモリ 3008MB(≒2.94GB)、リージョン hnd1(東京)。DB クエリ中心のため Active CPU 比率は wall-clock の 25-40% と推定。wall-clock = 84 GB-hrs ÷ 2.94GB ≒ 28.6時間。 - -| モデル | Function Duration | Invocations | 月額推定 | レガシー比 | -| ---------------- | ---------------------------------------------------- | ----------------------- | ---------- | ---------- | -| レガシー(現在) | 84 GB-hrs × $0.18 = $15.12 | 236k × $0.60/1M = $0.14 | **$15.26** | 100% | -| Fluid(CPU 40%) | CPU 11.4h × $0.202 + Mem 84 GB-hrs × $0.0167 = $3.71 | $0.14 | **$3.85** | 25% | -| Fluid(CPU 25%) | CPU 7.2h × $0.202 + Mem 84 GB-hrs × $0.0167 = $2.86 | $0.14 | **$3.00** | 20% | - -東京リージョン(hnd1)は Active CPU $0.202/hr、Provisioned Memory $0.0167/GB-hr。メモリ 3GB ではレガシーの GB-hrs が wall-clock の 3 倍で積まれるのに対し、Fluid Compute は Provisioned Memory 単価が大幅に安い($0.18 vs $0.0167/GB-hr)。**月 $11-12 程度(75-80%)の削減が見込める。** さらに optimized concurrency によるインスタンス集約で Provisioned Memory の実 GB-hrs も下がる可能性がある。 - -### Fluid Compute FAQ - -- **Q: warm インスタンスのキャッシュは同一ユーザーでのみ有効?** - A: ユーザー単位ではなくリクエスト単位。同じインスタンスに振られたリクエストはユーザーが異なっても同じモジュールスコープの変数を共有する。Vercel 公式もプロセス内キャッシュの活用を推奨している([What is Compute?](https://vercel.com/docs/functions/concepts)) -- **Q: コールドスタートは発生する?** - A: 発生する。ただし optimized concurrency でインスタンス数が減るため頻度は下がる。Pro プランの本番環境では pre-warmed instances により最低1インスタンスが warm 維持される([Fluid compute docs](https://vercel.com/docs/fluid-compute)) -- **Q: 1インスタンスにリクエストが集中してメモリが爆増しない?** - A: Vercel がインスタンスの capacity を監視し、余裕がある場合のみ同一インスタンスにルーティングする。容量不足時は自動で新インスタンスを起動する。Phase 4 のキャッシュはモジュールスコープに1つなので、並行リクエスト数が増えてもキャッシュのメモリ消費は増えない([Fluid compute docs](https://vercel.com/docs/fluid-compute)) -- **Q: リクエスト固有の値をグローバルに書き込むコードはある?(並行性安全性の調査)** - A: プロジェクト全体を調査済み。ユーザーID・セッション等をモジュールスコープに格納するコードはなく、リクエスト間のデータ汚染リスクはない。top-level await が2箇所あるが、いずれもマスタデータの読み取り専用で実害なし: - - `src/lib/services/task_results.ts:32-33` — `statusById` / `statusByName`(提出ステータス定義、変更頻度極低) - - `src/routes/problems/[slug]/+page.server.ts:9` — `buttons`(UI ボタン定義、変更頻度極低) - - これらはレガシーモデルでも warm インスタンス内で同じデータが使い回されており、Fluid Compute 固有の問題ではない。将来的にリクエストスコープへの移動を検討してもよいが Phase 4 のスコープ外。 - -### 結論 - -ユーザー数に比して Function Duration・転送量が大きく、削減の余地はある。ピーク集中型のトラフィックパターンから warm インスタンスでのキャッシュヒットが見込めるため、実装コストに見合う効果が期待できる。特異日(54k/日)のような突発的なトラフィック増加時は、キャッシュなしでは全リクエストが DB を叩くためプロセス内キャッシュの効果が最も大きくなる。Fluid Compute を有効にすることでキャッシュヒット率がさらに向上し、課金も最適化される。ただし `/workbooks/[slug]`(日2k)には効かない点に注意。 From 40aae4c3c53f2fdabbf73d4b7031b841fd83d6ea Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 02:58:27 +0000 Subject: [PATCH 7/7] refactor(cache): consolidate inflight cleanup into .finally .finally transparently re-throws on rejection, so error propagation is preserved while removing the duplicate inflight.delete call. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/clients/cache.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/lib/clients/cache.ts b/src/lib/clients/cache.ts index 6dfb3a880..528eee3f5 100644 --- a/src/lib/clients/cache.ts +++ b/src/lib/clients/cache.ts @@ -95,17 +95,14 @@ export class Cache { return pending; } - const promise = fetchFn().then( - (result) => { + const promise = fetchFn() + .then((result) => { this.set(key, result); - this.inflight.delete(key); return result; - }, - (error) => { + }) + .finally(() => { this.inflight.delete(key); - throw error; - }, - ); + }); this.inflight.set(key, promise);