Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions tests/ui/models/bugSuggestions_test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 40 additions & 4 deletions ui/models/bugSuggestions.js
Original file line number Diff line number Diff line change
@@ -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;
}
}