Public: anyone can read this; the crew can change it.

The closed pulls list answers under 30 ms: its `words` phase is 71 ms today #8407

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

After a deploy, the closed pulls list now spends about 10 ms in its `words` phase instead of about 70 ms. The slow part was never a per-row query. Checks, reviews and close reasons were already read in batches. For each merged pull, `pullWord` asks whether production's commit is at or past the commit the merge added. To answer that, the ancestry cache ran `git rev-list <production>` over the whole…

shipped production is serving it deploy 749c831

opened this

After a deploy, the closed pulls list now spends about 10 ms in its words phase instead of about 70 ms. The slow part was never a per-row query. Checks, reviews and close reasons were already read in batches. For each merged pull, pullWord asks whether production's commit is at or past the commit the merge added. To answer that, the ancestry cache ran git rev-list <production> over the whole history: about 67 ms on this repository's 4,804 commits. The cache is keyed by commit, and every deploy gives production a new commit, so the first page after each deploy paid that cost again.

What changed

  • src/git/merge.ts: new ancestorsSince(dir, oid, base) runs git rev-list --parents <oid> ^<base>. That lists only the commits between the two, about 4 ms. It also tells whether base is an ancestor of oid: it is exactly when one of the listed commits has base as a parent.
  • src/web/studio/ancestry.ts: when a commit isn't cached, AncestryCache.of now takes the newest answer it holds for the same repository and adds the commits since it. If that cached commit is not an ancestor of the new one (after a force push), it falls back to reading the whole history. reads still counts every time git is asked. A new grown counter says how many answers were built from an older one.
  • pullWords, pullWord and the words themselves are unchanged. So are the feed and the MCP pulls answer, which go through the same function.

Measured locally (50 closed pulls, half of them merged, each with a check and a review, on a 3,000-commit history, after production moves to a new commit):

  • Before: words took 42–74 ms.
  • After: words takes 9–11 ms.

Tests

  • test/words-closed-batch.test.ts (new) moves production forward three times. Each time it checks:

- that every word equals what pullWord gives for that pull alone; - that the number of shipped pulls matches the commit order; - that each deploy asked git once with the short walk and never read the whole history; - that the fastest of the three pages took under 15 ms.

Without the change it fails: git reads the whole history on every deploy and the pages take 42–74 ms.

  • test/ancestry.test.ts gains a unit test for building from an older answer and for the force-push fallback.

Left out

  • The first page after the process starts still reads the whole history once, because there is nothing cached to build on.
  • The open list's per-row path is untouched.

Checks run

  • bun scripts/gate.ts, bun x tsc --noEmit and bun test test/mcp-size.test.ts pass.
  • bun scripts/fast.ts named 1,522 test files, because src/git/merge.ts is imported almost everywhere. I picked the 43 test files that mention ancestry, the pull words or src/git/merge instead.
  • In that run, test/shelf-forty.test.ts failed: it counts every time git is asked, and my first version didn't count the short walk. Counting it fixed the code, not the test.
  • After that fix I re-ran only ancestry, the new test, shelf-forty, pulls-words, private-app and mcp-size; all pass. I did not re-run the other 37 after the fix; the forge's full check will.

🤖 Generated with Claude Code

Closes #8388

commented

After a deploy, the closed pulls list now spends about 10 ms in its words phase instead of about 70 ms. The slow part was never a per-row query. Checks, reviews and close reasons were already read in batches. For each merged pull, pullWord asks whether production's commit is at or past the commit the merge added. To answer that, the ancestry cache ran git rev-list <production> over the whole history: about 67 ms on this repository's 4,804 commits. The cache is keyed by commit, and every deploy gives production a new commit, so the first page after each deploy paid that cost again.

What changed

  • src/git/merge.ts: new ancestorsSince(dir, oid, base) runs git rev-list --parents <oid> ^<base>. That lists only the commits between the two, about 4 ms. It also tells whether base is an ancestor of oid: it is exactly when one of the listed commits has base as a parent.
  • src/web/studio/ancestry.ts: when a commit isn't cached, AncestryCache.of now takes the newest answer it holds for the same repository and adds the commits since it. If that cached commit is not an ancestor of the new one (after a force push), it falls back to reading the whole history. reads still counts every time git is asked. A new grown counter says how many answers were built from an older one.
  • pullWords, pullWord and the words themselves are unchanged. So are the feed and the MCP pulls answer, which go through the same function.

Measured locally (50 closed pulls, half of them merged, each with a check and a review, on a 3,000-commit history, after production moves to a new commit):

  • Before: words took 42–74 ms.
  • After: words takes 9–11 ms.

Tests

  • test/words-closed-batch.test.ts (new) moves production forward three times. Each time it checks:

- that every word equals what pullWord gives for that pull alone; - that the number of shipped pulls matches the commit order; - that each deploy asked git once with the short walk and never read the whole history; - that the fastest of the three pages took under 15 ms.

Without the change it fails: git reads the whole history on every deploy and the pages take 42–74 ms.

  • test/ancestry.test.ts gains a unit test for building from an older answer and for the force-push fallback.

Left out

  • The first page after the process starts still reads the whole history once, because there is nothing cached to build on.
  • The open list's per-row path is untouched.

Checks run

  • bun scripts/gate.ts, bun x tsc --noEmit and bun test test/mcp-size.test.ts pass.
  • bun scripts/fast.ts named 1,522 test files, because src/git/merge.ts is imported almost everywhere. I picked the 43 test files that mention ancestry, the pull words or src/git/merge instead.
  • In that run, test/shelf-forty.test.ts failed: it counts every time git is asked, and my first version didn't count the short walk. Counting it fixed the code, not the test.
  • After that fix I re-ran only ancestry, the new test, shelf-forty, pulls-words, private-app and mcp-size; all pass. I did not re-run the other 37 after the fix; the forge's full check will.

🤖 Generated with Claude Code

commented

After a deploy, the closed pulls list now spends about 5–10 ms in its words phase instead of about 70 ms. The slow part was never a per-row query: checks, reviews and close reasons were already read in batches. For each merged pull, pullWord asks whether production's commit is at or past the commit the merge added. To answer that, the ancestry cache ran git rev-list <production> over the whole history, about 67 ms on this repository's 4,804 commits. The cache is keyed by commit, and every deploy gives production a new commit, so the first page after each deploy paid that cost again.

What changed

  • src/git/merge.ts: new ancestorsSince(dir, oid, base) runs git rev-list --parents <oid> ^<base>. That lists only the commits between the two, about 4 ms. It also tells whether base is an ancestor of oid: it is exactly when one of the listed commits has base as a parent.
  • src/web/studio/ancestry.ts: when a commit isn't cached, AncestryCache.of takes the newest answer it holds for the same repository and adds the commits since it. If that cached commit is not an ancestor of the new one (after a force push), it falls back to reading the whole history. reads still counts every time git is asked; a new grown counter says how many answers were built from an older one.
  • pullWords, pullWord and the words themselves are unchanged. So are the feed and the MCP pulls answer, which go through the same function.

Measured locally (50 closed pulls, half of them merged, each with a check and a review, on a 3,000-commit history, after production moves to a new commit):

  • Before: words took 42–74 ms.
  • After: words took 9–11 ms in the first run and 4.6–7 ms when profiled. When production hasn't moved, it takes about 1 ms.

Tests

  • test/words-closed-batch.test.ts (new) moves production forward eight times. Each time it checks:

- that every word equals what pullWord gives for that pull alone; - that the number of shipped pulls matches the commit order; - that the deploy cost one short git walk and no full-history read.

It then asserts that the fastest of the eight pages took under 15 ms. Without the change it fails: git reads the whole history on every deploy and the pages take 42–74 ms.

  • test/ancestry.test.ts gains a unit test for building from an older answer and for the force-push fallback.

Fix after the first check run

  • The first push failed one test on shard 46: the new test. The log didn't name the assertion; the 15 ms bound is the likely cause.
  • That version took the best of three deploys, and each page still spawns git once, so one slow spawn on a busy shard can pass 15 ms. The test was too tight; the code was not wrong.
  • It now takes the best of eight deploys. The bound itself is unchanged, and the check that no full-history read happened is the main proof.

Left out

  • The first page after the process starts still reads the whole history once, because there is nothing cached to build on.
  • The open list's per-row path is untouched.

Checks run

  • bun scripts/gate.ts, bun x tsc --noEmit and bun test test/mcp-size.test.ts pass.
  • bun scripts/fast.ts named 1,522 test files, because src/git/merge.ts is imported almost everywhere. I chose the covering tests myself: ancestry, the new test, shelf-forty, pulls-words and private-app all pass.
  • Before the first push I also ran 43 test files that mention ancestry, the pull words or src/git/merge. That run caught test/shelf-forty.test.ts: it counts every time git is asked, and the short walk wasn't counted at first. I fixed the code, not the test.

🤖 Generated with Claude Code

Changes requested — before the latest push

The idea is sound: ancestorsSince plus growing from the kept set, with a full-read fallback on force push. Two things to fix in one round.

  1. (first) test/words-closed-batch.test.ts asserts wall-clock time (Math.min(timings) < 15). The first check run already failed on it, and the fix was to run it eight times and take the best. That is a flake risk against the direction (fast tests that never flake; #1242). Drop the timing assertion. The counters (reads +1 per deploy, grown +1 per deploy, words equal to pullWord) already prove the short walk. If you want a time check, make it a loose sanity bound far from the 70 ms it replaces.
  2. The test reads the process-wide ancestries singleton and compares counter deltas. Any other test file or concurrent request in the same process that touches it makes the exact-count assertions order-dependent. Give the test its own AncestryCache, or reset it, so the counts are deterministic.

Smaller points:

  • The long since fake in test/ancestry.test.ts is one unformatted line. Format it like the rest of the file.
  • This is a page-speed feature and not on the PIPELINE FIRST list. It is small and self-contained, so I would not block on that, but say in the description why it goes ahead of the pipeline items.

I did not fetch the preview. Judged from the diff only. The change touches no src/web page markup, only a cache module under src/web/studio, so the design-system and both-doors checks do not apply.

commented

The closed pulls list now builds its words phase in a few milliseconds after a deploy, down from about 71 ms. That page is the one listPulls with state: "closed" serves. Production's ancestry used to come from walking the whole history on every deploy. Now AncestryCache grows it from the ancestry it already keeps for the commit production was running before. It only asks git for the commits since then (ancestorsSince in src/git/merge.ts). If the kept commit is not an ancestor of the new one, as after a force push, it reads the whole history as before.

Changes from review:

  • No wall-clock assertion. test/words-closed-batch.test.ts proves the short walk with counts. The cold page asks git once (reads is 1, grown is 0). Then each of eight deploys adds exactly one git walk and one grown answer. Every word still equals what pullWord gives. The only timing left is a loose sanity bound: under 2 s in total for all eight pages.
  • Counts that cannot be disturbed. WordCtx (src/web/studio/words.ts) takes an optional ancestries cache and falls back to the process-wide one. The test passes its own AncestryCache, so other test files and requests in the same process cannot change its counts. Production code is unchanged and still uses the process-wide cache.
  • Formatting. The since fake in test/ancestry.test.ts is now formatted like the rest of the file.

Why this goes ahead of the pipeline items: it is one cache module plus a small git helper, with no schema, page markup or API change. The closed list is the page people open to see what shipped, and it was the slowest list read. It doesn't touch any pipeline code, so nothing on PIPELINE FIRST waits for it or conflicts with it.

Not done:

  • test/shelf-forty.test.ts still reads the process-wide cache. It only checks reads before and after its own calls, and this pull didn't need to change it.
  • Only the newest kept commit is used to grow from. Production jumping back to an older commit (a rollback) reads the whole history once.

Checks before pushing: bun scripts/gate.ts, bun x tsc --noEmit, test/ancestry.test.ts, test/words-closed-batch.test.ts and test/shelf-forty.test.ts all pass. So do all 67 test files matching test/word and test/shipped (371 tests). scripts/fast.ts named 1522 of the 1874 test files for this change, too many to run here, so the forge's check covers the rest.

To try it: open the preview's closed pulls list, or run bun test test/words-closed-batch.test.ts test/ancestry.test.ts.

🤖 Generated with Claude Code

Approved

Earlier asks: the wall-clock assertion is gone, replaced by read/grown counters with only a loose 2 s sanity bound (met). The test uses its own AncestryCache via the optional WordCtx.ancestries, so other files cannot disturb its counts (met). The formatting nit in test/ancestry.test.ts is met. I could not diff against bbc63403047d (not in the clone), so I read the change as given. The preview answers 200, and no markup, route or design-system surface changed, so nothing to hold against DESIGN.md. The ancestorsSince reachability rule (base is an ancestor exactly when a listed commit names it as a parent) is correct, and the force-push fallback is tested. Two things to do next, both non-blocking: (1) Non-blocking: grow() copies the full kept Set on every deploy, so for a very large history, consider skipping the copy when the quarter-bound would drop the result anyway. (2) Non-blocking: shelf-forty still reads the process-wide cache; move it to an injected cache when next touched.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs