diff --git a/.jules/bolt.md b/.jules/bolt.md index ba67a71..3f766af 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 DB Roundtrips during Badge Evaluation] +**Learning:** Badge evaluation loops through all published badges to verify criteria (completed modules, streak days, XP thresholds, etc.). Evaluating each badge's criteria independently triggers $O(R)$ database roundtrips for identical records (such as fetching user details or lesson counts multiple times), creating a query bottleneck during evaluation. +**Action:** Use a short-lived request-scoped `EvaluationCache` to cache and share database query promises across criteria checks during a single run, safely collapsing database queries from $O(R)$ to $O(1)$. diff --git a/package.json b/package.json index e60c440..bc7cc2f 100644 --- a/package.json +++ b/package.json @@ -72,5 +72,5 @@ "eslint --fix" ] }, - "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" + "packageManager": "pnpm@11.14.0+sha512.66c1ac4c7d4762d6d7dde44c7f3e5a73591ed0a0806e751d4ed32d4f004f25b2285a906b1fd8a9e3e621df3b4e2858bf88e50e0cf626bedbe977fe434a5caf85" } diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 7cbf60d..a076fb3 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -92,4 +92,23 @@ describe('badges.ts', () => { const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toEqual([]); }); + + it('collapses redundant database queries during a single evaluateBadges run using EvaluationCache', async () => { + // Set up multiple badges that check the same type of criteria + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Streak 1', slug: 'streak-1', criteria: JSON.stringify({ type: 'streak_days', threshold: 3 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'Streak 2', slug: 'streak-2', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + { id: 'b3', title: 'XP 1', slug: 'xp-1', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 500 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b4', title: 'XP 2', slug: 'xp-2', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 1000 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.user.findUnique as unknown as ReturnType).mockResolvedValue({ streakDays: 10, xp: 1200 }); + + const result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toHaveLength(4); + + // Because of the transient EvaluationCache, db.user.findUnique should only have been called once + // despite 4 different criteria checks checking user details (streak_days, streak_days, xp_threshold, xp_threshold) + expect(db.user.findUnique).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/__tests__/rate-limit.test.ts b/src/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..1666765 --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { rateLimit } from '../rate-limit'; + +describe('rateLimit', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows hits under the limit', () => { + const key = 'test-key-1'; + const limit = 3; + const windowMs = 60_000; + + // 1st hit + let res = rateLimit(key, limit, windowMs); + expect(res.allowed).toBe(true); + expect(res.retryAfterSeconds).toBe(0); + + // 2nd hit + res = rateLimit(key, limit, windowMs); + expect(res.allowed).toBe(true); + expect(res.retryAfterSeconds).toBe(0); + + // 3rd hit + res = rateLimit(key, limit, windowMs); + expect(res.allowed).toBe(true); + expect(res.retryAfterSeconds).toBe(0); + }); + + it('blocks hits exceeding the limit', () => { + const key = 'test-key-2'; + const limit = 2; + const windowMs = 60_000; + + // 1st hit + expect(rateLimit(key, limit, windowMs).allowed).toBe(true); + // 2nd hit + expect(rateLimit(key, limit, windowMs).allowed).toBe(true); + + // 3rd hit - should be blocked + const blockedRes = rateLimit(key, limit, windowMs); + expect(blockedRes.allowed).toBe(false); + expect(blockedRes.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('allows hits again after window expires', () => { + const key = 'test-key-3'; + const limit = 1; + const windowMs = 10_000; + + // 1st hit + expect(rateLimit(key, limit, windowMs).allowed).toBe(true); + + // 2nd hit in the same window - blocked + expect(rateLimit(key, limit, windowMs).allowed).toBe(false); + + // Advance time by 11 seconds + vi.advanceTimersByTime(11_000); + + // 3rd hit should now be allowed + const res = rateLimit(key, limit, windowMs); + expect(res.allowed).toBe(true); + expect(res.retryAfterSeconds).toBe(0); + }); + + it('correctly calculates retryAfterSeconds', () => { + const key = 'test-key-4'; + const limit = 2; + const windowMs = 60_000; + + // Hit at t = 0 + rateLimit(key, limit, windowMs); + + // Advance time by 15 seconds + vi.advanceTimersByTime(15_000); + + // Hit at t = 15s + rateLimit(key, limit, windowMs); + + // Hit at t = 15s (3rd hit, should be blocked) + const res = rateLimit(key, limit, windowMs); + expect(res.allowed).toBe(false); + // Expected retryAfterSeconds is from the oldest hit (at t = 0) to expiry: 60s - 15s = 45s + expect(res.retryAfterSeconds).toBe(45); + }); + + it('performs opportunistic cleanup when buckets Map gets very large', () => { + const limit = 2; + const windowMs = 60_000; + + // Seed Map with lots of expired keys to trigger cleanup when size > 10,000 + // We will generate 10,005 keys, all with expired timestamps + for (let i = 0; i < 10005; i++) { + const key = `expired-key-${i}`; + rateLimit(key, limit, windowMs); + } + + // Since they were all added at t = 0, let's advance time by 61 seconds so they are expired + vi.advanceTimersByTime(61_000); + + // Now adding one more key should trigger the cleanup loop and delete the expired ones + const res = rateLimit('trigger-cleanup-key', limit, windowMs); + expect(res.allowed).toBe(true); + }); +}); diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 6f288eb..b0e6f10 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -51,6 +51,56 @@ export interface BadgeEvaluationResult { totalXpGained: number; } +/** + * Transient evaluation cache that holds and shares database query promises across criteria checks + * during a single evaluateBadges call to collapse database roundtrips from O(R) to O(1). + */ +export class EvaluationCache { + private userId: string; + private completedCountPromise: Promise | null = null; + private toolSessionCountPromises = new Map>(); + private userDetailsPromise: Promise<{ xp: number; streakDays: number } | null> | null = null; + + constructor(userId: string) { + this.userId = userId; + } + + getCompletedCount(): Promise { + if (!this.completedCountPromise) { + this.completedCountPromise = db.lessonProgress.count({ + where: { userId: this.userId, status: 'COMPLETED' }, + }); + } + return this.completedCountPromise; + } + + getToolSessionCount(toolType?: string): Promise { + const key = toolType || 'ALL'; + let promise = this.toolSessionCountPromises.get(key); + if (!promise) { + promise = db.toolSession.count({ + where: { + userId: this.userId, + status: 'GRADED', + ...(toolType ? { toolType } : {}), + }, + }); + this.toolSessionCountPromises.set(key, promise); + } + return promise; + } + + getUserDetails(): Promise<{ xp: number; streakDays: number } | null> { + if (!this.userDetailsPromise) { + this.userDetailsPromise = db.user.findUnique({ + where: { id: this.userId }, + select: { xp: true, streakDays: true }, + }); + } + return this.userDetailsPromise; + } +} + /** * Evaluate all badges for a user against the current database state. Award any * newly-earned ones. Idempotent — re-running with no new events returns @@ -95,6 +145,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 +158,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); } @@ -146,12 +198,11 @@ 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.getCompletedCount(); // 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 +216,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(scopeToolType); return count >= criteria.threshold; } case 'streak_days': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { streakDays: true }, - }); + const user = await cache.getUserDetails(); 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.getUserDetails(); if (!user) return false; return user.xp >= criteria.threshold; }