Public: anyone can read this; the crew can change it.
A second read of an unmoved pull serves its diff from memory: `diff` under 5 ms when neither tip moved #8171
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8160-a-second-read-of-an-unmoved-pull-serves → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +489
−7
src/git/diff-memory.ts added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | /** | |
| 2 | * Diffs between two commits, and the merge base a pull's diff starts from, | |
| 3 | * kept for the next reader of the same pair. | |
| 4 | * | |
| 5 | * A diff between two object ids never changes, so the second reader of an | |
| 6 | * unmoved pull — a reviewer, the terminal, an agent through the API — is | |
| 7 | * served from here and spawns no git. The key carries the repository's | |
| 8 | * directory as well as both oids: a diff read in one repository is never | |
| 9 | * answered to a reader of another, whatever oids they name. | |
| 10 | * | |
| 11 | * Bounded by estimated bytes, least recently used out first. One diff over | |
| 12 | * `MAX_DIFF_BYTES` is never kept, so a single huge change cannot empty the | |
| 13 | * memory for everyone else. Values are deeply frozen: every reader shares | |
| 14 | * one copy and none can change another's. A failed git run is not kept. | |
| 15 | * Nothing is allocated until a process reads a diff, and it lives only as | |
| 16 | * long as that process. | |
| 17 | */ | |
| 18 | ||
| 19 | import type { Diff } from "./diff.ts"; | |
| 20 | ||
| 21 | /** All kept diffs together, estimated as their parsed size. */ | |
| 22 | export const MAX_BYTES = 32 * 1024 * 1024; | |
| 23 | /** One diff larger than this is served but never kept. */ | |
| 24 | export const MAX_DIFF_BYTES = 4 * 1024 * 1024; | |
| 25 | ||
| 26 | const kept = new Map<string, { diff: Diff; bytes: number }>(); | |
| 27 | let total = 0; | |
| 28 | ||
| 29 | export function diffMemoryKey(dir: string, from: string, to: string): string { | |
| 30 | return `${dir}\0${from}\0${to}`; | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * What a parsed diff holds in memory, roughly: each line's text twice over | |
| 35 | * (strings are UTF-16) plus the row object around it. | |
| 36 | */ | |
| 37 | export function sizeOfDiff(diff: Diff): number { | |
| 38 | let n = 0; | |
| 39 | for (const file of diff.files) { | |
| 40 | n += 128 + file.path.length * 2; | |
| 41 | for (const hunk of file.hunks) { | |
| 42 | n += 64 + hunk.section.length * 2; | |
| 43 | for (const line of hunk.lines) n += 48 + line.content.length * 2; | |
| 44 | } | |
| 45 | } | |
| 46 | return n; | |
| 47 | } | |
| 48 | ||
| 49 | /** The kept diff for `key`, freshened as most recently used. */ | |
| 50 | export function recalled(key: string): Diff | undefined { | |
| 51 | const known = kept.get(key); | |
| 52 | if (known === undefined) return undefined; | |
| 53 | kept.delete(key); | |
| 54 | kept.set(key, known); | |
| 55 | return known.diff; | |
| 56 | } | |
| 57 | ||
| 58 | /** Keep `diff` under `key` when it fits; returns the frozen value to serve. */ | |
| 59 | export function remember(key: string, diff: Diff): Diff { | |
| 60 | const frozen = deepFreeze(diff); | |
| 61 | const bytes = sizeOfDiff(frozen); | |
| 62 | if (bytes > MAX_DIFF_BYTES) return frozen; | |
| 63 | const known = kept.get(key); | |
| 64 | if (known !== undefined) { | |
| 65 | total -= known.bytes; | |
| 66 | kept.delete(key); | |
| 67 | } | |
| 68 | kept.set(key, { diff: frozen, bytes }); | |
| 69 | total += bytes; | |
| 70 | while (total > MAX_BYTES) { | |
| 71 | const oldest = kept.keys().next().value as string; | |
| 72 | total -= kept.get(oldest)!.bytes; | |
| 73 | kept.delete(oldest); | |
| 74 | } | |
| 75 | return frozen; | |
| 76 | } | |
| 77 | ||
| 78 | /** What is held now, for tests. */ | |
| 79 | export function diffMemoryCounts(): { entries: number; bytes: number } { | |
| 80 | return { entries: kept.size, bytes: total }; | |
| 81 | } | |
| 82 | ||
| 83 | /** Forget every kept diff and merge base (tests). */ | |
| 84 | export function forgetDiffs(): void { | |
| 85 | kept.clear(); | |
| 86 | total = 0; | |
| 87 | mergeBases.clear(); | |
| 88 | } | |
| 89 | ||
| 90 | // ---- merge bases ---- | |
| 91 | // | |
| 92 | // The fork point a pull's diff is measured from is as fixed as the diff: | |
| 93 | // two commits have one merge base. Each entry is three short strings, so | |
| 94 | // these are bounded by count, least recently used out first. | |
| 95 | ||
| 96 | /** How many merge bases are kept: a few hundred bytes each. */ | |
| 97 | export const MERGE_BASES_KEPT = 4096; | |
| 98 | const mergeBases = new Map<string, string>(); | |
| 99 | ||
| 100 | /** The kept merge base for `key` (`diffMemoryKey`), freshened as most recently used. */ | |
| 101 | export function recalledBase(key: string): string | undefined { | |
| 102 | const known = mergeBases.get(key); | |
| 103 | if (known === undefined) return undefined; | |
| 104 | mergeBases.delete(key); | |
| 105 | mergeBases.set(key, known); | |
| 106 | return known; | |
| 107 | } | |
| 108 | ||
| 109 | /** Keep the merge base found for `key`; never call this with a failed lookup. */ | |
| 110 | export function rememberBase(key: string, base: string): void { | |
| 111 | mergeBases.delete(key); | |
| 112 | mergeBases.set(key, base); | |
| 113 | if (mergeBases.size > MERGE_BASES_KEPT) mergeBases.delete(mergeBases.keys().next().value as string); | |
| 114 | } | |
| 115 | ||
| 116 | /** How many merge bases are held now, for tests. */ | |
| 117 | export function mergeBaseCount(): number { | |
| 118 | return mergeBases.size; | |
| 119 | } | |
| 120 | ||
| 121 | function deepFreeze(diff: Diff): Diff { | |
| 122 | for (const file of diff.files) { | |
| 123 | for (const hunk of file.hunks) { | |
| 124 | for (const line of hunk.lines) Object.freeze(line); | |
| 125 | Object.freeze(hunk.lines); | |
| 126 | Object.freeze(hunk); | |
| 127 | } | |
| 128 | Object.freeze(file.hunks); | |
| 129 | Object.freeze(file); | |
| 130 | } | |
| 131 | Object.freeze(diff.files); | |
| 132 | return Object.freeze(diff); | |
| 133 | } |
src/git/diff.ts +20 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 9 | import { diffMemoryKey, recalled, remember } from "./diff-memory.ts"; | |
| 9 | 10 | import { runGitRaw, type GitFailure } from "./env.ts"; |
| 10 | 11 | import { isSafeOid, isSafeRef } from "./guard.ts"; |
| 11 | 12 | |
| @@ -77,9 +78,27 @@ export async function diffRange( | ||
| 77 | 78 | ): Promise<Result<Diff, GitFailure | "unsafe_rev">> { |
| 78 | 79 | if (!safeRange(base, head)) return err("unsafe_rev"); |
| 79 | 80 | // `git diff A B` compares two tree-ish and needs no work tree. |
| 80 | return runDiff(dir, ["diff", ...DIFF_FLAGS, base, head, "--"], laps); | |
| 81 | const run = () => runDiff(dir, ["diff", ...DIFF_FLAGS, base, head, "--"], laps); | |
| 82 | // A ref can move; two object ids cannot, so only their diff is remembered. | |
| 83 | if (!isSafeOid(base) || !isSafeOid(head)) return run(); | |
| 84 | const key = diffMemoryKey(dir, base, head); | |
| 85 | const known = recalled(key); | |
| 86 | if (known !== undefined) return ok(known); | |
| 87 | // Readers arriving while git runs wait for that run rather than start one. | |
| 88 | const pending = running.get(key); | |
| 89 | if (pending !== undefined) return pending; | |
| 90 | const started = run().then((result) => (result.ok ? ok(remember(key, result.value)) : result)); | |
| 91 | running.set(key, started); | |
| 92 | try { | |
| 93 | return await started; | |
| 94 | } finally { | |
| 95 | running.delete(key); | |
| 96 | } | |
| 81 | 97 | } |
| 82 | 98 | |
| 99 | /** Range diffs git is producing right now, by memory key. */ | |
| 100 | const running = new Map<string, Promise<Result<Diff, GitFailure>>>(); | |
| 101 | ||
| 83 | 102 | /** Diff a commit against its first parent (or the empty tree for a root). */ |
| 84 | 103 | export async function diffCommit( |
| 85 | 104 | dir: string, |
src/git/merge.ts +9 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 9 | import { diffMemoryKey, recalledBase, rememberBase } from "./diff-memory.ts"; | |
| 9 | 10 | import { runGit, runGitCapture } from "./env.ts"; |
| 10 | 11 | import { isSafeOid, isSafeRef, isSafeTreePath } from "./guard.ts"; |
| 11 | 12 | |
| @@ -295,8 +296,15 @@ export async function mergeBase( | ||
| 295 | 296 | b: string, |
| 296 | 297 | ): Promise<string | undefined> { |
| 297 | 298 | if (!isSafeOid(a) || !isSafeOid(b)) return undefined; |
| 299 | // Two commits' merge base never changes: the next asker reads it from memory. | |
outdated
Use diffMemoryKey here, or move this memory into diff-memory.ts. Add a test for the 4096 bound and the LRU order, which has none. |
||
| 300 | const key = diffMemoryKey(dir, a, b); | |
| 301 | const known = recalledBase(key); | |
| 302 | if (known !== undefined) return known; | |
| 298 | 303 | const result = await runGit(["merge-base", a, b], { cwd: dir }); |
| 299 | return result.ok ? result.value.trim() : undefined; | |
| 304 | if (!result.ok) return undefined; | |
| 305 | const base = result.value.trim(); | |
| 306 | rememberBase(key, base); | |
| 307 | return base; | |
| 300 | 308 | } |
| 301 | 309 | |
| 302 | 310 | /** |
src/http/api.ts +6 −5
| @@ -257,7 +257,7 @@ import { carriedBy } from "../core/carried.ts"; | ||
| 257 | 257 | import { readGateLog } from "../checks/gate_log.ts"; |
| 258 | 258 | import { recentGates } from "../checks/gate_result.ts"; |
| 259 | 259 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 260 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; | |
| 260 | import { blobForRender, commitLog, listRefs, listTree, refTip } from "../git/read.ts"; | |
| 261 | 261 | import { repoDir } from "../git/repos.ts"; |
| 262 | 262 | import { carriedPulls, pullPath } from "../story/wave_pulls.ts"; |
| 263 | 263 | import { pullRefs, readWave, readWaves } from "../story/waves.ts"; |
| @@ -960,6 +960,10 @@ async function pullHeadOid(ctx: Ctx, repo: Repo, pull: Pull): Promise<string> { | ||
| 960 | 960 | return (await resolveOid(await pullDirOf(ctx, repo, pull), ref)) ?? pull.headOid; |
| 961 | 961 | } |
| 962 | 962 | |
| 963 | /** A branch's tip from its ref file (no process); git is asked only when that cannot be read. */ | |
| 964 | const baseTip = async (dir: string, ref: string): Promise<string | undefined> => | |
| 965 | (await refTip(dir, ref)) ?? (await resolveOid(dir, ref)); | |
| 966 | ||
| 963 | 967 | /** Where a pull's objects are (`pullDir`): its base, or a sealed one's own place. */ |
| 964 | 968 | const pullDirOf = (ctx: Ctx, repo: Repo, pull: Pull): Promise<string> => |
| 965 | 969 | pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull); |
| @@ -2400,10 +2404,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2400 | 2404 | live && |
| 2401 | 2405 | checkStale( |
| 2402 | 2406 | checkSummary(ctx.app.db, found.pull.id, status.headOid).run, |
| 2403 | await resolveOid( | |
| 2404 | await pullDirOf(ctx, found.repo, found.pull), | |
| 2405 | `refs/heads/${found.pull.baseBranch}`, | |
| 2406 | ), | |
| 2407 | await baseTip(await pullDirOf(ctx, found.repo, found.pull), `refs/heads/${found.pull.baseBranch}`), | |
| 2407 | 2408 | ), |
| 2408 | 2409 | ); |
| 2409 | 2410 | return timePhase(ctx.request, "render", () => { |
test/diff-memory.test.ts added +216
| @@ -0,0 +1,216 @@ | ||
| 1 | /** A second read of the same two commits is served from memory, never git (#8160). */ | |
| 2 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdir, mkdtemp, rename, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { diffRange, type Diff } from "../src/git/diff.ts"; | |
| 7 | import { | |
| 8 | diffMemoryCounts, | |
| 9 | diffMemoryKey, | |
| 10 | forgetDiffs, | |
| 11 | MAX_BYTES, | |
| 12 | MAX_DIFF_BYTES, | |
| 13 | MERGE_BASES_KEPT, | |
| 14 | mergeBaseCount, | |
| 15 | recalled, | |
| 16 | recalledBase, | |
| 17 | remember, | |
| 18 | rememberBase, | |
| 19 | sizeOfDiff, | |
| 20 | } from "../src/git/diff-memory.ts"; | |
| 21 | import { mergeBase } from "../src/git/merge.ts"; | |
| 22 | import { initBareRepo } from "../src/git/repos.ts"; | |
| 23 | import { unwrap } from "../src/kernel/result.ts"; | |
| 24 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 25 | ||
| 26 | const env = gitClientEnv({ | |
| 27 | GIT_AUTHOR_NAME: "D", | |
| 28 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 29 | GIT_COMMITTER_NAME: "D", | |
| 30 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 31 | }); | |
| 32 | ||
| 33 | async function sh(args: string[], cwd: string): Promise<string> { | |
| 34 | const p = Bun.spawn(args, { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 35 | const [out, errText, code] = await Promise.all([ | |
| 36 | new Response(p.stdout).text(), | |
| 37 | new Response(p.stderr).text(), | |
| 38 | p.exited, | |
| 39 | ]); | |
| 40 | if (code !== 0) throw new Error(`${args.join(" ")}: ${errText}`); | |
| 41 | return out.trim(); | |
| 42 | } | |
| 43 | ||
| 44 | let root: string; | |
| 45 | let dir: string; | |
| 46 | let first: string; | |
| 47 | let second: string; | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | root = await mkdtemp(join(tmpdir(), "forge-diff-memory-")); | |
| 51 | dir = join(root, "repo.git"); | |
| 52 | unwrap(await initBareRepo(dir)); | |
| 53 | const work = join(root, "work"); | |
| 54 | await sh(["git", "clone", dir, work], root); | |
| 55 | await Bun.write(join(work, "a.txt"), "one\ntwo\n"); | |
| 56 | await sh(["git", "add", "."], work); | |
| 57 | await sh(["git", "commit", "-m", "first"], work); | |
| 58 | first = await sh(["git", "rev-parse", "HEAD"], work); | |
| 59 | await Bun.write(join(work, "a.txt"), "one\nTWO\nthree\n"); | |
| 60 | await sh(["git", "commit", "-am", "second"], work); | |
| 61 | second = await sh(["git", "rev-parse", "HEAD"], work); | |
| 62 | await sh(["git", "push", "origin", "HEAD:refs/heads/main"], work); | |
| 63 | }); | |
| 64 | ||
| 65 | afterAll(async () => { | |
| 66 | await rm(root, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | beforeEach(() => { | |
| 70 | forgetDiffs(); | |
| 71 | }); | |
| 72 | ||
| 73 | /** Swap the repository for an empty directory: any git run inside it then fails. */ | |
| 74 | async function withoutRepo<T>(work: () => Promise<T>): Promise<T> { | |
| 75 | const away = `${dir}.away`; | |
| 76 | await rename(dir, away); | |
| 77 | await mkdir(dir); | |
| 78 | try { | |
| 79 | return await work(); | |
| 80 | } finally { | |
| 81 | await rm(dir, { recursive: true }); | |
| 82 | await rename(away, dir); | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | describe("diffRange between two oids", () => { | |
| 87 | test("the second read of the same pair runs no git", async () => { | |
| 88 | const once = unwrap(await diffRange(dir, first, second)); | |
| 89 | expect(once.additions).toBe(2); | |
| 90 | const again = await withoutRepo(() => diffRange(dir, first, second)); | |
| 91 | expect(again.ok).toBe(true); | |
| 92 | expect(again.ok && again.value).toBe(once); | |
| 93 | }); | |
| 94 | ||
| 95 | test("readers share one frozen value no one can change", async () => { | |
| 96 | const diff = unwrap(await diffRange(dir, first, second)); | |
| 97 | expect(Object.isFrozen(diff)).toBe(true); | |
| 98 | expect(() => (diff.files as unknown[]).push({})).toThrow(); | |
| 99 | expect(() => { | |
| 100 | (diff.files[0]!.hunks[0]!.lines[0] as { content: string }).content = "changed"; | |
| 101 | }).toThrow(); | |
| 102 | const again = unwrap(await diffRange(dir, first, second)); | |
| 103 | expect(again.files[0]!.hunks[0]!.lines[0]!.content).toBe("one"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a failed run is not kept: the next call asks git again", async () => { | |
| 107 | const failed = await withoutRepo(() => diffRange(dir, first, second)); | |
| 108 | expect(failed.ok).toBe(false); | |
| 109 | expect(diffMemoryCounts().entries).toBe(0); | |
| 110 | const retried = await diffRange(dir, first, second); | |
| 111 | expect(retried.ok).toBe(true); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a ref can move, so a range naming one is never kept", async () => { | |
| 115 | unwrap(await diffRange(dir, first, "main")); | |
| 116 | expect(diffMemoryCounts().entries).toBe(0); | |
| 117 | const later = await withoutRepo(() => diffRange(dir, first, "main")); | |
| 118 | expect(later.ok).toBe(false); | |
| 119 | }); | |
| 120 | ||
| 121 | test("the memory is per repository, whatever oids a reader names", async () => { | |
| 122 | unwrap(await diffRange(dir, first, second)); | |
| 123 | const other = join(root, "other.git"); | |
| 124 | await mkdir(other, { recursive: true }); | |
| 125 | const elsewhere = await diffRange(other, first, second); | |
| 126 | expect(elsewhere.ok).toBe(false); | |
| 127 | }); | |
| 128 | ||
| 129 | test("readers arriving together share one run", async () => { | |
| 130 | const [a, b] = await Promise.all([diffRange(dir, first, second), diffRange(dir, first, second)]); | |
| 131 | expect(a.ok && b.ok && a.value === b.value).toBe(true); | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | describe("mergeBase", () => { | |
| 136 | test("the second lookup of the same pair runs no git", async () => { | |
| 137 | const once = await mergeBase(dir, first, second); | |
| 138 | expect(once).toBe(first); | |
| 139 | expect(await withoutRepo(() => mergeBase(dir, first, second))).toBe(first); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a failed lookup is asked again", async () => { | |
| 143 | expect(await withoutRepo(() => mergeBase(dir, first, second))).toBeUndefined(); | |
| 144 | expect(await mergeBase(dir, first, second)).toBe(first); | |
| 145 | }); | |
| 146 | ||
| 147 | test("holds at most MERGE_BASES_KEPT, the least recently used out first", () => { | |
| 148 | for (let i = 0; i < MERGE_BASES_KEPT + 2; i++) { | |
| 149 | rememberBase(diffMemoryKey("d", `a${i}`, "b"), `base${i}`); | |
| 150 | // Reading the first keeps it the most recently used. | |
| 151 | recalledBase(diffMemoryKey("d", "a0", "b")); | |
| 152 | } | |
| 153 | expect(mergeBaseCount()).toBe(MERGE_BASES_KEPT); | |
| 154 | expect(recalledBase(diffMemoryKey("d", "a0", "b"))).toBe("base0"); | |
| 155 | expect(recalledBase(diffMemoryKey("d", "a1", "b"))).toBeUndefined(); | |
| 156 | expect(recalledBase(diffMemoryKey("d", "a2", "b"))).toBeUndefined(); | |
| 157 | expect(recalledBase(diffMemoryKey("d", "a3", "b"))).toBe("base3"); | |
| 158 | expect(recalledBase(diffMemoryKey("d", `a${MERGE_BASES_KEPT + 1}`, "b"))).toBe(`base${MERGE_BASES_KEPT + 1}`); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("the byte bound", () => { | |
| 163 | const diffOf = (chars: number): Diff => ({ | |
| 164 | additions: 1, | |
| 165 | deletions: 0, | |
| 166 | files: [ | |
| 167 | { | |
| 168 | status: "added", | |
| 169 | oldPath: null, | |
| 170 | newPath: "f", | |
| 171 | path: "f", | |
| 172 | binary: false, | |
| 173 | additions: 1, | |
| 174 | deletions: 0, | |
| 175 | similarity: null, | |
| 176 | hunks: [ | |
| 177 | { | |
| 178 | oldStart: 0, | |
| 179 | oldLines: 0, | |
| 180 | newStart: 1, | |
| 181 | newLines: 1, | |
| 182 | section: "", | |
| 183 | lines: [{ kind: "add", oldNumber: null, newNumber: 1, content: "x".repeat(chars), noNewline: false }], | |
| 184 | }, | |
| 185 | ], | |
| 186 | }, | |
| 187 | ], | |
| 188 | }); | |
| 189 | ||
| 190 | test("holds under the total, the least recently used out first", () => { | |
| 191 | const each = sizeOfDiff(diffOf(1024 * 1024)); | |
| 192 | const fits = Math.floor(MAX_BYTES / each); | |
| 193 | for (let i = 0; i < fits + 3; i++) { | |
| 194 | remember(diffMemoryKey("d", `a${i}`, "b"), diffOf(1024 * 1024)); | |
| 195 | // Reading the first keeps it the most recently used. | |
| 196 | recalled(diffMemoryKey("d", "a0", "b")); | |
| 197 | } | |
| 198 | const counts = diffMemoryCounts(); | |
| 199 | expect(counts.bytes).toBeLessThanOrEqual(MAX_BYTES); | |
| 200 | expect(counts.entries).toBe(fits); | |
| 201 | expect(recalled(diffMemoryKey("d", "a0", "b"))).toBeDefined(); | |
| 202 | expect(recalled(diffMemoryKey("d", "a1", "b"))).toBeUndefined(); | |
| 203 | expect(recalled(diffMemoryKey("d", `a${fits + 2}`, "b"))).toBeDefined(); | |
| 204 | }); | |
| 205 | ||
| 206 | test("one diff over the cap is served but evicts nothing", () => { | |
| 207 | remember(diffMemoryKey("d", "small", "b"), diffOf(10)); | |
| 208 | const huge = diffOf(MAX_DIFF_BYTES); | |
| 209 | expect(sizeOfDiff(huge)).toBeGreaterThan(MAX_DIFF_BYTES); | |
| 210 | const served = remember(diffMemoryKey("d", "huge", "b"), huge); | |
| 211 | expect(served.files[0]!.hunks[0]!.lines[0]!.content.length).toBe(MAX_DIFF_BYTES); | |
| 212 | expect(recalled(diffMemoryKey("d", "huge", "b"))).toBeUndefined(); | |
| 213 | expect(recalled(diffMemoryKey("d", "small", "b"))).toBeDefined(); | |
| 214 | expect(diffMemoryCounts().entries).toBe(1); | |
| 215 | }); | |
| 216 | }); |
test/pull-diff-memory.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** A second read of an unmoved pull, through `pullDiff` and the API, runs no git (#8160). */ | |
| 2 | import { afterAll, afterEach, beforeAll, beforeEach, expect, spyOn, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { getPull, pullDiff } from "../src/core/pulls.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { forgetDiffs } from "../src/git/diff-memory.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let dir: string; | |
| 22 | let repoId = ""; | |
| 23 | let number = 0; | |
| 24 | const cells = process.env["FORGE_CELLS"]; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | process.env["FORGE_CELLS"] = "off"; | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-pull-diff-memory-")); | |
| 29 | app = createApp({ dataDir, port: 0 }); | |
| 30 | servers = await serve(app); | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 33 | password: "a-good-passphrase", adminIfFirst: true, | |
| 34 | }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | const alice = made.value; | |
| 37 | const repo = await createRepo(app, alice, { name: "r", visibility: "public" }); | |
| 38 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 39 | repoId = repo.value.id; | |
| 40 | dir = repoDir(app.reposRoot, "alice", "r"); | |
| 41 | const commit = (path: string, content: string, branch?: string) => | |
| 42 | commitToBranch(app, alice, repo.value, { | |
| 43 | ...(branch === undefined ? {} : { branch }), | |
| 44 | message: "change", | |
| 45 | files: [{ path, content }], | |
| 46 | }); | |
| 47 | for (const done of [await commit("a.txt", "one\n"), await commit("b.txt", "two\n", "topic")]) | |
| 48 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 49 | const pull = await openPull(app, alice, repo.value, { title: "t", body: "", baseBranch: "main", headBranch: "topic" }); | |
| 50 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 51 | number = pull.value.number; | |
| 52 | }); | |
| 53 | ||
| 54 | let spawned: string[][] = []; | |
| 55 | let spy: ReturnType<typeof spyOn> | undefined; | |
| 56 | ||
| 57 | beforeEach(() => { | |
| 58 | forgetDiffs(); | |
| 59 | spawned = []; | |
| 60 | const real = Bun.spawn; | |
| 61 | spy = spyOn(Bun, "spawn").mockImplementation(((cmd: string[], options?: { cwd?: string }) => { | |
| 62 | if (cmd[0] === "git" && options?.cwd === dir) spawned.push(cmd.slice(1)); | |
| 63 | return real(cmd, options as Parameters<typeof Bun.spawn>[1]); | |
| 64 | }) as typeof Bun.spawn); | |
| 65 | }); | |
| 66 | ||
| 67 | afterEach(() => spy?.mockRestore()); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | await servers.stop(); | |
| 71 | app.close(); | |
| 72 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 73 | else process.env["FORGE_CELLS"] = cells; | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("pullDiff reads git once, then answers the same tips from memory", async () => { | |
| 78 | const pull = getPull(app.db, repoId, number)!; | |
| 79 | const first = await pullDiff(dir, pull); | |
| 80 | expect(first.diff.files.map((f) => f.path)).toEqual(["b.txt"]); | |
| 81 | expect(spawned.some((args) => args[0] === "diff")).toBe(true); | |
| 82 | spawned = []; | |
| 83 | const again = await pullDiff(dir, pull); | |
| 84 | expect(again.diff).toBe(first.diff); | |
| 85 | expect(spawned).toEqual([]); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the API's second read of an unmoved pull spawns no git in its diff phase", async () => { | |
| 89 | const url = `${servers.url}/api/v1/repos/alice/r/pulls/${number}`; | |
| 90 | const first = await fetch(url); | |
| 91 | expect(first.status).toBe(200); | |
| 92 | expect(((await first.json()) as { diff: { files: unknown[] } }).diff.files).toHaveLength(1); | |
| 93 | expect(spawned.length).toBeGreaterThan(0); | |
| 94 | spawned = []; | |
| 95 | const again = await fetch(url); | |
| 96 | expect(again.status).toBe(200); | |
| 97 | expect(((await again.json()) as { diff: { files: unknown[] } }).diff.files).toHaveLength(1); | |
| 98 | expect(spawned).toEqual([]); | |
| 99 | const timing = again.headers.get("server-timing") ?? ""; | |
| 100 | expect(timing).toContain("diff;dur="); | |
| 101 | }); |
test/pull-files-stretches.test.ts +4
| @@ -8,6 +8,7 @@ import { addReview, openPull } from "../src/actions/pulls.ts"; | ||
| 8 | 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { forgetDiffs } from "../src/git/diff-memory.ts"; | |
| 11 | 12 | import { clearDiffCache } from "../src/web/diff-cache.ts"; |
| 12 | 13 | import { clearHeadReads } from "../src/web/head-reads.ts"; |
| 13 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -71,7 +72,10 @@ const phase = (header: string, name: string) => { | ||
| 71 | 72 | }; |
| 72 | 73 | |
| 73 | 74 | test("a 3,000-line, 20-file diff names its stretches cold and draws under 30 ms warm, comments on their lines", async () => { |
| 75 | // Cold means nothing remembered anywhere: the page's cache, and git's own | |
| 76 | // memory of the pair and its merge base, which opening the pull filled. | |
| 74 | 77 | clearDiffCache(); |
| 78 | forgetDiffs(); | |
| 75 | 79 | clearHeadReads(); |
| 76 | 80 | const first = await fetch(url); |
| 77 | 81 | await first.arrayBuffer(); |
new above
offline
The comment mentions a Server-Timing note, but nothing outside tests calls this. Correct the comment or use it.