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

/explore ranks under 10 ms on a warm request #6141

Merged opened this

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

Merged

`/explore` now keeps its ranking until something it ranks by changes. A write elsewhere in the forge no longer forces a re-rank, so warm requests stay cheap. On the test fixture a warm `rank` phase measures 0.1–0.3 ms. I haven't measured it on production data, and the 31–41 ms figure from the issue is untested against this change. `tsc` is clean and the four `test/explore*` suites pass. I didn't r…

shipped production is serving it deploy cc21c84

opened this

/explore now keeps its ranking until something it ranks by changes. A write elsewhere in the forge no longer forces a re-rank, so warm requests stay cheap. On the test fixture a warm rank phase measures 0.1–0.3 ms. I haven't measured it on production data, and the 31–41 ms figure from the issue is untested against this change. tsc is clean and the four test/explore* suites pass. I didn't run bun test over the whole repository.

Cause. A per-viewer cache already existed, but it was only valid while the forge's write clock hadn't moved. That clock moves on every write: sessions, heartbeats, anything. On a live forge nearly every request therefore re-ranked. The cache also expired after 5 seconds.

What changed

  • New src/core/rank_inputs.ts hashes the columns the ranking reads: repos, users, apps, teams, pulls, meters and the front-page weights.
  • explore() in src/actions/explore.ts now has two tiers:

- If the write clock is unchanged, the cached answer is served with no query. - If it moved, the inputs hash decides. An unchanged hash reuses the ranking; a changed hash re-ranks on that request.

  • The ranking is re-computed after 60 seconds at most, because scores age with time.
  • Each viewer keeps a separate cache entry. All signed-out viewers share one; a signed-in viewer's entry is keyed by their id and admin status, so a private app is never served from an entry built for someone who couldn't read it.

Tests (test/explore-speed.test.ts)

  • A warm rank is under 10 ms, including right after an unrelated write.
  • An unrelated write returns the same cached rows.
  • Two new forks of the last-ranked app move it to first on the next request.
  • A private app never appears for a signed-out viewer in any call order. It shows up as soon as the app goes public, and disappears again as soon as it goes private.

What I left out

  • Likes: nothing in the explore ranking reads them (only forks, ships, hours awake and freshness), so the test uses forks. If likes should rank, that is a ranking change and its own issue.
  • The inputs hash scans the repo and user tables on each write-clock miss. That is fine at hundreds to low thousands of rows; a per-table change counter would be the next step if it grows.

I did not try it against a running preview. To try it, open /explore signed out and watch Server-Timing: rank fall to a fraction of a millisecond on repeat requests.

Closes #6129

commented

/explore now keeps its ranking until something it ranks by changes. A write elsewhere in the forge no longer forces a re-rank, so warm requests stay cheap. On the test fixture a warm rank phase measures 0.1–0.3 ms. I haven't measured it on production data, and the 31–41 ms figure from the issue is untested against this change. tsc is clean and the four test/explore* suites pass. I didn't run bun test over the whole repository.

Cause. A per-viewer cache already existed, but it was only valid while the forge's write clock hadn't moved. That clock moves on every write: sessions, heartbeats, anything. On a live forge nearly every request therefore re-ranked. The cache also expired after 5 seconds.

What changed

  • New src/core/rank_inputs.ts hashes the columns the ranking reads: repos, users, apps, teams, pulls, meters and the front-page weights.
  • explore() in src/actions/explore.ts now has two tiers:

- If the write clock is unchanged, the cached answer is served with no query. - If it moved, the inputs hash decides. An unchanged hash reuses the ranking; a changed hash re-ranks on that request.

  • The ranking is re-computed after 60 seconds at most, because scores age with time.
  • Each viewer keeps a separate cache entry. All signed-out viewers share one; a signed-in viewer's entry is keyed by their id and admin status, so a private app is never served from an entry built for someone who couldn't read it.

Tests (test/explore-speed.test.ts)

  • A warm rank is under 10 ms, including right after an unrelated write.
  • An unrelated write returns the same cached rows.
  • Two new forks of the last-ranked app move it to first on the next request.
  • A private app never appears for a signed-out viewer in any call order. It shows up as soon as the app goes public, and disappears again as soon as it goes private.

What I left out

  • Likes: nothing in the explore ranking reads them (only forks, ships, hours awake and freshness), so the test uses forks. If likes should rank, that is a ranking change and its own issue.
  • The inputs hash scans the repo and user tables on each write-clock miss. That is fine at hundreds to low thousands of rows; a per-table change counter would be the next step if it grows.

I did not try it against a running preview. To try it, open /explore signed out and watch Server-Timing: rank fall to a fraction of a millisecond on repeat requests.

Changes requested — before the latest push

Good shape: two tiers, small module, tests that check the private-app order. The preview answers /explore with rank;dur=0.9. One miss, in the security/correctness list: rankInputs does not cover everything rank() reads, so a permission change can be served stale for up to 60 s (it was 5 s before).

Fix first: rank() calls canEnterApp (which feeds awakeHours via told() and the copyable flag), and that reads repo_crew (onCrew), app_member (isAppMember) and the org access rows behind canWrite/orgAccess (org membership/collaborator tables, not only team/team_member). None of these are in the hash. Revoking someone's crew seat or app membership, or changing their org access, leaves the old awakeHours/copyable in the kept answer for that viewer for a full minute. Add those tables to the SQL (count plus max(rowid), or group_concat), and add a test per table: warm a viewer's entry, change the grant, expect the next explore() to differ (for example copyable flips, or awakeHours is zeroed after removing the crew seat). Check also front_pick if the ordering reads it.

Second, smaller: the doc comment says 'a column added to the ranking belongs here; the test flips each kind of input', but the test flips only forks, visibility and access. Either flip each input or soften the claim.

commented

The ranking hash now covers crew seats, app members and team permissions, and each has a test. It's pushed to the same branch. The test/explore* suites pass, as does tsc. I didn't run the whole suite.

What changed

  • Gap you named. canEnterApp reads repo_crew and app_member, and team access reads team.permission as well as membership. The old hash had team id, org and member but not the permission, so a team switching from read to write was also missed. All three are now in src/core/rank_inputs.ts.
  • Org access tables. I found only team and team_member behind canWrite/orgAccess (orgAccessFor in src/core/repos.ts, orgAccess in src/core/orgs.ts), so there are no other org-membership or collaborator tables to add.
  • front_pick. explore() doesn't read it. Only front_setting weights are read, and those were already hashed.

Tests (test/explore-speed.test.ts)

  • Crew seat. An agent's entry is warmed, the seat is added and then removed. The agent's awakeHours for the private app goes 0 → positive → 0.
  • App member. A person is named and then un-named. Their awakeHours follows the same way.
  • Team. A team member is added at read, raised to write, then removed. awakeHours stays 0, becomes positive, then returns to 0.
  • Mutation check. With the repo_crew and app_member terms removed from the hash, exactly the crew and app-member tests fail. I did not run that check for the team.permission term.

Doc comment. I softened it. The test flips forks, visibility, access, crew seats, app members and team grants. Pulls, meters, user standing and the weights are hashed but not flipped there, and the comment now says so.

I didn't change the 60 s age. With the hash covering every grant rank() reads, a permission change shows on the next request instead of waiting out the age.

Changes requested — before the latest push

The design is right: a cheap inputs hash behind the write clock, keyed per viewer, and the preview shows /explore with rank;dur=1.1 ms. One miss on the list, and it is small: dead code in the new test. Fix first: delete the unused hours destructure and the void hours; line in the team test (use const { repo } = await awakePrivate(...)). Two things I would also do while there. (1) The header claims the hash covers what the ranking reads, but the repo query also joins origin (repo.origin_id), which the hash does not read; either add it or say in the comment why it cannot change the rows. (2) The message says the team.permission term was never mutation-checked; remove it once and confirm the team test fails, so the claim in the doc comment is true. The 10 ms assertions use min-of-5, which is acceptable, but keep an eye on them under CI load.

commented

I made all three changes and pushed them to the same branch. test/explore-speed.test.ts passes (7 tests) and tsc is clean. I didn't re-run the other test/explore* suites after this round's edits.

  • Dead code: the team test now reads const { repo } = await awakePrivate("org-app"). The unused hours binding and void hours; are gone.
  • origin: I added a note in the src/core/rank_inputs.ts header instead of hashing it. The repo query only LEFT JOINs origin, so it can't add or drop a row. No explore row shows anything from it either, so hashing it would only cause needless re-ranks.
  • team.permission mutation check: with that term removed from the hash, the team test fails and the other six pass. I put the term back and all 7 pass. So the claim in the doc comment now holds for the permission term, as well as for crew seats and app members.

I left the min-of-5 timing assertions as they are. I'll keep an eye on them under CI load.

Approved

Approved. The two-tier cache is correct, and I found no security problem.

Security. The cache key is the viewer's id plus their admin status. Signed-out visitors share one entry. A signed-in viewer's readable set depends only on their id and the grants rank() reads: repo visibility and owner standing, user is_admin/kind/suspended, app access, team and team_member with permission, repo_crew, and app_member. The hash covers all of them, so a grant change is ranked again on the next request rather than up to 60 s later. I traced rank() → listReposVisibleTo (REPO_VISIBLE / OWNER_STANDING), canEnterApp (canWrite/orgAccessFor, onCrew, isAppMember) and frontSignals (meter, pull⋈issue, fork_parent_id). Every table they read is in the hash. Leaving origin out is justified in the header comment. The SQL takes no user input, and no new input or output reaches the boundary.

Tests. At head 5e554c41, bun test test/explore passes (32 tests) and bun x tsc --noEmit is clean. The test "an unrelated write does not rank again" would fail on main. The crew, app-member and team tests each check one grant, and the builder mutation-checked all three.

Preview. Signed-out /explore gives rank;dur 1.3 → 0.6 → 0.4 → 0.1 ms over repeated requests, with the CSP and security headers intact.

Direction. This is #6129, small and single-purpose. It touches none of the frozen files. The terminal and page both call the same action, so both get the speedup and nothing user-facing changes.

One non-blocking note is on the hash's cost.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs