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 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); + } + + /** + * 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