Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 53 additions & 12 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ describe("GitHubCli repository resolution", () => {
describe("GitHubCli.listPullRequestsByHead", () => {
const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]);

it.effect("reads heads on one repository in one GraphQL document", () => {
it.effect("reads a background sweep's staggered heads in one GraphQL document", () => {
const documents: Array<GitHubApi.GitHubGraphQlInput> = [];
const { layer } = harness({
remotes,
Expand All @@ -267,20 +267,23 @@ describe("GitHubCli.listPullRequestsByHead", () => {
});
return Effect.gen(function* () {
const gh = yield* GitHubCli.GitHubCli;
const lookups = yield* Effect.all(
["feature/a", "feature/b"].map((headSelector) =>
gh.listPullRequestsByHead({
const lookup = (headSelector: string) =>
gh
.listPullRequestsByHead({
cwd: "/repo",
headSelector,
state: "all",
limit: 100,
rateLimitHost: "github.com",
}),
),
{ concurrency: "unbounded" },
).pipe(Effect.forkChild);
yield* TestClock.adjust("50 millis");
const [first, second] = yield* Fiber.join(lookups);
})
.pipe(Effect.forkChild);
// Each branch's own git reads come first, so a sweep's lookups arrive spread out.
const firstLookup = yield* lookup("feature/a");
yield* TestClock.adjust("200 millis");
const secondLookup = yield* lookup("feature/b");
yield* TestClock.adjust("300 millis");
const first = yield* Fiber.join(firstLookup);
const second = yield* Fiber.join(secondLookup);
assert.deepStrictEqual(
first?.map((pr) => pr.number),
[7],
Expand All @@ -298,6 +301,44 @@ describe("GitHubCli.listPullRequestsByHead", () => {
}).pipe(Effect.provide(layer));
});

it.effect("caps a background document at twenty-five heads", () => {
const headCounts: Array<number> = [];
const { layer } = harness({
remotes,
api: {
graphql: (input) =>
Effect.sync(() => {
const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key));
headCounts.push(heads.length);
return encodeJson({
data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) },
});
}),
},
});
return Effect.gen(function* () {
const gh = yield* GitHubCli.GitHubCli;
const lookups = yield* Effect.all(
Array.from({ length: 26 }, (_, index) =>
gh.listPullRequestsByHead({
cwd: "/repo",
headSelector: `feature/${index}`,
state: "all",
limit: 100,
rateLimitHost: "github.com",
}),
),
{ concurrency: "unbounded" },
).pipe(Effect.forkChild);
yield* TestClock.adjust("500 millis");
yield* Fiber.join(lookups);
assert.deepStrictEqual(
headCounts.toSorted((a, b) => a - b),
[1, 25],
);
}).pipe(Effect.provide(layer));
});

it.effect("matches an owner:branch selector on the head owner", () => {
const { layer } = harness({
remotes,
Expand Down Expand Up @@ -355,10 +396,10 @@ describe("GitHubCli.listPullRequestsByHead", () => {
.listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 })
.pipe(Effect.flip, Effect.forkChild);
const missing = yield* read("missing");
yield* TestClock.adjust("50 millis");
yield* TestClock.adjust("500 millis");
assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError");
const limited = yield* read("limited");
yield* TestClock.adjust("50 millis");
yield* TestClock.adjust("500 millis");
const error = yield* Fiber.join(limited);
assert.strictEqual(error._tag, "GitHubCliRateLimitError");
assert.propertyVal(error, "retryAt", 123);
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,17 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50;
/**
* How long a head lookup waits for company. Branch discovery reaches GitHub only after each
* branch's own git reads, so lookups started together arrive tens of milliseconds apart.
* A background sweep's lookups spread over up to ~300ms, and every document costs a point no
* matter how few heads it holds, so reads without reserve wait longer.
*/
const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis";
/**
* Background documents fill up under the longer window, and a failed document fails every head
* in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own
* processing limit; twenty-five took ~7s.
*/
const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
/** A full document is 5,000 rows of well under 2 KB each. */
const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000;
/**
Expand Down Expand Up @@ -705,9 +714,13 @@ export const make = Effect.gen(function* () {
),
);
},
}).pipe(
}).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT));
const interactiveHeadResolver = headResolver.pipe(
RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW),
RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),
);
const backgroundHeadResolver = headResolver.pipe(
RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT),
RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW),
);

const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: {
Expand All @@ -733,7 +746,7 @@ export const make = Effect.gen(function* () {
limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit,
allowReserve: input.allowReserve,
}),
headResolver,
input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver,
);
if (!ownerMatch) return rows;
const headOwner = ownerMatch[1]!.toLowerCase();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Loading