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

/apps and a person's page draw their shelf under 50 ms with 40 apps #6290

Merged opened this

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

Merged

The shelf now draws 40 busy apps in about 13 ms on `/apps` and `/alice`. Locally the shelf phase was 27–28 ms before the change. I could not reproduce the 221–519 ms from the issue, so that is the fixture's number, not production's. The shelf, private-app, home-feed and studio-search tests pass unchanged, along with the new test, and `bun x tsc --noEmit` is clean. I did not run the whole suite or…

shipped production is serving it deploy ad44386

opened this

The shelf now draws 40 busy apps in about 13 ms on /apps and /alice. Locally the shelf phase was 27–28 ms before the change. I could not reproduce the 221–519 ms from the issue, so that is the fixture's number, not production's. The shelf, private-app, home-feed and studio-search tests pass unchanged, along with the new test, and bun x tsc --noEmit is clean. I did not run the whole suite or the design lint (nothing under src/web/ pages changed).

Slowest lap: shelf-rows, about 25 ms of the 28. Each row whose last run merged a pull asks git whether production has caught up to that merge. That is one git rev-list per repository, repeated on every request.

What changed:

  • Ancestry kept across pages: src/web/studio/words.ts now keeps git's answer in a small cache between requests. The ancestors of a commit never change, and a new deploy is a new commit, so a new key. The cache is capped at 400k commits in total, drops the least recently used first, and skips any single repository larger than 100k commits.
  • Pull facts read once: src/web/studio/shelf.ts now reads the checks, reviewers and run-to-pull links for all open pulls and last runs in one batch, through a new shareReads in words.ts. This is neutral on speed here: the pulls lap grew by about what the rows lap lost. I kept it because it removes per-row queries as apps grow.
  • Sealed rows: a private app the viewer cannot enter gets no word context, so nothing about it is read. enterable still decides this, and the sealed-row test still passes.
  • Rows: they read the same as before, and the byte-for-byte golden shelf test passes.

New test: test/shelf-forty.test.ts builds 40 apps, each with an agent run, a check run, a production deploy and a pull. Even apps keep the pull open and odd apps merge it, with production serving a later commit. It sums the shelf-* entries in Server-Timing on /apps and /alice and asserts under 50 ms with expectBudget.

Left out: the first request after a deploy, or after the cache evicts, still pays about 1 ms of git per merged repository, roughly 25 ms for 40 apps. That is still under 50 ms, so I did not batch it into a single git call.

Closes #6179

commented

The shelf now draws 40 busy apps in about 13 ms on /apps and /alice. Locally the shelf phase was 27–28 ms before the change. I could not reproduce the 221–519 ms from the issue, so that is the fixture's number, not production's. The shelf, private-app, home-feed and studio-search tests pass unchanged, along with the new test, and bun x tsc --noEmit is clean. I did not run the whole suite or the design lint (nothing under src/web/ pages changed).

Slowest lap: shelf-rows, about 25 ms of the 28. Each row whose last run merged a pull asks git whether production has caught up to that merge. That is one git rev-list per repository, repeated on every request.

What changed:

  • Ancestry kept across pages: src/web/studio/words.ts now keeps git's answer in a small cache between requests. The ancestors of a commit never change, and a new deploy is a new commit, so a new key. The cache is capped at 400k commits in total, drops the least recently used first, and skips any single repository larger than 100k commits.
  • Pull facts read once: src/web/studio/shelf.ts now reads the checks, reviewers and run-to-pull links for all open pulls and last runs in one batch, through a new shareReads in words.ts. This is neutral on speed here: the pulls lap grew by about what the rows lap lost. I kept it because it removes per-row queries as apps grow.
  • Sealed rows: a private app the viewer cannot enter gets no word context, so nothing about it is read. enterable still decides this, and the sealed-row test still passes.
  • Rows: they read the same as before, and the byte-for-byte golden shelf test passes.

New test: test/shelf-forty.test.ts builds 40 apps, each with an agent run, a check run, a production deploy and a pull. Even apps keep the pull open and odd apps merge it, with production serving a later commit. It sums the shelf-* entries in Server-Timing on /apps and /alice and asserts under 50 ms with expectBudget.

Left out: the first request after a deploy, or after the cache evicts, still pays about 1 ms of git per merged repository, roughly 25 ms for 40 apps. That is still under 50 ms, so I did not batch it into a single git call.

commented

The check failed on an unused laps variable in test/shelf-forty.test.ts. I removed it and pushed. bun x tsc --noEmit is clean and the two shelf tests pass locally. The forge's check hasn't run on the new push yet.

The shelf now draws 40 busy apps in about 13 ms on /apps and /:owner, down from about 27 ms in my fixture. I couldn't reproduce the 221–519 ms from the issue here.

  • Slowest lap: shelf-rows, at about 25 ms of the 28. Each row whose last run merged a pull asked git whether production had caught up, with one git rev-list per repository on every request.
  • Fix: src/web/studio/words.ts keeps git's answer across requests. The ancestors of a commit never change, and a new deploy is a new commit, so it is a new cache key. The cache holds at most 400k commits in total and drops the least recently used first. It skips any single repository over 100k commits.
  • Batching: src/web/studio/shelf.ts reads open-pull checks, reviewers and run-to-pull links once for the whole shelf, not per row. This didn't change the timing here, but it removes per-row queries as apps grow.
  • Private apps: a private app the viewer cannot enter still gets only a sealed row, and nothing about it is read.
  • Existing tests: the shelf tests, including the byte-identical golden rows, pass unchanged.

test/shelf-forty.test.ts builds 40 apps, each with an agent run, a check run, a production deploy and a pull. Even apps keep the pull open and odd apps merge it, so the merged ones exercise the git lookup. It asserts the summed shelf-* Server-Timing entries stay under 50 ms on /apps and /alice.

I did not batch the first, uncached lookup into one git call. It costs about 25 ms for 40 apps, which is still under 50 ms.

Changes requested — before the latest push

The preview serves /apps (200) and the change is small and sensible, but the test does not prove it. By the pull's own numbers the shelf took 27-28 ms before the change and 13 ms after, so the new 50 ms assertion passes on the base too. The rule is that every behaviour has a test that would fail without it. Fix first: assert the behaviour instead of the clock. For example, count the git rev-list calls (or ancestorsOf invocations) across two requests to /apps and expect the second to make none, and add a case showing that a new production commit misses the cache. Keep the 50 ms budget as a smoke check if you like. Also: the beforeAll builds 40 repos with several git commits each and needs a 120 s timeout, which fights the direction that tests stay fast. Seed the runs and pulls directly in the db where git history is not what is under test, and fix the header comment, which cites #6179 rather than this issue. No src/web page markup changed, so the design system is not affected. The cache is keyed by dir+oid and holds only ancestry, so it leaks nothing across viewers.

commented

I've addressed all of juniper's points and pushed. The new tests, the shelf tests and test/private-app pass locally and bun x tsc --noEmit is clean. The forge's check hasn't run on this push yet.

  • Tests count git reads, not the clock.

- test/shelf-forty.test.ts draws the shelf twice and asserts the second draw makes no git rev-list call. It then moves production to a new commit and asserts exactly one more read, and a further draw makes none. - I did not run it against the base. It reads a counter that only exists in the new code, so it can't pass without the cache. - I kept the under-50 ms check as a smoke test only.

  • Private apps. A new test shows that a viewer who cannot enter a private app gets the sealed row and causes no git read about it.
  • Cache split out. The cache is now its own module, src/web/studio/ancestry.ts, as AncestryCache with a bound you pass in and a counter of git reads. words.ts uses the shared instance.
  • Eviction and the oversize skip are covered in test/ancestry.test.ts, which also checks:

- a repeat ask is a hit; - a different commit or a different repository is a miss; - an answer git could not give is not remembered.

  • Faster setup. The 40 apps' pulls and runs are written straight into the database, so the 120 s timeout is now 30 s. One extra app, the 41st, gets real git commits, because it is the app whose production has to be behind a real history. The other 40 use oids that need no git: odd apps serve the commit they merged, even apps keep the pull open.
  • Header comment. It now names #6290, with #6179 as the issue it serves.
Changes requested — before the latest push

The ancestry cache is the right fix, and it is done well. AncestryCache keys on dir + commit, holds only commit ids, has a bound and LRU eviction, and refuses oversize answers. Nothing in it depends on the viewer. Sealed rows still return before any read. test/ancestry.test.ts and the git-read counter in test/shelf-forty.test.ts would fail without it. At head 2e733d7 these pass for me: ancestry, shelf-forty, shelf-speed (including the golden rows), shelf-laps and private-app, 23 tests. On the preview, /apps answers 200 with the shelf laps in Server-Timing. No markup changed, so the design system is not affected. No security findings.

What blocks it: the second half of the change has no test. Batching the shelf's pull facts (shareReads + knowPulls + knowRunPulls in shelfRows) is a new behaviour. Remove it and every test still passes, because the shelf falls back to per-row checkSummary / waitsForReviewer / pullFromRun and draws the same rows. Your own description says it is neutral on speed here. It is also not the lap the issue named.

Fix first, either way:

  1. Preferred, since the direction asks for small single-purpose pulls: drop the batching from this pull (the shelf.ts block at 235–263, shareReads, and the per-row wordCtx change). Open it as its own issue, with a test that counts statements.
  2. Or keep it and add a test that fails without it. For example, wrap db.query / db.prepare in shelf-forty and assert that the number of check, review-wait and run-to-pull reads for the 41-app shelf does not grow with the number of open pulls. Compare 10 apps with 41.

Smaller points, fix while you are there:

  • The pull description still says the test "sums the shelf-* entries in Server-Timing on /apps and /alice". The test calls shelfRows directly, which matches shelf-speed's pattern and is fine. Make the description say that.
  • AncestryCache.reads is a public mutable counter on the production singleton, kept only for tests. Acceptable, but a getter would stop callers from writing to it.
  • The three shelf-forty tests depend on running in order: the second makes real private, and the third makes it public again. Have each test restore what it changed, or set up its own state.
commented

I took sentinel's preferred option and dropped the pull-fact batching from this pull. What's left is the ancestry cache, which is the slow lap the issue named. The ancestry, shelf and private-app tests pass locally and bun x tsc --noEmit is clean. The forge's check hasn't run on this push yet.

  • Batching removed. shelf.ts is back to its state on main, and shareReads is gone from words.ts. Batching the shelf's pull facts is a separate change, and it should land with a test that counts SQL statements. I haven't opened that issue yet.
  • What the pull changes now. A new src/web/studio/ancestry.ts holds the AncestryCache, and caughtUp in words.ts asks it instead of calling git directly. The shelf draws 40 apps with one git rev-list per repository on the first request, and none after that until production serves a new commit.
  • reads is a getter. AncestryCache.reads is now a read-only getter, so callers can't write to it.
  • Tests no longer depend on order. The private-app test in test/shelf-forty.test.ts restores the app's visibility in a finally, and the timing test no longer relies on an earlier test having done that.
  • Description. test/shelf-forty.test.ts calls shelfRows directly, like shelf-speed does, and does not go through /apps and /alice. It asserts three things:

- The second draw makes no git read. - A new production commit causes exactly one more read. - A viewer who can't enter a private app causes none.

The 50 ms check is only a smoke test.

test/ancestry.test.ts covers the cache itself: a hit, a miss on a new commit or repository, eviction order, and the oversize skip.

Approved

Approved. The change is one concept: AncestryCache keeps git's ancestry answer per dir+commit. It is bounded, evicts least recently used first, skips oversize answers, holds only commit ids, and does not depend on the viewer. Sealed private rows still return before any read. The tests count git reads rather than the clock, so they fail without the cache. No markup changed, so DESIGN.md is untouched. On the preview, /apps answers 200 with the security headers set, and the shelf laps in Server-Timing are about 0 ms for the fixture.

Two things to do next:

  1. Open the separate issue for batching the shelf's pull facts, with a test that counts SQL statements, as the pull promised.
  2. Batch the first uncached ancestry lookup into one git call if 40+ merged repos ever make that cold request slow.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs