diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java index c6d0805fbca7..40b49f18e986 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java @@ -16,6 +16,7 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.Provider; import org.openmetadata.schema.EntityInterface; @@ -23,23 +24,73 @@ import org.openmetadata.service.util.EntityETag; /** - * JAX-RS filter that automatically adds ETag headers to GET responses - * containing EntityInterface entities. + * JAX-RS filter that adds {@code ETag} + {@code Cache-Control} headers to entity GET responses + * and short-circuits to {@code 304 Not Modified} when the client's {@code If-None-Match} matches + * the computed ETag. + * + *

The 304 path saves the response body bytes on the wire and the client-side render cost on + * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup + * to truly skip the work, see design doc), but the network and client savings are immediate. + * + *

{@code Cache-Control: must-revalidate, private}: clients (browsers, our Axios interceptor) + * may keep the body but must revalidate via {@code If-None-Match} before reusing it; private + * keeps it out of any shared/proxy cache so per-user data doesn't leak. */ @Provider public class ETagResponseFilter implements ContainerResponseFilter { + private static final String CACHE_CONTROL_VALUE = "must-revalidate, private"; + @Override public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) { try (var ignored = RequestLatencyContext.phase("etagGeneration")) { - if ("GET".equals(requestContext.getMethod()) - && responseContext.getStatus() == Response.Status.OK.getStatusCode() - && responseContext.getEntity() instanceof EntityInterface entity) { + if (!"GET".equals(requestContext.getMethod()) + || responseContext.getStatus() != Response.Status.OK.getStatusCode() + || !(responseContext.getEntity() instanceof EntityInterface entity)) { + return; + } + + String etag = EntityETag.generateETag(entity); + if (etag == null) { + return; + } + responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag); + responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE); + + String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH); + if (ifNoneMatch == null) { + return; + } + if (matchesAny(ifNoneMatch, etag)) { + // RFC 7232: 304 must NOT include a message body. Drop the entity so the + // serializer emits an empty body. Headers (including ETag) are preserved. + responseContext.setStatus(Response.Status.NOT_MODIFIED.getStatusCode()); + responseContext.setEntity(null); + } + } + } - String etag = EntityETag.generateETag(entity); - responseContext.getHeaders().add("ETag", etag); + /** + * RFC 7232 §3.2: {@code If-None-Match} can be {@code *} (match any), a single ETag, or a + * comma-separated list. Weak comparison is used — we treat {@code "abc"} and {@code W/"abc"} + * as matching, which is the spec's recommendation for cache-validation use. + */ + private static boolean matchesAny(String ifNoneMatch, String currentEtag) { + String trimmed = ifNoneMatch.trim(); + if ("*".equals(trimmed)) { + return true; + } + String currentBare = stripWeakPrefix(currentEtag); + for (String candidate : trimmed.split(",")) { + if (currentBare.equals(stripWeakPrefix(candidate.trim()))) { + return true; } } + return false; + } + + private static String stripWeakPrefix(String etag) { + return etag.startsWith("W/") ? etag.substring(2) : etag; } } diff --git a/openmetadata-ui/src/main/resources/ui/jest.config.js b/openmetadata-ui/src/main/resources/ui/jest.config.js index f9b94ac41c31..a0e1f01a2149 100644 --- a/openmetadata-ui/src/main/resources/ui/jest.config.js +++ b/openmetadata-ui/src/main/resources/ui/jest.config.js @@ -75,6 +75,16 @@ module.exports = { '/src/test/unit/mocks/reactColumnResize.mock.js', '^.*/Lineage/Layout/ELKUtil/ELKUtil$': '/src/test/unit/mocks/elkLayout.mock.js', + // Force every `require('react')` / `require('react-dom')` to resolve to the consumer's + // copy. The `openmetadata-ui-core-components` package has its own `node_modules/react` + // (for its own dev/test) — without these mappings the CJS bundle loaded from + // `dist/*.cjs.js` resolves React from the core-components tree, producing a second React + // instance with a null hooks dispatcher and the classic "Invalid hook call ... reading + // 'useContext'" TypeError. + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + '^react/(.*)$': '/node_modules/react/$1', + '^react-dom/(.*)$': '/node_modules/react-dom/$1', }, transformIgnorePatterns: [ 'node_modules/(?!(@azure/msal-react|react-dnd|react-dnd-html5-backend|dnd-core|@react-dnd/invariant|@react-dnd/asap|@react-dnd/shallowequal|@melloware/react-logviewer|@material/material-color-utilities|@openmetadata/ui-core-components|nanoid|@rjsf/core|@rjsf/utils|@rjsf/validator-ajv8|uuid|elkjs))', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index 74ecf1275491..f927e1d2a9da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -54,6 +54,7 @@ import { withDomainFilter } from '../../../hoc/withDomainFilter'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import axiosClient from '../../../rest'; +import { clearEtagCache } from '../../../rest/etagInterceptor'; import { fetchAuthenticationConfig, fetchAuthorizerConfig, @@ -218,6 +219,10 @@ export const AuthProvider = ({ // Clear tokens properly during logout await clearOidcToken(); + // Drop the ETag interceptor's response cache so a freshly-authenticated user can't + // pick up another principal's cached body via If-None-Match → 304 mid-session. + clearEtagCache(); + setApplicationLoading(false); // Clear the refresh flag (used after refresh is complete) diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts new file mode 100644 index 000000000000..23ca33311f8a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Collate. + * 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 { useEffect, useRef } from 'react'; + +/** + * Fire {@code fetcher} the first time {@code activeTab} matches {@code tabKey}, and never + * again unless one of the {@code resetDeps} changes (in which case the hook arms itself for + * a fresh fetch on the next activation — or fires immediately if the gated tab is already + * the active one). + * + * Use case: tabs whose data drives a count badge (e.g. Queries (5), Tests (2)) AND whose + * fetch is independent of the entity-detail render. Most users never click these tabs, so + * eagerly fetching them on page load wastes a server round-trip per page view. Deferring + * the fetch until first activation moves the cost off the critical path. + * + * Caveat: the badge count won't appear before the user activates the tab. Render the tab + * label without the count until the fetch resolves; the count populates from the consumer's + * own state once the fetcher resolves. + * + * Re-arm semantics: when any {@code resetDeps} entry changes (typically the entity FQN), the + * hook treats the situation as "new entity, stale data". If the user is already on the gated + * tab when that change happens, we fire {@code fetcher} immediately rather than waiting for + * a tab toggle the user has no reason to do — otherwise the badge would show the previous + * entity's count until something forced a re-activation. + * + * @param tabKey The tab id this hook is gated on (e.g. 'queries'). + * @param activeTab The currently-active tab id, typically from the URL or page state. + * @param fetcher Async function to run on first activation. Errors are swallowed by + * the caller's own try/catch — this hook just fires it. + * @param resetDeps Dependencies that should reset the "already fetched" flag. Typically + * includes the entity FQN so navigating to a different entity re-arms. + */ +export function useDeferredTabData( + tabKey: string, + activeTab: string | undefined, + fetcher: () => void | Promise, + resetDeps: ReadonlyArray = [] +): void { + const fetchedRef = useRef(false); + // Latest-fetcher ref so the resetDeps effect (which deliberately doesn't depend on + // {@code fetcher}) always calls the closure with up-to-date scope (e.g. tableFqn captured + // by the consumer). + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + + // Reset the once-flag when any reset dep changes — typically when the user navigates to + // a different entity, even if the tab id is the same. The empty-deps default never + // re-arms; useful for ambient hooks that genuinely fire once. + // + // If the gated tab is the currently-active tab at reset time, fire immediately so the + // badge updates for the new entity without waiting for a tab toggle. We set the flag to + // true *before* firing so the activation effect below doesn't double-fire on the same + // render cycle. + useEffect(() => { + fetchedRef.current = false; + if (activeTab === tabKey) { + fetchedRef.current = true; + void fetcherRef.current(); + } + // `activeTab` and `tabKey` are read above but the intent is to fire only on resetDeps + // changes — including them in deps would cause every tab switch to also reset, which + // is the opposite of what we want. + }, resetDeps); + + useEffect(() => { + if (activeTab !== tabKey || fetchedRef.current) { + return; + } + fetchedRef.current = true; + void fetcherRef.current(); + // The fetcher closure changes on every render in most callers — depending on it would + // re-fire the fetch. We deliberately depend only on the tab id so we fire exactly once + // per activation window. `fetcherRef` keeps the latest closure available. + }, [activeTab, tabKey]); +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 966e5ead5f89..2cec32ec47eb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -56,6 +56,7 @@ import { TagLabel } from '../../generated/type/tagLabel'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useSub } from '../../hooks/usePubSub'; import { FeedCounts } from '../../interface/feed.interface'; @@ -785,13 +786,33 @@ const TableDetailsPageV1: React.FC = () => { } }, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]); + // P1.2: getTestCaseFailureCount drives the global red-alert badge in the page chrome, + // so it must run as soon as tableDetails resolves — deferring would mean the user could + // miss a critical "this dataset has failing tests" indicator on first paint. useEffect(() => { if (tableDetails) { - fetchQueryCount(); getTestCaseFailureCount(); } }, [tableDetails?.fullyQualifiedName]); + // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that + // tab, so eagerly fetching it on every page load wasted a server round-trip per view. + // Defer until the user actually activates the Queries tab (or any of its column-scoped + // sub-tabs); the badge then populates on first activation. {@link useDeferredTabData} + // also re-fires on FQN change if the user is already on the Queries tab, so badge counts + // never show stale data from a previous entity. + useDeferredTabData(EntityTabs.TABLE_QUERIES, activeTab, fetchQueryCount, [ + tableDetails?.fullyQualifiedName, + ]); + + // Reset the badge count to 0 when navigating to a different entity. Without this the + // badge would show the previous table's queryCount until the deferred fetch resolves, + // which is briefly misleading when navigating between tables that have differing query + // counts. + useEffect(() => { + setQueryCount(0); + }, [tableDetails?.fullyQualifiedName]); + useSub( 'updateDetails', (suggestion: Suggestion) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts new file mode 100644 index 000000000000..e6bb48d349a2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Collate. + * 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 { + AxiosHeaders, + AxiosInstance, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; +import Qs from 'qs'; + +/** + * Client-side ETag / If-None-Match handling for entity GETs. + * + * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control on entity + * GET responses and short-circuits to 304 when If-None-Match matches. The flow: + * + * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed + * by the canonical URL+params. + * 2. Second GET to the same URL → we attach If-None-Match. Server compares against the + * current ETag. + * - Match → 304 with empty body. Interceptor returns cached body as if it were 200. + * - No match → 200 with fresh body. Interceptor refreshes the cached entry. + * + * Wins: zero body bytes on the wire on revisit, plus skip JSON parse + render. + * + * Bounded memory: simple LRU cap at MAX_ENTRIES; oldest evicted on overflow. A typical entity + * GET response is 5-50 KB so the cap holds the cache to ~10 MB worst case. + */ + +interface CachedEntry { + etag: string; + data: unknown; +} + +const MAX_ENTRIES = 200; + +// Map preserves insertion order — re-set on hit to keep recently-used entries at the back. +const etagCache = new Map(); + +function buildKey(config: InternalAxiosRequestConfig): string { + const method = (config.method ?? 'get').toUpperCase(); + const url = config.url ?? ''; + const params = config.params + ? `?${Qs.stringify(config.params, { arrayFormat: 'comma' })}` + : ''; + + return `${method} ${url}${params}`; +} + +function touch(key: string, entry: CachedEntry): void { + etagCache.delete(key); + etagCache.set(key, entry); + + if (etagCache.size > MAX_ENTRIES) { + const oldest = etagCache.keys().next().value; + if (oldest !== undefined) { + etagCache.delete(oldest); + } + } +} + +function readEtagHeader(response: AxiosResponse): string | undefined { + const headers = response.headers; + if (!headers) { + return undefined; + } + + if (headers instanceof AxiosHeaders) { + const v = headers.get('etag'); + + return typeof v === 'string' ? v : undefined; + } + + const candidate = (headers as Record).etag; + if (typeof candidate === 'string') { + return candidate; + } + const candidateUpper = (headers as Record).ETag; + + return typeof candidateUpper === 'string' ? candidateUpper : undefined; +} + +// Marker we stamp on an AxiosInstance once we've installed our interceptor pair. Lets the +// function be properly idempotent — re-invocation (HMR, test setup re-runs, callers that +// accidentally re-init) is a no-op rather than stacking another interceptor pair plus another +// `validateStatus` override on top of itself. +const ETAG_INTERCEPTOR_INSTALLED = Symbol.for( + '@openmetadata/etag-interceptor-installed' +); + +/** + * Wire ETag handling into the axios client. Idempotent — calling twice on the same client is + * a no-op (guarded via a symbol marker on the instance). Callers that re-init axios from + * scratch should also clear the cache via {@link clearEtagCache}. + */ +export function attachEtagInterceptor(client: AxiosInstance): void { + const marker = client as unknown as Record; + if (marker[ETAG_INTERCEPTOR_INSTALLED]) { + return; + } + marker[ETAG_INTERCEPTOR_INSTALLED] = true; + + // Treat 304 as a success status so axios delivers it through the response interceptor + // instead of the error path. Without this, our 304-handling code would have to live in + // the error interceptor and intercepts on every error path. + const previousValidate = client.defaults.validateStatus; + client.defaults.validateStatus = (status: number) => + status === 304 || + (previousValidate + ? previousValidate(status) + : status >= 200 && status < 300); + + client.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + if (method !== 'get') { + return config; + } + + const entry = etagCache.get(buildKey(config)); + if (!entry) { + return config; + } + + // Axios 1.x always populates config.headers with AxiosHeaders instance, but be + // defensive in case an upstream interceptor swapped it for a plain object. + if (config.headers instanceof AxiosHeaders) { + config.headers.set('If-None-Match', entry.etag); + } else if (config.headers) { + (config.headers as Record)['If-None-Match'] = entry.etag; + } else { + config.headers = new AxiosHeaders({ 'If-None-Match': entry.etag }); + } + + return config; + }); + + client.interceptors.response.use((response) => { + const method = (response.config.method ?? 'get').toLowerCase(); + if (method !== 'get') { + return response; + } + + const key = buildKey(response.config); + + if (response.status === 304) { + const entry = etagCache.get(key); + if (entry) { + touch(key, entry); + + // Deep-clone the cached body before handing it back. Consumers (UI components, + // utilities, edit handlers) routinely mutate the entity object they receive — adding + // local UI state, normalising fields, stripping properties — and a shared reference + // would let those mutations leak back into the cache. The next 304 would then return + // the mutated copy and cross-page bugs become very hard to track. structuredClone is + // available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+). + return { + ...response, + status: 200, + statusText: 'OK (from ETag cache)', + data: structuredClone(entry.data), + }; + } + + // 304 without a cached body shouldn't happen in normal flow — a stale interceptor + // attaching If-None-Match for a key we no longer hold. Bubble through; the caller + // sees 304 and decides. Better than fabricating a fake 200. + return response; + } + + if (response.status === 200) { + const etag = readEtagHeader(response); + if (etag && response.data !== undefined) { + touch(key, { etag, data: response.data }); + } + } + + return response; + }); +} + +/** + * Drop every cached ETag entry. Call on logout / user switch so a freshly-authenticated user + * never receives another user's cached body via 304. + */ +export function clearEtagCache(): void { + etagCache.clear(); +} + +/** Test/debug helper. Returns the count of entries currently held. */ +export function etagCacheSize(): number { + return etagCache.size; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts index fd795dc40623..4110647d3577 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts @@ -14,10 +14,17 @@ import axios from 'axios'; import Qs from 'qs'; import { getBasePath } from '../utils/HistoryUtils'; +import { attachEtagInterceptor } from './etagInterceptor'; const axiosClient = axios.create({ baseURL: `${getBasePath()}/api/v1`, paramsSerializer: (params) => Qs.stringify(params, { arrayFormat: 'comma' }), }); +// Client-side If-None-Match support paired with the server's ETagResponseFilter. Saves the +// response body bytes + a JSON parse + a render on entity GET revisits within a session. +// Attached here (before AuthProvider's interceptors) so it sits closest to the wire and +// every other interceptor sees the resolved 304→200-with-cached-body translation. +attachEtagInterceptor(axiosClient); + export default axiosClient;