Skip to content

Commit db3fe36

Browse files
authored
perf(queue): claim the actuation lock before the refresh it exists to prevent (#10181)
The publish-and-maintain pass refreshed PR details and THEN asked "does another pass already own this PR", so every contended pass did the work and threw it away. github_app.pr_public_surface_lock_contended is the single most frequent audit event on the production Orb: 1,180 occurrences between 09:00 and 10:53 today, roughly 10 per minute and about twice the next event. Each one is a discarded pass. The cost is real but worth stating precisely. refreshPullRequestDetails is itself cached -- it consults the detail-sync state and reuses stored pull_request_files rows when the last sync covered the current head SHA -- so a contention does not always cost a GitHub call. It always costs the sync-state reads, and on a cache miss it costs a token fetch plus the files/reviews fetch. That miss is what a busy PR produces, and a busy PR is also what contends, so the two peak together. This happened in the same window the installation exhausted its REST quota and 66 queue jobs stalled behind deferred_by: rate_limit. Claiming first changes no semantics: the lock's stated purpose (#9013) is to make "does another pass already own this PR" one question with one answer for the whole publish-then-maintain unit, and asking it before the expensive part is strictly better. Holding it across the refresh is already safe -- #9467 renews the lock while work runs precisely because this unit can span an AI review far longer than a refresh. The second contention site is deliberately untouched: it does not refresh beforehand, so it does not have this defect. Guarded by a test asserting SOURCE ORDER, which is unusual and deliberate. Both orderings behave identically on the happy path and differ only in what a LOSING pass spends before it throws, so no behavioural test can distinguish them -- which is why this drifted unnoticed. The test anchors on the publish pass's own contention audit event so an unrelated claim elsewhere in this 16k-line file cannot satisfy it, and asserts both landmarks still exist so a rename cannot make it pass vacuously. Closes #10174
1 parent 515de46 commit db3fe36

2 files changed

Lines changed: 73 additions & 17 deletions

File tree

src/queue/processors.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4397,23 +4397,13 @@ export async function reReviewStoredPullRequest(
43974397
scopedLinkedIssueClaimedAt,
43984398
});
43994399
await persistAdvisory(env, advisory);
4400-
// #2537 follow-up (gate-flagged): the durable review cache's only invalidation path is markPullRequestReviewsInvalidated
4401-
// on a webhook (processors.ts). A "quiet" PR (no new pushes, slop evidence + manifest gate both off, no
4402-
// pre-merge check paths) never hits any of the three reasons below, so a DROPPED invalidation write could sit
4403-
// stale indefinitely even though this per-PR sweep unit visits every open PR on a bounded cadence.
4404-
// Short-circuit the extra read when another reason already forces the refresh.
4405-
const otherRefreshReasons =
4406-
shouldCollectSlopEvidence(settings) ||
4407-
settings.manifestPolicyGateMode !== "off" ||
4408-
(await shouldRefreshFilesForPreMergeChecks(env, repoFullName));
4409-
const reviewsCacheStale =
4410-
!otherRefreshReasons &&
4411-
!isReviewsCacheUpToDate(await getPullRequestDetailSyncState(env, repoFullName, prNumber).catch(() => null));
4412-
if (otherRefreshReasons || reviewsCacheStale) {
4413-
await refreshPullRequestDetails(env, repoFullName, prNumber).catch(
4414-
() => undefined,
4415-
);
4416-
}
4400+
// #10174: the lock is claimed BEFORE the refresh below, not after. It answers "does another pass
4401+
// already own this PR" -- asking that only after paying for the refresh meant every contended pass did
4402+
// the work and threw it away. That was the single most frequent audit event on the Orb (1,180
4403+
// github_app.pr_public_surface_lock_contended in under two hours, ~2x the next event), and the refresh
4404+
// it wasted is a GitHub read whenever the detail-sync cache misses -- which is exactly what a busy PR
4405+
// does, and a busy PR is also what contends. Holding the lock across the refresh is already safe: #9467
4406+
// renews it while work runs, because this unit can span an AI review far longer than a refresh.
44174407
// #9013: ONE per-PR actuation-lock claim spans the publish pass AND the maintenance pass right after it.
44184408
// maybePublishPrPublicSurface used to run with no lock at all -- only the LATER maybeRunAgentMaintenance
44194409
// claimed one -- so two concurrent passes for the SAME PR (this sweep re-review racing a webhook delivery,
@@ -4438,6 +4428,23 @@ export async function reReviewStoredPullRequest(
44384428
}).catch(() => undefined);
44394429
throw new PrActuationLockContendedError(repoFullName, pr.number, "public-surface-publish");
44404430
}
4431+
// #2537 follow-up (gate-flagged): the durable review cache's only invalidation path is markPullRequestReviewsInvalidated
4432+
// on a webhook (processors.ts). A "quiet" PR (no new pushes, slop evidence + manifest gate both off, no
4433+
// pre-merge check paths) never hits any of the three reasons below, so a DROPPED invalidation write could sit
4434+
// stale indefinitely even though this per-PR sweep unit visits every open PR on a bounded cadence.
4435+
// Short-circuit the extra read when another reason already forces the refresh.
4436+
const otherRefreshReasons =
4437+
shouldCollectSlopEvidence(settings) ||
4438+
settings.manifestPolicyGateMode !== "off" ||
4439+
(await shouldRefreshFilesForPreMergeChecks(env, repoFullName));
4440+
const reviewsCacheStale =
4441+
!otherRefreshReasons &&
4442+
!isReviewsCacheUpToDate(await getPullRequestDetailSyncState(env, repoFullName, prNumber).catch(() => null));
4443+
if (otherRefreshReasons || reviewsCacheStale) {
4444+
await refreshPullRequestDetails(env, repoFullName, prNumber).catch(
4445+
() => undefined,
4446+
);
4447+
}
44414448
// #9467: this lock now spans the WHOLE publish -> AI review -> maintain unit (#9013 moved the claim here),
44424449
// and the AI review alone can outlive the 600s TTL. Renew it while the work runs so a slow-but-healthy pass
44434450
// cannot have its lock claimed out from under it mid-flight. Compare-and-extend, so if this pass has already
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
import { describe, expect, it } from "vitest";
5+
6+
// #10174: the publish-and-maintain pass must claim the actuation lock BEFORE doing the work the lock exists
7+
// to avoid duplicating.
8+
//
9+
// It used to refresh PR details first and ask "does another pass already own this?" second, so every
10+
// contended pass paid for the refresh and threw it away. That was the single most frequent audit event on the
11+
// production Orb -- 1,180 `github_app.pr_public_surface_lock_contended` in under two hours, about twice the
12+
// next event -- and `refreshPullRequestDetails` is a GitHub read whenever the detail-sync cache misses, which
13+
// is exactly what a busy PR does. Busy PRs are also what contend, so the two peak together. It happened in
14+
// the same window the installation exhausted its REST quota.
15+
//
16+
// Asserted structurally, on source order, because there is no behavioural seam here: both orderings produce
17+
// identical results on the happy path and differ only in what a LOSING pass spends before it throws. A unit
18+
// test that exercised the pass could not tell them apart, which is precisely why this drifted unnoticed.
19+
20+
const SOURCE = readFileSync(join(import.meta.dirname, "..", "..", "src", "queue", "processors.ts"), "utf8");
21+
22+
describe("actuation lock ordering (#10174)", () => {
23+
it("sanity: both landmarks still exist, so a rename cannot make this pass vacuously", () => {
24+
expect(SOURCE).toContain("claimPrActuationLock");
25+
expect(SOURCE).toContain("refreshPullRequestDetails");
26+
});
27+
28+
it("REGRESSION: the publish pass claims the lock before refreshing PR details", () => {
29+
// Scoped to the publish-and-maintain pass by anchoring on its own contention audit event, so the
30+
// assertion cannot be satisfied by some unrelated earlier claim elsewhere in this 16k-line file.
31+
const contention = SOURCE.indexOf('eventType: "github_app.pr_public_surface_lock_contended"');
32+
expect(contention).toBeGreaterThan(-1);
33+
34+
const claimBefore = SOURCE.lastIndexOf("claimPrActuationLock", contention);
35+
expect(claimBefore).toBeGreaterThan(-1);
36+
37+
// The refresh must come AFTER that claim, not before it.
38+
const refreshAfterClaim = SOURCE.indexOf("refreshPullRequestDetails(env, repoFullName, prNumber)", claimBefore);
39+
const refreshBeforeClaim = SOURCE.lastIndexOf("refreshPullRequestDetails(env, repoFullName, prNumber)", claimBefore);
40+
41+
expect(refreshAfterClaim, "the refresh should follow the lock claim").toBeGreaterThan(claimBefore);
42+
// And there must be no refresh sitting between the advisory persist and the claim.
43+
const persist = SOURCE.lastIndexOf("await persistAdvisory(env, advisory)", claimBefore);
44+
expect(
45+
refreshBeforeClaim < persist,
46+
"refreshPullRequestDetails must not run between persistAdvisory and the lock claim — that is the wasted work this fixes",
47+
).toBe(true);
48+
});
49+
});

0 commit comments

Comments
 (0)