fix(server): batch cold-start PR status polls (#4025)

The initial retained poll could run before repository identity discovery settled, routing every workspace through the legacy per-target path. Defer each unresolved target independently and rejoin the shared polling grid once its identity is ready.
This commit is contained in:
Mohamed Boudra
2026-08-29 12:23:47 +02:00
committed by GitHub
parent 7080648e17
commit aeb240be98
3 changed files with 164 additions and 5 deletions
@@ -1081,6 +1081,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
stderr: "",
})),
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
now: () => nowMs,
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
@@ -1186,6 +1188,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
now: () => nowMs,
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
@@ -1207,7 +1211,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
await vi.advanceTimersByTimeAsync(0);
await vi.waitFor(() => {
expect(runner).toHaveBeenCalledTimes(1);
expect(githubReadCalls).toContainEqual({
reason: "self-heal-github",
tickMs: 0,
});
});
await flushPromises();
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
@@ -7,6 +7,7 @@ import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
GITHUB_POLL_ALIGNMENT_MS,
computeGithubNextInterval,
createGitHubService,
parseStatusCheckRollup,
@@ -876,6 +877,8 @@ describe("ForgeService", () => {
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
});
const reads = recordCurrentPullRequestStatusReads(service);
@@ -885,6 +888,7 @@ describe("ForgeService", () => {
headRepositoryOwner: "fork-owner",
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(reads).toEqual([
expect.objectContaining({
@@ -1158,6 +1162,138 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("batches the first PR status poll after repository identity resolves", async () => {
let now = 0;
let resolveRepositoryIdentity!: () => void;
const repositoryIdentityReady = new Promise<void>((resolve) => {
resolveRepositoryIdentity = resolve;
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
headRefName: "feat-a",
headRefOid: "oid-a",
}),
]),
t1: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 52,
url: "https://github.com/acme/gadgets/pull/52",
headRefName: "feat-b",
headRefOid: "oid-b",
}),
]),
}),
batchPollStatusJson({
t0: batchPollChecksAliasJson([]),
t1: batchPollChecksAliasJson([]),
}),
]);
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => {
await repositoryIdentityReady;
return null;
},
resolveRepoSlug: async (cwd) => {
await repositoryIdentityReady;
return slugs[cwd] ?? null;
},
now: () => now,
});
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-a",
});
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-b",
headSha: "oid-b",
});
vi.advanceTimersByTime(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(0);
resolveRepositoryIdentity();
await flushMicrotasks();
now = GITHUB_POLL_ALIGNMENT_MS;
await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")');
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
it("does not let one unresolved repository identity block ready poll targets", async () => {
let now = 0;
const neverResolves = new Promise<string | null>(() => {});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
state: "OPEN",
mergedAt: null,
headRefName: "feat-ready",
headRefOid: "oid-ready",
}),
]),
}),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : null),
resolveRepoSlug: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : "acme/widgets"),
now: () => now,
});
const readyStatuses: Array<CurrentPullRequestStatus | null> = [];
const blockedSubscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-blocked",
headRef: "feat-blocked",
});
const readySubscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-ready",
headRef: "feat-ready",
headSha: "oid-ready",
onStatus: (status) => readyStatuses.push(status),
});
vi.advanceTimersByTime(0);
await flushMicrotasks();
now = GITHUB_POLL_ALIGNMENT_MS;
await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
expect(readyStatuses).toEqual([expect.objectContaining({ number: 41 })]);
blockedSubscription?.unsubscribe();
readySubscription?.unsubscribe();
service.dispose?.();
});
it("refetches checks for open PRs on every batched poll at fast cadence", async () => {
let now = 0;
const openNode = batchPollPrNodeJson({
+20 -4
View File
@@ -1471,13 +1471,29 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const legacyTargets: GitHubPollTarget[] = [];
const batchGroups = new Map<string, GitHubBatchPollEntry[]>();
for (const target of due) {
if (target.retainCount <= 0) {
continue;
}
const host = peekRepoHost(target.cwd);
const slug = peekRepoSlug(target.cwd);
if (!host.settled || !slug.settled) {
target.pollCycleStartedAt = null;
void Promise.all([
resolveRepoHostCached(target.cwd).catch(() => null),
resolveRepoSlugCached(target.cwd),
]).then(() => {
if (target.retainCount > 0 && target.nextDueAt === null) {
// A non-zero delay rejoins the shared polling grid.
scheduleGitHubPollAfter(target, 1);
}
return undefined;
});
continue;
}
const [owner, name, extra] = slug.value?.split("/") ?? [];
// A cwd whose host or slug is still resolving (or has no origin slug at
// all) polls through the legacy per-target path this cycle; batch
// grouping needs both facts synchronously.
if (!host.settled || !slug.settled || !owner || !name || extra !== undefined) {
// A cwd with no origin slug polls through the legacy per-target path;
// batch grouping needs a complete owner/name identity.
if (!owner || !name || extra !== undefined) {
legacyTargets.push(target);
continue;
}