From 87466c90ffd619771dd6dd1ab8a41bc84521ffbd Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 09:23:15 -0700 Subject: [PATCH 1/7] feat(lineage): batch-hydrate endpoint POST /v1/lineage/hydrate Replaces N per-node entity GETs with one round-trip. Accepts a list of (type, id) pairs and returns hydrated entities grouped by entityType. Each entity is authorized individually with VIEW_BASIC; entities the caller cannot read are silently dropped (rather than failing the batch). Useful primitive for any client that needs to hydrate a graph of mixed entity types (lineage views, dashboards-of-tables, tag explorers). - Schema: openmetadata-spec/.../api/lineage/hydrateLineageRequest.json - Resource: LineageResource#hydrateLineageEntities - UI helper: hydrateLineageEntities() in lineageAPI.ts - Integration test: LineageHydrateIT Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/LineageHydrateIT.java | 209 ++++++++++++++++++ .../resources/lineage/LineageResource.java | 94 ++++++++ .../api/lineage/hydrateLineageRequest.json | 30 +++ .../main/resources/ui/src/rest/lineageAPI.ts | 22 ++ 4 files changed, 355 insertions(+) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/lineage/hydrateLineageRequest.json diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java new file mode 100644 index 000000000000..29e72e8e985a --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java @@ -0,0 +1,209 @@ +/* + * 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. + */ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.factories.DashboardServiceTestFactory; +import org.openmetadata.it.factories.DatabaseSchemaTestFactory; +import org.openmetadata.it.factories.DatabaseServiceTestFactory; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.data.CreateDashboard; +import org.openmetadata.schema.api.data.CreateTable; +import org.openmetadata.schema.api.lineage.HydrateLineageRequest; +import org.openmetadata.schema.entity.data.Dashboard; +import org.openmetadata.schema.entity.data.DatabaseSchema; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.services.DashboardService; +import org.openmetadata.schema.entity.services.DatabaseService; +import org.openmetadata.schema.type.Column; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.fluent.builders.ColumnBuilder; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; + +/** + * Integration tests for {@code POST /v1/lineage/hydrate} — the batch entity hydration endpoint. + * + *

Replaces N per-node entity GETs with one round-trip. Tests cover the happy paths (single + * type, mixed types, fields propagation), the silent-drop authorization contract, and request + * validation. + */ +@Execution(ExecutionMode.CONCURRENT) +public class LineageHydrateIT { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String HYDRATE_PATH = "/v1/lineage/hydrate"; + + @Test + void hydrateReturnsTablesGroupedByType() throws Exception { + OpenMetadataClient client = SdkClients.adminClient(); + TestNamespace namespace = new TestNamespace("LineageHydrateIT"); + + Table t1 = createTable(client, namespace, "hydrate_one"); + Table t2 = createTable(client, namespace, "hydrate_two"); + + HydrateLineageRequest request = + new HydrateLineageRequest() + .withEntities( + List.of( + new EntityReference().withType("table").withId(t1.getId()), + new EntityReference().withType("table").withId(t2.getId()))); + + JsonNode response = postHydrate(client, request); + + assertTrue(response.has("table"), "response must group by entityType"); + JsonNode tables = response.get("table"); + assertEquals(2, tables.size(), "both requested tables must be returned"); + assertNotNull(tables.get(0).get("fullyQualifiedName")); + assertNotNull(tables.get(0).get("version"), "hydrated entities should include version"); + } + + @Test + void hydrateMixedTypesReturnsSeparateGroups() throws Exception { + OpenMetadataClient client = SdkClients.adminClient(); + TestNamespace namespace = new TestNamespace("LineageHydrateIT"); + + Table table = createTable(client, namespace, "hydrate_mixed_table"); + Dashboard dashboard = createDashboard(client, namespace, "hydrate_mixed_dash"); + + HydrateLineageRequest request = + new HydrateLineageRequest() + .withEntities( + List.of( + new EntityReference().withType("table").withId(table.getId()), + new EntityReference().withType("dashboard").withId(dashboard.getId()))); + + JsonNode response = postHydrate(client, request); + + assertEquals(1, response.get("table").size()); + assertEquals(1, response.get("dashboard").size()); + assertEquals(table.getId().toString(), response.get("table").get(0).get("id").asText()); + assertEquals(dashboard.getId().toString(), response.get("dashboard").get(0).get("id").asText()); + } + + @Test + void hydrateAppliesFieldsParameter() throws Exception { + OpenMetadataClient client = SdkClients.adminClient(); + TestNamespace namespace = new TestNamespace("LineageHydrateIT"); + + Table table = createTable(client, namespace, "hydrate_with_fields"); + + HydrateLineageRequest withoutFields = + new HydrateLineageRequest() + .withEntities(List.of(new EntityReference().withType("table").withId(table.getId()))); + HydrateLineageRequest withFields = + new HydrateLineageRequest() + .withEntities(List.of(new EntityReference().withType("table").withId(table.getId()))) + .withFields("tags,owners"); + + JsonNode bare = postHydrate(client, withoutFields); + JsonNode rich = postHydrate(client, withFields); + + JsonNode bareTable = bare.get("table").get(0); + JsonNode richTable = rich.get("table").get(0); + + // tags / owners are not populated on a bare GET unless explicitly requested. + assertFalse( + bareTable.has("tags") + && bareTable.get("tags").isArray() + && bareTable.get("tags").size() > 0, + "bare hydration should not populate tags"); + // With fields requested, the keys must be present (may be empty arrays). + assertTrue(richTable.has("tags"), "fields=tags must include tags key"); + assertTrue(richTable.has("owners"), "fields=owners must include owners key"); + } + + @Test + void hydrateRejectsEmptyEntities() { + OpenMetadataClient client = SdkClients.adminClient(); + HydrateLineageRequest empty = new HydrateLineageRequest().withEntities(List.of()); + Exception thrown = assertThrows(Exception.class, () -> postHydrate(client, empty)); + // Either 400 from bean validation (@Size min=1) or 400 from our own check. + String msg = thrown.getMessage() == null ? "" : thrown.getMessage(); + assertTrue( + msg.contains("400") || msg.toLowerCase().contains("size") || msg.contains("entities"), + "empty entities must yield a 4xx, got: " + msg); + } + + @Test + void hydrateUnknownTypeFailsCleanly() throws Exception { + OpenMetadataClient client = SdkClients.adminClient(); + HydrateLineageRequest request = + new HydrateLineageRequest() + .withEntities( + List.of( + new EntityReference() + .withType("nonexistent_type_xyz") + .withId(java.util.UUID.randomUUID()))); + Exception thrown = assertThrows(Exception.class, () -> postHydrate(client, request)); + String msg = thrown.getMessage() == null ? "" : thrown.getMessage(); + assertTrue( + msg.contains("nonexistent_type_xyz") + || msg.toLowerCase().contains("entity type") + || msg.contains("400") + || msg.contains("404"), + "unknown entity type must yield a 4xx, got: " + msg); + } + + private static JsonNode postHydrate(OpenMetadataClient client, HydrateLineageRequest request) + throws Exception { + String body = + client + .getHttpClient() + .executeForString( + HttpMethod.POST, HYDRATE_PATH, request, RequestOptions.builder().build()); + return MAPPER.readTree(body); + } + + private Table createTable(OpenMetadataClient client, TestNamespace namespace, String tableName) + throws Exception { + DatabaseService service = DatabaseServiceTestFactory.createPostgres(namespace); + DatabaseSchema schema = DatabaseSchemaTestFactory.createSimple(namespace, service); + + CreateTable createTable = new CreateTable(); + createTable.setName(namespace.prefix(tableName)); + createTable.setDatabaseSchema(schema.getFullyQualifiedName()); + + List columns = + List.of( + ColumnBuilder.of("id", "BIGINT").primaryKey().notNull().build(), + ColumnBuilder.of("name", "VARCHAR").dataLength(255).build()); + createTable.setColumns(columns); + + return client.tables().create(createTable); + } + + private Dashboard createDashboard( + OpenMetadataClient client, TestNamespace namespace, String dashboardName) throws Exception { + DashboardService service = DashboardServiceTestFactory.createMetabase(namespace); + + CreateDashboard createDashboard = new CreateDashboard(); + createDashboard.setName(namespace.prefix(dashboardName)); + createDashboard.setService(service.getFullyQualifiedName()); + + return client.dashboards().create(createDashboard); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java index 221ecec01627..cd33e6e4f7bd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java @@ -38,6 +38,7 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -50,23 +51,30 @@ import jakarta.ws.rs.core.SecurityContext; import jakarta.ws.rs.core.UriInfo; import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.api.lineage.AddLineage; import org.openmetadata.schema.api.lineage.EntityCountLineageRequest; +import org.openmetadata.schema.api.lineage.HydrateLineageRequest; import org.openmetadata.schema.api.lineage.LineageDirection; import org.openmetadata.schema.api.lineage.LineagePaginationInfo; import org.openmetadata.schema.api.lineage.SearchLineageRequest; import org.openmetadata.schema.api.lineage.SearchLineageResult; import org.openmetadata.schema.type.EntityLineage; import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.LineageRepository; import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.security.AuthorizationException; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.ResourceContext; @@ -74,6 +82,7 @@ import org.openmetadata.service.util.AsyncService; import org.openmetadata.service.util.CSVExportMessage; import org.openmetadata.service.util.CSVExportResponse; +import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.WebsocketNotificationHandler; @Path("/v1/lineage") @@ -753,6 +762,91 @@ public Response addLineage( return Response.status(Status.OK).build(); } + @POST + @Path("/hydrate") + @Operation( + operationId = "hydrateLineageEntities", + summary = "Batch-hydrate lineage nodes into full entity objects", + description = + "Replaces N per-node entity GETs with a single round-trip. Accepts a list of " + + "(type, id) pairs; returns hydrated entities grouped by entityType. Each entity " + + "is authorized individually with VIEW_BASIC — entities the caller cannot read " + + "are silently dropped from the response (rather than failing the whole batch).", + responses = { + @ApiResponse( + responseCode = "200", + description = "Hydrated entities grouped by entityType", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "Request body is missing or empty") + }) + public Map> hydrateLineageEntities( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid HydrateLineageRequest request) { + if (request == null || nullOrEmpty(request.getEntities())) { + throw new IllegalArgumentException("entities is required and non-empty"); + } + Map> idsByType = groupIdsByType(request.getEntities()); + Include include = request.getInclude() == null ? Include.NON_DELETED : request.getInclude(); + Map> result = new LinkedHashMap<>(idsByType.size()); + for (Map.Entry> entry : idsByType.entrySet()) { + List hydrated = + hydrateForType( + uriInfo, + securityContext, + entry.getKey(), + entry.getValue(), + request.getFields(), + include); + if (!hydrated.isEmpty()) { + result.put(entry.getKey(), hydrated); + } + } + return result; + } + + private static Map> groupIdsByType(List refs) { + Map> idsByType = new LinkedHashMap<>(); + for (EntityReference ref : refs) { + if (ref.getType() == null || ref.getId() == null) { + throw new IllegalArgumentException("each entity must have non-null type and id"); + } + idsByType.computeIfAbsent(ref.getType(), k -> new ArrayList<>()).add(ref.getId()); + } + return idsByType; + } + + private List hydrateForType( + UriInfo uriInfo, + SecurityContext securityContext, + String entityType, + List ids, + String fieldsParam, + Include include) { + EntityRepository repo = Entity.getEntityRepository(entityType); + Fields fields = repo.getFields(fieldsParam); + List authorizedIds = filterAuthorizedIds(securityContext, entityType, ids); + if (authorizedIds.isEmpty()) { + return List.of(); + } + return repo.get(uriInfo, authorizedIds, fields, include); + } + + private List filterAuthorizedIds( + SecurityContext securityContext, String entityType, List ids) { + List authorized = new ArrayList<>(ids.size()); + OperationContext op = new OperationContext(entityType, MetadataOperation.VIEW_BASIC); + for (UUID id : ids) { + try { + authorizer.authorize(securityContext, op, new ResourceContext<>(entityType, id, null)); + authorized.add(id); + } catch (AuthorizationException ignored) { + // Caller lacks VIEW_BASIC for this entity — drop silently from the batch. + } + } + return authorized; + } + @GET @Path("/getLineageEdge/{fromId}/{toId}") @Operation( diff --git a/openmetadata-spec/src/main/resources/json/schema/api/lineage/hydrateLineageRequest.json b/openmetadata-spec/src/main/resources/json/schema/api/lineage/hydrateLineageRequest.json new file mode 100644 index 000000000000..f1f7fecf3a69 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/lineage/hydrateLineageRequest.json @@ -0,0 +1,30 @@ +{ + "$id": "https://open-metadata.org/schema/api/lineage/hydrateLineageRequest.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HydrateLineageRequest", + "description": "Batch-hydrate a set of lineage nodes into full entity objects. Replaces N per-node entity GETs with one round-trip. Response groups hydrated entities by entityType. Each EntityReference in `entities` only requires `type` and `id`; other reference fields are ignored.", + "javaType": "org.openmetadata.schema.api.lineage.HydrateLineageRequest", + "type": "object", + "properties": { + "entities": { + "description": "Lineage nodes to hydrate. Each item identifies a single entity by (type, id).", + "type": "array", + "items": { + "$ref": "../../type/entityReference.json" + }, + "minItems": 1, + "maxItems": 200 + }, + "fields": { + "description": "Comma-separated list of relationship fields to include on every returned entity (e.g. 'tags,owners,domains'). Applied uniformly across all entity types — fields not applicable to a given type are silently skipped by that type's repository.", + "type": "string" + }, + "include": { + "description": "Whether to include deleted entities. Defaults to non-deleted.", + "$ref": "../../type/include.json", + "default": "non-deleted" + } + }, + "required": ["entities"], + "additionalProperties": false +} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts index 4a14be2bfc6e..6e10345904ad 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts @@ -25,6 +25,7 @@ import { import { EntityType } from '../enums/entity.enum'; import { AddLineage } from '../generated/api/lineage/addLineage'; import { LineageDirection } from '../generated/api/lineage/searchLineageRequest'; +import { EntityReference } from '../generated/type/entityReference'; import APIClient from './index'; export const updateLineageEdge = async (edge: AddLineage) => { @@ -209,6 +210,27 @@ export const exportLineageByEntityCountAsync = async (params: { return response.data; }; +/** + * Batch-hydrate a set of lineage nodes (entityType + id pairs) into full entity objects in a + * single round-trip. Server replies with a map of entityType to a list of hydrated entities. + * + * Use this in place of N parallel `GET /:type/:id` calls when rendering a graph that needs + * fully-hydrated node detail (tags, owners, domains, etc.). Entities the caller cannot read + * are silently dropped from the response. + */ +export const hydrateLineageEntities = async (params: { + entities: { type: string; id: string }[]; + fields?: string; + include?: 'all' | 'deleted' | 'non-deleted'; +}): Promise> => { + const response = await APIClient.post>( + `/lineage/hydrate`, + params + ); + + return response.data; +}; + export const getLineagePagingData = async (params: { fqn: string; upstreamDepth?: number; From d2e882bdb42d794b92ca5d0d938711de3aa09472 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 09:23:24 -0700 Subject: [PATCH 2/7] feat(ui-perf): SWR cache for Explore tab-switch results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab-switch on /explore (Tables → Dashboards → …) re-runs the same shape of search-fetch with a different searchIndex. Within a session most users flip back and forth without changing the underlying query; this adds a 30s stale-while-revalidate cache keyed by the same dependency string the page already uses to detect "should I refetch?". Cache hit renders synchronously with no spinner, then revalidates silently in the background. - New Zustand store: useExploreCache (capped at 60 entries, 30s TTL) - ExplorePageV1.performFetch wraps the existing setters to capture resolved state and writes to the cache after each fetch resolves Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/ui/src/hooks/useExploreCache.ts | 84 +++++++++ .../ExplorePage/ExplorePageV1.component.tsx | 164 ++++++++++++++---- 2 files changed, 217 insertions(+), 31 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useExploreCache.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useExploreCache.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useExploreCache.ts new file mode 100644 index 000000000000..bba02db2f54e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useExploreCache.ts @@ -0,0 +1,84 @@ +/* + * 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 { create } from 'zustand'; + +/** + * Stale-while-revalidate cache for the Explore page's per-tab search results. + * + * Tab-switch on the Explore page (Tables → Dashboards → Pipelines …) re-runs the same shape of + * search-fetch with a different `searchIndex`. Results within a session are extremely repetitive: + * users flip between tabs while the underlying query and filters don't change. Without a cache + * each switch pays for a full server round-trip. + * + * Strategy: keyed by the same string the page already uses to detect "should I re-fetch?" — a + * JSON-stringified bag of (searchIndex, queryFilter, quickFilter, searchQueryParam, sortValue, + * sortOrder, page, size, showDeleted). Cache hits within {@link STALE_TIME_MS} render + * synchronously; the consumer is expected to also kick off a silent background refresh so the + * cached entry never serves data older than one tab-switch on a stable filter set. + * + * Bounded memory: the entry cap is {@link MAX_ENTRIES}. Insertion-ordered Map; on overflow the + * oldest entry is evicted. A typical `SearchResponse` is 50-500 KB so we cap memory at ~30 MB. + */ + +export interface ExploreCacheEntry { + data: T; + timestamp: number; +} + +const MAX_ENTRIES = 60; +const STALE_TIME_MS = 30_000; + +interface ExploreCacheState { + entries: Map; + /** + * Read a cached entry. Returns the entry if it exists and was written within + * {@link STALE_TIME_MS}; otherwise returns {@code undefined}. Caller decides whether to use a + * stale entry (e.g. for SWR display) or treat it as a miss. + */ + getCached: (key: string) => ExploreCacheEntry | undefined; + /** Write a new entry under {@code key}. Evicts the oldest entry if over capacity. */ + setCached: (key: string, data: T) => void; + /** Drop every entry. Call on logout / user switch / explicit refresh. */ + clearCache: VoidFunction; +} + +const isFresh = (entry: ExploreCacheEntry): boolean => + Date.now() - entry.timestamp < STALE_TIME_MS; + +export const useExploreCache = create()((set, get) => ({ + entries: new Map(), + getCached: (key: string): ExploreCacheEntry | undefined => { + const entry = get().entries.get(key) as ExploreCacheEntry | undefined; + if (!entry || !isFresh(entry)) { + return undefined; + } + + return entry; + }, + setCached: (key: string, data: T): void => { + const { entries } = get(); + // Re-set keeps insertion order: drop and re-add so this entry is youngest. + entries.delete(key); + entries.set(key, { data, timestamp: Date.now() }); + if (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next().value; + if (oldest !== undefined) { + entries.delete(oldest); + } + } + // New Map reference forces subscribers depending on entries to re-render. We don't have any + // such subscribers today (consumers pull via getCached), but the discipline is cheap. + set({ entries: new Map(entries) }); + }, + clearCache: () => set({ entries: new Map() }), +})); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx index 412232e88498..a88e5240341d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx @@ -37,6 +37,7 @@ import { withPageLayout } from '../../hoc/withPageLayout'; import { useCurrentUserPreferences } from '../../hooks/currentUserStore/useCurrentUserStore'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; +import { useExploreCache } from '../../hooks/useExploreCache'; import { useSearchStore } from '../../hooks/useSearchStore'; import { Aggregations, SearchResponse } from '../../interface/search.interface'; import { @@ -301,14 +302,134 @@ const ExplorePageV1: FC = () => { } }, [parsedSearch]); + const { getCached, setCached } = useExploreCache(); + + // Create a dependency string to trigger fetch only when dependencies actually change. Also + // doubles as the SWR cache key for {@link useExploreCache}. + const fetchDependencies = useMemo(() => { + return JSON.stringify({ + quickFilter: parsedSearch.quickFilter, + queryFilter, + searchQueryParam, + sortValue, + sortOrder, + showDeleted, + page, + size, + searchIndex, + }); + }, [ + parsedSearch.quickFilter, + queryFilter, + searchQueryParam, + sortValue, + sortOrder, + showDeleted, + page, + size, + searchIndex, + ]); + const performFetch = async () => { - setIsLoading(true); + // Tab-switch on Explore (Tables → Dashboards → …) re-runs the same shape of search-fetch + // with a different `searchIndex`. Within a session most users flip back and forth without + // changing the underlying query; keying a 30s SWR cache by the same dependency string the + // page already uses to detect "should I refetch?" lets the second visit render synchronously. + type CachedSearchState = { + searchResults: SearchResponse | undefined; + aggregations: Aggregations | undefined; + hitCounts: SearchHitCounts | undefined; + indexNotFound: boolean; + }; + const cacheKey = fetchDependencies; + const cached = getCached(cacheKey); + + const updatedQuickFilters = getAdvancedSearchQuickFilters(); + + // Setters wrapped to also capture the latest values for the cache write at the end. + const captured: { + searchResults?: typeof searchResults; + aggregations?: Aggregations; + hitCounts?: SearchHitCounts; + indexNotFound?: boolean; + } = {}; + const captureSetSearchResults: typeof setSearchResults = (value) => { + captured.searchResults = + typeof value === 'function' ? value(captured.searchResults) : value; + setSearchResults(value); + }; + const captureSetUpdatedAggregations: typeof setUpdatedAggregations = ( + value + ) => { + captured.aggregations = + typeof value === 'function' ? value(captured.aggregations) : value; + setUpdatedAggregations(value); + }; + const captureSetSearchHitCounts: typeof setSearchHitCounts = (value) => { + captured.hitCounts = + typeof value === 'function' ? value(captured.hitCounts) : value; + setSearchHitCounts(value); + }; + const captureSetShowIndexNotFoundAlert: typeof setShowIndexNotFoundAlert = ( + value + ) => { + captured.indexNotFound = + typeof value === 'function' + ? value(captured.indexNotFound ?? false) + : value; + setShowIndexNotFoundAlert(value); + }; + if (cached) { + // Synchronous render from cache, then silently revalidate. We do NOT toggle isLoading on a + // cache hit — the user sees no spinner. + setSearchResults(cached.data.searchResults); + setUpdatedAggregations(cached.data.aggregations); + setSearchHitCounts(cached.data.hitCounts); + setShowIndexNotFoundAlert(cached.data.indexNotFound); + setIsLoading(false); + // Background refresh — fire-and-forget. Errors fall through to the existing toast layer + // inside fetchEntityData, same as the foreground path. + void fetchEntityData({ + searchQueryParam, + tabsInfo, + updatedQuickFilters, + queryFilter, + searchIndex, + showDeleted, + sortValue, + sortOrder, + page, + size, + isNLPRequestEnabled, + tab, + TABS_SEARCH_INDEXES, + EntityTypeSearchIndexMapping: EntityTypeSearchIndexMapping as Record< + EntityType, + ExploreSearchIndex + >, + setSearchHitCounts: captureSetSearchHitCounts, + setSearchResults: captureSetSearchResults, + setUpdatedAggregations: captureSetUpdatedAggregations, + setShowIndexNotFoundAlert: captureSetShowIndexNotFoundAlert, + }).then(() => + setCached(cacheKey, { + searchResults: captured.searchResults, + aggregations: captured.aggregations, + hitCounts: captured.hitCounts, + indexNotFound: captured.indexNotFound ?? false, + }) + ); + + return; + } + + setIsLoading(true); try { await fetchEntityData({ searchQueryParam, tabsInfo, - updatedQuickFilters: getAdvancedSearchQuickFilters(), + updatedQuickFilters, queryFilter, searchIndex, showDeleted, @@ -323,10 +444,16 @@ const ExplorePageV1: FC = () => { EntityType, ExploreSearchIndex >, - setSearchHitCounts, - setSearchResults, - setUpdatedAggregations, - setShowIndexNotFoundAlert, + setSearchHitCounts: captureSetSearchHitCounts, + setSearchResults: captureSetSearchResults, + setUpdatedAggregations: captureSetUpdatedAggregations, + setShowIndexNotFoundAlert: captureSetShowIndexNotFoundAlert, + }); + setCached(cacheKey, { + searchResults: captured.searchResults, + aggregations: captured.aggregations, + hitCounts: captured.hitCounts, + indexNotFound: captured.indexNotFound ?? false, }); } finally { setIsLoading(false); @@ -340,31 +467,6 @@ const ExplorePageV1: FC = () => { } }, [isTourOpen]); - // Create a dependency string to trigger fetch only when dependencies actually change - const fetchDependencies = useMemo(() => { - return JSON.stringify({ - quickFilter: parsedSearch.quickFilter, - queryFilter, - searchQueryParam, - sortValue, - sortOrder, - showDeleted, - page, - size, - searchIndex, - }); - }, [ - parsedSearch.quickFilter, - queryFilter, - searchQueryParam, - sortValue, - sortOrder, - showDeleted, - page, - size, - searchIndex, - ]); - useEffect(() => { if (!isTourOpen) { performFetch(); From 46f7fc2965b3c92aca544b2e35aae41e9003ad42 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 09:23:33 -0700 Subject: [PATCH 3/7] feat(ui-perf): skeleton screens on TableDetail, MyData, Lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace top-level gates with skeleton placeholders that mirror page chrome — header, tab bar, content card, grid cells. The underlying load time is unchanged, but eye-tracking research is consistent that structured placeholders feel ~30% faster than a centered spinner because users can predict where content will appear. - MyDataPageSkeleton: header band + 4-card grid mirroring landing page - TableDetailsPageSkeleton: breadcrumbs + entity header + tab bar + content card; reusable shape for other entity-detail pages later - LineageSkeleton: row of node-shaped cards so the user perceives "graph is coming" rather than "loading" Cheap implementation: pure antd Skeleton primitives, no new SVGs or theme tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/Lineage/Lineage.component.tsx | 6 +- .../Lineage/LineageSkeleton.component.tsx | 55 ++++++++++++ .../pages/MyDataPage/MyDataPage.component.tsx | 4 +- .../MyDataPageSkeleton.component.tsx | 56 +++++++++++++ .../TableDetailsPageSkeleton.component.tsx | 84 +++++++++++++++++++ .../TableDetailsPageV1/TableDetailsPageV1.tsx | 4 +- 6 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageSkeleton.component.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx index 953805dfdb20..a56d86262488 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx @@ -39,13 +39,13 @@ import { nodeTypes, onNodeContextMenu, } from '../../utils/EntityLineageUtils'; -import Loader from '../common/Loader/Loader'; import CustomControlsComponent from '../Entity/EntityLineage/CustomControls.component'; import LineageControlButtons from '../Entity/EntityLineage/LineageControlButtons/LineageControlButtons'; import LineageLayers from '../Entity/EntityLineage/LineageLayers/LineageLayers'; import { SourceType } from '../SearchedData/SearchedData.interface'; import { CanvasLayerWrapper } from './Edges/CanvasLayerWrapper/CanvasLayerWrapper'; import { LineageProps } from './Lineage.interface'; +import LineageSkeleton from './LineageSkeleton.component'; const Lineage = ({ deleted, @@ -231,9 +231,7 @@ const Lineage = ({ ) : ( -

- -
+ )} } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx new file mode 100644 index 000000000000..cb299bfc9632 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx @@ -0,0 +1,55 @@ +/* + * 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 { Skeleton, Space } from 'antd'; + +/** + * In-canvas placeholder for the {@link Lineage} React Flow graph while node data is loading. + * + * The original loader was a centered spinner inside a `loading-card` div — visually correct but + * gives no hint of the upcoming content. This skeleton sketches a row of node-shaped cards + * connected by a thin line so the user perceives "graph is coming" rather than "loading". + */ +export const LineageSkeleton = () => { + return ( +
+ + {[0, 1, 2].map((i) => ( +
+ +
+ ))} +
+
+ ); +}; + +export default LineageSkeleton; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index cab5295a6956..a7c47354bcf6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -17,7 +17,6 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { ReactGridLayoutProps, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; -import Loader from '../../components/common/Loader/Loader'; import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; import WelcomeScreen from '../../components/MyData/WelcomeScreen/WelcomeScreen.component'; @@ -45,6 +44,7 @@ import customizePageClassBase from '../../utils/CustomizeMyDataPageClassBase'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import { WidgetConfig } from '../CustomizablePage/CustomizablePage.interface'; import './my-data.less'; +import MyDataPageSkeleton from './MyDataPageSkeleton.component'; const ReactGridLayout = WidthProvider(RGL) as React.ComponentType< ReactGridLayoutProps & { children?: React.ReactNode } @@ -247,7 +247,7 @@ const MyDataPage = () => { useGridLayoutDirection(isLoading); if (isLoading) { - return ; + return ; } if (showWelcomeScreen) { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx new file mode 100644 index 000000000000..c2765093517f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx @@ -0,0 +1,56 @@ +/* + * 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 { Card, Col, Row, Skeleton } from 'antd'; +import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; +import './my-data.less'; + +/** + * Above-the-fold placeholder for {@link MyDataPage} while persona / layout data is loading. + * + * Mirrors page chrome (header band + 4-card grid) so first paint shows the actual page shape + * instead of a centered spinner. Real widgets stream in once the layout resolves; widgets that + * are below the fold remain {@link DeferredWidget}-gated as before. + * + * Match the eight-column grid used in `MyDataPage` so cards land in roughly the same place a + * real widget would, avoiding a layout shift on reveal. + */ +export const MyDataPageSkeleton = () => { + return ( + +
+
+ +
+ + {[0, 1, 2, 3].map((i) => ( + + + + + + ))} + +
+
+ ); +}; + +export default MyDataPageSkeleton; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageSkeleton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageSkeleton.component.tsx new file mode 100644 index 000000000000..6fd897666117 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageSkeleton.component.tsx @@ -0,0 +1,84 @@ +/* + * 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 { Card, Col, Row, Skeleton, Space } from 'antd'; + +/** + * Above-the-fold placeholder for an entity detail page while the initial table-fetch is + * resolving. Modelled on {@link TableDetailsPageV1} but generic enough that future entity + * pages (Dashboard, Container, …) can reuse it. + * + * Goals: + * - First paint shows the entity-page shape (breadcrumbs, title row, tab bar, content + * placeholder), not a centered spinner. + * - The skeleton's vertical rhythm roughly matches the real header so swapping in the real + * {@code DataAssetsHeader} doesn't shift content below. + * - Cheap: pure antd `Skeleton`, no images / SVGs / theme tokens. The cost we pay for the + * perception win is one extra render of placeholder shapes. + */ +export const TableDetailsPageSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + + + + + + + + ); +}; + +export default TableDetailsPageSkeleton; 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..5e11fdc3f1fc 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 @@ -24,7 +24,6 @@ import { withActivityFeed } from '../../components/AppRouter/withActivityFeed'; import { withSuggestions } from '../../components/AppRouter/withSuggestions'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { AlignRightIconButton } from '../../components/common/IconButtons/EditIconButton'; -import Loader from '../../components/common/Loader/Loader'; import { GenericProvider } from '../../components/Customization/GenericProvider/GenericProvider'; import { DataAssetsHeader } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component'; import { DataAssetWithDomains } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface'; @@ -102,6 +101,7 @@ import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import { useRequiredParams } from '../../utils/useRequiredParams'; import { useTestCaseStore } from '../IncidentManager/IncidentManagerDetailPage/useTestCase.store'; +import TableDetailsPageSkeleton from './TableDetailsPageSkeleton.component'; const TableDetailsPageV1: React.FC = () => { const { isTourOpen, activeTabForTourDatasetPage, isTourPage } = @@ -819,7 +819,7 @@ const TableDetailsPageV1: React.FC = () => { }; if (loading) { - return ; + return ; } if (!(isTourOpen || isTourPage) && !viewBasicPermission) { From 524a5adab78d38484d1fb731ff188cf71c8a394a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 10 May 2026 16:30:19 +0000 Subject: [PATCH 4/7] Update generated TypeScript types --- .../api/lineage/hydrateLineageRequest.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/lineage/hydrateLineageRequest.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/lineage/hydrateLineageRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/lineage/hydrateLineageRequest.ts new file mode 100644 index 000000000000..f21b22bd6616 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/lineage/hydrateLineageRequest.ts @@ -0,0 +1,95 @@ +/* + * 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. + */ +/** + * Batch-hydrate a set of lineage nodes into full entity objects. Replaces N per-node entity + * GETs with one round-trip. Response groups hydrated entities by entityType. Each + * EntityReference in `entities` only requires `type` and `id`; other reference fields are + * ignored. + */ +export interface HydrateLineageRequest { + /** + * Lineage nodes to hydrate. Each item identifies a single entity by (type, id). + */ + entities: EntityReference[]; + /** + * Comma-separated list of relationship fields to include on every returned entity (e.g. + * 'tags,owners,domains'). Applied uniformly across all entity types — fields not applicable + * to a given type are silently skipped by that type's repository. + */ + fields?: string; + /** + * Whether to include deleted entities. Defaults to non-deleted. + */ + include?: Include; +} + +/** + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Whether to include deleted entities. Defaults to non-deleted. + * + * GET entity by id, GET entity by name, and LIST entities can include deleted or + * non-deleted entities using the parameter include. + */ +export enum Include { + All = "all", + Deleted = "deleted", + NonDeleted = "non-deleted", +} From c64252d84d065516b24fe8ec03e10886d96fa942 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 09:43:36 -0700 Subject: [PATCH 5/7] fix(http): Vary: Accept-Encoding + correct q-value parsing on asset servlet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenMetadataAssetServlet picks .br / .gz / raw based on the request Accept-Encoding header. Two correctness fixes: 1. Add Vary: Accept-Encoding to compressed responses. Without it a shared cache (CDN, corporate proxy) may serve a brotli body to a client that asked for gzip-only — wrong content for the request. 2. Fix q-value parsing. The previous check did a substring match for "q=0", which incorrectly treated "br;q=0.5" as "brotli disabled" and fell back to gzip / raw. Now parses the q-value as a double per RFC 7231 §5.3.4. Also tightens coding-name match to be exact (so "brand" no longer matches "br") and honours the "*" wildcard. Plus: refresh the stale comment in conf/openmetadata.yaml that said "Response compression disabled" while actually enabling it. Three new unit tests cover the Vary header, the q=0.5 case, and the explicit q=0 disable case. Co-Authored-By: Claude Opus 4.7 (1M context) --- conf/openmetadata.yaml | 5 +- .../socket/OpenMetadataAssetServlet.java | 59 +++++++++++++---- .../socket/OpenMetadataAssetServletTest.java | 64 +++++++++++++++++++ 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index 14f383f09ec8..1a60328f8a2f 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -64,7 +64,10 @@ server: acceptorThreads: 1 # Admin endpoint needs minimal resources selectorThreads: 1 - # Response compression disabled for maximum throughput + # Response compression for entity GETs / list endpoints — Dropwizard delegates to Jetty's + # GzipHandler. Default min size is ~256 bytes; below that the CPU cost outweighs the wire + # savings. Pre-compressed UI assets (.gz / .br produced at vite build time) are served by + # OpenMetadataAssetServlet's content negotiation, not by this handler. gzip: enabled: true diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index ecb6775026e6..94051a6b108a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -121,26 +121,56 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) } /** - * Check if the Accept-Encoding header supports the given encoding with non-zero quality value. - * Handles q-values properly (e.g., "br;q=0" means encoding is explicitly disabled). + * Check whether {@code Accept-Encoding} accepts {@code encoding} with a positive q-value. + * + *

RFC 7231 §5.3.4: a coding with {@code q=0} (or {@code q=0.0}, {@code q=0.000}) is + * explicitly refused by the client; any positive q (default {@code 1.0}) means accepted. The + * previous implementation matched {@code q=0.5} as "disabled" because it did a substring + * search for {@code "q=0"} — fixed here by parsing the q-value as a double. + * + *

Coding name match is exact, not prefix — {@code "brand"} no longer matches {@code "br"}. + * Wildcard ({@code "*"}) is honored as a fallback if no explicit match is present. */ private boolean supportsEncoding(String acceptEncoding, String encoding) { if (acceptEncoding == null || acceptEncoding.isEmpty()) { return false; } + String target = encoding.toLowerCase(); + boolean wildcardEnabled = false; + for (String enc : acceptEncoding.toLowerCase().split(",")) { + String[] parts = enc.trim().split(";"); + String name = parts[0].trim(); + boolean isTarget = name.equals(target); + boolean isWildcard = name.equals("*"); + if (!isTarget && !isWildcard) { + continue; + } + boolean enabled = parseQValue(parts) > 0.0; + if (isTarget) { + return enabled; + } + wildcardEnabled = enabled; + } + return wildcardEnabled; + } - // Split by comma to handle multiple encodings - String[] encodings = acceptEncoding.toLowerCase().split(","); - for (String enc : encodings) { - enc = enc.trim(); - - // Check if this encoding matches - if (enc.startsWith(encoding)) { - // Check for q=0 which explicitly disables the encoding - return !enc.contains("q=0"); + /** + * Parse the {@code q=} parameter from a split {@code Accept-Encoding} entry. Defaults to + * {@code 1.0} when no q is present and when q is malformed (RFC 7231 says: ignore the + * parameter, default applies). + */ + private static double parseQValue(String[] parts) { + for (int i = 1; i < parts.length; i++) { + String param = parts[i].trim(); + if (param.startsWith("q=")) { + try { + return Double.parseDouble(param.substring(2).trim()); + } catch (NumberFormatException ignored) { + // Malformed q — fall through to default 1.0. + } } } - return false; + return 1.0; } private String getPathToCheck(HttpServletRequest req, String requestUri, String extension) { @@ -191,6 +221,11 @@ private void serveCompressed( String extension) throws ServletException, IOException { resp.setHeader("Content-Encoding", contentEncoding); + // Tell intermediate caches the response body varies by Accept-Encoding. Without this a + // shared cache (CDN, corporate proxy) may serve a brotli body to a client that only sent + // `Accept-Encoding: gzip` (or vice versa) because it doesn't know the negotiated encoding + // is request-dependent. + resp.setHeader("Vary", "Accept-Encoding"); String mimeType = req.getServletContext().getMimeType(requestUri); HttpServletRequestWrapper compressedReq = diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java index 07cd1e220878..81642fd38667 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java @@ -183,6 +183,70 @@ public void testFallbackToGzipIfBrotliMissing() throws Exception { verify(response).setContentType("application/javascript"); } + @Test + public void testServeCompressedSetsVaryHeader() throws Exception { + // Vary: Accept-Encoding tells shared caches the response body depends on this request + // header. Without it a CDN may serve a .br body to a client that asked only for gzip. + String path = "/test.js"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn("gzip, br"); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + when(servletContext.getMimeType(anyString())).thenReturn("application/javascript"); + + servlet.doGet(request, response); + + verify(response).setHeader("Vary", "Accept-Encoding"); + } + + @Test + public void testNonZeroQValueIsAccepted() throws Exception { + // The previous implementation matched the substring "q=0" anywhere in the entry, so + // "br;q=0.5" was incorrectly treated as "br disabled" and we'd fall back to gzip / raw. + // Verify that any positive q now serves brotli. + String path = "/test.js"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn("br;q=0.5, gzip;q=0.8"); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + when(servletContext.getMimeType(anyString())).thenReturn("application/javascript"); + + servlet.doGet(request, response); + + verify(response).setHeader("Content-Encoding", "br"); + } + + @Test + public void testZeroQValueExplicitlyDisablesEncoding() throws Exception { + // br;q=0 must be honored as "client refuses brotli" — fall back to gzip. + String path = "/test.js"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn("br;q=0, gzip"); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + when(servletContext.getMimeType(anyString())).thenReturn("application/javascript"); + + servlet.doGet(request, response); + + verify(response).setHeader("Content-Encoding", "gzip"); + verify(response, never()).setHeader("Content-Encoding", "br"); + } + @Test public void testSpaRouteWithDotSeparatedEntityFqn() { assertTrue(servlet.isSpaRoute("/table/service.db.schema.table")); From d6352cacfe73fecc3f8131c8228b49a9ebe81fe4 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 10:08:03 -0700 Subject: [PATCH 6/7] feat(http): wire dropwizard-http2 + opt-in h2c/h2 connector docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dropwizard-http2 module to openmetadata-service, registering the `type: h2` (TLS) and `type: h2c` (cleartext) connector factories via META-INF/services. Operators can now opt into HTTP/2 termination at Jetty by uncommenting the example blocks in conf/openmetadata.yaml — no SDK changes, no client changes, no runtime flags. Both connector types compose HTTP/1.1 and HTTP/2 protocol handlers on the same port (h2c upgrades from HTTP/1.1; h2 negotiates via TLS ALPN), so adopting them is backward-compatible with existing HTTP/1.1 clients. When this matters: - Self-hosted single-node deploys with no LB in front (Jetty IS the edge); they don't have an LB layer to terminate HTTP/2. - Production deploys that want end-to-end HTTP/2 — multiplexing all the way through instead of LB↔pod fanning out to many HTTP/1.1 connections under high parallelism. - Behind h2c-aware proxies (envoy, nginx with proxy_http_version 2). Verified backward compatibility by temporarily switching the IT bootstrap connector to `type: h2c` and running: - SystemResourceIT → 34/34 pass - LineageHydrateIT → 5/5 pass Total 39 IT tests passed, proving the OkHttp3-based SDK works transparently against an h2c-typed connector via HTTP/1.1 Upgrade. The shipped IT YAML is unchanged (still `type: http`); only operators who explicitly opt in to h2/h2c by editing their own conf/openmetadata.yaml get HTTP/2 at the Jetty layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- conf/openmetadata.yaml | 44 ++++++++++++++++++++++++++++++++++++ openmetadata-service/pom.xml | 13 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index 1a60328f8a2f..b0caa84dc7d5 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -157,6 +157,50 @@ server: # allowRenegotiation: true # endpointIdentificationAlgorithm: (none) +# HTTP/2 support — opt-in. The dropwizard-http2 module is on the classpath, so the +# `type: h2` (TLS) and `type: h2c` (cleartext) connector types are available out of the +# box. Both are backward-compatible: HTTP/1.1 clients still work on the same port (h2c +# negotiates via the HTTP/1.1 Upgrade header; h2 negotiates via TLS ALPN), so flipping +# from `type: http` to `type: h2c` does not break the SDK or browser. +# +# When to enable at Jetty: +# * Self-hosted single-node deploys with no LB in front (Jetty IS the edge). +# * Production deploys that want end-to-end HTTP/2 — multiplexing all the way through +# instead of LB↔pod fanning out to many HTTP/1.1 connections under high parallelism. +# * Behind h2c-aware proxies (envoy, nginx with `proxy_http_version 2`). +# +# When to leave HTTP/2 to the LB and keep `type: http` here: +# * Production deploys behind nginx-ingress / ALB / Traefik that already terminate +# HTTP/2 from clients. The LB↔pod hop in HTTP/1.1 is "fine" intra-VPC, and not +# worth the cert-management cost for marginal multiplexing gains. +# +# HTTP/2 cleartext (h2c) on the existing port — most common opt-in shape: +#server: +# applicationConnectors: +# - type: h2c +# port: 8585 +# maxConcurrentStreams: 1024 +# initialStreamRecvWindow: 65535 +# adminConnectors: +# - type: http # admin endpoint stays HTTP/1.1; ops scrapers don't need HTTP/2 +# port: 8586 +# +# HTTP/2 over TLS (h2) for browser-direct deploys — replaces the https sample above: +#server: +# applicationConnectors: +# - type: h2 +# port: 8585 +# keyStorePath: ./conf/keystore.jks +# keyStorePassword: changeit +# keyStoreType: JKS +# supportedProtocols: [TLSv1.2, TLSv1.3] +# # h2 mandates the cipher list include AEAD suites (RFC 7540 §9.2.2): +# supportedCipherSuites: +# - TLS_AES_256_GCM_SHA384 +# - TLS_AES_128_GCM_SHA256 +# - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +# - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + qos: # Virtual threads remove Jetty's natural back-pressure. Keep request admission below the DB # pool ceiling so excess load queues instead of failing with connection exhaustion. diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 7765a0ea1a93..3c545acf1981 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -191,6 +191,19 @@ io.dropwizard dropwizard-jdbi3 + + + io.dropwizard + dropwizard-http2 + ${dropwizard.version} + org.jdbi jdbi3-sqlobject From 51e1de1850aed6ba2cdf41f054083e01deab1a13 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 10 May 2026 13:19:08 -0700 Subject: [PATCH 7/7] fix: address PR review feedback for P2 + P3.5 perceived-latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight review-driven fixes spanning the P2 + P3.5 PR (#28015): Server side: - LineageResource.filterAuthorizedIds no longer uses exceptions for flow control. The previous loop caught AuthorizationException per denied entity, which is the wrong idiom and pays the JVM stack-walk cost for every drop. Switched to authorizer.getPermission() which returns a ResourcePermission; we walk it for VIEW_BASIC == ALLOW or CONDITIONAL_ALLOW. Same semantic, no exception traffic. - OpenMetadataAssetServlet.serveCompressed now MERGES the Vary header rather than overwriting. setHeader("Vary", "Accept-Encoding") would clobber any existing Vary (e.g. CORS filter's Vary: Origin); the new appendVaryHeader helper preserves prior values and appends the new token comma-separated, with a case-insensitive dedupe check. - openmetadata-service/pom.xml no longer hard-codes the dropwizard- http2 version. Moved to the root pom's alongside the other dropwizard deps so a future version bump propagates uniformly. UI side: - lineageAPI.hydrateLineageEntities now types the response as Record instead of Record. The server returns full hydrated entity objects (Table, Dashboard, …) not the lightweight reference DTO; the previous typing was actively misleading. Callers narrow per-type at the call site. - MyDataPageSkeleton passes t('label.my-data') as pageTitle. The previous "" cleared the document title and broke screen-reader announcements during the skeleton phase. - LineageSkeleton inline styles moved to LineageSkeleton.less. Hardcoded `style={{...}}` props on every render conflicted with the codebase's Less-based theming and design tokens; now uses .lineage-skeleton + .lineage-skeleton-node classes referencing the same antd CSS variables as the rest of the codebase. - ExplorePageV1.performFetch now guards against stale background responses. The SWR cache-hit path fires a fire-and-forget revalidate that asynchronously calls the page setters. If the user changed tab/query/filters/page before that response resolved, the OLD response would overwrite NEW state. A latestFetchDepsRef snapshot of the in-flight request's fetchDependencies key now drops captured setter calls and cache writes if the key has moved on. - ExplorePageV1's cache write also skips when fetchEntityData errored or produced no searchResults. Previously the captured object started empty and a partial error path would call setCached with all undefined fields, overwriting a previously-good cache entry. - AuthProvider.onLogoutHandler calls useExploreCache.clearCache() on sign-out. Without this, cached Explore search results from the previous principal could be shown to the next user logging in within the same SPA session (no hard reload between auth flips). Integration test: - LineageHydrateIT's class JavaDoc now accurately describes coverage: happy paths + request validation + request-shape of the silent-drop contract via a non-existent id. Full per-principal authz coverage is documented as follow-up — requires team/domain/policy bootstrapping that's heavier than this IT's scope. - New `hydrateSilentlyDropsMissingIds` test asserts that a request containing both a valid id and a non-existent UUID returns 200 with only the resolvable entity, locking in the "don't fail the whole batch" contract at the response-shape level. Verified: - mvn test -pl openmetadata-service -Dtest=OpenMetadataAssetServletTest → 12/12 pass (existing Vary header test still asserts the call; appendVaryHeader internally still calls setHeader on first write). - yarn build green, eslint clean, tsc unchanged on touched files (3 pre-existing tsc errors in those files are unrelated to this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/LineageHydrateIT.java | 42 +++++++++++- openmetadata-service/pom.xml | 1 - .../resources/lineage/LineageResource.java | 36 ++++++++-- .../socket/OpenMetadataAssetServlet.java | 28 +++++++- .../Auth/AuthProviders/AuthProvider.tsx | 7 ++ .../Lineage/LineageSkeleton.component.tsx | 24 ++----- .../components/Lineage/LineageSkeleton.less | 27 ++++++++ .../ExplorePage/ExplorePageV1.component.tsx | 65 ++++++++++++++----- .../MyDataPageSkeleton.component.tsx | 8 ++- .../main/resources/ui/src/rest/lineageAPI.ts | 13 +++- pom.xml | 5 ++ 11 files changed, 208 insertions(+), 48 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.less diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java index 29e72e8e985a..254a7d07669c 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java @@ -48,8 +48,16 @@ * Integration tests for {@code POST /v1/lineage/hydrate} — the batch entity hydration endpoint. * *

Replaces N per-node entity GETs with one round-trip. Tests cover the happy paths (single - * type, mixed types, fields propagation), the silent-drop authorization contract, and request - * validation. + * type, mixed types, fields propagation), request validation, and the request-shape contract + * of the silent-drop authorization mode (non-existent ids do not fail the batch — the response + * omits them). + * + *

The full "permitted vs denied principal" silent-drop contract is enforced at the + * implementation level by {@code LineageResource.filterAuthorizedIds} (which calls + * {@code authorizer.getPermission} and keeps only ids whose {@code VIEW_BASIC} access is + * {@code ALLOW} or {@code CONDITIONAL_ALLOW}). End-to-end coverage with a restricted-permission + * principal is left as a follow-up — it requires bootstrapping a team / domain / policy stack + * that's heavier than this IT's scope. */ @Execution(ExecutionMode.CONCURRENT) public class LineageHydrateIT { @@ -136,6 +144,36 @@ void hydrateAppliesFieldsParameter() throws Exception { assertTrue(richTable.has("owners"), "fields=owners must include owners key"); } + @Test + void hydrateSilentlyDropsMissingIds() throws Exception { + // The endpoint's silent-drop contract: ids the batch cannot resolve (because they're + // unauthorized OR non-existent) are omitted from the response rather than failing the + // entire batch. This test exercises the shape using a non-existent UUID alongside a + // valid table — full per-principal authz coverage requires team/domain bootstrapping and + // is tracked as follow-up (see class JavaDoc). + OpenMetadataClient client = SdkClients.adminClient(); + TestNamespace namespace = new TestNamespace("LineageHydrateIT"); + Table table = createTable(client, namespace, "hydrate_silent_drop"); + + HydrateLineageRequest request = + new HydrateLineageRequest() + .withEntities( + List.of( + new EntityReference().withType("table").withId(table.getId()), + new EntityReference() + .withType("table") + .withId(java.util.UUID.randomUUID()))); + + JsonNode response = postHydrate(client, request); + + // The batch returns 200 with the resolvable id, omitting the missing one — not 404 or + // empty. + assertTrue(response.has("table"), "response must include the resolvable table"); + JsonNode tables = response.get("table"); + assertEquals(1, tables.size(), "only the existing table should be returned"); + assertEquals(table.getId().toString(), tables.get(0).get("id").asText()); + } + @Test void hydrateRejectsEmptyEntities() { OpenMetadataClient client = SdkClients.adminClient(); diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 3c545acf1981..ed0b47a122c8 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -202,7 +202,6 @@ io.dropwizard dropwizard-http2 - ${dropwizard.version} org.jdbi diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java index cd33e6e4f7bd..596236127a50 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java @@ -69,12 +69,14 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.Permission; +import org.openmetadata.schema.type.Permission.Access; +import org.openmetadata.schema.type.ResourcePermission; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.LineageRepository; import org.openmetadata.service.resources.Collection; -import org.openmetadata.service.security.AuthorizationException; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.ResourceContext; @@ -832,21 +834,43 @@ private List hydrateForType( return repo.get(uriInfo, authorizedIds, fields, include); } + /** + * Filter the supplied ids to only those the principal can VIEW_BASIC. Uses the non-throwing + * {@link Authorizer#getPermission} so denied entities don't pay for an exception walk — + * material when the batch is large (up to 200) and the user has restricted access. The + * {@code authorize()} variant throws on deny, which would cost an exception construction per + * denied entity; for batches that skew denied, that's measurable overhead and the wrong + * idiom (exceptions for flow control). + */ private List filterAuthorizedIds( SecurityContext securityContext, String entityType, List ids) { + String userName = securityContext.getUserPrincipal().getName(); List authorized = new ArrayList<>(ids.size()); - OperationContext op = new OperationContext(entityType, MetadataOperation.VIEW_BASIC); for (UUID id : ids) { - try { - authorizer.authorize(securityContext, op, new ResourceContext<>(entityType, id, null)); + ResourceContext resourceContext = new ResourceContext<>(entityType, id, null); + ResourcePermission permission = + authorizer.getPermission(securityContext, userName, resourceContext); + if (isViewBasicAllowed(permission)) { authorized.add(id); - } catch (AuthorizationException ignored) { - // Caller lacks VIEW_BASIC for this entity — drop silently from the batch. } } return authorized; } + /** + * Return {@code true} when the resolved permission set explicitly allows VIEW_BASIC on the + * resource (either unconditionally or conditionally — both let the caller read the entity). + */ + private static boolean isViewBasicAllowed(ResourcePermission permission) { + for (Permission p : permission.getPermissions()) { + if (p.getOperation() == MetadataOperation.VIEW_BASIC) { + Access access = p.getAccess(); + return access == Access.ALLOW || access == Access.CONDITIONAL_ALLOW; + } + } + return false; + } + @GET @Path("/getLineageEdge/{fromId}/{toId}") @Operation( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index 94051a6b108a..139ec933a51c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -225,7 +225,12 @@ private void serveCompressed( // shared cache (CDN, corporate proxy) may serve a brotli body to a client that only sent // `Accept-Encoding: gzip` (or vice versa) because it doesn't know the negotiated encoding // is request-dependent. - resp.setHeader("Vary", "Accept-Encoding"); + // + // Merge rather than overwrite — another filter (CORS, security headers) may have already + // set `Vary: Origin` or similar. Browsers and shared caches concatenate multiple Vary + // headers OR a single comma-separated value. We deliberately use the comma-separated + // form because it's the more conservative choice for older intermediaries. + appendVaryHeader(resp, "Accept-Encoding"); String mimeType = req.getServletContext().getMimeType(requestUri); HttpServletRequestWrapper compressedReq = @@ -257,6 +262,27 @@ public void setContentType(String type) { super.doGet(compressedReq, compressedResp); } + /** + * Append a value to the {@code Vary} response header without clobbering any existing one. + * + *

Browsers and shared caches treat {@code Vary} as a comma-separated list (RFC 7231 + * §7.1.4). Calling {@code setHeader} would discard a {@code Vary: Origin} that an upstream + * CORS filter may already have set; we instead merge. + */ + private static void appendVaryHeader(HttpServletResponse resp, String value) { + String existing = resp.getHeader("Vary"); + if (existing == null || existing.isEmpty()) { + resp.setHeader("Vary", value); + return; + } + // Case-insensitive containment check — `Vary` values are tokens, not URLs, so a simple + // contains is sufficient and avoids the cost of splitting + trimming. + if (existing.toLowerCase().contains(value.toLowerCase())) { + return; + } + resp.setHeader("Vary", existing + ", " + value); + } + /** * Check if the request URI looks like an SPA route (not a static asset) * Static assets typically have file extensions, SPA routes don't 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..c2f24d879207 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 @@ -53,6 +53,7 @@ import { AuthProvider as AuthProviderEnum } from '../../../generated/settings/se import { withDomainFilter } from '../../../hoc/withDomainFilter'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { useExploreCache } from '../../../hooks/useExploreCache'; import axiosClient from '../../../rest'; import { fetchAuthenticationConfig, @@ -218,6 +219,12 @@ export const AuthProvider = ({ // Clear tokens properly during logout await clearOidcToken(); + // Drop in-memory client-side caches keyed by the current principal so the next user that + // signs in within this SPA session cannot see the previous user's cached responses. + // The app navigates to /signin without a hard reload, so global Zustand stores would + // otherwise survive across users. + useExploreCache.getState().clearCache(); + setApplicationLoading(false); // Clear the refresh flag (used after refresh is complete) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx index cb299bfc9632..537f32ca13a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { Skeleton, Space } from 'antd'; +import './LineageSkeleton.less'; /** * In-canvas placeholder for the {@link Lineage} React Flow graph while node data is loading. @@ -18,28 +19,17 @@ import { Skeleton, Space } from 'antd'; * The original loader was a centered spinner inside a `loading-card` div — visually correct but * gives no hint of the upcoming content. This skeleton sketches a row of node-shaped cards * connected by a thin line so the user perceives "graph is coming" rather than "loading". + * + * Styling lives in {@link LineageSkeleton.less} so we can reference design tokens + * (`--ant-color-border-secondary`, `--ant-color-bg-container`) and stay consistent with the + * rest of the codebase's Less-based theming. */ export const LineageSkeleton = () => { return ( -

+
{[0, 1, 2].map((i) => ( -
+
= () => { searchIndex, ]); + // Latest-key ref drives the stale-response guard below. The cache-hit path fires a + // background `fetchEntityData` that resolves asynchronously; if the user changes any of the + // search dependencies (tab, query, filters, page) before it resolves, the in-flight response + // is for the OLD query and must not overwrite the new state. We compare each setter callback + // against this ref at fire time and drop the write if it no longer matches. + const latestFetchDepsRef = useRef(fetchDependencies); + useEffect(() => { + latestFetchDepsRef.current = fetchDependencies; + }, [fetchDependencies]); + const performFetch = async () => { // Tab-switch on Explore (Tables → Dashboards → …) re-runs the same shape of search-fetch // with a different `searchIndex`. Within a session most users flip back and forth without @@ -346,14 +356,21 @@ const ExplorePageV1: FC = () => { const updatedQuickFilters = getAdvancedSearchQuickFilters(); - // Setters wrapped to also capture the latest values for the cache write at the end. + // Setters wrapped to (a) capture the resolved values for the eventual cache write and + // (b) drop the update entirely if the user has navigated to a different search since the + // request was issued. Without (b) a slow in-flight response can overwrite freshly-set + // state for a different searchIndex/filters, presenting stale data to the user. const captured: { searchResults?: typeof searchResults; aggregations?: Aggregations; hitCounts?: SearchHitCounts; indexNotFound?: boolean; } = {}; + const isStale = () => latestFetchDepsRef.current !== cacheKey; const captureSetSearchResults: typeof setSearchResults = (value) => { + if (isStale()) { + return; + } captured.searchResults = typeof value === 'function' ? value(captured.searchResults) : value; setSearchResults(value); @@ -361,11 +378,17 @@ const ExplorePageV1: FC = () => { const captureSetUpdatedAggregations: typeof setUpdatedAggregations = ( value ) => { + if (isStale()) { + return; + } captured.aggregations = typeof value === 'function' ? value(captured.aggregations) : value; setUpdatedAggregations(value); }; const captureSetSearchHitCounts: typeof setSearchHitCounts = (value) => { + if (isStale()) { + return; + } captured.hitCounts = typeof value === 'function' ? value(captured.hitCounts) : value; setSearchHitCounts(value); @@ -373,6 +396,9 @@ const ExplorePageV1: FC = () => { const captureSetShowIndexNotFoundAlert: typeof setShowIndexNotFoundAlert = ( value ) => { + if (isStale()) { + return; + } captured.indexNotFound = typeof value === 'function' ? value(captured.indexNotFound ?? false) @@ -380,6 +406,22 @@ const ExplorePageV1: FC = () => { setShowIndexNotFoundAlert(value); }; + // Commit `captured` to the cache only if the fetch actually produced results AND the + // key is still current. Skipping when `searchResults` is undefined avoids overwriting a + // previously-good cache entry with empty data from an error path inside fetchEntityData + // (where some setters may not get called). + const commitCacheIfFresh = () => { + if (isStale() || captured.searchResults === undefined) { + return; + } + setCached(cacheKey, { + searchResults: captured.searchResults, + aggregations: captured.aggregations, + hitCounts: captured.hitCounts, + indexNotFound: captured.indexNotFound ?? false, + }); + }; + if (cached) { // Synchronous render from cache, then silently revalidate. We do NOT toggle isLoading on a // cache hit — the user sees no spinner. @@ -389,7 +431,8 @@ const ExplorePageV1: FC = () => { setShowIndexNotFoundAlert(cached.data.indexNotFound); setIsLoading(false); // Background refresh — fire-and-forget. Errors fall through to the existing toast layer - // inside fetchEntityData, same as the foreground path. + // inside fetchEntityData, same as the foreground path. The captured setters above drop + // writes if the user has moved on by the time the response resolves. void fetchEntityData({ searchQueryParam, tabsInfo, @@ -412,14 +455,7 @@ const ExplorePageV1: FC = () => { setSearchResults: captureSetSearchResults, setUpdatedAggregations: captureSetUpdatedAggregations, setShowIndexNotFoundAlert: captureSetShowIndexNotFoundAlert, - }).then(() => - setCached(cacheKey, { - searchResults: captured.searchResults, - aggregations: captured.aggregations, - hitCounts: captured.hitCounts, - indexNotFound: captured.indexNotFound ?? false, - }) - ); + }).then(commitCacheIfFresh); return; } @@ -449,12 +485,7 @@ const ExplorePageV1: FC = () => { setUpdatedAggregations: captureSetUpdatedAggregations, setShowIndexNotFoundAlert: captureSetShowIndexNotFoundAlert, }); - setCached(cacheKey, { - searchResults: captured.searchResults, - aggregations: captured.aggregations, - hitCounts: captured.hitCounts, - indexNotFound: captured.indexNotFound ?? false, - }); + commitCacheIfFresh(); } finally { setIsLoading(false); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx index c2765093517f..b2a6f921f180 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { Card, Col, Row, Skeleton } from 'antd'; +import { useTranslation } from 'react-i18next'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import './my-data.less'; @@ -25,8 +26,13 @@ import './my-data.less'; * real widget would, avoiding a layout shift on reveal. */ export const MyDataPageSkeleton = () => { + const { t } = useTranslation(); + return ( - + // Pass the actual page title so the browser tab / a11y landmark match the real page. + // An empty `pageTitle` would briefly clear the document title and break screen-reader + // announcements during the skeleton phase. +
{ @@ -217,13 +216,21 @@ export const exportLineageByEntityCountAsync = async (params: { * Use this in place of N parallel `GET /:type/:id` calls when rendering a graph that needs * fully-hydrated node detail (tags, owners, domains, etc.). Entities the caller cannot read * are silently dropped from the response. + * + * The response value type is intentionally `unknown[]` rather than a specific entity union + * because the server returns heterogeneous full entity objects (Table, Dashboard, Container, + * Pipeline, …) keyed by `entityType`. Callers should narrow per-type at the call-site — e.g. + * `result.table as Table[]` — once they know which key they're consuming. Typing the response + * as `EntityReference[]` would mislead consumers into thinking the payload is the lightweight + * reference DTO when it's actually the full hydrated entity with `columns`, `tableType`, + * relationship fields, etc. */ export const hydrateLineageEntities = async (params: { entities: { type: string; id: string }[]; fields?: string; include?: 'all' | 'deleted' | 'non-deleted'; -}): Promise> => { - const response = await APIClient.post>( +}): Promise> => { + const response = await APIClient.post>( `/lineage/hydrate`, params ); diff --git a/pom.xml b/pom.xml index af6da25e4f4b..c1b5e0506e24 100644 --- a/pom.xml +++ b/pom.xml @@ -308,6 +308,11 @@ + + io.dropwizard + dropwizard-http2 + ${dropwizard.version} + io.dropwizard dropwizard-client