authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 01:32:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 01:32:35-07:00
logc93080b0a3bf4faffb375169c5ec06adce05eb2f
tree1941163151a11f7ba1ec19fe4c2776835d325117
parent10395bcc59707302c1476e19186532ff5a9d3557
signature Signed by PGP key B5690EEEBB952194

perf(server): background branch lookups share one GitHub query per sweep (#16760)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 69 insertions(+), 15 deletions(-)

apps/server/src/sourceControl/GitHubCli.test.ts+53-12
......@@ -251,7 +251,7 @@ describe("GitHubCli repository resolution", () => {
251251describe("GitHubCli.listPullRequestsByHead", () => {
252252 const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]);
253253
254 it.effect("reads heads on one repository in one GraphQL document", () => {
254 it.effect("reads a background sweep's staggered heads in one GraphQL document", () => {
255255 const documents: Array<GitHubApi.GitHubGraphQlInput> = [];
256256 const { layer } = harness({
257257 remotes,
......@@ -267,20 +267,23 @@ describe("GitHubCli.listPullRequestsByHead", () => {
267267 });
268268 return Effect.gen(function* () {
269269 const gh = yield* GitHubCli.GitHubCli;
270 const lookups = yield* Effect.all(
271 ["feature/a", "feature/b"].map((headSelector) =>
272 gh.listPullRequestsByHead({
270 const lookup = (headSelector: string) =>
271 gh
272 .listPullRequestsByHead({
273273 cwd: "/repo",
274274 headSelector,
275275 state: "all",
276276 limit: 100,
277277 rateLimitHost: "github.com",
278 }),
279 ),
280 { concurrency: "unbounded" },
281 ).pipe(Effect.forkChild);
282 yield* TestClock.adjust("50 millis");
283 const [first, second] = yield* Fiber.join(lookups);
278 })
279 .pipe(Effect.forkChild);
280 // Each branch's own git reads come first, so a sweep's lookups arrive spread out.
281 const firstLookup = yield* lookup("feature/a");
282 yield* TestClock.adjust("200 millis");
283 const secondLookup = yield* lookup("feature/b");
284 yield* TestClock.adjust("300 millis");
285 const first = yield* Fiber.join(firstLookup);
286 const second = yield* Fiber.join(secondLookup);
284287 assert.deepStrictEqual(
285288 first?.map((pr) => pr.number),
286289 [7],
......@@ -298,6 +301,44 @@ describe("GitHubCli.listPullRequestsByHead", () => {
298301 }).pipe(Effect.provide(layer));
299302 });
300303
304 it.effect("caps a background document at twenty-five heads", () => {
305 const headCounts: Array<number> = [];
306 const { layer } = harness({
307 remotes,
308 api: {
309 graphql: (input) =>
310 Effect.sync(() => {
311 const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key));
312 headCounts.push(heads.length);
313 return encodeJson({
314 data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) },
315 });
316 }),
317 },
318 });
319 return Effect.gen(function* () {
320 const gh = yield* GitHubCli.GitHubCli;
321 const lookups = yield* Effect.all(
322 Array.from({ length: 26 }, (_, index) =>
323 gh.listPullRequestsByHead({
324 cwd: "/repo",
325 headSelector: `feature/${index}`,
326 state: "all",
327 limit: 100,
328 rateLimitHost: "github.com",
329 }),
330 ),
331 { concurrency: "unbounded" },
332 ).pipe(Effect.forkChild);
333 yield* TestClock.adjust("500 millis");
334 yield* Fiber.join(lookups);
335 assert.deepStrictEqual(
336 headCounts.toSorted((a, b) => a - b),
337 [1, 25],
338 );
339 }).pipe(Effect.provide(layer));
340 });
341
301342 it.effect("matches an owner:branch selector on the head owner", () => {
302343 const { layer } = harness({
303344 remotes,
......@@ -355,10 +396,10 @@ describe("GitHubCli.listPullRequestsByHead", () => {
355396 .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 })
356397 .pipe(Effect.flip, Effect.forkChild);
357398 const missing = yield* read("missing");
358 yield* TestClock.adjust("50 millis");
399 yield* TestClock.adjust("500 millis");
359400 assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError");
360401 const limited = yield* read("limited");
361 yield* TestClock.adjust("50 millis");
402 yield* TestClock.adjust("500 millis");
362403 const error = yield* Fiber.join(limited);
363404 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
364405 assert.propertyVal(error, "retryAt", 123);
apps/server/src/sourceControl/GitHubCli.ts+16-3
......@@ -352,8 +352,17 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50;
352352/**
353353 * How long a head lookup waits for company. Branch discovery reaches GitHub only after each
354354 * branch's own git reads, so lookups started together arrive tens of milliseconds apart.
355 * A background sweep's lookups spread over up to ~300ms, and every document costs a point no
356 * matter how few heads it holds, so reads without reserve wait longer.
355357 */
356358const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
359const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis";
360/**
361 * Background documents fill up under the longer window, and a failed document fails every head
362 * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own
363 * processing limit; twenty-five took ~7s.
364 */
365const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
357366/** A full document is 5,000 rows of well under 2 KB each. */
358367const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000;
359368/**
......@@ -705,9 +714,13 @@ export const make = Effect.gen(function* () {
705714 ),
706715 );
707716 },
708 }).pipe(
717 }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT));
718 const interactiveHeadResolver = headResolver.pipe(
709719 RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW),
710 RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),
720 );
721 const backgroundHeadResolver = headResolver.pipe(
722 RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT),
723 RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW),
711724 );
712725
713726 const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: {
......@@ -733,7 +746,7 @@ export const make = Effect.gen(function* () {
733746 limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit,
734747 allowReserve: input.allowReserve,
735748 }),
736 headResolver,
749 input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver,
737750 );
738751 if (!ownerMatch) return rows;
739752 const headOwner = ownerMatch[1]!.toLowerCase();