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

A pull's Server-Timing names its phases: session, diff, checks, reviews, render #3216

Merged opened this

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

Merged

scout/forge:scout/3192-a-pull-s-server-timing-names-its-phases → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/3192-a-pull-s-server-timing-names-its-phases was merged into main as 73e8bc4.

Review

Sign in to review this change.
2 files changed +147 −11
src/web/studio/change.ts +58 −11
@@ -9,6 +9,7 @@
99 * bigger.
1010 */
1111
12 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1213 import { cardPath } from "../og/path.ts";
1314 import { pullProposalOf } from "../../core/proposal_token.ts";
1415 import { getIssueById } from "../../core/issues.ts";
@@ -287,7 +288,7 @@ export const changePage = pullPage;
287288 * disagree. Bounded the way the page is: `pullWord` and one `pullStatus`, no
288289 * diff, no query per comment.
289290 */
290 async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull) {
291 async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull, spent: Spent = newSpent()) {
291292 const record: AppRecord | undefined = getAppByRepo(ctx.app.db, repo.id);
292293 const db = ctx.app.db;
293294 const base = repoBase(repo);
@@ -305,7 +306,9 @@ async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull) {
305306 history: servingHistory(),
306307 viewer: ctx.auth.user,
307308 };
308 const { word, note: checkNote } = await pullWord(wordCtx, pull);
309 const { word, note: checkNote } = await lap(spent, "checks", () =>
310 pullWord(wordCtx, pull),
311 );
309312 // Whether this change still merges into its base is asked here, on its own
310313 // page, and nowhere a list of changes renders — a git call per open pull
311314 // would undo the home's own bound (`askCandidatesFor`). Only an open,
@@ -314,10 +317,12 @@ async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull) {
314317 // a change `mergePull` would refuse for want of an approval.
315318 const status =
316319 pull.mergedAt === null && pull.state !== "closed"
317 ? await pullStatus(wordCtx.dir, pull, {
318 db,
319 requireReview: repo.requireReview,
320 })
320 ? await lap(spent, "diff", () =>
321 pullStatus(wordCtx.dir, pull, {
322 db,
323 requireReview: repo.requireReview,
324 }),
325 )
321326 : undefined;
322327 const conflicted =
323328 status !== undefined && !status.mergeable && status.conflicts.length > 0;
@@ -335,7 +340,9 @@ async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull) {
335340 // down, and the word above is judged by production regardless.
336341 let deploy: { id: string; commitOid: string } | undefined;
337342 if (pull.mergedAt === null) {
338 const run = checkSummary(db, pull.id, pull.headOid).run;
343 const run = (await lap(spent, "checks", async () =>

checkSummary is synchronous; wrapping it in an async lap just to time it adds an await. Time it inline, or give lap a sync twin.

344 checkSummary(db, pull.id, pull.headOid),
345 )).run;
339346 if (run) deploy = { id: run.id, commitOid: run.commitOid };
340347 } else {
341348 const production =
@@ -419,20 +426,28 @@ async function changeConversation(
419426 repo: Repo,
420427 pull: Pull,
421428 ): Promise<Response> {
429 const { request } = ctx;
430 const began = performance.now();
431 const spent = newSpent();
432 notePhase(request, "session", takeSessionTime(request));
422433 const { record, db, base, thread, wordCtx, word, status, conflicted, note, checkLine, preview } =
423 await pullPanels(ctx, repo, pull);
434 await pullPanels(ctx, repo, pull, spent);
424435 // The check's own line, live, with a running check's counts: the one the
425436 // Files step draws (`pullChecksLineFor`), for an open change.
426437 const runLine =
427438 status === undefined
428439 ? undefined
429 : await pullChecksLineFor(ctx, repo, pull, status, wordCtx.dir);
440 : await lap(spent, "checks", () =>
441 pullChecksLineFor(ctx, repo, pull, status, wordCtx.dir),
442 );
430443 // A change that loosens the policy is a person's call: said here, above
431444 // the decision, as well as on the Files step beside the merge box.
432445 const wall =
433446 status === undefined
434447 ? undefined
435 : await wallOf(wordCtx.dir, status.baseOid, status.headOid);
448 : await lap(spent, "diff", () =>
449 wallOf(wordCtx.dir, status.baseOid, status.headOid),
450 );
436451 const crewDecision = decisionsFor(ctx, [repo], { evenDismissed: true }).find(
437452 (d) => d.pull?.id === pull.id,
438453 );
@@ -559,11 +574,13 @@ async function changeConversation(
559574 const siblings = variantSiblings(db, [pull])[0] ?? [];
560575 const glyph = originGlyph(repo);
561576 const headOid = status?.headOid ?? pull.headOid;
577 const reviewsAt = performance.now();
562578 const reviews = listReviews(db, pull.id);
563579 const reviewerMarks = marksByName(
564580 db,
565581 reviews.map((r) => r.authorName),
566582 );
583 spent.reviews += performance.now() - reviewsAt;
567584 const talk = conversation(ctx, repo, pull, {
568585 openVerb: "opened this",
569586 extra: reviews.map((review) =>
@@ -622,7 +639,7 @@ async function changeConversation(
622639 head: pull.mergedOid ?? pull.headOid,
623640 })}`;
624641
625 return page({
642 const response = page({
626643 ctx,
627644 title: `${pull.title} · ${repo.name}`,
628645 ogImage: cardPath("pull", repo, pull),
@@ -637,4 +654,34 @@ async function changeConversation(
637654 body,
638655 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
639656 });
657 // Each phase is named once, from what the laps added up to; render is what
658 // is left of the page's own time, so the phases never exceed `app`.
659 const { diff, checks, reviews: reviewMs } = spent;
660 notePhase(request, "diff", diff);
661 notePhase(request, "checks", checks);
662 notePhase(request, "reviews", reviewMs);
663 notePhase(
664 request,
665 "render",
666 Math.max(0, performance.now() - began - diff - checks - reviewMs),
667 );
668 return response;
669 }
670
671 /** Milliseconds a change's page spent in each of its named phases. */
672 interface Spent {
673 diff: number;
674 checks: number;
675 reviews: number;
676 }
677 const newSpent = (): Spent => ({ diff: 0, checks: 0, reviews: 0 });
678
679 /** Run `work` and add the time it took to `spent[key]`. */
680 async function lap<T>(spent: Spent, key: "diff" | "checks", work: () => Promise<T>): Promise<T> {
681 const started = performance.now();
682 try {
683 return await work();
684 } finally {
685 spent[key] += performance.now() - started;
686 }
640687 }
test/pull-timing.test.ts added +89
@@ -0,0 +1,89 @@
1 /** `/:owner/:name/pulls/:n`'s Server-Timing names its phases (#3192). */
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 { createRun } from "../src/core/runs.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie = "";
21 let number = 0;
22 const cells = process.env["FORGE_CELLS"];
23
24 beforeAll(async () => {
25 process.env["FORGE_CELLS"] = "off";
26 dataDir = await mkdtemp(join(tmpdir(), "forge-pull-timing-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 const made = await createUser(app.db, {
30 origin: "seed",
31 trusted: true,
32 username: "alice",
33 email: "[email protected]",
34 password: "a-good-passphrase",
35 adminIfFirst: true,
36 });
37 if (!made.ok) throw new Error(String(made.error));
38 const alice = made.value;
39 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
40 const repo = await createRepo(app, alice, { name: "r", visibility: "public" });
41 if (!repo.ok) throw new Error(String(repo.error));
42 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
43 const done = await commitToBranch(app, alice, repo.value, {
44 ...(branch === undefined ? {} : { branch }),
45 message: "change",
46 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
47 });
48 if (!done.ok) throw new Error(JSON.stringify(done.error));
49 }
50 const pull = await openPull(app, alice, repo.value, {
51 title: "t",
52 body: "",
53 baseBranch: "main",
54 headBranch: "topic",
55 });
56 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
57 number = pull.value.number;
58 createRun(app.db, {
59 repoId: repo.value.id,
60 kind: "check",
61 issueId: pull.value.id,
62 env: `pull-${number}`,
63 commitOid: pull.value.headOid,
64 });
65 });
66
67 afterAll(async () => {
68 await servers.stop();
69 app.close();
70 if (cells === undefined) delete process.env["FORGE_CELLS"];
71 else process.env["FORGE_CELLS"] = cells;
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 test("a pull names session, diff, checks, reviews and render after app, summing to no more than app", async () => {
76 const res = await fetch(`${servers.url}/alice/r/pulls/${number}`, { headers: { cookie } });
77 expect(res.status).toBe(200);
78 await res.arrayBuffer();
79 const parts = (res.headers.get("server-timing") ?? "").split(", ");
80 const names = parts.map((p) => p.split(";")[0]!);
81 const after = names.slice(names.indexOf("app") + 1);
82 expect(after).toEqual(["session", "diff", "checks", "reviews", "render"]);
83 const ms = (n: string) => Number(parts[names.indexOf(n)]!.match(/dur=([\d.]+)/)![1]);
84 const sum = after.reduce((t, n) => t + ms(n), 0);
85 // Each is rounded to 0.1 ms, so allow one step per phase.
86 expect(sum).toBeLessThanOrEqual(ms("app") + 0.1 * after.length);
87 expect(ms("diff")).toBeGreaterThan(0);
88 expect(ms("checks")).toBeGreaterThan(0);

toBeGreaterThan(0) on a sub-millisecond phase can flake on a fast box. Prefer asserting the names, their order and the sum bound.

89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs