mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user