From 55c426ab341d9147e84c6dc8300f83ea761bcdc6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:49:38 +0000 Subject: [PATCH 1/2] perf: collapse database queries in badge evaluation loops Implemented transient EvaluationCache in badges.ts to cache and share query promises (lesson count, tool session count, and user profile) during the lifetime of a single badge evaluation execution. This avoids redundant identical database queries and collapses DB roundtrips from O(R) to O(1) where R is the number of rules. Added unit tests to ensure query caching behavior and correct badge evaluation. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- .jules/bolt.md | 4 ++ src/lib/__tests__/badges.test.ts | 70 ++++++++++++++++++++++++++++ src/lib/badges.ts | 80 ++++++++++++++++++++++++-------- 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index ba67a71..1e76ac6 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-17 - [Redundant Database Hits in Badge Rules Loops] +**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. Caching query promises rather than resolved values in a transient cache avoids multiple database roundtrips and safely collapses them from O(R) to O(1) where R is the number of rules. +**Action:** Utilize an `EvaluationCache` to cache and share database query promises across criteria checks during the lifecycle of an evaluation call. diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 7cbf60d..32d12d4 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -92,4 +92,74 @@ describe('badges.ts', () => { const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toEqual([]); }); + + it('awards module_complete badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Module', slug: 'module', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.lessonProgress.count as unknown as ReturnType).mockResolvedValue(1); + const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' }); + expect(result.awarded).toHaveLength(1); + expect(result.awarded[0]?.slug).toBe('module'); + }); + + it('awards tool_sessions badge when criteria met (general and scope specific)', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Sessions', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 5 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'Specific Tool', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + + // Mock general tool sessions to return 5 and specific campaign builder to return 3 + (db.toolSession.count as unknown as ReturnType).mockImplementation(async (args: any) => { + if (args?.where?.toolType === 'CAMPAIGN_BUILDER') { + return 3; + } + return 5; + }); + + const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true }); + expect(result.awarded).toHaveLength(2); + expect(result.totalXpGained).toBe(50); + }); + + it('does not award quiz_score badge when trigger is not quiz_submit or failed', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Quiz', criteria: JSON.stringify({ type: 'quiz_score', threshold: 100 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + + // Scenario 1: trigger is not quiz_submit + let result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toEqual([]); + + // Scenario 2: quiz failed + result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 100, passed: false }); + expect(result.awarded).toEqual([]); + + // Scenario 3: score below threshold + result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 90, passed: true }); + expect(result.awarded).toEqual([]); + + // Scenario 4: passes successfully + result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 100, passed: true }); + expect(result.awarded).toHaveLength(1); + }); + + it('caches the queries correctly in transient EvaluationCache during execution', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Streak', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'XP', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 100 }), xpReward: 50, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + + const findUniqueSpy = vi.spyOn(db.user, 'findUnique'); + findUniqueSpy.mockResolvedValue({ streakDays: 10, xp: 150 }); + + const result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toHaveLength(2); + // Should only query the user profile once because of the cache! + expect(findUniqueSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 6f288eb..4839e86 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -61,6 +61,57 @@ export interface BadgeEvaluationResult { * wants to count it; the engine does NOT mutate `User.xp` to keep this function * composable inside larger transactions. */ +/** + * Transient cache to hold and share database query promises across criteria checks + * during a single evaluateBadges execution. This prevents multiple identical or + * redundant database calls and collapses DB roundtrips from O(R) to O(1) where R is the number of rules. + */ +class EvaluationCache { + private lessonCompletedCountPromise: Promise | null = null; + private toolSessionCountPromises = new Map>(); + private generalToolSessionCountPromise: Promise | null = null; + private userProfilePromise: Promise<{ streakDays: number; xp: number } | null> | null = null; + + getLessonCompletedCount(userId: string): Promise { + if (!this.lessonCompletedCountPromise) { + this.lessonCompletedCountPromise = db.lessonProgress.count({ + where: { userId, status: 'COMPLETED' }, + }); + } + return this.lessonCompletedCountPromise!; + } + + getToolSessionCount(userId: string, toolType?: string): Promise { + if (toolType) { + const cached = this.toolSessionCountPromises.get(toolType); + if (cached) return cached; + + const promise = db.toolSession.count({ + where: { userId, status: 'GRADED', toolType }, + }); + this.toolSessionCountPromises.set(toolType, promise); + return promise; + } else { + if (!this.generalToolSessionCountPromise) { + this.generalToolSessionCountPromise = db.toolSession.count({ + where: { userId, status: 'GRADED' }, + }); + } + return this.generalToolSessionCountPromise!; + } + } + + getUserProfile(userId: string): Promise<{ streakDays: number; xp: number } | null> { + if (!this.userProfilePromise) { + this.userProfilePromise = db.user.findUnique({ + where: { id: userId }, + select: { streakDays: true, xp: true }, + }) as Promise<{ streakDays: number; xp: number } | null>; + } + return this.userProfilePromise!; + } +} + export async function evaluateBadges( userId: string, event: BadgeTrigger, @@ -95,6 +146,8 @@ export async function evaluateBadges( xpReward: number; }> = []; + const cache = new EvaluationCache(); + for (const badge of published) { if (alreadyAwardedSet.has(badge.id)) continue; @@ -106,7 +159,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); } @@ -141,17 +194,18 @@ 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. + * + * Bolt optimization: uses EvaluationCache to reuse query promises. */ 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.getLessonCompletedCount(userId); // 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 +219,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.getToolSessionCount(userId, scopeToolType); return count >= criteria.threshold; } case 'streak_days': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { streakDays: true }, - }); + const user = await cache.getUserProfile(userId); 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.getUserProfile(userId); if (!user) return false; return user.xp >= criteria.threshold; } From 82cefd8ba87d699f77f3bdc66884b14f79921a0b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:54:36 +0000 Subject: [PATCH 2/2] perf: collapse database queries in badge evaluation loops Implemented transient EvaluationCache in badges.ts to cache and share query promises (lesson count, tool session count, and user profile) during the lifetime of a single badge evaluation execution. This avoids redundant identical database queries and collapses DB roundtrips from O(R) to O(1) where R is the number of rules. Added unit tests to ensure query caching behavior and correct badge evaluation. Updated package.json to pnpm@11.13.1 to avoid broken pnpm@11.13.0 release in GitHub Actions. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- package.json | 2 +- src/lib/__tests__/badges.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e60c440..c2719c4 100644 --- a/package.json +++ b/package.json @@ -72,5 +72,5 @@ "eslint --fix" ] }, - "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" + "packageManager": "pnpm@11.13.1" } diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 32d12d4..d90d3fc 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -155,7 +155,7 @@ describe('badges.ts', () => { (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); const findUniqueSpy = vi.spyOn(db.user, 'findUnique'); - findUniqueSpy.mockResolvedValue({ streakDays: 10, xp: 150 }); + findUniqueSpy.mockResolvedValue({ streakDays: 10, xp: 150 } as any); const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toHaveLength(2);