Bound GitHub project picker browse cache (#7681)

This commit is contained in:
Neil 2026-07-11 01:27:59 -07:00 committed by GitHub
parent 5952ad0623
commit 5da02b0105
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 194 additions and 14 deletions

View File

@ -30,6 +30,11 @@ import {
isGitHubProjectRefInputTooLarge
} from '../../../../shared/github-project-ref-input'
import { filterGitHubProjectPickerProjects } from './github-project-picker-filter'
import {
getProjectPickerBrowseCacheEntry,
peekProjectPickerBrowseCacheEntry,
rememberProjectPickerBrowseCacheEntry
} from './project-picker-browse-cache'
import { translate } from '@/i18n/i18n'
export type ResolvedProjectSelection = {
@ -49,15 +54,6 @@ type Props = {
onSelect: (selection: ResolvedProjectSelection) => void
}
const BROWSE_CACHE_TTL_MS = 5 * 60_000
type BrowseCacheEntry = {
fetchedAt: number
projects: GitHubProjectSummary[]
partialFailures?: { owner: string; message: string }[]
}
const browseCacheByRuntimeScope = new Map<string, BrowseCacheEntry>()
function getProjectPickerRuntimeScope(
settings: Parameters<typeof getActiveRuntimeTarget>[0]
): string {
@ -125,7 +121,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
const [query, setQuery] = useState('')
const [browseLoading, setBrowseLoading] = useState(false)
const [browseError, setBrowseError] = useState<GitHubProjectViewError | null>(null)
const browseCache = browseCacheByRuntimeScope.get(getProjectPickerRuntimeScope(settings))
const browseCache = peekProjectPickerBrowseCacheEntry(getProjectPickerRuntimeScope(settings))
const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>(
() => browseCache?.projects ?? []
)
@ -147,8 +143,8 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
const loadBrowse = useCallback(async () => {
const cacheKey = getProjectPickerRuntimeScope(settings)
const cached = browseCacheByRuntimeScope.get(cacheKey) ?? null
if (cached && Date.now() - cached.fetchedAt < BROWSE_CACHE_TTL_MS) {
const cached = getProjectPickerBrowseCacheEntry(cacheKey)
if (cached) {
setBrowseProjects(cached.projects)
setPartialFailures(cached.partialFailures ?? [])
return
@ -158,8 +154,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
try {
const res = await listAccessibleProjectsForRuntime(settings)
if (res.ok) {
browseCacheByRuntimeScope.set(cacheKey, {
fetchedAt: Date.now(),
rememberProjectPickerBrowseCacheEntry(cacheKey, {
projects: res.projects,
partialFailures: res.partialFailures
})

View File

@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { GitHubProjectSummary } from '../../../../shared/github-project-types'
import {
PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES,
PROJECT_PICKER_BROWSE_CACHE_TTL_MS,
_clearProjectPickerBrowseCacheForTest,
_getProjectPickerBrowseCacheSizeForTest,
getProjectPickerBrowseCacheEntry,
peekProjectPickerBrowseCacheEntry,
rememberProjectPickerBrowseCacheEntry
} from './project-picker-browse-cache'
function project(scope: string): GitHubProjectSummary {
return {
id: `${scope}-project`,
owner: scope,
ownerType: 'organization',
number: 1,
title: `${scope} Roadmap`,
url: `https://github.com/orgs/${scope}/projects/1`,
source: 'viewer'
}
}
describe('project-picker-browse-cache', () => {
beforeEach(() => {
_clearProjectPickerBrowseCacheForTest()
})
it('prunes expired scopes when a different runtime is read', () => {
rememberProjectPickerBrowseCacheEntry('runtime:old-1', { projects: [project('old-1')] }, 1_000)
rememberProjectPickerBrowseCacheEntry('runtime:old-2', { projects: [project('old-2')] }, 900)
rememberProjectPickerBrowseCacheEntry(
'runtime:current',
{ projects: [project('current')] },
2_000
)
expect(
getProjectPickerBrowseCacheEntry(
'runtime:current',
1_000 + PROJECT_PICKER_BROWSE_CACHE_TTL_MS
)
).toMatchObject({ projects: [expect.objectContaining({ owner: 'current' })] })
expect(_getProjectPickerBrowseCacheSizeForTest()).toBe(1)
})
it('stays bounded through prolonged churn while preserving a reused scope', () => {
let inserted = 1
rememberProjectPickerBrowseCacheEntry(
'runtime:retained',
{ projects: [project('retained')] },
0
)
for (let wave = 0; wave < 4; wave += 1) {
expect(
getProjectPickerBrowseCacheEntry('runtime:retained', inserted)
).toMatchObject({ projects: [expect.objectContaining({ owner: 'retained' })] })
for (let index = 1; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) {
const scope = `scope-${inserted}`
rememberProjectPickerBrowseCacheEntry(
`runtime:${scope}`,
{ projects: [project(scope)] },
inserted
)
inserted += 1
}
expect(_getProjectPickerBrowseCacheSizeForTest()).toBe(
PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES
)
}
expect(getProjectPickerBrowseCacheEntry('runtime:retained', inserted + 1)).toMatchObject({
projects: [expect.objectContaining({ owner: 'retained' })]
})
expect(getProjectPickerBrowseCacheEntry('runtime:scope-1', inserted + 1)).toBeNull()
})
it('peeks without changing cache recency during render', () => {
for (let index = 0; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) {
rememberProjectPickerBrowseCacheEntry(
`runtime:${index}`,
{ projects: [project(`runtime-${index}`)] },
index
)
}
expect(peekProjectPickerBrowseCacheEntry('runtime:0', 40)).not.toBeNull()
rememberProjectPickerBrowseCacheEntry('runtime:new', { projects: [project('new')] }, 41)
expect(getProjectPickerBrowseCacheEntry('runtime:0', 42)).toBeNull()
})
it('refreshes an existing scope without growing the cache', () => {
rememberProjectPickerBrowseCacheEntry('runtime:one', { projects: [project('old')] }, 1_000)
rememberProjectPickerBrowseCacheEntry('runtime:one', { projects: [project('new')] }, 1_001)
expect(_getProjectPickerBrowseCacheSizeForTest()).toBe(1)
expect(getProjectPickerBrowseCacheEntry('runtime:one', 1_002)).toMatchObject({
projects: [expect.objectContaining({ owner: 'new' })]
})
})
})

View File

@ -0,0 +1,82 @@
import type { GitHubProjectSummary } from '../../../../shared/github-project-types'
export const PROJECT_PICKER_BROWSE_CACHE_TTL_MS = 5 * 60_000
export const PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES = 32
type ProjectPickerBrowseCacheEntry = {
fetchedAt: number
projects: GitHubProjectSummary[]
partialFailures?: { owner: string; message: string }[]
}
const browseCacheByRuntimeScope = new Map<string, ProjectPickerBrowseCacheEntry>()
function pruneExpiredProjectPickerBrowseCache(now: number): void {
for (const [key, entry] of browseCacheByRuntimeScope) {
if (now - entry.fetchedAt >= PROJECT_PICKER_BROWSE_CACHE_TTL_MS) {
browseCacheByRuntimeScope.delete(key)
}
}
}
function trimProjectPickerBrowseCache(): void {
while (browseCacheByRuntimeScope.size > PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES) {
const oldestKey = browseCacheByRuntimeScope.keys().next().value
if (oldestKey === undefined) {
return
}
browseCacheByRuntimeScope.delete(oldestKey)
}
}
export function peekProjectPickerBrowseCacheEntry(
cacheKey: string,
now = Date.now()
): ProjectPickerBrowseCacheEntry | null {
const entry = browseCacheByRuntimeScope.get(cacheKey)
if (!entry || now - entry.fetchedAt >= PROJECT_PICKER_BROWSE_CACHE_TTL_MS) {
return null
}
return entry
}
export function getProjectPickerBrowseCacheEntry(
cacheKey: string,
now = Date.now()
): ProjectPickerBrowseCacheEntry | null {
pruneExpiredProjectPickerBrowseCache(now)
const entry = peekProjectPickerBrowseCacheEntry(cacheKey, now)
if (!entry) {
browseCacheByRuntimeScope.delete(cacheKey)
return null
}
// Why: cache keys are runtime scopes; refresh recency so active runtimes do
// not get evicted just because a user briefly tries many other runtimes.
browseCacheByRuntimeScope.delete(cacheKey)
browseCacheByRuntimeScope.set(cacheKey, entry)
return entry
}
export function rememberProjectPickerBrowseCacheEntry(
cacheKey: string,
entry: Omit<ProjectPickerBrowseCacheEntry, 'fetchedAt'>,
now = Date.now()
): void {
pruneExpiredProjectPickerBrowseCache(now)
browseCacheByRuntimeScope.delete(cacheKey)
browseCacheByRuntimeScope.set(cacheKey, {
...entry,
fetchedAt: now
})
trimProjectPickerBrowseCache()
}
/** @internal - exposed for leak-regression tests only */
export function _getProjectPickerBrowseCacheSizeForTest(): number {
return browseCacheByRuntimeScope.size
}
/** @internal - exposed for leak-regression tests only */
export function _clearProjectPickerBrowseCacheForTest(): void {
browseCacheByRuntimeScope.clear()
}