This is an automated email from the ASF dual-hosted git repository.
roryqi 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 fcbf3c59a6 [#9736] feat(iceberg-rest): Support freshness-aware table
loading with ETag (#10498)
fcbf3c59a6 is described below
commit fcbf3c59a6f9607682a81c7f3623d582171d1574
Author: akshay thorat <[email protected]>
AuthorDate: Tue Mar 24 04:56:54 2026 -0700
[#9736] feat(iceberg-rest): Support freshness-aware table loading with ETag
(#10498)
Implement HTTP ETag-based conditional request support for the Iceberg
REST server's loadTable endpoint to enable freshness-aware caching.
When loading a table, the server now generates an ETag header from a
SHA-256 hash of the table's metadata file location. If the client sends
an If-None-Match header matching the current ETag, the server responds
with HTTP 304 Not Modified instead of the full table metadata payload.
This allows compute engines that cache Iceberg metadata (e.g., Impala)
to skip re-fetching unchanged metadata, improving read-heavy workload
performance.
Changes:
- Add If-None-Match header parameter to IcebergTableOperations.loadTable
- Generate ETag from metadata file location via SHA-256 hash
- Return 304 Not Modified when client ETag matches, 200 with ETag
otherwise
- Add tests: ETag presence, 304 on match, 200 on mismatch, ETag changes
after update, and ETag consistency across loads
Closes: #9736
---
.../service/rest/IcebergTableOperations.java | 116 +++++++++++++-
.../service/rest/TestIcebergTableOperations.java | 171 +++++++++++++++++++++
2 files changed, 283 insertions(+), 4 deletions(-)
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 e15de6d51b..9d3092503d 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
@@ -23,6 +23,9 @@ import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
@@ -40,6 +43,7 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
+import javax.ws.rs.core.EntityTag;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.commons.lang3.StringUtils;
@@ -86,6 +90,8 @@ public class IcebergTableOperations {
@VisibleForTesting
public static final String X_ICEBERG_ACCESS_DELEGATION =
"X-Iceberg-Access-Delegation";
+ @VisibleForTesting public static final String IF_NONE_MATCH =
"If-None-Match";
+
private IcebergMetricsManager icebergMetricsManager;
private ObjectMapper icebergObjectMapper;
@@ -173,7 +179,7 @@ public class IcebergTableOperations {
new IcebergRequestContext(httpServletRequest(), catalogName,
isCredentialVending);
LoadTableResponse loadTableResponse =
tableOperationDispatcher.createTable(context, icebergNS,
createTableRequest);
- return IcebergRESTUtils.ok(loadTableResponse);
+ return buildResponseWithETag(loadTableResponse);
});
} catch (Exception e) {
return IcebergExceptionMapper.toRESTResponse(e);
@@ -218,7 +224,7 @@ public class IcebergTableOperations {
TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS,
tableName);
LoadTableResponse loadTableResponse =
tableOperationDispatcher.updateTable(context, tableIdentifier,
updateTableRequest);
- return IcebergRESTUtils.ok(loadTableResponse);
+ return buildResponseWithETag(loadTableResponse);
});
} catch (Exception e) {
return IcebergExceptionMapper.toRESTResponse(e);
@@ -283,7 +289,8 @@ public class IcebergTableOperations {
@IcebergAuthorizationMetadata(type = RequestType.LOAD_TABLE) @Encoded()
@PathParam("table")
String table,
@DefaultValue("all") @QueryParam("snapshots") String snapshots,
- @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation) {
+ @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation,
+ @HeaderParam(IF_NONE_MATCH) String ifNoneMatch) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
String tableName = RESTUtil.decodeString(table);
@@ -306,7 +313,12 @@ public class IcebergTableOperations {
new IcebergRequestContext(httpServletRequest(), catalogName,
isCredentialVending);
LoadTableResponse loadTableResponse =
tableOperationDispatcher.loadTable(context, tableIdentifier);
- return IcebergRESTUtils.ok(loadTableResponse);
+ EntityTag etag =
+
generateETag(loadTableResponse.tableMetadata().metadataFileLocation(),
snapshots);
+ if (etag != null && etagMatches(ifNoneMatch, etag)) {
+ return Response.notModified(etag).build();
+ }
+ return buildResponseWithETag(loadTableResponse, etag);
});
} catch (Exception e) {
return IcebergExceptionMapper.toRESTResponse(e);
@@ -508,6 +520,102 @@ public class IcebergTableOperations {
}
}
+ /**
+ * Builds an OK response with the ETag header derived from the table
metadata location.
+ *
+ * @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());
+ return buildResponseWithETag(loadTableResponse, etag);
+ }
+
+ /**
+ * Builds an OK response with the given ETag header.
+ *
+ * @param loadTableResponse the table response to include in the body
+ * @param etag the pre-computed ETag, may be null
+ * @return a Response with ETag header set if etag is non-null
+ */
+ private static Response buildResponseWithETag(
+ LoadTableResponse loadTableResponse, EntityTag etag) {
+ Response.ResponseBuilder responseBuilder =
+ Response.ok(loadTableResponse, MediaType.APPLICATION_JSON_TYPE);
+ if (etag != null) {
+ responseBuilder.tag(etag);
+ }
+ return responseBuilder.build();
+ }
+
+ /**
+ * Generates an ETag based on the table metadata file location. The ETag is
a SHA-256 hash of the
+ * metadata location, which changes whenever the table metadata is updated.
+ *
+ * @param metadataLocation the metadata file location
+ * @return the generated ETag, or null if generation fails
+ */
+ @VisibleForTesting
+ static EntityTag generateETag(String metadataLocation) {
+ return generateETag(metadataLocation, null);
+ }
+
+ /**
+ * Generates an ETag based on the table metadata file location and snapshot
mode. The ETag is a
+ * SHA-256 hash that incorporates both the metadata location and the
snapshots parameter, ensuring
+ * distinct ETags for different representations of the same table version
(e.g., snapshots=all vs
+ * snapshots=refs).
+ *
+ * @param metadataLocation the metadata file location
+ * @param snapshots the snapshots query parameter value (e.g., "all",
"refs"), may be null
+ * @return the generated ETag, or null if generation fails
+ */
+ @VisibleForTesting
+ static EntityTag generateETag(String metadataLocation, String snapshots) {
+ if (metadataLocation == null) {
+ return null;
+ }
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(metadataLocation.getBytes(StandardCharsets.UTF_8));
+ if (snapshots != null) {
+ digest.update(snapshots.getBytes(StandardCharsets.UTF_8));
+ }
+ byte[] hash = digest.digest();
+ StringBuilder hexString = new StringBuilder();
+ for (byte b : hash) {
+ String hex = Integer.toHexString(0xff & b);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ return new EntityTag(hexString.toString());
+ } catch (NoSuchAlgorithmException e) {
+ LOG.warn("Failed to generate ETag for metadata location: {}",
metadataLocation, e);
+ return null;
+ }
+ }
+
+ /**
+ * Checks if the client's If-None-Match header value matches the current
ETag.
+ *
+ * @param ifNoneMatch the If-None-Match header value from the client
+ * @param etag the current ETag
+ * @return true if the ETag matches (table unchanged), false otherwise
+ */
+ private static boolean etagMatches(String ifNoneMatch, EntityTag etag) {
+ if (ifNoneMatch == null || ifNoneMatch.isEmpty()) {
+ return false;
+ }
+ // Strip quotes if present to compare the raw value
+ String clientEtag = ifNoneMatch.trim();
+ if (clientEtag.startsWith("\"") && clientEtag.endsWith("\"")) {
+ clientEtag = clientEtag.substring(1, clientEtag.length() - 1);
+ }
+ return etag.getValue().equals(clientEtag);
+ }
+
private boolean isCredentialVending(String accessDelegation) {
if (StringUtils.isBlank(accessDelegation)) {
return false;
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 606367ecaa..21063176a2 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
@@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -484,6 +485,13 @@ public class TestIcebergTableOperations extends
IcebergNamespaceTestBase {
return getTableClientBuilder(ns, Optional.of(name)).get();
}
+ private Response doLoadTableWithSnapshots(Namespace ns, String name, String
snapshots) {
+ String path =
+ IcebergRestTestUtil.NAMESPACE_PATH + "/" +
RESTUtil.encodeNamespace(ns) + "/tables/" + name;
+ Map<String, String> queryParams = ImmutableMap.of("snapshots", snapshots);
+ return getIcebergClientBuilder(path, Optional.of(queryParams)).get();
+ }
+
private Response doPlanTableScan(Namespace ns, String tableName,
PlanTableScanRequest request) {
Invocation.Builder builder = getTableClientBuilder(ns,
Optional.of(tableName + "/scan"));
return builder.post(Entity.entity(request,
MediaType.APPLICATION_JSON_TYPE));
@@ -751,4 +759,167 @@ public class TestIcebergTableOperations extends
IcebergNamespaceTestBase {
errorBody.contains("vended-credentials") &&
errorBody.contains("illegal"),
"Error message should mention valid values: " + errorBody);
}
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testCreateTableReturnsETag(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ Response response = doCreateTable(namespace, "create_etag_foo1");
+ Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+ String etag = response.getHeaderString("ETag");
+ Assertions.assertNotNull(etag, "ETag header should be present in create
table response");
+ Assertions.assertFalse(etag.isEmpty(), "ETag header should not be empty");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testUpdateTableReturnsETag(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "update_etag_foo1");
+ TableMetadata metadata = getTableMeta(namespace, "update_etag_foo1");
+ Response response = doUpdateTable(namespace, "update_etag_foo1", metadata);
+ Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+ String etag = response.getHeaderString("ETag");
+ Assertions.assertNotNull(etag, "ETag header should be present in update
table response");
+ Assertions.assertFalse(etag.isEmpty(), "ETag header should not be empty");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableReturnsETag(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_foo1");
+
+ // Load the table and verify ETag header is present
+ Response response = doLoadTable(namespace, "etag_foo1");
+ Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+ String etag = response.getHeaderString("ETag");
+ Assertions.assertNotNull(etag, "ETag header should be present in load
table response");
+ Assertions.assertFalse(etag.isEmpty(), "ETag header should not be empty");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableReturns304WhenETagMatches(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_304_foo1");
+
+ // First, load the table to get the ETag
+ Response firstResponse = doLoadTable(namespace, "etag_304_foo1");
+ Assertions.assertEquals(Status.OK.getStatusCode(),
firstResponse.getStatus());
+ String etag = firstResponse.getHeaderString("ETag");
+ Assertions.assertNotNull(etag, "ETag header should be present");
+
+ // Second, load the table with If-None-Match header set to the ETag
+ Response secondResponse =
+ getTableClientBuilder(namespace, Optional.of("etag_304_foo1"))
+ .header(IcebergTableOperations.IF_NONE_MATCH, etag)
+ .get();
+ Assertions.assertEquals(
+ Status.NOT_MODIFIED.getStatusCode(),
+ secondResponse.getStatus(),
+ "Should return 304 when ETag matches");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableReturns200WhenETagDoesNotMatch(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_mismatch_foo1");
+
+ // Load with a non-matching If-None-Match header
+ Response response =
+ getTableClientBuilder(namespace, Optional.of("etag_mismatch_foo1"))
+ .header(IcebergTableOperations.IF_NONE_MATCH,
"\"non-matching-etag-value\"")
+ .get();
+ Assertions.assertEquals(
+ Status.OK.getStatusCode(),
+ response.getStatus(),
+ "Should return 200 when ETag does not match");
+ String etag = response.getHeaderString("ETag");
+ Assertions.assertNotNull(etag, "ETag header should be present");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableETagChangesAfterUpdate(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_update_foo1");
+
+ // Load the table and get the initial ETag
+ Response firstResponse = doLoadTable(namespace, "etag_update_foo1");
+ Assertions.assertEquals(Status.OK.getStatusCode(),
firstResponse.getStatus());
+ String firstEtag = firstResponse.getHeaderString("ETag");
+ Assertions.assertNotNull(firstEtag, "ETag header should be present");
+
+ // Update the table
+ TableMetadata metadata = getTableMeta(namespace, "etag_update_foo1");
+ verifyUpdateSucc(namespace, "etag_update_foo1", metadata);
+
+ // Load the table again and verify the ETag has changed
+ Response secondResponse = doLoadTable(namespace, "etag_update_foo1");
+ Assertions.assertEquals(Status.OK.getStatusCode(),
secondResponse.getStatus());
+ String secondEtag = secondResponse.getHeaderString("ETag");
+ Assertions.assertNotNull(secondEtag, "ETag header should be present after
update");
+ Assertions.assertNotEquals(
+ firstEtag, secondEtag, "ETag should change after table metadata is
updated");
+
+ // Verify old ETag no longer returns 304
+ Response thirdResponse =
+ getTableClientBuilder(namespace, Optional.of("etag_update_foo1"))
+ .header(IcebergTableOperations.IF_NONE_MATCH, firstEtag)
+ .get();
+ Assertions.assertEquals(
+ Status.OK.getStatusCode(),
+ thirdResponse.getStatus(),
+ "Old ETag should not return 304 after table update");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableETagConsistency(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_consistent_foo1");
+
+ // Load the table twice and verify the ETag is the same
+ Response firstResponse = doLoadTable(namespace, "etag_consistent_foo1");
+ String firstEtag = firstResponse.getHeaderString("ETag");
+
+ Response secondResponse = doLoadTable(namespace, "etag_consistent_foo1");
+ String secondEtag = secondResponse.getHeaderString("ETag");
+
+ Assertions.assertEquals(
+ firstEtag, secondEtag, "ETag should be consistent for the same table
metadata");
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+ void testLoadTableETagDiffersForSnapshotsParam(Namespace namespace) {
+ verifyCreateNamespaceSucc(namespace);
+ verifyCreateTableSucc(namespace, "etag_snapshots_foo1");
+
+ // Load with snapshots=all (default)
+ Response allResponse = doLoadTableWithSnapshots(namespace,
"etag_snapshots_foo1", "all");
+ Assertions.assertEquals(Status.OK.getStatusCode(),
allResponse.getStatus());
+ String allEtag = allResponse.getHeaderString("ETag");
+ Assertions.assertNotNull(allEtag, "ETag header should be present for
snapshots=all");
+
+ // Load with snapshots=refs
+ Response refsResponse = doLoadTableWithSnapshots(namespace,
"etag_snapshots_foo1", "refs");
+ Assertions.assertEquals(Status.OK.getStatusCode(),
refsResponse.getStatus());
+ String refsEtag = refsResponse.getHeaderString("ETag");
+ Assertions.assertNotNull(refsEtag, "ETag header should be present for
snapshots=refs");
+
+ // ETags should differ for different snapshots values
+ Assertions.assertNotEquals(
+ allEtag,
+ refsEtag,
+ "ETags should be distinct for different snapshots query parameter
values");
+
+ // Loading with the same snapshots value should produce the same ETag
+ Response allResponse2 = doLoadTableWithSnapshots(namespace,
"etag_snapshots_foo1", "all");
+ String allEtag2 = allResponse2.getHeaderString("ETag");
+ Assertions.assertEquals(
+ allEtag, allEtag2, "ETag should be consistent for the same snapshots
value");
+ }
}