fix(github): bound PR-refresh alias fan-out and hosted-review cache growth (#16943)

* wip: memory-growth

* fix(github): preserve newer refresh candidates
This commit is contained in:
Neil
2026-08-27 22:13:01 -07:00
committed by GitHub
parent 8dd7d6060c
commit cf5e087246
4 changed files with 484 additions and 29 deletions
@@ -5,6 +5,10 @@ import {
getHostedReviewCacheKey,
linkedReviewHintKey
} from '../slices/hosted-review-cache-identity'
import {
hasNewerHostedReviewCacheEntry,
withHostedReviewCacheEntry
} from '../slices/hosted-review-cache-state'
import type { GitHubPRFallbackSource } from './cache-model'
export function githubHostedReviewFallbackPRNumber(
@@ -67,20 +71,6 @@ export function linkedReviewHintKeyForNoGitHubPR(
return entry?.linkedReviewHintKey
}
export function hasNewerHostedReviewCacheEntry(
cache: AppState['hostedReviewCache'],
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: AppState['hostedReviewCache'][string] | undefined
): boolean {
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
export function syncHostedReviewCacheFromGitHubPRResult(args: {
cache: AppState['hostedReviewCache']
repoPath: string
@@ -155,18 +145,17 @@ export function syncHostedReviewCacheFromGitHubPRResult(args: {
hostedReviewEntry?.branchLookupGitHubPRNumber === args.pr.number)
? args.pr.number
: undefined
// Why: the key embeds the branch, so this write path grows with every distinct
// (host, repo, branch) a session refreshes. Share the hosted-review slice's bound.
return {
cache: {
...args.cache,
[hostedReviewCacheKey]: {
data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null,
fetchedAt: args.fetchedAt,
linkedReviewHintKey: args.pr
? linkedReviewHintKey({ linkedGitHubPR: args.pr.number })
: linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry),
...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {})
}
},
cache: withHostedReviewCacheEntry(args.cache, hostedReviewCacheKey, {
data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null,
fetchedAt: args.fetchedAt,
linkedReviewHintKey: args.pr
? linkedReviewHintKey({ linkedGitHubPR: args.pr.number })
: linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry),
...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {})
}),
accepted: true
}
}
@@ -0,0 +1,101 @@
/**
* Memory-leak regression: hostedReviewCache must stay bounded on the GitHub
* PR-refresh write path.
*
* `withHostedReviewCacheEntry` caps the map at HOSTED_REVIEW_CACHE_MAX, but the
* GitHub refresh path writes through `syncHostedReviewCacheFromGitHubPRResult`,
* which spreads a new key in without ever applying that cap. The key embeds the
* branch, so the map grows with every distinct (host, repo, branch) tuple a
* session ever refreshes.
*/
import { describe, it, expect, vi } from 'vitest'
import { create } from 'zustand'
import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import type { AppState } from '../types'
import type { GitHubPRRefreshEvent } from '../../../../shared/github/pull-request-refresh-types'
const MAX_ENTRIES = 500
const mockApi = {
gh: {
prForBranch: vi.fn().mockResolvedValue(null),
refreshPRNow: vi.fn(),
enqueuePRRefresh: vi.fn().mockResolvedValue(undefined),
issue: vi.fn().mockResolvedValue(null),
prChecks: vi.fn().mockResolvedValue([])
},
hostedReview: { forBranch: vi.fn().mockResolvedValue(null) },
runtimeEnvironments: { call: vi.fn() },
cache: {
getGitHub: vi.fn().mockResolvedValue(null),
setGitHub: vi.fn().mockResolvedValue(undefined)
}
}
// @ts-expect-error -- minimal window.api stub for the slice under test
globalThis.window = { api: mockApi }
function createTestStore() {
return create<AppState>()(
(...a) =>
({
...createGitHubSlice(...a),
...createHostedReviewSlice(...a)
}) as AppState
)
}
function foundEvent(branch: string, sequence: number): GitHubPRRefreshEvent {
return {
sequence,
reason: 'visible',
aliases: [
{
cacheKey: `local::/repo::${branch}`,
repoId: '/repo',
repoPath: '/repo',
branch,
executionHostId: 'local'
}
],
outcome: {
kind: 'found',
fetchedAt: sequence,
pr: {
number: 1,
title: 'pr',
state: 'open',
url: 'https://example.test/pr/1',
checksStatus: 'success',
updatedAt: new Date().toISOString(),
mergeable: 'MERGEABLE'
}
}
}
}
describe('hostedReviewCache stays bounded on the PR-refresh write path', () => {
it('caps hostedReviewCache when driven past the cap by the real writer', () => {
const store = createTestStore()
const total = MAX_ENTRIES + 150
for (let i = 0; i < total; i++) {
store.getState().applyGitHubPRRefreshEvent(foundEvent(`branch-${i}`, i + 1))
}
const cache = store.getState().hostedReviewCache
expect(Object.keys(cache).length).toBeLessThanOrEqual(MAX_ENTRIES)
// Newest survives, oldest is evicted.
expect(cache[`local::/repo::branch-${total - 1}`]).toBeDefined()
expect(cache['local::/repo::branch-0']).toBeUndefined()
})
it('keeps every entry while under the cap', () => {
const store = createTestStore()
for (let i = 0; i < 10; i++) {
store.getState().applyGitHubPRRefreshEvent(foundEvent(`kept-${i}`, i + 1))
}
const cache = store.getState().hostedReviewCache
expect(Object.keys(cache)).toHaveLength(10)
expect(cache['local::/repo::kept-0']).toBeDefined()
})
})