Skip to content

Commit f282f34

Browse files
committed
feat(github): cache bare PR reads and reviews, mirroring the head-SHA sync-state pattern
Closes #2537. GET /pulls/{n} (PR state/mergeable_state) and GET /pulls/{n}/reviews were the largest and third-largest GitHub REST contributors observed in a live rate-limit trough, and neither was covered by the earlier PR-files cache (#audit-rate-headroom / #2527). The bare-PR-state read was implemented as four near-identical helpers with no caching or coalescing, called from readiness checks, the maintenance planner, duplicate-sibling reconciliation, and a gate-override command; reviews were explicitly left uncached ("more volatile than files" -- true at a fixed head, but reviews only actually change on a pull_request_review webhook). Extends the existing pull_request_detail_sync_state table (migration 0093) with prMergeableState/prState/prStateFetchedAt, and reuses the already-present reviewsSyncedAt column as the review cache's freshness marker. PR-state is event-invalidated on pull_request synchronize/closed/reopened (with a 5-minute safety-net TTL so a missed webhook self-heals within one sweep tick); reviews are invalidated on pull_request_review submitted/edited/dismissed rather than on head-SHA change, since reviews are independent of the head. The pre-merge verdict thread and its unified-comment mirror (the #4220 act-boundary) are deliberately left routed through the raw, uncached fetch -- they must always force-refetch live, since the stored/cached mergeable_state lagging GitHub's async recompute is exactly what caused #4220. This is verified by two dedicated regression tests plus direct mutation testing (temporarily rerouting the act-boundary through the cache and confirming both tests -- and no others in the suite -- catch it).
1 parent f8de661 commit f282f34

9 files changed

Lines changed: 1182 additions & 21 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
ALTER TABLE pull_request_detail_sync_state
2+
ADD COLUMN pr_mergeable_state TEXT;
3+
4+
ALTER TABLE pull_request_detail_sync_state
5+
ADD COLUMN pr_state TEXT;
6+
7+
ALTER TABLE pull_request_detail_sync_state
8+
ADD COLUMN pr_state_fetched_at TEXT;

src/db/repositories.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,8 +1133,10 @@ export async function getRepoQueueTrendSnapshot(env: Env, repoFullName: string):
11331133
// drizzle's `onConflictDoUpdate` strips `undefined` entries from the generated SQL `SET` clause rather than
11341134
// writing NULL. Every "running" pre-fetch stamp (backfill.ts) relies on this to touch only `status` without
11351135
// clearing the PREVIOUS `headSha`/`*SyncedAt` row — including the repo+PR+headSha file cache
1136-
// (#audit-rate-headroom), which would silently stop hitting if a future edit here coalesced an omitted field to
1137-
// `null` (e.g. `headSha: state.headSha ?? null`). Pass `null` explicitly to actually clear a column.
1136+
// (#audit-rate-headroom) and the durable PR-state / review caches (#2537), which would silently stop hitting if a
1137+
// future edit here coalesced an omitted field to `null` (e.g. `headSha: state.headSha ?? null`). Pass `null`
1138+
// explicitly to actually clear a column (this is exactly how webhook invalidation clears prMergeableState/
1139+
// prState/reviewsSyncedAt below).
11381140
export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequestDetailSyncStateRecord): Promise<void> {
11391141
const db = getDb(env.DB);
11401142
await db
@@ -1150,6 +1152,9 @@ export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequ
11501152
checksSyncedAt: state.checksSyncedAt,
11511153
lastSyncedAt: state.lastSyncedAt,
11521154
errorSummary: state.errorSummary,
1155+
prMergeableState: state.prMergeableState,
1156+
prState: state.prState,
1157+
prStateFetchedAt: state.prStateFetchedAt,
11531158
updatedAt: nowIso(),
11541159
})
11551160
.onConflictDoUpdate({
@@ -1162,6 +1167,9 @@ export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequ
11621167
checksSyncedAt: state.checksSyncedAt,
11631168
lastSyncedAt: state.lastSyncedAt,
11641169
errorSummary: state.errorSummary,
1170+
prMergeableState: state.prMergeableState,
1171+
prState: state.prState,
1172+
prStateFetchedAt: state.prStateFetchedAt,
11651173
updatedAt: nowIso(),
11661174
},
11671175
});
@@ -4217,6 +4225,9 @@ function toPullRequestDetailSyncStateRecord(row: typeof pullRequestDetailSyncSta
42174225
checksSyncedAt: row.checksSyncedAt,
42184226
lastSyncedAt: row.lastSyncedAt,
42194227
errorSummary: row.errorSummary,
4228+
prMergeableState: row.prMergeableState,
4229+
prState: row.prState,
4230+
prStateFetchedAt: row.prStateFetchedAt,
42204231
updatedAt: row.updatedAt,
42214232
};
42224233
}

src/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,13 @@ export const pullRequestDetailSyncState = sqliteTable(
231231
checksSyncedAt: text("checks_synced_at"),
232232
lastSyncedAt: text("last_synced_at"),
233233
errorSummary: text("error_summary"),
234+
// Durable bare-PR-state cache (#2537): mirrors GET /pulls/{n}'s mutable state/mergeable_state, refreshed on
235+
// synchronize/closed/reopened webhooks and read by the freshness-guard/readiness/dup-winner/gate-override
236+
// call sites that don't need the disposition's own live-recompute guarantee. NEVER read by the act-boundary
237+
// merge/close decision (planAgentMaintenanceActions / the unified-comment mirror), which always force-refetches.
238+
prMergeableState: text("pr_mergeable_state"),
239+
prState: text("pr_state"),
240+
prStateFetchedAt: text("pr_state_fetched_at"),
234241
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
235242
},
236243
(table) => ({

src/github/backfill.ts

Lines changed: 197 additions & 16 deletions
Large diffs are not rendered by default.

src/queue/processors.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ import {
7575
backfillOpenPullRequestDetails,
7676
backfillRegisteredRepositories,
7777
backfillRepositorySegment,
78+
cachedFetchLivePullRequestHeadSha,
79+
cachedFetchLivePullRequestMergeState,
80+
cachedFetchLivePullRequestState,
7881
enqueueRepositoryOpenDataBackfill,
7982
fetchAndStorePullRequestFilesForReview,
8083
fetchLinkedIssueFacts,
@@ -89,6 +92,9 @@ import {
8992
fetchLivePullRequestState,
9093
fetchOpenPullRequestNumbersForCommit,
9194
fetchRequiredStatusContexts,
95+
invalidatePrReviewsCache,
96+
invalidatePrStateCache,
97+
primeDurablePrStateCache,
9298
refreshContributorActivity,
9399
refreshInstallationHealth,
94100
refreshPullRequestDetails,
@@ -613,15 +619,24 @@ function cachedLiveMergeState(
613619
const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token));
614620
const cached = facts.mergeStates.get(key);
615621
if (cached) return cached;
622+
// #2537: on a request-local miss, check the DURABLE cross-webhook cache before hitting GitHub — this is the
623+
// readiness/freshness-guard path, not the act-boundary disposition (that's refreshLiveMergeState below, which
624+
// NEVER routes through the durable cache). A durable hit is itself memoized request-locally for the rest of
625+
// this pass via facts.mergeStates, same as a live fetch would be.
616626
const next = evictLiveFactOnReject(
617627
facts.mergeStates,
618628
key,
619-
fetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey),
629+
cachedFetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey),
620630
);
621631
facts.mergeStates.set(key, next);
622632
return next;
623633
}
624634

635+
// #4220 contradiction: the stored pr.mergeableState lags GitHub's async recompute, so a base-conflicting PR could
636+
// read clean here (safe to merge) while the disposition reads the live dirty and auto-CLOSES it. This ALWAYS
637+
// force-refetches live from GitHub and MUST NEVER be routed through the durable pull_request_detail_sync_state
638+
// cache added by #2537 — both act-boundary-adjacent callers (runAgentMaintenancePlanAndExecute's disposition
639+
// input, and the unified-comment mirror) depend on this staying live and uncached.
625640
function refreshLiveMergeState(
626641
env: Env,
627642
repoFullName: string,
@@ -2154,6 +2169,10 @@ async function reReviewStoredPullRequest(
21542169
resyncAdmissionKey,
21552170
);
21562171
primeLiveMergeState(liveFacts, repoFullName, prNumber, resyncToken, live?.mergeable_state);
2172+
// #2537: this resync ALREADY paid for a bare GET /pulls/{n} — persist it to the durable cross-webhook cache so
2173+
// the readiness/dup-winner/gate-override readers below (and future webhook deliveries) don't re-fetch it.
2174+
// Best-effort, never blocks the sweep on a write hiccup.
2175+
await primeDurablePrStateCache(env, repoFullName, prNumber, live).catch(() => undefined);
21572176
// Terminal early-exit (#1942): the PR is CLOSED/merged on GitHub even though the stored row still reads open — a
21582177
// dropped `closed` webhook (relay down). Reconcile the stored row from the live payload and RETURN before the
21592178
// expensive resync (files) + readiness + re-review reads. A stale sweep must never spend GitHub budget — or post
@@ -4082,6 +4101,14 @@ async function processGitHubWebhook(
40824101
repoFullName,
40834102
payload.pull_request,
40844103
);
4104+
// #2537: the durable PR-state cache (mergeable_state/state) goes stale exactly when GitHub recomputes them —
4105+
// synchronize (new head → new mergeable_state recompute), closed (state flips), reopened (state flips back).
4106+
// Clear explicitly (null, not omitted — PARTIAL-UPDATE CONTRACT) so the next cached read is a forced live
4107+
// miss; other pull_request actions (labeled, edited, etc.) don't change these fields and are left untouched
4108+
// to avoid spurious cache churn / extra writes on high-frequency low-signal actions.
4109+
if (eventName === "pull_request" && (payload.action === "synchronize" || payload.action === "closed" || payload.action === "reopened")) {
4110+
await invalidatePrStateCache(env, repoFullName, pr.number).catch(() => undefined);
4111+
}
40854112
// Reopen-prevention (#one-shot-reopen): a CONTRIBUTOR may not reopen a PR that gittensory or a maintainer
40864113
// closed — closes are one-shot (resubmit, don't reopen). If a non-maintainer reopened a PR whose last close
40874114
// was by the bot / repo owner / admin, re-close it and skip the re-review. Self-closes (the contributor
@@ -4179,6 +4206,14 @@ async function processGitHubWebhook(
41794206
installationId &&
41804207
shouldProcessPullRequestPublicSurface(eventName, payload.action)
41814208
) {
4209+
// #2537: reviews only actually change on a pull_request_review webhook (submitted/edited/dismissed) — not
4210+
// on every sweep tick / unrelated pull_request action — so invalidate the durable review cache HERE,
4211+
// before the refreshPullRequestDetails call below reads it, rather than on head-SHA change (reviews are
4212+
// independent of head). shouldProcessPullRequestPublicSurface already gates pull_request_review to
4213+
// exactly these 3 actions.
4214+
if (eventName === "pull_request_review") {
4215+
await invalidatePrReviewsCache(env, repoFullName, pr.number).catch(() => undefined);
4216+
}
41824217
if (
41834218
shouldCollectSlopEvidence(settings) ||
41844219
settings.manifestPolicyGateMode !== "off" ||
@@ -5472,7 +5507,9 @@ export async function reconcileLiveDuplicateSiblings(
54725507
const staleClosed = new Set<number>();
54735508
await Promise.all(
54745509
lowerOverlapping.map(async (sibling) => {
5475-
const liveState = await fetchLivePullRequestState(
5510+
// #2537: durable-cached — this is a non-authoritative reconcile read (not the act-boundary decision), so
5511+
// repeat calls across webhook deliveries for an unchanged sibling can reuse the cross-webhook cache.
5512+
const liveState = await cachedFetchLivePullRequestState(
54765513
env,
54775514
repoFullName,
54785515
sibling.number,
@@ -7247,6 +7284,8 @@ async function recordGithubProductUsage(
72477284
* THAT commit (the neutral check-run is per-commit by design). FAIL-OPEN: an unreadable live fetch returns the
72487285
* cached head, so a transient GitHub hiccup never strands the override — it just targets the stored SHA as before.
72497286
* Mirrors the rebase path's live re-fetch (prReadyForReview) and the dup-winner live reconcile.
7287+
* #2537: durable-cached — a separate `issue_comment` webhook handler with no request-local `liveFacts`, so the
7288+
* cross-webhook cache is a pure win here (not the act-boundary merge/close decision).
72507289
*/
72517290
export async function resolveOverrideHeadSha(
72527291
env: Env,
@@ -7259,7 +7298,7 @@ export async function resolveOverrideHeadSha(
72597298
() => undefined,
72607299
)) ?? env.GITHUB_PUBLIC_TOKEN;
72617300
const admissionKey = githubAdmissionKeyForToken(env, installationId, token);
7262-
const liveHeadSha = await fetchLivePullRequestHeadSha(
7301+
const liveHeadSha = await cachedFetchLivePullRequestHeadSha(
72637302
env,
72647303
repoFullName,
72657304
pr.number,

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,10 @@ export type PullRequestDetailSyncStateRecord = {
867867
lastSyncedAt?: string | null | undefined;
868868
errorSummary?: string | null | undefined;
869869
updatedAt?: string | null | undefined;
870+
// #2537: durable bare-PR-state cache fields (mergeable_state/state from GET /pulls/{n}).
871+
prMergeableState?: string | null | undefined;
872+
prState?: string | null | undefined;
873+
prStateFetchedAt?: string | null | undefined;
870874
};
871875

872876
export type GitHubRateLimitObservationRecord = {

0 commit comments

Comments
 (0)