From 640f18404ccc7cd385b996d3dd72ae6cf7661d12 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:40:27 -0700 Subject: [PATCH] fix(queue): bound issue-linked re-gate fanout --- src/queue/processors.ts | 11 ++++++----- test/unit/queue.test.ts | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 92e3ed89cd..c97eb1867b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4290,8 +4290,8 @@ async function maybeInvalidateCiCacheOnLegacyCiEvent( * Wake linked PRs on an issue-side signal (#2259). Labeling/unlabeling (e.g. maintainer-only) or * assigning/unassigning on a linked ISSUE can flip a linked-issue hard-rule verdict, but that only gets * re-evaluated when the PR ITSELF receives a webhook or the staleness-ordered sweep eventually reaches it — - * which can lag for many cycles on a repo with more than a few open PRs. Enqueue staggered per-PR re-gate jobs - * for EVERY open PR that links this issue instead of doing the expensive live re-review inline. + * which can lag for many cycles on a repo with more than a few open PRs. Enqueue a bounded, staggered batch of + * per-PR re-gate jobs instead of doing the expensive live re-review inline. * Uses its OWN coalesce window (issueLinkedPrReReviewCoalesced, * DISTINCT from CI-completion's — #2371): the two triggers are not interchangeable, so a shared window let an * unrelated CI re-review silently suppress a genuinely different issue-side signal. Within the issue-side @@ -4320,11 +4320,12 @@ async function maybeReReviewOnLinkedIssueChange( if (isConvergenceRepoAllowed(env, repoFullName)) { const openPullRequests = await listOpenPullRequests(env, repoFullName); // Issue-side label/assignment changes can flip linked-issue hard-rule verdicts from mergeable to close. - // Wake every linked PR promptly: a silent tail can keep stale passing gate/check state after the issue flips - // to an ineligible hard-rule state. Stagger the jobs so the expensive re-gates do not run inline or stampede - // the queue consumer. + // Wake a bounded prompt batch: unbounded issue-side fan-out can turn one webhook into thousands of + // foreground re-gates, exhausting queue, REST, and AI-review budgets. The regular stale sweep continues to + // converge any tail while preserving the same SWEEP_MAX_PRS source budget used by rate-aware fan-out. const linkingPrs = openPullRequests .filter((pr) => pr.linkedIssues.includes(issueNumber)) + .slice(0, SWEEP_MAX_PRS) .map((pr) => ({ number: pr.number, createdAt: pr.createdAt ?? null })); for (const [index, pr] of linkingPrs.entries()) { const prNumber = pr.number; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 4eaa7612a0..1f89fec5b0 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -2956,7 +2956,7 @@ describe("queue processors", () => { ]); }); - it("REGRESSION: issue-side linked PR wake re-gates every linked PR instead of leaving a stale tail", async () => { + it("REGRESSION: issue-side linked PR wake keeps fan-out bounded when many PRs link the same issue", async () => { const sent: Array<{ message: import("../../src/types").JobMessage; options?: QueueSendOptions }> = []; const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), @@ -2981,7 +2981,7 @@ describe("queue processors", () => { await processJob(env, { type: "github-webhook", - deliveryId: "issue-label-all-linked-fanout", + deliveryId: "issue-label-bounded-linked-fanout", eventName: "issues", payload: { action: "labeled", @@ -2993,13 +2993,13 @@ describe("queue processors", () => { }); expect(fetchCount).toBe(0); - expect(sent).toHaveLength(SWEEP_MAX_PRS + 2); + expect(sent).toHaveLength(SWEEP_MAX_PRS); expect(sent.map(({ message }) => message)).toEqual( - Array.from({ length: SWEEP_MAX_PRS + 2 }, (_, index) => + Array.from({ length: SWEEP_MAX_PRS }, (_, index) => expect.objectContaining({ type: "agent-regate-pr", repoFullName: "owner/agent-repo", prNumber: index + 1, installationId: 9001 }), ), ); - expect(sent.map(({ options }) => options)).toEqual([undefined, { delaySeconds: 10 }, { delaySeconds: 20 }, { delaySeconds: 30 }, { delaySeconds: 40 }]); + expect(sent.map(({ options }) => options)).toEqual([undefined, { delaySeconds: 10 }, { delaySeconds: 20 }]); }); it("REGRESSION (#2371): a coalesced issue-side signal schedules a trailing re-review so an add-then-remove sequence is never lost", async () => {