Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 111 additions & 2 deletions apps/web/src/lobby/home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,70 @@
// This is the one decision the reader makes on its own, and it is a decision a
// player notices — a tile offering a quest they finished this morning while an
// unfinished one sits behind it is a tile that wastes the space it takes.
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { dailyWorthShowing } from './home.js';
import { dailyWorthShowing, readHome } from './home.js';
import type { LiveQuest } from '../quests/sets.js';

/*
* Step R.3 — the reads, held open so the test can see which ones started.
*
* Every dependency records its name and then waits on one gate. `readHome` is
* called and *not* awaited: what is asserted is which reads are in flight
* before any of them has answered, which is the whole of the finding — the
* board fed none of the others and none of them fed the board, and it was
* awaited on its own line anyway.
*/
const probe = vi.hoisted(() => {
const started: string[] = [];
let open: () => void = () => undefined;
let fail: (error: Error) => void = () => undefined;
let gate = new Promise<void>((resolve, reject) => {
open = resolve;
fail = reject;
});
// Nothing awaits `gate` itself — only the promises derived from it — so a
// rejection here would be unhandled before the first mock is called.
gate.catch(() => undefined);

return {
started,
/** Records the call, then answers only once the gate is opened. */
read: <T>(name: string, value: T): Promise<T> => {
started.push(name);
return gate.then(() => value);
},
openGate: (): void => {
open();
},
failGate: (): void => {
fail(new Error('the board is down'));
},
reset: (): void => {
started.length = 0;
gate = new Promise<void>((resolve, reject) => {
open = resolve;
fail = reject;
});
gate.catch(() => undefined);
},
};
});

vi.mock('../leaderboard/board.js', () => ({
readBoard: () => probe.read('board', { rows: [], viewer: null }),
}));
vi.mock('../daily/tile.js', () => ({
readDailyTile: () => probe.read('tile', { day: 0 }),
}));
vi.mock('../quests/sets.js', () => ({
readLiveQuests: () => probe.read('quests', []),
}));
vi.mock('@wikifake/db', () => ({
selectPlayerStats: () => probe.read('stats', null),
selectGameHistory: () => probe.read('history', []),
}));

function quest(over: Partial<LiveQuest> = {}): LiveQuest {
return {
questId: 'q',
Expand Down Expand Up @@ -54,3 +113,53 @@ describe('L.6 — the daily quest the home shows', () => {
expect(dailyWorthShowing([claimed])?.questId).toBe('claimed');
});
});

describe('R.3 — what the home starts before it waits', () => {
const context = { db: {} } as Parameters<typeof readHome>[0];

beforeEach(() => {
probe.reset();
});

// The defect, stated as a test: `readBoard` was awaited on its own line, so
// the four reads under it had not been started when it answered.
it('starts every read a signed-in home needs before awaiting any of them', async () => {
const view = readHome(context, 'ada', 0);

expect([...probe.started].sort()).toEqual([
'board',
'history',
'quests',
'stats',
'tile',
]);

probe.openGate();
await view;
});

// The guest path had the same shape with one fewer read: the day's tile does
// not depend on the board either, and was waiting behind it.
it('starts the board and the day’s tile together for a guest', async () => {
const view = readHome(context, null, 0);

expect([...probe.started].sort()).toEqual(['board', 'tile']);

probe.openGate();
await view;
});

// A promise created before a branch is the shape track Q spent five steps on,
// and Q.2 is the one where an unheld one ended the process. Both paths carry
// the board into a `Promise.all` with no `await` in between, which is what
// makes a failing read a rejected `readHome` rather than an unhandled
// rejection.
it.each([
['a signed-in viewer', 'ada'],
['a guest', null],
] as const)('reports a failing read as a rejection, for %s', async (_who, viewer) => {
const view = readHome(context, viewer, 0);
probe.failGate();
await expect(view).rejects.toThrow('the board is down');
});
});
39 changes: 30 additions & 9 deletions apps/web/src/lobby/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { selectGameHistory, selectPlayerStats, type Database } from '@wikifake/db';

import { readDailyTile, type DailyTile } from '../daily/tile.js';
import { readBoard } from '../leaderboard/board.js';
import { readBoard, type BoardView } from '../leaderboard/board.js';
import { readLiveQuests, type LiveQuest } from '../quests/sets.js';

/** How many finished rounds the home lists. Four: a tile, not a history. */
Expand Down Expand Up @@ -93,22 +93,43 @@ export function dailyWorthShowing(quests: readonly LiveQuest[]): LiveQuest | nul
* `13-ui-overhaul.md`: *a dashboard that draws zeroes says the game is empty*.
* Everything else needs an identity and is skipped without one.
*/
/** The head of the board, which is all a tile has room for. */
function topOf(board: BoardView): HomeView['board'] {
return board.rows.slice(0, HOME_BOARD_ROWS).map((row) => ({
displayName: row.displayName,
score: row.score,
}));
}

