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

/map's Server-Timing names its phases: session, lanes, layout, render #3172

Merged opened this

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

Merged

scout/forge:scout/3159-map-s-server-timing-names-its-phases-ses → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3159-map-s-server-timing-names-its-phases-ses was merged into main as 9c4a5f6.

Review

Sign in to review this change.
7 files changed +191 −10
bench/map-home.ts +46
@@ -363,6 +363,49 @@ export async function benchMapHome(
363363 return samples;
364364 }
365365
366 /** `/map`'s Server-Timing phases, p50 and p95 in ms, over `iterations` warm hits. */
367 export async function benchMapPhases(
368 fixture: Fixture,
369 iterations: number,
370 ): Promise<string> {
371 const servers = await serve(fixture.app);
372 const names = ["app", "session", "lanes", "layout", "render"];
373 const rows: string[] = [
374 "| `GET /map` phase | owner p50 | owner p95 | stranger p50 | stranger p95 |",
375 "| ---------------- | --------: | --------: | -----------: | -----------: |",
376 ];
377 const stats = new Map<string, number[][]>();
378 try {
379 for (const cookie of [fixture.ownerCookie, fixture.strangerCookie]) {
380 const seen = new Map<string, number[]>(names.map((n) => [n, []]));
381 for (let i = 0; i < iterations + 1; i++) {
382 const response = await fetch(`${servers.url}/map`, { headers: { cookie } });
383 await response.arrayBuffer();
384 if (i === 0) continue;
385 const header = response.headers.get("server-timing") ?? "";
386 for (const m of header.matchAll(/([a-z-]+);dur=([\d.]+)/g))
387 seen.get(m[1]!)?.push(Number(m[2]));
388 }
389 for (const n of names) {
390 const sorted = seen.get(n)!.sort((a, b) => a - b);
391 const at = (p: number) =>
392 sorted[Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100))] ?? 0;
393 stats.set(n, [...(stats.get(n) ?? []), [at(50), at(95)]]);
394 }
395 }
396 } finally {
397 servers.stop();
398 }
399 for (const n of names) {
400 const [o, s] = stats.get(n)!;
401 const label = n === "app" ? "total (`app`)" : `\`${n}\``;
402 rows.push(
403 `| ${label} | ${o![0]!.toFixed(1)} | ${o![1]!.toFixed(1)} | ${s![0]!.toFixed(1)} | ${s![1]!.toFixed(1)} |`,
404 );
405 }
406 return rows.join("\n");
407 }
408
366409 const TITLES = [
367410 "the map lays out by what waits for what",
368411 "home paints before the feed",
@@ -390,6 +433,9 @@ if (import.meta.main) {
390433 Number.isFinite(n) && n > 0 ? n : 30,
391434 );
392435 console.log(report(samples, argv.includes("--verbose")));
436 console.log(
437 `\n${await benchMapPhases(fixture, Number.isFinite(n) && n > 0 ? n : 30)}`,
438 );
393439 fixture.app.close();
394440 } finally {
395441 rmSync(dir, { recursive: true, force: true });
docs/CACHING.md +15
@@ -91,6 +91,21 @@ After #2716 (same fixture, same machine, `--n 30`, warm; `/` unchanged):
9191 What is left is rendering the ~1 MB of SVG (25 ms), which rule 3 keeps
9292 per request.
9393
94 `/map`'s `Server-Timing` names where that goes (#3159): `app` is the total,
95 then `session`, `lanes` (the cache lookups, and the build on a miss, less the
96 layout inside it), `layout` (graph layout on a miss) and `render`. Their sum
97 is never more than `app`; the rest is routing and sending. One
98 `bun run bench/map-home.ts` run (`--n 30`, warm, so `layout` is a cache hit
99 and rounds to 0.0):
100
101 | `GET /map` phase | owner p50 | owner p95 | stranger p50 | stranger p95 |
102 | ---------------- | --------: | --------: | -----------: | -----------: |
103 | total (`app`) | 27.8 | 31.2 | 27.2 | 29.6 |
104 | `session` | 0.2 | 0.3 | 0.2 | 0.3 |
105 | `lanes` | 1.8 | 2.2 | 1.7 | 1.9 |
106 | `layout` | 0.0 | 0.0 | 0.0 | 0.0 |
107 | `render` | 17.7 | 20.1 | 17.4 | 19.1 |
108
94109 ### Where the time goes
95110
96111 - **`/map`: `layout` of the big lane alone takes 408 ms of the ~490 ms
src/core/route_timing.ts +48 −4
@@ -149,11 +149,55 @@ export function readTiming(db: Database, sinceHour: string): TimingRow[] {
149149 }
150150
151151 /** The header every response carries: `app;dur=<ms>`, and `db` when known. */
152 export function serverTiming(appMs: number, dbMs?: number): string {
152 export function serverTiming(
153 appMs: number,
154 dbMs?: number,
155 phases: readonly Phase[] = [],
156 ): string {
153157 const f = (n: number) => n.toFixed(1);
154 return dbMs === undefined
155 ? `app;dur=${f(appMs)}`
156 : `db;dur=${f(dbMs)}, app;dur=${f(appMs)}`;
158 const head =
159 dbMs === undefined
160 ? `app;dur=${f(appMs)}`
161 : `db;dur=${f(dbMs)}, app;dur=${f(appMs)}`;
162 return [head, ...phases.map((p) => `${p.name};dur=${f(p.ms)}`)].join(", ");
163 }
164
165 /** One named stretch of a request, in milliseconds. */
166 export interface Phase {
167 readonly name: string;
168 readonly ms: number;
169 }
170
171 /** A phase name is a header token: lowercase letters, nothing a viewer chose. */
172 const PHASE_NAME = /^[a-z][a-z0-9-]{0,15}$/;
173
174 const phaseLists = new WeakMap<Request, Phase[]>();
175
176 /** A page names a phase it spent `ms` in; `serverTiming` writes it after `app`. */
177 export function notePhase(request: Request, name: string, ms: number): void {
178 if (!PHASE_NAME.test(name))
179 throw new RangeError("phase refused: the name must be a lowercase token");
180 if (!Number.isFinite(ms) || ms < 0) return;
181 const list = phaseLists.get(request) ?? [];
182 list.push({ name, ms });
183 phaseLists.set(request, list);
184 }
185
186 export function takePhases(request: Request): Phase[] {
187 const list = phaseLists.get(request) ?? [];
188 phaseLists.delete(request);
189 return list;
190 }
191
192 const sessionTimes = new WeakMap<Request, number>();
193
194 /** The server says how long resolving the session took; a page may name it. */
195 export function noteSessionTime(request: Request, ms: number): void {
196 sessionTimes.set(request, ms);
197 }
198
199 export function takeSessionTime(request: Request): number {
200 return sessionTimes.get(request) ?? 0;

Nit: takeSessionTime doesn't delete its entry the way takePhases (and takeDbTime) do. That's harmless with a WeakMap, but take* reads as consume-once. Either delete it here or call it sessionTime. Also, noteSessionTime runs on every GET while only /map reads it. That's fine for now, but if a second page wants session, it's worth folding into the server's own serverTiming call.

157201 }
158202
159203 const dbTimes = new WeakMap<Request, number>();
src/http/server.ts +5 −1
@@ -462,6 +462,8 @@ import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts";
462462 import {
463463 createRouteTiming,
464464 noteDbTime,
465 noteSessionTime,
466 takePhases,
465467 serverTiming,
466468 takeDbTime,
467469 type RouteTiming,
@@ -2123,7 +2125,7 @@ export async function serve(app: App): Promise<Servers> {
21232125 const tag = takeUsage(request);
21242126 const ms = performance.now() - started;
21252127 try {
2126 response.headers.set("server-timing", serverTiming(ms, takeDbTime(request)));
2128 response.headers.set("server-timing", serverTiming(ms, takeDbTime(request), takePhases(request)));
21272129 } catch {
21282130 // A response the forge is only carrying keeps the headers it came with.
21292131 }
@@ -2428,7 +2430,9 @@ async function answerGet(
24282430 }
24292431 return noRoute(request.method, url.pathname);
24302432 }
2433 const authStart = performance.now();
24312434 const auth = await resolveAuth(app, request);
2435 noteSessionTime(request, performance.now() - authStart);
24322436 if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter);
24332437 noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) });
24342438 // From here a body's cap depends on who is asking (`http/limits.ts`), and
src/web/pages/map.ts +36 −5
@@ -33,6 +33,7 @@ import {
3333 type Ranked,
3434 } from "../../core/dependencies.ts";
3535 import type { Issue } from "../../core/issues.ts";
36 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
3637 import { cachedLane, cachedLayout, repoHeads, scopeKey } from "../map_cache.ts";
3738 import { holdsFor } from "../../core/holds.ts";
3839 import {
@@ -1513,6 +1514,21 @@ type KeptLane = Omit<Lane, "repo" | "workers"> & {
15131514 readonly workerIds: string[];
15141515 };
15151516
1517 /** Milliseconds a request spent laying graphs out, inside the lanes' build. */
1518 export interface Spent {
1519 layoutMs: number;
1520 }
1521
1522 function timed<T>(spent: Spent | undefined, run: () => T): T {
1523 if (spent === undefined) return run();
1524 const started = performance.now();
1525 try {
1526 return run();
1527 } finally {
1528 spent.layoutMs += performance.now() - started;
1529 }
1530 }
1531
15161532 /**
15171533 * One repository's lane for one scope, or null when it has nothing to draw.
15181534 * Every read of issues here respects `scope`, so the entry it becomes is
@@ -1523,6 +1539,7 @@ function buildLane(
15231539 repo: Repo,
15241540 viewer: User | undefined,
15251541 scope: ReturnType<typeof sealedScope>,
1542 spent?: Spent,
15261543 ): KeptLane | null {
15271544 const db = app.db;
15281545 // A run on a sealed report is not shown to who may not see the report.
@@ -1550,7 +1567,7 @@ function buildLane(
15501567 );
15511568 return {
15521569 graph,
1553 placed: cachedLayout(graph, layout),
1570 placed: timed(spent, () => cachedLayout(graph, layout)),
15541571 marks,
15551572 ready,
15561573 critical: criticalPath(graph),
@@ -1571,7 +1588,11 @@ function buildLane(
15711588 * for every run in flight and one for who runs them; a graph and its marks
15721589 * only for the lanes that stay.
15731590 */
1574 export function globalLanes(app: App, viewer: User | undefined): Lane[] {
1591 export function globalLanes(
1592 app: App,
1593 viewer: User | undefined,
1594 spent?: Spent,
1595 ): Lane[] {
15751596 const db = app.db;
15761597 const now = new Date();
15771598 // Access first, every request, and into the key (docs/CACHING.md rule 1).
@@ -1593,7 +1614,7 @@ export function globalLanes(app: App, viewer: User | undefined): Lane[] {
15931614 scope: scopeKey(scope),
15941615 now,
15951616 },
1596 () => buildLane(app, repo, viewer, scope),
1617 () => buildLane(app, repo, viewer, scope, spent),
15971618 );
15981619 if (lane !== null) built.push({ repo, lane });
15991620 }
@@ -1680,7 +1701,15 @@ export function laneHtml(lane: Lane, width: number): Html {
16801701 export function globalMapPage(ctx: Ctx): Response {
16811702 const viewer = ctx.auth.user;
16821703 if (!viewer) return signInFirst(ctx);
1683 const lanes = globalLanes(ctx.app, viewer);
1704 const { request } = ctx;
1705 notePhase(request, "session", takeSessionTime(request));
1706 const spent: Spent = { layoutMs: 0 };
1707 const lanesStart = performance.now();
1708 const lanes = globalLanes(ctx.app, viewer, spent);
1709 const lanesMs = performance.now() - lanesStart;
1710 notePhase(request, "lanes", Math.max(0, lanesMs - spent.layoutMs));
1711 notePhase(request, "layout", spent.layoutMs);
1712 const renderStart = performance.now();
16841713 const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed)));
16851714 const body = html`${pageHead("Map", {
16861715 sub: "Every app you can see, laid out by what waits for what. The top edge of each lane is ready now; a ringed card is where the crew is.",
@@ -1693,13 +1722,15 @@ export function globalMapPage(ctx: Ctx): Response {
16931722 </p>`
16941723 : lanes.map((lane) => laneHtml(lane, width))}
16951724 </div>`;
1696 return page({
1725 const response = page({
16971726 ctx,
16981727 title: "Map",
16991728 body,
17001729 styleCss: mapCss,
17011730 scriptJs: `${liveRegionsJs}\n${mapViewJs}`,
17021731 });
1732 notePhase(request, "render", performance.now() - renderStart);
1733 return response;
17031734 }
17041735
17051736 /** GET /api/v1/map — the lanes as data, one entry per repository. */
test/map-cache.test.ts +31
@@ -230,3 +230,34 @@ describe("the layout key", () => {
230230 expect(layout(graph).nodes.length).toBe(graph.issues.length);
231231 });
232232 });
233
234 describe("/map's Server-Timing names its phases", () => {
235 let servers: Servers;
236 let f: Fixture;
237 beforeAll(async () => {
238 ({ f, servers } = await boot(SMALL));
239 }, 120_000);
240 afterAll(() => servers.stop(), 60_000);
241
242 for (const who of ["owner", "stranger"] as const) {
243 test(`${who}: session, lanes, layout and render follow app, and sum to no more than it`, async () => {
244 clearMapCache();
245 const cookie = who === "owner" ? f.ownerCookie : f.strangerCookie;
246 for (const pass of ["cold", "warm"]) {
247 const res = await fetch(`${servers.url}/map`, { headers: { cookie } });
248 const header = res.headers.get("server-timing") ?? "";
249 const dur = new Map(
250 [...header.matchAll(/([a-z-]+);dur=(\d+\.\d)/g)].map((m) => [m[1]!, Number(m[2])]),
251 );
252 const names = [...dur.keys()];
253 expect(names.slice(-4), `${pass}: ${header}`).toEqual(["session", "lanes", "layout", "render"]);
254 expect(header.split(", ").every((p) => /^[a-z-]+;dur=\d+\.\d$/.test(p))).toBe(true);
255 const sum = ["session", "lanes", "layout", "render"].reduce((n, k) => n + dur.get(k)!, 0);
256 // Each is printed to a tenth of a millisecond, so allow that rounding.
257 expect(sum).toBeLessThanOrEqual(dur.get("app")! + 0.4);
258 // No repository name rides in the header.
259 expect(header).not.toMatch(/\//);
260 }
261 });
262 }
263 });
test/route-timing.test.ts +10
@@ -11,7 +11,9 @@ import {
1111 createRouteTiming,
1212 KEEP_DAYS,
1313 readTiming,
14 notePhase,
1415 serverTiming,
16 takePhases,
1517 } from "../src/core/route_timing.ts";
1618 import { serve } from "../src/http/server.ts";
1719 import { migrate } from "../src/kernel/migrate.ts";
@@ -88,3 +90,11 @@ test("the header names db time only when known", () => {
8890 expect(serverTiming(1.234)).toBe("app;dur=1.2");
8991 expect(serverTiming(2, 0.5)).toBe("db;dur=0.5, app;dur=2.0");
9092 });
93
94 test("phases follow app in the header, and a name that is not a token is refused", () => {
95 const request = new Request("http://x/");
96 notePhase(request, "lanes", 1.26);
97 expect(serverTiming(5, undefined, takePhases(request))).toBe("app;dur=5.0, lanes;dur=1.3");
98 expect(takePhases(request)).toEqual([]);
99 expect(() => notePhase(request, "my repo", 1)).toThrow();
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs