From 5d4882d9cdecd014eaec877c209cdc127b49ab73 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:53:58 +0000 Subject: [PATCH] perf(badges): consolidate redundant DB queries via EvaluationCache Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- .jules/bolt.md | 4 ++ src/lib/__tests__/badges.test.ts | 14 +++++++ src/lib/badges.ts | 66 ++++++++++++++++++++++---------- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index ba67a71..9f98997 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -3,3 +3,7 @@ ## 2026-07-16 - [O(N*M) Nested Loop Lookups in Grading Engines] **Learning:** In interactive scenarios (such as Bid Elevator and STR Triage), grading engines frequently iterate over user decisions and match them against scenario properties (like keywords or search terms). Performing `array.find()` inside loop bodies or filter predicates results in costly O(N*M) lookups. **Action:** Convert arrays to `Map` lookups before entering loops/nested scans. Mapping keys once in O(M) time enables O(1) lookups during execution, transforming the time complexity of the grading logic to O(N + M). + +## 2026-07-16 - [Redundant DB Queries in Badge Evaluation Engine] +**Learning:** Evaluators checking multiple rules (such as checkCriteria in the badge engine) can generate redundant database queries for identical user records or resource aggregates when looping over each rule. These can be optimized with a transient local cache (caching query promises rather than resolved values) during the evaluation lifecycle, safely collapsing database roundtrips from O(R) to O(1) where R is the number of rules. +**Action:** Store the generated database query promises inside a transient `EvaluationCache` class during the call lifecycle. Re-use those pending/resolved promises across criteria evaluations to eliminate duplicate database I/O overhead. diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 7cbf60d..396369e 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -92,4 +92,18 @@ describe('badges.ts', () => { const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toEqual([]); }); + + it('reuses promises from EvaluationCache to prevent duplicate DB queries', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Module 1', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'Module 2', criteria: JSON.stringify({ type: 'module_complete', threshold: 2 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.lessonProgress.count as unknown as ReturnType).mockResolvedValue(5); + + await evaluateBadges('user-1', { trigger: 'login' }); + + // Both badge criteria check 'module_complete', but db.lessonProgress.count should only be called once! + expect(db.lessonProgress.count).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 6f288eb..c48ad79 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -61,6 +61,43 @@ export interface BadgeEvaluationResult { * wants to count it; the engine does NOT mutate `User.xp` to keep this function * composable inside larger transactions. */ +/** + * Transient cache of database query promises to prevent redundant database lookups + * when checking criteria for multiple badges within a single evaluation lifecycle. + * This collapses the database overhead from O(R) to O(1) where R is the number of rules. + */ +class EvaluationCache { + private completed: Promise | null = null; + private tools = new Map>(); + private user: Promise<{ xp: number; streakDays: number } | null> | null = null; + + constructor(private userId: string) {} + + getCompleted(): Promise { + return this.completed ??= db.lessonProgress.count({ + where: { userId: this.userId, status: 'COMPLETED' }, + }); + } + + getToolCount(toolType?: string): Promise { + const cached = this.tools.get(toolType); + if (cached) return cached; + + const p = db.toolSession.count({ + where: { userId: this.userId, status: 'GRADED', ...(toolType ? { toolType } : {}) }, + }); + this.tools.set(toolType, p); + return p; + } + + getUser(): Promise<{ xp: number; streakDays: number } | null> { + return this.user ??= db.user.findUnique({ + where: { id: this.userId }, + select: { xp: true, streakDays: true }, + }) as Promise<{ xp: number; streakDays: number } | null>; + } +} + export async function evaluateBadges( userId: string, event: BadgeTrigger, @@ -95,6 +132,8 @@ export async function evaluateBadges( xpReward: number; }> = []; + const cache = new EvaluationCache(userId); + for (const badge of published) { if (alreadyAwardedSet.has(badge.id)) continue; @@ -106,7 +145,7 @@ export async function evaluateBadges( continue; } - const qualifies = await checkCriteria(userId, criteria, event); + const qualifies = await checkCriteria(userId, criteria, event, cache); if (qualifies) earnedNow.push(badge); } @@ -140,18 +179,17 @@ export async function evaluateBadges( /** * Returns true if the user has met the given badge criteria at this moment. - * Each branch is a narrow DB read — no writes. + * Reuses the provided transient EvaluationCache to avoid redundant database lookups. */ async function checkCriteria( userId: string, criteria: BadgeCriteria, event: BadgeTrigger, + cache: EvaluationCache, ): Promise { switch (criteria.type) { case 'module_complete': { - const completedCount = await db.lessonProgress.count({ - where: { userId, status: 'COMPLETED' }, - }); + const completedCount = await cache.getCompleted(); // Treat each completed lesson as progress toward module_complete; the // seed threshold is 1 so this triggers after the first lesson. return completedCount >= criteria.threshold; @@ -165,30 +203,18 @@ async function checkCriteria( case 'tool_sessions': { const scopeToolType = criteria.scope?.toolType; - const count = await db.toolSession.count({ - where: { - userId, - status: 'GRADED', - ...(scopeToolType ? { toolType: scopeToolType } : {}), - }, - }); + const count = await cache.getToolCount(scopeToolType); return count >= criteria.threshold; } case 'streak_days': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { streakDays: true }, - }); + const user = await cache.getUser(); if (!user) return false; return user.streakDays >= criteria.threshold; } case 'xp_threshold': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { xp: true }, - }); + const user = await cache.getUser(); if (!user) return false; return user.xp >= criteria.threshold; }