Public: anyone can read this; the crew can change it.
Every git process the forge spawns is timed per subcommand, and the performance watcher files a finding when its p95 passes a budget #8162
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8158-every-git-process-the-forge-spawns-is-ti → main 3 commits fork
12 files
changed +233
−19
docs/features/performance-watcher.md +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | # Performance watcher (#7559) |
| 2 | 2 | |
| 3 | `src/perf/` keeps a capped in-memory record of real requests per route pattern (total, each Server-Timing phase, signed in or out; no paths, ids or queries). `budgets.ts` holds the budgets in words with the number beside them. Once a minute `duty.ts` files one issue per route and phase in the forge's repo when p95 has been over budget for 15 min with 20+ real requests; the finding names the slowest phase, its share and the cache hit rate. `loop.ts` names the routes running across an event-loop stall in the `loop` log line; nothing is kept beyond that line. | |
| 3 | `src/perf/` keeps a capped in-memory record of real requests per route pattern (total, each Server-Timing phase, signed in or out; no paths, ids or queries). `budgets.ts` holds the budgets in words with the number beside them. Once a minute `duty.ts` files one issue per route and phase in the forge's repo when p95 has been over budget for 15 min with 20+ real requests; the finding names the slowest phase, its share and the cache hit rate. `git.ts` keeps the same record for every git process the forge spawns (`git/env.ts`, `git/local.ts`), keyed `git <subcommand>` from a fixed list (never an argument, path or ref; anything else is `git other`), timed from before the spawn gate so a queue counts; a subcommand whose p95 passes its budget the same way (250 ms; 10 s for the pack-moving ones: clone, fetch, push, gc, repack, pack-objects and the like) files one issue naming it, its count, p50 and p95. `loop.ts` names the routes running across an event-loop stall in the `loop` log line; nothing is kept beyond that line. | |
| 4 | 4 | |
| 5 | 5 | Not yet: a synthetic probe for routes with no traffic (a client-written label would let anyone hide requests; it needs an admin-only door first), live route stats in the terminal and MCP (findings reach both doors as issues), browser timings, 24 h/7 d history, the board page, release before/after. |
src/git/env.ts +7
| @@ -16,6 +16,8 @@ import { tmpdir } from "node:os"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 18 | import { memoryLimitBytes } from "../core/capacity.ts"; |
| 19 | import { noteGit } from "../perf/git.ts"; | |
| 20 | import { gitRecord } from "../perf/live.ts"; | |
| 19 | 21 | |
| 20 | 22 | /** Base env for all server-side git. Nothing from the host leaks in. */ |
| 21 | 23 | export function gitEnv(extra?: Record<string, string>): Record<string, string> { |
| @@ -252,6 +254,8 @@ export async function runGitRaw( | ||
| 252 | 254 | args: readonly string[], |
| 253 | 255 | options: RunOptions = {}, |
| 254 | 256 | ): Promise<Result<Uint8Array, GitFailure>> { |
| 257 | // Timed from before the gate: a wait for a permit is the queue #8158 names. | |
| 258 | const start = performance.now(); | |
| 255 | 259 | return gitGate.run(async () => { |
| 256 | 260 | const proc = Bun.spawn(["git", ...args], { |
| 257 | 261 | ...(options.cwd === undefined ? {} : { cwd: options.cwd }), |
| @@ -277,6 +281,7 @@ export async function runGitRaw( | ||
| 277 | 281 | } finally { |
| 278 | 282 | clearTimeout(timer); |
| 279 | 283 | options.signal?.removeEventListener("abort", stop); |
| 284 | noteGit(gitRecord, args, start); | |
| 280 | 285 | } |
| 281 | 286 | }); |
| 282 | 287 | } |
| @@ -308,6 +313,7 @@ export async function runGitCapture( | ||
| 308 | 313 | args: readonly string[], |
| 309 | 314 | options: RunOptions = {}, |
| 310 | 315 | ): Promise<GitCapture> { |
| 316 | const start = performance.now(); | |
| 311 | 317 | return gitGate.run(async () => { |
| 312 | 318 | const proc = Bun.spawn(["git", ...args], { |
| 313 | 319 | ...(options.cwd === undefined ? {} : { cwd: options.cwd }), |
| @@ -333,6 +339,7 @@ export async function runGitCapture( | ||
| 333 | 339 | return { code, stdout, stderr, ...(timedOut ? { timedOut } : {}) }; |
| 334 | 340 | } finally { |
| 335 | 341 | clearTimeout(timer); |
| 342 | noteGit(gitRecord, args, start); | |
| 336 | 343 | } |
| 337 | 344 | }); |
| 338 | 345 | } |
src/git/local.ts +15 −5
| @@ -19,6 +19,8 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 19 | 19 | import { parseUnifiedDiff, type Diff, type Hunk } from "./diff.ts"; |
| 20 | 20 | import type { GitFailure } from "./env.ts"; |
| 21 | 21 | import { isSafeRef } from "./guard.ts"; |
| 22 | import { noteGit } from "../perf/git.ts"; | |
| 23 | import { gitRecord } from "../perf/live.ts"; | |
| 22 | 24 | |
| 23 | 25 | export type { GitFailure }; |
| 24 | 26 | export type LocalResult<T> = Result<T, GitFailure>; |
| @@ -62,6 +64,7 @@ export async function runLocal( | ||
| 62 | 64 | args: readonly string[], |
| 63 | 65 | opts: RunOpts, |
| 64 | 66 | ): Promise<LocalResult<string>> { |
| 67 | const start = performance.now(); | |
| 65 | 68 | const proc = Bun.spawn(["git", ...args], { |
| 66 | 69 | cwd: opts.cwd, |
| 67 | 70 | env: localGitEnv(opts.env), |
| @@ -83,6 +86,7 @@ export async function runLocal( | ||
| 83 | 86 | return ok(stdout); |
| 84 | 87 | } finally { |
| 85 | 88 | clearTimeout(timer); |
| 89 | noteGit(gitRecord, args, start); | |
| 86 | 90 | } |
| 87 | 91 | } |
| 88 | 92 | |
| @@ -357,17 +361,23 @@ export async function diff( | ||
| 357 | 361 | "/dev/null", |
| 358 | 362 | opts.path, |
| 359 | 363 | ]; |
| 364 | const start = performance.now(); | |
| 360 | 365 | const proc = Bun.spawn(["git", ...args], { |
| 361 | 366 | cwd: dir, |
| 362 | 367 | env: localGitEnv(), |
| 363 | 368 | stdout: "pipe", |
| 364 | 369 | stderr: "pipe", |
| 365 | 370 | }); |
| 366 | const [text, stderr, code] = await Promise.all([ | |
| 367 | new Response(proc.stdout).text(), | |
| 368 | new Response(proc.stderr).text(), | |
| 369 | proc.exited, | |
| 370 | ]); | |
| 371 | let text: string, stderr: string, code: number; | |
| 372 | try { | |
| 373 | [text, stderr, code] = await Promise.all([ | |
| 374 | new Response(proc.stdout).text(), | |
| 375 | new Response(proc.stderr).text(), | |
| 376 | proc.exited, | |
| 377 | ]); | |
| 378 | } finally { | |
| 379 | noteGit(gitRecord, args, start); | |
| 380 | } | |
| 371 | 381 | if (code > 1) return err({ code, stderr: stderr.trim(), args }); |
| 372 | 382 | return ok(parseUnifiedDiff(text)); |
| 373 | 383 | } |
src/main.ts +3 −3
| @@ -83,9 +83,9 @@ import { reapLeftoverBridges } from "./crew/leftovers.ts"; | ||
| 83 | 83 | import { crewRoot } from "./crew/workdir.ts"; |
| 84 | 84 | import { createLog } from "./kernel/log.ts"; |
| 85 | 85 | import { watchStalls } from "./kernel/loop_lag.ts"; |
| 86 | import { startPerfWatcher } from "./perf/duty.ts"; | |
| 86 | import { startPerfWatcher, TICK_MS } from "./perf/duty.ts"; | |
| 87 | 87 | import { stallLine } from "./perf/loop.ts"; |
| 88 | import { loopWatch, perfRecord } from "./perf/live.ts"; | |
| 88 | import { gitRecord, loopWatch, perfRecord } from "./perf/live.ts"; | |
| 89 | 89 | import { timeStatements } from "./kernel/slow_sql.ts"; |
| 90 | 90 | import { waitForPeer } from "./runs/peer.ts"; |
| 91 | 91 | |
| @@ -305,7 +305,7 @@ loadTrustedHosts(app.db); | ||
| 305 | 305 | const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP); |
| 306 | 306 | if (bootSignup.ok && bootSignup.value !== null) |
| 307 | 307 | setSignup(app.db, bootSignup.value, BOOT_ACTOR); |
| 308 | startPerfWatcher(app, perfRecord); | |
| 308 | startPerfWatcher(app, perfRecord, Date.now, TICK_MS, gitRecord); | |
| 309 | 309 | const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000); |
| 310 | 310 | await reconcile(app); |
| 311 | 311 | // Issues linked under a pillar's umbrella before pillars had labels. |
src/perf/budgets.ts +19 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | /** Budgets per route pattern, in words with the number beside it (#7559). One file. */ | |
| 1 | /** Budgets per route pattern and per git subcommand, in words with the number beside it (#7559, #8158). One file. */ | |
| 2 | 2 | |
| 3 | 3 | export interface Budget { |
| 4 | 4 | /** The router pattern, or a prefix ending in `*`. */ |
| @@ -24,3 +24,21 @@ export function budgetFor(pattern: string): Budget | undefined { | ||
| 24 | 24 | BUDGETS.find((b) => b.pattern.endsWith("*") && pattern.startsWith(b.pattern.slice(0, -1))) |
| 25 | 25 | ); |
| 26 | 26 | } |
| 27 | ||
| 28 | /** A plumbing git subcommand's budget (#8158): a single process past this at p95 is a queue or a loaded machine. */ | |
| 29 | export const GIT_BUDGET: Budget = { pattern: "git *", words: "each git process p95 under 250 ms", p95Ms: 250 }; | |
| 30 | ||
| 31 | /** | |
| 32 | * Subcommands that move or rewrite whole packs are slow on a healthy machine, | |
| 33 | * so 250 ms would file a finding per subcommand; they are held to 10 s. | |
| 34 | */ | |
| 35 | const HEAVY_GIT = new Set([ | |
| 36 | "clone", "fetch", "pull", "push", "gc", "repack", "pack-objects", "index-pack", | |
| 37 | "unpack-objects", "archive", "bundle", "upload-pack", "receive-pack", "fsck", "prune", | |
| 38 | ]); | |
| 39 | export const HEAVY_GIT_BUDGET: Budget = { pattern: "git *", words: "a pack-moving git process p95 under 10 s", p95Ms: 10_000 }; | |
| 40 | ||
| 41 | /** The budget a `git <subcommand>` key is held to. */ | |
| 42 | export function gitBudgetFor(key: string): Budget { | |
| 43 | return HEAVY_GIT.has(key.slice(4)) ? HEAVY_GIT_BUDGET : GIT_BUDGET; | |
| 44 | } |
src/perf/duty.ts +8 −5
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The `performance-watcher` duty (#7559): once a minute, judge the record and |
| 3 | * file each new finding as one issue in the forge's own repository, opened by | |
| 4 | * its owner. A title already open is not filed twice, and neither is a key | |
| 5 | * this process has filed. | |
| 3 | * file each new finding, of a route or of a git subcommand (#8158), as one | |
| 4 | * issue in the forge's own repository, opened by its owner. A title already | |
| 5 | * open is not filed twice, and neither is a key this process has filed. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { App } from "../app.ts"; |
| @@ -10,8 +10,9 @@ import { openIssue } from "../actions/issues.ts"; | ||
| 10 | 10 | import { getUserById } from "../core/users.ts"; |
| 11 | 11 | import { getRepo } from "../core/repos.ts"; |
| 12 | 12 | import { listIssues } from "../core/issues.ts"; |
| 13 | import { createGitRecord } from "./git.ts"; | |
| 13 | 14 | import type { PerfRecord } from "./record.ts"; |
| 14 | import { evaluate, fresh } from "./watch.ts"; | |
| 15 | import { evaluate, evaluateGit, fresh } from "./watch.ts"; | |
| 15 | 16 | |
| 16 | 17 | export const TICK_MS = 60_000; |
| 17 | 18 | |
| @@ -20,6 +21,7 @@ export function startPerfWatcher( | ||
| 20 | 21 | record: PerfRecord, |
| 21 | 22 | now: () => number = Date.now, |
| 22 | 23 | everyMs = TICK_MS, |
| 24 | gitRecord: PerfRecord = createGitRecord(), | |
| 23 | 25 | ): () => void { |
| 24 | 26 | const filed = new Set<string>(); |
| 25 | 27 | const tick = (): void => { |
| @@ -31,7 +33,8 @@ export function startPerfWatcher( | ||
| 31 | 33 | const actor = repo && getUserById(app.db, repo.ownerId); |
| 32 | 34 | if (!repo || !actor) return; |
| 33 | 35 | const open = new Set(listIssues(app.db, repo.id, { state: "open" }).map((i) => i.title)); |
| 34 | for (const f of fresh(evaluate(record, now()), filed)) { | |
| 36 | const findings = [...evaluate(record, now()), ...evaluateGit(gitRecord, now())]; | |
| 37 | for (const f of fresh(findings, filed)) { | |
| 35 | 38 | if (open.has(f.title)) continue; |
| 36 | 39 | openIssue(app, actor, repo, { title: f.title, body: f.evidence }); |
| 37 | 40 | } |
src/perf/git.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The rolling record of git processes (#8158): per subcommand, each spawn's | |
| 3 | * wall time, waiting for a permit included, so a queue of git processes on a | |
| 4 | * loaded machine is named rather than smeared across slow routes. The record | |
| 5 | * is the route record's (`record.ts`: 15-minute window, bounded samples and | |
| 6 | * keys); only the key differs. A key is `git <subcommand>` and the subcommand | |
| 7 | * is from a fixed list, so no argument, path or ref can ever be kept: anything | |
| 8 | * else is `git other`. | |
| 9 | */ | |
| 10 | ||
| 11 | import { createPerfRecord, type PerfRecord } from "./record.ts"; | |
| 12 | ||
| 13 | const SUBCOMMANDS = new Set([ | |
| 14 | "add", "apply", "archive", "blame", "branch", "bundle", "cat-file", "checkout", | |
| 15 | "checkout-index", "cherry-pick", "clean", "clone", "commit", "commit-tree", | |
| 16 | "config", "count-objects", "diff", "diff-tree", "fetch", "for-each-ref", "fsck", | |
| 17 | "gc", "grep", "hash-object", "index-pack", "init", "log", "ls-files", "ls-remote", | |
| 18 | "ls-tree", "merge", "merge-base", "merge-file", "merge-tree", "mktree", "mv", | |
| 19 | "name-rev", "pack-objects", "pack-refs", "prune", "pull", "push", "read-tree", | |
| 20 | "rebase", "receive-pack", "reflog", "remote", "repack", "replay", "reset", | |
| 21 | "restore", "rev-list", "rev-parse", "revert", "rm", "show", "show-ref", "stash", | |
| 22 | "status", "switch", "symbolic-ref", "tag", "unpack-objects", "update-index", | |
| 23 | "update-ref", "update-server-info", "upload-pack", "verify-pack", "worktree", | |
| 24 | "write-tree", | |
| 25 | ]); | |
| 26 | ||
| 27 | /** Global options that take their value as the next argument. */ | |
| 28 | const VALUED = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]); | |
| 29 | ||
| 30 | export const OTHER = "git other"; | |
| 31 | ||
| 32 | /** `git rev-parse` for `["-C", dir, "rev-parse", "--verify", ref]`; never an argument. */ | |
| 33 | export function gitKey(args: readonly string[]): string { | |
| 34 | let i = 0; | |
| 35 | while (i < args.length && args[i]!.startsWith("-")) i += VALUED.has(args[i]!) ? 2 : 1; | |
| 36 | const sub = args[i]; | |
| 37 | return sub !== undefined && SUBCOMMANDS.has(sub) ? `git ${sub}` : OTHER; | |
| 38 | } | |
| 39 | ||
| 40 | const validKey = (key: string): boolean => key === OTHER || (key.startsWith("git ") && SUBCOMMANDS.has(key.slice(4))); | |
| 41 | ||
| 42 | export function createGitRecord(): PerfRecord { | |
| 43 | return createPerfRecord(validKey); | |
| 44 | } | |
| 45 | ||
| 46 | /** Note one finished git process that started at `start` (performance.now()). */ | |
| 47 | export function noteGit(record: PerfRecord, args: readonly string[], start: number): void { | |
| 48 | record.add(gitKey(args), { at: Date.now(), ms: performance.now() - start, signedIn: false, phases: [] }); | |
| 49 | } |
src/perf/live.ts +3 −1
| @@ -1,7 +1,9 @@ | ||
| 1 | /** The process's one record and loop watch (#7559): what the server feeds and the duty reads. */ | |
| 1 | /** The process's one record, git record (#8158) and loop watch (#7559): what the server feeds and the duty reads. */ | |
| 2 | 2 | |
| 3 | import { createGitRecord } from "./git.ts"; | |
| 3 | 4 | import { createLoopWatch } from "./loop.ts"; |
| 4 | 5 | import { createPerfRecord } from "./record.ts"; |
| 5 | 6 | |
| 6 | 7 | export const perfRecord = createPerfRecord(); |
| 7 | 8 | export const loopWatch = createLoopWatch(); |
| 9 | export const gitRecord = createGitRecord(); |
src/perf/record.ts +3 −2
| @@ -48,12 +48,13 @@ export function percentile(xs: readonly number[], p: number): number | null { | ||
| 48 | 48 | return sorted[Math.max(1, Math.ceil((p / 100) * sorted.length)) - 1]!; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | export function createPerfRecord(): PerfRecord { | |
| 51 | /** `valid` says which keys are kept: route patterns here, `git <subcommand>` in `git.ts`. */ | |
| 52 | export function createPerfRecord(valid: (key: string) => boolean = validPattern): PerfRecord { | |
| 52 | 53 | const real = new Map<string, Sample[]>(); |
| 53 | 54 | const firsts = new Map<string, number>(); |
| 54 | 55 | return { |
| 55 | 56 | add(pattern, sample) { |
| 56 | if (!validPattern(pattern) || !Number.isFinite(sample.ms) || sample.ms < 0) return; | |
| 57 | if (!valid(pattern) || !Number.isFinite(sample.ms) || sample.ms < 0) return; | |
| 57 | 58 | let list = real.get(pattern); |
| 58 | 59 | if (list === undefined) { |
| 59 | 60 | if (real.size >= MAX_PATTERNS) return; |
src/perf/watch.ts +28 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { found, type Finding } from "../hunt/finding.ts"; |
| 9 | import { budgetFor } from "./budgets.ts"; | |
| 9 | import { budgetFor, gitBudgetFor } from "./budgets.ts"; | |
| 10 | 10 | import { summarize, WINDOW_MS, type PerfRecord } from "./record.ts"; |
| 11 | 11 | |
| 12 | 12 | export { WINDOW_MS }; |
| @@ -55,6 +55,33 @@ export function evaluate(record: PerfRecord, now: number): Finding[] { | ||
| 55 | 55 | return out; |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | /** | |
| 59 | * A git subcommand whose p95 has been over its `gitBudgetFor` for the window (#8158), | |
| 60 | * held to the route rule: enough processes, watched most of the window. One | |
| 61 | * key per subcommand, so one issue. | |
| 62 | */ | |
| 63 | export function evaluateGit(record: PerfRecord, now: number): Finding[] { | |
| 64 | const out: Finding[] = []; | |
| 65 | for (const key of record.patterns()) { | |
| 66 | const budget = gitBudgetFor(key); | |
| 67 | const stat = summarize(record.traffic(key, now - WINDOW_MS)); | |
| 68 | if (stat === null || stat.n < MIN_REQUESTS || stat.p95 <= budget.p95Ms) continue; | |
| 69 | if (now - (record.first(key) ?? now) < WINDOW_MS * 0.9) continue; | |
| 70 | out.push( | |
| 71 | found( | |
| 72 | "performance-watcher", | |
| 73 | key, | |
| 74 | `${key} is over its budget`, | |
| 75 | [ | |
| 76 | `Budget: ${budget.words} (${budget.p95Ms} ms), waiting for a permit included.`, | |
| 77 | `Subcommand: ${key}, ${stat.n} processes in 15 min: p50 ${Math.round(stat.p50)} ms, p95 ${Math.round(stat.p95)} ms, p99 ${Math.round(stat.p99)} ms.`, | |
| 78 | ].join("\n"), | |
| 79 | ), | |
| 80 | ); | |
| 81 | } | |
| 82 | return out; | |
| 83 | } | |
| 84 | ||
| 58 | 85 | /** The findings not filed before; `filed` remembers keys, so each is offered once. */ |
| 59 | 86 | export function fresh(findings: readonly Finding[], filed: Set<string>): Finding[] { |
| 60 | 87 | const out = findings.filter((f) => !filed.has(f.key)); |
test/perf-duty.test.ts +10
| @@ -8,6 +8,7 @@ import { listIssues } from "../src/core/issues.ts"; | ||
| 8 | 8 | import { getRepo } from "../src/core/repos.ts"; |
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { startPerfWatcher } from "../src/perf/duty.ts"; |
| 11 | import { createGitRecord } from "../src/perf/git.ts"; | |
| 11 | 12 | import { createPerfRecord, WINDOW_MS } from "../src/perf/record.ts"; |
| 12 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | 14 | import { proveAll } from "./helpers/verified.ts"; |
| @@ -74,3 +75,12 @@ test("a missing forge repo is skipped quietly", async () => { | ||
| 74 | 75 | expect(warn).not.toHaveBeenCalled(); |
| 75 | 76 | expect(after.n).toBe(before.n); |
| 76 | 77 | }); |
| 78 | test("the watcher files a slow git subcommand once (#8158)", async () => { | |
| 79 | const git = createGitRecord(); | |
| 80 | for (let i = 0; i < 100; i++) | |
| 81 | git.add("git rev-parse", { at: now - WINDOW_MS + (i * WINDOW_MS) / 100, ms: 7000, signedIn: false, phases: [] }); | |
| 82 | const stop = startPerfWatcher(app, createPerfRecord(), () => now, 5, git); | |
| 83 | await Bun.sleep(60); | |
| 84 | stop(); | |
| 85 | expect(titles().filter((t) => t.startsWith("git "))).toEqual(["git rev-parse is over its budget"]); | |
| 86 | }); |
test/perf-git.test.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { runGit, runGitCapture } from "../src/git/env.ts"; | |
| 6 | import { GIT_BUDGET, HEAVY_GIT_BUDGET } from "../src/perf/budgets.ts"; | |
| 7 | import { createGitRecord, gitKey, OTHER } from "../src/perf/git.ts"; | |
| 8 | import { gitRecord } from "../src/perf/live.ts"; | |
| 9 | import { MAX_PATTERNS, MAX_SAMPLES } from "../src/perf/record.ts"; | |
| 10 | import { evaluateGit, fresh, WINDOW_MS } from "../src/perf/watch.ts"; | |
| 11 | ||
| 12 | const now = 10_000_000; | |
| 13 | function seed(sub: string, ms: number, n = 100) { | |
| 14 | const r = createGitRecord(); | |
| 15 | for (let i = 0; i < n; i++) | |
| 16 | r.add(`git ${sub}`, { at: now - WINDOW_MS + (i * WINDOW_MS) / n, ms, signedIn: false, phases: [] }); | |
| 17 | return r; | |
| 18 | } | |
| 19 | ||
| 20 | test("the key is the subcommand alone, past global options, never an argument", () => { | |
| 21 | expect(gitKey(["rev-parse", "--verify", "refs/heads/secret"])).toBe("git rev-parse"); | |
| 22 | expect(gitKey(["-C", "/srv/repos/alice/x.git", "-c", "core.quotepath=off", "merge-tree", "a", "b"])).toBe("git merge-tree"); | |
| 23 | expect(gitKey(["--git-dir=/srv/x.git", "cat-file", "blob", "HEAD:secret.txt"])).toBe("git cat-file"); | |
| 24 | expect(gitKey(["/srv/repos/alice/x.git"])).toBe(OTHER); | |
| 25 | expect(gitKey(["refs/heads/main"])).toBe(OTHER); | |
| 26 | expect(gitKey([])).toBe(OTHER); | |
| 27 | }); | |
| 28 | ||
| 29 | test("a key that is not git and a subcommand is dropped", () => { | |
| 30 | const r = createGitRecord(); | |
| 31 | for (const key of ["git /srv/x.git", "/", "git refs/heads/main", "rev-parse"]) | |
| 32 | r.add(key, { at: now, ms: 5, signedIn: false, phases: [] }); | |
| 33 | expect(r.patterns()).toEqual([]); | |
| 34 | }); | |
| 35 | ||
| 36 | test("the spawn helpers record each process under its subcommand, without arguments", async () => { | |
| 37 | const dir = mkdtempSync(join(tmpdir(), "forge-perf-git-")); | |
| 38 | try { | |
| 39 | const before = gitRecord.traffic("git init", 0).length; | |
| 40 | expect((await runGit(["init", "--quiet", join(dir, "secret-repo-name")])).ok).toBe(true); | |
| 41 | await runGitCapture(["-C", join(dir, "secret-repo-name"), "rev-parse", "refs/heads/secret-branch"]); | |
| 42 | expect(gitRecord.traffic("git init", 0).length).toBe(before + 1); | |
| 43 | expect(gitRecord.traffic("git rev-parse", 0).length).toBeGreaterThan(0); | |
| 44 | const kept = JSON.stringify(gitRecord.patterns().map((k) => [k, gitRecord.traffic(k, 0)])); | |
| 45 | expect(kept).not.toContain("secret"); | |
| 46 | expect(kept).not.toContain(dir); | |
| 47 | } finally { | |
| 48 | rmSync(dir, { recursive: true, force: true }); | |
| 49 | } | |
| 50 | }); | |
| 51 | ||
| 52 | test("a slow rev-parse past the budget files exactly one finding naming it, its count, p50 and p95", () => { | |
| 53 | const filed = new Set<string>(); | |
| 54 | const f = evaluateGit(seed("rev-parse", 7000), now); | |
| 55 | expect(f.length).toBe(1); | |
| 56 | expect(f[0]!.key).toBe("performance-watcher:git rev-parse"); | |
| 57 | expect(f[0]!.title).toBe("git rev-parse is over its budget"); | |
| 58 | expect(f[0]!.evidence).toContain("git rev-parse, 100 processes in 15 min: p50 7000 ms, p95 7000 ms"); | |
| 59 | expect(f[0]!.evidence).toContain(`${GIT_BUDGET.p95Ms} ms`); | |
| 60 | expect(fresh(f, filed).length).toBe(1); | |
| 61 | expect(fresh(evaluateGit(seed("rev-parse", 7000), now), filed).length).toBe(0); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a fast subcommand, too few processes, or a young one file nothing", () => { | |
| 65 | expect(evaluateGit(seed("rev-parse", 20), now)).toEqual([]); | |
| 66 | expect(evaluateGit(seed("rev-parse", 7000, 5), now)).toEqual([]); | |
| 67 | const r = createGitRecord(); | |
| 68 | for (let i = 0; i < 50; i++) r.add("git rev-parse", { at: now - 1000, ms: 7000, signedIn: false, phases: [] }); | |
| 69 | expect(evaluateGit(r, now)).toEqual([]); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a slow clone or repack past 250 ms files nothing; past its own budget it files one", () => { | |
| 73 | for (const sub of ["clone", "repack", "fetch", "push", "gc", "pack-objects", "upload-pack", "receive-pack"]) | |
| 74 | expect(evaluateGit(seed(sub, 3000), now)).toEqual([]); | |
| 75 | const f = evaluateGit(seed("repack", 12_000), now); | |
| 76 | expect(f.length).toBe(1); | |
| 77 | expect(f[0]!.evidence).toContain(`${HEAVY_GIT_BUDGET.p95Ms} ms`); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the record stays bounded in samples and keys", () => { | |
| 81 | const r = createGitRecord(); | |
| 82 | for (let i = 0; i < MAX_SAMPLES * 2 + 10; i++) r.add("git rev-parse", { at: now, ms: 1, signedIn: false, phases: [] }); | |
| 83 | expect(r.traffic("git rev-parse", 0).length).toBeLessThanOrEqual(MAX_SAMPLES); | |
| 84 | for (let i = 0; i < MAX_PATTERNS * 2; i++) r.add(gitKey([`sub${i}`]), { at: now, ms: 1, signedIn: false, phases: [] }); | |
| 85 | expect(r.patterns().length).toBeLessThanOrEqual(MAX_PATTERNS); | |
| 86 | expect(r.patterns()).toEqual(["git rev-parse", OTHER]); | |
| 87 | }); |
new above
offline