Skip to content

perf(db): R.2 — the home asks for four rounds, not for all of them - #316

Merged
Willi363363 merged 1 commit into
stagingfrom
perf/home-history-limit
Sep 18, 2026
Merged

Willi363363 merged 1 commit into
stagingfrom
perf/home-history-limit

Conversation

@Willi363363

@Willi363363 Willi363363 commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Second step of track R, rebased on staging now that #314 is merged and
carrying its own R.2 ⬜ → ✅ tick. Independent of #315.

selectGameHistory had no LIMIT. home.ts took four rows with
.slice(0, RECENT_ROUNDS), in Node, after every participation the account had
ever had crossed the wire — and no index serves both halves, since the predicate
is participant.user_id and the order is game.started_at on the joined
table.

The number the register asked for and did not have

09-query-debt.md recorded the shape on 2026-09-17 and said so plainly: "not
measured, because the machine that would produce it has four rounds in it"
.
Seeded — 2 000 rounds for one player among 22 000, four in five of them
finished — and measured from Node, median of 20 runs after 3 warm-ups:

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 honest finding. Both shapes
still hash-join a sequential scan; the limit buys a top-N heapsort instead of a
full one, and about four milliseconds. What it buys that matters is six
hundred kilobytes that stop crossing the wire on every home page load
—
invisible on a loopback, the payload between a Vercel function and Neon, and the
only half that grows with the account.

The trap, which the register did not name

A limit alone would have been a regression. The home was also filtering, on
ended_at, after the slice. Four abandoned rounds at the top of a history fill
the budget, and a player who has played all week is shown an empty list.

So HistoryWindow carries limit and finishedOnly, both defaulting to
off: exportAccount passes no window and still gets every row, unfinished
included, which is what an export is.

finishedOnly reads game.ended_at, not participant.submitted_at — somebody
who walked out of a round that ran to the end still played it.

Tests, checked against the absence of the piece each covers

Four new cases in history.test.ts. Not assumed — each run with its own piece
removed:

Piece removed Cases that fail
the ended_at is not null predicate 1 (the one that would have blanked the home)
the limit 1
game.ended_at swapped for participant.submitted_at 2

Checks

  • turbo run test --force --concurrency=1: 10 of 10 tasks, 3 868 passed, 0 skipped, against a local Postgres and Redis.
  • turbo run typecheck lint --force: 22 of 22.
  • pnpm format:check: green.
  • 09-query-debt.md loses the entry in this PR, and the file is back at exactly 200 lines.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DsMRxyowr2Ch5RwiAa3t9z

@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wikifake Ready Ready Preview Sep 18, 2026 11:49am UTC

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

`selectGameHistory` had no `LIMIT`. The home took four with `.slice()`, in
Node, after every participation the account had ever had crossed the wire —
and the sort can use no index either, since the predicate is
`participant.user_id` and the order is `game.started_at` on the joined table.

`09-query-debt.md` recorded the shape on 2026-09-17 and said it had no number,
because the machine that would produce one had four rounds in it. Seeded:
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.** Both shapes still hash-join a sequential
scan; 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.

**A 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 fields, and both default to
off — `exportAccount` passes no window and still gets every row, unfinished
ones included, which is what an export is.

Each new case was checked against the absence of the piece it covers: dropping
the predicate fails the first, dropping the limit fails the second, and
filtering on `participant.submitted_at` instead of `game.ended_at` fails two —
somebody who left a round that ran to the end still played it.

`turbo run test --force --concurrency=1`: 10 of 10 tasks, 3 868 passed, 0
skipped. Typecheck and lint green over all 22 tasks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsMRxyowr2Ch5RwiAa3t9z
@Willi363363
Willi363363 force-pushed the perf/home-history-limit branch from 63f1462 to ceeb121 Compare September 18, 2026 11:48
@Willi363363
Willi363363 merged commit 23c9181 into staging Sep 18, 2026
17 of 18 checks passed

This branch was successfully deployed

1 active deployment
Preview — ceeb121b Deployed Sep 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant