mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user