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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/8156-get-pulls-answers-in-under-1-s-with-50-o → main 2 commits fork

  • Merged. torque/8156-get-pulls-answers-in-under-1-s-with-50-o was merged into main as b84f843.

Review

Sign in to review this change.
4 files changed +275 −21
src/core/pulls.ts +121 −19
@@ -849,6 +849,9 @@ interface MergeReading {
849849 readonly conflicts: string[];
850850 }
851851
852 /** The merge check a reading runs: git's merge-tree, or a test's stand-in. */
853 type MergeCheck = typeof mergeable;
854
852855 /** Readings by `head:base` oid: a merge-tree over two commits never changes. */
853856 const readings = new Map<string, MergeReading>();
854857 const READINGS_MAX = 500;
@@ -857,6 +860,7 @@ async function mergeReading(
857860 dir: string,
858861 baseOid: string,
859862 headOid: string,
863 check: MergeCheck = mergeable,
860864 ): Promise<MergeReading> {
861865 const key = `${headOid}:${baseOid}`;
862866 const known = readings.get(key);
@@ -865,16 +869,16 @@ async function mergeReading(
865869 let reading: MergeReading;
866870 if (ahead === 0) reading = { ahead, mergeable: false, conflicts: [] };
867871 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
870874 ? { ahead, mergeable: true, conflicts: [] }
871875 : {
872876 ahead,
873877 mergeable: false,
874 conflicts: check.error.kind === "conflict" ? check.error.files : [],
878 conflicts: merged.error.kind === "conflict" ? merged.error.files : [],
875879 };
876880 // 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;
878882 }
879883 if (readings.size >= READINGS_MAX)
880884 readings.delete(readings.keys().next().value!);
@@ -896,25 +900,100 @@ export async function pullMergeState(
896900 return { mergeable: clean, conflicts };
897901 }
898902
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. */
900904 const READ_AT_ONCE = 4;
901905
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
902970 /**
903971 * `pullMergeState` for a page of pulls: tips read from the ref files (no git
904972 * 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.
907984 */
908985 export async function pullMergeStates(
909986 items: readonly { dir: string; pull: Pull }[],
987 opts: { budgetMs?: number; check?: MergeCheck } = {},
910988 ): Promise<({ mergeable: boolean; conflicts: string[] } | null)[]> {
989 const budgetMs = opts.budgetMs ?? MERGE_BUDGET_MS;
990 const check = opts.check ?? mergeable;
911991 const out: ({ mergeable: boolean; conflicts: string[] } | null)[] =
912992 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;
918997 const baseRef = `refs/heads/${pull.baseBranch}`;
919998 const headRef = pull.crossRepo
920999 ? `refs/pull/${pull.number}/head`
@@ -922,14 +1001,37 @@ export async function pullMergeStates(
9221001 const live = await readTips(dir, [baseRef, headRef]);
9231002 const baseOid = live.get(baseRef) ?? pull.baseOid;
9241003 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 }),
9311022 );
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] }));
9331035 }
9341036
9351037 /**
test/api-pulls-timing.test.ts +3
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { openPull } from "../src/actions/pulls.ts";
8 import { mergeReadingsSettled } from "../src/core/pulls.ts";
89 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
910 import { createSession } from "../src/core/sessions.ts";
1011 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
112113 const url = `${servers.url}/api/v1/repos/alice/r/pulls?limit=200`;
113114 const res = await fetch(url, { headers: { cookie } });
114115 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();
115118 const spawn = spyOn(Bun, "spawn");
116119 try {
117120 // 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";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 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";
77 import { gitClientEnv } from "./helpers/git-client.ts";
88
99 const env = gitClientEnv();
@@ -58,7 +58,7 @@ test("a second read of 130 pulls does no git work and spawns nothing; a moved he
5858 const items = Array.from({ length: N }, (_, i) => ({ dir, pull: pullOf(i) }));
5959 const spawn = spyOn(Bun, "spawn");
6060 try {
61 const first = await pullMergeStates(items);
61 const first = await pullMergeStates(items, { budgetMs: Infinity });
6262 expect(first.every((m) => m?.mergeable === true)).toBe(true);
6363 expect(spawn.mock.calls.length).toBeGreaterThan(0);
6464
@@ -78,6 +78,7 @@ test("a second read of 130 pulls does no git work and spawns nothing; a moved he
7878 await git(work, "push", "-q", dir, "b7:b7");
7979 spawn.mockClear();
8080 await pullMergeStates(items);
81 await mergeReadingsSettled();
8182 const verbs = spawn.mock.calls.map((c) => (c[0] as string[]).filter((a) => !a.startsWith("-"))[1]);
8283 expect(verbs.filter((v) => v === "rev-list").length).toBe(1);
8384 expect(verbs.filter((v) => v === "merge-tree").length).toBe(1);
@@ -97,6 +98,7 @@ test("a base that moves recomputes every pull", async () => {
9798 const spawn = spyOn(Bun, "spawn");
9899 try {
99100 await pullMergeStates(items);
101 await mergeReadingsSettled();
100102 const revLists = spawn.mock.calls.filter((c) => (c[0] as string[]).includes("rev-list"));
101103 expect(revLists.length).toBe(N);
102104 } 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs