diff --git a/tests/ui/models/bugSuggestions_test.js b/tests/ui/models/bugSuggestions_test.js new file mode 100644 index 00000000000..c38f90800d2 --- /dev/null +++ b/tests/ui/models/bugSuggestions_test.js @@ -0,0 +1,74 @@ +import fetchMock from 'fetch-mock'; + +import BugSuggestionsModel, { + bugSuggestionsCache, + BUG_SUGGESTIONS_CACHE_LIMIT, + BUG_SUGGESTIONS_CACHE_TTL_MS, +} from '../../../ui/models/bugSuggestions'; +import { getProjectJobUrl } from '../../../ui/helpers/location'; + +describe('BugSuggestionsModel', () => { + const jobUrl = (jobId) => getProjectJobUrl('/bug_suggestions/', jobId); + const sampleSuggestions = [{ search: 'a failure', bugs: { open_recent: [] } }]; + + afterEach(() => { + fetchMock.reset(); + bugSuggestionsCache.clear(); + }); + + test('caches non-empty results so a repeat get does not refetch', async () => { + fetchMock.get(jobUrl(1), sampleSuggestions); + + const first = await BugSuggestionsModel.get(1); + expect(first).toEqual(sampleSuggestions); + expect(fetchMock.calls(jobUrl(1))).toHaveLength(1); + + const second = await BugSuggestionsModel.get(1); + expect(second).toEqual(sampleSuggestions); + // Served from cache; still only the one network call. + expect(fetchMock.calls(jobUrl(1))).toHaveLength(1); + }); + + test('does not cache empty results, so a still-parsing job refetches', async () => { + fetchMock.get(jobUrl(2), []); + + await BugSuggestionsModel.get(2); + await BugSuggestionsModel.get(2); + + // Empty results are never cached, so both calls hit the network. + expect(fetchMock.calls(jobUrl(2))).toHaveLength(2); + }); + + test('refetches once a cached entry is older than the TTL', async () => { + fetchMock.get(jobUrl(3), sampleSuggestions); + + await BugSuggestionsModel.get(3); + expect(fetchMock.calls(jobUrl(3))).toHaveLength(1); + + // Age the cached entry past its TTL. + bugSuggestionsCache.get(3).timestamp -= BUG_SUGGESTIONS_CACHE_TTL_MS + 1; + + await BugSuggestionsModel.get(3); + // Expired -> refetched. + expect(fetchMock.calls(jobUrl(3))).toHaveLength(2); + }); + + test('evicts the oldest entry once the cache limit is exceeded', async () => { + // Fill the cache to its limit with distinct jobIds. + for (let jobId = 1; jobId <= BUG_SUGGESTIONS_CACHE_LIMIT; jobId++) { + fetchMock.get(jobUrl(jobId), sampleSuggestions); + await BugSuggestionsModel.get(jobId); + } + expect(bugSuggestionsCache.size).toBe(BUG_SUGGESTIONS_CACHE_LIMIT); + expect(bugSuggestionsCache.has(1)).toBe(true); + + // One more job evicts the oldest (jobId 1). + const overflowId = BUG_SUGGESTIONS_CACHE_LIMIT + 1; + fetchMock.get(jobUrl(overflowId), sampleSuggestions); + await BugSuggestionsModel.get(overflowId); + + expect(bugSuggestionsCache.size).toBe(BUG_SUGGESTIONS_CACHE_LIMIT); + expect(bugSuggestionsCache.has(1)).toBe(false); + expect(bugSuggestionsCache.has(overflowId)).toBe(true); + }); +}); diff --git a/ui/models/bugSuggestions.js b/ui/models/bugSuggestions.js index 03c9da7d259..62d9656fc59 100644 --- a/ui/models/bugSuggestions.js +++ b/ui/models/bugSuggestions.js @@ -1,9 +1,45 @@ import { getProjectJobUrl } from '../helpers/location'; +// Per-session, bounded cache of bug-suggestion responses keyed by jobId. The +// Failure Summary tab unmounts/remounts every time the user switches tabs (or +// re-selects a job), which would otherwise refetch and re-process the same +// suggestions on every remount. See ui/models/push.js `decisionTaskIdCache` +// for the same pattern. +// +// Entries carry a timestamp and expire after BUG_SUGGESTIONS_CACHE_TTL_MS so a +// long-lived session (e.g. a dashboard left open for days) eventually picks up +// a newly-filed bug once the backend's own cache has refreshed. The backend +// caches error summaries for 24h, so a shorter TTL here mostly bounds the +// worst case rather than surfacing new bugs faster. +export const bugSuggestionsCache = new Map(); +export const BUG_SUGGESTIONS_CACHE_LIMIT = 50; +export const BUG_SUGGESTIONS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + export default class BugSuggestionsModel { - static get(jobId) { - return fetch(getProjectJobUrl('/bug_suggestions/', jobId)).then((resp) => - resp.json(), - ); + static async get(jobId) { + const cached = bugSuggestionsCache.get(jobId); + if (cached && Date.now() - cached.timestamp < BUG_SUGGESTIONS_CACHE_TTL_MS) { + return cached.data; + } + + const resp = await fetch(getProjectJobUrl('/bug_suggestions/', jobId)); + const suggestions = await resp.json(); + + // Only cache meaningful results. An empty array means "no failures yet" or + // "log parsing still in progress"; leaving those uncached lets a later + // revisit pick up suggestions once the log finishes parsing. + if (Array.isArray(suggestions) && suggestions.length > 0) { + // A stale entry past its TTL is being replaced; drop it first so the + // refreshed entry counts as the newest for eviction ordering. + bugSuggestionsCache.delete(jobId); + // Evict the oldest entry once we exceed the limit (Map preserves + // insertion order). + if (bugSuggestionsCache.size >= BUG_SUGGESTIONS_CACHE_LIMIT) { + bugSuggestionsCache.delete(bugSuggestionsCache.keys().next().value); + } + bugSuggestionsCache.set(jobId, { data: suggestions, timestamp: Date.now() }); + } + + return suggestions; } }