feat(kubernetes-react): add optional getClusters() cache to KubernetesBackendClient (#34136)
fix(kubernetes-react): coalesce concurrent getClusters() fetches and validate TTL input Signed-off-by: Rickard Dybeck <dybeck@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-react': patch
|
||||
---
|
||||
|
||||
Added optional clustersCacheTtlMs option to KubernetesBackendClient that caches getClusters() responses for the specified duration, avoiding repeated /clusters requests when multiple proxy calls resolve cluster auth in quick succession.
|
||||
@@ -395,6 +395,7 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
discoveryApi: DiscoveryApi;
|
||||
fetchApi: FetchApi;
|
||||
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
|
||||
clustersCacheTtlMs?: number;
|
||||
});
|
||||
// (undocumented)
|
||||
getCluster(clusterName: string): Promise<{
|
||||
|
||||
@@ -386,6 +386,120 @@ describe('KubernetesBackendClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('getClusters caching', () => {
|
||||
let fetchCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCount = 0;
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
http.get('http://localhost:1234/api/kubernetes/clusters', () => {
|
||||
fetchCount++;
|
||||
return HttpResponse.json({
|
||||
items: [{ name: 'cluster-a', authProvider: 'aws' }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache by default', async () => {
|
||||
await backendClient.getClusters();
|
||||
await backendClient.getClusters();
|
||||
|
||||
expect(fetchCount).toBe(2);
|
||||
});
|
||||
|
||||
it('returns cached results within TTL when caching is enabled', async () => {
|
||||
const cachingClient = new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
fetchApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
clustersCacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const first = await cachingClient.getClusters();
|
||||
const second = await cachingClient.getClusters();
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it('coalesces concurrent requests into a single fetch', async () => {
|
||||
const cachingClient = new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
fetchApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
clustersCacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const [first, second, third] = await Promise.all([
|
||||
cachingClient.getClusters(),
|
||||
cachingClient.getClusters(),
|
||||
cachingClient.getClusters(),
|
||||
]);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(second).toEqual(third);
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects negative TTL values', () => {
|
||||
expect(
|
||||
() =>
|
||||
new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
fetchApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
clustersCacheTtlMs: -1,
|
||||
}),
|
||||
).toThrow('clustersCacheTtlMs must be a finite number >= 0');
|
||||
});
|
||||
|
||||
it('rejects NaN TTL values', () => {
|
||||
expect(
|
||||
() =>
|
||||
new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
fetchApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
clustersCacheTtlMs: NaN,
|
||||
}),
|
||||
).toThrow('clustersCacheTtlMs must be a finite number >= 0');
|
||||
});
|
||||
|
||||
it('fetches again after TTL expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const cachingClient = new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
fetchApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
clustersCacheTtlMs: 1000,
|
||||
});
|
||||
|
||||
await cachingClient.getClusters();
|
||||
expect(fetchCount).toBe(1);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
|
||||
await cachingClient.getClusters();
|
||||
expect(fetchCount).toBe(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy', () => {
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
|
||||
@@ -31,15 +31,38 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly fetchApi: FetchApi;
|
||||
private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
|
||||
private readonly clustersCacheTtlMs: number | undefined;
|
||||
private clustersCache: { name: string; authProvider: string }[] | undefined;
|
||||
private clustersCacheTimestamp = 0;
|
||||
private clustersFetchPromise:
|
||||
| Promise<{ name: string; authProvider: string }[]>
|
||||
| undefined;
|
||||
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
fetchApi: FetchApi;
|
||||
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
|
||||
/**
|
||||
* When set, `getClusters()` results are cached for this many milliseconds.
|
||||
* Useful when many proxy calls resolve cluster auth in quick succession.
|
||||
*/
|
||||
clustersCacheTtlMs?: number;
|
||||
}) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.fetchApi = options.fetchApi;
|
||||
this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi;
|
||||
if (
|
||||
options.clustersCacheTtlMs !== undefined &&
|
||||
!(
|
||||
Number.isFinite(options.clustersCacheTtlMs) &&
|
||||
options.clustersCacheTtlMs >= 0
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`clustersCacheTtlMs must be a finite number >= 0, got ${options.clustersCacheTtlMs}`,
|
||||
);
|
||||
}
|
||||
this.clustersCacheTtlMs = options.clustersCacheTtlMs;
|
||||
}
|
||||
|
||||
private async handleResponse(response: Response): Promise<any> {
|
||||
@@ -128,10 +151,40 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
}
|
||||
|
||||
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
|
||||
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`;
|
||||
const response = await this.fetchApi.fetch(url);
|
||||
if (
|
||||
this.clustersCacheTtlMs !== undefined &&
|
||||
this.clustersCache &&
|
||||
Date.now() - this.clustersCacheTimestamp < this.clustersCacheTtlMs
|
||||
) {
|
||||
return this.clustersCache;
|
||||
}
|
||||
|
||||
return (await this.handleResponse(response)).items;
|
||||
if (this.clustersFetchPromise) {
|
||||
return this.clustersFetchPromise;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'kubernetes',
|
||||
)}/clusters`;
|
||||
const response = await this.fetchApi.fetch(url);
|
||||
return (await this.handleResponse(response)).items;
|
||||
})();
|
||||
|
||||
if (this.clustersCacheTtlMs !== undefined) {
|
||||
this.clustersFetchPromise = fetchPromise;
|
||||
}
|
||||
|
||||
try {
|
||||
const clusters = await fetchPromise;
|
||||
if (this.clustersCacheTtlMs !== undefined) {
|
||||
this.clustersCache = clusters;
|
||||
this.clustersCacheTimestamp = Date.now();
|
||||
}
|
||||
return clusters;
|
||||
} finally {
|
||||
this.clustersFetchPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async proxy(options: {
|
||||
|
||||
Reference in New Issue
Block a user