Bound GitHub project picker browse cache (#7681)
This commit is contained in:
parent
5952ad0623
commit
5da02b0105
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue