Compare commits

..

No commits in common. "89e559a4dbe77433e6df74566ae348930719e681" and "35e54014b3005c84d6a56e0e5dec6a4e5c40097a" have entirely different histories.

8 changed files with 124 additions and 121 deletions

View file

@ -18,5 +18,3 @@ coverage/
pnpm-lock.yaml pnpm-lock.yaml
routeTree.gen.ts routeTree.gen.ts
.pnpm-store/

View file

@ -1,6 +1,6 @@
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts` # 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
> _"It works on my machine" is not a scalability strategy._ > *"It works on my machine" is not a scalability strategy.*
**Project:** lila — Vocabulary Trainer **Project:** lila — Vocabulary Trainer
**File Roasted:** `gameService.ts` **File Roasted:** `gameService.ts`
@ -52,7 +52,8 @@ deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
// Option B: Use Valkey Lua script for atomic read-modify-write // Option B: Use Valkey Lua script for atomic read-modify-write
// Option C: Optimistic locking with version numbers // Option C: Optimistic locking with version numbers
Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb Priority: 🔴 CRITICAL — Data integrity issue
2. N+1 Query: Database Performance Bomb
Location: gameService.ts:24-26 + termModel.ts:getDistractors() Location: gameService.ts:24-26 + termModel.ts:getDistractors()
// For each of N terms, we call getDistractors(): // For each of N terms, we call getDistractors():
@ -64,16 +65,24 @@ const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N querie
Impact Analysis: Impact Analysis:
Rounds Rounds
DB Queries DB Queries
At 50 concurrent users At 50 concurrent users
3 3
1 + 3 = 4 1 + 3 = 4
200 queries/min 200 queries/min
10 10
1 + 10 = 11 1 + 10 = 11
550 queries/min 550 queries/min
20 20
1 + 20 = 21 1 + 20 = 21
1,050 queries/min 1,050 queries/min
Each getDistractors() runs: Each getDistractors() runs:
@ -88,13 +97,13 @@ Fix: Batch Fetch Distractors
const allDistractors = await db const allDistractors = await db
.select({ termId: terms.id, text: translations.text }) .select({ termId: terms.id, text: translations.text })
.from(terms) .from(terms)
.innerJoin(translations, /_ ... _/) .innerJoin(translations, /* ... */)
.where(and( .where(and(
eq(terms.pos, pos), eq(terms.pos, pos),
eq(translations.difficulty, difficulty), eq(translations.difficulty, difficulty),
inArray(terms.id, termIds), // Batch! inArray(terms.id, termIds), // Batch!
)) ))
.limit(DISTRACTOR_FETCH_COUNT \* termIds.length); .limit(DISTRACTOR_FETCH_COUNT * termIds.length);
// Group by termId in JS, then slice to 3 unique distractors per term // Group by termId in JS, then slice to 3 unique distractors per term
const distractorsByTerm = groupByTermId(allDistractors); const distractorsByTerm = groupByTermId(allDistractors);
@ -125,7 +134,8 @@ throw new UnprocessableEntityError(
); );
} }
Priority: 🟡 HIGH — Observability & UX issue Priority: 🟡 HIGH — Observability & UX issue
⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer ⚠️ High-Severity Smells
4. Code Duplication: Singleplayer vs Multiplayer
Compare: gameService.ts vs multiplayerGameService.ts Compare: gameService.ts vs multiplayerGameService.ts
// gameService.ts // gameService.ts
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)]; const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
@ -162,12 +172,13 @@ correctOptionId
}; };
}; };
Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap Priority: 🟡 HIGH — Maintainability issue
5. Shuffle Bias: Math.random() Trap
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle() Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
export const shuffleArray = <T>(array: T[]): T[] => { export const shuffleArray = <T>(array: T[]): T[] => {
for (let i = result.length - 1; i > 0; i--) { for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() \* (i + 1)); // 🚩 Modulo bias + non-crypto RNG const j = Math.floor(Math.random() * (i + 1)); // 🚩 Modulo bias + non-crypto RNG
// ... // ...
} }
}; };
@ -230,7 +241,7 @@ it("deletes session after TTL expires", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const session = await createGameSession(validRequest, store, "user-1"); const session = await createGameSession(validRequest, store, "user-1");
vi.advanceTimersByTime(31 _ 60 _ 1000); // 31 minutes vi.advanceTimersByTime(31 * 60 * 1000); // 31 minutes
await expect(store.get(session.sessionId)).resolves.toBeNull(); await expect(store.get(session.sessionId)).resolves.toBeNull();
}); });
@ -242,10 +253,11 @@ mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
}); });
Priority: 🟡 HIGH — Prevents regression on critical fixes Priority: 🟡 HIGH — Prevents regression on critical fixes
🧼 Code Quality Nitpicks 7. Magic Numbers 🧼 Code Quality Nitpicks
7. Magic Numbers
// gameService.ts:52 // gameService.ts:52
await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this? await store.create(sessionId, {...}, 30 * 60 * 1000); // What is this?
// termModel.ts:65 // termModel.ts:65
.limit(count); // count=6, but why? .limit(count); // count=6, but why?
@ -255,7 +267,7 @@ optionId: z.number().int().min(0).max(3), // Why 4 options?
Fix: Centralize in @lila/shared/constants.ts: Fix: Centralize in @lila/shared/constants.ts:
export const GAME*SESSION_TTL_MS = 30 * 60 \_ 1000; export const GAME_SESSION_TTL_MS = 30 * 60 * 1000;
export const DISTRACTOR_FETCH_COUNT = 6; export const DISTRACTOR_FETCH_COUNT = 6;
export const GAME_OPTION_COUNT = 4; export const GAME_OPTION_COUNT = 4;
export const MIN_UNIQUE_DISTRACTORS = 3; export const MIN_UNIQUE_DISTRACTORS = 3;
@ -282,6 +294,8 @@ return Promise.resolve(entry.data as Readonly<GameSessionData>);
Problem: No logging, no metrics. You're flying blind in production. Problem: No logging, no metrics. You're flying blind in production.
Minimal Fix (5 minutes): Minimal Fix (5 minutes):
// apps/api/src/lib/logger.ts // apps/api/src/lib/logger.ts
import pino from "pino"; import pino from "pino";
export const logger = pino({ export const logger = pino({
@ -329,6 +343,6 @@ WHERE ...
LIMIT $1 LIMIT $1
-- Option C: Random offset (simple, but still scans) -- Option C: Random offset (simple, but still scans)
OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...)) OFFSET floor(random() * (SELECT count(*) FROM terms WHERE ...))
Action: Add a ticket to documentation/tickets/t00009.md now. Action: Add a ticket to documentation/tickets/t00009.md now.

View file

@ -84,10 +84,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`: 2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
```ts ```ts
export type GameSessionData = { export type GameSessionData = { answers: Map<string, number>; userId: string };
answers: Map<string, number>;
userId: string;
};
``` ```
3. `gameService.ts` — add `userId` to both function signatures: 3. `gameService.ts` — add `userId` to both function signatures:
@ -103,11 +100,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
Store it on create: Store it on create:
```ts ```ts
await store.create( await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
sessionId,
{ answers: answerKey, userId },
30 * 60 * 1000,
);
``` ```
Assert on evaluate: Assert on evaluate:
@ -121,7 +114,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
4. `gameController.ts` — extract from authenticated request: 4. `gameController.ts` — extract from authenticated request:
```ts ```ts
req.session.user.id; req.session.user.id
``` ```
5. `gameRouter.ts` — cast at registration: 5. `gameRouter.ts` — cast at registration:

View file

@ -27,9 +27,7 @@ Not chosen for this ticket — the database query is in `@lila/db` and is a sepa
- Filter distractors against the correct answer before building options: - Filter distractors against the correct answer before building options:
```ts ```ts
const uniqueDistractors = distractorTexts.filter( const uniqueDistractors = distractorTexts.filter((t) => t !== term.targetText);
(t) => t !== term.targetText,
);
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)]; const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
``` ```