export async function readHome(
context: HomeContext,
viewerId: string | null,
atMs: number,
): Promise<HomeView> {
const board = await readBoard(context, 'allTime', null, atMs, viewerId);
const top = board.rows.slice(0, HOME_BOARD_ROWS).map((row) => ({
displayName: row.displayName,
score: row.score,
}));
/*
* Step R.3 — started before the branch, awaited on both, floating on neither.
*
* This was `await readBoard(…)` on its own line. Nothing below feeds the board
* and the board feeds nothing below — they take the same `viewerId` and the
* same clock — so every home page load spent one round trip waiting its turn
* for no reason. Same arithmetic as O.6's, one query further out.
*
* A promise created before a branch is the shape track Q spent five steps on,
* so it is worth saying why this one is held: there is no `await` between here
* and the `Promise.all` that takes it, on either path, which is what makes a
* rejection handled rather than an unhandled one.
*/
const board = readBoard(context, 'allTime', null, atMs, viewerId);

if (viewerId === null) {
return { ...NOTHING, board: top, today: await readDailyTile(context, null, atMs) };
// The guest path had the same defect and one fewer read: the tile waited on
// the board too, and needs nothing from it.
const [rows, today] = await Promise.all([board, readDailyTile(context, null, atMs)]);
return { ...NOTHING, board: topOf(rows), today };
}

const [stats, quests, recent, today] = await Promise.all([
const [rows, stats, quests, recent, today] = await Promise.all([
board,
selectPlayerStats(context.db, viewerId),
readLiveQuests(context, viewerId, atMs),
// Step R.2 — four finished rounds, asked for as four finished rounds. This
Expand All @@ -131,7 +152,7 @@ export async function readHome(
currentStreak: stats.currentStreak,
},
daily: dailyWorthShowing(quests),
board: top,
board: topOf(rows),
today,
// Finished rounds only, and `endedAt` is what says so: a round somebody
// walked out of has no score worth listing under "what you played". The
Expand Down
46 changes: 23 additions & 23 deletions plans/current-state/09-query-debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,29 +172,29 @@ for one player among 22 000, four in five finished.
before median 8.76 ms 2000 rows 601.7 KiB quicksort of 2000
after median 4.17 ms 4 rows 1.2 KiB top-N heapsort of 4
```
**The plan is barely the point, and that is the finding.** Both shapes still
hash-join a sequential scan — no index serves a predicate on
`participant.user_id` and an order on `game.started_at`, on the joined table —
so the limit buys a top-N heapsort and four milliseconds. What it actually buys
is **six hundred kilobytes that stop crossing the wire per home page load**,
invisible on a loopback and the payload between a Vercel function and Neon. It
grows with the account, which is the half that matters.
**The plan is barely the point.** Both shapes still hash-join a sequential scan —
no index serves a predicate on `participant.user_id` and an order on
`game.started_at`, on the joined table — so the limit buys a top-N heapsort and
four milliseconds. What it buys that matters is **six hundred kilobytes that
stop crossing the wire per home page load**, and a cost that stops growing with
the account.

**The limit alone would have been a regression**, which the register did not
say. The home was *also* filtering on `ended_at` after the fact: four abandoned
rounds at the top fill the budget and leave a player who has played all week
looking at an empty list. So `HistoryWindow` carries both, and `history.test.ts`
has the case that fails with the limit and no predicate.

`exportAccount` (`queries/account.ts:322`) passes no window and gets every row,
unfinished included — which is what an export is.

## The home's board is read before the four reads it does not depend on

`lobby/home.ts:101` awaits `readBoard` and only then opens the `Promise.all` on
line 111. Nothing in that group feeds the board and the board feeds none of
them — they take the same `viewerId` and the same clock.

One avoidable round trip on every signed-in home page load, which is the same
arithmetic O.6 did for the session: not a slow query, a query waiting its turn
for no reason. Moving it into the group is the whole change.
rounds at the top fill the budget and leave a week's player looking at an empty
list. `HistoryWindow` carries both, `history.test.ts` has the case that fails
with the limit and no predicate, and `exportAccount` passes no window at all —
every row, unfinished included, which is what an export is.

## The home's board waited its turn — closed by R.3

`lobby/home.ts:101` awaited `readBoard` and only then opened the `Promise.all`
below it; nothing in that group fed the board and the board fed none of them.
The guest path had it twice over — the day's tile waited on the board too.

**On this machine it does not measure.** 40 runs of the guest home against a
local Postgres: 4.45 ms before, 4.81 ms after — inside the noise, a loopback
round trip being smaller than the variance. What does measure is the depth,
with 25 ms injected per read — about a Neon round trip from a Vercel function:
**50.8 → 25.8 ms** signed in, **50.6 → 25.4 ms** for a guest. One round trip,
both paths, and `home.test.ts` asserts the structure rather than the timing.
2 changes: 1 addition & 1 deletion plans/product/19-registers.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ strength of another query's number is the guess this register exists to refuse.
|---|---|---|
| R.1 | The timeout says what it saw, and how long it waited | ✅ |
| R.2 | The home's history stops at four, in the query | ✅ |
| R.3 | The board stops waiting its turn | ⬜ |
| R.3 | The board stops waiting its turn | ✅ |
| R.4 | `desc nulls last`, swept and measured site by site | ⬜ |

**R.1** — `apps/realtime/src/testing/client.ts`, and the two `rosterOf` helpers
Expand Down
Loading