From 847373c159b444f527ebf86c50c13e6b487ee9bf Mon Sep 17 00:00:00 2001 From: Rudraksha Singh Sengar Date: Sun, 17 May 2026 18:55:52 +0530 Subject: [PATCH] frontend: api/v2: Include class identity in kube object query keys The query keys used by `useKubeObjectList` and `useKubeObject` are derived from `apiVersion` + `apiName` + cluster + namespace + name + queryParams. They omit the class itself, so two distinct KubeObject subclasses that target the same API endpoint share a cache entry. Concretely: a plugin defines `class MyPod extends KubeObject` with `apiVersion = 'v1'` and `apiName = 'pods'` to render its own detail route. Headlamp's built-in Pod uses the same `apiVersion` and `apiName`. The two `useList()` calls produce the same query key, so react-query runs only one queryFn and serves both call sites from the first class's cached items. `_class()`, `detailsRoute`, and `getDetailsLink()` then resolve to whichever class loaded first rather than the class the caller actually used. Adds `kubeObjectClass.name` to the list query key in `kubeObjectListQuery` and threads a new `kubeObjectClassName` field through `kubeObjectQueryKey` for the single-object hook. Updates the two callers of `kubeObjectQueryKey` (`useKubeObject` and `Link`) to pass the class name. The hardcoded query-key arrays in `useKubeObjectList.test.tsx` are updated to match the new shape. Adds a `kubeObjectListQuery query key` describe block covering two cases: two distinct classes targeting the same endpoint produce distinct keys, and the same class with the same arguments produces identical keys. Note: this fix relies on plugin-authored classes carrying a JS class name that differs from the built-in's (the reported repro uses `MyPod` vs `Pod`). Plugins that define a custom class with the same JS name as a built-in will still collide; a follow-up could expose an explicit static cache-key field on `KubeObject` for that edge case. Bug report: #4780. Signed-off-by: Rudraksha Singh Sengar --- frontend/src/components/common/Link.tsx | 4 +- frontend/src/lib/k8s/api/v2/hooks.test.tsx | 165 +++++++++++++++++- frontend/src/lib/k8s/api/v2/hooks.ts | 71 +++++--- frontend/src/lib/k8s/api/v2/queryKeys.ts | 104 +++++++++++ .../lib/k8s/api/v2/useKubeObjectList.test.tsx | 115 ++++++++++-- .../src/lib/k8s/api/v2/useKubeObjectList.ts | 10 +- 6 files changed, 418 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/k8s/api/v2/queryKeys.ts diff --git a/frontend/src/components/common/Link.tsx b/frontend/src/components/common/Link.tsx index 90fe82e236a..8a4f6cab5a4 100644 --- a/frontend/src/components/common/Link.tsx +++ b/frontend/src/components/common/Link.tsx @@ -19,7 +19,8 @@ import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { formatClusterPathParam, getCluster, getSelectedClusters } from '../../lib/cluster'; -import { kubeObjectQueryKey, useEndpoints } from '../../lib/k8s/api/v2/hooks'; +import { useEndpoints } from '../../lib/k8s/api/v2/hooks'; +import { getKubeObjectClassCacheKey, kubeObjectQueryKey } from '../../lib/k8s/api/v2/queryKeys'; import type { KubeObject } from '../../lib/k8s/KubeObject'; import type { RouteURLProps } from '../../lib/router/createRouteURL'; import { createRouteURL } from '../../lib/router/createRouteURL'; @@ -74,6 +75,7 @@ function KubeObjectLink(props: { endpoint, namespace, name, + kubeObjectClassCacheKey: getKubeObjectClassCacheKey(kubeObject._class()), }); // prepopulate the query cache with existing object client.setQueryData(key, kubeObject); diff --git a/frontend/src/lib/k8s/api/v2/hooks.test.tsx b/frontend/src/lib/k8s/api/v2/hooks.test.tsx index d91d8a4b61d..c34e3a4dbd4 100644 --- a/frontend/src/lib/k8s/api/v2/hooks.test.tsx +++ b/frontend/src/lib/k8s/api/v2/hooks.test.tsx @@ -18,10 +18,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; +import type { QueryParameters } from '../v1/queryParameters'; import { ApiError } from './ApiError'; import { clusterFetch } from './fetch'; -import { useEndpoints, useKubeObject } from './hooks'; +import { useEndpoints, useKubeObject, useStableCleanedQueryParams } from './hooks'; import type { KubeObjectEndpoint } from './KubeObjectEndpoint'; +import { getKubeObjectClassCacheKey } from './queryKeys'; vi.mock('./fetch', () => ({ clusterFetch: vi.fn(), @@ -397,3 +399,164 @@ describe('useKubeObject watch wiring', () => { }); }); }); + +describe('useKubeObject class-identity cache key', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.unstubAllEnvs()); + + it('produces distinct query entries for two classes with the same JS name and endpoint', async () => { + vi.stubEnv('REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER', 'false'); + mockClusterFetch.mockResolvedValue( + mockJsonResponse({ + apiVersion: 'v1', + kind: 'Pod', + metadata: { name: 'my-pod', namespace: 'my-ns' }, + }) + ); + + const PodA = class Pod { + static apiEndpoint = { + apiInfo: [{ version: 'v1', resource: 'pods' }] as KubeObjectEndpoint[], + }; + constructor(public jsonData: any, public cluster?: string) {} + } as any; + const PodB = class Pod { + static apiEndpoint = { + apiInfo: [{ version: 'v1', resource: 'pods' }] as KubeObjectEndpoint[], + }; + constructor(public jsonData: any, public cluster?: string) {} + } as any; + + expect(PodA.name).toBe(PodB.name); + expect(PodA).not.toBe(PodB); + expect(getKubeObjectClassCacheKey(PodA)).not.toBe(getKubeObjectClassCacheKey(PodB)); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const resultA = renderHook( + () => + useKubeObject({ + kubeObjectClass: PodA, + name: 'my-pod', + namespace: 'my-ns', + cluster: 'test', + }), + { wrapper } + ); + const resultB = renderHook( + () => + useKubeObject({ + kubeObjectClass: PodB, + name: 'my-pod', + namespace: 'my-ns', + cluster: 'test', + }), + { wrapper } + ); + + await waitFor(() => { + expect(resultA.result.current.data).toBeTruthy(); + expect(resultB.result.current.data).toBeTruthy(); + }); + + expect(resultA.result.current.data).toBeInstanceOf(PodA); + expect(resultB.result.current.data).toBeInstanceOf(PodB); + expect(resultA.result.current.data).not.toBeInstanceOf(PodB); + expect(resultB.result.current.data).not.toBeInstanceOf(PodA); + + const objectQueries = queryClient + .getQueryCache() + .getAll() + .filter(q => Array.isArray(q.queryKey) && q.queryKey[0] === 'object'); + expect(objectQueries).toHaveLength(2); + }); + + it('agrees on the same discriminator across separate module instances of queryKeys', async () => { + // Surrogate for the plugin SDK shipping its own copy of `frontend/src/lib/**`: + // re-import `queryKeys` from a fresh module instance and verify that calling + // `getKubeObjectClassCacheKey(Cls)` from either instance yields the same id + // for the same class. The globalThis-stored registry under `Symbol.for(...)` + // means the second call reads the value the first call stamped, regardless + // of module identity. + const SharedCls = class Shared {} as any; + const first = getKubeObjectClassCacheKey(SharedCls); + + vi.resetModules(); + const fresh = await import('./queryKeys'); + expect(fresh.getKubeObjectClassCacheKey).not.toBe(getKubeObjectClassCacheKey); + const second = fresh.getKubeObjectClassCacheKey(SharedCls); + + expect(second).toBe(first); + }); +}); + +describe('useStableCleanedQueryParams', () => { + it('returns the same reference when the cleaned entries are unchanged across rerenders', () => { + const { result, rerender } = renderHook< + ReturnType, + { qp: QueryParameters | undefined } + >(({ qp }) => useStableCleanedQueryParams(qp), { + initialProps: { qp: { labelSelector: 'a=b' } }, + }); + const first = result.current; + + // New input object, structurally equal content → same returned reference. + rerender({ qp: { labelSelector: 'a=b' } }); + expect(result.current).toBe(first); + }); + + it('returns the same reference regardless of key insertion order', () => { + const { result, rerender } = renderHook< + ReturnType, + { qp: QueryParameters | undefined } + >(({ qp }) => useStableCleanedQueryParams(qp), { + initialProps: { qp: { labelSelector: 'a=b', fieldSelector: 'metadata.name=foo' } }, + }); + const first = result.current; + + rerender({ qp: { fieldSelector: 'metadata.name=foo', labelSelector: 'a=b' } }); + expect(result.current).toBe(first); + }); + + it('treats undefined/empty values as equivalent to the entry being absent', () => { + const { result, rerender } = renderHook< + ReturnType, + { qp: QueryParameters | undefined } + >(({ qp }) => useStableCleanedQueryParams(qp), { + initialProps: { qp: { labelSelector: 'a=b' } }, + }); + const first = result.current; + + rerender({ qp: { labelSelector: 'a=b', fieldSelector: '' } }); + expect(result.current).toBe(first); + + rerender({ qp: { labelSelector: 'a=b', fieldSelector: undefined } }); + expect(result.current).toBe(first); + }); + + it('returns a new reference when an effective value actually changes', () => { + const { result, rerender } = renderHook< + ReturnType, + { qp: QueryParameters | undefined } + >(({ qp }) => useStableCleanedQueryParams(qp), { + initialProps: { qp: { labelSelector: 'a=b' } }, + }); + const first = result.current; + + rerender({ qp: { labelSelector: 'a=c' } }); + expect(result.current).not.toBe(first); + expect(result.current).toEqual({ labelSelector: 'a=c' }); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/hooks.ts b/frontend/src/lib/k8s/api/v2/hooks.ts index 70519c26744..b851a42b878 100644 --- a/frontend/src/lib/k8s/api/v2/hooks.ts +++ b/frontend/src/lib/k8s/api/v2/hooks.ts @@ -25,8 +25,12 @@ import type { KubeListUpdateEvent } from './KubeList'; import { KubeObjectEndpoint } from './KubeObjectEndpoint'; import { makeUrl } from './makeUrl'; import { useWebSocket } from './multiplexer'; +import { + getKubeObjectClassCacheKey, + getWebsocketMultiplexerEnabled, + kubeObjectQueryKey, +} from './queryKeys'; import { kubeRequestRetry } from './retry'; -import { getWebsocketMultiplexerEnabled } from './useKubeObjectList'; import { useWebSockets } from './webSocket'; export type QueryStatus = 'pending' | 'success' | 'error'; @@ -82,20 +86,6 @@ export interface QueryListResponse errors: ApiError[] | null; } -export const kubeObjectQueryKey = ({ - cluster, - endpoint, - namespace, - name, - queryParams, -}: { - cluster: string; - endpoint?: KubeObjectEndpoint | null; - namespace?: string; - name: string; - queryParams?: QueryParameters; -}) => ['object', cluster, endpoint, namespace ?? '', name, queryParams ?? {}]; - /** * Returns a single KubeObject. */ @@ -124,15 +114,19 @@ export function useKubeObject({ name ); - const cleanedUpQueryParams = Object.fromEntries( - Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '') - ); + const cleanedUpQueryParams = useStableCleanedQueryParams(queryParams); const queryKey = useMemo( () => - kubeObjectQueryKey({ cluster, name, namespace, endpoint, queryParams: cleanedUpQueryParams }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [endpoint, namespace, name] + kubeObjectQueryKey({ + cluster, + name, + namespace, + endpoint, + queryParams: cleanedUpQueryParams, + kubeObjectClassCacheKey: getKubeObjectClassCacheKey(kubeObjectClass), + }), + [cluster, endpoint, namespace, name, kubeObjectClass, cleanedUpQueryParams] ); const client = useQueryClient(); @@ -174,8 +168,7 @@ export function useKubeObject({ }, }, ]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [endpoint]); + }, [endpoint, namespace, name, cluster, cleanedUpQueryParams, kubeObjectClass, queryKey, client]); const multiplexerEnabled = getWebsocketMultiplexerEnabled(); @@ -284,3 +277,35 @@ export const useEndpoints = ( return { endpoint, error }; }; + +/** + * Filters `queryParams` to drop undefined/empty values and returns a reference + * that's stable across renders when the resulting set of entries is unchanged + * (regardless of input reference identity or key insertion order). The + * stability matters for downstream effect deps (the legacy WebSocket + * connection in `useKubeObject`) that would otherwise churn on each render. + * + * Exported so the contract (stability + reference equality across reorders) + * can be exercised directly in unit tests. + */ +export function useStableCleanedQueryParams( + queryParams: QueryParameters | undefined +): QueryParameters { + const key = useMemo( + () => + JSON.stringify( + Object.entries(queryParams ?? {}) + .filter(([, value]) => value !== undefined && value !== '') + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ), + [queryParams] + ); + return useMemo( + () => + Object.fromEntries( + Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '') + ) as QueryParameters, + // eslint-disable-next-line react-hooks/exhaustive-deps -- proxied via the stringified key above + [key] + ); +} diff --git a/frontend/src/lib/k8s/api/v2/queryKeys.ts b/frontend/src/lib/k8s/api/v2/queryKeys.ts new file mode 100644 index 00000000000..aa5a4b1e664 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/queryKeys.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { KubeObjectClass } from '../../KubeObject'; +import type { QueryParameters } from '../v1/queryParameters'; +import type { KubeObjectEndpoint } from './KubeObjectEndpoint'; + +/** + * Feature flag for the websocket multiplexer. Reads + * `REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER`; only the literal string `'true'` + * enables it, so the multiplexer is opt-in and defaults to disabled when the + * env var is unset or any other value. + */ +export function getWebsocketMultiplexerEnabled(): boolean { + return import.meta.env.REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER === 'true'; +} + +/** + * Stable cache-key discriminator per KubeObject class so two classes hitting + * the same API endpoint (e.g. plugin MyPod vs built-in Pod) get separate + * react-query cache entries (#4780). The class→id map is stored on `globalThis` + * under a `Symbol.for(...)` key so the plugin SDK's bundled copy of + * `frontend/src/lib/**` shares the same lookup as the app; a WeakMap is keyed + * by the class constructor reference (so subclasses don't inherit the parent's + * id and unused classes can be GC'd); a manual `randomUUID` fallback covers + * environments where `crypto.randomUUID` isn't present. + */ +const REGISTRY_KEY = Symbol.for('headlamp.kubeObjectClassCacheKey'); + +function getClassIdRegistry(): WeakMap { + const slot = globalThis as unknown as { [key: symbol]: unknown }; + let registry = slot[REGISTRY_KEY] as WeakMap | undefined; + if (!registry) { + registry = new WeakMap(); + slot[REGISTRY_KEY] = registry; + } + return registry; +} + +function randomId(): string { + const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; + if (typeof c?.randomUUID === 'function') { + return c.randomUUID(); + } + // Fallback for environments without `crypto.randomUUID`. Not cryptographically + // secure, but we only need per-class uniqueness within a session. + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; +} + +export function getKubeObjectClassCacheKey(cls: KubeObjectClass): string { + const registry = getClassIdRegistry(); + let id = registry.get(cls); + if (id === undefined) { + id = `cls-${randomId()}-${cls.name || 'anon'}`; + registry.set(cls, id); + } + return id; +} + +/** + * Builds the react-query cache key for a single KubeObject GET. + */ +export const kubeObjectQueryKey = ({ + cluster, + endpoint, + namespace, + name, + queryParams, + kubeObjectClassCacheKey, +}: { + cluster: string; + endpoint?: KubeObjectEndpoint | null; + namespace?: string; + name: string; + queryParams?: QueryParameters; + /** + * Stable per-class cache discriminator from `getKubeObjectClassCacheKey`. + * Used to separate cache entries between two classes targeting the same + * API endpoint (e.g. a plugin's MyPod and the built-in Pod), so `_class()` + * resolves correctly on each (#4780). + */ + kubeObjectClassCacheKey?: string; +}) => [ + 'object', + cluster, + endpoint, + namespace ?? '', + name, + kubeObjectClassCacheKey ?? '', + queryParams ?? {}, +]; diff --git a/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx b/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx index a4f8d8c41b0..81bfb42c83f 100644 --- a/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx +++ b/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx @@ -314,16 +314,23 @@ describe('useKubeObjectList', () => { const spy = vi.spyOn(websocket, 'useWebSockets'); const queryClient = new QueryClient(); - queryClient.setQueryData(['kubeObject', 'list', 'v1', 'pods', 'default', 'a', {}], { - list: { items: [], metadata: { resourceVersion: '0' } }, - cluster: 'default', - namespace: 'a', - }); - queryClient.setQueryData(['kubeObject', 'list', 'v1', 'pods', 'default', 'b', {}], { - list: { items: [], metadata: { resourceVersion: '0' } }, - cluster: 'default', - namespace: 'b', - }); + const podsEndpoint = { version: 'v1', resource: 'pods' }; + queryClient.setQueryData( + kubeObjectListQuery(mockClass, podsEndpoint, 'a', 'default', {}).queryKey, + { + list: { items: [], metadata: { resourceVersion: '0' } }, + cluster: 'default', + namespace: 'a', + } + ); + queryClient.setQueryData( + kubeObjectListQuery(mockClass, podsEndpoint, 'b', 'default', {}).queryKey, + { + list: { items: [], metadata: { resourceVersion: '0' } }, + cluster: 'default', + namespace: 'b', + } + ); const result = renderHook( (props: {}) => @@ -351,14 +358,21 @@ describe('useKubeObjectList', () => { const spy = vi.spyOn(websocket, 'useWebSockets'); const queryClient = new QueryClient(); - queryClient.setQueryData(['kubeObject', 'list', 'v1', 'nodes', 'cluster-1', '', {}], { - list: { items: [], metadata: { resourceVersion: '0' } }, - cluster: 'cluster-1', - }); - queryClient.setQueryData(['kubeObject', 'list', 'v1', 'nodes', 'cluster-2', '', {}], { - list: { items: [], metadata: { resourceVersion: '0' } }, - cluster: 'cluster-2', - }); + const nodesEndpoint = { version: 'v1', resource: 'nodes' }; + queryClient.setQueryData( + kubeObjectListQuery(mockNodeClass, nodesEndpoint, undefined, 'cluster-1', {}).queryKey, + { + list: { items: [], metadata: { resourceVersion: '0' } }, + cluster: 'cluster-1', + } + ); + queryClient.setQueryData( + kubeObjectListQuery(mockNodeClass, nodesEndpoint, undefined, 'cluster-2', {}).queryKey, + { + list: { items: [], metadata: { resourceVersion: '0' } }, + cluster: 'cluster-2', + } + ); const result = renderHook( (props: { requests: Array<{ cluster: string; namespaces?: string[] }> }) => @@ -498,3 +512,68 @@ describe('useWatchKubeObjectLists (Multiplexer)', () => { expect(spy).toHaveBeenCalledWith({ enabled: false, connections: [] }); }); }); + +describe('kubeObjectListQuery query key', () => { + const endpoint = { version: 'v1', resource: 'pods' }; + const cluster = 'default'; + const namespace = 'ns'; + const queryParams = {}; + + it('separates cache entries for distinct classes that target the same API (#4780)', () => { + const builtInPodClass = class { + static apiVersion = 'v1'; + static apiName = 'pods'; + } as any; + const pluginPodClass = class { + static apiVersion = 'v1'; + static apiName = 'pods'; + } as any; + + const builtInKey = kubeObjectListQuery( + builtInPodClass, + endpoint, + namespace, + cluster, + queryParams + ).queryKey; + const pluginKey = kubeObjectListQuery( + pluginPodClass, + endpoint, + namespace, + cluster, + queryParams + ).queryKey; + + expect(builtInKey).not.toEqual(pluginKey); + // Both keys still share the apiVersion + apiName segments; only the + // per-class discriminator differentiates them. + expect(builtInKey).toContain('v1'); + expect(builtInKey).toContain('pods'); + expect(pluginKey).toContain('v1'); + expect(pluginKey).toContain('pods'); + }); + + it('separates cache entries even when two distinct classes share a JS name (#4780)', () => { + const A = class Pod { + static apiVersion = 'v1'; + static apiName = 'pods'; + } as any; + const B = class Pod { + static apiVersion = 'v1'; + static apiName = 'pods'; + } as any; + expect(A).not.toBe(B); + expect(A.name).toBe(B.name); + + const keyA = kubeObjectListQuery(A, endpoint, namespace, cluster, queryParams).queryKey; + const keyB = kubeObjectListQuery(B, endpoint, namespace, cluster, queryParams).queryKey; + expect(keyA).not.toEqual(keyB); + }); + + it('produces the same cache key for the same class and arguments', () => { + const keyA = kubeObjectListQuery(mockClass, endpoint, namespace, cluster, queryParams).queryKey; + const keyB = kubeObjectListQuery(mockClass, endpoint, namespace, cluster, queryParams).queryKey; + + expect(keyA).toEqual(keyB); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts index c6e2fdf1ddd..7af749d821b 100644 --- a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts +++ b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts @@ -28,17 +28,10 @@ import { KubeList } from './KubeList'; import { KubeObjectEndpoint } from './KubeObjectEndpoint'; import { makeUrl } from './makeUrl'; import { WebSocketManager } from './multiplexer'; +import { getKubeObjectClassCacheKey, getWebsocketMultiplexerEnabled } from './queryKeys'; import { kubeRequestRetry } from './retry'; import { BASE_WS_URL, useWebSockets } from './webSocket'; -/** - * @returns true if the websocket multiplexer is enabled. - * defaults to true. This is a feature flag to enable the websocket multiplexer. - */ -export function getWebsocketMultiplexerEnabled(): boolean { - return import.meta.env.REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER === 'true'; -} - /** * Object representing a List of Kube object * with information about which cluster and namespace it came from @@ -79,6 +72,7 @@ export function kubeObjectListQuery( 'list', kubeObjectClass.apiVersion, kubeObjectClass.apiName, + getKubeObjectClassCacheKey(kubeObjectClass), cluster, namespace ?? '', queryParams,