Public: anyone can read this; the crew can change it.
GET …/pulls answers in under 1 s with 50 open pulls right after main moves #8166
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8156-get-pulls-answers-in-under-1-s-with-50-o → main 2 commits fork
4 files
changed +275
−21
src/core/pulls.ts +121 −19
| @@ -849,6 +849,9 @@ interface MergeReading { | ||
| 849 | 849 | readonly conflicts: string[]; |
| 850 | 850 | } |
| 851 | 851 | |
| 852 | /** The merge check a reading runs: git's merge-tree, or a test's stand-in. */ | |
| 853 | type MergeCheck = typeof mergeable; | |
| 854 | ||
| 852 | 855 | /** Readings by `head:base` oid: a merge-tree over two commits never changes. */ |
| 853 | 856 | const readings = new Map<string, MergeReading>(); |
| 854 | 857 | const READINGS_MAX = 500; |
| @@ -857,6 +860,7 @@ async function mergeReading( | ||
| 857 | 860 | dir: string, |
| 858 | 861 | baseOid: string, |
| 859 | 862 | headOid: string, |
| 863 | check: MergeCheck = mergeable, | |
| 860 | 864 | ): Promise<MergeReading> { |
| 861 | 865 | const key = `${headOid}:${baseOid}`; |
| 862 | 866 | const known = readings.get(key); |
| @@ -865,16 +869,16 @@ async function mergeReading( | ||
| 865 | 869 | let reading: MergeReading; |
| 866 | 870 | if (ahead === 0) reading = { ahead, mergeable: false, conflicts: [] }; |
| 867 | 871 | else { |
| 868 | const check = await mergeable(dir, baseOid, headOid); | |
| 869 | reading = check.ok | |
| 872 | const merged = await check(dir, baseOid, headOid); | |
| 873 | reading = merged.ok | |
| 870 | 874 | ? { ahead, mergeable: true, conflicts: [] } |
| 871 | 875 | : { |
| 872 | 876 | ahead, |
| 873 | 877 | mergeable: false, |
| 874 | conflicts: check.error.kind === "conflict" ? check.error.files : [], | |
| 878 | conflicts: merged.error.kind === "conflict" ? merged.error.files : [], | |
| 875 | 879 | }; |
| 876 | 880 | // A git failure is not a verdict about the commits: do not remember it. |
| 877 | if (!check.ok && check.error.kind !== "conflict") return reading; | |
| 881 | if (!merged.ok && merged.error.kind !== "conflict") return reading; | |
| 878 | 882 | } |
| 879 | 883 | if (readings.size >= READINGS_MAX) |
| 880 | 884 | readings.delete(readings.keys().next().value!); |
| @@ -896,25 +900,100 @@ export async function pullMergeState( | ||
| 896 | 900 | return { mergeable: clean, conflicts }; |
| 897 | 901 | } |
| 898 | 902 | |
| 899 | /** How many merge readings a list runs at once: a page is never a fork bomb. */ | |
| 903 | /** How many merge readings run at once, across every list: never a fork bomb. */ | |
| 900 | 904 | const READ_AT_ONCE = 4; |
| 901 | 905 | |
| 906 | /** | |
| 907 | * How long a list waits for readings it does not have (#8156). Every landing | |
| 908 | * moves the base and so invalidates every open pull's (head, base) pair; with | |
| 909 | * 47 pulls, four readings at a time, a list waited 71 s. Past this budget | |
| 910 | * the list answers what it knows and the readings finish in the background. | |
| 911 | */ | |
| 912 | export const MERGE_BUDGET_MS = 300; | |
| 913 | ||
| 914 | /** Readings in flight by `dir:head:base`: one per pair, however many lists ask. */ | |
| 915 | const inflight = new Map<string, Promise<MergeReading>>(); | |
| 916 | const waiting: (() => Promise<void>)[] = []; | |
| 917 | let running = 0; | |
| 918 | ||
| 919 | function pump(): void { | |
| 920 | while (running < READ_AT_ONCE && waiting.length > 0) { | |
| 921 | const job = waiting.shift()!; | |
| 922 | running++; | |
| 923 | void job().finally(() => { | |
| 924 | running--; | |
| 925 | pump(); | |
| 926 | }); | |
| 927 | } | |
| 928 | } | |
| 929 | ||
| 930 | /** A reading of the pair, queued behind at most `READ_AT_ONCE` others. */ | |
| 931 | function readQueued( | |
| 932 | dir: string, | |
| 933 | baseOid: string, | |
| 934 | headOid: string, | |
| 935 | check: MergeCheck, | |
| 936 | ): Promise<MergeReading> { | |
| 937 | const key = `${dir}:${headOid}:${baseOid}`; | |
| 938 | const known = inflight.get(key); | |
| 939 | if (known !== undefined) return known; | |
| 940 | const reading = new Promise<MergeReading>((resolve, reject) => { | |
| 941 | waiting.push(() => | |
| 942 | mergeReading(dir, baseOid, headOid, check).then(resolve, reject), | |
| 943 | ); | |
| 944 | }).finally(() => inflight.delete(key)); | |
| 945 | inflight.set(key, reading); | |
| 946 | pump(); | |
| 947 | return reading; | |
| 948 | } | |
| 949 | ||
| 950 | /** Resolves when no reading is queued or running: for tests and shutdown. */ | |
| 951 | export async function mergeReadingsSettled(): Promise<void> { | |
| 952 | while (inflight.size > 0) | |
| 953 | await Promise.allSettled([...inflight.values()]); | |
| 954 | } | |
| 955 | ||
| 956 | /** | |
| 957 | * The last reading a list gave for a pull, by `dir:number`, and the head it | |
| 958 | * was for. Kept so a pull whose base moved answers what it said a moment ago | |
| 959 | * rather than nothing while its fresh reading runs. | |
| 960 | */ | |
| 961 | const lastByPull = new Map<string, { headOid: string; reading: MergeReading }>(); | |
| 962 | ||
| 963 | function remember(key: string, headOid: string, reading: MergeReading): void { | |
| 964 | lastByPull.delete(key); | |
| 965 | if (lastByPull.size >= READINGS_MAX) | |
| 966 | lastByPull.delete(lastByPull.keys().next().value!); | |
| 967 | lastByPull.set(key, { headOid, reading }); | |
| 968 | } | |
| 969 | ||
| 902 | 970 | /** |
| 903 | 971 | * `pullMergeState` for a page of pulls: tips read from the ref files (no git |
| 904 | 972 | * process), each answer remembered by (base tip, head tip) in `mergeReading` |
| 905 | * so git runs only for a pair that moved, and at most `READ_AT_ONCE` | |
| 906 | * readings in flight. Same answers, same order as the input. | |
| 973 | * so git runs only for a pair that moved, at most `READ_AT_ONCE` readings in | |
| 974 | * flight, and at most one per pair. | |
| 975 | * | |
| 976 | * A list waits at most `budgetMs` for readings it has not got. A pair still | |
| 977 | * reading after that answers the pull's last reading when its head has not | |
| 978 | * moved — only the base moved, and what this head did against the base one | |
| 979 | * landing ago is the best guess there is — and null when the head moved or | |
| 980 | * the pull was never read: no reading of these commits exists, and a guess | |
| 981 | * about new commits would be invented. A stale `mergeable: true` misleads | |
| 982 | * nobody into a bad land: `mergePull` reads the pair afresh before it merges. | |
| 983 | * The reading carries on in the background, so the next list has it. | |
| 907 | 984 | */ |
| 908 | 985 | export async function pullMergeStates( |
| 909 | 986 | items: readonly { dir: string; pull: Pull }[], |
| 987 | opts: { budgetMs?: number; check?: MergeCheck } = {}, | |
| 910 | 988 | ): Promise<({ mergeable: boolean; conflicts: string[] } | null)[]> { |
| 989 | const budgetMs = opts.budgetMs ?? MERGE_BUDGET_MS; | |
| 990 | const check = opts.check ?? mergeable; | |
| 911 | 991 | const out: ({ mergeable: boolean; conflicts: string[] } | null)[] = |
| 912 | 992 | new Array(items.length).fill(null); |
| 913 | let next = 0; | |
| 914 | const worker = async (): Promise<void> => { | |
| 915 | for (let i = next++; i < items.length; i = next++) { | |
| 916 | const { dir, pull } = items[i]!; | |
| 917 | if (pull.state !== "open" || pull.mergedAt !== null) continue; | |
| 993 | const pending: Promise<void>[] = []; | |
| 994 | await Promise.all( | |
| 995 | items.map(async ({ dir, pull }, i) => { | |
| 996 | if (pull.state !== "open" || pull.mergedAt !== null) return; | |
| 918 | 997 | const baseRef = `refs/heads/${pull.baseBranch}`; |
| 919 | 998 | const headRef = pull.crossRepo |
| 920 | 999 | ? `refs/pull/${pull.number}/head` |
| @@ -922,14 +1001,37 @@ export async function pullMergeStates( | ||
| 922 | 1001 | const live = await readTips(dir, [baseRef, headRef]); |
| 923 | 1002 | const baseOid = live.get(baseRef) ?? pull.baseOid; |
| 924 | 1003 | const headOid = live.get(headRef) ?? pull.headOid; |
| 925 | const r = await mergeReading(dir, baseOid, headOid); | |
| 926 | out[i] = { mergeable: r.mergeable, conflicts: r.conflicts }; | |
| 927 | } | |
| 928 | }; | |
| 929 | await Promise.all( | |
| 930 | Array.from({ length: Math.min(READ_AT_ONCE, items.length) }, worker), | |
| 1004 | const pullKey = `${dir}:${pull.number}`; | |
| 1005 | // A pair already read answers now, never queued behind slow ones. | |
| 1006 | const known = readings.get(`${headOid}:${baseOid}`); | |
| 1007 | if (known !== undefined) { | |
| 1008 | remember(pullKey, headOid, known); | |
| 1009 | out[i] = { mergeable: known.mergeable, conflicts: [...known.conflicts] }; | |
| 1010 | return; | |
| 1011 | } | |
| 1012 | const last = lastByPull.get(pullKey); | |
| 1013 | if (last !== undefined && last.headOid === headOid) | |
| 1014 | out[i] = { mergeable: last.reading.mergeable, conflicts: [...last.reading.conflicts] }; | |
| 1015 | pending.push( | |
| 1016 | readQueued(dir, baseOid, headOid, check).then((r) => { | |
| 1017 | remember(pullKey, headOid, r); | |
| 1018 | out[i] = { mergeable: r.mergeable, conflicts: [...r.conflicts] }; | |
| 1019 | }), | |
| 1020 | ); | |
| 1021 | }), | |
| 931 | 1022 | ); |
| 932 | return out; | |
| 1023 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 1024 | // An infinite budget waits for every reading (setTimeout would fire at once). | |
| 1025 | const budget = Number.isFinite(budgetMs) | |
| 1026 | ? new Promise<void>((resolve) => { | |
| 1027 | timer = setTimeout(resolve, budgetMs); | |
| 1028 | }) | |
| 1029 | : new Promise<void>(() => {}); | |
| 1030 | // A failed reading is answered by what the pull already holds. | |
| 1031 | await Promise.race([Promise.allSettled(pending), budget]); | |
| 1032 | clearTimeout(timer); | |
| 1033 | // Copies: a reading that lands after the answer must not change it. | |
| 1034 | return out.map((m) => (m === null ? null : { ...m, conflicts: [...m.conflicts] })); | |
| 933 | 1035 | } |
| 934 | 1036 | |
| 935 | 1037 | /** |
test/api-pulls-timing.test.ts +3
| @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { openPull } from "../src/actions/pulls.ts"; |
| 8 | import { mergeReadingsSettled } from "../src/core/pulls.ts"; | |
| 8 | 9 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 9 | 10 | import { createSession } from "../src/core/sessions.ts"; |
| 10 | 11 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -112,6 +113,8 @@ test("130 open pulls read their mergeability from the cache: the merge phase is | ||
| 112 | 113 | const url = `${servers.url}/api/v1/repos/alice/r/pulls?limit=200`; |
| 113 | 114 | const res = await fetch(url, { headers: { cookie } }); |
| 114 | 115 | expect((JSON.parse(await res.text()) as { items: unknown[] }).items.length).toBeGreaterThanOrEqual(130); |
| 116 | // Past the list's budget the first readings finish in the background (#8156). | |
| 117 | await mergeReadingsSettled(); | |
| 115 | 118 | const spawn = spyOn(Bun, "spawn"); |
| 116 | 119 | try { |
| 117 | 120 | // Counted, not timed: one read per page, not one per pull (130 pulls, no N+1). |
test/pull-merge-states-cache.test.ts +4 −2
| @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { readTips } from "../src/git/tips.ts"; |
| 6 | import { pullMergeStates, type Pull } from "../src/core/pulls.ts"; | |
| 6 | import { mergeReadingsSettled, pullMergeStates, type Pull } from "../src/core/pulls.ts"; | |
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | 8 | |
| 9 | 9 | const env = gitClientEnv(); |
| @@ -58,7 +58,7 @@ test("a second read of 130 pulls does no git work and spawns nothing; a moved he | ||
| 58 | 58 | const items = Array.from({ length: N }, (_, i) => ({ dir, pull: pullOf(i) })); |
| 59 | 59 | const spawn = spyOn(Bun, "spawn"); |
| 60 | 60 | try { |
| 61 | const first = await pullMergeStates(items); | |
| 61 | const first = await pullMergeStates(items, { budgetMs: Infinity }); | |
| 62 | 62 | expect(first.every((m) => m?.mergeable === true)).toBe(true); |
| 63 | 63 | expect(spawn.mock.calls.length).toBeGreaterThan(0); |
| 64 | 64 | |
| @@ -78,6 +78,7 @@ test("a second read of 130 pulls does no git work and spawns nothing; a moved he | ||
| 78 | 78 | await git(work, "push", "-q", dir, "b7:b7"); |
| 79 | 79 | spawn.mockClear(); |
| 80 | 80 | await pullMergeStates(items); |
| 81 | await mergeReadingsSettled(); | |
| 81 | 82 | const verbs = spawn.mock.calls.map((c) => (c[0] as string[]).filter((a) => !a.startsWith("-"))[1]); |
| 82 | 83 | expect(verbs.filter((v) => v === "rev-list").length).toBe(1); |
| 83 | 84 | expect(verbs.filter((v) => v === "merge-tree").length).toBe(1); |
| @@ -97,6 +98,7 @@ test("a base that moves recomputes every pull", async () => { | ||
| 97 | 98 | const spawn = spyOn(Bun, "spawn"); |
| 98 | 99 | try { |
| 99 | 100 | await pullMergeStates(items); |
| 101 | await mergeReadingsSettled(); | |
| 100 | 102 | const revLists = spawn.mock.calls.filter((c) => (c[0] as string[]).includes("rev-list")); |
| 101 | 103 | expect(revLists.length).toBe(N); |
| 102 | 104 | } finally { |
test/pulls-merge-budget.test.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * A list of pulls waits a bounded budget for merge readings it does not | |
| 3 | * have; the rest finish in the background, one per pair, four at a time | |
| 4 | * (#8156). Seen: 47 open pulls, main moved, `merge;dur=71458.8`. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { mergeable } from "../src/git/merge.ts"; | |
| 11 | import { | |
| 12 | MERGE_BUDGET_MS, | |
| 13 | mergeReadingsSettled, | |
| 14 | pullMergeStates, | |
| 15 | type Pull, | |
| 16 | } from "../src/core/pulls.ts"; | |
| 17 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 18 | ||
| 19 | const env = gitClientEnv(); | |
| 20 | const N = 50; | |
| 21 | /** Pulls 0..CLASH-1 edit a.txt, which main edits too once it moves. */ | |
| 22 | const CLASH = 10; | |
| 23 | const SLOW_MS = 200; | |
| 24 | let root: string; | |
| 25 | let dir: string; | |
| 26 | ||
| 27 | async function git(cwd: string, ...args: string[]): Promise<string> { | |
| 28 | const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 29 | const [out, errText, code] = await Promise.all([ | |
| 30 | new Response(p.stdout).text(), | |
| 31 | new Response(p.stderr).text(), | |
| 32 | p.exited, | |
| 33 | ]); | |
| 34 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${errText}`); | |
| 35 | return out.trim(); | |
| 36 | } | |
| 37 | ||
| 38 | const pullOf = (i: number): Pull => | |
| 39 | ({ | |
| 40 | number: i + 1, | |
| 41 | state: "open", | |
| 42 | mergedAt: null, | |
| 43 | crossRepo: false, | |
| 44 | baseBranch: "main", | |
| 45 | headBranch: `b${i}`, | |
| 46 | baseOid: "0".repeat(40), | |
| 47 | headOid: "0".repeat(40), | |
| 48 | }) as unknown as Pull; | |
| 49 | ||
| 50 | /** git's merge check, 200 ms slower, counting calls and how many overlap. */ | |
| 51 | let calls = 0; | |
| 52 | let running = 0; | |
| 53 | let most = 0; | |
| 54 | const slow: typeof mergeable = async (d, base, head) => { | |
| 55 | calls++; | |
| 56 | running++; | |
| 57 | most = Math.max(most, running); | |
| 58 | try { | |
| 59 | await Bun.sleep(SLOW_MS); | |
| 60 | return await mergeable(d, base, head); | |
| 61 | } finally { | |
| 62 | running--; | |
| 63 | } | |
| 64 | }; | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | root = await mkdtemp(join(tmpdir(), "forge-merge-budget-")); | |
| 68 | const work = join(root, "work"); | |
| 69 | dir = join(root, "bare.git"); | |
| 70 | await git(root, "init", "-q", "-b", "main", work); | |
| 71 | await Bun.write(join(work, "a.txt"), "a\n"); | |
| 72 | await git(work, "add", "."); | |
| 73 | await git(work, "commit", "-q", "-m", "base"); | |
| 74 | for (let i = 0; i < N; i++) { | |
| 75 | await git(work, "checkout", "-q", "-b", `b${i}`, "main"); | |
| 76 | if (i < CLASH) await Bun.write(join(work, "a.txt"), `pull ${i}\n`); | |
| 77 | else await Bun.write(join(work, `f${i}.txt`), "x\n"); | |
| 78 | await git(work, "add", "."); | |
| 79 | await git(work, "commit", "-q", "-m", `p${i}`); | |
| 80 | } | |
| 81 | await git(work, "checkout", "-q", "main"); | |
| 82 | await git(root, "clone", "-q", "--bare", work, dir); | |
| 83 | }, 60_000); | |
| 84 | ||
| 85 | afterAll(() => rm(root, { recursive: true, force: true })); | |
| 86 | ||
| 87 | test("50 pulls, a slow merge check, main moves: the list answers in under 1 s and the next has fresh answers", async () => { | |
| 88 | const items = Array.from({ length: N }, (_, i) => ({ dir, pull: pullOf(i) })); | |
| 89 | const before = await pullMergeStates(items, { budgetMs: Infinity, check: slow }); | |
| 90 | expect(before.every((m) => m?.mergeable === true)).toBe(true); | |
| 91 | ||
| 92 | // A landing: main edits a.txt, so the first CLASH pulls now conflict. | |
| 93 | const work = join(root, "work"); | |
| 94 | await Bun.write(join(work, "a.txt"), "main\n"); | |
| 95 | await git(work, "commit", "-q", "-am", "main moves"); | |
| 96 | await git(work, "push", "-q", dir, "main:main"); | |
| 97 | calls = 0; | |
| 98 | most = 0; | |
| 99 | ||
| 100 | const t0 = performance.now(); | |
| 101 | const [during, again] = await Promise.all([ | |
| 102 | pullMergeStates(items, { check: slow }), | |
| 103 | pullMergeStates(items, { check: slow }), | |
| 104 | ]); | |
| 105 | const took = performance.now() - t0; | |
| 106 | expect(took).toBeLessThan(1000); | |
| 107 | expect(took).toBeGreaterThanOrEqual(MERGE_BUDGET_MS - 5); | |
| 108 | // 50 readings at four at a time and 200 ms each is 2.6 s: most are unread, | |
| 109 | // and those answer their last reading (same head), never a blank. | |
| 110 | expect(during.every((m) => m !== null)).toBe(true); | |
| 111 | expect(during.filter((m, i) => i < CLASH && m!.mergeable).length).toBeGreaterThan(0); | |
| 112 | expect(again).toEqual(during); | |
| 113 | ||
| 114 | await mergeReadingsSettled(); | |
| 115 | // Two lists asked; each pair was read once, never more than four at a time. | |
| 116 | expect(calls).toBe(N); | |
| 117 | expect(most).toBeLessThanOrEqual(4); | |
| 118 | ||
| 119 | // The next list is answered from the readings: not one more merge check. | |
| 120 | const after = await pullMergeStates(items, { check: slow }); | |
| 121 | expect(calls).toBe(N); | |
| 122 | after.forEach((m, i) => { | |
| 123 | expect(m!.mergeable).toBe(i >= CLASH); | |
| 124 | expect(m!.conflicts).toEqual(i < CLASH ? ["a.txt"] : []); | |
| 125 | }); | |
| 126 | }, 30_000); | |
| 127 | ||
| 128 | test("a pull whose head moved, still unread at the budget, answers null rather than a guess", async () => { | |
| 129 | const work = join(root, "work"); | |
| 130 | await git(work, "checkout", "-q", `b${N - 1}`); | |
| 131 | await Bun.write(join(work, "new.txt"), "n\n"); | |
| 132 | await git(work, "add", "."); | |
| 133 | await git(work, "commit", "-q", "-m", "head moves"); | |
| 134 | await git(work, "push", "-q", dir, `b${N - 1}:b${N - 1}`); | |
| 135 | await git(work, "checkout", "-q", "main"); | |
| 136 | const items = [{ dir, pull: pullOf(N - 1) }]; | |
| 137 | const [got] = await pullMergeStates(items, { budgetMs: 20, check: slow }); | |
| 138 | expect(got).toBeNull(); | |
| 139 | await mergeReadingsSettled(); | |
| 140 | const [fresh] = await pullMergeStates(items, { check: slow }); | |
| 141 | expect(fresh).toEqual({ mergeable: true, conflicts: [] }); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a closed pull answers null at once", async () => { | |
| 145 | const closed = { ...pullOf(0), state: "closed" } as unknown as Pull; | |
| 146 | expect(await pullMergeStates([{ dir, pull: closed }], { check: slow })).toEqual([null]); | |
| 147 | }); |
new above
offline