Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,81 @@
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;
import org.openmetadata.service.monitoring.RequestLatencyContext;
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.
*
* <p>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.
*
* <p>{@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);

Comment on lines +35 to +60
Comment on lines +42 to +60
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;
}
}
10 changes: 10 additions & 0 deletions openmetadata-ui/src/main/resources/ui/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ module.exports = {
'<rootDir>/src/test/unit/mocks/reactColumnResize.mock.js',
'^.*/Lineage/Layout/ELKUtil/ELKUtil$':
'<rootDir>/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$': '<rootDir>/node_modules/react',
'^react-dom$': '<rootDir>/node_modules/react-dom',
'^react/(.*)$': '<rootDir>/node_modules/react/$1',
'^react-dom/(.*)$': '<rootDir>/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))',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>,
resetDeps: ReadonlyArray<unknown> = []
): 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]);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Comment on lines +83 to +85
Comment on lines +44 to +85
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
]);
Comment on lines +789 to +806

// 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) => {
Expand Down
Loading
Loading