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
16 changes: 11 additions & 5 deletions apps/web/src/lobby/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,16 @@ export async function readHome(
return { ...NOTHING, board: top, today: await readDailyTile(context, null, atMs) };
}

const [stats, quests, history, today] = await Promise.all([
const [stats, quests, recent, today] = await Promise.all([
selectPlayerStats(context.db, viewerId),
readLiveQuests(context, viewerId, atMs),
selectGameHistory(context.db, viewerId),
// Step R.2 — four finished rounds, asked for as four finished rounds. This
// read every participation the account had ever had and threw all but four
// away in Node, which cost most for the players who play most.
selectGameHistory(context.db, viewerId, {
limit: RECENT_ROUNDS,
finishedOnly: true,
}),
readDailyTile(context, viewerId, atMs),
]);

Expand All @@ -128,10 +134,10 @@ export async function readHome(
board: top,
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".
recent: history
// walked out of has no score worth listing under "what you played". The
// query holds that rule now; the narrowing below is what tells TypeScript.
recent: recent
.filter((row): row is typeof row & { endedAt: Date } => row.endedAt !== null)
.slice(0, RECENT_ROUNDS)
.map((row) => ({
gameId: row.gameId,
topic: row.topic,
Expand Down
96 changes: 96 additions & 0 deletions packages/db/src/queries/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,100 @@ describe.skipIf(url === null)('a guest, and the account that comes after', () =>
attachGuestRecords(store.db, 'account-1', 'account-1', perfectRound),
).rejects.toThrow(/same user/);
});

/*
* Step R.2 — the window, and the trap in the obvious version of it.
*
* `09-query-debt.md` asked for a limit: the home shows four rounds and this
* query had none, so every participation an account had ever had crossed the
* wire to be thrown away in Node. What it did not say is that the home was
* *also* filtering, on `ended_at`, after the fact — so a limit alone would
* have been a regression rather than a saving, and the first case below is
* the one that catches it.
*/
describe('the window a caller asks for', () => {
/** A round, with the two clocks the window reads. */
const addRound = async (
topic: string,
startedAt: Date,
endedAt: Date | null,
): Promise<string> => {
const [row] = await store.db
.insert(game)
.values({
mode: 'solo',
topic,
sourceUrl: `https://fr.wikipedia.org/wiki/${topic}`,
paragraphs: ['un paragraphe'],
totalFakes: 2,
timeLimit: 300,
startedAt,
endedAt,
})
.returning({ id: game.id });
if (row === undefined) throw new Error('no game');
await store.db
.insert(participant)
.values({ gameId: row.id, userId: 'account-1', colour: '#ff0000' });
return row.id;
};

const day = (n: number): Date => new Date(Date.UTC(2026, 0, n));

beforeEach(async () => {
await addUser('account-1', 'Élise Dupont', false);
});

// The case the home would have failed. Five abandoned rounds, all newer than
// the one that finished: a `limit(4)` with no predicate returns four rows
// the home then filters down to nothing, and a player who has played all
// week is shown an empty list.
it('spends the limit on finished rounds, not on the newest ones', async () => {
for (const n of [2, 3, 4, 5, 6])
await addRound(`Abandon ${String(n)}`, day(n), null);
await addRound('Chocolat', day(1), day(1));

const window = await selectGameHistory(store.db, 'account-1', {
limit: 4,
finishedOnly: true,
});

expect(window.map((row) => row.topic)).toEqual(['Chocolat']);
});

it('returns the newest first, and stops at the limit', async () => {
await addRound('Chat', day(1), day(1));
await addRound('Chocolat', day(2), day(2));
await addRound('Café', day(3), day(3));

const window = await selectGameHistory(store.db, 'account-1', { limit: 2 });

expect(window.map((row) => row.topic)).toEqual(['Café', 'Chocolat']);
});

// What `exportAccount` gets, and the reason both fields default to off: an
// export of an account is every row of it, abandoned rounds included.
it('gives every row, unfinished ones too, when no window is asked for', async () => {
await addRound('Chat', day(1), day(1));
await addRound('Abandon', day(2), null);
await addRound('Chocolat', day(3), day(3));

const all = await selectGameHistory(store.db, 'account-1');

expect(all.map((row) => row.topic)).toEqual(['Chocolat', 'Abandon', 'Chat']);
});

// The predicate is on the round's clock and not the player's: somebody who
// left a round that ran to the end still played it.
it('counts a round that ended without this player submitting', async () => {
await addRound('Chocolat', day(1), day(1));

const window = await selectGameHistory(store.db, 'account-1', {
finishedOnly: true,
});

expect(window).toHaveLength(1);
expect(window[0]?.submittedAt).toBeNull();
});
});
});
49 changes: 45 additions & 4 deletions packages/db/src/queries/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,47 @@ import { recomputePlayerStats, type PerfectRound } from './stats.js';

type Db = Database['db'];

/**
* How much of a history a caller wants — step R.2.
*
* The default is every row, unfiltered, because that is what `exportAccount`
* needs and what every caller got before this existed.
*
* **The two fields go together, and that is the point of the type.**
* `09-query-debt.md` asked only for the limit; a limit alone would have been a
* regression, because the one caller that wants four rows wants four *finished*
* ones, and it was filtering in Node after the fact. Four unfinished rounds at
* the top of a player's history would have filled the budget and left the home
* drawing an empty list for somebody who has played all week.
*/
export interface HistoryWindow {
/** At most this many rows. Absent means every one of them. */
readonly limit?: number;
/**
* Only rounds that ended.
*
* `game.ended_at`, not `participant.submitted_at`: a round somebody walked out
* of still ended, and it belongs in a list of what they played. An export
* wants the unfinished ones too, which is why this is off by default.
*/
readonly finishedOnly?: boolean;
}

/**
* The games an account has played, most recent first.
*
* C1.1 and C1.2 — no `game_position`. A history view has no business carrying
* solutions: it is the easiest place to leak them, because a debrief and a
* history list look alike and one of them is about games somebody else may still
* be playing. A test asserts this query never mentions that table.
*
* **The predicate is `participant.user_id` and the order is `game.started_at`**,
* on the joined table, so no index serves both: Postgres reads every
* participation the player has and sorts them. A window is what keeps that from
* growing with the account — see `HistoryWindow`.
*/
export function selectGameHistory(db: Db, userId: string) {
return db
export function selectGameHistory(db: Db, userId: string, window: HistoryWindow = {}) {
const rows = db
.select({
gameId: game.id,
topic: game.topic,
Expand All @@ -40,8 +71,18 @@ export function selectGameHistory(db: Db, userId: string) {
})
.from(participant)
.innerJoin(game, eq(participant.gameId, game.id))
.where(eq(participant.userId, userId))
.orderBy(desc(game.startedAt));
.where(
window.finishedOnly === true
? and(eq(participant.userId, userId), isNotNull(game.endedAt))
: eq(participant.userId, userId),
)
.orderBy(desc(game.startedAt))
// `$dynamic` so both shapes are one type: `account.ts` reads this function's
// return type to describe an export, and a union of two builders would make
// that type depend on an argument nobody passes there.
.$dynamic();

return window.limit === undefined ? rows : rows.limit(window.limit);
}

/** Every read that must never touch the solution. Asserted, not trusted. */
Expand Down
50 changes: 30 additions & 20 deletions plans/current-state/09-query-debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,26 +158,36 @@ against Neon's ceiling, and recorded rather than acted on for that reason — bu
Fluid Compute keeps instances alive, so the pools are held for as long as the
instance is, and the arithmetic is per instance.

## The home reads a whole history to show four rows

`queries/history.ts:24` — `selectGameHistory` has **no `LIMIT`**. `lobby/home.ts:134`
takes what it wants with `.slice(0, RECENT_ROUNDS)`, in Node, after the rows
have crossed the wire.

Two costs, and the second is the one that grows. The rows are wasted, and the
**sort cannot use an index**: the predicate is `participant.userId`, which
`participant_user_id_idx` covers, but the order is `game.startedAt` on the
joined table. So Postgres fetches every participation a player has, joins, and
sorts — per home page load, for the players who play most.

The fix is a `limit` parameter rather than a second query. `exportAccount`
(`queries/account.ts:322`) is the only other caller and wants all of them,
which is exactly what an optional limit leaves it.

Found by reading, on 2026-09-17, and **not measured**: `09` usually carries a
timing beside a claim and this one has none, because the machine that would
produce it has four rounds in it. The shape is the finding; the number wants a
seeded history, the way `H.2` seeded five thousand movements.
## The home read a whole history to show four rows — closed by R.2

`queries/history.ts:24` — `selectGameHistory` had **no `LIMIT`**, and
`lobby/home.ts:134` sliced to `RECENT_ROUNDS` in Node, after the rows had
crossed the wire.

Found by reading on 2026-09-17 and left unmeasured, because the machine that
would produce a number had four rounds in it. **R.2 seeded one**: 2 000 rounds
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 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

Expand Down
2 changes: 1 addition & 1 deletion plans/product/19-registers.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ strength of another query's number is the guess this register exists to refuse.
| # | Step | State |
|---|---|---|
| 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.2 | The home's history stops at four, in the query | ✅ |
| R.3 | The board stops waiting its turn | ⬜ |
| R.4 | `desc nulls last`, swept and measured site by site | ⬜ |

Expand Down
Loading