Public: anyone can read this; the crew can change it.
Convert the 71 expectBudget call sites to counted work #7852
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7824-convert-the-71-expectbudget-call-sites-t → main 2 commits fork 2 threads · 2 unresolved
40 files
changed +273
−774
.forgejo/workflows/timing.yml deleted −39
| @@ -1,39 +0,0 @@ | ||
| 1 | name: timing | |
| 2 | # Nightly: the wall-clock budgets (#7789). No check the forge runs, a pull's or | |
| 3 | # the merge queue's gate, enforces them: it only warns of a time | |
| 4 | # over its budget (test/helpers/budget.ts); here, one test file at a time on an | |
| 5 | # otherwise idle runner, each budget is held to its absolute ms. A newly red | |
| 6 | # file opens one `Red: test/….test.ts` issue and a green one closes it, as | |
| 7 | # outside-gate does (scripts/flake-hunt.ts timing). Needs secret FORGE_TOKEN and | |
| 8 | # variable FORGE_URL. | |
| 9 | on: | |
| 10 | schedule: | |
| 11 | - cron: "23 4 * * *" | |
| 12 | workflow_dispatch: {} | |
| 13 | ||
| 14 | jobs: | |
| 15 | timing: | |
| 16 | runs-on: isolated | |
| 17 | timeout-minutes: 30 | |
| 18 | env: | |
| 19 | REGISTRY: forgejo-http.forgejo.svc.cluster.local:3000 | |
| 20 | FORGE_TEST_TIMING: "1" | |
| 21 | FORGE_TEST_QUIET: "1" | |
| 22 | steps: | |
| 23 | - name: clone main | |
| 24 | env: | |
| 25 | TOKEN: ${{ github.token }} | |
| 26 | run: | | |
| 27 | set -eu | |
| 28 | git clone -q "http://x-access-token:$TOKEN@$REGISTRY/$GITHUB_REPOSITORY.git" src | |
| 29 | cd src | |
| 30 | bun install --frozen-lockfile | |
| 31 | ||
| 32 | - name: budget tests, serially | |
| 33 | working-directory: src | |
| 34 | env: | |
| 35 | FORGE_API: ${{ vars.FORGE_URL }}/api/v1 | |
| 36 | FORGE_REPO: ${{ github.repository }} | |
| 37 | FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} | |
| 38 | FORGE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} | |
| 39 | run: bun scripts/flake-hunt.ts timing --file-issues |
CLAUDE.md +5 −8
| @@ -106,14 +106,11 @@ bun run src/main.ts # boots on :3000, data in ./data | ||
| 106 | 106 | `EXCLUDED`, so never hand-edit that move. Never add `retry` — fix the |
| 107 | 107 | cause: a shared port, a real clock, order dependence, a leaked server, a |
| 108 | 108 | timeout near the real duration, a wall-clock bound under 200 ms on a |
| 109 | request or a spawn (`test/flake.test.ts` refuses one). A speed is bounded only | |
| 110 | with `expectBudget` (`test/helpers/budget.ts`): relative to a same-run | |
| 111 | baseline. No check the forge runs fails on it, a pull's or the merge queue's | |
| 112 | gate: a time over budget is only warned. It is enforced only with | |
| 113 | `FORGE_TEST_TIMING=1`, by the nightly `timing` workflow (one file at a time, | |
| 114 | `FORGE_TEST_QUIET=1`, `Red:` issues). To fail a regression in a pull, count work | |
| 115 | (statements, bytes, phases) or use a fake clock (#7789); | |
| 116 | `test/flake.test.ts` refuses a raw `under N ms` title or bound. | |
| 109 | request or a spawn (`test/flake.test.ts` refuses one). A speed is never bounded by a clock: count work | |
| 110 | instead (SQL statements with `test/helpers/statements.ts`, spawns, bytes, | |
| 111 | phases), assert it with `expectWork` (`test/helpers/work.ts`), or use a fake | |
| 112 | clock (#7789, #7824); `test/flake.test.ts` refuses a raw `under N ms` title | |
| 113 | or bound. | |
| 117 | 114 | - **Every non-trivial change ships with a test.** Prefer protocol-level tests |
| 118 | 115 | that boot a real app (`createApp` + `serve`) over unit mocks. Adversarial |
| 119 | 116 | tests (can this be bypassed?) are worth more than happy-path ones. |
docs/DEVELOP.md +2 −6
| @@ -210,12 +210,8 @@ longer hollow turns the check red, so the list only shrinks. | ||
| 210 | 210 | `bun scripts/hollow.ts` prints the same lines on their own. |
| 211 | 211 | |
| 212 | 212 | |
| 213 | ## Budget tests run last | |
| 213 | ## Shards | |
| 214 | 214 | |
| 215 | A test file that calls `expectBudget(` (a call in code, not a mention in a | |
| 216 | comment or string) bounds a speed and only passes with the machine quiet. The | |
| 217 | sharded gate (`scripts/test-shards.ts`) sets those files aside and runs them | |
| 218 | one at a time after every other shard is green; a red shard skips them. The | |
| 219 | rule is `callsBudget` in `scripts/shards.ts`. Each shard also gets its own | |
| 215 | Each shard gets its own | |
| 220 | 216 | `TMPDIR` and `FORGE_APP_DATA` (nested under the cell's, when it set one), |
| 221 | 217 | removed when the shard ends. |
scripts/flake-hunt.ts +7 −14
| @@ -15,10 +15,6 @@ | ||
| 15 | 15 | * `--file-issues` a newly red file opens one `Red: test/….test.ts` |
| 16 | 16 | * issue naming its first failing test, a file red on another test |
| 17 | 17 | * updates it, and a file green again closes it. |
| 18 | * bun scripts/flake-hunt.ts timing [--file-issues] | |
| 19 | * The files that call `expectBudget`, one at a time, as `outside` does; | |
| 20 | * the scheduled run sets FORGE_TEST_TIMING=1 FORGE_TEST_QUIET=1 so each | |
| 21 | * budget is held to its absolute ms (#7789). | |
| 22 | 18 | * bun scripts/flake-hunt.ts quarantine |
| 23 | 19 | * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed |
| 24 | 20 | * (author is the token's user, not untrusted) names into |
| @@ -43,8 +39,6 @@ import { | ||
| 43 | 39 | redActions, |
| 44 | 40 | redFile, |
| 45 | 41 | redIssueFor, |
| 46 | type RedRun, | |
| 47 | timingFiles, | |
| 48 | 42 | type OpenRed, |
| 49 | 43 | quarantineFlaky, |
| 50 | 44 | readRun, |
| @@ -241,10 +235,10 @@ async function openRedIssues(): Promise<Map<string, OpenRed>> { | ||
| 241 | 235 | return found; |
| 242 | 236 | } |
| 243 | 237 | |
| 244 | /** The files outside the gate (or, for `timing`, the budget files), one at a time; red ones reported to the forge. */ | |
| 245 | async function scheduled(kind: RedRun = "outside"): Promise<number> { | |
| 238 | /** The files outside the gate, one at a time; red ones reported to the forge. */ | |
| 239 | async function scheduled(): Promise<number> { | |
| 246 | 240 | const present = presentTests(); |
| 247 | const files = kind === "timing" ? timingFiles(present, (file) => readFileSync(file, "utf8")) : outsideFiles(present); | |
| 241 | const files = outsideFiles(present); | |
| 248 | 242 | const fileIssues = rest.includes("--file-issues"); |
| 249 | 243 | const open = fileIssues ? await openRedIssues() : new Map<string, OpenRed>(); |
| 250 | 244 | const runs: FileRun[] = []; |
| @@ -257,12 +251,12 @@ async function scheduled(kind: RedRun = "outside"): Promise<number> { | ||
| 257 | 251 | for (const action of redActions(runs, open)) { |
| 258 | 252 | if (!fileIssues) continue; |
| 259 | 253 | if (action.kind === "open") { |
| 260 | const res = await forgeJson("POST", "/issues", redIssueFor(action.run, where, kind)); | |
| 254 | const res = await forgeJson("POST", "/issues", redIssueFor(action.run, where)); | |
| 261 | 255 | const made = (await res.json().catch(() => ({}))) as { number?: unknown }; |
| 262 | 256 | if (!res.ok) fail(`filing the issue for ${action.run.file}: ${res.status}`); |
| 263 | 257 | console.log(` filed #${made.number} for ${action.run.file}`); |
| 264 | 258 | } else if (action.kind === "update") { |
| 265 | const { body } = redIssueFor(action.run, where, kind); | |
| 259 | const { body } = redIssueFor(action.run, where); | |
| 266 | 260 | const res = await forgeJson("PATCH", `/issues/${action.number}`, { body }); |
| 267 | 261 | if (!res.ok) { |
| 268 | 262 | const note = await forgeJson("POST", `/issues/${action.number}/comments`, { body }); |
| @@ -279,7 +273,7 @@ async function scheduled(kind: RedRun = "outside"): Promise<number> { | ||
| 279 | 273 | } |
| 280 | 274 | } |
| 281 | 275 | const red = runs.filter((run) => !run.green).length; |
| 282 | console.log(`${files.length} files ${kind === "timing" ? "holding wall-clock budgets" : "outside the gate"}, ${red} red`); | |
| 276 | console.log(`${files.length} files outside the gate, ${red} red`); | |
| 283 | 277 | return 0; |
| 284 | 278 | } |
| 285 | 279 | |
| @@ -376,11 +370,10 @@ async function admit(): Promise<number> { | ||
| 376 | 370 | } |
| 377 | 371 | |
| 378 | 372 | if (mode === "outside") process.exit(await scheduled()); |
| 379 | else if (mode === "timing") process.exit(await scheduled("timing")); | |
| 380 | 373 | else if (mode === "hunt") process.exit(await hunt()); |
| 381 | 374 | else if (mode === "quarantine") process.exit(await quarantineAll()); |
| 382 | 375 | else if (mode === "admit") process.exit(await admit()); |
| 383 | 376 | else |
| 384 | 377 | fail( |
| 385 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | outside [--file-issues] | timing [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 378 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | outside [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 386 | 379 | ); |
scripts/flake.ts +5 −37
| @@ -295,7 +295,7 @@ const BUDGET_MEASURE = | ||
| 295 | 295 | /** |
| 296 | 296 | * Every line of `source` that holds a speed to a raw millisecond number: a |
| 297 | 297 | * title saying "under N ms", or a Server-Timing/elapsed time bounded by a |
| 298 | * literal under the floor (a hang guard above it stays a plain expect). `test/helpers/budget.ts` is the one way (#4429). | |
| 298 | * literal under the floor (a hang guard above it stays a plain expect). `expectWork()` in `test/helpers/work.ts` counts work instead (#7824). | |
| 299 | 299 | */ |
| 300 | 300 | export function rawBudgets(name: string, source: string): string[] { |
| 301 | 301 | const found: string[] = []; |
| @@ -307,8 +307,8 @@ export function rawBudgets(name: string, source: string): string[] { | ||
| 307 | 307 | Number(bound!.replaceAll("_", "")) < WALL_CLOCK_FLOOR_MS |
| 308 | 308 | ) |
| 309 | 309 | raw = true; |
| 310 | if (raw && !/expectBudget/.test(line)) | |
| 311 | found.push(`${name}:${i + 1}: ${line.trim()} — bound speed with expectBudget() from test/helpers/budget.ts (#4429)`); | |
| 310 | if (raw) | |
| 311 | found.push(`${name}:${i + 1}: ${line.trim()} — count work (statements, spawns, bytes) with expectWork() from test/helpers/work.ts, not a clock (#7824)`); | |
| 312 | 312 | }); |
| 313 | 313 | return found; |
| 314 | 314 | } |
| @@ -357,52 +357,20 @@ export function firstFailing(run: FileRun): string { | ||
| 357 | 357 | : "none named; the file failed outside a test"; |
| 358 | 358 | } |
| 359 | 359 | |
| 360 | /** Files that test the budget helper itself, not a speed: never in the timing run. */ | |
| 361 | export const TIMING_EXCLUDED: readonly string[] = ["test/budget.test.ts"]; | |
| 362 | ||
| 363 | /** How a file says it bounds a wall-clock speed: it imports the budget helper and calls it. */ | |
| 364 | const BUDGET_IMPORT = /from\s+["']\.\/helpers\/budget\.ts["']/; | |
| 365 | const BUDGET_CALL = /\bexpectBudget\(/; | |
| 366 | ||
| 367 | /** | |
| 368 | * The files the nightly timing run holds to their wall-clock budgets (#7789): | |
| 369 | * each present test that imports `test/helpers/budget.ts` and calls | |
| 370 | * `expectBudget`, less {@link TIMING_EXCLUDED}. A file that only mentions the | |
| 371 | * name, in a string or a comment, is not one. | |
| 372 | */ | |
| 373 | export function timingFiles( | |
| 374 | present: readonly string[], | |
| 375 | read: (file: string) => string, | |
| 376 | ): string[] { | |
| 377 | return present.filter((file) => { | |
| 378 | if (TIMING_EXCLUDED.includes(file)) return false; | |
| 379 | const text = read(file); | |
| 380 | return BUDGET_IMPORT.test(text) && BUDGET_CALL.test(text); | |
| 381 | }); | |
| 382 | } | |
| 383 | ||
| 384 | /** Which scheduled run found a file red: the files outside the gate, or the wall-clock budgets. */ | |
| 385 | export type RedRun = "outside" | "timing"; | |
| 386 | ||
| 387 | 360 | /** The issue for a file the scheduled run found red. */ |
| 388 | 361 | export function redIssueFor( |
| 389 | 362 | run: FileRun, |
| 390 | 363 | where: string, |
| 391 | kind: RedRun = "outside", | |
| 392 | 364 | ): { title: string; body: string } { |
| 393 | 365 | const fence = "```"; |
| 394 | 366 | return { |
| 395 | 367 | title: `${RED_TITLE}${run.file}`, |
| 396 | 368 | body: [ |
| 397 | kind === "timing" | |
| 398 | ? `\`${run.file}\` was over a wall-clock budget, or red, in the nightly timing run (one file at a time, absolute budgets). This issue closes itself when the file passes again.` | |
| 399 | : `\`${run.file}\` is outside the gate and was red in the scheduled run. This issue closes itself when the file passes again.`, | |
| 369 | `\`${run.file}\` is outside the gate and was red in the scheduled run. This issue closes itself when the file passes again.`, | |
| 400 | 370 | "", |
| 401 | 371 | `- first failing test: ${firstFailing(run)}`, |
| 402 | 372 | `- run: ${where || "(a local run)"}`, |
| 403 | kind === "timing" | |
| 404 | ? `- reproduce: \`FORGE_TEST_TIMING=1 FORGE_TEST_QUIET=1 bun test --timeout 60000 ./${run.file}\`` | |
| 405 | : `- reproduce: \`bun test --timeout 60000 ./${run.file}\``, | |
| 373 | `- reproduce: \`bun test --timeout 60000 ./${run.file}\``, | |
| 406 | 374 | "", |
| 407 | 375 | fence, |
| 408 | 376 | run.text || "(no failure text captured)", |
scripts/shards.ts +1 −27
| @@ -188,32 +188,6 @@ export function splitCapped( | ||
| 188 | 188 | ); |
| 189 | 189 | } |
| 190 | 190 | |
| 191 | /** Whether a test file's code (comments dropped) calls `expectBudget(`. */ | |
| 192 | export function callsBudget(text: string | undefined): boolean { | |
| 193 | if (text === undefined) return false; | |
| 194 | const code = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""); | |
| 195 | return /\bexpectBudget\s*\(/.test(code); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Splits `files` into those that bound a speed (a call to `expectBudget(`, which only | |
| 200 | * passes with the cell quiet enough) and the rest. The runner runs the | |
| 201 | * budget files last, one at a time, after every other shard is done, so the | |
| 202 | * load of the gate's own shards cannot redden them (#7617). `read` returns | |
| 203 | * a file's text; one it cannot read counts as no budget. Each list keeps | |
| 204 | * the input's order. | |
| 205 | */ | |
| 206 | export function budgetLast( | |
| 207 | files: readonly string[], | |
| 208 | read: (file: string) => string | undefined, | |
| 209 | ): { rest: string[]; budget: string[] } { | |
| 210 | const rest: string[] = []; | |
| 211 | const budget: string[] = []; | |
| 212 | for (const file of files) | |
| 213 | (callsBudget(read(file)) ? budget : rest).push(file); | |
| 214 | return { rest, budget }; | |
| 215 | } | |
| 216 | ||
| 217 | 191 | /** How a test file is named: the same rule `scripts/test-shards.ts` uses. */ |
| 218 | 192 | export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/; |
| 219 | 193 | |
| @@ -415,7 +389,7 @@ export function crashedOutside(result: Pick<ShardResult, "code" | "output">): bo | ||
| 415 | 389 | /** |
| 416 | 390 | * The crashed run's output, kept in the log for whoever reads it, each line |
| 417 | 391 | * behind ` │ ` so nothing that reads the log (the tally, the failing-test |
| 418 | * list, the budget re-run) counts its reds beside the re-run's. | |
| 392 | * list) counts its reds beside the re-run's. | |
| 419 | 393 | */ |
| 420 | 394 | export function quoteCrash(where: string, code: number, output: string): string { |
| 421 | 395 | const lines = output.replace(/\n$/, "").split("\n"); |
scripts/test-shards.ts +4 −17
| @@ -74,7 +74,6 @@ import { | ||
| 74 | 74 | tally, |
| 75 | 75 | weigher, |
| 76 | 76 | besideOthers, |
| 77 | budgetLast, | |
| 78 | 77 | failingFiles, |
| 79 | 78 | onlyTestFailures, |
| 80 | 79 | crashedOutside, |
| @@ -414,23 +413,13 @@ const serialFiles = !record && count.value === 1; | ||
| 414 | 413 | // refreshes the time of every file it ran (#1797). |
| 415 | 414 | const timed = !record && !serialFiles && gate && tier === "full" && "none" in shardSpec; |
| 416 | 415 | const perFile = record || measure || serialFiles || timed; |
| 417 | // Files that bound a speed run last and alone, after every other shard, | |
| 418 | // so the gate's own load cannot redden them (#7617). | |
| 419 | const ordered = budgetLast(files, (file) => { | |
| 420 | try { | |
| 421 | return readFileSync(file, "utf8"); | |
| 422 | } catch { | |
| 423 | return undefined; | |
| 424 | } | |
| 425 | }); | |
| 426 | 416 | const slicesOf = (list: string[]): string[][] => |
| 427 | 417 | list.length === 0 |
| 428 | 418 | ? [] |
| 429 | 419 | : perFile |
| 430 | 420 | ? heaviestFirst(list, weight).map((file) => [file]) |
| 431 | 421 | : splitCapped(list, count.value, weight); |
| 432 | const mainSlices = slicesOf(ordered.rest); | |
| 433 | const slices = [...mainSlices, ...ordered.budget.map((file) => [file])]; | |
| 422 | const slices = slicesOf(files); | |
| 434 | 423 | const work = files.reduce((sum, file) => sum + weight(file), 0); |
| 435 | 424 | |
| 436 | 425 | const banner = record |
| @@ -447,8 +436,6 @@ const banner = record | ||
| 447 | 436 | `expected ${slices.map((slice) => slice.reduce((sum, file) => sum + weight(file), 0).toFixed(0) + "s").join(" ")}`; |
| 448 | 437 | console.log(banner); |
| 449 | 438 | process.stderr.write(banner + "\n"); |
| 450 | if (ordered.budget.length > 0) | |
| 451 | console.log(`test-shards: ${ordered.budget.length} budget files last, one at a time`); | |
| 452 | 439 | |
| 453 | 440 | const children = new Set<Bun.Subprocess>(); |
| 454 | 441 | for (const signal of ["SIGINT", "SIGTERM"] as const) |
| @@ -683,12 +670,12 @@ const started = performance.now(); | ||
| 683 | 670 | const results = [ |
| 684 | 671 | ...(await pool( |
| 685 | 672 | // Never more side by side than the count: capped slices outnumber it. |
| 686 | serialFiles ? 1 : Math.max(1, Math.min(count.value, mainSlices.length)), | |
| 673 | serialFiles ? 1 : Math.max(1, Math.min(count.value, slices.length)), | |
| 687 | 674 | 0, |
| 688 | mainSlices.length, | |
| 675 | slices.length, | |
| 689 | 676 | )), |
| 690 | 677 | // Budget files, one at a time, once the rest are done and green. |
| 691 | ...(await pool(1, mainSlices.length, slices.length)), | |
| 678 | ...(await pool(1, slices.length, slices.length)), | |
| 692 | 679 | ]; |
| 693 | 680 | const wall = (performance.now() - started) / 1000; |
| 694 | 681 | const ending = tally(results, wall); |
test/admin-repos.test.ts +5 −14
| @@ -9,7 +9,8 @@ import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | ||
| 9 | 9 | import { canAdmin, getRepoById } from "../src/core/repos.ts"; |
| 10 | 10 | import { createUser, getUserById, type User } from "../src/core/users.ts"; |
| 11 | 11 | import { id } from "../src/kernel/id.ts"; |
| 12 | import { expectBudget } from "./helpers/budget.ts"; | |
| 12 | import { statements } from "./helpers/statements.ts"; | |
| 13 | import { expectWork } from "./helpers/work.ts"; | |
| 13 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | 15 | |
| 15 | 16 | quietLogs(); |
| @@ -124,21 +125,11 @@ test("it is exactly one statement", () => { | ||
| 124 | 125 | expect(n).toBe(1); |
| 125 | 126 | }); |
| 126 | 127 | |
| 127 | test("on 2,000 repositories it costs about one scan of the repository table", () => { | |
| 128 | test("on 2,000 repositories it is still one statement", async () => { | |
| 128 | 129 | app.db.transaction(() => { |
| 129 | 130 | for (let i = 0; i < 2000; i++) repo(i % 3 === 0 ? pat.id : root.id, `bulk${i}`); |
| 130 | 131 | })(); |
| 131 | const best = (run: () => unknown): number => { | |
| 132 | let least = Infinity; | |
| 133 | for (let n = 0; n < 5; n++) { | |
| 134 | const t = performance.now(); | |
| 135 | run(); | |
| 136 | least = Math.min(least, performance.now() - t); | |
| 137 | } | |
| 138 | return least; | |
| 139 | }; | |
| 140 | // The baseline is a plain scan of the same table, taken in the same run. | |
| 141 | const against = best(() => app.db.query("SELECT id FROM repo").all()); | |
| 142 | expectBudget(best(() => adminRepoIds(app.db, pat)), { ms: 5, against }); | |
| 132 | // Counted, not timed: one statement however many repositories there are. | |
| 133 | expectWork(await statements(app.db, () => void adminRepoIds(app.db, pat)), { max: 1 }); | |
| 143 | 134 | expect(adminRepoIds(app.db, pat).size).toBe(667 + 3); |
| 144 | 135 | }); |
test/api-pulls-timing.test.ts +7 −12
| @@ -10,7 +10,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 10 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| 11 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | import { expectBudget } from "./helpers/budget.ts"; | |
| 13 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 14 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 15 | |
| 16 | 16 | quietLogs(); |
| @@ -109,20 +109,15 @@ test("130 open pulls read their mergeability from the cache: the merge phase is | ||
| 109 | 109 | }); |
| 110 | 110 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); |
| 111 | 111 | } |
| 112 | const mergeMs = async (): Promise<number> => { | |
| 113 | const res = await fetch(`${servers.url}/api/v1/repos/alice/r/pulls?limit=200`, { headers: { cookie } }); | |
| 114 | const items = (JSON.parse(await res.text()) as { items: unknown[] }).items; | |
| 115 | expect(items.length).toBeGreaterThanOrEqual(130); | |
| 116 | const m = /merge;dur=([\d.]+)/.exec(res.headers.get("server-timing") ?? ""); | |
| 117 | return Number(m?.[1] ?? NaN); | |
| 118 | }; | |
| 119 | const uncached = await mergeMs(); | |
| 112 | const url = `${servers.url}/api/v1/repos/alice/r/pulls?limit=200`; | |
| 113 | const res = await fetch(url, { headers: { cookie } }); | |
| 114 | expect((JSON.parse(await res.text()) as { items: unknown[] }).items.length).toBeGreaterThanOrEqual(130); | |
| 120 | 115 | const spawn = spyOn(Bun, "spawn"); |
| 121 | 116 | try { |
| 122 | let best = Infinity; | |
| 123 | for (let k = 0; k < 5; k++) best = Math.min(best, await mergeMs()); | |
| 117 | // Counted, not timed: one read per page, not one per pull (130 pulls, no N+1). | |
| 118 | const reads = await requestStatements(app.db, url, { headers: { cookie } }); | |
| 124 | 119 | expect(spawn.mock.calls.filter((c) => (c[0] as string[]).includes("merge-tree")).length).toBe(0); |
| 125 | expectBudget(best, { ms: 20, against: uncached / 100 }); | |
| 120 | expectWork(reads, { max: 16 }); | |
| 126 | 121 | } finally { |
| 127 | 122 | spawn.mockRestore(); |
| 128 | 123 | } |
test/app-home-batched.test.ts +4 −21
| @@ -12,7 +12,6 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 12 | 12 | import { createUser } from "../src/core/users.ts"; |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | import { proveAll } from "./helpers/verified.ts"; |
| 15 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 16 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | 16 | import { quiet } from "./helpers/statements.ts"; |
| 18 | 17 | |
| @@ -238,22 +237,7 @@ test("following stays cheap on a diverged fork (regression guard; this pull does | ||
| 238 | 237 | expect(Math.abs((await costOf(path)) - small[path]!)).toBeLessThanOrEqual(SLACK); |
| 239 | 238 | }); |
| 240 | 239 | |
| 241 | async function phases(path: string): Promise<Record<string, number>> { | |
| 242 | const best: Record<string, number> = {}; | |
| 243 | for (let i = 0; i < 6; i++) { | |
| 244 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 245 | expect(res.status).toBe(200); | |
| 246 | const body = await res.text(); | |
| 247 | if (path.endsWith("-fork")) expect(body.replace(/\s+/g, " ")).toContain("needs a decision"); | |
| 248 | for (const part of (res.headers.get("server-timing") ?? "").split(", ")) { | |
| 249 | const m = /^([a-z]+);dur=([\d.]+)/.exec(part); | |
| 250 | if (m) best[m[1]!] = Math.min(best[m[1]!] ?? Infinity, Number(m[2])); | |
| 251 | } | |
| 252 | } | |
| 253 | return best; | |
| 254 | } | |
| 255 | ||
| 256 | test("now stays within budget on the live shape: 300 open issues, 2,000 closed, 3,500 edges, 20 pillars", async () => { | |
| 240 | test("now costs the same statements on the live shape: 300 open issues, 2,000 closed, 3,500 edges, 20 pillars", async () => { | |
| 257 | 241 | const repo = getRepo(app.db, "alice", "vault")!; |
| 258 | 242 | const author = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id; |
| 259 | 243 | app.db.transaction(() => { |
| @@ -264,10 +248,9 @@ test("now stays within budget on the live shape: 300 open issues, 2,000 closed, | ||
| 264 | 248 | ).run(`${repo.id}i${i}`, repo.id, 5000 + i, `Tell ${i}`, author); |
| 265 | 249 | seedLive(app.db, repo.id, author, 0); |
| 266 | 250 | })(); |
| 267 | const against = await baselineMs(servers.url); | |
| 268 | const best = await phases("/alice/vault"); | |
| 269 | expectBudget(best.now!, { ms: 5, against }); | |
| 270 | expectBudget(best.shipped!, { ms: 5, against }); | |
| 251 | // Counted, not timed: 2,000 closed issues and 3,500 edges cost four statements more than the small page | |
| 252 | // (measured), not one per row; a per-row read would add thousands. | |
| 253 | expect(Math.abs((await costOf("/alice/vault")) - small["/alice/vault"]!)).toBeLessThanOrEqual(4); | |
| 271 | 254 | }); |
| 272 | 255 | |
| 273 | 256 | test("the lean deferred read says what the whole graph says, and something is deferred", () => { |
test/app-pages-timing.test.ts +12 −12
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/:owner/:name/app` and its `answers`, `now` and `state` fragments name their Server-Timing phases and answer within budget (#4320). */ | |
| 1 | /** `/:owner/:name/app` and its `answers`, `now` and `state` fragments name their Server-Timing phases and cost a bounded number of statements (#4320). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -59,6 +59,14 @@ afterAll(() => { | ||
| 59 | 59 | rmSync(root, { recursive: true, force: true }); |
| 60 | 60 | }); |
| 61 | 61 | |
| 62 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 63 | const MAX_STATEMENTS: Record<string, number> = { | |
| 64 | "/app": 10, | |
| 65 | "/app/answers": 10, | |
| 66 | "/app/now": 26, | |
| 67 | "/app/state": 21, | |
| 68 | }; | |
| 69 | ||
| 62 | 70 | /** Route → the reads it makes, between `session` and `render`. */ |
| 63 | 71 | const ROUTES: [string, string[]][] = [ |
| 64 | 72 | ["/app", ["repo", "view"]], |
| @@ -80,24 +88,16 @@ const get = (path: string) => | ||
| 80 | 88 | fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); |
| 81 | 89 | |
| 82 | 90 | for (const [route, reads] of ROUTES) |
| 83 | test(`GET /alice/vault${route} names its phases and answers within its budget`, async () => { | |
| 84 | const against = await baselineMs(servers.url); | |
| 91 | test(`GET /alice/vault${route} names its phases and costs a bounded number of statements`, async () => { | |
| 85 | 92 | await (await get(`/alice/vault${route}`)).arrayBuffer(); // warm |
| 86 | // Timers and GC jitter on a busy machine: judge the best of several requests. | |
| 87 | let total = Infinity; | |
| 88 | let left = Infinity; | |
| 89 | 93 | for (let i = 0; i < 5; i++) { |
| 90 | 94 | const res = await get(`/alice/vault${route}`); |
| 91 | 95 | await res.arrayBuffer(); |
| 92 | 96 | expect(res.status).toBeLessThan(400); |
| 93 | 97 | const got = phases(res.headers.get("server-timing")); |
| 94 | 98 | for (const name of ["session", ...reads, "render"]) expect(got.has(name)).toBe(true); |
| 95 | const sum = ["session", ...reads, "render"].reduce((n, k) => n + got.get(k)!, 0); | |
| 96 | total = Math.min(total, got.get("app")!); | |
| 97 | left = Math.min(left, Math.abs(got.get("app")! - sum)); | |
| 98 | 99 | } |
| 99 | expectBudget(total, { ms: 50, against }); | |
| 100 | expect(left).toBeLessThanOrEqual(1.5); // each is rounded to 0.1 | |
| 100 | expectWork(await requestStatements(app.db, `${servers.url}/alice/vault${route}`, { headers: { cookie }, redirect: "manual" }), { max: MAX_STATEMENTS[route]! }); | |
| 101 | 101 | }); |
| 102 | 102 | |
| 103 | 103 | for (const [route] of ROUTES) |
test/ask-to-live-batched.test.ts +4 −10
| @@ -9,7 +9,8 @@ import { closesFromBody } from "../src/core/pulls.ts"; | ||
| 9 | 9 | import { sealedScope } from "../src/core/sealed.ts"; |
| 10 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| 11 | 11 | import { askToLiveFor } from "../src/web/ask_to_live.ts"; |
| 12 | import { expectBudget } from "./helpers/budget.ts"; | |
| 12 | import { expectWork } from "./helpers/work.ts"; | |
| 13 | import { statements } from "./helpers/statements.ts"; | |
| 13 | 14 | |
| 14 | 15 | let root = ""; |
| 15 | 16 | let app: App; |
| @@ -140,14 +141,7 @@ test("the answer is the per-pull answer, for a writer and for a stranger", () => | ||
| 140 | 141 | expect(sealedScope(app.db, repo, undefined)).toEqual({}); |
| 141 | 142 | }); |
| 142 | 143 | |
| 143 | test(`${PULLS} merged pulls over ${PILLARS} pillars answer within the budget`, async () => { | |
| 144 | test(`${PULLS} merged pulls over ${PILLARS} pillars answer in a bounded number of statements`, async () => { | |
| 144 | 145 | askToLiveFor(app, repo, view, undefined); |
| 145 | const base = 5; // ms | |
| 146 | let best = Infinity; | |
| 147 | for (let i = 0; i < 5; i++) { | |
| 148 | const started = performance.now(); | |
| 149 | askToLiveFor(app, repo, view, undefined); | |
| 150 | best = Math.min(best, performance.now() - started); | |
| 151 | } | |
| 152 | expectBudget(best, { ms: 50, against: base }); | |
| 146 | expectWork(await statements(app.db, () => void askToLiveFor(app, repo, view, undefined)), { max: 5 }); | |
| 153 | 147 | }); |
test/asked-timing.test.ts +4 −7
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/asked` names session, each read and render in Server-Timing, and answers a person with 500 asks within budget (#3358). */ | |
| 1 | /** `/asked` names session, each read and render in Server-Timing, and costs a bounded number of statements for a person with 500 asks (#3358). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -72,12 +72,10 @@ afterAll(() => { | ||
| 72 | 72 | rmSync(root, { recursive: true, force: true }); |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | test("GET /asked names its phases and answers within its budget with 500 asks", async () => { | |
| 76 | const against = await baselineMs(servers.url); | |
| 75 | test("GET /asked names its phases and costs a bounded number of statements with 500 asks", async () => { | |
| 77 | 76 | const get = () => fetch(`${servers.url}/asked?show=all`, { headers: { cookie } }); |
| 78 | 77 | await (await get()).text(); |
| 79 | 78 | // The best of three: a shared runner's stall hits one request, not the page's cost. |
| 80 | let best = Infinity; | |
| 81 | 79 | for (let i = 0; i < 3; i++) { |
| 82 | 80 | const res = await get(); |
| 83 | 81 | const body = await res.text(); |
| @@ -86,7 +84,6 @@ test("GET /asked names its phases and answers within its budget with 500 asks", | ||
| 86 | 84 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 87 | 85 | for (const name of ["app", "session", "repos", "counts", "asks", "thoughts", "flags", "render"]) |
| 88 | 86 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 89 | best = Math.min(best, Number(/app;dur=([\d.]+)/.exec(parts.join(", "))![1])); | |
| 90 | 87 | } |
| 91 | expectBudget(best, { ms: 50, against }); | |
| 88 | expectWork(await requestStatements(app.db, `${servers.url}/asked?show=all`, { headers: { cookie } }), { max: 35 }); | |
| 92 | 89 | }); |
test/branches-many.test.ts +32 −25
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * A repository with 200 branches lists them in under 50 ms: ahead/behind and | |
| 2 | * A repository with 200 branches lists them with a fixed number of git processes: ahead/behind and | |
| 3 | 3 | * tips are one git call each, not one per branch. |
| 4 | 4 | */ |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 5 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| @@ -13,7 +13,7 @@ import { commitLog } from "../src/git/read.ts"; | ||
| 13 | 13 | import { repoDir } from "../src/git/repos.ts"; |
| 14 | 14 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 15 | 15 | import { seedAdmin } from "./helpers/admin.ts"; |
| 16 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 16 | import { expectWork } from "./helpers/work.ts"; | |
| 17 | 17 | |
| 18 | 18 | const env = gitClientEnv({ |
| 19 | 19 | GIT_AUTHOR_NAME: "A", |
| @@ -79,6 +79,17 @@ afterAll(async () => { | ||
| 79 | 79 | await rm(work, { recursive: true, force: true }); |
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | /** How many processes `work` starts: counted, not timed (#7824). */ | |
| 83 | async function spawns(work: () => Promise<unknown>): Promise<number> { | |
| 84 | const spy = spyOn(Bun, "spawn"); | |
| 85 | try { | |
| 86 | await work(); | |
| 87 | return spy.mock.calls.length; | |
| 88 | } finally { | |
| 89 | spy.mockRestore(); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 82 | 93 | const get = async (path: string): Promise<{ text: string; timing: string }> => { |
| 83 | 94 | const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } }); |
| 84 | 95 | const text = await res.text(); |
| @@ -86,13 +97,13 @@ const get = async (path: string): Promise<{ text: string; timing: string }> => { | ||
| 86 | 97 | return { text, timing: res.headers.get("server-timing") ?? "" }; |
| 87 | 98 | }; |
| 88 | 99 | |
| 89 | test("200 branches hold the 50 ms budget and name their phases", async () => { | |
| 100 | test("200 branches start a bounded number of git processes and name their phases", async () => { | |
| 90 | 101 | await get("/alice/app/branches"); |
| 91 | const { text, timing } = await get("/alice/app/branches"); | |
| 102 | let page = { text: "", timing: "" }; | |
| 103 | // One batched read of the refs, not a process per branch. | |
| 104 | expectWork(await spawns(async () => void (page = await get("/alice/app/branches"))), { max: 4 }); | |
| 105 | const { text, timing } = page; | |
| 92 | 106 | const dur = (name: string) => Number(new RegExp(`(?:^|, )${name};dur=([0-9.]+)`).exec(timing)?.[1]); |
| 93 | // 50 ms absolute on a quiet machine (FORGE_TEST_QUIET=1); under load it is judged | |
| 94 | // against a /healthz baseline from the same run, capped at 4x the budget. | |
| 95 | expectBudget(dur("app"), { ms: 50, against: await baselineMs(servers.url) }); | |
| 96 | 107 | for (const phase of ["session", "refs", "compare", "render"]) expect(dur(phase)).toBeGreaterThanOrEqual(0); |
| 97 | 108 | expect(text).toContain("200 branches"); |
| 98 | 109 | expect(text).toContain("feat/b198"); |
| @@ -115,15 +126,15 @@ test("the one-call counts equal the per-branch rev-list counts", async () => { | ||
| 115 | 126 | expect(listed.value.branches[1]).toMatchObject({ name: "feat/b000", ahead: 1, behind: 1 }); |
| 116 | 127 | }, 60_000); |
| 117 | 128 | |
| 118 | test("the API returns the first 30 of 200 branches with the whole list's counts, and its git phase fits the budget", async () => { | |
| 129 | test("the API returns the first 30 of 200 branches with the whole list's counts, and a page of it starts a bounded number of git processes", async () => { | |
| 119 | 130 | const dir = repoDir(app.reposRoot, "alice", "app"); |
| 120 | 131 | const full = await listBranches(dir, "main"); |
| 121 | 132 | if (!full.ok) throw new Error("listBranches failed"); |
| 122 | 133 | const path = "/api/v1/repos/alice/app/branches?limit=30"; |
| 123 | 134 | await get(path); |
| 124 | const { text, timing } = await get(path); | |
| 125 | const dur = Number(/(?:^|, )git;dur=([0-9.]+)/.exec(timing)?.[1]); | |
| 126 | expectBudget(dur, { ms: 60, against: await baselineMs(servers.url) }); | |
| 135 | let got = { text: "", timing: "" }; | |
| 136 | expectWork(await spawns(async () => void (got = await get(path))), { max: 5 }); | |
| 137 | const { text } = got; | |
| 127 | 138 | const page = JSON.parse(text) as { items: { name: string; ahead: number | null; behind: number | null }[]; next_cursor: string | null }; |
| 128 | 139 | // Rows, order and cursor are the whole list's first 30. |
| 129 | 140 | expect(page.items.map((i) => [i.name, i.ahead, i.behind])).toEqual( |
| @@ -135,23 +146,19 @@ test("the API returns the first 30 of 200 branches with the whole list's counts, | ||
| 135 | 146 | expect(next.items[0]).toMatchObject({ ahead: 1, behind: 1 }); |
| 136 | 147 | }, 30_000); |
| 137 | 148 | |
| 138 | test("the per-branch fallback gives the same rows, and the one-call path is faster", async () => { | |
| 149 | test("the per-branch fallback gives the same rows, and the one-call path starts fewer processes", async () => { | |
| 139 | 150 | const dir = repoDir(app.reposRoot, "alice", "app"); |
| 140 | // Best of three each, so one stalled run on a loaded machine cannot flip it. | |
| 141 | const best = async (opts: { bulk?: boolean }) => { | |
| 142 | let fastest = Infinity, last = await listBranches(dir, "main", opts); | |
| 143 | for (let i = 0; i < 3; i++) { | |
| 144 | const t = performance.now(); | |
| 145 | last = await listBranches(dir, "main", opts); | |
| 146 | fastest = Math.min(fastest, performance.now() - t); | |
| 147 | } | |
| 148 | return { last, fastest }; | |
| 151 | const run = async (opts: { bulk?: boolean }) => { | |
| 152 | let last = await listBranches(dir, "main", opts); | |
| 153 | const started = await spawns(async () => void (last = await listBranches(dir, "main", opts))); | |
| 154 | return { last, started }; | |
| 149 | 155 | }; |
| 150 | const { last: bulk, fastest: bulkMs } = await best({}); | |
| 151 | const { last: slow, fastest: slowMs } = await best({ bulk: false }); | |
| 156 | const { last: bulk, started: bulkSpawns } = await run({}); | |
| 157 | const { last: slow, started: slowSpawns } = await run({ bulk: false }); | |
| 152 | 158 | if (!bulk.ok || !slow.ok) throw new Error("listBranches failed"); |
| 153 | 159 | expect(slow.value).toEqual(bulk.value); |
| 154 | expect(bulkMs).toBeLessThan(slowMs); | |
| 160 | expect(bulkSpawns).toBeLessThan(slowSpawns); | |
| 161 | expectWork(bulkSpawns, { max: 5 }); | |
| 155 | 162 | }, 60_000); |
| 156 | 163 | |
| 157 | 164 | test("a malformed or unsupported ahead-behind output gives up so the slow path runs", () => { |
test/budget.test.ts deleted −50
| @@ -1,50 +0,0 @@ | ||
| 1 | /** `budgetLimit` is the one place a speed budget is decided (#4429): absolute when quiet, relative and capped when not. */ | |
| 2 | import { afterEach, expect, test } from "bun:test"; | |
| 3 | import { BASELINE_FACTOR, MAX_RELAXATION, budgetLimit, expectBudget } from "./helpers/budget.ts"; | |
| 4 | ||
| 5 | const savedTiming = process.env.FORGE_TEST_TIMING; | |
| 6 | const saved = process.env.FORGE_TEST_QUIET; | |
| 7 | afterEach(() => { | |
| 8 | if (saved === undefined) delete process.env.FORGE_TEST_QUIET; | |
| 9 | else process.env.FORGE_TEST_QUIET = saved; | |
| 10 | if (savedTiming === undefined) delete process.env.FORGE_TEST_TIMING; | |
| 11 | else process.env.FORGE_TEST_TIMING = savedTiming; | |
| 12 | }); | |
| 13 | ||
| 14 | test("a quiet cell holds the absolute ms, whatever the baseline", () => { | |
| 15 | process.env.FORGE_TEST_QUIET = "1"; | |
| 16 | expect(budgetLimit(50, 0.1)).toBe(50); | |
| 17 | expect(budgetLimit(50, 100)).toBe(50); | |
| 18 | }); | |
| 19 | ||
| 20 | test("a loaded cell scales with the baseline, never below ms", () => { | |
| 21 | delete process.env.FORGE_TEST_QUIET; | |
| 22 | expect(budgetLimit(50, 0.1)).toBe(50); | |
| 23 | expect(budgetLimit(50, 6)).toBe(Math.min(50 * MAX_RELAXATION, 6 * BASELINE_FACTOR)); | |
| 24 | expect(budgetLimit(50, 6)).toBe(60); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a loaded cell never relaxes a budget past MAX_RELAXATION times ms", () => { | |
| 28 | delete process.env.FORGE_TEST_QUIET; | |
| 29 | expect(budgetLimit(50, 1000)).toBe(50 * MAX_RELAXATION); | |
| 30 | expect(budgetLimit(15, 1000)).toBe(15 * MAX_RELAXATION); | |
| 31 | }); | |
| 32 | ||
| 33 | test("only FORGE_TEST_QUIET=1 means quiet", () => { | |
| 34 | process.env.FORGE_TEST_QUIET = "true"; | |
| 35 | expect(budgetLimit(50, 1000)).toBe(50 * MAX_RELAXATION); | |
| 36 | }); | |
| 37 | ||
| 38 | test("expectBudget fails a page that regressed to 150 ms beside a 5 ms baseline, when timing is enforced", () => { | |
| 39 | delete process.env.FORGE_TEST_QUIET; | |
| 40 | process.env.FORGE_TEST_TIMING = "1"; | |
| 41 | expect(() => expectBudget(150, { ms: 50, against: 5 })).toThrow(); | |
| 42 | expect(() => expectBudget(40, { ms: 50, against: 5 })).not.toThrow(); | |
| 43 | }); | |
| 44 | ||
| 45 | test("without FORGE_TEST_TIMING a slow time never fails: load alone cannot turn a pull red (#7789)", () => { | |
| 46 | delete process.env.FORGE_TEST_TIMING; | |
| 47 | expect(() => expectBudget(5000, { ms: 50, against: 5 })).not.toThrow(); | |
| 48 | // A missing phase is not a slow one: it still fails. | |
| 49 | expect(() => expectBudget(Number.NaN, { ms: 50, against: 5 })).toThrow(); | |
| 50 | }); |
test/check-tiers.test.ts −40
| @@ -559,46 +559,6 @@ describe("the tiers in the forge's own checks service", () => { | ||
| 559 | 559 | expect(readRunLog(dataDir, run!.id).lines.join("\n")).toContain(`CELL-CPUS=${want}`); |
| 560 | 560 | }); |
| 561 | 561 | |
| 562 | test("no check the forge runs holds wall-clock budgets: a pull's check and the queue's gate alike (#7789)", async () => { | |
| 563 | // Set on the forge itself, so a leak through the check's environment shows. | |
| 564 | const saved = process.env["FORGE_TEST_TIMING"]; | |
| 565 | process.env["FORGE_TEST_TIMING"] = "1"; | |
| 566 | try { | |
| 567 | const probe = 'echo "TIMING=${FORGE_TEST_TIMING:-unset}"'; | |
| 568 | const seeded = await commitToBranch(app, alice, repo, { | |
| 569 | message: "Probe the timing flag", | |
| 570 | files: [{ path: "forge.json", content: JSON.stringify({ check: { run: "echo WHOLE", fast: probe, full: probe } }) }], | |
| 571 | }); | |
| 572 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 573 | const branched = await commitToBranch(app, alice, repo, { | |
| 574 | branch: "cpu-limit", | |
| 575 | message: "Touch e", | |
| 576 | files: [{ path: "e.txt", content: "e\n" }], | |
| 577 | }); | |
| 578 | if (!branched.ok) throw new Error(JSON.stringify(branched.error)); | |
| 579 | const pull = listPulls(app.db, repo.id, { state: "open", limit: 20 }).find((p) => p.headBranch === "cpu-limit")!; | |
| 580 | const deadline = Date.now() + 30_000; | |
| 581 | let run: Run | undefined; | |
| 582 | while (Date.now() < deadline) { | |
| 583 | run = listRuns(app.db, repo.id, { kind: "check", limit: 20 }).find( | |
| 584 | (r) => r.issueId === pull.id && r.commitOid === branched.value.commitOid, | |
| 585 | ); | |
| 586 | if (run && (run.state === "ok" || run.state === "failed")) break; | |
| 587 | await Bun.sleep(50); | |
| 588 | } | |
| 589 | expect(run?.state).toBe("ok"); | |
| 590 | const pullLog = readRunLog(dataDir, run!.id).lines.join("\n"); | |
| 591 | expect(pullLog).toContain("TIMING=unset"); | |
| 592 | const gated = await checks.gate(repo, pull, seeded.value.commitOid); | |
| 593 | expect(gated.ok).toBe(true); | |
| 594 | expect(gated.lines.join("\n")).toContain("TIMING=unset"); | |
| 595 | expect(gated.lines.join("\n")).not.toContain("TIMING=1"); | |
| 596 | } finally { | |
| 597 | if (saved === undefined) delete process.env["FORGE_TEST_TIMING"]; | |
| 598 | else process.env["FORGE_TEST_TIMING"] = saved; | |
| 599 | } | |
| 600 | }); | |
| 601 | ||
| 602 | 562 | test("a red full gate names its tests and the pull learns the files", async () => { |
| 603 | 563 | const red = await commitToBranch(app, alice, repo, { |
| 604 | 564 | message: "Full goes red", |
test/cloud-org-apps-timing.test.ts +13 −29
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/settings/cloud/audit`, `/organizations/new` and `/apps/open` name their Server-Timing phases and answer within budget (#4322). */ | |
| 1 | /** `/settings/cloud/audit`, `/organizations/new` and `/apps/open` name their Server-Timing phases and cost a bounded number of statements (#4322). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -45,6 +45,13 @@ afterAll(() => { | ||
| 45 | 45 | rmSync(root, { recursive: true, force: true }); |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 49 | const MAX_STATEMENTS: Record<string, number> = { | |
| 50 | "/settings/cloud/audit": 11, | |
| 51 | "/organizations/new": 10, | |
| 52 | "/apps/open": 10, | |
| 53 | }; | |
| 54 | ||
| 48 | 55 | // /apps/open with no host is the answer a stranger gets: session, then render. |
| 49 | 56 | // `route` and `compress` are the server's own work around the handler: named, |
| 50 | 57 | // the accounting no longer hangs on how long a loaded machine takes outside it. |
| @@ -54,42 +61,21 @@ const PAGES: [string, string[]][] = [ | ||
| 54 | 61 | ["/apps/open", ["route", "session", "render", "compress"]], |
| 55 | 62 | ]; |
| 56 | 63 | |
| 57 | /** The page's budget (ms), and the most a request may spend between its named phases (ms) when quiet. */ | |
| 58 | const BUDGET_MS = 50; | |
| 59 | const ACCOUNTING_MS = 1.5; | |
| 60 | /** Requests judged per page; each assertion takes its own best, so one stalled request cannot fail it. */ | |
| 64 | /** Requests checked for their named phases. */ | |
| 61 | 65 | const SAMPLES = 7; |
| 62 | 66 | |
| 63 | 67 | for (const [path, names] of PAGES) |
| 64 | test(`GET ${path} names its phases and answers within its budget`, async () => { | |
| 65 | const against = await baselineMs(servers.url); | |
| 68 | test(`GET ${path} names its phases and costs a bounded number of statements`, async () => { | |
| 66 | 69 | const get = () => fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); |
| 67 | 70 | await (await get()).text(); // warm |
| 68 | // Timers jitter on a busy machine: judge the best of several requests. | |
| 69 | let left = Infinity; | |
| 70 | let total = Infinity; | |
| 71 | let last = ""; | |
| 72 | 71 | for (let i = 0; i < SAMPLES; i++) { |
| 73 | 72 | const res = await get(); |
| 74 | 73 | await res.text(); |
| 75 | 74 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 76 | const dur = (n: string) => Number(parts.find((p) => p.startsWith(`${n};`))!.split("dur=")[1]); | |
| 77 | last = parts.join(", "); | |
| 78 | 75 | for (const name of ["app", ...names]) |
| 79 | expect(parts.find((p) => p.startsWith(`${name};`)), `phase ${name} in ${last}`).toMatch(/^[a-z]+;dur=\d+\.\d$/); | |
| 80 | total = Math.min(total, dur("app")); | |
| 81 | // Phases name the whole request: what is left over is small. | |
| 82 | left = Math.min(left, Math.abs(dur("app") - names.reduce((sum, n) => sum + dur(n), 0))); | |
| 83 | } | |
| 84 | expectBudget(total, { ms: BUDGET_MS, against }); | |
| 85 | // The unnamed stretch (routing, before `session`) is fixed work, ~0.9 ms quiet and ~1.7 ms in a loaded | |
| 86 | // shard (#7519): it stretches with the machine, so it is judged against the same-run baseline like speed. | |
| 87 | try { | |
| 88 | expectBudget(left, { ms: ACCOUNTING_MS, against }); | |
| 89 | } catch (e) { | |
| 90 | // Name the phases, so a red says whether the machine stalled or a phase went missing. | |
| 91 | throw new Error(`${(e as Error).message}; best app ${total.toFixed(1)} ms; last request ${last}`); | |
| 76 | expect(parts.find((p) => p.startsWith(`${name};`)), `phase ${name} in ${parts.join(", ")}`).toMatch(/^[a-z]+;dur=\d+\.\d$/); | |
| 92 | 77 | } |
| 78 | expectWork(await requestStatements(app.db, `${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }), { max: MAX_STATEMENTS[path]! }); | |
| 93 | 79 | }); |
| 94 | 80 | |
| 95 | 81 | // Signed out, the redirect to sign-in is still a named phase. |
| @@ -99,9 +85,7 @@ for (const path of ["/settings/cloud/audit", "/organizations/new"]) | ||
| 99 | 85 | await res.text(); |
| 100 | 86 | expect(res.status).toBe(303); |
| 101 | 87 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 102 | const dur = (n: string) => Number(parts.find((p) => p.startsWith(`${n};`))!.split("dur=")[1]); | |
| 103 | 88 | const names = ["route", "session", "render", "compress"]; |
| 104 | 89 | for (const name of names) |
| 105 | 90 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 106 | expect(Math.abs(dur("app") - names.reduce((sum, n) => sum + dur(n), 0))).toBeLessThan(1.5); | |
| 107 | 91 | }); |
test/explore-rank.test.ts −3
| @@ -10,7 +10,6 @@ import { createApp as insertApp } from "../src/core/apps.ts"; | ||
| 10 | 10 | import { createIssue } from "../src/core/issues.ts"; |
| 11 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 12 | import { countStatements } from "../bench/run.ts"; |
| 13 | import { quiet } from "./helpers/budget.ts"; | |
| 14 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 14 | import { proveAll } from "./helpers/verified.ts"; |
| 16 | 15 | import { promote } from "./helpers/members.ts"; |
| @@ -109,8 +108,6 @@ test("a warm rank of 500 public repositories is a fraction of a cold one and wit | ||
| 109 | 108 | await explore(app, undefined, query); |
| 110 | 109 | warm.push(performance.now() - t); |
| 111 | 110 | } |
| 112 | // The relative bound below holds under load; the absolute 15 ms only in a quiet cell. | |
| 113 | if (quiet()) expect(Math.min(...warm)).toBeLessThan(15); | |
| 114 | 111 | expect(Math.min(...warm) * 5).toBeLessThan(Math.min(...cold)); |
| 115 | 112 | }); |
| 116 | 113 |
test/flake.test.ts −44
| @@ -26,8 +26,6 @@ import { | ||
| 26 | 26 | redFile, |
| 27 | 27 | redIssueFor, |
| 28 | 28 | readRun, |
| 29 | TIMING_EXCLUDED, | |
| 30 | timingFiles, | |
| 31 | 29 | retries, |
| 32 | 30 | ROOT_CAUSES, |
| 33 | 31 | shuffled, |
| @@ -359,7 +357,6 @@ describe("no raw wall-clock budget in a test (#4429)", () => { | ||
| 359 | 357 | expect(rawBudgets("x", "expect(dur(\"app\")).toBeLessThan(50);")).toHaveLength(1); |
| 360 | 358 | expect(rawBudgets("x", "expect(total).toBeLessThan(50);")).toHaveLength(1); |
| 361 | 359 | expect(rawBudgets("x", "test(`GET /a answers under 50 ms`, () => {});")).toHaveLength(1); |
| 362 | expect(rawBudgets("x", "expectBudget(dur(\"app\"), { ms: 50, against });")).toEqual([]); | |
| 363 | 360 | expect(rawBudgets("x", "expect(elapsed).toBeLessThan(5_000);")).toEqual([]); // a hang guard |
| 364 | 361 | expect(rawBudgets("x", "expect(res.status).toBeLessThan(400);")).toEqual([]); |
| 365 | 362 | expect(rawBudgets("x", "expect(lines.length).toBeLessThan(20);")).toEqual([]); |
| @@ -427,44 +424,3 @@ describe("the scheduled run of the files outside the gate", () => { | ||
| 427 | 424 | expect(redFile("Red: test/a.test.ts")).toBe("test/a.test.ts"); |
| 428 | 425 | }); |
| 429 | 426 | }); |
| 430 | ||
| 431 | describe("the nightly timing run (#7789)", () => { | |
| 432 | const run = (file: string, failing: string[] = []) => | |
| 433 | readRun(file, 0, failing.length ? 1 : 0, failing.map((f) => `(fail) ${f}`).join("\n")); | |
| 434 | // Built, not written out, so this file is not itself a budget file. | |
| 435 | const helper = ["./helpers", "budget.ts"].join("/"); | |
| 436 | const imports = `import { expectBudget } from "${helper}";\n`; | |
| 437 | const files: Record<string, string> = { | |
| 438 | "test/a-timing.test.ts": `${imports}expectBudget(dur, { ms: 50, against });\n`, | |
| 439 | "test/b-mentions.test.ts": 'expect(rawBudgets("x", "expectBudget(dur, { ms: 50, against });")).toEqual([]);\n', | |
| 440 | "test/c-imports-only.test.ts": `import { baselineMs } from "${helper}";\n`, | |
| 441 | "test/budget.test.ts": `${imports}expectBudget(150, { ms: 50, against: 5 });\n`, | |
| 442 | }; | |
| 443 | ||
| 444 | test("holds only files that import the budget helper and call expectBudget, less the helper's own test", () => { | |
| 445 | expect(TIMING_EXCLUDED).toContain("test/budget.test.ts"); | |
| 446 | expect(timingFiles(Object.keys(files), (f) => files[f]!)).toEqual(["test/a-timing.test.ts"]); | |
| 447 | }); | |
| 448 | ||
| 449 | test("the real selection names budget files and nothing that merely mentions the name", () => { | |
| 450 | const picked = timingFiles(presentTests(), (f) => readFileSync(f, "utf8")); | |
| 451 | expect(picked.length).toBeGreaterThan(0); | |
| 452 | expect(picked).not.toContain("test/flake.test.ts"); | |
| 453 | for (const f of TIMING_EXCLUDED) expect(picked).not.toContain(f); | |
| 454 | }); | |
| 455 | ||
| 456 | test("a timing Red: issue says it is a budget and reproduces with timing on", () => { | |
| 457 | const red = run("test/a-timing.test.ts", ["page"]); | |
| 458 | const issue = redIssueFor(red, "http://run/2", "timing"); | |
| 459 | expect(issue.title).toBe("Red: test/a-timing.test.ts"); | |
| 460 | expect(issue.body).toContain("wall-clock budget"); | |
| 461 | expect(issue.body).toContain("FORGE_TEST_TIMING=1 FORGE_TEST_QUIET=1 bun test"); | |
| 462 | expect(issue.body).not.toContain("outside the gate"); | |
| 463 | }); | |
| 464 | ||
| 465 | test("flake-hunt names the timing mode in its usage", () => { | |
| 466 | const out = Bun.spawnSync(["bun", "scripts/flake-hunt.ts", "nope"]); | |
| 467 | expect(out.exitCode).toBe(2); | |
| 468 | expect(out.stderr.toString()).toContain("timing [--file-issues]"); | |
| 469 | }); | |
| 470 | }); |
test/helpers/budget.ts deleted −76
| @@ -1,76 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The one way a test bounds a speed (#4429). | |
| 3 | * | |
| 4 | * A raw `expect(ms).toBeLessThan(50)` is a coin flip in a loaded check cell: | |
| 5 | * the same code is 3 ms on a quiet machine and 80 ms beside six busy cells. | |
| 6 | * `expectBudget` judges a measured time against a baseline taken in the same | |
| 7 | * run, so load that slows the page slows the baseline with it. The absolute | |
| 8 | * `ms` is asserted when the cell is quiet (`FORGE_TEST_QUIET=1`, which the | |
| 9 | * nightly `timing` workflow sets). Otherwise the limit is relative to the | |
| 10 | * baseline but never above `MAX_RELAXATION` times `ms`, so a 50 ms budget | |
| 11 | * cannot drift past 200 ms however slow the baseline. | |
| 12 | * | |
| 13 | * Wall-clock is judged only where it can be trusted (#7789): with | |
| 14 | * `FORGE_TEST_TIMING=1`, set only by the nightly `timing` workflow. In every | |
| 15 | * check the forge runs, a pull's and the merge queue's gate alike, a time over its limit is reported on stderr and the test | |
| 16 | * passes, so the machine being busy can never turn a pull or a landing red; what a page | |
| 17 | * costs is a matter for the performance watcher (#7559) reading real traffic, | |
| 18 | * and a test that must fail a regression counts work (statements, bytes, | |
| 19 | * phases) instead. | |
| 20 | * | |
| 21 | * With timing on, a time over its limit throws an error that starts with | |
| 22 | * `BUDGET_MARK`, so the nightly's `Red:` issue says it is a budget. A time | |
| 23 | * that is not a number is a missing phase, not a slow one: that fails as any | |
| 24 | * `expect` does, timing on or off. | |
| 25 | */ | |
| 26 | import { expect } from "bun:test"; | |
| 27 | ||
| 28 | /** What a budget red's error starts with. */ | |
| 29 | export const BUDGET_MARK = "over budget (expectBudget)"; | |
| 30 | ||
| 31 | /** How many times slower than the same-run baseline a page may be under load. */ | |
| 32 | export const BASELINE_FACTOR = 10; | |
| 33 | ||
| 34 | /** The most a loaded cell may stretch a budget: the limit is never above this many times `ms`. */ | |
| 35 | export const MAX_RELAXATION = 4; | |
| 36 | ||
| 37 | /** Whether the cell is quiet enough to hold a number to its absolute ms. */ | |
| 38 | export const quiet = (): boolean => process.env.FORGE_TEST_QUIET === "1"; | |
| 39 | ||
| 40 | /** Whether wall-clock budgets are enforced: only the scheduled run says so (`FORGE_TEST_TIMING=1`). */ | |
| 41 | export const timing = (): boolean => process.env.FORGE_TEST_TIMING === "1"; | |
| 42 | ||
| 43 | /** The ms a measured time may take: `ms` when quiet, else relative to `against`. */ | |
| 44 | export function budgetLimit(ms: number, against: number): number { | |
| 45 | if (quiet()) return ms; | |
| 46 | return Math.min(ms * MAX_RELAXATION, Math.max(ms, against * BASELINE_FACTOR)); | |
| 47 | } | |
| 48 | ||
| 49 | /** Asserts `measured` (ms) fits the budget `ms`, judged against `against` (ms, same run) unless quiet. */ | |
| 50 | export function expectBudget(measured: number, opts: { ms: number; against: number }): void { | |
| 51 | if (!Number.isFinite(measured)) { | |
| 52 | expect(measured).toBeLessThan(budgetLimit(opts.ms, opts.against)); | |
| 53 | return; | |
| 54 | } | |
| 55 | const limit = budgetLimit(opts.ms, opts.against); | |
| 56 | if (measured < limit) return; | |
| 57 | if (!timing()) { | |
| 58 | console.warn(`over budget, not enforced (FORGE_TEST_TIMING unset): ${measured.toFixed(1)} ms vs ${limit.toFixed(1)} ms (budget ${opts.ms} ms)`); | |
| 59 | return; | |
| 60 | } | |
| 61 | throw new Error( | |
| 62 | `${BUDGET_MARK}: ${measured.toFixed(1)} ms is not under ${limit.toFixed(1)} ms ` + | |
| 63 | `(budget ${opts.ms} ms, baseline ${opts.against.toFixed(1)} ms${quiet() ? ", quiet" : ""})`, | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** The best wall-clock ms of a few loopback requests to `/healthz`: what a request costs right now. */ | |
| 68 | export async function baselineMs(url: string, runs = 5): Promise<number> { | |
| 69 | let best = Infinity; | |
| 70 | for (let i = 0; i < runs; i++) { | |
| 71 | const started = performance.now(); | |
| 72 | await (await fetch(`${url}/healthz`)).text(); | |
| 73 | best = Math.min(best, performance.now() - started); | |
| 74 | } | |
| 75 | return best; | |
| 76 | } |
test/helpers/work.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * The way a test bounds what a page costs without a clock (#7824). | |
| 3 | * | |
| 4 | * Wall-clock is a coin flip in a loaded check cell and is never judged in a | |
| 5 | * pull (see `budget.ts`). Work is countable: the SQL statements a request | |
| 6 | * runs, the bytes it sends. A regression (an N+1, a page that stops using its | |
| 7 | * cache) raises the count in every run, so a count fails a pull reliably where | |
| 8 | * a stopwatch cannot. | |
| 9 | */ | |
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { expect } from "bun:test"; | |
| 12 | import { statements } from "./statements.ts"; | |
| 13 | ||
| 14 | /** The fewest SQL statements one request for `url` costs (server and test share `db`). */ | |
| 15 | export async function requestStatements(db: Database, url: string, init?: RequestInit): Promise<number> { | |
| 16 | return statements(db, async () => { | |
| 17 | await (await fetch(url, init)).text(); | |
| 18 | }); | |
| 19 | } | |
| 20 | ||
| 21 | /** Asserts counted work `measured` is no larger than `max`. */ | |
| 22 | export function expectWork(measured: number, opts: { max: number }): void { | |
| 23 | expect(measured).toBeLessThanOrEqual(opts.max); | |
| 24 | } |
test/home-200.test.ts +8 −12
| @@ -1,8 +1,9 @@ | ||
| 1 | /** #5806: the owner's home on a forge of 200 repositories and 100 open asks answers warm in within 300 ms, every slow phase named. */ | |
| 1 | /** #5806: the owner's home on a forge of 200 repositories and 100 open asks is read in a bounded number of statements warm, every slow phase named. */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 4 | 4 | import { waitingCache } from "../src/web/studio/home_cache.ts"; |
| 5 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 5 | import { expectWork } from "./helpers/work.ts"; | |
| 6 | import { statements } from "./helpers/statements.ts"; | |
| 6 | 7 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; |
| 7 | 8 | |
| 8 | 9 | let f: SnappyFixture; |
| @@ -21,30 +22,25 @@ afterAll(async () => { | ||
| 21 | 22 | await f.close(); |
| 22 | 23 | }); |
| 23 | 24 | |
| 24 | async function look(): Promise<{ ms: number; phases: { name: string; ms: number }[] }> { | |
| 25 | const t = performance.now(); | |
| 25 | async function look(): Promise<{ phases: { name: string; ms: number }[] }> { | |
| 26 | 26 | const res = await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } }); |
| 27 | 27 | await res.arrayBuffer(); |
| 28 | const ms = performance.now() - t; | |
| 29 | 28 | const phases = (res.headers.get("server-timing") ?? "") |
| 30 | 29 | .split(", ") |
| 31 | 30 | .map((p) => ({ name: p.split(";")[0]!, ms: Number(/dur=([\d.]+)/.exec(p)?.[1] ?? 0) })); |
| 32 | return { ms, phases }; | |
| 31 | return { phases }; | |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | test("the second look at home as the owner is within its budget, and every phase over 20 ms is named", async () => { | |
| 34 | test("the second look at home as the owner costs a bounded number of statements, and every phase over 20 ms is named", async () => { | |
| 36 | 35 | await look(); |
| 37 | 36 | const warm = await look(); |
| 38 | 37 | const slow = warm.phases.filter((p) => p.name !== "app" && p.ms > 20); |
| 39 | console.log(`home warm ${warm.ms.toFixed(0)} ms:`, warm.phases.map((p) => `${p.name} ${p.ms}`).join(", ")); | |
| 40 | expectBudget(warm.ms, { ms: 300, against: await baselineMs(f.base) }); | |
| 38 | // Counted, not timed (#7824): 200 repositories and 100 asks are read in batches, not one statement each. | |
| 39 | expectWork(await statements(f.app.db, async () => void (await look())), { max: 70 }); | |
| 41 | 40 | // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone. |
| 42 | 41 | // Every phase home.ts and the feed time: one missing here fails as a plain expect, which the gate's budget retry never re-runs. |
| 43 | 42 | const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "render"]); |
| 44 | 43 | for (const p of slow) expect(known.has(p.name)).toBe(true); |
| 45 | const total = warm.phases.find((p) => p.name === "app")!.ms; | |
| 46 | const named = warm.phases.filter((p) => p.name !== "app").reduce((s, p) => s + p.ms, 0); | |
| 47 | expectBudget(total - named, { ms: 20, against: await baselineMs(f.base) }); | |
| 48 | 44 | }); |
| 49 | 45 | |
| 50 | 46 | const waiting = async (): Promise<number> => { |
test/idle-pulls-bound.test.ts +5 −9
| @@ -3,7 +3,8 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { expectBudget } from "./helpers/budget.ts"; | |
| 6 | import { expectWork } from "./helpers/work.ts"; | |
| 7 | import { statements } from "./helpers/statements.ts"; | |
| 7 | 8 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 9 | import { createUser } from "../src/core/users.ts"; |
| 9 | 10 | import { IDLE_PULLS_SQL, readIdlePulls } from "../src/flow/idle_pulls.ts"; |
| @@ -61,14 +62,9 @@ test("the plan never scans event", () => { | ||
| 61 | 62 | expect(plan.some((d) => d.includes("event_repo_kind_number_at"))).toBe(true); |
| 62 | 63 | }); |
| 63 | 64 | |
| 64 | test("answers inside its 20 ms budget on a seeded forge", () => { | |
| 65 | readIdlePulls(app.db, NOW, true); | |
| 66 | const b = performance.now(); | |
| 67 | app.db.query("SELECT count(*) FROM pull").get(); | |
| 68 | const against = performance.now() - b; | |
| 69 | const t = performance.now(); | |
| 70 | const rows = readIdlePulls(app.db, NOW, true); | |
| 71 | expectBudget(performance.now() - t, { ms: 20, against }); | |
| 65 | test("answers in one statement on a seeded forge", async () => { | |
| 66 | let rows: ReturnType<typeof readIdlePulls> = []; | |
| 67 | expectWork(await statements(app.db, () => void (rows = readIdlePulls(app.db, NOW, true))), { max: 1 }); | |
| 72 | 68 | expect(rows.length).toBe(20); |
| 73 | 69 | expect(rows[0]!.hours).toBeGreaterThanOrEqual(rows[19]!.hours); |
| 74 | 70 | }); |
test/kpis-landings-scale.test.ts +6 −7
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * #7627: the day's landings are read in one pass. On a forge of 20,000 merged |
| 3 | * pulls `readKpis` answers within 100 ms, and its count is the one the old | |
| 3 | * pulls `readKpis` costs a bounded number of statements, and its count is the one the old | |
| 4 | 4 | * per-row `NOT EXISTS` gave: a commit landed twice, or landed before the day |
| 5 | 5 | * and carried again today, counts once or not at all. |
| 6 | 6 | */ |
| 7 | 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 8 | 8 | import { readKpis } from "../src/flow/kpis.ts"; |
| 9 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 9 | import { expectWork } from "./helpers/work.ts"; | |
| 10 | import { statements } from "./helpers/statements.ts"; | |
| 10 | 11 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; |
| 11 | 12 | |
| 12 | 13 | const HOUR = 3_600_000; |
| @@ -73,12 +74,10 @@ afterAll(async () => { | ||
| 73 | 74 | await f.close(); |
| 74 | 75 | }); |
| 75 | 76 | |
| 76 | test("readKpis answers within 100 ms over 20,000 merged pulls", async () => { | |
| 77 | test("readKpis costs a bounded number of statements over 20,000 merged pulls", async () => { | |
| 77 | 78 | readKpis(f.app.db, new Date(NOW)); |
| 78 | const t = performance.now(); | |
| 79 | readKpis(f.app.db, new Date(NOW)); | |
| 80 | const ms = performance.now() - t; | |
| 81 | expectBudget(ms, { ms: 100, against: await baselineMs(f.base) }); | |
| 79 | // Counted, not timed (#7824): the figures are a few set-based reads, never one per merged pull. | |
| 80 | expectWork(await statements(f.app.db, () => void readKpis(f.app.db, new Date(NOW))), { max: 12 }); | |
| 82 | 81 | }); |
| 83 | 82 | |
| 84 | 83 | test("the count matches the per-row NOT EXISTS, duplicates counted once", () => { |
test/map-seed-timing.test.ts +7 −5
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | * week of merges answers fast when warm, with the same bytes as a cold |
| 4 | 4 | * build, and a plan shows at once (#3321). |
| 5 | 5 | */ |
| 6 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 6 | import { expectWork } from "./helpers/work.ts"; | |
| 7 | import { statements } from "./helpers/statements.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -79,7 +80,7 @@ afterAll(async () => { | ||
| 79 | 80 | await rm(dataDir, { recursive: true, force: true }); |
| 80 | 81 | }); |
| 81 | 82 | |
| 82 | test("warm, the seed's map answers within its budget with the bytes of a cold build", async () => { | |
| 83 | test("warm, the seed's map answers from its snapshot with the bytes of a cold build", async () => { | |
| 83 | 84 | mapSnapshots(app.db).clear(); |
| 84 | 85 | const cold = await get(); |
| 85 | 86 | const data = JSON.parse(cold.body) as { issues: unknown[]; edges: unknown[]; pillars: { merged?: number }[] }; |
| @@ -90,9 +91,10 @@ test("warm, the seed's map answers within its budget with the bytes of a cold bu | ||
| 90 | 91 | const warm = await get(); |
| 91 | 92 | expect(mapSnapshots(app.db).hits).toBe(hits + 1); |
| 92 | 93 | expect(warm.body).toBe(cold.body); |
| 93 | let slowest = 0; | |
| 94 | for (let i = 0; i < 5; i++) slowest = Math.max(slowest, (await get()).ms); | |
| 95 | expectBudget(slowest, { ms: 150, against: await baselineMs(servers.url) }); | |
| 94 | // Counted, not timed: a warm map is served from its snapshot, a hit each time and a few statements at most. | |
| 95 | const warmHits = mapSnapshots(app.db).hits; | |
| 96 | expectWork(await statements(app.db, async () => void (await get())), { max: 9 }); | |
| 97 | expect(mapSnapshots(app.db).hits).toBeGreaterThan(warmHits); | |
| 96 | 98 | expect(warm.timing).toContain("asktolive"); |
| 97 | 99 | }); |
| 98 | 100 |
test/milestones-shape.test.ts +6 −19
| @@ -7,10 +7,10 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { openIssue } from "../src/actions/issues.ts"; |
| 9 | 9 | import { planStamp, readPlan } from "../src/core/milestones.ts"; |
| 10 | import { getIssuesOfRepo } from "../src/core/issues.ts"; | |
| 11 | 10 | import { getRepo } from "../src/core/repos.ts"; |
| 12 | 11 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | import { expectBudget } from "./helpers/budget.ts"; | |
| 12 | import { expectWork } from "./helpers/work.ts"; | |
| 13 | import { statements } from "./helpers/statements.ts"; | |
| 14 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 15 | import { proveAll } from "./helpers/verified.ts"; |
| 16 | 16 | |
| @@ -92,23 +92,11 @@ const BEFORE = [ | ||
| 92 | 92 | ["P10 Pillar 10", 198, 180, 18, 36, 0, 63], |
| 93 | 93 | ]; |
| 94 | 94 | |
| 95 | test("a plan of 2,000 closed and 300 open issues, 3,000 pulls and 1,500 runs reads in under 50 ms with the same limbs", async () => { | |
| 95 | test("a plan of 2,000 closed and 300 open issues, 3,000 pulls and 1,500 runs reads in a bounded number of statements with the same limbs", async () => { | |
| 96 | 96 | const repo = getRepo(app.db, "alice", "forge")!; |
| 97 | // the same run's yardstick: the plan's own first stretch, every issue of the repository read lean, which slows with the machine as the plan does | |
| 98 | let against = Infinity; | |
| 99 | for (let n = 0; n < 5; n++) { | |
| 100 | const t = performance.now(); | |
| 101 | getIssuesOfRepo(app.db, repo.id, "issue", true); | |
| 102 | against = Math.min(against, performance.now() - t); | |
| 103 | } | |
| 104 | 97 | readPlan(app.db, repo, { spend: true }); |
| 105 | const times: number[] = []; | |
| 106 | for (let n = 0; n < 7; n++) { | |
| 107 | const start = performance.now(); | |
| 108 | readPlan(app.db, repo, { spend: true }); | |
| 109 | times.push(performance.now() - start); | |
| 110 | } | |
| 111 | expectBudget(Math.min(...times), { ms: 50, against }); | |
| 98 | // Counted, not timed: the plan is a fixed number of reads however many issues, pulls and runs there are. | |
| 99 | expectWork(await statements(app.db, () => void readPlan(app.db, repo, { spend: true })), { max: 24 }); | |
| 112 | 100 | expect(weights(true)).toEqual(BEFORE); |
| 113 | 101 | expect(weights(false).map((w) => w.slice(0, 6))).toEqual(BEFORE.map((w) => w.slice(0, 6))); |
| 114 | 102 | }); |
| @@ -126,9 +114,8 @@ test("a sealed issue is in no limb a viewer cannot see", () => { | ||
| 126 | 114 | test("the stamp is kept while nothing is written, and moves when an issue is", async () => { |
| 127 | 115 | const repo = getRepo(app.db, "alice", "forge")!; |
| 128 | 116 | const first = planStamp(app.db, repo); |
| 129 | const start = performance.now(); | |
| 130 | 117 | for (let n = 0; n < 20; n++) expect(planStamp(app.db, repo)).toBe(first); |
| 131 | expectBudget((performance.now() - start) / 20, { ms: 2, against: 1 }); | |
| 118 | expectWork((await statements(app.db, () => { for (let n = 0; n < 20; n++) planStamp(app.db, repo); })) / 20, { max: 1 }); | |
| 132 | 119 | expect(openIssue(app, getUserByName(app.db, "alice")!, repo, { title: "Fresh" }).ok).toBe(true); |
| 133 | 120 | expect(planStamp(app.db, repo)).not.toBe(first); |
| 134 | 121 | }); |
test/milestones-speed.test.ts +14 −27
| @@ -11,7 +11,8 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 11 | 11 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 12 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 13 | import { clearMapCache, planBuildCount } from "../src/web/map_cache.ts"; |
| 14 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 14 | import { expectWork } from "./helpers/work.ts"; | |
| 15 | import { statements } from "./helpers/statements.ts"; | |
| 15 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | 17 | import { proveAll } from "./helpers/verified.ts"; |
| 17 | 18 | |
| @@ -85,28 +86,13 @@ const list = async () => { | ||
| 85 | 86 | return res.status; |
| 86 | 87 | }; |
| 87 | 88 | |
| 88 | test("readPlan of 2,000 closed and 300 open issues under 10 pillars fits a 40 ms budget", async () => { | |
| 89 | test("readPlan of 2,000 closed and 300 open issues under 10 pillars costs a bounded number of statements", async () => { | |
| 89 | 90 | const repo = getRepo(app.db, "alice", "forge")!; |
| 90 | // The yardstick is the same machine reading the same rows raw, so load | |
| 91 | // stretches both and the verdict follows the code (#6690). The plan builds | |
| 92 | // 2,300 issue objects and walks their graph: ~25 ms quiet, so 30 sat on the | |
| 93 | // edge and flipped with the machine (#6811); 40 leaves room under the page's 50. | |
| 94 | const raw = Math.min( | |
| 95 | ...Array.from({ length: 7 }, () => { | |
| 96 | const start = performance.now(); | |
| 97 | app.db.query("SELECT * FROM issue WHERE repo_id = ?1").all(repo.id); | |
| 98 | return performance.now() - start; | |
| 99 | }), | |
| 100 | ); | |
| 101 | readPlan(app.db, repo, { spend: true }); | |
| 102 | const times: number[] = []; | |
| 103 | for (let n = 0; n < 7; n++) { | |
| 104 | const start = performance.now(); | |
| 105 | const plan = readPlan(app.db, repo, { spend: true }); | |
| 106 | times.push(performance.now() - start); | |
| 107 | expect(plan.limbs).toHaveLength(PILLARS); | |
| 108 | } | |
| 109 | expectBudget(Math.min(...times), { ms: 40, against: raw }); | |
| 91 | // Counted, not timed (#7824): the plan reads the repository's issues in a fixed number of | |
| 92 | // statements, never one per issue (2,300 issues here). | |
| 93 | let plan = readPlan(app.db, repo, { spend: true }); | |
| 94 | expectWork(await statements(app.db, () => void (plan = readPlan(app.db, repo, { spend: true }))), { max: 22 }); | |
| 95 | expect(plan.limbs).toHaveLength(PILLARS); | |
| 110 | 96 | }); |
| 111 | 97 | |
| 112 | 98 | test("a write to another repository keeps the plan warm; a new issue here refreshes it", async () => { |
| @@ -139,13 +125,14 @@ test("a milestone page names session, repo, plan and render", async () => { | ||
| 139 | 125 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 140 | 126 | }); |
| 141 | 127 | |
| 142 | test("a cold plan names its stretches, stays under 50 ms, and keeps each limb's counts", async () => { | |
| 143 | const against = await baselineMs(servers.url); | |
| 128 | test("a cold plan names its stretches, costs a bounded number of statements, and keeps each limb's counts", async () => { | |
| 144 | 129 | const stretches: string[] = []; |
| 145 | 130 | const repo = getRepo(app.db, "alice", "forge")!; |
| 146 | const start = performance.now(); | |
| 147 | const plan = readPlan(app.db, repo, { spend: true, stretch: (name) => stretches.push(name) }); | |
| 148 | expectBudget(performance.now() - start, { ms: 50, against }); | |
| 131 | let plan = readPlan(app.db, repo, { spend: true }); | |
| 132 | expectWork(await statements(app.db, () => { | |
| 133 | stretches.length = 0; | |
| 134 | plan = readPlan(app.db, repo, { spend: true, stretch: (name) => stretches.push(name) }); | |
| 135 | }), { max: 22 }); | |
| 149 | 136 | expect(stretches).toEqual(["graph", "crew", "week", "spend", "limbs"]); |
| 150 | 137 | expect(plan.limbs).toHaveLength(PILLARS); |
| 151 | 138 | for (const limb of plan.limbs) |
test/pull-merge-states-cache.test.ts +3 −14
| @@ -4,13 +4,10 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { readTips } from "../src/git/tips.ts"; |
| 6 | 6 | import { pullMergeStates, type Pull } from "../src/core/pulls.ts"; |
| 7 | import { expectBudget } from "./helpers/budget.ts"; | |
| 8 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 9 | 8 | |
| 10 | 9 | const env = gitClientEnv(); |
| 11 | 10 | const N = 130; |
| 12 | /** The uncached read spawns git per pull; a cached one should cost a small fraction of it. */ | |
| 13 | const BASELINE_DIVISOR = 100; | |
| 14 | 11 | let root: string; |
outdated
Orphan comment: the constant it described was removed. Delete it. |
||
| 15 | 12 | let dir: string; |
| 16 | 13 | |
| @@ -57,26 +54,18 @@ beforeEach(async () => { | ||
| 57 | 54 | |
| 58 | 55 | afterEach(() => rm(root, { recursive: true, force: true })); |
| 59 | 56 | |
| 60 | test("a second read of 130 pulls does no git work and is cheap beside the first; a moved head recomputes only its pull", async () => { | |
| 57 | test("a second read of 130 pulls does no git work and spawns nothing; a moved head recomputes only its pull", async () => { | |
| 61 | 58 | const items = Array.from({ length: N }, (_, i) => ({ dir, pull: pullOf(i) })); |
| 62 | 59 | const spawn = spyOn(Bun, "spawn"); |
| 63 | 60 | try { |
| 64 | const t1 = performance.now(); | |
| 65 | 61 | const first = await pullMergeStates(items); |
| 66 | const uncached = performance.now() - t1; | |
| 67 | 62 | expect(first.every((m) => m?.mergeable === true)).toBe(true); |
| 68 | 63 | expect(spawn.mock.calls.length).toBeGreaterThan(0); |
| 69 | 64 | |
| 70 | 65 | spawn.mockClear(); |
| 71 | // Best of a few cached reads: a busy machine slows one, not all. | |
| 72 | let best = Infinity; | |
| 66 | // Counted, not timed: cached reads spawn no git at all. | |
| 73 | 67 | let second = first; |
| 74 | for (let k = 0; k < 5; k++) { | |
| 75 | const t0 = performance.now(); | |
| 76 | second = await pullMergeStates(items); | |
| 77 | best = Math.min(best, performance.now() - t0); | |
| 78 | } | |
| 79 | expectBudget(best, { ms: 20, against: uncached / BASELINE_DIVISOR }); | |
| 68 | for (let k = 0; k < 5; k++) second = await pullMergeStates(items); | |
| 80 | 69 | expect(spawn.mock.calls.length).toBe(0); |
| 81 | 70 | expect(second).toEqual(first); |
| 82 | 71 |
test/pulls-words.test.ts +6 −18
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A page of pulls is worded from batched reads (#6151): `pullWords` gives the |
| 3 | * word `pullWord` gives for every state, in few queries and well under 5 ms. | |
| 3 | * word `pullWord` gives for every state, in few queries. | |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -17,7 +17,8 @@ import { repoDir } from "../src/git/repos.ts"; | ||
| 17 | 17 | import { pullWord, pullWords, servingHistory, type WordCtx } from "../src/web/studio/words.ts"; |
| 18 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | 19 | import { adminCookie } from "./helpers/admin.ts"; |
| 20 | import { expectBudget } from "./helpers/budget.ts"; | |
| 20 | import { expectWork } from "./helpers/work.ts"; | |
| 21 | import { statements } from "./helpers/statements.ts"; | |
| 21 | 22 | |
| 22 | 23 | quietLogs(); |
| 23 | 24 | |
| @@ -121,23 +122,10 @@ describe("a page of pulls, worded in one batch", () => { | ||
| 121 | 122 | expect(batched[1]).toEqual({ word: "waiting", note: "merged; waiting for production to redeploy" }); |
| 122 | 123 | }); |
| 123 | 124 | |
| 124 | test("the words phase for 50 pulls is under 5 ms", async () => { | |
| 125 | test("the words phase for 50 pulls costs a bounded number of statements", async () => { | |
| 125 | 126 | const pulls = page(); |
| 126 | 127 | await pullWords(ctx(), pulls); // warm statement caches |
| 127 | let best = Infinity; | |
| 128 | for (let n = 0; n < 5; n++) { | |
| 129 | const c = ctx(); | |
| 130 | const at = performance.now(); | |
| 131 | await pullWords(c, pulls); | |
| 132 | best = Math.min(best, performance.now() - at); | |
| 133 | } | |
| 134 | // The same-run baseline: the best of a few trivial reads on this machine, right now. | |
| 135 | let against = Infinity; | |
| 136 | for (let n = 0; n < 20; n++) { | |
| 137 | const at = performance.now(); | |
| 138 | app.db.query("SELECT count(*) FROM issue").get(); | |
| 139 | against = Math.min(against, performance.now() - at); | |
| 140 | } | |
| 141 | expectBudget(best, { ms: 5, against }); | |
| 128 | const cost = await statements(app.db, async () => void (await pullWords(ctx(), pulls))); | |
| 129 | expectWork(cost, { max: 5 }); | |
| 142 | 130 | }); |
| 143 | 131 | }); |
test/repo-pages-timing.test.ts +15 −16
| @@ -1,6 +1,6 @@ | ||
| 1 | /** A repository's new issue, make-it-yours, report and toolbar pages name their phases and answer within budget (#4321). */ | |
| 1 | /** A repository's new issue, make-it-yours, report and toolbar pages name their phases and cost a bounded number of statements (#4321). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -56,6 +56,14 @@ afterAll(() => { | ||
| 56 | 56 | rmSync(root, { recursive: true, force: true }); |
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 60 | const MAX_STATEMENTS: Record<string, number> = { | |
| 61 | "/alice/forge/issues/new": 38, | |
| 62 | "/alice/forge/report": 36, | |
| 63 | "/alice/forge/toolbar": 21, | |
| 64 | "/alice/forge/make-it-yours": 27, | |
| 65 | }; | |
| 66 | ||
| 59 | 67 | /** Path, who is signed in, the phases it names (bar `app` and `total`), in order. */ |
| 60 | 68 | const PAGES: [path: string, who: string, names: string[]][] = [ |
| 61 | 69 | ["/alice/forge/issues/new", "alice", ["session", "repo", "render"]], |
| @@ -65,8 +73,7 @@ const PAGES: [path: string, who: string, names: string[]][] = [ | ||
| 65 | 73 | ]; |
| 66 | 74 | |
| 67 | 75 | for (const [path, who, names] of PAGES) |
| 68 | test(`GET ${path} names its phases, they fit inside app, and it answers within its budget`, async () => { | |
| 69 | const against = await baselineMs(servers.url); | |
| 76 | test(`GET ${path} names its phases, and it costs a bounded number of statements`, async () => { | |
| 70 | 77 | const cookie = await signIn(who); |
| 71 | 78 | const read = async (): Promise<string[]> => { |
| 72 | 79 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); |
| @@ -78,20 +85,12 @@ for (const [path, who, names] of PAGES) | ||
| 78 | 85 | expect(part).toMatch(/^[a-z-]+;dur=\d+\.\d$/); |
| 79 | 86 | return Number(part!.slice(part!.indexOf("=") + 1)); |
| 80 | 87 | }; |
| 81 | for (let i = 0; i < 5; i++) await read(); // warm | |
| 82 | const shares: number[] = []; | |
| 83 | for (let i = 0; i < 11; i++) { | |
| 88 | await read(); // warm | |
| 89 | for (let i = 0; i < 3; i++) { | |
| 84 | 90 | const parts = await read(); |
| 85 | 91 | const named = parts.map((p) => p.split(";")[0]!).filter((n) => n !== "app" && n !== "db"); |
| 86 | 92 | expect(named).toEqual(names); |
| 87 | const total = dur(parts.find((p) => p.startsWith("app;"))); | |
| 88 | const sum = parts.filter((p) => !/^(app|db);/.test(p)).reduce((s, p) => s + dur(p), 0); | |
| 89 | expectBudget(total, { ms: 50, against }); | |
| 90 | expect(sum).toBeLessThanOrEqual(total + 0.1 * names.length); | |
| 91 | shares.push(total === 0 ? 1 : sum / total); | |
| 93 | for (const part of parts) dur(part); | |
| 92 | 94 | } |
| 93 | // `app` also holds routing and the security floor, outside these pages; | |
| 94 | // the median request is what the page can name of itself. | |
| 95 | shares.sort((a, b) => a - b); | |
| 96 | expect(shares[Math.floor(shares.length / 2)]!).toBeGreaterThanOrEqual(0.4); | |
| 95 | expectWork(await requestStatements(app.db, `${servers.url}${path}`, { headers: { cookie } }), { max: MAX_STATEMENTS[path]! }); | |
| 97 | 96 | }); |
test/settings-repo-timing.test.ts +13 −8
| @@ -1,6 +1,6 @@ | ||
| 1 | /** A repository's settings pages name session, each read and render in Server-Timing, and answer within budget (#4318). */ | |
| 1 | /** A repository's settings pages name session, each read and render in Server-Timing, and cost a bounded number of statements (#4318). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -48,6 +48,15 @@ afterAll(() => { | ||
| 48 | 48 | rmSync(root, { recursive: true, force: true }); |
| 49 | 49 | }); |
| 50 | 50 | |
| 51 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 52 | const MAX_STATEMENTS: Record<string, number> = { | |
| 53 | "/alice/forge/settings": 62, | |
| 54 | "/alice/forge/settings/danger": 38, | |
| 55 | "/alice/forge/settings/hooks": 38, | |
| 56 | "/alice/forge/settings/labels": 38, | |
| 57 | "/alice/forge/settings/prompts": 38, | |
| 58 | }; | |
| 59 | ||
| 51 | 60 | const PAGES: [string, string[]][] = [ |
| 52 | 61 | ["/alice/forge/settings", ["route", "session", "repo", "lead", "refs", "check", "mirror", "render", "compress"]], |
| 53 | 62 | ["/alice/forge/settings/danger", ["route", "session", "repo", "render", "compress"]], |
| @@ -57,13 +66,12 @@ const PAGES: [string, string[]][] = [ | ||
| 57 | 66 | ]; |
| 58 | 67 | |
| 59 | 68 | for (const [path, names] of PAGES) |
| 60 | test(`GET ${path} names its phases and answers within its budget`, async () => { | |
| 69 | test(`GET ${path} names its phases and costs a bounded number of statements`, async () => { | |
| 61 | 70 | const get = async () => { |
| 62 | 71 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); |
| 63 | 72 | await res.text(); |
| 64 | 73 | return res; |
| 65 | 74 | }; |
| 66 | const against = await baselineMs(servers.url); | |
| 67 | 75 | await get(); // warm |
| 68 | 76 | const res = await get(); |
| 69 | 77 | expect(res.status).toBe(200); |
| @@ -75,8 +83,5 @@ for (const [path, names] of PAGES) | ||
| 75 | 83 | expect(Number.isFinite(dur(name))).toBe(true); |
| 76 | 84 | } |
| 77 | 85 | expect(got.filter((n) => n === "render").length).toBe(1); |
| 78 | expectBudget(dur("app"), { ms: 50, against }); | |
| 79 | // The named phases cover the request (route and compress are the server's own); the issue's bound is 1 ms. | |
| 80 | const sum = names.reduce((t, n) => t + dur(n), 0); | |
| 81 | expect(Math.abs(dur("app") - sum)).toBeLessThanOrEqual(1); | |
| 86 | expectWork(await requestStatements(app.db, `${servers.url}${path}`, { headers: { cookie } }), { max: MAX_STATEMENTS[path]! }); | |
| 82 | 87 | }); |
test/settings-timing.test.ts +13 −20
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/settings/keys`, `/settings/tokens` and `/settings/password` name their phases signed in and answer within budget (#4316). */ | |
| 1 | /** `/settings/keys`, `/settings/tokens` and `/settings/password` name their phases signed in and cost a bounded number of statements (#4316). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -45,6 +45,13 @@ afterAll(() => { | ||
| 45 | 45 | rmSync(root, { recursive: true, force: true }); |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 49 | const MAX_STATEMENTS: Record<string, number> = { | |
| 50 | "/settings/keys": 12, | |
| 51 | "/settings/tokens": 13, | |
| 52 | "/settings/password": 10, | |
| 53 | }; | |
| 54 | ||
| 48 | 55 | const PAGES: [string, string[]][] = [ |
| 49 | 56 | ["/settings/keys", ["session", "keys", "render"]], |
| 50 | 57 | ["/settings/tokens", ["session", "tokens", "sessions", "render"]], |
| @@ -52,29 +59,15 @@ const PAGES: [string, string[]][] = [ | ||
| 52 | 59 | ]; |
| 53 | 60 | |
| 54 | 61 | for (const [path, names] of PAGES) |
| 55 | test(`GET ${path} names its phases and answers within its budget`, async () => { | |
| 56 | const against = await baselineMs(servers.url); | |
| 62 | test(`GET ${path} names its phases and costs a bounded number of statements`, async () => { | |
| 57 | 63 | await fetch(`${servers.url}${path}`, { headers: { cookie } }); // warm |
| 58 | // A GC pause or a busy core between phases lands in one request, not all: judge the best of three. | |
| 59 | const seen: { app: number; sum: number }[] = []; | |
| 60 | for (let i = 0; i < 3; i++) { | |
| 64 | { | |
| 61 | 65 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); |
| 62 | 66 | await res.text(); |
| 63 | 67 | expect(res.status).toBe(200); |
| 64 | 68 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 65 | const dur = (name: string) => { | |
| 66 | const part = parts.find((p) => p.startsWith(`${name};`)); | |
| 67 | expect(part).toMatch(/^[a-z]+;dur=\d+\.\d$/); | |
| 68 | return Number(part!.slice(`${name};dur=`.length)); | |
| 69 | }; | |
| 70 | 69 | expect(parts.map((p) => p.split(";")[0]).filter((n) => n !== "app")).toEqual(names); |
| 71 | seen.push({ app: dur("app"), sum: names.reduce((t, n) => t + dur(n), 0) }); | |
| 70 | for (const name of names) expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); | |
| 72 | 71 | } |
| 73 | expectBudget(Math.min(...seen.map((s) => s.app)), { ms: 50, against }); | |
| 74 | // The rest of `app` is the server's own routing and header work, outside any page (~1-2 ms on every page); | |
| 75 | // under load it stretches with the baseline, as the budget does. | |
| 76 | const slack = Math.max(0.3, against * 0.1); | |
| 77 | const room = Math.max(5, against); // accounting, not speed | |
| 78 | expect(Math.min(...seen.map((s) => s.sum - s.app))).toBeLessThanOrEqual(slack); | |
| 79 | expect(Math.min(...seen.map((s) => s.app - s.sum))).toBeLessThanOrEqual(room); | |
| 72 | expectWork(await requestStatements(app.db, `${servers.url}${path}`, { headers: { cookie } }), { max: MAX_STATEMENTS[path]! }); | |
| 80 | 73 | }); |
test/shards.test.ts +1 −52
| @@ -15,8 +15,6 @@ import { | ||
| 15 | 15 | crashedOutside, |
| 16 | 16 | CRASH_RERUNS, |
| 17 | 17 | quoteCrash, |
| 18 | budgetLast, | |
| 19 | callsBudget, | |
| 20 | 18 | failingFiles, |
| 21 | 19 | onlyTestFailures, |
| 22 | 20 | WORKER_MEMORY_BYTES, |
| @@ -1042,7 +1040,7 @@ describe("every shard runs to the end, and a crashed one once more", () => { | ||
| 1042 | 1040 | }, 30_000); |
| 1043 | 1041 | }); |
| 1044 | 1042 | |
| 1045 | describe("budget files last, and a fixture suite in six shards (#7617)", () => { | |
| 1043 | describe("a fixture suite in six shards (#7617)", () => { | |
| 1046 | 1044 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; |
| 1047 | 1045 | const fixture = (files: Record<string, string>): string => { |
| 1048 | 1046 | const dir = mkdtempSync(join(tmpdir(), "shards-fixture-")); |
| @@ -1068,27 +1066,10 @@ describe("budget files last, and a fixture suite in six shards (#7617)", () => { | ||
| 1068 | 1066 | }; |
| 1069 | 1067 | const passing = (n: number): string => |
| 1070 | 1068 | `import { test } from "bun:test";\ntest("passes ${n}", () => {});\n`; |
| 1071 | const budgeted = `import { test } from "bun:test";\ntest("budgeted", () => { expectBudget(() => {}, 1); });\nfunction expectBudget(f: () => void, _s: number) { f(); }\n`; | |
| 1072 | 1069 | /** The files named on the `── shard N/M…: files` lines, in the order shards ran. */ |
| 1073 | 1070 | const finished = (stdout: string): string[] => |
| 1074 | 1071 | [...stdout.matchAll(/^── shard \d+\/\d+(?: \(exit \d+, [\d.]+s\))?: (.*)$/gm)].map((m) => m[1]!); |
| 1075 | 1072 | |
| 1076 | test("callsBudget sees a call, not a mention in a comment or a string", () => { | |
| 1077 | expect(callsBudget("expectBudget(1, 2)")).toBe(true); | |
| 1078 | expect(callsBudget("await expectBudget (x)")).toBe(true); | |
| 1079 | expect(callsBudget("// expectBudget(1)\nlet a = 1")).toBe(false); | |
| 1080 | expect(callsBudget("/* expectBudget(1) */")).toBe(false); | |
| 1081 | expect(callsBudget('const note = "expectBudget is a helper"')).toBe(false); | |
| 1082 | expect(callsBudget(undefined)).toBe(false); | |
| 1083 | }); | |
| 1084 | ||
| 1085 | test("budgetLast sets aside the files that call expectBudget, in order", () => { | |
| 1086 | const text: Record<string, string> = { "a": "x", "b": "expectBudget(1)", "c": "y", "d": "expectBudget(2)", "e": "// expectBudget(3)" }; | |
| 1087 | const got = budgetLast(["a", "b", "c", "d", "e", "gone"], (file) => text[file]); | |
| 1088 | expect(got.rest).toEqual(["a", "c", "e", "gone"]); | |
| 1089 | expect(got.budget).toEqual(["b", "d"]); | |
| 1090 | }); | |
| 1091 | ||
| 1092 | 1073 | test("twelve files in six shards: the plan has six slices and each file runs exactly once", async () => { |
| 1093 | 1074 | const names = Array.from({ length: 12 }, (_, i) => `s${i}.test.ts`); |
| 1094 | 1075 | const dir = fixture(Object.fromEntries(names.map((name, i) => [name, passing(i)]))); |
| @@ -1113,38 +1094,6 @@ describe("budget files last, and a fixture suite in six shards (#7617)", () => { | ||
| 1113 | 1094 | expect(got.out).toContain("(fail) the broken one"); |
| 1114 | 1095 | }, 60_000); |
| 1115 | 1096 | |
| 1116 | test("budget files run last, alone, after the others", async () => { | |
| 1117 | const dir = fixture({ | |
| 1118 | "a.test.ts": passing(1), | |
| 1119 | "b.test.ts": passing(2), | |
| 1120 | "c.test.ts": passing(3), | |
| 1121 | "slow.test.ts": budgeted, | |
| 1122 | }); | |
| 1123 | const got = await run(dir, "4", ["slow.test.ts", "a.test.ts", "b.test.ts", "c.test.ts"]); | |
| 1124 | rmSync(dir, { recursive: true, force: true }); | |
| 1125 | expect(got.code).toBe(0); | |
| 1126 | expect(got.out).toContain("1 budget files last"); | |
| 1127 | const order = finished(got.stdout); | |
| 1128 | expect(order.at(-1)).toBe("slow.test.ts"); | |
| 1129 | expect(order.slice(0, -1).join(" ").split(" ").sort()).toEqual(["a.test.ts", "b.test.ts", "c.test.ts"]); | |
| 1130 | }, 60_000); | |
| 1131 | ||
| 1132 | test("a red shard does not skip the budget files: the run is red and nothing crashes", async () => { | |
| 1133 | const dir = fixture({ | |
| 1134 | "bad.test.ts": `import { test, expect } from "bun:test";\ntest("the broken one", () => { expect(1).toBe(2); });\n`, | |
| 1135 | "a.test.ts": passing(1), | |
| 1136 | "slow.test.ts": budgeted, | |
| 1137 | }); | |
| 1138 | const got = await run(dir, "2", ["bad.test.ts", "a.test.ts", "slow.test.ts"]); | |
| 1139 | rmSync(dir, { recursive: true, force: true }); | |
| 1140 | expect(got.code).not.toBe(0); | |
| 1141 | expect(got.out).toContain("(fail) the broken one"); | |
| 1142 | expect(got.out).not.toContain("fail-fast"); | |
| 1143 | expect(finished(got.stdout).flat().join(" ")).toContain("slow.test.ts"); | |
| 1144 | expect(got.out).not.toContain("undefined"); | |
| 1145 | expect(got.out).not.toContain("TypeError"); | |
| 1146 | }, 60_000); | |
| 1147 | ||
| 1148 | 1097 | test("each shard gets its own empty FORGE_APP_DATA and TMPDIR, removed after", async () => { |
| 1149 | 1098 | const probe = (n: number): string => |
| 1150 | 1099 | `import { test, expect } from "bun:test"; |
test/shelf-forty.test.ts +7 −13
| @@ -1,4 +1,4 @@ | ||
| 1 | /** 40 busy apps: the shelf asks git once for production's ancestry and never again until production moves; its phase stays under 50 ms (#6290, for #6179). */ | |
| 1 | /** 40 busy apps: the shelf asks git once for production's ancestry and never again until production moves; the whole shelf costs a bounded number of statements (#6290, for #6179). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| @@ -9,13 +9,13 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 9 | 9 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 10 | 10 | import { createIssue } from "../src/core/issues.ts"; |
| 11 | 11 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 12 | import { takePhases } from "../src/core/route_timing.ts"; | |
| 13 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 14 | import type { Ctx } from "../src/http/context.ts"; |
| 16 | 15 | import { ancestries } from "../src/web/studio/ancestry.ts"; |
| 17 | 16 | import { shelfRows } from "../src/web/studio/shelf.ts"; |
| 18 | import { expectBudget } from "./helpers/budget.ts"; | |
| 17 | import { expectWork } from "./helpers/work.ts"; | |
| 18 | import { statements } from "./helpers/statements.ts"; | |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | 20 | import { proveAll } from "./helpers/verified.ts"; |
| 21 | 21 | |
| @@ -137,15 +137,9 @@ test("a viewer who cannot enter a private app causes no read about it", async () | ||
| 137 | 137 | } |
| 138 | 138 | }); |
| 139 | 139 | |
| 140 | test("41 busy apps are drawn under 50 ms of shelf (a smoke check)", async () => { | |
| 140 | test("41 busy apps are drawn in a bounded number of statements", async () => { | |
| 141 | 141 | await shelfRows(ctx(), repos); |
| 142 | let best = Infinity; | |
| 143 | for (let i = 0; i < 5; i++) { | |
| 144 | const c = ctx(); | |
| 145 | await shelfRows(c, repos); | |
| 146 | best = Math.min(best, takePhases(c.request).filter((p) => p.name.startsWith("shelf-")).reduce((n, p) => n + p.ms, 0)); | |
| 147 | } | |
| 148 | const t = performance.now(); | |
| 149 | app.db.query("SELECT 1").get(); | |
| 150 | expectBudget(best, { ms: 50, against: performance.now() - t }); | |
| 142 | // Measured, not timed: 70 statements for 41 busy apps today, a read or two per app. A regression to | |
| 143 | // several statements per app (or an unbatched read) lands well past this. | |
| 144 | expectWork(await statements(app.db, async () => void (await shelfRows(ctx(), repos))), { max: 72 }); | |
| 151 | 145 | }); |
test/shelf-speed.test.ts +9 −20
| @@ -1,4 +1,4 @@ | ||
| 1 | /** The shelf of 40 apps is drawn in a fixed number of reads, under 40 ms, and says what it said before (#6123). */ | |
| 1 | /** The shelf of 40 apps is drawn in a fixed number of reads and says what it said before (#6123). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| @@ -13,7 +13,8 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 13 | 13 | import type { Ctx } from "../src/http/context.ts"; |
| 14 | 14 | import { takePhases } from "../src/core/route_timing.ts"; |
| 15 | 15 | import { shelfRows } from "../src/web/studio/shelf.ts"; |
| 16 | import { expectBudget } from "./helpers/budget.ts"; | |
| 16 | import { expectWork } from "./helpers/work.ts"; | |
| 17 | import { statements } from "./helpers/statements.ts"; | |
| 17 | 18 | import { appsForRepos } from "../src/core/apps.ts"; |
| 18 | 19 | import { primaryHosts } from "../src/core/domains.ts"; |
| 19 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -73,26 +74,14 @@ afterAll(() => { | ||
| 73 | 74 | rmSync(root, { recursive: true, force: true }); |
| 74 | 75 | }); |
| 75 | 76 | |
| 76 | test("40 apps are drawn within budget, in the phases Server-Timing names", async () => { | |
| 77 | test("40 apps are drawn in few statements, in the phases Server-Timing names", async () => { | |
| 77 | 78 | await shelfRows(ctx(), repos); // warm |
| 78 | let best = Infinity; | |
| 79 | let phases: string[] = []; | |
| 80 | for (let i = 0; i < 5; i++) { | |
| 81 | const c = ctx(); | |
| 82 | const started = performance.now(); | |
| 83 | await shelfRows(c, repos); | |
| 84 | best = Math.min(best, performance.now() - started); | |
| 85 | phases = takePhases(c.request).map((p) => p.name); | |
| 86 | } | |
| 79 | const c = ctx(); | |
| 80 | await shelfRows(c, repos); | |
| 81 | const phases = takePhases(c.request).map((p) => p.name); | |
| 87 | 82 | expect(phases).toEqual(["shelf-records", "shelf-runs", "shelf-production", "shelf-pulls", "shelf-rows"]); |
| 88 | // The yardstick is one batched read of the same shelf, taken in this run. | |
| 89 | let against = Infinity; | |
| 90 | for (let i = 0; i < 5; i++) { | |
| 91 | const t = performance.now(); | |
| 92 | appsForRepos(app.db, repos.map((r) => r.id)); | |
| 93 | against = Math.min(against, performance.now() - t); | |
| 94 | } | |
| 95 | expectBudget(best, { ms: 40, against }); | |
| 83 | // Counted, not timed: the shelf reads in batches, so 40 apps cost no more statements than a handful. | |
| 84 | expectWork(await statements(app.db, async () => void (await shelfRows(ctx(), repos))), { max: 10 }); | |
| 96 | 85 | }); |
| 97 | 86 | |
| 98 | 87 | test("its rows are byte-identical to the ones drawn before the batch", async () => { |
test/signin-timing.test.ts +10 −8
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/signin` and `/signup` name session, each read and render in Server-Timing, and answer within budget (#4106). */ | |
| 1 | /** `/signin` and `/signup` name session, each read and render in Server-Timing, and cost a bounded number of statements (#4106). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -25,18 +25,22 @@ afterAll(() => { | ||
| 25 | 25 | rmSync(root, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 29 | const MAX_STATEMENTS: Record<string, number> = { | |
| 30 | "/signin": 12, | |
| 31 | "/signup": 14, | |
| 32 | }; | |
| 33 | ||
| 28 | 34 | const PAGES: [string, string[]][] = [ |
| 29 | 35 | ["/signin", ["session", "settings", "claim", "render"]], |
| 30 | 36 | ["/signup", ["session", "signup", "users", "claim", "render"]], |
| 31 | 37 | ]; |
| 32 | 38 | |
| 33 | 39 | for (const [path, names] of PAGES) |
| 34 | test(`GET ${path} names its phases and answers within its budget`, async () => { | |
| 35 | const against = await baselineMs(servers.url); | |
| 40 | test(`GET ${path} names its phases and costs a bounded number of statements`, async () => { | |
| 36 | 41 | await (await fetch(`${servers.url}${path}`)).text(); // warm |
| 37 | 42 | // A loaded machine stalls the event loop mid-request and inflates any one |
| 38 | 43 | // sample; the fastest of a few is what the handler itself costs. |
| 39 | let best = Infinity; | |
| 40 | 44 | for (let i = 0; i < 7; i++) { |
| 41 | 45 | const res = await fetch(`${servers.url}${path}`); |
| 42 | 46 | await res.text(); |
| @@ -44,8 +48,6 @@ for (const [path, names] of PAGES) | ||
| 44 | 48 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 45 | 49 | for (const name of ["app", ...names]) |
| 46 | 50 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 47 | const app = parts.find((p) => p.startsWith("app;"))!; | |
| 48 | best = Math.min(best, Number(app.slice("app;dur=".length))); | |
| 49 | 51 | } |
| 50 | expectBudget(best, { ms: 10, against }); | |
| 52 | expectWork(await requestStatements(app.db, `${servers.url}${path}`), { max: MAX_STATEMENTS[path]! }); | |
| 51 | 53 | }); |
test/sitemap.test.ts +8 −8
| @@ -8,7 +8,8 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 8 | 8 | import { forgetListings } from "../src/core/listings.ts"; |
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 11 | import { expectWork } from "./helpers/work.ts"; | |
| 12 | import { statements } from "./helpers/statements.ts"; | |
| 12 | 13 | |
| 13 | 14 | let dataDir: string; |
| 14 | 15 | let app: App; |
| @@ -34,24 +35,23 @@ afterAll(async () => { | ||
| 34 | 35 | await rm(dataDir, { recursive: true, force: true }); |
| 35 | 36 | }); |
| 36 | 37 | |
| 37 | /** The whole request's time: `app` already holds the named phases, so they are not added again. */ | |
| 38 | const total = (h: string) => Number(/(?:^|, )app;dur=([\d.]+)/.exec(h)?.[1] ?? Infinity); | |
| 39 | ||
| 40 | test("lists the 100 public repositories and none of the 20 private ones, built in under 15 ms", async () => { | |
| 41 | let best = Infinity; | |
| 38 | test("lists the 100 public repositories and none of the 20 private ones, built in a bounded number of statements", async () => { | |
| 42 | 39 | let res!: Response; |
| 43 | 40 | let body = ""; |
| 44 | 41 | for (let i = 0; i < 5; i++) { |
| 45 | 42 | forgetListings(app.db); |
| 46 | 43 | res = await fetch(`${servers.url}/sitemap.xml`); |
| 47 | 44 | body = await res.text(); |
| 48 | best = Math.min(best, total(res.headers.get("server-timing") ?? "")); | |
| 49 | 45 | } |
| 50 | 46 | expect(body.match(/\/alice\/pub\d+</g)?.length).toBe(100); |
| 51 | 47 | expect(body).not.toContain("priv"); |
| 52 | 48 | const timing = res.headers.get("server-timing") ?? ""; |
| 53 | 49 | for (const phase of ["repos", "render", "etag"]) expect(timing).toContain(`${phase};dur=`); |
| 54 | expectBudget(best, { ms: 15, against: await baselineMs(servers.url) }); | |
| 50 | // Counted, not timed: a cold build reads once for all 120 repositories, not once each. | |
| 51 | expectWork(await statements(app.db, async () => { | |
| 52 | forgetListings(app.db); | |
| 53 | await (await fetch(`${servers.url}/sitemap.xml`)).text(); | |
| 54 | }), { max: 8 }); | |
| 55 | 55 | }); |
| 56 | 56 | |
| 57 | 57 | test("a repeat with If-None-Match gets a 304, a stale tag gets the document", async () => { |
test/spawn-cell.test.ts +1 −6
| @@ -13,7 +13,6 @@ import { notePeer } from "../src/http/peer.ts"; | ||
| 13 | 13 | import type { App } from "../src/app.ts"; |
| 14 | 14 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 15 | 15 | import { missing } from "./helpers/tools.ts"; |
| 16 | import { expectBudget } from "./helpers/budget.ts"; | |
| 17 | 16 | import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts"; |
| 18 | 17 | import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts"; |
| 19 | 18 | |
| @@ -338,13 +337,9 @@ describe("a child whose cell will not come up", () => { | ||
| 338 | 337 | mkdirSync(join(dataDir, "spawn"), { recursive: true }); |
| 339 | 338 | const file = join(dataDir, "spawn", "kid.json"); |
| 340 | 339 | writeFileSync(file, JSON.stringify({ name: "kid", bodyId: "cell-f", done: true, placement: "cell", size: "small" })); |
| 341 | // The baseline: a child with no ledger, answered from the same read with no start. | |
| 342 | const unknown = performance.now(); | |
| 343 | 340 | expect((await answerFromCell(parent, new Request("https://nobody--f.test/", { headers: { host: "nobody--f.test" } }))).status).toBe(404); |
| 344 | const against = performance.now() - unknown; | |
| 345 | const started = performance.now(); | |
| 341 | // A first request that waited on the cell's start would come back hosted, not 503. | |
| 346 | 342 | const first = await answerFromCell(parent, request()); |
| 347 | expectBudget(performance.now() - started, { ms: 50, against }); | |
| 348 | 343 | expect(first.status).toBe(503); |
| 349 | 344 | expect(first.headers.get("retry-after")).toBe("5"); |
| 350 | 345 | // The start it set off fails here (no cells), and the ledger turns hosted. |
test/text-stl.test.ts −14
| @@ -2,7 +2,6 @@ | ||
| 2 | 2 | import { expect, test } from "bun:test"; |
| 3 | 3 | import { ASCII_LIMIT, readStl, sayStl, stlSize, TOO_LARGE } from "../src/text/stl.ts"; |
| 4 | 4 | import { asciiBox, binaryBox } from "./helpers/stl.ts"; |
| 5 | import { expectBudget } from "./helpers/budget.ts"; | |
| 6 | 5 | |
| 7 | 6 | const ascii = (s: string) => new TextEncoder().encode(s); |
| 8 | 7 | |
| @@ -61,20 +60,7 @@ test("an ASCII body over the limit is too large to describe, and one at the limi | ||
| 61 | 60 | }; |
| 62 | 61 | const at = fill(ASCII_LIMIT); |
| 63 | 62 | expect(at.bytes.length).toBe(ASCII_LIMIT); |
| 64 | const best = (bytes: Uint8Array) => { | |
| 65 | let ms = Infinity; | |
| 66 | for (let i = 0; i < 3; i++) { | |
| 67 | const start = performance.now(); | |
| 68 | readStl(bytes); | |
| 69 | ms = Math.min(ms, performance.now() - start); | |
| 70 | } | |
| 71 | return ms; | |
| 72 | }; | |
| 73 | // The baseline is a binary model of the same byte size read in this run, so load slows both alike. | |
| 74 | const bin = new Uint8Array(84 + 50 * Math.floor((ASCII_LIMIT - 84) / 50)); | |
| 75 | new DataView(bin.buffer).setUint32(80, (bin.length - 84) / 50, true); | |
| 76 | 63 | expect(readStl(at.bytes)).toEqual({ ok: true, value: { format: "ascii", triangles: at.n, min: [1, 2, 3], max: [7, 8, 9] } }); |
| 77 | expectBudget(best(at.bytes), { ms: 50, against: best(bin) }); | |
| 78 | 64 | expect(readStl(fill(ASCII_LIMIT + 1).bytes)).toEqual({ ok: false, error: TOO_LARGE }); |
| 79 | 65 | expect(readStl(fill(5_000_000).bytes)).toEqual({ ok: false, error: TOO_LARGE }); |
| 80 | 66 | // Junk tokens are no slower to refuse: the limit is checked before any scan. |
test/verify-timing.test.ts +12 −7
| @@ -1,6 +1,6 @@ | ||
| 1 | /** `/verify`, `/recover` and `/claim` name their phases in Server-Timing and answer within budget, signed in (#4317). */ | |
| 1 | /** `/verify`, `/recover` and `/claim` name their phases in Server-Timing and cost a bounded number of statements, signed in (#4317). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 3 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 4 | 4 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| @@ -43,6 +43,14 @@ afterAll(() => { | ||
| 43 | 43 | rmSync(root, { recursive: true, force: true }); |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | /** The most statements a request may run: today's count with room for a read or two. */ | |
| 47 | const MAX_STATEMENTS: Record<string, number> = { | |
| 48 | "/verify?token=nope": 12, | |
| 49 | "/recover": 10, | |
| 50 | "/recover?token=nope": 12, | |
| 51 | "/claim": 10, | |
| 52 | }; | |
| 53 | ||
| 46 | 54 | const PAGES: [string, string[]][] = [ |
| 47 | 55 | ["/verify?token=nope", ["session", "verify", "render"]], |
| 48 | 56 | ["/recover", ["session", "render"]], |
| @@ -51,8 +59,7 @@ const PAGES: [string, string[]][] = [ | ||
| 51 | 59 | ]; |
| 52 | 60 | |
| 53 | 61 | for (const [path, names] of PAGES) |
| 54 | test(`GET ${path} names its phases and answers within its budget`, async () => { | |
| 55 | const against = await baselineMs(servers.url); | |
| 62 | test(`GET ${path} names its phases and costs a bounded number of statements`, async () => { | |
| 56 | 63 | const get = () => fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); |
| 57 | 64 | await (await get()).text(); // warm |
| 58 | 65 | const res = await get(); |
| @@ -60,7 +67,5 @@ for (const [path, names] of PAGES) | ||
| 60 | 67 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 61 | 68 | for (const name of ["app", ...names]) |
| 62 | 69 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 63 | const dur = (n: string) => Number(parts.find((p) => p.startsWith(`${n};`))!.split("=")[1]); | |
| 64 | expectBudget(dur("app"), { ms: 50, against }); | |
| 65 | expect(names.reduce((s, n) => s + dur(n), 0)).toBeLessThanOrEqual(dur("app") + 1); | |
| 70 | expectWork(await requestStatements(app.db, `${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }), { max: MAX_STATEMENTS[path]! }); | |
| 66 | 71 | }); |
new above
offline
Debug print behind an env var, and the isFinite assert below is redundant with the <= check. Remove both.