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

A pull's files page answers under 50 ms on a diff cache hit #6131

Merged opened this

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

Merged

scout/forge:scout/6128-a-pull-s-files-page-answers-under-50-ms → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6128-a-pull-s-files-page-answers-under-50-ms was merged into main as d5fa305.

Review

Sign in to review this change.
6 files changed +207 −17
src/core/runs.ts +11 −4
@@ -815,13 +815,14 @@ export function checksForHead(
815815 db: Database,
816816 issueId: string,
817817 headOid: string,
818 limit = -1,
818819 ): Run[] {
819820 return db
820821 .query<
821822 RunRow,
822 [string, string]
823 >("SELECT * FROM run WHERE kind IN ('deploy','check') AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC")
824 .all(issueId, headOid)
823 [string, string, number]
824 >("SELECT * FROM run WHERE kind IN ('deploy','check') AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC LIMIT ?3")
825 .all(issueId, headOid, limit)
825826 .map(toRun);
826827 }
827828
@@ -854,7 +855,13 @@ export function checkSummary(
854855 issueId: string,
855856 headOid: string,
856857 ): CheckSummary {
857 const latest = checksForHead(db, issueId, headOid)[0];
858 const row = db
859 .query<
860 RunRow,
861 [string, string]
862 >("SELECT * FROM run WHERE kind IN ('deploy','check') AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC LIMIT 1")
863 .get(issueId, headOid);
864 const latest = row ? toRun(row) : undefined;
858865 return latest
859866 ? { state: latest.state, run: latest }
860867 : { state: "none", run: null };
src/web/diff-cache.ts +28
@@ -11,6 +11,7 @@
1111 */
1212
1313 import type { Diff } from "../git/diff.ts";
14 import type { Html } from "./html.ts";
1415 import type { SyntaxMap } from "./diff-view.ts";
1516
1617 export interface KeptDiff {
@@ -86,7 +87,34 @@ export async function keptDiff(key: string, make: () => Promise<KeptDiff>): Prom
8687 /** Forget everything (tests). */
8788 export function clearDiffCache(): void {
8889 kept.clear();
90 rendered.clear();
8991 total = 0;
9092 hits = 0;
9193 misses = 0;
9294 }
95
96 const RENDER_ENTRIES = 16;
97 const RENDER_MAX_CHARS = 2 * 1024 * 1024;
98 const rendered = new Map<string, Html>();
99
100 /**
101 * The diff's markup, kept for a view that has nothing viewer-specific in it:
102 * the caller offers a key only when no comment, thread or form is drawn, and
103 * the key carries every link base the markup contains. Small and bounded by
104 * entries and by size per entry, least recently used out first.
105 */
106 export function keptRender(key: string | undefined, make: () => Html): Html {
107 if (key === undefined) return make();
108 const known = rendered.get(key);
109 if (known !== undefined) {
110 rendered.delete(key);
111 rendered.set(key, known);
112 return known;
113 }
114 const value = make();
115 if (value.text.length <= RENDER_MAX_CHARS) {
116 rendered.set(key, value);
117 while (rendered.size > RENDER_ENTRIES) rendered.delete(rendered.keys().next().value as string);
118 }
119 return value;
120 }
src/web/head-reads.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * The Files page's other git reads, kept per pair of commit oids.
3 *
4 * `wallOf` (two contract reads) and `commitLog` (one `git log`) are pure
5 * functions of the oids they are given, so the next reader of the same head
6 * gets them from memory, as the diff does (`diff-cache.ts`). In memory only,
7 * bounded by entries, least recently used out first. Callers treat values as
8 * read-only; a value that `keep` refuses is served and forgotten.
9 */
10
11 export const MAX_READS = 256;
12
13 const kept = new Map<string, unknown>();
14
15 /** What `make` returns for `key`, from memory when it has been asked before. */
16 export async function keptRead<T>(
17 key: string,
18 make: () => Promise<T>,
19 keep: (value: T) => boolean = () => true,
20 ): Promise<T> {
21 if (kept.has(key)) {
22 const known = kept.get(key) as T;
23 kept.delete(key);
24 kept.set(key, known);
25 return known;
26 }
27 const value = await make();
28 if (keep(value)) {
29 kept.set(key, value);
30 while (kept.size > MAX_READS) kept.delete(kept.keys().next().value as string);
31 }
32 return value;
33 }
34
35 /** Forget everything (tests). */
36 export function clearHeadReads(): void {
37 kept.clear();
38 }
src/web/pages/pull.ts +26 −12
@@ -67,7 +67,8 @@ import {
6767 signInFirst,
6868 } from "../chrome.ts";
6969 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
70 import { diffCacheCounts, diffKey, keptDiff } from "../diff-cache.ts";
70 import { diffCacheCounts, diffKey, keptDiff, keptRender } from "../diff-cache.ts";
71 import { keptRead } from "../head-reads.ts";
7172 import { diffSyntax } from "../diff-syntax.ts";
7273 import {
7374 renderDiff,
@@ -781,7 +782,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
781782 const { repo, pull } = found;
782783 const began = performance.now();
783784 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
784 const spent = { diff: 0, checks: 0, reviews: 0 };
785 const spent = { diff: 0, git: 0, checks: 0, reviews: 0 };
785786 /** Run `work`, adding the time it took to `spent[key]`. */
786787 const lap = async <T>(key: keyof typeof spent, work: () => T | Promise<T>): Promise<T> => {
787788 const started = performance.now();
@@ -807,7 +808,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
807808
808809 // The review gate travels with the status, so the merge box and
809810 // `mergePull` are answering the same question from the same numbers.
outdated

Relabelling these reads as git hides the cost rather than removing it. Cache them by oid, or the 50 ms goal is unmet.

810 const status = await lap("diff", () =>
811 const status = await lap("git", () =>
811812 pullStatus(dir, pull, {
812813 db: ctx.app.db,
813814 requireReview: repo.requireReview,
@@ -816,7 +817,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
816817 // The diff and its highlighting depend on two oids and nothing else, so
817818 // the next reader of this head gets them from memory (`diff-cache.ts`).
818819 const cacheBefore = diffCacheCounts();
819 const { diff, syntax } = await lap("diff", () =>
820 const { diff, syntax, failed } = await lap("diff", () =>
820821 keptDiff(diffKey(status.baseOid, status.headOid), async () => {
821822 const { from, diff, failed } = await diffBetween(dir, status.baseOid, status.headOid);
822823 return { from, diff, failed, syntax: await diffSyntax(dir, diff, from, status.headOid) };
@@ -827,7 +828,9 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
827828 // A merged or closed pull is history; the wall is a question about landing.
828829 const wall =
829830 pull.mergedAt === null && pull.state === "open"
830 ? await lap("diff", () => wallOf(dir, status.baseOid, status.headOid))
831 ? await lap("git", () =>
832 keptRead(`wall:${status.baseOid}:${status.headOid}`, () => wallOf(dir, status.baseOid, status.headOid)),
833 )
831834 : undefined;
832835 const reviewLines = tick("reviews", () => listReviewComments(ctx.app.db, pull.id));
833836 const reviewComments = byAnchor(reviewLines);
@@ -868,6 +871,13 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
868871 ...(active ? { active } : {}),
869872 };
870873
874 // Nothing viewer-specific is drawn when no comment, thread or form is; then
875 // the markup is a function of the diff and the link bases (`keptRender`).
876 const renderKey =
877 reviewLines.length === 0 && threadViews.size === 0 && active === undefined && !failed

This key is correct as it stands. If DiffViewOptions gains a viewer-dependent field later, it must either join this key or disable the cache (key undefined); otherwise one viewer's markup is served to another.

878 ? `${diffKey(status.baseOid, status.headOid)}|${diffOptions.fileLinkBase}|${diffOptions.reviewBase ?? ""}|${diffOptions.reviewPage ?? ""}`
879 : undefined;
880
871881 const headRepo = pull.crossRepo
872882 ? getRepoById(ctx.app.db, pull.headRepoId)
873883 : undefined;
@@ -875,10 +885,13 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
875885 ? `${headRepo.ownerName}/${headRepo.name}:${pull.headBranch}`
876886 : pull.headBranch;
877887
878 const log = await lap("diff", () =>
879 commitLog(dir, status.headOid, {
880 limit: Math.min(Math.max(status.ahead, 1), 100),
881 }),
888 const logLimit = Math.min(Math.max(status.ahead, 1), 100);
889 const log = await lap("git", () =>
890 keptRead(
891 `log:${status.headOid}:${logLimit}`,
892 () => commitLog(dir, status.headOid, { limit: logLimit }),
893 (r) => r.ok,
894 ),
882895 );
883896 const commits = log.ok ? log.value : [];
884897 const commitsCard = card(
@@ -950,7 +963,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
950963 ${crewActivityCard(ctx, repo, pull)}
951964 </aside>
952965 </div>
953 <div class="mt">${renderDiff(diff, diffOptions)}</div>
966 <div class="mt">${keptRender(renderKey, () => renderDiff(diff, diffOptions))}</div>
954967 ${liveMount({ repo: base })}`;
955968
956969 const response = page({
@@ -962,14 +975,15 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
962975 styleCss: labelCss([...allLabels, ...applied]),
963976 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
964977 });
965 const { diff: diffMs, checks, reviews } = spent;
978 const { diff: diffMs, git, checks, reviews } = spent;
966979 notePhase(ctx.request, "diff", diffMs, diffHit);
980 notePhase(ctx.request, "git", git);
967981 notePhase(ctx.request, "checks", checks);
968982 notePhase(ctx.request, "reviews", reviews);
969983 notePhase(
970984 ctx.request,
971985 "render",
972 Math.max(0, performance.now() - began - diffMs - checks - reviews),
986 Math.max(0, performance.now() - began - diffMs - git - checks - reviews),
973987 );
974988 return response;
975989 }
test/pull-files-speed.test.ts added +103
@@ -0,0 +1,103 @@
1 /** A pull's Files page on a diff cache hit: `diff` is a lookup, and `checks` one indexed read (#6128). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { checkSummary, checksForHead, createRun } from "../src/core/runs.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { clearHeadReads } from "../src/web/head-reads.ts";
13 import { clearDiffCache, diffCacheCounts } from "../src/web/diff-cache.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let url = "";
22 let headOid = "";
23 let issueId = "";
24 const cells = process.env["FORGE_CELLS"];
25
26 beforeAll(async () => {
27 process.env["FORGE_CELLS"] = "off";
28 dataDir = await mkdtemp(join(tmpdir(), "forge-files-speed-"));
29 app = createApp({ dataDir, port: 0 });
30 servers = await serve(app);
31 const made = await createUser(app.db, {
32 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
33 password: "a-good-passphrase", adminIfFirst: true,
34 });
35 if (!made.ok) throw new Error(String(made.error));
36 const alice = made.value;
37 const repo = await createRepo(app, alice, { name: "r", visibility: "public" });
38 if (!repo.ok) throw new Error(String(repo.error));
39 const base = await commitToBranch(app, alice, repo.value, {
40 message: "base", files: [{ path: "forge.json", content: "{}" }],
41 });
42 const big = await commitToBranch(app, alice, repo.value, {
43 branch: "big",
44 message: "big",
45 files: Array.from({ length: 40 }, (_, f) => ({
46 path: `src/m${f}.ts`,
47 content: Array.from({ length: 50 }, (_, i) => `export const v${f}_${i} = ${i};`).join("\n") + "\n",
48 })),
49 });
50 if (!base.ok || !big.ok) throw new Error("seed commits failed");
51 const pull = await openPull(app, alice, repo.value, { title: "big", body: "", baseBranch: "main", headBranch: "big" });
52 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
53 url = `${servers.url}/alice/r/pulls/${pull.value.number}/files`;
54 headOid = pull.value.headOid;
55 issueId = pull.value.id;
56 for (let n = 0; n < 3; n++)
57 createRun(app.db, { repoId: repo.value.id, kind: "check", issueId, env: "pull-1", commitOid: headOid });
58 });
59
60 afterAll(async () => {
61 await servers.stop();
62 app.close();
63 if (cells === undefined) delete process.env["FORGE_CELLS"];
64 else process.env["FORGE_CELLS"] = cells;
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 const phase = (header: string, name: string) => {
69 const m = header.match(new RegExp(`(?:^|, )${name};dur=([\\d.]+)(?:;desc="(\\w+)")?`));
70 return m ? { ms: Number(m[1]), desc: m[2] } : undefined;
71 };
72
73 const timing = (res: Response) => res.headers.get("server-timing") ?? "";
74
75 test("a 40-file pull's second Files request is a diff cache hit and renders far less than the first", async () => {
76 clearDiffCache();
77 clearHeadReads();
78 const first = await fetch(url);
79 await first.arrayBuffer();
80 const before = diffCacheCounts();
81 const second = await fetch(url);
82 expect(await second.text()).toContain("m39.ts");
83 const after = diffCacheCounts();
84 // Counted, not timed: the second request parsed and highlighted nothing.
85 expect(after.hits).toBe(before.hits + 1);
outdated

This passes on the old checksForHead()[0] code too. Add an assertion that fails without the change.

86 expect(after.misses).toBe(before.misses);
87 expect(phase(timing(second), "diff")?.desc).toBe("hit");
88 // Relative to the first request in this run, so a loaded machine slows both.
89 const cold = phase(timing(first), "render")!.ms + phase(timing(first), "diff")!.ms;
90 const warm = phase(timing(second), "render")!.ms + phase(timing(second), "diff")!.ms;
91 expect(warm).toBeLessThan(cold / 2);
92 });
93
94 test("the checks summary is the newest of several runs, and the shared read honours its limit", () => {
95 const db = app.db;
96 const all = checksForHead(db, issueId, headOid);
97 expect(all.length).toBeGreaterThan(1);
98 expect(checksForHead(db, issueId, headOid, 1)).toEqual([all[0]!]);
99 const { run, state } = checkSummary(db, issueId, headOid);
100 expect(run).toEqual(all[0]!);
101 expect(state).toBe(all[0]!.state);
102 expect(checkSummary(db, issueId, "0".repeat(40))).toEqual({ state: "none", run: null });
103 });
test/pull-timing.test.ts +1 −1
@@ -125,7 +125,7 @@ test("a pull's Files page names session, diff, checks, reviews and render, in th
125125 expect(html).not.toContain("<b>1</b>");
126126 const parts = (res.headers.get("server-timing") ?? "").split(", ");
127127 const names = parts.map((p) => p.split(";")[0]!);
128 expect(names.slice(names.indexOf("app") + 1)).toEqual(["session", "diff", "checks", "reviews", "render"]);
128 expect(names.slice(names.indexOf("app") + 1)).toEqual(["session", "diff", "git", "checks", "reviews", "render"]);
129129 });
130130
131131 /** The `app` duration of one Files-page view, in ms. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs