Skip to content

Commit bbfcd04

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 84b9a50 commit bbfcd04

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
@@ -1138,8 +1138,10 @@ export async function getRepoQueueTrendSnapshot(env: Env, repoFullName: string):
11381138
// drizzle's `onConflictDoUpdate` strips `undefined` entries from the generated SQL `SET` clause rather than
11391139
// writing NULL. Every "running" pre-fetch stamp (backfill.ts) relies on this to touch only `status` without
11401140
// clearing the PREVIOUS `headSha`/`*SyncedAt` row — including the repo+PR+headSha file cache
1141-
// (#audit-rate-headroom), which would silently stop hitting if a future edit here coalesced an omitted field to
1142-
// `null` (e.g. `headSha: state.headSha ?? null`). Pass `null` explicitly to actually clear a column.
1141+
// (#audit-rate-headroom) and the durable PR-state / review caches (#2537), which would silently stop hitting if a
1142+
// future edit here coalesced an omitted field to `null` (e.g. `headSha: state.headSha ?? null`). Pass `null`
1143+
// explicitly to actually clear a column (this is exactly how webhook invalidation clears prMergeableState/
1144+
// prState/reviewsSyncedAt below).
11431145
export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequestDetailSyncStateRecord): Promise<void> {
11441146
const db = getDb(env.DB);
11451147
await db
@@ -1155,6 +1157,9 @@ export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequ
11551157
checksSyncedAt: state.checksSyncedAt,
11561158
lastSyncedAt: state.lastSyncedAt,
11571159
errorSummary: state.errorSummary,
1160+
prMergeableState: state.prMergeableState,
1161+
prState: state.prState,
1162+
prStateFetchedAt: state.prStateFetchedAt,
11581163
updatedAt: nowIso(),
11591164
})
11601165
.onConflictDoUpdate({
@@ -1167,6 +1172,9 @@ export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequ
11671172
checksSyncedAt: state.checksSyncedAt,
11681173
lastSyncedAt: state.lastSyncedAt,
11691174
errorSummary: state.errorSummary,
1175+
prMergeableState: state.prMergeableState,
1176+
prState: state.prState,
1177+
prStateFetchedAt: state.prStateFetchedAt,
11701178
updatedAt: nowIso(),
11711179
},
11721180
});
@@ -4229,6 +4237,9 @@ function toPullRequestDetailSyncStateRecord(row: typeof pullRequestDetailSyncSta
42294237
checksSyncedAt: row.checksSyncedAt,
42304238
lastSyncedAt: row.lastSyncedAt,
42314239
errorSummary: row.errorSummary,
4240+
prMergeableState: row.prMergeableState,
4241+
prState: row.prState,
4242+
prStateFetchedAt: row.prStateFetchedAt,
42324243
updatedAt: row.updatedAt,
42334244
};
42344245
}

src/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,13 @@ export const pullRequestDetailSyncState = sqliteTable(
234234
checksSyncedAt: text("checks_synced_at"),
235235
lastSyncedAt: text("last_synced_at"),
236236
errorSummary: text("error_summary"),
237+
// Durable bare-PR-state cache (#2537): mirrors GET /pulls/{n}'s mutable state/mergeable_state, refreshed on
238+
// synchronize/closed/reopened webhooks and read by the freshness-guard/readiness/dup-winner/gate-override
239+
// call sites that don't need the disposition's own live-recompute guarantee. NEVER read by the act-boundary
240+
// merge/close decision (planAgentMaintenanceActions / the unified-comment mirror), which always force-refetches.
241+
prMergeableState: text("pr_mergeable_state"),
242+
prState: text("pr_state"),
243+
prStateFetchedAt: text("pr_state_fetched_at"),
237244
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
238245
},
239246
(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,
@@ -90,6 +93,9 @@ import {
9093
fetchLivePullRequestState,
9194
fetchOpenPullRequestNumbersForCommit,
9295
fetchRequiredStatusContexts,
96+
invalidatePrReviewsCache,
97+
invalidatePrStateCache,
98+
primeDurablePrStateCache,
9399
refreshContributorActivity,
94100
refreshInstallationHealth,
95101
refreshPullRequestDetails,
@@ -614,15 +620,24 @@ function cachedLiveMergeState(
614620
const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token));
615621
const cached = facts.mergeStates.get(key);
616622
if (cached) return cached;
623+
// #2537: on a request-local miss, check the DURABLE cross-webhook cache before hitting GitHub — this is the
624+
// readiness/freshness-guard path, not the act-boundary disposition (that's refreshLiveMergeState below, which
625+
// NEVER routes through the durable cache). A durable hit is itself memoized request-locally for the rest of
626+
// this pass via facts.mergeStates, same as a live fetch would be.
617627
const next = evictLiveFactOnReject(
618628
facts.mergeStates,
619629
key,
620-
fetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey),
630+
cachedFetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey),
621631
);
622632
facts.mergeStates.set(key, next);
623633
return next;
624634
}
625635

636+
// #4220 contradiction: the stored pr.mergeableState lags GitHub's async recompute, so a base-conflicting PR could
637+
// read clean here (safe to merge) while the disposition reads the live dirty and auto-CLOSES it. This ALWAYS
638+
// force-refetches live from GitHub and MUST NEVER be routed through the durable pull_request_detail_sync_state
639+
// cache added by #2537 — both act-boundary-adjacent callers (runAgentMaintenancePlanAndExecute's disposition
640+
// input, and the unified-comment mirror) depend on this staying live and uncached.
626641
function refreshLiveMergeState(
627642
env: Env,
628643
repoFullName: string,
@@ -2188,6 +2203,10 @@ async function reReviewStoredPullRequest(
21882203
resyncAdmissionKey,
21892204
);
21902205
primeLiveMergeState(liveFacts, repoFullName, prNumber, resyncToken, live?.mergeable_state);
2206+
// #2537: this resync ALREADY paid for a bare GET /pulls/{n} — persist it to the durable cross-webhook cache so
2207+
// the readiness/dup-winner/gate-override readers below (and future webhook deliveries) don't re-fetch it.
2208+
// Best-effort, never blocks the sweep on a write hiccup.
2209+
await primeDurablePrStateCache(env, repoFullName, prNumber, live).catch(() => undefined);
21912210
// Terminal early-exit (#1942): the PR is CLOSED/merged on GitHub even though the stored row still reads open — a
21922211
// dropped `closed` webhook (relay down). Reconcile the stored row from the live payload and RETURN before the
21932212
// expensive resync (files) + readiness + re-review reads. A stale sweep must never spend GitHub budget — or post
@@ -4225,6 +4244,14 @@ async function processGitHubWebhook(
42254244
repoFullName,
42264245
payload.pull_request,
42274246
);
4247+
// #2537: the durable PR-state cache (mergeable_state/state) goes stale exactly when GitHub recomputes them —
4248+
// synchronize (new head → new mergeable_state recompute), closed (state flips), reopened (state flips back).
4249+
// Clear explicitly (null, not omitted — PARTIAL-UPDATE CONTRACT) so the next cached read is a forced live
4250+
// miss; other pull_request actions (labeled, edited, etc.) don't change these fields and are left untouched
4251+
// to avoid spurious cache churn / extra writes on high-frequency low-signal actions.
4252+
if (eventName === "pull_request" && (payload.action === "synchronize" || payload.action === "closed" || payload.action === "reopened")) {
4253+
await invalidatePrStateCache(env, repoFullName, pr.number).catch(() => undefined);
4254+
}
42284255
// Reopen-prevention (#one-shot-reopen): a CONTRIBUTOR may not reopen a PR that gittensory or a maintainer
42294256
// closed — closes are one-shot (resubmit, don't reopen). If a non-maintainer reopened a PR whose last close
42304257
// was by the bot / repo owner / admin, re-close it and skip the re-review. Self-closes (the contributor
@@ -4322,6 +4349,14 @@ async function processGitHubWebhook(
43224349
installationId &&
43234350
shouldProcessPullRequestPublicSurface(eventName, payload.action)
43244351
) {
4352+
// #2537: reviews only actually change on a pull_request_review webhook (submitted/edited/dismissed) — not
4353+
// on every sweep tick / unrelated pull_request action — so invalidate the durable review cache HERE,
4354+
// before the refreshPullRequestDetails call below reads it, rather than on head-SHA change (reviews are
4355+
// independent of head). shouldProcessPullRequestPublicSurface already gates pull_request_review to
4356+
// exactly these 3 actions.
4357+
if (eventName === "pull_request_review") {
4358+
await invalidatePrReviewsCache(env, repoFullName, pr.number).catch(() => undefined);
4359+
}
43254360
if (
43264361
shouldCollectSlopEvidence(settings) ||
43274362
settings.manifestPolicyGateMode !== "off" ||
@@ -5615,7 +5650,9 @@ export async function reconcileLiveDuplicateSiblings(
56155650
const staleClosed = new Set<number>();
56165651
await Promise.all(
56175652
lowerOverlapping.map(async (sibling) => {
5618-
const liveState = await fetchLivePullRequestState(
5653+
// #2537: durable-cached — this is a non-authoritative reconcile read (not the act-boundary decision), so
5654+
// repeat calls across webhook deliveries for an unchanged sibling can reuse the cross-webhook cache.
5655+
const liveState = await cachedFetchLivePullRequestState(
56195656
env,
56205657
repoFullName,
56215658
sibling.number,
@@ -7390,6 +7427,8 @@ async function recordGithubProductUsage(
73907427
* THAT commit (the neutral check-run is per-commit by design). FAIL-OPEN: an unreadable live fetch returns the
73917428
* cached head, so a transient GitHub hiccup never strands the override — it just targets the stored SHA as before.
73927429
* Mirrors the rebase path's live re-fetch (prReadyForReview) and the dup-winner live reconcile.
7430+
* #2537: durable-cached — a separate `issue_comment` webhook handler with no request-local `liveFacts`, so the
7431+
* cross-webhook cache is a pure win here (not the act-boundary merge/close decision).
73937432
*/
73947433
export async function resolveOverrideHeadSha(
73957434
env: Env,
@@ -7402,7 +7441,7 @@ export async function resolveOverrideHeadSha(
74027441
() => undefined,
74037442
)) ?? env.GITHUB_PUBLIC_TOKEN;
74047443
const admissionKey = githubAdmissionKeyForToken(env, installationId, token);
7405-
const liveHeadSha = await fetchLivePullRequestHeadSha(
7444+
const liveHeadSha = await cachedFetchLivePullRequestHeadSha(
74067445
env,
74077446
repoFullName,
74087447
pr.number,

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,10 @@ export type PullRequestDetailSyncStateRecord = {
874874
lastSyncedAt?: string | null | undefined;
875875
errorSummary?: string | null | undefined;
876876
updatedAt?: string | null | undefined;
877+
// #2537: durable bare-PR-state cache fields (mergeable_state/state from GET /pulls/{n}).
878+
prMergeableState?: string | null | undefined;
879+
prState?: string | null | undefined;
880+
prStateFetchedAt?: string | null | undefined;
877881
};
878882

879883
export type GitHubRateLimitObservationRecord = {

0 commit comments

Comments
 (0)