-
-
Notifications
You must be signed in to change notification settings - Fork 10
feat(cache): add server-side caching for tasks, votes, and workbooks #3707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d1b0d00
feat(cache): add server-side caching for tasks, votes, and workbooks
KATO-Hiro 86dfac1
fix(cache): add stampede prevention, fix vote invalidation, strengthe…
KATO-Hiro 68c31ad
docs(rules): add cache type constraint, stampede prevention, invalida…
KATO-Hiro 798073a
docs(rules): fix cache test pattern example to use concrete function …
KATO-Hiro ae202d3
refactor(cache): simplify workbook cache interface, remove what-comme…
KATO-Hiro d0559cb
chore(dev-notes): remove phase4-server-cache planning docs after bran…
KATO-Hiro 40aae4c
refactor(cache): consolidate inflight cleanup into .finally
KATO-Hiro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| --- | ||
| 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<T>.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<Foo>): Promise<Foo> { | ||
| 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<T>.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. | ||
|
|
||
| Always call `afterAll(() => dispose*Caches())` to prevent timer leaks. Isolate tests with `beforeEach(() => invalidate*Caches())`. | ||
|
|
||
| ## Type Constraint | ||
|
|
||
| `Cache<T extends {}>` — 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<T>.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. | ||
|
|
||
| ```typescript | ||
| // In service file | ||
| import { getCachedTasksMap } from '$lib/server/tasks/cache'; | ||
|
|
||
| export async function getTasksByTaskId(): Promise<Map<string, Task>> { | ||
| 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<unknown>) => fetchFn(), | ||
| invalidateTaskCaches: vi.fn(), | ||
| })); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, VotedGradeStatistics> => | ||
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Map<string, VotedGradeStatistics>>(VOTE_STATS_TTL_MS); | ||
|
|
||
| export function getCachedVoteStats( | ||
| fetchFn: () => Promise<Map<string, VotedGradeStatistics>>, | ||
| ): Promise<Map<string, VotedGradeStatistics>> { | ||
| return cache.getOrFetch(KEY, fetchFn); | ||
| } | ||
|
|
||
| export function invalidateVoteCaches(): void { | ||
| cache.delete(KEY); | ||
| } | ||
|
|
||
| export function disposeVoteCaches(): void { | ||
| cache.dispose(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; | ||
|
|
||
| import { | ||
| getCachedWorkbooksByPlacement, | ||
| getCachedWorkbooksByUser, | ||
| invalidateWorkbookCaches, | ||
| disposeWorkbookCaches, | ||
| } from './cache'; | ||
|
|
||
| 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('SOLUTION:SEARCH_SIMULATION:false', fetchFn); | ||
| await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn); | ||
| expect(fetchFn).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| test('misses cache when key differs', async () => { | ||
| const fetchFn = mockFetchFn(); | ||
| await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn); | ||
| await getCachedWorkbooksByPlacement('SOLUTION:DYNAMIC_PROGRAMMING:false', 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('SOLUTION:SEARCH_SIMULATION:false', placementFn); | ||
| await getCachedWorkbooksByUser(userFn); | ||
| invalidateWorkbookCaches(); | ||
| await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', placementFn); | ||
| await getCachedWorkbooksByUser(userFn); | ||
| expect(placementFn).toHaveBeenCalledTimes(2); | ||
| expect(userFn).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.