formatting
This commit is contained in:
parent
35e54014b3
commit
4f47e18ad9
7 changed files with 119 additions and 124 deletions
|
|
@ -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,8 +52,7 @@ interface GameSessionStore {
|
||||||
// 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
|
Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb
|
||||||
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():
|
||||||
|
|
@ -65,24 +64,16 @@ const questions: GameQuestion[] = await Promise.all(
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|
|
@ -97,13 +88,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);
|
||||||
|
|
@ -134,8 +125,7 @@ if (uniqueDistractors.length < 3) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Priority: 🟡 HIGH — Observability & UX issue
|
Priority: 🟡 HIGH — Observability & UX issue
|
||||||
⚠️ High-Severity Smells
|
⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer
|
||||||
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)];
|
||||||
|
|
@ -172,13 +162,12 @@ export const buildQuestionOptions = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Maintainability issue
|
Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap
|
||||||
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
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -241,7 +230,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();
|
||||||
});
|
});
|
||||||
|
|
@ -253,11 +242,10 @@ it("uses fallback when <3 unique distractors available", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Prevents regression on critical fixes
|
Priority: 🟡 HIGH — Prevents regression on critical fixes
|
||||||
🧼 Code Quality Nitpicks
|
🧼 Code Quality Nitpicks 7. Magic Numbers
|
||||||
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?
|
||||||
|
|
@ -267,7 +255,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;
|
||||||
|
|
@ -294,8 +282,6 @@ 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({
|
||||||
|
|
@ -343,6 +329,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.
|
||||||
|
|
@ -84,7 +84,10 @@ 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 = { answers: Map<string, number>; userId: string };
|
export type GameSessionData = {
|
||||||
|
answers: Map<string, number>;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
3. `gameService.ts` — add `userId` to both function signatures:
|
3. `gameService.ts` — add `userId` to both function signatures:
|
||||||
|
|
@ -100,7 +103,11 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
|
||||||
Store it on create:
|
Store it on create:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
|
await store.create(
|
||||||
|
sessionId,
|
||||||
|
{ answers: answerKey, userId },
|
||||||
|
30 * 60 * 1000,
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Assert on evaluate:
|
Assert on evaluate:
|
||||||
|
|
@ -114,7 +121,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:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ 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((t) => t !== term.targetText);
|
const uniqueDistractors = distractorTexts.filter(
|
||||||
|
(t) => t !== term.targetText,
|
||||||
|
);
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue