diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml
index 14f383f09ec8..b0caa84dc7d5 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
@@ -154,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-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..254a7d07669c
--- /dev/null
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LineageHydrateIT.java
@@ -0,0 +1,247 @@
+/*
+ * 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), 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 {
+
+ 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 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();
+ 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/pom.xml b/openmetadata-service/pom.xml
index efbca4987287..32e513025a12 100644
--- a/openmetadata-service/pom.xml
+++ b/openmetadata-service/pom.xml
@@ -191,6 +191,18 @@
io.dropwizard
dropwizard-jdbi3
+
+
+ io.dropwizard
+ dropwizard-http2
+
org.jdbi
jdbi3-sqlobject
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..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
@@ -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,21 +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.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.Authorizer;
@@ -74,6 +84,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 +764,113 @@ 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 extends EntityInterface> 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 extends EntityInterface> hydrateForType(
+ UriInfo uriInfo,
+ SecurityContext securityContext,
+ String entityType,
+ List ids,
+ String fieldsParam,
+ Include include) {
+ EntityRepository extends EntityInterface> 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);
+ }
+
+ /**
+ * 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());
+ for (UUID id : ids) {
+ ResourceContext> resourceContext = new ResourceContext<>(entityType, id, null);
+ ResourcePermission permission =
+ authorizer.getPermission(securityContext, userName, resourceContext);
+ if (isViewBasicAllowed(permission)) {
+ authorized.add(id);
+ }
+ }
+ 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 ecb6775026e6..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
@@ -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,16 @@ 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.
+ //
+ // 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 =
@@ -222,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-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"));
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/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/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..537f32ca13a2
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.component.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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';
+import './LineageSkeleton.less';
+
+/**
+ * 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".
+ *
+ * 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) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+export default LineageSkeleton;
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.less b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.less
new file mode 100644
index 000000000000..fae7807d44d4
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageSkeleton.less
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+.lineage-skeleton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 320px;
+}
+
+.lineage-skeleton-node {
+ padding: 16px;
+ border: 1px solid var(--ant-color-border-secondary);
+ border-radius: 8px;
+ min-width: 180px;
+ background: var(--ant-color-bg-container);
+}
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",
+}
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 5003c570c533..3e837c35ed23 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
@@ -13,7 +13,7 @@
import { get, isEmpty, isNil, isString, omit } from 'lodash';
import Qs from 'qs';
-import { FC, useCallback, useEffect, useMemo, useState } from 'react';
+import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { withAdvanceSearch } from '../../components/AppRouter/withAdvanceSearch';
import { useAdvanceSearch } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component';
@@ -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,170 @@ 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,
+ ]);
+
+ // 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 () => {
- 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 (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);
+ };
+ 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);
+ };
+ const captureSetShowIndexNotFoundAlert: typeof setShowIndexNotFoundAlert = (
+ value
+ ) => {
+ if (isStale()) {
+ return;
+ }
+ captured.indexNotFound =
+ typeof value === 'function'
+ ? value(captured.indexNotFound ?? false)
+ : value;
+ 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.
+ 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. The captured setters above drop
+ // writes if the user has moved on by the time the response resolves.
+ 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(commitCacheIfFresh);
+ return;
+ }
+
+ setIsLoading(true);
try {
await fetchEntityData({
searchQueryParam,
tabsInfo,
- updatedQuickFilters: getAdvancedSearchQuickFilters(),
+ updatedQuickFilters,
queryFilter,
searchIndex,
showDeleted,
@@ -323,11 +480,12 @@ const ExplorePageV1: FC = () => {
EntityType,
ExploreSearchIndex
>,
- setSearchHitCounts,
- setSearchResults,
- setUpdatedAggregations,
- setShowIndexNotFoundAlert,
+ setSearchHitCounts: captureSetSearchHitCounts,
+ setSearchResults: captureSetSearchResults,
+ setUpdatedAggregations: captureSetUpdatedAggregations,
+ setShowIndexNotFoundAlert: captureSetShowIndexNotFoundAlert,
});
+ commitCacheIfFresh();
} finally {
setIsLoading(false);
}
@@ -340,31 +498,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();
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..b2a6f921f180
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPageSkeleton.component.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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 { useTranslation } from 'react-i18next';
+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 = () => {
+ 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.
+
+
+
+
+
+
+ {[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) {
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..7c1d77175431 100644
--- a/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/rest/lineageAPI.ts
@@ -209,6 +209,35 @@ 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.
+ *
+ * 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>(
+ `/lineage/hydrate`,
+ params
+ );
+
+ return response.data;
+};
+
export const getLineagePagingData = async (params: {
fqn: string;
upstreamDepth?: number;
diff --git a/pom.xml b/pom.xml
index b8947814d68a..9dfda6161ab9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -323,6 +323,11 @@
+
+ io.dropwizard
+ dropwizard-http2
+ ${dropwizard.version}
+
io.dropwizard
dropwizard-client