Public: anyone can read this; the crew can change it.
/explore ranks under 10 ms on a warm request #6141
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6129-explore-ranks-under-10-ms-on-a-warm-requ → main 3 commits fork 3 threads · 3 unresolved
src/actions/explore.ts +25 −13
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | import { isSafeName } from "../git/guard.ts"; |
| 29 | 29 | import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts"; |
| 30 | 30 | import { getUsersByIds, isActive, type User } from "../core/users.ts"; |
| 31 | import { rankInputs } from "../core/rank_inputs.ts"; | |
| 31 | 32 | import { writeStamp } from "../core/write_clock.ts"; |
| 32 | 33 | import { Stamped } from "../kernel/stamped.ts"; |
| 33 | 34 | import { listChildren } from "./spawn.ts"; |
| @@ -153,14 +154,20 @@ export interface ExploreRow { | ||
| 153 | 154 | |
| 154 | 155 | /** |
| 155 | 156 | * The ranking as last computed, per viewer (id and admin standing) and line |
| 156 | * (#3394). Valid while the forge's write clock has not moved — a new star, | |
| 157 | * push or merge moves it, so it shows on the next request — and for 5 seconds | |
| 158 | * at most, because a score ages with the clock. Admins' answers are kept too; | |
| 159 | * the spawned forges, which no write clock sees, are read fresh on every call | |
| 160 | * and added to a copy after the kept read. A call at a given `now` is never kept. | |
| 157 | * (#3394, #6129). A signed-out viewer has the one key every stranger shares; | |
| 158 | * a signed-in one is keyed by id, which fixes their readable set — so a | |
| 159 | * private app is never served from an entry built for someone who could not | |
| 160 | * read it. Two tiers: while the forge's write clock has not moved, the answer | |
| 161 | * stands without a query; when it has (it moves on every write, ranked or | |
| 162 | * not), `rankInputs` says whether anything the ranking reads changed, and | |
| 163 | * only then is it ranked again. A score ages with the clock, so the answer | |
| 164 | * is ranked again after a minute at most. The spawned forges, which no write | |
| 165 | * clock sees, are read fresh on every call and added to a copy after the kept | |
| 166 | * read. A call at a given `now` is never kept. | |
| 161 | 167 | */ |
| 162 | const RANK_MAX_AGE_MS = 5_000; | |
| 163 | const ranked = new WeakMap<Database, Stamped<ExploreRow[]>>(); | |
| 168 | const RANK_MAX_AGE_MS = 60_000; | |
| 169 | const CLOCK_MAX_AGE_MS = 5_000; | |
| 170 | const ranked = new WeakMap<Database, { clock: Stamped<ExploreRow[]>; inputs: Stamped<ExploreRow[]> }>(); | |
| 164 | 171 | |
| 165 | 172 | export async function explore( |
| 166 | 173 | app: App, |
| @@ -171,13 +178,18 @@ export async function explore( | ||
| 171 | 178 | const forgeRows = await forges(app, viewer, query); |
| 172 | 179 | const stamp = now === undefined ? writeStamp(app.db) : undefined; |
| 173 | 180 | if (stamp === undefined) return place(rank(app, viewer, query, now ?? Date.now()), forgeRows, query); |
| 174 | let cache = ranked.get(app.db); | |
| 175 | if (cache === undefined) { | |
| 176 | cache = new Stamped<ExploreRow[]>({ maxEntries: 64, maxAgeMs: RANK_MAX_AGE_MS }); | |
| 177 | ranked.set(app.db, cache); | |
| 181 | let tiers = ranked.get(app.db); | |
| 182 | if (tiers === undefined) { | |
| 183 | tiers = { | |
| 184 | clock: new Stamped<ExploreRow[]>({ maxEntries: 64, maxAgeMs: CLOCK_MAX_AGE_MS }), | |
| 185 | inputs: new Stamped<ExploreRow[]>({ maxEntries: 64, maxAgeMs: RANK_MAX_AGE_MS }), | |
| 186 | }; | |
| 187 | ranked.set(app.db, tiers); | |
| 178 | 188 | } |
| 179 | const kept = cache.get(`${viewer?.id ?? ""}\n${viewer?.isAdmin === true ? "admin" : ""}\n${exploreLine(query)}`, stamp, () => | |
| 180 | rank(app, viewer, query, Date.now()), | |
| 189 | const { clock, inputs } = tiers; | |
| 190 | const key = `${viewer?.id ?? ""}\n${viewer?.isAdmin === true ? "admin" : ""}\n${exploreLine(query)}`; | |
| 191 | const kept = clock.get(key, stamp, () => | |
| 192 | inputs.get(key, rankInputs(app.db), () => rank(app, viewer, query, Date.now())), | |
| 181 | 193 | ); |
| 182 | 194 | return place(kept, forgeRows, query); |
| 183 | 195 | } |
src/core/rank_inputs.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * What the explorer's ranking reads, as one short string (#6129). | |
| 3 | * | |
| 4 | * The write clock (`write_clock.ts`) moves on every write in the forge — a | |
| 5 | * session, a heartbeat, a log line — so on a busy forge it says "stale" far | |
| 6 | * more often than the ranking changed. This is the finer question: the | |
| 7 | * columns `actions/explore.ts` ranks, filters and shows by, every grant | |
| 8 | * that decides who may read or enter what (teams and their permission, crew | |
| 9 | * seats, the people a private app names), and the front page's weights, | |
| 10 | * reduced to one hash. Equal hashes mean the ranking would come out the same (up to the | |
| 11 | * passage of time, which its max age covers). | |
| 12 | * | |
| 13 | * Not hashed on purpose: the repo query LEFT JOINs `origin`, which cannot add | |
| 14 | * or drop a row, and no explore row shows anything from it. | |
| 15 | * | |
| 16 | * An input added to the ranking belongs here. `test/explore-speed.test.ts` | |
| 17 | * flips the forks, visibility, access, crew seats, app members and team | |
| 18 | * grants and watches the answer follow; pulls, meters, user standing and the | |
| 19 | * weights are hashed but not flipped there. | |
| 20 | */ | |
| 21 | ||
| 22 | import type { Database } from "bun:sqlite"; | |
| 23 | ||
| 24 | const SQL = `SELECT | |
Non-blocking: on every write-clock miss, this concatenates every row of |
||
| 25 | (SELECT group_concat(id || char(31) || owner_id || char(31) || name || char(31) || visibility || char(31) | |
| 26 | || description || char(31) || COALESCE(archived_at, '') || char(31) || COALESCE(fork_parent_id, ''), char(30)) FROM repo) AS repo, | |
| 27 | (SELECT group_concat(id || char(31) || username || char(31) || display_name || char(31) || is_admin || char(31) | |
| 28 | || is_agent || char(31) || kind || char(31) || COALESCE(suspended_at, '') || char(31) || COALESCE(removed_at, ''), char(30)) FROM user) AS user, | |
| 29 | (SELECT group_concat(repo_id || char(31) || access, char(30)) FROM app) AS app, | |
| 30 | (SELECT group_concat(team.id || char(31) || team.org_id || char(31) || team.permission || char(31) || team_member.user_id, char(30)) | |
| 31 | FROM team JOIN team_member ON team_member.team_id = team.id) AS team, | |
| 32 | (SELECT group_concat(repo_id || char(31) || user_id, char(30)) FROM repo_crew) AS crew, | |
| 33 | (SELECT group_concat(app_id || char(31) || user_id, char(30)) FROM app_member) AS named, | |
| 34 | (SELECT count(*) || char(31) || COALESCE(max(rowid), 0) || char(31) || count(merged_at) || char(31) || COALESCE(max(merged_at), '') | |
| 35 | FROM pull) AS pull, | |
| 36 | (SELECT count(*) || char(31) || COALESCE(max(rowid), 0) || char(31) || count(ended_at) || char(31) | |
| 37 | || COALESCE(max(ended_at), '') || char(31) || COALESCE(max(started_at), '') FROM meter) AS meter, | |
| 38 | (SELECT group_concat(key || char(31) || value, char(30)) FROM front_setting) AS setting`; | |
| 39 | ||
| 40 | /** A hash of the ranking's inputs; cheap enough to ask on every request the write clock calls stale. */ | |
| 41 | export function rankInputs(db: Database): string { | |
| 42 | const row = db.query<Record<string, string | null>, []>(SQL).get(); | |
| 43 | if (row === null) return ""; | |
| 44 | return String(Bun.hash(Object.values(row).map((v) => v ?? "").join(String.fromCharCode(29)))); | |
| 45 | } |
test/explore-speed.test.ts added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | /** /explore's ranking is kept on what it ranks by: warm under 10 ms, fresh on the next change, never shared across readers (#6129). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 4 | import { explore, parseExploreQuery } from "../src/actions/explore.ts"; | |
| 5 | import { createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 7 | import { createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { addCrew, removeCrew } from "../src/core/agents.ts"; | |
| 9 | import { setAppMembers } from "../src/core/app_members.ts"; | |
| 10 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | ||
| 14 | let f: SnappyFixture; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | f = await snappyFixture(); | |
| 18 | for (const repo of f.repos) { | |
| 19 | if (repo.visibility !== "public") continue; | |
| 20 | const made = insertApp(f.app.db, { repoId: repo.id, slug: `${repo.ownerName}-${repo.name}`, access: "public" }); | |
| 21 | if (!made.ok) throw new Error(made.error); | |
| 22 | } | |
| 23 | }, 120_000); | |
| 24 | afterAll(async () => { | |
| 25 | await f.close(); | |
| 26 | }); | |
| 27 | ||
| 28 | const all = parseExploreQuery(""); | |
| 29 | const byForks = parseExploreQuery("kind:app sort:forks"); | |
| 30 | const names = (rows: readonly { name: string }[]) => rows.map((r) => r.name); | |
| 31 | ||
| 32 | async function app(owner: typeof f.owner, name: string, access: "public" | "private") { | |
| 33 | const repo = await createRepo(f.app, owner, { name, visibility: access }); | |
| 34 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 35 | const made = insertApp(f.app.db, { repoId: repo.value.id, slug: `${owner.username}-${name}`, access }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | return repo.value; | |
| 38 | } | |
| 39 | ||
| 40 | test("a warm rank is under 10 ms, even after a write the ranking does not read", async () => { | |
| 41 | await explore(f.app, undefined, all); | |
| 42 | const warm: number[] = []; | |
| 43 | const busy: number[] = []; | |
| 44 | for (let i = 0; i < 5; i++) { | |
| 45 | let t = performance.now(); | |
| 46 | await explore(f.app, undefined, all); | |
| 47 | warm.push(performance.now() - t); | |
| 48 | createSession(f.app.db, f.peer.id); // moves the write clock; ranks nothing | |
| 49 | t = performance.now(); | |
| 50 | await explore(f.app, undefined, all); | |
| 51 | busy.push(performance.now() - t); | |
| 52 | } | |
| 53 | expect(Math.min(...warm)).toBeLessThan(10); | |
| 54 | expect(Math.min(...busy)).toBeLessThan(10); | |
| 55 | }); | |
| 56 | ||
| 57 | test("an unrelated write does not rank again", async () => { | |
| 58 | const before = await explore(f.app, undefined, all); | |
| 59 | expect(before.length).toBeGreaterThan(3); | |
| 60 | createSession(f.app.db, f.peer.id); | |
| 61 | // Kept rows are shared objects: the same row back means it was not ranked again. | |
| 62 | expect((await explore(f.app, undefined, all))[0]).toBe(before[0]); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a new fork reorders the list on the next request", async () => { | |
| 66 | await explore(f.app, undefined, byForks); | |
| 67 | const last = (await explore(f.app, undefined, byForks)).at(-1)!; | |
| 68 | const parent = f.repos.find((r) => `${r.ownerName}/${r.name}` === last.name); | |
| 69 | if (parent === undefined) throw new Error(`no repo for ${last.name}`); | |
| 70 | const first = names(await explore(f.app, undefined, byForks))[0]; | |
| 71 | expect(first).not.toBe(last.name); | |
| 72 | for (const n of ["a", "b"]) { | |
| 73 | const fork = await createRepo(f.app, f.peer, { name: `fork-of-last-${n}`, visibility: "public" }); | |
| 74 | if (!fork.ok) throw new Error(String(fork.error)); | |
| 75 | f.app.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(parent.id, fork.value.id); | |
| 76 | } | |
| 77 | expect(names(await explore(f.app, undefined, byForks))[0]).toBe(last.name); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a private app is never served from an entry built for a viewer who could not read it", async () => { | |
| 81 | const peer = f.peer; | |
| 82 | const secret = await app(peer, "secret-garden", "private"); | |
| 83 | const name = `${peer.username}/${secret.name}`; | |
| 84 | // Stranger first, then the owner, then the stranger again: every order. | |
| 85 | expect(names(await explore(f.app, undefined, all))).not.toContain(name); | |
| 86 | expect(names(await explore(f.app, peer, all))).toContain(name); | |
| 87 | expect(names(await explore(f.app, undefined, all))).not.toContain(name); | |
| 88 | expect(names(await explore(f.app, f.owner, all))).toContain(name); | |
| 89 | // The other direction: the owner's entry is warm, then the repository goes public. | |
| 90 | await explore(f.app, peer, all); | |
| 91 | f.app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(secret.id); | |
| 92 | f.app.db.query("UPDATE app SET access = 'public' WHERE repo_id = ?1").run(secret.id); | |
| 93 | expect(names(await explore(f.app, undefined, all))).toContain(name); | |
| 94 | f.app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(secret.id); | |
| 95 | expect(names(await explore(f.app, undefined, all))).not.toContain(name); | |
| 96 | }); | |
| 97 | ||
| 98 | // Grants canEnterApp reads (#6129): each is warmed in the viewer's entry, changed, and the next answer must follow. | |
| 99 | async function guest(username: string, kind: "user" | "agent" = "user") { | |
| 100 | if (kind === "agent") { | |
| 101 | const made = await createAgent(f.app, f.owner, { username }); | |
| 102 | if (!made.ok) throw new Error(String(made.error)); | |
| 103 | return made.value.user; | |
| 104 | } | |
| 105 | const made = await createUser(f.app.db, { | |
| 106 | origin: "seed", | |
| 107 | trusted: true, | |
| 108 | username, | |
| 109 | email: `${username}@forge.local`, | |
| 110 | password: "a-good-passphrase", | |
| 111 | adminIfFirst: false, | |
| 112 | }); | |
| 113 | if (!made.ok) throw new Error(made.error); | |
| 114 | return made.value; | |
| 115 | } | |
| 116 | ||
| 117 | /** A private app of the peer's, awake for three hours, and what `viewer` is told about its use. */ | |
| 118 | async function awakePrivate(name: string) { | |
| 119 | const repo = await app(f.peer, name, "private"); | |
| 120 | f.app.db | |
| 121 | .query( | |
| 122 | `INSERT INTO meter (repo_id, env, placement, started_at, beat_at) VALUES (?1, 'production', 'owned', ?2, ?2)`, | |
| 123 | ) | |
| 124 | .run(repo.id, new Date(Date.now() - 3 * 3_600_000).toISOString()); | |
| 125 | const hours = async (viewer: typeof f.owner) => | |
| 126 | (await explore(f.app, viewer, parseExploreQuery(`kind:app ${name}`))).find((r) => r.name === `${f.peer.username}/${name}`) | |
| 127 | ?.numbers.awakeHours; | |
| 128 | return { repo, hours }; | |
| 129 | } | |
| 130 | ||
| 131 | test("removing a crew seat takes the app's use from the agent's next answer", async () => { | |
| 132 | const bot = await guest("bot-seat", "agent"); | |
| 133 | const { repo, hours } = await awakePrivate("crew-seat"); | |
| 134 | expect(await hours(bot)).toBeUndefined(); // an agent not on the crew cannot read it at all | |
| 135 | f.app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id); | |
| 136 | expect(await hours(bot)).toBe(0); | |
| 137 | expect(addCrew(f.app.db, repo.id, bot.id)).toBe(true); | |
| 138 | expect(await hours(bot)).toBeGreaterThan(2); | |
| 139 | expect(await hours(bot)).toBeGreaterThan(2); | |
| 140 | removeCrew(f.app.db, repo.id, bot.id); | |
| 141 | expect(await hours(bot)).toBe(0); | |
| 142 | }); | |
| 143 | ||
| 144 | test("naming and un-naming a person changes what their next answer tells", async () => { | |
| 145 | const carol = await guest("carol-named"); | |
| 146 | const { repo, hours } = await awakePrivate("named-app"); | |
| 147 | f.app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id); | |
| 148 | const record = f.app.db.query<{ id: string }, [string]>("SELECT id FROM app WHERE repo_id = ?1").get(repo.id)!; | |
| 149 | expect(await hours(carol)).toBe(0); | |
| 150 | setAppMembers(f.app.db, record.id, [carol.id], f.peer.id); | |
| 151 | expect(await hours(carol)).toBeGreaterThan(2); | |
| 152 | expect(await hours(carol)).toBeGreaterThan(2); | |
| 153 | setAppMembers(f.app.db, record.id, [], f.peer.id); | |
| 154 | expect(await hours(carol)).toBe(0); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a team's permission and membership change the next answer of the org's member", async () => { | |
| 158 | const dana = await guest("dana-team"); | |
| 159 | const org = createOrg(f.app.db, "teamwork", f.owner.id); | |
| 160 | if (!org.ok) throw new Error(org.error); | |
| 161 | const { repo } = await awakePrivate("org-app"); | |
| 162 | f.app.db.query("UPDATE repo SET owner_id = ?1, visibility = 'public' WHERE id = ?2").run(org.value.id, repo.id); | |
| 163 | const team = createTeam(f.app.db, org.value.id, "builders", "read"); | |
| 164 | if (!team.ok) throw new Error(team.error); | |
| 165 | const name = `teamwork/org-app`; | |
| 166 | const seen = async () => | |
| 167 | (await explore(f.app, dana, parseExploreQuery("kind:app org-app"))).find((r) => r.name === name)?.numbers.awakeHours; | |
| 168 | expect(await seen()).toBe(0); | |
| 169 | addTeamMember(f.app.db, team.value, dana.id); | |
| 170 | expect(await seen()).toBe(0); // read only: still not told | |
| 171 | f.app.db.query("UPDATE team SET permission = 'write' WHERE id = ?1").run(team.value.id); | |
| 172 | expect(await seen()).toBeGreaterThan(2); | |
| 173 | expect(await seen()).toBeGreaterThan(2); | |
| 174 | f.app.db.query("DELETE FROM team_member WHERE team_id = ?1").run(team.value.id); | |
| 175 | expect(await seen()).toBe(0); | |
| 176 | }); | |
outdated
|
app is hashed, but app_member, repo_crew and the org-access tables canEnterApp reads are not. Add them here, with a test that changes each grant and expects the viewer's next answer to follow.