From cf93dbd7e94baabb48c14cf3df6232fbdb0dfadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 11:09:23 +0100 Subject: [PATCH 1/6] fix(konflux): replace unfiltered catalog scan with targeted entity lookup getRelatedEntities was calling catalog.getEntities() with no filter, fetching the entire catalog on every request. Now uses the parent entity's hasPart relations with catalog.getEntitiesByRefs() to fetch only the needed subcomponents. --- .../src/helpers/__tests__/config.test.ts | 18 +++++++++--- .../konflux-backend/src/helpers/config.ts | 29 ++++++++++--------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/workspaces/konflux/plugins/konflux-backend/src/helpers/__tests__/config.test.ts b/workspaces/konflux/plugins/konflux-backend/src/helpers/__tests__/config.test.ts index bc90a46d00..5fd9a041f2 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/helpers/__tests__/config.test.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/helpers/__tests__/config.test.ts @@ -113,6 +113,7 @@ describe('config', () => { mockCatalog = { getEntities: jest.fn(), getEntityByRef: jest.fn(), + getEntitiesByRefs: jest.fn().mockResolvedValue({ items: [] }), } as unknown as CatalogService; mockCredentials = {} as BackstageCredentials; @@ -269,8 +270,13 @@ describe('config', () => { mockParseEntityKonfluxConfig.mockReturnValue(clusterConfigs); - const entity = createMockEntity('test-entity'); - (mockCatalog.getEntities as jest.Mock).mockResolvedValue({ + const entity = createMockEntity('test-entity', undefined, [ + { + type: 'hasPart', + targetRef: 'component:default/subcomponent1', + }, + ]); + (mockCatalog.getEntitiesByRefs as jest.Mock).mockResolvedValue({ items: [subcomponent1], }); @@ -502,7 +508,6 @@ describe('config', () => { authProvider: 'serviceAccount', }; - const entity = createMockEntity('test-entity'); const subcomponent1 = createMockEntity('sub1', {}, [ { type: 'partOf', targetRef: 'component:default/test-entity' }, ]); @@ -510,6 +515,11 @@ describe('config', () => { { type: 'partOf', targetRef: 'component:default/test-entity' }, ]); + const entity = createMockEntity('test-entity', undefined, [ + { type: 'hasPart', targetRef: 'component:default/sub1' }, + { type: 'hasPart', targetRef: 'component:default/sub2' }, + ]); + mockParseSubcomponentClusterConfigurations.mockReturnValue([ createMockSubcomponentClusterConfig('sub1', 'cluster1', 'ns1', [ 'app1', @@ -519,7 +529,7 @@ describe('config', () => { ]), ]); - (mockCatalog.getEntities as jest.Mock).mockResolvedValue({ + (mockCatalog.getEntitiesByRefs as jest.Mock).mockResolvedValue({ items: [subcomponent1, subcomponent2], }); diff --git a/workspaces/konflux/plugins/konflux-backend/src/helpers/config.ts b/workspaces/konflux/plugins/konflux-backend/src/helpers/config.ts index 671b8a042d..5d76ffe0d0 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/helpers/config.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/helpers/config.ts @@ -28,7 +28,7 @@ import { import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { KonfluxLogger } from './logger'; @@ -37,8 +37,9 @@ import { KonfluxLogger } from './logger'; * * In Konflux plugin, a "subcomponent" refers to a Backstage Component that has a * `subcomponentOf` relationship to the current Component being viewed. - * Backstage automatically creates a `partOf` relation from the subcomponent - * entity to the parent entity, which is what we query for. + * Backstage automatically creates a `hasPart` relation on the parent entity + * for each subcomponent, so we read those refs and batch-fetch the entities + * instead of scanning the entire catalog. * * @param entity - The main/parent entity to find related entities for * @param credentials - Backstage credentials for authentication @@ -53,20 +54,22 @@ const getRelatedEntities = async ( ): Promise => { try { if (catalog) { - const entityRef = stringifyEntityRef(entity); + const hasPartRefs = (entity.relations || []) + .filter(rel => rel.type === 'hasPart') + .map(rel => rel.targetRef); - const allEntitiesForFiltering = await catalog.getEntities( - {}, + if (hasPartRefs.length === 0) { + return []; + } + + const response = await catalog.getEntitiesByRefs( + { entityRefs: hasPartRefs }, { credentials }, ); - const filteredRelatedEntities = allEntitiesForFiltering.items.filter( - item => - item.relations?.some( - rel => rel.type === 'partOf' && rel.targetRef === entityRef, - ), - ); - return filteredRelatedEntities; + return response.items.filter( + (item): item is Entity => item !== undefined, + ); } return null; } catch (error) { From 66c85193233576200e0ae11834924984f60a4488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 11:19:27 +0100 Subject: [PATCH 2/6] perf(konflux): cache API clients and catalog lookups, strip managedFields - Cache CustomObjectsApi instances per cluster instead of creating new KubeConfig + client on every request. Auth headers are injected per-request via middleware, so cached clients are safe to share. - Add a 30s TTL cache for catalog entity/config/combination lookups to avoid duplicate calls across parallel resource requests. - Strip metadata.managedFields from K8s and Kubearchive responses to reduce payload size (~50% reduction). --- .../src/helpers/client-factory.ts | 64 +++++++++++- .../__tests__/kubearchive-service.test.ts | 24 ++--- .../__tests__/resource-fetcher.test.ts | 23 ++--- .../src/services/konflux-service.ts | 97 +++++++++++++++---- .../src/services/kubearchive-service.ts | 21 ++-- .../src/services/resource-fetcher.ts | 21 ++-- 6 files changed, 181 insertions(+), 69 deletions(-) diff --git a/workspaces/konflux/plugins/konflux-backend/src/helpers/client-factory.ts b/workspaces/konflux/plugins/konflux-backend/src/helpers/client-factory.ts index 95bc972a46..ff03fa25a2 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/helpers/client-factory.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/helpers/client-factory.ts @@ -14,10 +14,21 @@ * limitations under the License. */ import { KonfluxConfig } from '@red-hat-developer-hub/backstage-plugin-konflux-common'; -import type { KubeConfig } from '@kubernetes/client-node'; +import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'; import { KonfluxLogger } from './logger'; import { getKubeClient } from './kube-client'; +/** + * Cache for CustomObjectsApi clients keyed by "cluster:apiUrl". + * + * Per-request auth headers are injected via middleware in the callers, + * so cached clients are safe to share across users. + * + * Caching avoids creating a new KubeConfig, CustomObjectsApi, and TLS + * connection on every API call. + */ +const clientCache = new Map(); + /** * Creates a KubeConfig for connecting to a Kubernetes cluster. * @@ -94,3 +105,54 @@ export const createKubeConfig = async ( return null; } }; + +/** + * Returns a cached CustomObjectsApi client for the given cluster, creating + * one on first access. The client is safe to share across requests because + * auth headers are injected per-request via middleware. + * + * @param konfluxConfig - Konflux configuration + * @param cluster - Cluster name + * @param konfluxLogger - Logger instance + * @param useKubearchiveUrl - If true, targets the kubearchive API URL + * @returns CustomObjectsApi instance or null if client creation fails + */ +export const getOrCreateClient = async ( + konfluxConfig: KonfluxConfig | undefined, + cluster: string, + konfluxLogger: KonfluxLogger, + useKubearchiveUrl = false, +): Promise => { + if (!konfluxConfig) { + return null; + } + + const clusterConfig = konfluxConfig.clusters?.[cluster]; + const apiUrl = useKubearchiveUrl + ? clusterConfig?.kubearchiveApiUrl + : clusterConfig?.apiUrl; + + const cacheKey = `${cluster}:${apiUrl}`; + + const cached = clientCache.get(cacheKey); + if (cached) { + return cached; + } + + const kc = await createKubeConfig( + konfluxConfig, + cluster, + konfluxLogger, + clusterConfig?.serviceAccountToken, + useKubearchiveUrl, + ); + + if (!kc) { + return null; + } + + const { CustomObjectsApi } = await getKubeClient(); + const client = kc.makeApiClient(CustomObjectsApi); + clientCache.set(cacheKey, client); + return client; +}; diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/kubearchive-service.test.ts b/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/kubearchive-service.test.ts index 4d98df318b..f8ac4fcfdf 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/kubearchive-service.test.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/kubearchive-service.test.ts @@ -16,13 +16,13 @@ import { KubearchiveService } from '../kubearchive-service'; import { LoggerService } from '@backstage/backend-plugin-api'; -import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'; +import type { CustomObjectsApi } from '@kubernetes/client-node'; import { KonfluxConfig, K8sResourceCommonWithClusterInfo, } from '@red-hat-developer-hub/backstage-plugin-konflux-common'; import { KonfluxLogger } from '../../helpers/logger'; -import { createKubeConfig } from '../../helpers/client-factory'; +import { getOrCreateClient } from '../../helpers/client-factory'; import { getKubeClient } from '../../helpers/kube-client'; jest.mock('../../helpers/logger'); @@ -32,7 +32,6 @@ jest.mock('../../helpers/kube-client'); describe('KubearchiveService', () => { let mockLogger: jest.Mocked; let mockKonfluxLogger: jest.Mocked; - let mockKubeConfig: jest.Mocked; let mockCustomObjectsApi: jest.Mocked; let service: KubearchiveService; @@ -104,18 +103,13 @@ describe('KubearchiveService', () => { listNamespacedCustomObjectWithHttpInfo: jest.fn(), } as unknown as jest.Mocked; - mockKubeConfig = { - makeApiClient: jest.fn().mockReturnValue(mockCustomObjectsApi), - loadFromOptions: jest.fn(), - } as unknown as jest.Mocked; - ( KonfluxLogger as jest.MockedClass ).mockImplementation(() => mockKonfluxLogger); ( - createKubeConfig as jest.MockedFunction - ).mockResolvedValue(mockKubeConfig); + getOrCreateClient as jest.MockedFunction + ).mockResolvedValue(mockCustomObjectsApi); class MockObservable { constructor(public value: T) {} @@ -545,10 +539,10 @@ describe('KubearchiveService', () => { }); ( - createKubeConfig as jest.MockedFunction + getOrCreateClient as jest.MockedFunction ).mockResolvedValue(null); - // provide OIDC token so it passes token check and reaches createKubeConfig + // provide OIDC token so it passes token check and reaches getOrCreateClient await expect( service.fetchResources({ konfluxConfig, @@ -564,7 +558,7 @@ describe('KubearchiveService', () => { ).rejects.toThrow(`Cluster '${cluster}' not found`); expect(mockKonfluxLogger.error).toHaveBeenCalledWith( - 'Failed to create KubeConfig - cluster not found', + 'Failed to create API client - cluster not found', undefined, expect.objectContaining({ cluster, @@ -586,7 +580,7 @@ describe('KubearchiveService', () => { }); ( - createKubeConfig as jest.MockedFunction + getOrCreateClient as jest.MockedFunction ).mockResolvedValue(null); await expect( @@ -602,7 +596,7 @@ describe('KubearchiveService', () => { ).rejects.toThrow(`Cluster '${cluster}' not found`); expect(mockKonfluxLogger.error).toHaveBeenCalledWith( - 'Failed to create KubeConfig - cluster not found', + 'Failed to create API client - cluster not found', undefined, expect.objectContaining({ cluster, diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/resource-fetcher.test.ts b/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/resource-fetcher.test.ts index 92aceb9fdb..74c3d3660d 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/resource-fetcher.test.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/__tests__/resource-fetcher.test.ts @@ -20,10 +20,10 @@ import { FetchOptions, } from '../resource-fetcher'; import { LoggerService } from '@backstage/backend-plugin-api'; -import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'; +import type { CustomObjectsApi } from '@kubernetes/client-node'; import { K8sResourceCommonWithClusterInfo } from '@red-hat-developer-hub/backstage-plugin-konflux-common'; import { KubearchiveService } from '../kubearchive-service'; -import { createKubeConfig } from '../../helpers/client-factory'; +import { getOrCreateClient } from '../../helpers/client-factory'; import { KonfluxLogger } from '../../helpers/logger'; import { getKubeClient } from '../../helpers/kube-client'; @@ -36,7 +36,6 @@ describe('ResourceFetcherService', () => { let mockLogger: jest.Mocked; let mockKonfluxLogger: jest.Mocked; let mockKubearchiveService: jest.Mocked; - let mockKubeConfig: jest.Mocked; let mockCustomObjectsApi: jest.Mocked; let service: ResourceFetcherService; @@ -122,10 +121,6 @@ describe('ResourceFetcherService', () => { listNamespacedCustomObjectWithHttpInfo: jest.fn(), } as unknown as jest.Mocked; - mockKubeConfig = { - makeApiClient: jest.fn().mockReturnValue(mockCustomObjectsApi), - } as unknown as jest.Mocked; - ( KonfluxLogger as jest.MockedClass ).mockImplementation(() => mockKonfluxLogger); @@ -135,8 +130,8 @@ describe('ResourceFetcherService', () => { ).mockImplementation(() => mockKubearchiveService); ( - createKubeConfig as jest.MockedFunction - ).mockResolvedValue(mockKubeConfig); + getOrCreateClient as jest.MockedFunction + ).mockResolvedValue(mockCustomObjectsApi); class MockObservable { constructor(public value: T) {} @@ -174,11 +169,10 @@ describe('ResourceFetcherService', () => { expect(result.items).toEqual(mockItems); expect(result.continueToken).toBeUndefined(); - expect(createKubeConfig).toHaveBeenCalledWith( + expect(getOrCreateClient).toHaveBeenCalledWith( context.konfluxConfig, 'cluster1', mockKonfluxLogger, - 'service-token-123', ); expect( mockCustomObjectsApi.listNamespacedCustomObjectWithHttpInfo, @@ -301,11 +295,10 @@ describe('ResourceFetcherService', () => { await service.fetchFromKubernetes(context); - expect(createKubeConfig).toHaveBeenCalledWith( - expect.anything(), + expect(getOrCreateClient).toHaveBeenCalledWith( expect.anything(), + 'cluster1', expect.anything(), - 'oidc-token-456', ); expect(mockKonfluxLogger.debug).toHaveBeenCalledWith( 'Using OIDC token for authentication', @@ -423,7 +416,7 @@ describe('ResourceFetcherService', () => { it('should throw error when cluster not found', async () => { const context = createMockFetchContext(); ( - createKubeConfig as jest.MockedFunction + getOrCreateClient as jest.MockedFunction ).mockResolvedValue(null); await expect(service.fetchFromKubernetes(context)).rejects.toThrow( diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts index 559b034c5a..a47e04fa33 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts @@ -25,11 +25,14 @@ import { SubcomponentClusterConfig, Filters, K8sResourceCommonWithClusterInfo, + KonfluxConfig, PAGINATION_CONFIG, ClusterError, GroupVersionKind, } from '@red-hat-developer-hub/backstage-plugin-konflux-common'; +import { Entity } from '@backstage/catalog-model'; + import { createResourceWithClusterInfo, filterResourcesByApplication, @@ -50,6 +53,36 @@ import { KonfluxLogger } from '../helpers/logger'; import { validateUserEmailForImpersonation } from '../helpers/validation'; import { extractKubernetesErrorDetails } from '../helpers/error-extraction'; +const CATALOG_CACHE_TTL_MS = 30_000; // 30 seconds + +interface CacheEntry { + data: T; + expiry: number; +} + +/** + * Simple TTL cache for catalog lookups. Prevents duplicate catalog calls + * when multiple resource requests arrive in parallel for the same entity. + */ +class CatalogCache { + private cache = new Map>(); + + get(key: string): T | undefined { + const entry = this.cache.get(key); + if (entry && Date.now() < entry.expiry) { + return entry.data as T; + } + if (entry) { + this.cache.delete(key); + } + return undefined; + } + + set(key: string, data: T): void { + this.cache.set(key, { data, expiry: Date.now() + CATALOG_CACHE_TTL_MS }); + } +} + export interface AggregatedResourcesResponse { data: K8sResourceCommonWithClusterInfo[]; metadata?: { @@ -85,6 +118,7 @@ export class KonfluxService { private readonly catalog?: CatalogService; private readonly config: Config; private readonly resourceFetcher: ResourceFetcherService; + private readonly catalogCache = new CatalogCache(); constructor(config: Config, logger: LoggerService, catalog: CatalogService) { this.konfluxLogger = new KonfluxLogger(logger); @@ -167,10 +201,18 @@ export class KonfluxService { limitPerCluster: PAGINATION_CONFIG.DEFAULT_PAGE_SIZE, }; - // fetch the main entity - const entity = await this.catalog.getEntityByRef(entityRef, { - credentials, - }); + // fetch the main entity (cached to avoid duplicate lookups across parallel requests) + const entityCacheKey = `entity:${entityRef}`; + let entity = this.catalogCache.get(entityCacheKey); + if (!entity) { + entity = + (await this.catalog.getEntityByRef(entityRef, { + credentials, + })) ?? undefined; + if (entity) { + this.catalogCache.set(entityCacheKey, entity); + } + } if (!entity) { this.konfluxLogger.error('Entity not found', undefined, { entityRef, @@ -179,13 +221,21 @@ export class KonfluxService { throw new Error(`Entity not found: ${entityRef}`); } - const konfluxConfig = await getKonfluxConfig( - this.config, - entity, - credentials, - this.catalog, - this.konfluxLogger, - ); + // get Konflux config (cached) + const configCacheKey = `config:${entityRef}`; + let konfluxConfig = this.catalogCache.get(configCacheKey); + if (!konfluxConfig) { + konfluxConfig = await getKonfluxConfig( + this.config, + entity, + credentials, + this.catalog, + this.konfluxLogger, + ); + if (konfluxConfig) { + this.catalogCache.set(configCacheKey, konfluxConfig); + } + } if (!konfluxConfig) { this.konfluxLogger.warn('No Konflux configuration found', { @@ -195,14 +245,20 @@ export class KonfluxService { return { data: [] }; } - // determine cluster-namespace combinations - let combinations = await determineClusterNamespaceCombinations( - entity, - credentials, - konfluxConfig, - this.konfluxLogger, - this.catalog, - ); + // determine cluster-namespace combinations (cached) + const comboCacheKey = `combinations:${entityRef}`; + let combinations = + this.catalogCache.get(comboCacheKey); + if (!combinations) { + combinations = await determineClusterNamespaceCombinations( + entity, + credentials, + konfluxConfig, + this.konfluxLogger, + this.catalog, + ); + this.catalogCache.set(comboCacheKey, combinations); + } if (combinations.length === 0) { this.konfluxLogger.warn('No cluster-namespace combinations found', { @@ -292,7 +348,8 @@ export class KonfluxService { const aggregatedData = results .filter((r): r is NonNullable => !!r && !!r.items) .flatMap(result => { - const clusterInfo = konfluxConfig.clusters[result.combination.cluster]; + const clusterInfo = + konfluxConfig?.clusters?.[result.combination.cluster]; return result.items.map((item: K8sResourceCommonWithClusterInfo) => createResourceWithClusterInfo( item, diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/kubearchive-service.ts b/workspaces/konflux/plugins/konflux-backend/src/services/kubearchive-service.ts index 2655fb67eb..b8a09fe86b 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/kubearchive-service.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/kubearchive-service.ts @@ -22,7 +22,7 @@ import { import type { ObservableMiddleware } from '@kubernetes/client-node'; import { KonfluxLogger } from '../helpers/logger'; import { getAuthToken } from '../helpers/auth'; -import { createKubeConfig } from '../helpers/client-factory'; +import { getOrCreateClient } from '../helpers/client-factory'; import { getKubeClient } from '../helpers/kube-client'; /** @@ -81,16 +81,15 @@ export class KubearchiveService { { cluster, namespace, resource }, ); - const kc = await createKubeConfig( + const customApi = await getOrCreateClient( konfluxConfig, cluster, this.logger, - token, true, // useKubearchiveUrl = true ); - if (!kc) { + if (!customApi) { this.logger.error( - 'Failed to create KubeConfig - cluster not found', + 'Failed to create API client - cluster not found', undefined, { cluster, @@ -101,7 +100,7 @@ export class KubearchiveService { throw new Error(`Cluster '${cluster}' not found`); } - const { CustomObjectsApi, Observable } = await getKubeClient(); + const { Observable } = await getKubeClient(); const requestHeaders = { ...(requiresImpersonation && { @@ -130,8 +129,6 @@ export class KubearchiveService { middleware: [headerMiddleware], } : undefined; - - const customApi = kc.makeApiClient(CustomObjectsApi); const response = await customApi.listNamespacedCustomObjectWithHttpInfo( { group: apiGroup, @@ -169,7 +166,13 @@ export class KubearchiveService { } | undefined; - const items = responseBody?.items ?? []; + const items = (responseBody?.items ?? []).map(item => { + if (item.metadata?.managedFields) { + const { managedFields, ...rest } = item.metadata; + return { ...item, metadata: rest }; + } + return item; + }); const nextPageToken = responseBody?.metadata?.continue; this.logger.debug('Fetched items from Kubearchive', { diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/resource-fetcher.ts b/workspaces/konflux/plugins/konflux-backend/src/services/resource-fetcher.ts index d86244ab72..e3f8b68b78 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/resource-fetcher.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/resource-fetcher.ts @@ -26,7 +26,7 @@ import { } from '@red-hat-developer-hub/backstage-plugin-konflux-common'; import { KubearchiveService } from './kubearchive-service'; import uniqBy from 'lodash/uniqBy'; -import { createKubeConfig } from '../helpers/client-factory'; +import { getOrCreateClient } from '../helpers/client-factory'; import { KonfluxLogger } from '../helpers/logger'; import { getAuthToken } from '../helpers/auth'; import { getKubeClient } from '../helpers/kube-client'; @@ -125,15 +125,14 @@ export class ResourceFetcherService { }, ); - const kc = await createKubeConfig( + const customApi = await getOrCreateClient( konfluxConfig, cluster, this.konfluxLogger, - token, ); - if (!kc) { + if (!customApi) { this.konfluxLogger.error( - 'Failed to create KubeConfig - cluster not found', + 'Failed to create API client - cluster not found', undefined, { cluster, @@ -147,7 +146,7 @@ export class ResourceFetcherService { try { const { apiGroup, apiVersion, plural } = resourceModel; - const { CustomObjectsApi, Observable } = await getKubeClient(); + const { Observable } = await getKubeClient(); const requestHeaders = { ...(requiresImpersonation && { @@ -178,8 +177,6 @@ export class ResourceFetcherService { } : undefined; - const customApi = kc.makeApiClient(CustomObjectsApi); - const response = await customApi.listNamespacedCustomObjectWithHttpInfo( { group: apiGroup, @@ -220,7 +217,13 @@ export class ResourceFetcherService { } | undefined; - const items = responseBody?.items || []; + const items = (responseBody?.items || []).map(item => { + if (item.metadata?.managedFields) { + const { managedFields, ...rest } = item.metadata; + return { ...item, metadata: rest }; + } + return item; + }); const continueToken = responseBody?.metadata?.continue; this.konfluxLogger.info('Fetched resources from Kubernetes', { From 11d9f488b1e4cb764eb89328661b53aaa7a5e093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 11:22:48 +0100 Subject: [PATCH 3/6] perf(konflux): increase staleTime and disable refetchOnWindowFocus Increase react-query staleTime from 30s to 5min and disable refetchOnWindowFocus to reduce unnecessary re-fetches when switching browser tabs. --- .../plugins/konflux/src/api/KonfluxQueryProvider.tsx | 6 ++---- .../plugins/konflux/src/hooks/useKonfluxResource.ts | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/workspaces/konflux/plugins/konflux/src/api/KonfluxQueryProvider.tsx b/workspaces/konflux/plugins/konflux/src/api/KonfluxQueryProvider.tsx index 5b8b669011..e2b431017f 100644 --- a/workspaces/konflux/plugins/konflux/src/api/KonfluxQueryProvider.tsx +++ b/workspaces/konflux/plugins/konflux/src/api/KonfluxQueryProvider.tsx @@ -24,12 +24,10 @@ function getQueryClient() { queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 30 * 1000, // 30 seconds + staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 5 * 60 * 1000, // 5 minutes retry: 1, - // TODO: check if the bellow options are really needed - refetchOnWindowFocus: true, // refetch when window regains focus - refetchOnReconnect: true, // refetch when network reconnects + refetchOnWindowFocus: false, // do not refetch when window regains focus }, }, }); diff --git a/workspaces/konflux/plugins/konflux/src/hooks/useKonfluxResource.ts b/workspaces/konflux/plugins/konflux/src/hooks/useKonfluxResource.ts index 0cc54ae281..a9cfc969ed 100644 --- a/workspaces/konflux/plugins/konflux/src/hooks/useKonfluxResource.ts +++ b/workspaces/konflux/plugins/konflux/src/hooks/useKonfluxResource.ts @@ -186,10 +186,9 @@ export const useKonfluxResource = < getNextPageParam: lastPage => lastPage.continuationToken, initialPageParam: undefined as string | undefined, enabled: !!entity && !!resource && options?.enabled !== false, - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, - refetchOnWindowFocus: true, - refetchOnReconnect: true, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, }); const allData = query.data?.pages.flatMap(page => page.data) ?? []; From a6988635c05730148f9d08bfbbb71d74ecc68a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 11:39:32 +0100 Subject: [PATCH 4/6] chore(konflux): add changesets Add changesets with the changes made for both konflux and konflux-backend plugins. --- workspaces/konflux/.changeset/better-melons-shave.md | 7 +++++++ workspaces/konflux/.changeset/fifty-moose-appear.md | 5 +++++ 2 files changed, 12 insertions(+) create mode 100644 workspaces/konflux/.changeset/better-melons-shave.md create mode 100644 workspaces/konflux/.changeset/fifty-moose-appear.md diff --git a/workspaces/konflux/.changeset/better-melons-shave.md b/workspaces/konflux/.changeset/better-melons-shave.md new file mode 100644 index 0000000000..ba53f707d7 --- /dev/null +++ b/workspaces/konflux/.changeset/better-melons-shave.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-konflux-backend': patch +--- + +- Fix: replace unfiltered catalog scan in getRelatedEntities with targeted lookup using hasPart relations and getEntitiesByRefs. +- Perf: cache K8s API clients per cluster and catalog lookups (30s TTL) to avoid redundant work across parallel requests. +- Perf: strip metadata.managedFields from K8s and Kubearchive responses to reduce payload size. diff --git a/workspaces/konflux/.changeset/fifty-moose-appear.md b/workspaces/konflux/.changeset/fifty-moose-appear.md new file mode 100644 index 0000000000..4fc9d7f661 --- /dev/null +++ b/workspaces/konflux/.changeset/fifty-moose-appear.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-konflux': patch +--- + +- Increase react-query staleTime from 30s to 5min and disable refetchOnWindowFocus to reduce unnecessary re-fetches. From 760879861a8a63c1fab83f6cc01f5ff86d63d739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 13:50:26 +0100 Subject: [PATCH 5/6] fixup! perf(konflux): cache API clients and catalog lookups, strip managedFields --- .../src/services/konflux-service.ts | 128 ++++++++++++------ 1 file changed, 84 insertions(+), 44 deletions(-) diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts index a47e04fa33..55ae67d356 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts @@ -65,7 +65,7 @@ interface CacheEntry { * when multiple resource requests arrive in parallel for the same entity. */ class CatalogCache { - private cache = new Map>(); + private readonly cache = new Map>(); get(key: string): T | undefined { const entry = this.cache.get(key); @@ -175,6 +175,77 @@ export class KonfluxService { return filtered; } + /** + * Fetch and cache the entity from the catalog + */ + private async getCachedEntity( + entityRef: string, + credentials: BackstageCredentials, + ): Promise { + const cacheKey = `entity:${entityRef}`; + let entity = this.catalogCache.get(cacheKey); + if (!entity) { + entity = + (await this.catalog!.getEntityByRef(entityRef, { + credentials, + })) ?? undefined; + if (entity) { + this.catalogCache.set(cacheKey, entity); + } + } + return entity; + } + + /** + * Fetch and cache the Konflux configuration for an entity + */ + private async getCachedKonfluxConfig( + entityRef: string, + entity: Entity, + credentials: BackstageCredentials, + ): Promise { + const cacheKey = `config:${entityRef}`; + let konfluxConfig = this.catalogCache.get(cacheKey); + if (!konfluxConfig) { + konfluxConfig = await getKonfluxConfig( + this.config, + entity, + credentials, + this.catalog!, + this.konfluxLogger, + ); + if (konfluxConfig) { + this.catalogCache.set(cacheKey, konfluxConfig); + } + } + return konfluxConfig; + } + + /** + * Fetch and cache cluster-namespace combinations for an entity + */ + private async getCachedCombinations( + entityRef: string, + entity: Entity, + credentials: BackstageCredentials, + konfluxConfig: KonfluxConfig, + ): Promise { + const cacheKey = `combinations:${entityRef}`; + let combinations = + this.catalogCache.get(cacheKey); + if (!combinations) { + combinations = await determineClusterNamespaceCombinations( + entity, + credentials, + konfluxConfig, + this.konfluxLogger, + this.catalog!, + ); + this.catalogCache.set(cacheKey, combinations); + } + return combinations; + } + /** * Aggregate resources from multiple clusters based on entity configuration */ @@ -201,18 +272,7 @@ export class KonfluxService { limitPerCluster: PAGINATION_CONFIG.DEFAULT_PAGE_SIZE, }; - // fetch the main entity (cached to avoid duplicate lookups across parallel requests) - const entityCacheKey = `entity:${entityRef}`; - let entity = this.catalogCache.get(entityCacheKey); - if (!entity) { - entity = - (await this.catalog.getEntityByRef(entityRef, { - credentials, - })) ?? undefined; - if (entity) { - this.catalogCache.set(entityCacheKey, entity); - } - } + const entity = await this.getCachedEntity(entityRef, credentials); if (!entity) { this.konfluxLogger.error('Entity not found', undefined, { entityRef, @@ -221,22 +281,11 @@ export class KonfluxService { throw new Error(`Entity not found: ${entityRef}`); } - // get Konflux config (cached) - const configCacheKey = `config:${entityRef}`; - let konfluxConfig = this.catalogCache.get(configCacheKey); - if (!konfluxConfig) { - konfluxConfig = await getKonfluxConfig( - this.config, - entity, - credentials, - this.catalog, - this.konfluxLogger, - ); - if (konfluxConfig) { - this.catalogCache.set(configCacheKey, konfluxConfig); - } - } - + const konfluxConfig = await this.getCachedKonfluxConfig( + entityRef, + entity, + credentials, + ); if (!konfluxConfig) { this.konfluxLogger.warn('No Konflux configuration found', { entityRef, @@ -245,21 +294,12 @@ export class KonfluxService { return { data: [] }; } - // determine cluster-namespace combinations (cached) - const comboCacheKey = `combinations:${entityRef}`; - let combinations = - this.catalogCache.get(comboCacheKey); - if (!combinations) { - combinations = await determineClusterNamespaceCombinations( - entity, - credentials, - konfluxConfig, - this.konfluxLogger, - this.catalog, - ); - this.catalogCache.set(comboCacheKey, combinations); - } - + let combinations = await this.getCachedCombinations( + entityRef, + entity, + credentials, + konfluxConfig, + ); if (combinations.length === 0) { this.konfluxLogger.warn('No cluster-namespace combinations found', { entityRef, From ad350d97436c0da626148f43deb2e97844551d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20R=2E=20Galv=C3=A3o?= Date: Fri, 27 Mar 2026 14:11:03 +0100 Subject: [PATCH 6/6] fixup! perf(konflux): cache API clients and catalog lookups, strip managedFields --- .../src/services/konflux-service.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts index 55ae67d356..2cd7b25a4c 100644 --- a/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts +++ b/workspaces/konflux/plugins/konflux-backend/src/services/konflux-service.ts @@ -181,8 +181,9 @@ export class KonfluxService { private async getCachedEntity( entityRef: string, credentials: BackstageCredentials, + userId: string, ): Promise { - const cacheKey = `entity:${entityRef}`; + const cacheKey = `entity:${entityRef}:${userId}`; let entity = this.catalogCache.get(cacheKey); if (!entity) { entity = @@ -203,8 +204,9 @@ export class KonfluxService { entityRef: string, entity: Entity, credentials: BackstageCredentials, + userId: string, ): Promise { - const cacheKey = `config:${entityRef}`; + const cacheKey = `config:${entityRef}:${userId}`; let konfluxConfig = this.catalogCache.get(cacheKey); if (!konfluxConfig) { konfluxConfig = await getKonfluxConfig( @@ -229,8 +231,9 @@ export class KonfluxService { entity: Entity, credentials: BackstageCredentials, konfluxConfig: KonfluxConfig, + userId: string, ): Promise { - const cacheKey = `combinations:${entityRef}`; + const cacheKey = `combinations:${entityRef}:${userId}`; let combinations = this.catalogCache.get(cacheKey); if (!combinations) { @@ -272,7 +275,9 @@ export class KonfluxService { limitPerCluster: PAGINATION_CONFIG.DEFAULT_PAGE_SIZE, }; - const entity = await this.getCachedEntity(entityRef, credentials); + const userId = userEntityRef || userEmail || 'unknown'; + + const entity = await this.getCachedEntity(entityRef, credentials, userId); if (!entity) { this.konfluxLogger.error('Entity not found', undefined, { entityRef, @@ -285,6 +290,7 @@ export class KonfluxService { entityRef, entity, credentials, + userId, ); if (!konfluxConfig) { this.konfluxLogger.warn('No Konflux configuration found', { @@ -299,6 +305,7 @@ export class KonfluxService { entity, credentials, konfluxConfig, + userId, ); if (combinations.length === 0) { this.konfluxLogger.warn('No cluster-namespace combinations found', { @@ -320,8 +327,6 @@ export class KonfluxService { let paginationState: PaginationState = {}; const isLoadMoreRequest = !!validatedFilters?.continuationToken; - const userId = userEntityRef || userEmail || 'unknown'; - if (validatedFilters?.continuationToken) { try { paginationState = decodeContinuationToken(