This is an automated email from the ASF dual-hosted git repository.

jshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 81da629115 [#9736][followup] feat(iceberg-rest): Skip full table load 
on ETag match via SupportsMetadataLocation (#10536)
81da629115 is described below

commit 81da629115534f20ae23256114057a16061d11c3
Author: akshay thorat <[email protected]>
AuthorDate: Mon Mar 30 00:12:37 2026 -0700

    [#9736][followup] feat(iceberg-rest): Skip full table load on ETag match 
via SupportsMetadataLocation (#10536)
    
    ### What changes were proposed in this pull request?
    
    Optimize the ETag-based freshness check in `loadTable` by leveraging
    `SupportsMetadataLocation` to resolve the metadata file location cheaply
    without loading full table metadata. When the client sends
    `If-None-Match` and the catalog supports it, the server can return `304
    Not Modified` without the cost of a full `loadTable` call.
    
    Also fixes ETag consistency between `createTable`/`updateTable` and
    `loadTable`: ETags from create and update now include the default
    `snapshots` value (`"all"`), matching the default `loadTable` ETag. This
    allows clients to reuse ETags across endpoints as specified by the
    Iceberg REST spec.
    
    ### Why are the changes needed?
    
    1. **Performance**: The original implementation always performs a full
    `loadTable` before comparing ETags. For read-heavy workloads where
    clients already have fresh metadata (ETag matches), this full load is
    wasted. By using `SupportsMetadataLocation` (already implemented for
    JDBC and Hive catalogs), we can compare ETags via a lightweight metadata
    location query and skip the full load entirely.
    
    2. **ETag consistency** (as reported by @FANNG1 in #10498):
    `createTable` returned an ETag derived from `metadataLocation` only,
    while `loadTable` derived its ETag from `metadataLocation + snapshots`.
    For the default `snapshots=all` path, these values differed, so a client
    that reuses the ETag from create would never get `304 Not Modified` on a
    subsequent unchanged `loadTable`.
    
    Follow-up to: #10498
    
    ### Does this PR introduce _any_ user-facing change?
    
    No user-facing API changes. The ETag values may differ from the previous
    implementation due to the consistency fix, but this is transparent to
    clients — they will simply get fresh ETags on the next request.
    
    ### How was this patch tested?
    
    - Added `testCreateTableETagMatchesLoadTableETag`: Verifies that the
    ETag from `createTable` matches the default `loadTable` ETag, and that
    it produces `304` on a subsequent conditional load.
    - Added `testUpdateTableETagMatchesLoadTableETag`: Same verification for
    `updateTable`.
    - All existing ETag tests continue to pass (8 tests covering ETag
    presence, 304 matching, ETag changes after update, consistency, and
    snapshot-dependent ETags).
---
 .../iceberg/common/ops/IcebergCatalogWrapper.java  | 17 +++++++
 .../dispatcher/IcebergTableEventDispatcher.java    |  6 +++
 .../dispatcher/IcebergTableHookDispatcher.java     |  7 +++
 .../IcebergTableOperationDispatcher.java           | 13 +++++
 .../dispatcher/IcebergTableOperationExecutor.java  |  9 ++++
 .../service/rest/IcebergTableOperations.java       | 27 ++++++++--
 .../service/rest/TestIcebergTableOperations.java   | 59 ++++++++++++++++++++++
 7 files changed, 134 insertions(+), 4 deletions(-)

diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index 864e538649..43295d0297 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -204,6 +204,23 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
     return loadTableResponse;
   }
 
+  /**
+   * Retrieves the metadata file location for the specified table without 
loading full table
+   * metadata. This is an optional fast path for catalogs that implement {@link
+   * SupportsMetadataLocation}.
+   *
+   * @param tableIdentifier the table identifier
+   * @return an Optional containing the metadata file location, or empty if 
the catalog doesn't
+   *     support this operation
+   */
+  public Optional<String> getTableMetadataLocation(TableIdentifier 
tableIdentifier) {
+    if (catalog instanceof SupportsMetadataLocation) {
+      return Optional.ofNullable(
+          ((SupportsMetadataLocation) 
catalog).metadataLocation(tableIdentifier));
+    }
+    return Optional.empty();
+  }
+
   public boolean tableExists(TableIdentifier tableIdentifier) {
     return catalog.tableExists(tableIdentifier);
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
index e3160ff92a..65744546fa 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
@@ -300,4 +300,10 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
     eventBus.dispatchEvent(new IcebergPlanTableScanEvent(context, 
gravitinoNameIdentifier));
     return planTableScanResponse;
   }
+
+  @Override
+  public Optional<String> getTableMetadataLocation(
+      IcebergRequestContext context, TableIdentifier tableIdentifier) {
+    return icebergTableOperationDispatcher.getTableMetadataLocation(context, 
tableIdentifier);
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
index 0f498db38d..9775b36b5c 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.iceberg.service.dispatcher;
 
 import java.io.IOException;
 import java.time.Instant;
+import java.util.Optional;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
@@ -179,6 +180,12 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
     return dispatcher.planTableScan(context, tableIdentifier, scanRequest);
   }
 
+  @Override
+  public Optional<String> getTableMetadataLocation(
+      IcebergRequestContext context, TableIdentifier tableIdentifier) {
+    return dispatcher.getTableMetadataLocation(context, tableIdentifier);
+  }
+
   private void importTable(String catalogName, Namespace namespace, String 
tableName) {
     TableDispatcher tableDispatcher = 
GravitinoEnv.getInstance().tableDispatcher();
     if (tableDispatcher != null) {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
index 0c4d08a6f0..5a90842dbb 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.iceberg.service.dispatcher;
 
+import java.util.Optional;
 import org.apache.gravitino.listener.api.event.IcebergRequestContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -133,4 +134,16 @@ public interface IcebergTableOperationDispatcher {
       IcebergRequestContext context,
       TableIdentifier tableIdentifier,
       PlanTableScanRequest scanRequest);
+
+  /**
+   * Retrieves the metadata file location for a table without loading full 
table metadata. This is
+   * an optional fast path for catalogs that support cheap metadata location 
retrieval.
+   *
+   * @param context Iceberg REST request context information.
+   * @param tableIdentifier The Iceberg table identifier.
+   * @return an Optional containing the metadata file location, or empty if 
the catalog doesn't
+   *     support this operation
+   */
+  Optional<String> getTableMetadataLocation(
+      IcebergRequestContext context, TableIdentifier tableIdentifier);
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index 8319a200aa..f689ec9ef2 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service.dispatcher;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.auth.AuthConstants;
@@ -186,4 +187,12 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
         .getCatalogWrapper(context.catalogName())
         .planTableScan(tableIdentifier, scanRequest);
   }
+
+  @Override
+  public Optional<String> getTableMetadataLocation(
+      IcebergRequestContext context, TableIdentifier tableIdentifier) {
+    return icebergCatalogWrapperManager
+        .getCatalogWrapper(context.catalogName())
+        .getTableMetadataLocation(tableIdentifier);
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 9d3092503d..78b5381187 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -28,6 +28,7 @@ import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import javax.inject.Inject;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.Consumes;
@@ -92,6 +93,8 @@ public class IcebergTableOperations {
 
   @VisibleForTesting public static final String IF_NONE_MATCH = 
"If-None-Match";
 
+  @VisibleForTesting static final String DEFAULT_SNAPSHOTS = "all";
+
   private IcebergMetricsManager icebergMetricsManager;
 
   private ObjectMapper icebergObjectMapper;
@@ -288,7 +291,7 @@ public class IcebergTableOperations {
           String namespace,
       @IcebergAuthorizationMetadata(type = RequestType.LOAD_TABLE) @Encoded() 
@PathParam("table")
           String table,
-      @DefaultValue("all") @QueryParam("snapshots") String snapshots,
+      @DefaultValue(DEFAULT_SNAPSHOTS) @QueryParam("snapshots") String 
snapshots,
       @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation,
       @HeaderParam(IF_NONE_MATCH) String ifNoneMatch) {
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
@@ -311,6 +314,19 @@ public class IcebergTableOperations {
             TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS, 
tableName);
             IcebergRequestContext context =
                 new IcebergRequestContext(httpServletRequest(), catalogName, 
isCredentialVending);
+
+            // Fast path: if client sent If-None-Match, try to resolve ETag 
without full table load
+            if (StringUtils.isNotBlank(ifNoneMatch)) {
+              Optional<String> metadataLocation =
+                  tableOperationDispatcher.getTableMetadataLocation(context, 
tableIdentifier);
+              if (metadataLocation.isPresent()) {
+                EntityTag etag = generateETag(metadataLocation.get(), 
snapshots);
+                if (etag != null && etagMatches(ifNoneMatch, etag)) {
+                  return Response.notModified(etag).build();
+                }
+              }
+            }
+
             LoadTableResponse loadTableResponse =
                 tableOperationDispatcher.loadTable(context, tableIdentifier);
             EntityTag etag =
@@ -521,13 +537,16 @@ public class IcebergTableOperations {
   }
 
   /**
-   * Builds an OK response with the ETag header derived from the table 
metadata location.
+   * Builds an OK response with the ETag header derived from the table 
metadata location. Uses the
+   * default snapshots value to ensure ETags from create/update are consistent 
with the default
+   * loadTable endpoint.
    *
    * @param loadTableResponse the table response to include in the body
    * @return a Response with ETag header set
    */
   private static Response buildResponseWithETag(LoadTableResponse 
loadTableResponse) {
-    EntityTag etag = 
generateETag(loadTableResponse.tableMetadata().metadataFileLocation());
+    EntityTag etag =
+        generateETag(loadTableResponse.tableMetadata().metadataFileLocation(), 
DEFAULT_SNAPSHOTS);
     return buildResponseWithETag(loadTableResponse, etag);
   }
 
@@ -605,7 +624,7 @@ public class IcebergTableOperations {
    * @return true if the ETag matches (table unchanged), false otherwise
    */
   private static boolean etagMatches(String ifNoneMatch, EntityTag etag) {
-    if (ifNoneMatch == null || ifNoneMatch.isEmpty()) {
+    if (StringUtils.isBlank(ifNoneMatch)) {
       return false;
     }
     // Strip quotes if present to compare the raw value
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
index 21063176a2..cd904f14d9 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
@@ -771,6 +771,65 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
     Assertions.assertFalse(etag.isEmpty(), "ETag header should not be empty");
   }
 
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testCreateTableETagMatchesLoadTableETag(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    Response createResponse = doCreateTable(namespace, 
"create_load_etag_foo1");
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
createResponse.getStatus());
+    String createEtag = createResponse.getHeaderString("ETag");
+    Assertions.assertNotNull(createEtag, "ETag should be present in create 
response");
+
+    // Load the same table with default snapshots — ETag should match
+    Response loadResponse = doLoadTable(namespace, "create_load_etag_foo1");
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
loadResponse.getStatus());
+    String loadEtag = loadResponse.getHeaderString("ETag");
+    Assertions.assertNotNull(loadEtag, "ETag should be present in load 
response");
+
+    Assertions.assertEquals(
+        createEtag, loadEtag, "ETag from createTable should match ETag from 
default loadTable");
+
+    // The create ETag should be reusable for If-None-Match on a subsequent 
loadTable
+    Response conditionalResponse =
+        getTableClientBuilder(namespace, Optional.of("create_load_etag_foo1"))
+            .header(IcebergTableOperations.IF_NONE_MATCH, createEtag)
+            .get();
+    Assertions.assertEquals(
+        Status.NOT_MODIFIED.getStatusCode(),
+        conditionalResponse.getStatus(),
+        "Create ETag should produce 304 on subsequent unchanged loadTable");
+  }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testUpdateTableETagMatchesLoadTableETag(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    verifyCreateTableSucc(namespace, "update_load_etag_foo1");
+    TableMetadata metadata = getTableMeta(namespace, "update_load_etag_foo1");
+    Response updateResponse = doUpdateTable(namespace, 
"update_load_etag_foo1", metadata);
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
updateResponse.getStatus());
+    String updateEtag = updateResponse.getHeaderString("ETag");
+    Assertions.assertNotNull(updateEtag, "ETag should be present in update 
response");
+
+    // Load the same table — ETag should match
+    Response loadResponse = doLoadTable(namespace, "update_load_etag_foo1");
+    Assertions.assertEquals(Status.OK.getStatusCode(), 
loadResponse.getStatus());
+    String loadEtag = loadResponse.getHeaderString("ETag");
+
+    Assertions.assertEquals(
+        updateEtag, loadEtag, "ETag from updateTable should match ETag from 
default loadTable");
+
+    // The update ETag should be reusable for If-None-Match
+    Response conditionalResponse =
+        getTableClientBuilder(namespace, Optional.of("update_load_etag_foo1"))
+            .header(IcebergTableOperations.IF_NONE_MATCH, updateEtag)
+            .get();
+    Assertions.assertEquals(
+        Status.NOT_MODIFIED.getStatusCode(),
+        conditionalResponse.getStatus(),
+        "Update ETag should produce 304 on subsequent unchanged loadTable");
+  }
+
   @ParameterizedTest
   
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
   void testUpdateTableReturnsETag(Namespace namespace) {

Reply via email to