Skip to content

feat(cache): add server-side caching for tasks, votes, and workbooks#3707

Merged
KATO-Hiro merged 7 commits into
stagingfrom
#3706
Jun 21, 2026
Merged

feat(cache): add server-side caching for tasks, votes, and workbooks#3707
KATO-Hiro merged 7 commits into
stagingfrom
#3706

Conversation

@KATO-Hiro

@KATO-Hiro KATO-Hiro commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

close #3706

Summary by CodeRabbit

  • Documentation
    • サーバーサイドキャッシュの運用ルール(取得・無効化・破棄・TTL・テスト方針)を体系化しました。
  • Tests
    • 投票グレード統計、ワークブック、タスクのキャッシュ挙動と無効化タイミングを検証するテストを追加しました。
  • Refactor
    • キャッシュ取得を一体化し、同時取得の重複抑止や確実な後始末を改善しました。
    • 投票・ワークブック・タスクの読み取り/更新処理でキャッシュの連携と無効化を適用しました。

KATO-Hiro and others added 6 commits June 20, 2026 13:49
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n type constraint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion audit, and test patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…name placeholders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nts, eliminate dead wrapper

- 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<T> (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 <noreply@anthropic.com>
…ch completion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: feb3cdc5-2fca-4790-9e2e-e9232ceea652

📥 Commits

Reviewing files that changed from the base of the PR and between d0559cb and 40aae4c.

📒 Files selected for processing (1)
  • src/lib/clients/cache.ts

📝 Walkthrough

Walkthrough

Cache<T> クラスに getOrFetch メソッドと inflight マップを追加してスタンピード防止を実装。tasks・votes・workbooks の各ドメインにキャッシュモジュールを新設し、既存サービスの読み取りをキャッシュ経由へ、書き込み後は invalidate*Caches() 呼び出しへ統合。キャッシュ規約とテストガイドラインも文書化。

Changes

サーバーサイドキャッシュ基盤とドメイン統合

Layer / File(s) Summary
Cache クラス: getOrFetch・inflight・型制約
src/lib/clients/cache.ts, src/lib/clients/cache.test.ts
Cache<T extends {}>inflight Map を追加し getOrFetch を実装。同一キーの同時呼び出しで fetchFn を1回に束ね、成功/失敗後のインフライトクリーンアップと dispose 拡張を含む。テストで全境界条件を網羅。
ContestTaskCache 汎用メソッド削除・getOrFetch 委譲
src/lib/clients/cache_strategy.ts
getCachedOrFetch 汎用メソッドと例外時フォールバック・ログ処理を削除。getCachedOrFetchContests/TaskscontestCache.getOrFetch へ直接委譲へ変更。
tasks キャッシュモジュール・サービス統合
src/lib/server/tasks/cache.ts, src/lib/server/tasks/cache.test.ts, src/lib/services/tasks.ts, src/test/lib/services/tasks.test.ts
getCachedTasksMapgetCachedMergedTasksMapinvalidateTaskCaches を新設。tasks.tsgetTasksByTaskIdgetMergedTasksMap をキャッシュ経由に変更し、createTask/updateTask 後に invalidateTaskCaches を呼ぶ。テストにキャッシュモックを追加。
votes キャッシュモジュール・サービス統合
src/features/votes/server/cache.ts, src/features/votes/server/cache.test.ts, src/features/votes/services/vote_statistics.ts, src/features/votes/services/vote_statistics.test.ts, src/features/votes/services/vote_grade.ts, src/features/votes/services/vote_grade.test.ts
TTL 10分の getCachedVoteStats を新設。vote_statistics.ts をキャッシュ経由に変更。upsertVoteGradeTables$transaction 結果を boolean で返し、更新発生時のみ invalidateVoteCaches を呼ぶ制御フローを追加。
workbooks キャッシュモジュール・サービス統合
src/features/workbooks/server/cache.ts, src/features/workbooks/server/cache.test.ts, src/features/workbooks/services/workbooks.ts, src/features/workbooks/services/workbooks.test.ts, src/features/workbooks/utils/workbooks.ts, src/features/workbooks/utils/workbooks.test.ts
buildPlacementKey でキー生成。placement 別・user 固定キーの2系統キャッシュを新設。getWorkbooksByPlacementgetWorkBooksCreatedByUsers をキャッシュ経由に変更し、create/update/delete 後に invalidateWorkbookCaches を呼ぶ。
キャッシュ規約・テストガイドライン
.claude/rules/server-cache.md, .claude/rules/testing.md
server-cache.mdgetOrFetch 必須・TTL・無効化タイミング・型制約・stampede prevention など運用規約を体系化。testing.md に Cache Module Tests セクション追加しタイマーリーク防止とキャッシュバイパス例を追記。

Sequence Diagram(s)

sequenceDiagram
  participant Service as Service層
  participant DomainCache as domain/cache.ts
  participant CacheClass as Cache.getOrFetch
  participant DB as Prisma DB

  rect rgba(100, 149, 237, 0.5)
    note over Service,DB: 読み取りフロー(キャッシュ経由)
    Service->>DomainCache: getCached*(fetchFn)
    DomainCache->>CacheClass: getOrFetch(key, fetchFn)
    alt キャッシュヒット
      CacheClass-->>DomainCache: キャッシュ値
    else インフライト共有
      CacheClass-->>DomainCache: 既存 Promise
    else キャッシュミス
      CacheClass->>DB: fetchFn() → DB クエリ
      DB-->>CacheClass: 結果
      CacheClass->>CacheClass: cache.set(key, 結果)
      CacheClass-->>DomainCache: 結果
    end
    DomainCache-->>Service: Map / List
  end

  rect rgba(205, 92, 92, 0.5)
    note over Service,DB: 書き込みフロー(無効化)
    Service->>DB: create / update / delete
    DB-->>Service: 完了
    Service->>DomainCache: invalidate*Caches()
    DomainCache->>CacheClass: cache.delete(key) / cache.clear()
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

📦 キャッシュに宿る一期一会
同じ呼び出し、inflight で束ねられ
tasks も votes も workbooks も
getOrFetch ひとつで揃う旅
TTL 刻んで、また新鮮に 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed タイトルはサーバーサイドキャッシュ機能の追加という主要な変更を明確に要約しており、プルリクエストの中核を適切に表現している。
Linked Issues check ✅ Passed リンク issue #3706 では共有データのサーバー側キャッシュ実装を要求し、本 PR はタスク・投票・ワークブックのキャッシュ機構を完全に実装して要件を満たしている。
Out of Scope Changes check ✅ Passed 全変更はサーバーサイドキャッシュの実装・テスト・ルール定義の範囲内で、無関係な変更は含まれていない。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch #3706

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/votes/server/cache.ts`:
- Around line 4-5: The constant names VOTE_STATS_TTL_MS and KEY violate the
camelCase naming convention required by the coding guidelines. Rename
VOTE_STATS_TTL_MS to voteStatsTtlMs and KEY to key (or a more descriptive
camelCase name like voteCacheKey if appropriate) to comply with the TypeScript
naming standards for variables and constants in the codebase.

In `@src/lib/clients/cache.ts`:
- Around line 98-103: In-flight fetch promises can recache stale values after
cache invalidation because the then handler in the fetchFn promise completion
(lines 98-103) always calls set() regardless of whether the cache was
invalidated after the fetch started. Implement a generation token mechanism
where each invalidate*Caches() call increments a generation counter, store the
generation at the time the promise is created, and only allow set() to proceed
if the stored generation matches the current generation. Apply the same
generation token check to the delete() operation around lines 126-128 to ensure
inflight cleanup also respects invalidation boundaries.
- Around line 85-107: The getOrFetch method can have the inflight map polluted
if the set() call throws an "Invalid cache key" error, because the
inflight.delete(key) in the success handler won't be executed. Move the key
validation to the beginning of the getOrFetch method before creating the
promise, and add a finally block to the promise chain to ensure
inflight.delete(key) is always called regardless of whether the promise resolves
or rejects, preventing the inflight map from being left with orphaned entries.

In `@src/lib/server/tasks/cache.ts`:
- Around line 6-7: The constant names TASK_MAP_KEY and MERGED_KEY violate the
repository's camelCase naming convention for variables in TypeScript files.
Rename TASK_MAP_KEY to taskMapKey and MERGED_KEY to mergedKey to comply with the
coding guidelines, and update all references to these constants throughout the
file to use the new camelCase names.
- Around line 24-27: The invalidateTaskCaches function in
src/lib/server/tasks/cache.ts uses delete() to clear cached values, but this
does not clear the in-flight requests stored in the underlying cache client from
src/lib/clients/cache.ts. This causes a race condition where completing
in-flight fetches can re-cache stale data. Modify the invalidateTaskCaches
function to not only delete the cached entries (tasksCache and mergedTasksCache)
but also clear the associated inflight promises for both TASK_MAP_KEY and
MERGED_KEY so that subsequent requests will trigger fresh fetches instead of
using stale cached data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 43e91752-a08d-4c0d-8a70-2175bb1e3824

📥 Commits

Reviewing files that changed from the base of the PR and between 276ac67 and d0559cb.

📒 Files selected for processing (21)
  • .claude/rules/server-cache.md
  • .claude/rules/testing.md
  • src/features/votes/server/cache.test.ts
  • src/features/votes/server/cache.ts
  • src/features/votes/services/vote_grade.test.ts
  • src/features/votes/services/vote_grade.ts
  • src/features/votes/services/vote_statistics.test.ts
  • src/features/votes/services/vote_statistics.ts
  • src/features/workbooks/server/cache.test.ts
  • src/features/workbooks/server/cache.ts
  • src/features/workbooks/services/workbooks.test.ts
  • src/features/workbooks/services/workbooks.ts
  • src/features/workbooks/utils/workbooks.test.ts
  • src/features/workbooks/utils/workbooks.ts
  • src/lib/clients/cache.test.ts
  • src/lib/clients/cache.ts
  • src/lib/clients/cache_strategy.ts
  • src/lib/server/tasks/cache.test.ts
  • src/lib/server/tasks/cache.ts
  • src/lib/services/tasks.ts
  • src/test/lib/services/tasks.test.ts

Comment thread src/features/votes/server/cache.ts
Comment thread src/lib/clients/cache.ts Outdated
Comment on lines +85 to +107
async getOrFetch(key: string, fetchFn: () => Promise<T>): Promise<T> {
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;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

getOrFetch はキー検証を先に行ってください

  • Line 100 の set()Invalid cache key が投げられると、then 成功ハンドラ内の inflight.delete が実行されず残留します。
  • 結果として不要な fetchFn 実行や、同一キーでの失敗再利用が発生します。
修正案(事前検証 + finally で確実に inflight 解放)
 async getOrFetch(key: string, fetchFn: () => Promise<T>): Promise<T> {
+  if (!key || typeof key !== 'string' || key.length > 255) {
+    throw new Error('Invalid cache key');
+  }

   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;
-    },
-  );
+  const promise = fetchFn()
+    .then((result) => {
+      this.set(key, result);
+      return result;
+    })
+    .finally(() => {
+      this.inflight.delete(key);
+    });

   this.inflight.set(key, promise);
   return promise;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async getOrFetch(key: string, fetchFn: () => Promise<T>): Promise<T> {
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;
},
async getOrFetch(key: string, fetchFn: () => Promise<T>): Promise<T> {
if (!key || typeof key !== 'string' || key.length > 255) {
throw new Error('Invalid cache key');
}
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);
return result;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return promise;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/clients/cache.ts` around lines 85 - 107, The getOrFetch method can
have the inflight map polluted if the set() call throws an "Invalid cache key"
error, because the inflight.delete(key) in the success handler won't be
executed. Move the key validation to the beginning of the getOrFetch method
before creating the promise, and add a finally block to the promise chain to
ensure inflight.delete(key) is always called regardless of whether the promise
resolves or rejects, preventing the inflight map from being left with orphaned
entries.

Comment thread src/lib/clients/cache.ts Outdated
Comment on lines +98 to +103
const promise = fetchFn().then(
(result) => {
this.set(key, result);
this.inflight.delete(key);
return result;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

無効化後に進行中フェッチが古い値を再キャッシュします

Line 98-103 で進行中 Promise 完了時に常に set しており、Line 126-128 の delete()inflight/世代管理に触れないため、invalidate*Caches() 後でも古い結果が TTL ぶん残る競合が起きます(書き込み直後の stale read)。

修正案(世代トークンで無効化後の書き戻しを抑止)
 export class Cache<T extends {}> {
   private cache: Map<string, CacheEntry<T>> = new Map();
   private inflight = new Map<string, Promise<T>>();
+  private keyGeneration = new Map<string, number>();

   async getOrFetch(key: string, fetchFn: () => Promise<T>): Promise<T> {
+    const generationAtStart = this.keyGeneration.get(key) ?? 0;
     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;
-      },
-    );
+    const promise = fetchFn()
+      .then((result) => {
+        if ((this.keyGeneration.get(key) ?? 0) === generationAtStart) {
+          this.set(key, result);
+        }
+        return result;
+      })
+      .finally(() => {
+        this.inflight.delete(key);
+      });

     this.inflight.set(key, promise);
     return promise;
   }

   delete(key: string): void {
     this.cache.delete(key);
+    this.keyGeneration.set(key, (this.keyGeneration.get(key) ?? 0) + 1);
   }
 }

Also applies to: 126-128

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/clients/cache.ts` around lines 98 - 103, In-flight fetch promises can
recache stale values after cache invalidation because the then handler in the
fetchFn promise completion (lines 98-103) always calls set() regardless of
whether the cache was invalidated after the fetch started. Implement a
generation token mechanism where each invalidate*Caches() call increments a
generation counter, store the generation at the time the promise is created, and
only allow set() to proceed if the stored generation matches the current
generation. Apply the same generation token check to the delete() operation
around lines 126-128 to ensure inflight cleanup also respects invalidation
boundaries.

Comment thread src/lib/server/tasks/cache.ts
Comment on lines +24 to +27
export function invalidateTaskCaches(): void {
tasksCache.delete(TASK_MAP_KEY);
mergedTasksCache.delete(MERGED_KEY);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

無効化中の in-flight フェッチで古い値が再キャッシュされます。

Line 24-26 の delete() ベース無効化は、src/lib/clients/cache.tsdelete()inflight を消さないため、更新直後でも旧データが戻る競合が発生します(createTask/updateTask 後に stale が TTL まで残る可能性)。

修正案(根本対処)
--- a/src/lib/clients/cache.ts
+++ b/src/lib/clients/cache.ts
@@
   clear(): void {
     this.cache.clear();
+    this.inflight.clear();
   }

   delete(key: string): void {
     this.cache.delete(key);
+    this.inflight.delete(key);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/tasks/cache.ts` around lines 24 - 27, The invalidateTaskCaches
function in src/lib/server/tasks/cache.ts uses delete() to clear cached values,
but this does not clear the in-flight requests stored in the underlying cache
client from src/lib/clients/cache.ts. This causes a race condition where
completing in-flight fetches can re-cache stale data. Modify the
invalidateTaskCaches function to not only delete the cached entries (tasksCache
and mergedTasksCache) but also clear the associated inflight promises for both
TASK_MAP_KEY and MERGED_KEY so that subsequent requests will trigger fresh
fetches instead of using stale cached data.

.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 <noreply@anthropic.com>

@KATO-Hiro KATO-Hiro left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@KATO-Hiro KATO-Hiro merged commit 5aff85c into staging Jun 21, 2026
3 checks passed
@KATO-Hiro KATO-Hiro deleted the #3706 branch June 21, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[perf] 共有データのサーバー側キャッシュを実装しましょう

1 participant