Public: anyone can read this; the crew can change it.
/map's Server-Timing names its phases: session, lanes, layout, render #3172
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3159-map-s-server-timing-names-its-phases-ses → main 1 commit fork 1 thread · 1 unresolved
7 files
changed +191
−10
bench/map-home.ts +46
| @@ -363,6 +363,49 @@ export async function benchMapHome( | ||
| 363 | 363 | return samples; |
| 364 | 364 | } |
| 365 | 365 | |
| 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 | ||
| 366 | 409 | const TITLES = [ |
| 367 | 410 | "the map lays out by what waits for what", |
| 368 | 411 | "home paints before the feed", |
| @@ -390,6 +433,9 @@ if (import.meta.main) { | ||
| 390 | 433 | Number.isFinite(n) && n > 0 ? n : 30, |
| 391 | 434 | ); |
| 392 | 435 | console.log(report(samples, argv.includes("--verbose"))); |
| 436 | console.log( | |
| 437 | `\n${await benchMapPhases(fixture, Number.isFinite(n) && n > 0 ? n : 30)}`, | |
| 438 | ); | |
| 393 | 439 | fixture.app.close(); |
| 394 | 440 | } finally { |
| 395 | 441 | rmSync(dir, { recursive: true, force: true }); |
docs/CACHING.md +15
| @@ -91,6 +91,21 @@ After #2716 (same fixture, same machine, `--n 30`, warm; `/` unchanged): | ||
| 91 | 91 | What is left is rendering the ~1 MB of SVG (25 ms), which rule 3 keeps |
| 92 | 92 | per request. |
| 93 | 93 | |
| 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 | ||
| 94 | 109 | ### Where the time goes |
| 95 | 110 | |
| 96 | 111 | - **`/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[] { | ||
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | /** 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 { | |
| 153 | 157 | 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; | |
| 157 | 201 | } |
| 158 | 202 | |
| 159 | 203 | 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"; | ||
| 462 | 462 | import { |
| 463 | 463 | createRouteTiming, |
| 464 | 464 | noteDbTime, |
| 465 | noteSessionTime, | |
| 466 | takePhases, | |
| 465 | 467 | serverTiming, |
| 466 | 468 | takeDbTime, |
| 467 | 469 | type RouteTiming, |
| @@ -2123,7 +2125,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2123 | 2125 | const tag = takeUsage(request); |
| 2124 | 2126 | const ms = performance.now() - started; |
| 2125 | 2127 | try { |
| 2126 | response.headers.set("server-timing", serverTiming(ms, takeDbTime(request))); | |
| 2128 | response.headers.set("server-timing", serverTiming(ms, takeDbTime(request), takePhases(request))); | |
| 2127 | 2129 | } catch { |
| 2128 | 2130 | // A response the forge is only carrying keeps the headers it came with. |
| 2129 | 2131 | } |
| @@ -2428,7 +2430,9 @@ async function answerGet( | ||
| 2428 | 2430 | } |
| 2429 | 2431 | return noRoute(request.method, url.pathname); |
| 2430 | 2432 | } |
| 2433 | const authStart = performance.now(); | |
| 2431 | 2434 | const auth = await resolveAuth(app, request); |
| 2435 | noteSessionTime(request, performance.now() - authStart); | |
| 2432 | 2436 | if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter); |
| 2433 | 2437 | noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) }); |
| 2434 | 2438 | // 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 { | ||
| 33 | 33 | type Ranked, |
| 34 | 34 | } from "../../core/dependencies.ts"; |
| 35 | 35 | import type { Issue } from "../../core/issues.ts"; |
| 36 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 36 | 37 | import { cachedLane, cachedLayout, repoHeads, scopeKey } from "../map_cache.ts"; |
| 37 | 38 | import { holdsFor } from "../../core/holds.ts"; |
| 38 | 39 | import { |
| @@ -1513,6 +1514,21 @@ type KeptLane = Omit<Lane, "repo" | "workers"> & { | ||
| 1513 | 1514 | readonly workerIds: string[]; |
| 1514 | 1515 | }; |
| 1515 | 1516 | |
| 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 | ||
| 1516 | 1532 | /** |
| 1517 | 1533 | * One repository's lane for one scope, or null when it has nothing to draw. |
| 1518 | 1534 | * Every read of issues here respects `scope`, so the entry it becomes is |
| @@ -1523,6 +1539,7 @@ function buildLane( | ||
| 1523 | 1539 | repo: Repo, |
| 1524 | 1540 | viewer: User | undefined, |
| 1525 | 1541 | scope: ReturnType<typeof sealedScope>, |
| 1542 | spent?: Spent, | |
| 1526 | 1543 | ): KeptLane | null { |
| 1527 | 1544 | const db = app.db; |
| 1528 | 1545 | // A run on a sealed report is not shown to who may not see the report. |
| @@ -1550,7 +1567,7 @@ function buildLane( | ||
| 1550 | 1567 | ); |
| 1551 | 1568 | return { |
| 1552 | 1569 | graph, |
| 1553 | placed: cachedLayout(graph, layout), | |
| 1570 | placed: timed(spent, () => cachedLayout(graph, layout)), | |
| 1554 | 1571 | marks, |
| 1555 | 1572 | ready, |
| 1556 | 1573 | critical: criticalPath(graph), |
| @@ -1571,7 +1588,11 @@ function buildLane( | ||
| 1571 | 1588 | * for every run in flight and one for who runs them; a graph and its marks |
| 1572 | 1589 | * only for the lanes that stay. |
| 1573 | 1590 | */ |
| 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[] { | |
| 1575 | 1596 | const db = app.db; |
| 1576 | 1597 | const now = new Date(); |
| 1577 | 1598 | // 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[] { | ||
| 1593 | 1614 | scope: scopeKey(scope), |
| 1594 | 1615 | now, |
| 1595 | 1616 | }, |
| 1596 | () => buildLane(app, repo, viewer, scope), | |
| 1617 | () => buildLane(app, repo, viewer, scope, spent), | |
| 1597 | 1618 | ); |
| 1598 | 1619 | if (lane !== null) built.push({ repo, lane }); |
| 1599 | 1620 | } |
| @@ -1680,7 +1701,15 @@ export function laneHtml(lane: Lane, width: number): Html { | ||
| 1680 | 1701 | export function globalMapPage(ctx: Ctx): Response { |
| 1681 | 1702 | const viewer = ctx.auth.user; |
| 1682 | 1703 | 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(); | |
| 1684 | 1713 | const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed))); |
| 1685 | 1714 | const body = html`${pageHead("Map", { |
| 1686 | 1715 | 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 { | ||
| 1693 | 1722 | </p>` |
| 1694 | 1723 | : lanes.map((lane) => laneHtml(lane, width))} |
| 1695 | 1724 | </div>`; |
| 1696 | return page({ | |
| 1725 | const response = page({ | |
| 1697 | 1726 | ctx, |
| 1698 | 1727 | title: "Map", |
| 1699 | 1728 | body, |
| 1700 | 1729 | styleCss: mapCss, |
| 1701 | 1730 | scriptJs: `${liveRegionsJs}\n${mapViewJs}`, |
| 1702 | 1731 | }); |
| 1732 | notePhase(request, "render", performance.now() - renderStart); | |
| 1733 | return response; | |
| 1703 | 1734 | } |
| 1704 | 1735 | |
| 1705 | 1736 | /** 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", () => { | ||
| 230 | 230 | expect(layout(graph).nodes.length).toBe(graph.issues.length); |
| 231 | 231 | }); |
| 232 | 232 | }); |
| 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 { | ||
| 11 | 11 | createRouteTiming, |
| 12 | 12 | KEEP_DAYS, |
| 13 | 13 | readTiming, |
| 14 | notePhase, | |
| 14 | 15 | serverTiming, |
| 16 | takePhases, | |
| 15 | 17 | } from "../src/core/route_timing.ts"; |
| 16 | 18 | import { serve } from "../src/http/server.ts"; |
| 17 | 19 | import { migrate } from "../src/kernel/migrate.ts"; |
| @@ -88,3 +90,11 @@ test("the header names db time only when known", () => { | ||
| 88 | 90 | expect(serverTiming(1.234)).toBe("app;dur=1.2"); |
| 89 | 91 | expect(serverTiming(2, 0.5)).toBe("db;dur=0.5, app;dur=2.0"); |
| 90 | 92 | }); |
| 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 | }); |
new above
offline
Nit:
takeSessionTimedoesn't delete its entry the waytakePhases(andtakeDbTime) do. That's harmless with a WeakMap, buttake*reads as consume-once. Either delete it here or call itsessionTime. Also,noteSessionTimeruns on every GET while only/mapreads it. That's fine for now, but if a second page wantssession, it's worth folding into the server's ownserverTimingcall.