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

The kept front page is keyed on a one-row counter the database bumps when the shelf's data changes #6339

Merged opened this

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

Merged

scout/forge:scout/6325-the-kept-front-page-is-keyed-on-a-one-ro → main 2 commits fork

  • Merged. scout/6325-the-kept-front-page-is-keyed-on-a-one-ro was merged into main as c69afd7.

Review

Sign in to review this change.
7 files changed +151 −18
src/actions/front.ts +15 −8
@@ -45,6 +45,7 @@ import { keepLook } from "../looks/service.ts";
4545 import { canRead, getRepo, getReposByIds, type Repo } from "../core/repos.ts";
4646 import { listActiveRuns } from "../core/runs.ts";
4747 import { getUsersByIds, isActive, type User } from "../core/users.ts";
48 import { timePhase } from "../core/route_timing.ts";
4849 import { err, ok, type Result } from "../kernel/result.ts";
4950 import { limitValue } from "../settings/limits.ts";
5051
@@ -163,18 +164,24 @@ const PAGE_MS = 3_000;
163164 */
164165 const built = new WeakMap<App, { key: string; at: number; page: FrontPage }>();
165166
166 function dbKey(app: App): string {
167 /**
168 * The shelf's data version: one row that triggers bump when a column the
169 * shelf renders changes (`front_epoch`). Its size never depends on how many
170 * apps there are, and a session, a heartbeat or a push does not move it.
171 */
172 export function frontKey(app: App): string {
167173 const row = app.db
168 .query<{ c: number; v: number }, []>(
169 "SELECT total_changes() AS c, (SELECT data_version FROM pragma_data_version) AS v",
170 )
174 .query<{ n: number }, []>("SELECT n FROM front_epoch")
171175 .get();
172 return `${row?.c}:${row?.v}`;
176 return String(row?.n);
173177 }
174178
175 /** The front page as a stranger is shown it. */
176 export function frontPage(app: App, now = Date.now()): FrontPage {
177 const key = dbKey(app);
179 /** The front page as a stranger is shown it; `request` is told what the key cost. */
180 export function frontPage(app: App, now = Date.now(), request?: Request): FrontPage {
181 const key =
182 request === undefined
183 ? frontKey(app)
184 : timePhase(request, "key", () => frontKey(app));
178185 const kept = built.get(app);
179186 const wall = performance.now();
180187 if (kept !== undefined && kept.key === key && wall - kept.at < PAGE_MS)
src/core/migrations/20261026120000-front-epoch.ts added +66
@@ -0,0 +1,66 @@
1 // The kept front page asks one row whether the shelf's data changed, instead
2 // of reading every app and look (#6325). `front_epoch.n` is bumped by
3 // triggers on only the columns the shelf renders: never `repo.issue_seq` or
4 // `repo.last_fetch_at`, which move on every push, and on `look` only a
5 // production thumb. Additive.
6 export default `
7 CREATE TABLE front_epoch (n INTEGER NOT NULL);
8 INSERT INTO front_epoch (n) VALUES (0);
9
10 CREATE TRIGGER front_epoch_app_insert AFTER INSERT ON app
11 BEGIN UPDATE front_epoch SET n = n + 1; END;
12
13 CREATE TRIGGER front_epoch_app_delete AFTER DELETE ON app
14 BEGIN UPDATE front_epoch SET n = n + 1; END;
15
16 CREATE TRIGGER front_epoch_app_update AFTER UPDATE OF repo_id, slug, access ON app
17 BEGIN UPDATE front_epoch SET n = n + 1; END;
18
19 CREATE TRIGGER front_epoch_repo_insert AFTER INSERT ON repo
20 BEGIN UPDATE front_epoch SET n = n + 1; END;
21
22 CREATE TRIGGER front_epoch_repo_delete AFTER DELETE ON repo
23 BEGIN UPDATE front_epoch SET n = n + 1; END;
24
25 CREATE TRIGGER front_epoch_repo_update AFTER UPDATE OF owner_id, name, visibility, description, archived_at, sealed_for ON repo
26 BEGIN UPDATE front_epoch SET n = n + 1; END;
27
28 CREATE TRIGGER front_epoch_front_pick_insert AFTER INSERT ON front_pick
29 BEGIN UPDATE front_epoch SET n = n + 1; END;
30
31 CREATE TRIGGER front_epoch_front_pick_delete AFTER DELETE ON front_pick
32 BEGIN UPDATE front_epoch SET n = n + 1; END;
33
34 CREATE TRIGGER front_epoch_front_pick_update AFTER UPDATE OF place, position ON front_pick
35 BEGIN UPDATE front_epoch SET n = n + 1; END;
36
37 CREATE TRIGGER front_epoch_front_setting_insert AFTER INSERT ON front_setting
38 BEGIN UPDATE front_epoch SET n = n + 1; END;
39
40 CREATE TRIGGER front_epoch_front_setting_delete AFTER DELETE ON front_setting
41 BEGIN UPDATE front_epoch SET n = n + 1; END;
42
43 CREATE TRIGGER front_epoch_front_setting_update AFTER UPDATE OF value ON front_setting
44 BEGIN UPDATE front_epoch SET n = n + 1; END;
45
46 CREATE TRIGGER front_epoch_user_insert AFTER INSERT ON user
47 BEGIN UPDATE front_epoch SET n = n + 1; END;
48
49 CREATE TRIGGER front_epoch_user_delete AFTER DELETE ON user
50 BEGIN UPDATE front_epoch SET n = n + 1; END;
51
52 CREATE TRIGGER front_epoch_user_update AFTER UPDATE OF username, suspended_at, removed_at ON user
53 BEGIN UPDATE front_epoch SET n = n + 1; END;
54
55 CREATE TRIGGER front_epoch_look_insert AFTER INSERT ON look
56 WHEN NEW.env = 'production' AND NEW.kind = 'thumb'
57 BEGIN UPDATE front_epoch SET n = n + 1; END;
58
59 CREATE TRIGGER front_epoch_look_delete AFTER DELETE ON look
60 WHEN OLD.env = 'production' AND OLD.kind = 'thumb'
61 BEGIN UPDATE front_epoch SET n = n + 1; END;
62
63 CREATE TRIGGER front_epoch_look_update AFTER UPDATE OF app_id, env, commit_oid, kind, path, error, taken_as ON look
64 WHEN NEW.env = 'production' AND NEW.kind = 'thumb' OR OLD.env = 'production' AND OLD.kind = 'thumb'
65 BEGIN UPDATE front_epoch SET n = n + 1; END;
66 `;
src/core/repos.ts +5 −4
@@ -1152,13 +1152,14 @@ export function moveArchived(
11521152 archived: boolean,
11531153 at: string = new Date().toISOString(),
11541154 ): boolean {
1155 // RETURNING counts the repository, not the trigger's write to `front_epoch`.
11551156 const changes = archived
11561157 ? db
1157 .query("UPDATE repo SET archived_at = ?2 WHERE id = ?1 AND archived_at IS NULL")
1158 .run(repoId, at).changes
1158 .query("UPDATE repo SET archived_at = ?2 WHERE id = ?1 AND archived_at IS NULL RETURNING id")
1159 .all(repoId, at).length
11591160 : db
1160 .query("UPDATE repo SET archived_at = NULL WHERE id = ?1 AND archived_at IS NOT NULL")
1161 .run(repoId).changes;
1161 .query("UPDATE repo SET archived_at = NULL WHERE id = ?1 AND archived_at IS NOT NULL RETURNING id")
1162 .all(repoId).length;
11621163 if (changes === 1) forgetListings(db);
11631164 return changes === 1;
11641165 }
src/core/users.ts +3 −2
@@ -218,8 +218,9 @@ interface CreateUserFields {
218218 * other trace is removed; anything else stays.
219219 */
220220 export function discardFreshUser(db: Database, userId: string): void {
221 const removed = db.query("DELETE FROM user WHERE id = ?1 AND password_hash = ''").run(userId).changes;
222 if (removed === 1) reopenInvite(db, userId);
221 // RETURNING counts the account, not the trigger's write to `front_epoch`.
222 const removed = db.query("DELETE FROM user WHERE id = ?1 AND password_hash = '' RETURNING id").all(userId);
223 if (removed.length === 1) reopenInvite(db, userId);
223224 }
224225
225226 /**
src/web/studio/home.ts +1 −1
@@ -504,7 +504,7 @@ function frontControls(ctx: Ctx, front: FrontPage): Html {
504504 export function studioDoor(ctx: Ctx): Response {
505505 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
506506 let tiles: Html[] = [];
507 const front = timePhase(ctx.request, "shelf", () => frontPage(ctx.app));
507 const front = timePhase(ctx.request, "shelf", () => frontPage(ctx.app, Date.now(), ctx.request));
508508 timePhase(ctx.request, "tiles", () => {
509509 tiles = front.shelf.map(tile);
510510 });
test/front-speed.test.ts +59 −1
@@ -5,7 +5,7 @@ import { mkdtemp, rm } from "node:fs/promises";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
8 import { forgetFrontScores, frontPage } from "../src/actions/front.ts";
8 import { forgetFrontScores, frontKey, frontPage } from "../src/actions/front.ts";
99 import { createRepo } from "../src/actions/repos.ts";
1010 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
1111 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
@@ -103,3 +103,61 @@ test("making one app private builds a new page without it on the next request",
103103 expect(after.total).toBe(APPS - 1);
104104 expect([after.featured, ...after.shelf].some((e) => e?.repo.id === gone.id)).toBe(false);
105105 });
106
107 test("a session insert and an issue_seq bump leave the kept page as it was (#6325)", () => {
108 const first = frontPage(app);
109 const owner = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id;
110 app.db
111 .query("INSERT INTO session (token_hash, user_id, expires_at) VALUES ('h6325', ?1, '2099-01-01T00:00:00Z')")
112 .run(owner);
113 app.db.query("UPDATE repo SET issue_seq = issue_seq + 1, last_fetch_at = 'now'").run();
114 expect(frontPage(app)).toBe(first);
115 });
116
117 test("archiving a repo, making an app private and retaking a thumb each give a new page (#6325)", () => {
118 const ids = (p: ReturnType<typeof frontPage>) =>
119 [p.featured, ...p.shelf].flatMap((e) => (e === undefined ? [] : [e.repo.id]));
120 let page = frontPage(app);
121 const [a, b] = ids(page);
122 app.db.query("UPDATE repo SET archived_at = '2026-10-26T00:00:00Z' WHERE id = ?1").run(a!);
123 let next = frontPage(app);
124 expect(next).not.toBe(page);
125 expect(frontPage(app)).toBe(next);
126 page = next;
127 app.db.query("UPDATE app SET access = 'private' WHERE repo_id = ?1").run(b!);
128 next = frontPage(app);
129 expect(next).not.toBe(page);
130 expect(frontPage(app)).toBe(next);
131 page = next;
132 const appId = app.db.query<{ id: string }, [string]>("SELECT id FROM app WHERE repo_id = ?1").get(ids(page)[0]!)!.id;
133 recordLook(app.db, { appId, env: "production", commitOid: "b".repeat(40), kind: "thumb", path: `looks/${appId}/thumb.png` });
134 next = frontPage(app);
135 expect(next).not.toBe(page);
136 // A phone look, or a preview's thumb, is not the shelf's concern.
137 recordLook(app.db, { appId, env: "production", commitOid: "c".repeat(40), kind: "phone", path: "x.png" });
138 recordLook(app.db, { appId, env: "preview", commitOid: "c".repeat(40), kind: "thumb", path: "x.png" });
139 expect(frontPage(app)).toBe(next);
140 });
141
142 test("with 2,000 more apps the key keeps its size and reads no app or look column (#6325)", () => {
143 const owner = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id;
144 const small = frontKey(app);
145 app.db.transaction(() => {
146 for (let n = 0; n < 2000; n++) {
147 app.db.query("INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, 'public')").run(`bulk${n}`, owner, `bulk${n}`);
148 app.db.query("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'public')").run(`bulkapp${n}`, `bulk${n}`, `bulk${n}`);
149 }
150 })();
151 const big = frontKey(app);
152 expect(big).not.toBe(small);
153 expect(big).toMatch(/^\d{1,9}$/);
154 const page = frontPage(app);
155 const asked: string[] = [];
156 const spy = spyOn(app.db, "query").mockImplementation(((sql: string) => {
157 asked.push(sql);
158 return (Object.getPrototypeOf(app.db).query as (s: string) => unknown).call(app.db, sql);
159 }) as never);
160 expect(frontPage(app)).toBe(page);
161 spy.mockRestore();
162 expect(asked).toEqual(["SELECT n FROM front_epoch"]);
163 });
test/front-timing.test.ts +2 −2
@@ -33,13 +33,13 @@ async function read(): Promise<string[]> {
3333 return (res.headers.get("server-timing") ?? "").split(", ");
3434 }
3535
36 test("GET / signed out names session, shelf, tiles, render in order and covers the handler's share of app", async () => {
36 test("GET / signed out names session, key, shelf, tiles, render in order and covers the handler's share of app", async () => {
3737 for (let i = 0; i < 5; i++) await read(); // warm
3838 const shares: number[] = [];
3939 for (let i = 0; i < 15; i++) {
4040 const parts = await read();
4141 const names = parts.map((p) => p.split(";")[0]);
42 expect(names.filter((n) => n !== "app" && n !== "db")).toEqual(["session", "shelf", "tiles", "render"]);
42 expect(names.filter((n) => n !== "app" && n !== "db")).toEqual(["session", "key", "shelf", "tiles", "render"]);
4343 const total = dur(parts.find((p) => p.startsWith("app;"))!);
4444 const named = parts.filter((p) => /^(session|shelf|tiles|render);/.test(p)).reduce((s, p) => s + dur(p), 0);
4545 shares.push(total === 0 ? 1 : named / total);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs