Skip to content

Commit 3f0fc8d

Browse files
momesginMo Mesgin
andauthored
Make refreshing repositories more efficient (#16793)
* more efficient way to get repositories on refresh * handle race condition * copilot feedback * error handling for refresh repo --------- Co-authored-by: Mo Mesgin <mmesgin@Mos-M2-MacBook-Pro.local>
1 parent 12e0981 commit 3f0fc8d

5 files changed

Lines changed: 277 additions & 17 deletions

File tree

shell/assets/translations/en-us.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,8 @@ catalog:
14351435
refreshInterval:
14361436
label: Refresh Interval
14371437
placeholder: 'default: {hours}'
1438+
error:
1439+
refresh: Error refreshing repository
14381440
tools:
14391441
iconAlt: Icon for {app} Cluster Tool
14401442
header: Cluster Tools
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import ClusterRepo from '../catalog.cattle.io.clusterrepo';
2+
3+
describe('clusterRepo', () => {
4+
let model: any;
5+
6+
beforeEach(() => {
7+
model = new ClusterRepo({
8+
metadata: { name: 'test-repo' },
9+
spec: { url: 'https://test-url.com' }
10+
}, {
11+
getters: {},
12+
dispatch: jest.fn(),
13+
rootGetters: {}
14+
});
15+
16+
jest.spyOn(model, '_key', 'get').mockReturnValue('test-key');
17+
jest.spyOn(model, 'save').mockImplementation().mockResolvedValue(true);
18+
jest.spyOn(model, 'waitForState').mockImplementation().mockResolvedValue(true);
19+
jest.spyOn(model, '$dispatch', 'get').mockReturnValue(jest.fn());
20+
});
21+
22+
describe('refresh', () => {
23+
it('updates forceUpdate, saves, waits for active state, and dispatches load by default', async() => {
24+
// Mock Date to ensure deterministic forceUpdate value
25+
const mockDate = new Date('2023-01-01T12:00:00.000Z');
26+
const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
27+
28+
await model.refresh();
29+
30+
expect(model.spec.forceUpdate).toBe('2023-01-01T12:00:00Z');
31+
expect(model.save).toHaveBeenCalledWith();
32+
expect(model.waitForState).toHaveBeenCalledWith('active', 10000, 1000);
33+
expect(model.$dispatch).toHaveBeenCalledWith('catalog/load', { force: true, repoKeys: [model._key] }, { root: true });
34+
35+
spy.mockRestore();
36+
});
37+
38+
it('updates forceUpdate, saves, waits for active state, but DOES NOT dispatch load if dispatchLoad is false', async() => {
39+
await model.refresh(false);
40+
41+
expect(model.save).toHaveBeenCalledWith();
42+
expect(model.waitForState).toHaveBeenCalledWith('active', 10000, 1000);
43+
expect(model.$dispatch).not.toHaveBeenCalled();
44+
});
45+
46+
it('dispatches error to growl if save or waitForState fails', async() => {
47+
const error = new Error('waitForState timeout');
48+
49+
model.waitForState.mockRejectedValue(error);
50+
jest.spyOn(model, 't', 'get').mockReturnValue(jest.fn().mockReturnValue('Error refreshing repository'));
51+
jest.spyOn(model, 'nameDisplay', 'get').mockReturnValue('Test Repo');
52+
53+
await model.refresh();
54+
55+
expect(model.$dispatch).toHaveBeenCalledWith('growl/fromError', {
56+
title: 'Error refreshing repository',
57+
err: error
58+
}, { root: true });
59+
});
60+
});
61+
62+
describe('refreshBulk', () => {
63+
it('calls refresh(false) on all items and then dispatches a single catalog/load with all repoKeys', async() => {
64+
const mockItem1 = {
65+
_key: 'repo-1',
66+
refresh: jest.fn().mockResolvedValue(true)
67+
};
68+
const mockItem2 = {
69+
_key: 'repo-2',
70+
refresh: jest.fn().mockResolvedValue(true)
71+
};
72+
73+
await model.refreshBulk([mockItem1, mockItem2]);
74+
75+
expect(mockItem1.refresh).toHaveBeenCalledWith(false);
76+
expect(mockItem2.refresh).toHaveBeenCalledWith(false);
77+
78+
expect(model.$dispatch).toHaveBeenCalledWith('catalog/load', {
79+
force: true,
80+
repoKeys: ['repo-1', 'repo-2']
81+
}, { root: true });
82+
});
83+
});
84+
});

shell/models/catalog.cattle.io.clusterrepo.js

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,54 @@ export default class ClusterRepo extends SteveModel {
4141
});
4242

4343
insertAt(out, 0, {
44-
action: 'refresh',
45-
label: this.t('action.refresh'),
46-
icon: 'icon icon-refresh',
47-
enabled: !!this.links.update,
48-
bulkable: true,
44+
action: 'refresh',
45+
label: this.t('action.refresh'),
46+
icon: 'icon icon-refresh',
47+
enabled: !!this.links.update,
48+
bulkable: true,
49+
bulkAction: 'refreshBulk',
4950
});
5051
}
5152

5253
return out;
5354
}
5455

55-
async refresh() {
56-
const now = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
56+
/**
57+
* Refreshes the repository by updating its forceUpdate annotation and waiting for it to become active.
58+
* @param {boolean} dispatchLoad - Whether to dispatch the catalog/load action after refreshing. Defaults to true.
59+
*/
60+
async refresh(dispatchLoad = true) {
61+
try {
62+
const now = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
5763

58-
this.spec.forceUpdate = now;
59-
await this.save();
64+
this.spec.forceUpdate = now;
65+
await this.save();
66+
67+
await this.waitForState('active', 10000, 1000);
6068

61-
await this.waitForState('active', 10000, 1000);
69+
if (dispatchLoad) {
70+
this.$dispatch('catalog/load', { force: true, repoKeys: [this._key] }, { root: true });
71+
}
72+
} catch (err) {
73+
this.$dispatch('growl/fromError', {
74+
title: this.t('catalog.repo.error.refresh', {}, 'Error refreshing repository'),
75+
err,
76+
}, { root: true });
77+
}
78+
}
6279

63-
this.$dispatch('catalog/load', { force: true, reset: true }, { root: true });
80+
/**
81+
* Performs a bulk refresh on multiple repositories concurrently, bypassing individual
82+
* catalog loads, and dispatches a single catalog/load for all repositories once they are active.
83+
* @param {ClusterRepo[]} items - Array of repository instances to refresh.
84+
*/
85+
async refreshBulk(items) {
86+
await Promise.allSettled(items.map((item) => item.refresh(false)));
87+
88+
this.$dispatch('catalog/load', {
89+
force: true,
90+
repoKeys: items.map((item) => item._key)
91+
}, { root: true });
6492
}
6593

6694
async disableClusterRepo() {

shell/store/__tests__/catalog.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,102 @@ describe('catalog', () => {
205205
// Version info should be changed (it's now empty given reset)
206206
expect(store.state[catalogStoreName].versionInfos).toStrictEqual({ });
207207
});
208+
209+
it('repoKeys provided', async() => {
210+
const store = createStore(constructStore());
211+
212+
// Validate initial state of store
213+
expect(store.getters[`${ catalogStoreName }/rawCharts`]).toStrictEqual(initialRawCharts);
214+
expect(store.getters[`${ catalogStoreName }/charts`]).toStrictEqual([]);
215+
216+
// Make the request targeting specific repo (we provide repo._key)
217+
await store.dispatch(`${ catalogStoreName }/load`, {
218+
force: true,
219+
repoKeys: [repo._key]
220+
});
221+
222+
const rawCharts = store.getters[`${ catalogStoreName }/rawCharts`];
223+
const charts = store.getters[`${ catalogStoreName }/charts`];
224+
225+
// We expect the old chart belonging to this repo to be wiped out
226+
// and replaced entirely by the newly fetched chart.
227+
expect(rawCharts[initialRawChart.id]).not.toBeDefined();
228+
expect(rawCharts[expectedChartKey]).toBeDefined();
229+
expect(rawCharts[expectedChartKey].id).toBe(expectedChartKey);
230+
expect(rawCharts[expectedChartKey].versions[0].version).toBe(repoChart.version);
231+
232+
expect(charts).toHaveLength(1);
233+
expect(charts[0]).toBeDefined();
234+
expect(charts[0].id).toBe(expectedChartKey);
235+
});
236+
237+
it('repoKeys provided, ignoring unrelated charts and versions', async() => {
238+
// We will manually inject an unrelated chart to prove it isn't wiped out
239+
const store = createStore(constructStore());
240+
const unrelatedChart = {
241+
id: `namespace/unrelatedRepo/unrelatedChart`,
242+
repoKey: 'unrelatedRepo',
243+
type: 'namespaced',
244+
name: 'unrelatedChart',
245+
version: 1,
246+
metadata: { name: 'unrelatedChart' }
247+
};
248+
249+
const targetedVersionKey = `namespace/testRepo/myChart/1.0.0`;
250+
const unrelatedVersionKey = `namespace/unrelatedRepo/unrelatedChart/1.0.0`;
251+
252+
store.state[catalogStoreName].charts = {
253+
...initialRawCharts,
254+
[unrelatedChart.id]: unrelatedChart
255+
};
256+
257+
store.state[catalogStoreName].versionInfos = {
258+
[targetedVersionKey]: { data: 'old-data' },
259+
[unrelatedVersionKey]: { data: 'keep-me' }
260+
};
261+
262+
// Make the request targeting specific repo
263+
await store.dispatch(`${ catalogStoreName }/load`, {
264+
force: true,
265+
repoKeys: [repo._key]
266+
});
267+
268+
const rawCharts = store.getters[`${ catalogStoreName }/rawCharts`];
269+
const versionInfos = store.state[catalogStoreName].versionInfos;
270+
271+
// The unrelated chart should NOT be wiped
272+
expect(rawCharts[unrelatedChart.id]).toBeDefined();
273+
expect(rawCharts[unrelatedChart.id].repoKey).toBe('unrelatedRepo');
274+
275+
// The old initialRawChart (with repoKey: repo._key) SHOULD be wiped and replaced
276+
expect(rawCharts[initialRawChart.id]).not.toBeDefined();
277+
expect(rawCharts[expectedChartKey]).toBeDefined();
278+
279+
// The targeted version info SHOULD be wiped
280+
expect(versionInfos[targetedVersionKey]).not.toBeDefined();
281+
// The unrelated version info should NOT be wiped
282+
expect(versionInfos[unrelatedVersionKey]).toBeDefined();
283+
expect(versionInfos[unrelatedVersionKey].data).toBe('keep-me');
284+
});
285+
});
286+
287+
describe('refresh', () => {
288+
it('calls refresh(false) on all repos and then dispatches a reset load', async() => {
289+
const mockRepo1 = { refresh: jest.fn().mockResolvedValue(true) };
290+
const mockRepo2 = { refresh: jest.fn().mockResolvedValue(true) };
291+
292+
const getters = { repos: [mockRepo1, mockRepo2] };
293+
const dispatch = jest.fn().mockResolvedValue(true);
294+
const commit = jest.fn();
295+
296+
await actions.refresh({
297+
getters, commit, dispatch
298+
});
299+
300+
expect(mockRepo1.refresh).toHaveBeenCalledWith(false);
301+
expect(mockRepo2.refresh).toHaveBeenCalledWith(false);
302+
expect(dispatch).toHaveBeenCalledWith('load', { force: true, reset: true });
303+
});
208304
});
209305

210306
describe('filterAndArrangeCharts', () => {

shell/store/catalog.js

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,12 @@ export const actions = {
325325
* force: Always refresh catalog's helm repo by re-fetching index.yaml
326326
*
327327
* reset: clear existing charts and version cache
328+
*
329+
* repoKeys: Optional array of specific repo keys (IDs) to refresh. When provided, only these specific
330+
* repos will be fetched, and only their existing charts will be cleared from the cache to avoid
331+
* duplicate chart entries or wiping out unrelated chart data.
328332
*/
329-
async load(ctx, { force, reset } = {}) {
333+
async load(ctx, { force, reset, repoKeys = [] } = {}) {
330334
const {
331335
state, getters, rootGetters, commit, dispatch
332336
} = ctx;
@@ -360,14 +364,34 @@ export const actions = {
360364
promises = {};
361365

362366
for ( const repo of repos ) {
363-
if ( (force === true || !getters.isLoaded(repo)) && repo.canLoad ) {
367+
let shouldLoad = false;
368+
369+
if (repoKeys.length) {
370+
// If repoKeys are explicitly provided (e.g. refreshing a single repo from the UI),
371+
// we ONLY want to load the repos in that array. We intentionally ignore `!getters.isLoaded(repo)`
372+
// here so we don't accidentally fetch other unrelated repos just because they haven't loaded yet.
373+
shouldLoad = repoKeys.includes(repo._key);
374+
} else {
375+
// Default behavior: load if explicitly forced, OR if the repo hasn't been loaded into state yet.
376+
shouldLoad = force === true || !getters.isLoaded(repo);
377+
}
378+
379+
if ( shouldLoad && repo.canLoad ) {
364380
console.info('Loading index for repo', repo.name, `(${ repo._key })`); // eslint-disable-line no-console
365381
promises[repo._key] = repo.followLink('index');
366382
}
367383
}
368384

369385
const res = await allHashSettled(promises);
370-
const charts = reset ? {} : state.charts;
386+
const charts = reset ? {} : { ...state.charts };
387+
let versionInfos = null;
388+
389+
if (reset) {
390+
versionInfos = {};
391+
} else if (repoKeys.length) {
392+
versionInfos = { ...state.versionInfos };
393+
}
394+
371395
const errors = [];
372396

373397
for ( const key of Object.keys(res) ) {
@@ -379,6 +403,28 @@ export const actions = {
379403
continue;
380404
}
381405

406+
// We are targeting specific repos. To prevent duplicate chart versions from appearing,
407+
// we must remove the old charts for this specific repo before appending the newly fetched ones,
408+
// but ONLY if the fetch was successful.
409+
if (repoKeys.length && repoKeys.includes(key)) {
410+
for (const chartKey in charts) {
411+
if (charts[chartKey].repoKey === key) {
412+
delete charts[chartKey];
413+
}
414+
}
415+
416+
// Also clear out cached version info for this repo so we don't display stale READMEs/values
417+
const repoType = repo.type === CATALOG.CLUSTER_REPO ? 'cluster' : 'namespace';
418+
const repoName = repo.metadata.name;
419+
const versionPrefix = `${ repoType }/${ repoName }/`;
420+
421+
for (const versionKey in versionInfos) {
422+
if (versionKey.startsWith(versionPrefix)) {
423+
delete versionInfos[versionKey];
424+
}
425+
}
426+
}
427+
382428
for ( const k in obj.value.entries ) {
383429
for ( const entry of obj.value.entries[k] ) {
384430
addChart(ctx, charts, entry, repo);
@@ -394,13 +440,17 @@ export const actions = {
394440
loaded,
395441
});
396442

397-
if (reset) {
398-
commit('setVersions', {});
443+
if (versionInfos) {
444+
commit('setVersions', versionInfos);
399445
}
400446
},
401447

448+
/**
449+
* Globally refreshes all loaded repositories by triggering their refresh actions concurrently,
450+
* bypassing individual catalog loads, and then performs a single, global catalog/load.
451+
*/
402452
async refresh({ getters, commit, dispatch }) {
403-
const promises = getters.repos.map((x) => x.refresh());
453+
const promises = getters.repos.map((x) => x.refresh(false));
404454

405455
// @TODO wait for repo state to indicate they're done once the API has that
406456

0 commit comments

Comments
 (0)