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

scout/forge:scout/6129-explore-ranks-under-10-ms-on-a-warm-requ → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6129-explore-ranks-under-10-ms-on-a-warm-requ was merged into main as 59bcf62.

Review

Sign in to review this change.
3 files changed +246 −13
src/actions/explore.ts +25 −13
@@ -28,6 +28,7 @@ import {
2828 import { isSafeName } from "../git/guard.ts";
2929 import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts";
3030 import { getUsersByIds, isActive, type User } from "../core/users.ts";
31 import { rankInputs } from "../core/rank_inputs.ts";
3132 import { writeStamp } from "../core/write_clock.ts";
3233 import { Stamped } from "../kernel/stamped.ts";
3334 import { listChildren } from "./spawn.ts";
@@ -153,14 +154,20 @@ export interface ExploreRow {
153154
154155 /**
155156 * 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.
161167 */
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[]> }>();
164171
165172 export async function explore(
166173 app: App,
@@ -171,13 +178,18 @@ export async function explore(
171178 const forgeRows = await forges(app, viewer, query);
172179 const stamp = now === undefined ? writeStamp(app.db) : undefined;
173180 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);
178188 }
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())),
181193 );
182194 return place(kept, forgeRows, query);
183195 }
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
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.

24 const SQL = `SELECT

Non-blocking: on every write-clock miss, this concatenates every row of repo and user, descriptions included. That costs O(table) per stale request, and on a busy forge a stale request comes at most every 5 s for each viewer key. It is fine at today's size. If production rank;dur creeps up as the tables grow, the next step is the one the pull names: a per-table change counter kept by triggers. Worth an issue once the table sizes justify it.

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

void hours; exists only to silence an unused variable. Remove it and the hours in the destructure on the awakePrivate("org-app") line.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs