From 387de946388b9bbbc8d445b14d2888694b3eec1c Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 03:53:43 +0000 Subject: [PATCH 1/3] feat(votes): cache getAllTasksWithVoteInfo and add CDN cache-control for anonymous requests - Add getCachedAllTasksWithVoteInfo() to votes server cache (10min TTL) - Extend invalidateVoteCaches() to clear the new cache entry - Wrap getAllTasksWithVoteInfo() in the cache in vote_statistics.ts - Invalidate vote caches on createTask() / updateTask() (task grade affects estimatedGrade) - Set public s-maxage=300 stale-while-revalidate=600 for anonymous /votes responses - Skip CDN header on degraded responses to avoid pinning broken pages at CDN - Add page_server.test.ts covering header and data return for anonymous/logged-in/degraded cases Co-Authored-By: Claude Sonnet 4.6 --- .../votes-cache-optimization/plan.md | 97 +++++++++++++++++ src/features/votes/server/cache.test.ts | 49 ++++++++- src/features/votes/server/cache.ts | 23 +++- .../votes/services/vote_statistics.test.ts | 1 + .../votes/services/vote_statistics.ts | 7 +- src/lib/services/tasks.ts | 4 + src/routes/votes/+page.server.ts | 17 ++- src/routes/votes/page_server.test.ts | 101 ++++++++++++++++++ src/test/lib/services/tasks.test.ts | 29 +++++ 9 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md create mode 100644 src/routes/votes/page_server.test.ts diff --git a/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md b/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md new file mode 100644 index 000000000..5c355c958 --- /dev/null +++ b/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md @@ -0,0 +1,97 @@ +# votes 一覧のキャッシュ最適化 + +## 概要 + +`/votes` の load は毎回 **全タスク(本番≈9000件)+ 全 stats + 全 counters** を 3 テーブルから取得・結合して SSR に載せている。UI 上は初期表示 0 件、検索しても最大 20 件のみ表示。データは全員共有(個人の解答状態・投票を含まない)。 + +**方針 B**(`docs/dev-notes/2026-06-13/sveltekit-caching/plan.md` で定義)を採用: + +- サーバー側キャッシュ: `getAllTasksWithVoteInfo()` の結果を丸ごとキャッシュ +- CDN キャッシュ: 匿名時のみ `s-maxage` を付与し、bot/匿名アクセスで関数起動を省略 + +クライアント側の即時検索 UX は維持。全件転送(≈9000 件)は毎回発生し続けるが、DB クエリの Duration はキャッシュで削減。匿名時は CDN キャッシュで関数起動自体を省略。 + +## 設計根拠 + +### 方針 A(サーバー検索エンドポイント化)を採用しない理由 + +方針 A は Duration と Transfer 両方を最大削減できるが、検索のたびにサーバーリクエストが発生し、現在のクライアント側即時検索の UX が損なわれる。ユーザー判断により UX 維持を優先。 + +### アプローチ選択: 単一キャッシュ vs 構成要素別キャッシュ + +- **単一キャッシュ(採用)**: `getAllTasksWithVoteInfo()` の結果(`TaskWithVoteInfo[]`)を丸ごとキャッシュ。シンプルで、plan の影響範囲表と整合。 +- **構成要素別キャッシュ(却下)**: 既存の tasks/stats キャッシュを再利用し counters のみ新規追加。`getAllTasksWithVoteInfo` は 7 カラムの projection + 結合ロジックなので、全カラムの既存キャッシュとは型が合わず不自然。 + +### キャッシュ対象データの安全性 + +`TaskWithVoteInfo` は以下の共有データのみ含む(個人データなし): + +- `task_id`, `contest_id`, `title`, `grade`, `task_table_index` — タスクの基本情報 +- `estimatedGrade` — 全ユーザーの投票から算出された中央値 +- `voteTotal` — 投票の合計数 + +個人の回答状況(`taskAnswer`)や個人の投票(`voteGrade`)は一切含まれない。キャッシュしても他ユーザーの個人データが漏洩するリスクはゼロ。 + +### ログイン/匿名の表示差異 + +現在 votes 一覧はログイン/匿名で同一表示だが、将来ログイン時に差分表示(投票ボタン等)を追加する可能性がある。CDN キャッシュは匿名時のみに限定する。 + +## Phase 1: サーバーキャッシュ + +### 変更ファイル + +| ファイル | 変更内容 | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/features/votes/server/cache.ts` | `Cache` インスタンス追加。`getCachedAllTasksWithVoteInfo()` ラッパー。`invalidateVoteCaches()` / `disposeVoteCaches()` 拡張 | +| `src/features/votes/services/vote_statistics.ts` | `getAllTasksWithVoteInfo()` を `getCachedAllTasksWithVoteInfo()` でラップ | +| `src/lib/services/tasks.ts` | `createTask()` / `updateTask()` に `invalidateVoteCaches()` 追加 | +| `src/features/votes/server/cache.test.ts` | 新キャッシュのワイヤリング・invalidation テスト追加 | +| `src/features/votes/services/vote_statistics.test.ts` | mock 更新 | + +### キャッシュ仕様 + +- TTL: 10 分(既存の vote stats キャッシュと同じ。投票データの更新頻度に合わせる) +- キー: 固定文字列(全タスク対象で引数なし) +- Stampede 防止: `Cache.getOrFetch()` の in-flight 共有で自動対応 + +### Invalidation 経路 + +| 書き込み操作 | invalidation | +| --------------------------- | --------------------------------------------------------------------------- | +| `upsertVoteGradeTables()` | 既存の `invalidateVoteCaches()` が拡張されるので自動的にカバー | +| `updateTask()` (grade 変更) | `invalidateVoteCaches()` を追加 | +| `createTask()` | `invalidateVoteCaches()` を追加(新規タスクが検索にヒットしない状態を防止) | + +### テスト + +- `cache.test.ts`: ワイヤリング(`getCachedAllTasksWithVoteInfo` が fetchFn を委譲し、2 回目はキャッシュヒット)、invalidation グルーピング(`invalidateVoteCaches()` で新キャッシュもクリアされる) +- `vote_statistics.test.ts`: mock 更新(`getCachedAllTasksWithVoteInfo` を passthrough mock) + +## Phase 2: CDN キャッシュ(匿名時) + +### 変更ファイル + +| ファイル | 変更内容 | +| -------------------------------------- | ------------------------------------------------------ | +| `src/routes/votes/+page.server.ts` | 匿名時に `setHeaders({ 'cache-control': '...' })` 追加 | +| `src/routes/votes/page_server.test.ts` | 新規。load の匿名/ログイン時のヘッダー検証 | + +### CDN キャッシュ仕様 + +- 匿名時(`session === null`)のみ: `public, max-age=0, s-maxage=300, stale-while-revalidate=600` +- ログイン時: `setHeaders` を呼ばない(将来のログイン時差分表示に備える) +- degraded フラグは不要(votes の load に try/catch がなく、例外は SvelteKit のエラーハンドリングに委ねる) + +### テスト + +Phase 3(`/problems`)で確立したパターンに倣う: + +1. 匿名時に cache-control ヘッダーが設定される +2. ログイン時に cache-control ヘッダーが設定されない +3. 正しいデータ(tasks, isLoggedIn)を返す + +## 検証 + +- `pnpm test:unit` で各テストが通ること +- ローカルで匿名/ログインのレスポンスヘッダー確認 +- デプロイ後、Vercel ダッシュボードで Duration / Fast Origin Transfer を観測 diff --git a/src/features/votes/server/cache.test.ts b/src/features/votes/server/cache.test.ts index adf2a92f3..a798b0767 100644 --- a/src/features/votes/server/cache.test.ts +++ b/src/features/votes/server/cache.test.ts @@ -2,7 +2,26 @@ import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vit import type { VotedGradeStatistics } from '@prisma/client'; import { TaskGrade } from '$lib/types/task'; -import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache'; +import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics'; +import { + getCachedVoteStats, + getCachedAllTasksWithVoteInfo, + invalidateVoteCaches, + disposeVoteCaches, +} from './cache'; + +const makeTasksWithVoteInfo = (): TaskWithVoteInfo[] => [ + { + task_id: 'abc408_d', + contest_id: 'abc408', + title: 'D - Flip Cards', + grade: TaskGrade.PENDING as unknown as import('@prisma/client').TaskGrade, + task_table_index: 'D', + estimatedGrade: TaskGrade.Q1 as unknown as import('@prisma/client').TaskGrade, + voteTotal: 12, + }, +]; +const mockTasksFn = () => vi.fn().mockResolvedValue(makeTasksWithVoteInfo()); const makeStats = (): Map => new Map([ @@ -41,6 +60,26 @@ describe('getCachedVoteStats', () => { }); }); +describe('getCachedAllTasksWithVoteInfo', () => { + beforeEach(() => invalidateVoteCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to fetchFn and returns fetched value', async () => { + const fetchFn = mockTasksFn(); + const result = await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result[0].task_id).toBe('abc408_d'); + expect(result[0].voteTotal).toBe(12); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockTasksFn(); + await getCachedAllTasksWithVoteInfo(fetchFn); + await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + describe('invalidateVoteCaches', () => { beforeEach(() => invalidateVoteCaches()); afterEach(() => vi.restoreAllMocks()); @@ -52,4 +91,12 @@ describe('invalidateVoteCaches', () => { await getCachedVoteStats(fetchFn); expect(fetchFn).toHaveBeenCalledTimes(2); }); + + test('clears all tasks with vote info cache', async () => { + const fetchFn = mockTasksFn(); + await getCachedAllTasksWithVoteInfo(fetchFn); + invalidateVoteCaches(); + await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/features/votes/server/cache.ts b/src/features/votes/server/cache.ts index 1b82831fe..ebecf1030 100644 --- a/src/features/votes/server/cache.ts +++ b/src/features/votes/server/cache.ts @@ -1,21 +1,34 @@ import { Cache } from '$lib/clients/cache'; + import type { VotedGradeStatistics } from '@prisma/client'; +import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics'; + const VOTE_STATS_TTL_MS = 10 * 60 * 1000; -const KEY = 'vote_grade_statistics'; +const VOTE_STATS_KEY = 'vote_grade_statistics'; +const ALL_TASKS_WITH_VOTE_INFO_KEY = 'all_tasks_with_vote_info'; -const cache = new Cache>(VOTE_STATS_TTL_MS); +const voteStatsCache = new Cache>(VOTE_STATS_TTL_MS); +const allTasksWithVoteInfoCache = new Cache(VOTE_STATS_TTL_MS); export function getCachedVoteStats( fetchFn: () => Promise>, ): Promise> { - return cache.getOrFetch(KEY, fetchFn); + return voteStatsCache.getOrFetch(VOTE_STATS_KEY, fetchFn); +} + +export function getCachedAllTasksWithVoteInfo( + fetchFn: () => Promise, +): Promise { + return allTasksWithVoteInfoCache.getOrFetch(ALL_TASKS_WITH_VOTE_INFO_KEY, fetchFn); } export function invalidateVoteCaches(): void { - cache.delete(KEY); + voteStatsCache.delete(VOTE_STATS_KEY); + allTasksWithVoteInfoCache.delete(ALL_TASKS_WITH_VOTE_INFO_KEY); } export function disposeVoteCaches(): void { - cache.dispose(); + voteStatsCache.dispose(); + allTasksWithVoteInfoCache.dispose(); } diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index 22c6b51dd..729dddeb2 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -32,6 +32,7 @@ vi.mock('$lib/server/database', () => ({ vi.mock('$features/votes/server/cache', () => ({ getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), + getCachedAllTasksWithVoteInfo: (fetchFn: () => Promise) => fetchFn(), })); import prisma from '$lib/server/database'; diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index 83898ca62..60db5299f 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -1,5 +1,5 @@ import { default as prisma } from '$lib/server/database'; -import { getCachedVoteStats } from '$features/votes/server/cache'; +import { getCachedVoteStats, getCachedAllTasksWithVoteInfo } from '$features/votes/server/cache'; import type { VotedGradeStatistics, VotedGradeCounter, TaskGrade } from '@prisma/client'; @@ -37,6 +37,10 @@ export async function getVoteGradeStatisticsForTaskIds( } export async function getAllTasksWithVoteInfo(): Promise { + return getCachedAllTasksWithVoteInfo(fetchAllTasksWithVoteInfo); +} + +async function fetchAllTasksWithVoteInfo(): Promise { const [allTasks, stats, counters] = await Promise.all([ prisma.task.findMany({ orderBy: { task_id: 'desc' } }), prisma.votedGradeStatistics.findMany(), @@ -45,6 +49,7 @@ export async function getAllTasksWithVoteInfo(): Promise { const statsMap = new Map(stats.map((s) => [s.taskId, s])); const totalsMap = new Map(); + for (const c of counters) { totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count); } diff --git a/src/lib/services/tasks.ts b/src/lib/services/tasks.ts index e4ddc99df..81a8017e6 100644 --- a/src/lib/services/tasks.ts +++ b/src/lib/services/tasks.ts @@ -16,6 +16,8 @@ import { getCachedMergedTasksMap, invalidateTaskCaches, } from '$lib/server/tasks/cache'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; + import { classifyContest } from '$lib/utils/contest'; import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; @@ -189,6 +191,7 @@ export async function createTask( }); invalidateTaskCaches(); + invalidateVoteCaches(); console.log(task); } @@ -210,6 +213,7 @@ export async function updateTask(task_id: string, task_grade: TaskGrade): Promis }); invalidateTaskCaches(); + invalidateVoteCaches(); console.log(task); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts index 8633f1238..a7c859a10 100644 --- a/src/routes/votes/+page.server.ts +++ b/src/routes/votes/+page.server.ts @@ -1,9 +1,22 @@ import type { PageServerLoad } from './$types'; import { getAllTasksWithVoteInfo } from '$features/votes/services/vote_statistics'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, setHeaders }) => { const session = await locals.auth.validate(); - const tasks = await getAllTasksWithVoteInfo(); + + let tasks: Awaited> = []; + let dataOk = true; + + try { + tasks = await getAllTasksWithVoteInfo(); + } catch (error) { + dataOk = false; + console.error('Failed to load tasks with vote info:', error); + } + + if (session === null && dataOk) { + setHeaders({ 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + } return { tasks, diff --git a/src/routes/votes/page_server.test.ts b/src/routes/votes/page_server.test.ts new file mode 100644 index 000000000..ffd5b091d --- /dev/null +++ b/src/routes/votes/page_server.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { Roles } from '$lib/types/user'; + +vi.mock('$features/votes/services/vote_statistics', () => ({ + getAllTasksWithVoteInfo: vi.fn(), +})); + +import * as voteStatsModule from '$features/votes/services/vote_statistics'; +import { load } from './+page.server'; + +const mockGetAllTasksWithVoteInfo = vi.mocked(voteStatsModule.getAllTasksWithVoteInfo); + +type MockSession = { user: { userId: string; username: string; role: Roles } } | null; + +const createMockEvent = ({ session = null }: { session?: MockSession } = {}) => { + const setHeaders = vi.fn(); + const locals = { + auth: { validate: vi.fn().mockResolvedValue(session) }, + }; + + return { locals, setHeaders } as unknown as Parameters[0] & { + setHeaders: ReturnType; + }; +}; + +const LOGGED_IN_SESSION: MockSession = { + user: { userId: 'user-abc123', username: 'testuser', role: Roles.USER }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); +}); + +describe('load() cache-control behaviour', () => { + describe('sets cache-control', () => { + test('anonymous users get a public shared-cache header when data fetch succeeds', async () => { + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).toHaveBeenCalledOnce(); + const headerArg = event.setHeaders.mock.calls[0][0] as Record; + expect(headerArg['Cache-Control']).toBe( + 'public, max-age=0, s-maxage=300, stale-while-revalidate=600', + ); + }); + }); + + describe('does not set cache-control', () => { + test('logged-in users — personalized response must never be shared-cached', async () => { + const event = createMockEvent({ session: LOGGED_IN_SESSION }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + + test('degraded response when data fetch fails — avoids pinning a broken page at the CDN', async () => { + mockGetAllTasksWithVoteInfo.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + }); +}); + +describe('load() return data', () => { + test('returns tasks and isLoggedIn for anonymous users', async () => { + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); + const event = createMockEvent({ session: null }); + + const result = await load(event); + + expect(result.tasks).toEqual([]); + expect(result.isLoggedIn).toBe(false); + }); + + test('returns tasks and isLoggedIn for logged-in users', async () => { + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); + const event = createMockEvent({ session: LOGGED_IN_SESSION }); + + const result = await load(event); + + expect(result.tasks).toEqual([]); + expect(result.isLoggedIn).toBe(true); + }); + + test('returns empty tasks array when data fetch fails (degraded)', async () => { + mockGetAllTasksWithVoteInfo.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + const result = await load(event); + + expect(result.tasks).toEqual([]); + expect(result.isLoggedIn).toBe(false); + }); +}); diff --git a/src/test/lib/services/tasks.test.ts b/src/test/lib/services/tasks.test.ts index 62427f247..e9e65012e 100644 --- a/src/test/lib/services/tasks.test.ts +++ b/src/test/lib/services/tasks.test.ts @@ -19,7 +19,12 @@ vi.mock('$lib/server/tasks/cache', () => ({ invalidateTaskCaches: vi.fn(), })); +vi.mock('$features/votes/server/cache', () => ({ + invalidateVoteCaches: vi.fn(), +})); + import db from '$lib/server/database'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; describe('updateTask', () => { const mockDb = db as unknown as { task: { update: ReturnType } }; @@ -44,6 +49,18 @@ describe('updateTask', () => { data: { grade: TaskGrade.Q2 }, }); }); + + test('invalidates vote caches after successful update', async () => { + mockDb.task.update.mockResolvedValue({ + id: '1', + task_id: 'abc450_d', + grade: TaskGrade.Q2, + }); + + await updateTask('abc450_d', TaskGrade.Q2); + + expect(invalidateVoteCaches).toHaveBeenCalledOnce(); + }); }); describe('error cases', () => { @@ -60,6 +77,18 @@ describe('updateTask', () => { expect(result).toBeNull(); }); + test('does not invalidate vote caches when task is not found (P2025)', async () => { + const error = new Prisma.PrismaClientKnownRequestError('Record to update not found.', { + code: 'P2025', + clientVersion: '5.0.0', + }); + mockDb.task.update.mockRejectedValue(error); + + await updateTask('nonexistent_task', TaskGrade.Q1); + + expect(invalidateVoteCaches).not.toHaveBeenCalled(); + }); + test('re-throws non-P2025 errors', async () => { const error = new Error('Database connection error'); mockDb.task.update.mockRejectedValue(error); From c228997e6cf7fcb3bcea6c61ddc2e49521d950e7 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 04:58:09 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(votes):=20clean=20up=20type=20casts,=20?= =?UTF-8?q?rename=20dataOk=E2=86=92fetchFailed,=20add=20review=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `as unknown as import('@prisma/client').TaskGrade` casts in cache.test.ts (TaskGrade from '$lib/types/task' is directly assignable) - Rename dataOk → fetchFailed and invert condition for positive-default readability - Simplify load() type annotation: Awaited> → TaskWithVoteInfo[] - Add result!. non-null assertions in page_server.test.ts to satisfy SvelteKit's void | PageData inference on load() return type - Add review.md summarising findings (0 critical/high, 5 nit fixes applied) Co-Authored-By: Claude Sonnet 4.6 --- .../votes-cache-optimization/review.md | 99 +++++++++++++++++++ src/features/votes/server/cache.test.ts | 4 +- src/routes/votes/+page.server.ts | 13 ++- src/routes/votes/page_server.test.ts | 15 +-- 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 docs/dev-notes/2026-06-21/votes-cache-optimization/review.md diff --git a/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md b/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md new file mode 100644 index 000000000..c6e6f7868 --- /dev/null +++ b/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md @@ -0,0 +1,99 @@ +# votes-cache-optimization レビュー + +staging..#3708 の差分に対するコードレビュー + simplify + 型エラー分析。 + +## 変更概要 + +| ファイル | 内容 | +|---|---| +| `src/features/votes/server/cache.ts` | `allTasksWithVoteInfoCache` 追加、`invalidateVoteCaches` / `disposeVoteCaches` 拡張 | +| `src/features/votes/services/vote_statistics.ts` | `getAllTasksWithVoteInfo()` を `getCachedAllTasksWithVoteInfo` でラップ、内部関数 `fetchAllTasksWithVoteInfo` 抽出 | +| `src/lib/services/tasks.ts` | `createTask()` / `updateTask()` に `invalidateVoteCaches()` 追加 | +| `src/routes/votes/+page.server.ts` | try-catch + `fetchFailed` フラグ + 匿名時 CDN cache-control | +| `src/routes/votes/page_server.test.ts` | 新規。load() のキャッシュヘッダーとデータ返却のテスト | +| `src/features/votes/server/cache.test.ts` | `getCachedAllTasksWithVoteInfo` のワイヤリング・invalidation テスト追加 | +| `src/features/votes/services/vote_statistics.test.ts` | mock に `getCachedAllTasksWithVoteInfo` 追加 | +| `src/test/lib/services/tasks.test.ts` | `invalidateVoteCaches` の呼び出し検証テスト追加 | + +## コードレビュー + +### Good + +1. **cache.ts の設計が既存パターンと一貫**: `voteStatsCache` のリネーム、`allTasksWithVoteInfoCache` の追加、`invalidateVoteCaches` / `disposeVoteCaches` の拡張がすべて server-cache ルールに準拠。 +2. **Invalidation 経路の網羅**: `createTask()` と `updateTask()` の両方に `invalidateVoteCaches()` を追加し、`updateTask` の P2025 パスでは呼ばない設計が正しい。テストでも検証済み。 +3. **テストカバレッジ**: cache のワイヤリング、invalidation グルーピング、route の匿名/ログイン/degraded の 3 パターンすべてカバー。 +4. **CDN キャッシュの匿名限定**: `/problems` と同じパターンで、ログイン時に setHeaders を呼ばない設計。 + +### Issues + +#### Critical + +なし。 + +#### High + +なし(型エラーは修正済み)。 + +#### Medium + +なし(以下すべて修正済み)。 + +#### 修正済み + +1. **型エラー: `page_server.test.ts`** — `result!` の assert スタイルで解消(`pnpm check` 0 errors) +2. **plan.md との乖離: try-catch + degraded** — 実装が意図通り。plan のほうを実態に合わせて更新が必要 +3. **`cache.test.ts` のキャスト** — `as unknown as import('@prisma/client').TaskGrade` を削除、`$lib/types/task` の `TaskGrade` をそのまま使用 +4. **`+page.server.ts` の `dataOk` → `fetchFailed`** — 逆条件に変更(`if (!session && !fetchFailed)`) +5. **型注釈の簡略化** — `Awaited>` → `TaskWithVoteInfo[]` + +## Simplify 分析 + +### 既存コードとの再利用 + +- `cache.ts` のパターンは `$lib/server/tasks/cache.ts` と完全に同型。Cache の汎用性がうまく活かされている。追加の抽象は不要。 +- `vote_statistics.ts` の `fetchAllTasksWithVoteInfo` 抽出は適切。`getCachedAllTasksWithVoteInfo` にラムダを渡す代わりに名前付き関数を使う方が可読性が高い。 + +### 品質・効率 + +- cache.ts: 問題なし。2 つのキャッシュインスタンスが同一 TTL を共有しているのは意図的(同じドメイン)。 +- invalidation: `createTask` / `updateTask` の両方でカバーされており、漏れなし。 +- テスト: 各レイヤーで適切なモックが設定されており、テスト間の依存もない。 + +## 型エラー分析 + +### エラー内容 + +``` +src/routes/votes/page_server.test.ts:78:19 + Property 'tasks' does not exist on type 'void | (Omit> & ...)' +``` + +6 箇所すべて同一原因。line 78, 79, 88, 89, 98, 99。 + +### 原因 + +SvelteKit の `PageServerLoad` 型は `load()` の戻り値を `void | PageData` として推論する。`void` は `load()` が明示的に `return` しないパスが型システム上存在しうることを示す。 + +テストで `const result = await load(event)` とすると、`result` の型が `void | PageData` になり、`result.tasks` に直接アクセスできない。 + +### 修正 + +assert スタイルで解消: + +```typescript +const result = await load(event); +expect(result).toBeDefined(); +expect(result!.tasks).toEqual([]); +expect(result!.isLoggedIn).toBe(false); +``` + +## まとめ + +| 区分 | 件数 | 対応 | +|---|---|---| +| Critical | 0 | - | +| High | 0 | 型エラー修正済み | +| Medium | 0 | cache.test.ts キャスト統一済み | +| 修正済み | 5 | 型エラー、キャスト、fetchFailed 逆条件、型注釈簡略化、plan 乖離は意図通り | + +テスト: 全 77 ファイル pass(2637 tests passed)。型チェック: 0 errors。 diff --git a/src/features/votes/server/cache.test.ts b/src/features/votes/server/cache.test.ts index a798b0767..7775076ca 100644 --- a/src/features/votes/server/cache.test.ts +++ b/src/features/votes/server/cache.test.ts @@ -15,9 +15,9 @@ const makeTasksWithVoteInfo = (): TaskWithVoteInfo[] => [ task_id: 'abc408_d', contest_id: 'abc408', title: 'D - Flip Cards', - grade: TaskGrade.PENDING as unknown as import('@prisma/client').TaskGrade, + grade: TaskGrade.PENDING, task_table_index: 'D', - estimatedGrade: TaskGrade.Q1 as unknown as import('@prisma/client').TaskGrade, + estimatedGrade: TaskGrade.Q1, voteTotal: 12, }, ]; diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts index a7c859a10..7a3b551b1 100644 --- a/src/routes/votes/+page.server.ts +++ b/src/routes/votes/+page.server.ts @@ -1,20 +1,23 @@ import type { PageServerLoad } from './$types'; -import { getAllTasksWithVoteInfo } from '$features/votes/services/vote_statistics'; +import { + getAllTasksWithVoteInfo, + type TaskWithVoteInfo, +} from '$features/votes/services/vote_statistics'; export const load: PageServerLoad = async ({ locals, setHeaders }) => { const session = await locals.auth.validate(); - let tasks: Awaited> = []; - let dataOk = true; + let tasks: TaskWithVoteInfo[] = []; + let fetchFailed = false; try { tasks = await getAllTasksWithVoteInfo(); } catch (error) { - dataOk = false; + fetchFailed = true; console.error('Failed to load tasks with vote info:', error); } - if (session === null && dataOk) { + if (session === null && !fetchFailed) { setHeaders({ 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); } diff --git a/src/routes/votes/page_server.test.ts b/src/routes/votes/page_server.test.ts index ffd5b091d..b7d9a1767 100644 --- a/src/routes/votes/page_server.test.ts +++ b/src/routes/votes/page_server.test.ts @@ -75,8 +75,9 @@ describe('load() return data', () => { const result = await load(event); - expect(result.tasks).toEqual([]); - expect(result.isLoggedIn).toBe(false); + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(false); }); test('returns tasks and isLoggedIn for logged-in users', async () => { @@ -85,8 +86,9 @@ describe('load() return data', () => { const result = await load(event); - expect(result.tasks).toEqual([]); - expect(result.isLoggedIn).toBe(true); + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(true); }); test('returns empty tasks array when data fetch fails (degraded)', async () => { @@ -95,7 +97,8 @@ describe('load() return data', () => { const result = await load(event); - expect(result.tasks).toEqual([]); - expect(result.isLoggedIn).toBe(false); + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(false); }); }); From b6491fb47b4ae0bbfe26446e686890ff4dc5ff44 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 21 Jun 2026 04:58:49 +0000 Subject: [PATCH 3/3] chore(dev-notes): remove votes-cache-optimization planning docs after branch completion Co-Authored-By: Claude Sonnet 4.6 --- .../votes-cache-optimization/plan.md | 97 ------------------ .../votes-cache-optimization/review.md | 99 ------------------- 2 files changed, 196 deletions(-) delete mode 100644 docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md delete mode 100644 docs/dev-notes/2026-06-21/votes-cache-optimization/review.md diff --git a/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md b/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md deleted file mode 100644 index 5c355c958..000000000 --- a/docs/dev-notes/2026-06-21/votes-cache-optimization/plan.md +++ /dev/null @@ -1,97 +0,0 @@ -# votes 一覧のキャッシュ最適化 - -## 概要 - -`/votes` の load は毎回 **全タスク(本番≈9000件)+ 全 stats + 全 counters** を 3 テーブルから取得・結合して SSR に載せている。UI 上は初期表示 0 件、検索しても最大 20 件のみ表示。データは全員共有(個人の解答状態・投票を含まない)。 - -**方針 B**(`docs/dev-notes/2026-06-13/sveltekit-caching/plan.md` で定義)を採用: - -- サーバー側キャッシュ: `getAllTasksWithVoteInfo()` の結果を丸ごとキャッシュ -- CDN キャッシュ: 匿名時のみ `s-maxage` を付与し、bot/匿名アクセスで関数起動を省略 - -クライアント側の即時検索 UX は維持。全件転送(≈9000 件)は毎回発生し続けるが、DB クエリの Duration はキャッシュで削減。匿名時は CDN キャッシュで関数起動自体を省略。 - -## 設計根拠 - -### 方針 A(サーバー検索エンドポイント化)を採用しない理由 - -方針 A は Duration と Transfer 両方を最大削減できるが、検索のたびにサーバーリクエストが発生し、現在のクライアント側即時検索の UX が損なわれる。ユーザー判断により UX 維持を優先。 - -### アプローチ選択: 単一キャッシュ vs 構成要素別キャッシュ - -- **単一キャッシュ(採用)**: `getAllTasksWithVoteInfo()` の結果(`TaskWithVoteInfo[]`)を丸ごとキャッシュ。シンプルで、plan の影響範囲表と整合。 -- **構成要素別キャッシュ(却下)**: 既存の tasks/stats キャッシュを再利用し counters のみ新規追加。`getAllTasksWithVoteInfo` は 7 カラムの projection + 結合ロジックなので、全カラムの既存キャッシュとは型が合わず不自然。 - -### キャッシュ対象データの安全性 - -`TaskWithVoteInfo` は以下の共有データのみ含む(個人データなし): - -- `task_id`, `contest_id`, `title`, `grade`, `task_table_index` — タスクの基本情報 -- `estimatedGrade` — 全ユーザーの投票から算出された中央値 -- `voteTotal` — 投票の合計数 - -個人の回答状況(`taskAnswer`)や個人の投票(`voteGrade`)は一切含まれない。キャッシュしても他ユーザーの個人データが漏洩するリスクはゼロ。 - -### ログイン/匿名の表示差異 - -現在 votes 一覧はログイン/匿名で同一表示だが、将来ログイン時に差分表示(投票ボタン等)を追加する可能性がある。CDN キャッシュは匿名時のみに限定する。 - -## Phase 1: サーバーキャッシュ - -### 変更ファイル - -| ファイル | 変更内容 | -| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/features/votes/server/cache.ts` | `Cache` インスタンス追加。`getCachedAllTasksWithVoteInfo()` ラッパー。`invalidateVoteCaches()` / `disposeVoteCaches()` 拡張 | -| `src/features/votes/services/vote_statistics.ts` | `getAllTasksWithVoteInfo()` を `getCachedAllTasksWithVoteInfo()` でラップ | -| `src/lib/services/tasks.ts` | `createTask()` / `updateTask()` に `invalidateVoteCaches()` 追加 | -| `src/features/votes/server/cache.test.ts` | 新キャッシュのワイヤリング・invalidation テスト追加 | -| `src/features/votes/services/vote_statistics.test.ts` | mock 更新 | - -### キャッシュ仕様 - -- TTL: 10 分(既存の vote stats キャッシュと同じ。投票データの更新頻度に合わせる) -- キー: 固定文字列(全タスク対象で引数なし) -- Stampede 防止: `Cache.getOrFetch()` の in-flight 共有で自動対応 - -### Invalidation 経路 - -| 書き込み操作 | invalidation | -| --------------------------- | --------------------------------------------------------------------------- | -| `upsertVoteGradeTables()` | 既存の `invalidateVoteCaches()` が拡張されるので自動的にカバー | -| `updateTask()` (grade 変更) | `invalidateVoteCaches()` を追加 | -| `createTask()` | `invalidateVoteCaches()` を追加(新規タスクが検索にヒットしない状態を防止) | - -### テスト - -- `cache.test.ts`: ワイヤリング(`getCachedAllTasksWithVoteInfo` が fetchFn を委譲し、2 回目はキャッシュヒット)、invalidation グルーピング(`invalidateVoteCaches()` で新キャッシュもクリアされる) -- `vote_statistics.test.ts`: mock 更新(`getCachedAllTasksWithVoteInfo` を passthrough mock) - -## Phase 2: CDN キャッシュ(匿名時) - -### 変更ファイル - -| ファイル | 変更内容 | -| -------------------------------------- | ------------------------------------------------------ | -| `src/routes/votes/+page.server.ts` | 匿名時に `setHeaders({ 'cache-control': '...' })` 追加 | -| `src/routes/votes/page_server.test.ts` | 新規。load の匿名/ログイン時のヘッダー検証 | - -### CDN キャッシュ仕様 - -- 匿名時(`session === null`)のみ: `public, max-age=0, s-maxage=300, stale-while-revalidate=600` -- ログイン時: `setHeaders` を呼ばない(将来のログイン時差分表示に備える) -- degraded フラグは不要(votes の load に try/catch がなく、例外は SvelteKit のエラーハンドリングに委ねる) - -### テスト - -Phase 3(`/problems`)で確立したパターンに倣う: - -1. 匿名時に cache-control ヘッダーが設定される -2. ログイン時に cache-control ヘッダーが設定されない -3. 正しいデータ(tasks, isLoggedIn)を返す - -## 検証 - -- `pnpm test:unit` で各テストが通ること -- ローカルで匿名/ログインのレスポンスヘッダー確認 -- デプロイ後、Vercel ダッシュボードで Duration / Fast Origin Transfer を観測 diff --git a/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md b/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md deleted file mode 100644 index c6e6f7868..000000000 --- a/docs/dev-notes/2026-06-21/votes-cache-optimization/review.md +++ /dev/null @@ -1,99 +0,0 @@ -# votes-cache-optimization レビュー - -staging..#3708 の差分に対するコードレビュー + simplify + 型エラー分析。 - -## 変更概要 - -| ファイル | 内容 | -|---|---| -| `src/features/votes/server/cache.ts` | `allTasksWithVoteInfoCache` 追加、`invalidateVoteCaches` / `disposeVoteCaches` 拡張 | -| `src/features/votes/services/vote_statistics.ts` | `getAllTasksWithVoteInfo()` を `getCachedAllTasksWithVoteInfo` でラップ、内部関数 `fetchAllTasksWithVoteInfo` 抽出 | -| `src/lib/services/tasks.ts` | `createTask()` / `updateTask()` に `invalidateVoteCaches()` 追加 | -| `src/routes/votes/+page.server.ts` | try-catch + `fetchFailed` フラグ + 匿名時 CDN cache-control | -| `src/routes/votes/page_server.test.ts` | 新規。load() のキャッシュヘッダーとデータ返却のテスト | -| `src/features/votes/server/cache.test.ts` | `getCachedAllTasksWithVoteInfo` のワイヤリング・invalidation テスト追加 | -| `src/features/votes/services/vote_statistics.test.ts` | mock に `getCachedAllTasksWithVoteInfo` 追加 | -| `src/test/lib/services/tasks.test.ts` | `invalidateVoteCaches` の呼び出し検証テスト追加 | - -## コードレビュー - -### Good - -1. **cache.ts の設計が既存パターンと一貫**: `voteStatsCache` のリネーム、`allTasksWithVoteInfoCache` の追加、`invalidateVoteCaches` / `disposeVoteCaches` の拡張がすべて server-cache ルールに準拠。 -2. **Invalidation 経路の網羅**: `createTask()` と `updateTask()` の両方に `invalidateVoteCaches()` を追加し、`updateTask` の P2025 パスでは呼ばない設計が正しい。テストでも検証済み。 -3. **テストカバレッジ**: cache のワイヤリング、invalidation グルーピング、route の匿名/ログイン/degraded の 3 パターンすべてカバー。 -4. **CDN キャッシュの匿名限定**: `/problems` と同じパターンで、ログイン時に setHeaders を呼ばない設計。 - -### Issues - -#### Critical - -なし。 - -#### High - -なし(型エラーは修正済み)。 - -#### Medium - -なし(以下すべて修正済み)。 - -#### 修正済み - -1. **型エラー: `page_server.test.ts`** — `result!` の assert スタイルで解消(`pnpm check` 0 errors) -2. **plan.md との乖離: try-catch + degraded** — 実装が意図通り。plan のほうを実態に合わせて更新が必要 -3. **`cache.test.ts` のキャスト** — `as unknown as import('@prisma/client').TaskGrade` を削除、`$lib/types/task` の `TaskGrade` をそのまま使用 -4. **`+page.server.ts` の `dataOk` → `fetchFailed`** — 逆条件に変更(`if (!session && !fetchFailed)`) -5. **型注釈の簡略化** — `Awaited>` → `TaskWithVoteInfo[]` - -## Simplify 分析 - -### 既存コードとの再利用 - -- `cache.ts` のパターンは `$lib/server/tasks/cache.ts` と完全に同型。Cache の汎用性がうまく活かされている。追加の抽象は不要。 -- `vote_statistics.ts` の `fetchAllTasksWithVoteInfo` 抽出は適切。`getCachedAllTasksWithVoteInfo` にラムダを渡す代わりに名前付き関数を使う方が可読性が高い。 - -### 品質・効率 - -- cache.ts: 問題なし。2 つのキャッシュインスタンスが同一 TTL を共有しているのは意図的(同じドメイン)。 -- invalidation: `createTask` / `updateTask` の両方でカバーされており、漏れなし。 -- テスト: 各レイヤーで適切なモックが設定されており、テスト間の依存もない。 - -## 型エラー分析 - -### エラー内容 - -``` -src/routes/votes/page_server.test.ts:78:19 - Property 'tasks' does not exist on type 'void | (Omit> & ...)' -``` - -6 箇所すべて同一原因。line 78, 79, 88, 89, 98, 99。 - -### 原因 - -SvelteKit の `PageServerLoad` 型は `load()` の戻り値を `void | PageData` として推論する。`void` は `load()` が明示的に `return` しないパスが型システム上存在しうることを示す。 - -テストで `const result = await load(event)` とすると、`result` の型が `void | PageData` になり、`result.tasks` に直接アクセスできない。 - -### 修正 - -assert スタイルで解消: - -```typescript -const result = await load(event); -expect(result).toBeDefined(); -expect(result!.tasks).toEqual([]); -expect(result!.isLoggedIn).toBe(false); -``` - -## まとめ - -| 区分 | 件数 | 対応 | -|---|---|---| -| Critical | 0 | - | -| High | 0 | 型エラー修正済み | -| Medium | 0 | cache.test.ts キャスト統一済み | -| 修正済み | 5 | 型エラー、キャスト、fetchFailed 逆条件、型注釈簡略化、plan 乖離は意図通り | - -テスト: 全 77 ファイル pass(2637 tests passed)。型チェック: 0 errors。