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

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


The following commit(s) were added to refs/heads/main by this push:
     new 8b5dfa97a Added freshness aware table loading using metadata file 
location for ETag (#1037)
8b5dfa97a is described below

commit 8b5dfa97a8e50a53412d2ded40f2e4db5a40cc42
Author: Mansehaj Singh <[email protected]>
AuthorDate: Tue Apr 1 16:11:57 2025 -0700

    Added freshness aware table loading using metadata file location for ETag 
(#1037)
    
    * Pulled in iceberg 1.8.0 spec changes for freshness aware table loading 
and added feature to Polaris
    
    * Changed etag support to use entityId:version tuple
    
    * fixed getresponse call
    
    * Changed etagged response to record and gave default implementation to 
ETaggableEntity
    
    * Made iceberg rest spec docs clearer
    
    * Added HTTP Compliant ETag and IfNoneMatch representations and separated 
persistence from etag logic
    
    * Changed ETag to be a record and improved semantics of IfNoneMatch
    
    * Fixed semantics of if none match
    
    * Removed ETag representation, consolidated in IfNoneMatch
    
    * fixed if none match parsing
    
    * Added table entity retrieval method to table operations
    
    * removed accidental commit of pycache folders
    
    * Fixed formatting
    
    * Changed to use metadata location hash
    
    * Ran formatting
    
    * use sha256
    
    * Moved out ETag functions to utility class and removed 
ETaggedLoadTableResponse
    
    * Addressed comments
    
    * Fixed IcebergTableLikeEntity package rename
---
 .../it/test/PolarisRestCatalogIntegrationTest.java | 151 +++++++++++++++++++++
 .../IcebergCatalogHandlerWrapperAuthzTest.java     |  97 +++++++++++++
 .../catalog/iceberg/IcebergCatalogAdapter.java     |  58 +++++++-
 .../iceberg/IcebergCatalogHandlerWrapper.java      |  92 ++++++++++++-
 .../polaris/service/http/IcebergHttpUtil.java      |  42 ++++++
 .../apache/polaris/service/http/IfNoneMatch.java   | 113 +++++++++++++++
 .../polaris/service/http/IfNoneMatchTest.java      | 132 ++++++++++++++++++
 7 files changed, 675 insertions(+), 10 deletions(-)

diff --git 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java
 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java
index 83c9791b3..f7466b77f 100644
--- 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java
+++ 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java
@@ -25,6 +25,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 import com.google.common.collect.ImmutableMap;
 import jakarta.ws.rs.client.Entity;
 import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.core.HttpHeaders;
 import jakarta.ws.rs.core.Response;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -56,6 +57,7 @@ import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.io.ResolvingFileIO;
 import org.apache.iceberg.rest.RESTCatalog;
+import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.apache.iceberg.types.Types;
 import org.apache.polaris.core.admin.model.AwsStorageConfigInfo;
@@ -92,6 +94,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -646,6 +649,154 @@ public class PolarisRestCatalogIntegrationTest extends 
CatalogTests<RESTCatalog>
     }
   }
 
+  /**
+   * Register a table. Then, invoke an initial loadTable request to fetch and 
ensure ETag is
+   * present. Then, invoke a second loadTable to ensure that ETag is matched.
+   */
+  @Test
+  @Disabled("Enable once ETag support is available in the API for loadTable.")
+  public void testLoadTableTwiceWithETag() {
+    // TODO: Re-enable test once spec is up to date with ETag change for 
loadTable in Iceberg
+
+    Namespace ns1 = Namespace.of("ns1");
+    restCatalog.createNamespace(ns1);
+    TableMetadata tableMetadata =
+        TableMetadata.newTableMetadata(
+            new Schema(List.of(Types.NestedField.of(1, false, "col1", new 
Types.StringType()))),
+            PartitionSpec.unpartitioned(),
+            "file:///tmp/ns1/my_table",
+            Map.of());
+    try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+      resolvingFileIO.initialize(Map.of());
+      resolvingFileIO.setConf(new Configuration());
+      String fileLocation = 
"file:///tmp/ns1/my_table/metadata/v1.metadata.json";
+      TableMetadataParser.write(tableMetadata, 
resolvingFileIO.newOutputFile(fileLocation));
+      restCatalog.registerTable(TableIdentifier.of(ns1, "my_table_etagged"), 
fileLocation);
+      Invocation invocation =
+          catalogApi
+              .request("v1/" + currentCatalogName + 
"/namespaces/ns1/tables/my_table_etagged")
+              .build("GET");
+      try (Response initialLoadTable = invocation.invoke()) {
+        
assertThat(initialLoadTable.getHeaders()).containsKey(HttpHeaders.ETAG);
+        String etag = 
initialLoadTable.getHeaders().getFirst(HttpHeaders.ETAG).toString();
+
+        Invocation etaggedInvocation =
+            catalogApi
+                .request("v1/" + currentCatalogName + 
"/namespaces/ns1/tables/my_table_etagged")
+                .header(HttpHeaders.IF_NONE_MATCH, etag)
+                .build("GET");
+
+        try (Response etaggedLoadTable = etaggedInvocation.invoke()) {
+          assertThat(etaggedLoadTable.getStatus())
+              .isEqualTo(Response.Status.NOT_MODIFIED.getStatusCode());
+        }
+      } finally {
+        resolvingFileIO.deleteFile(fileLocation);
+      }
+    }
+  }
+
+  /**
+   * Invoke an initial registerTable request to fetch and ensure ETag is 
present. Then, invoke a
+   * second loadTable to ensure that ETag is matched.
+   */
+  @Test
+  @Disabled("Enable once ETag support is available in the API for loadTable.")
+  public void testRegisterAndLoadTableWithReturnedETag() {
+    // TODO: Re-enable test once spec is up to date with ETag change for 
loadTable in Iceberg
+
+    Namespace ns1 = Namespace.of("ns1");
+    restCatalog.createNamespace(ns1);
+    TableMetadata tableMetadata =
+        TableMetadata.newTableMetadata(
+            new Schema(List.of(Types.NestedField.of(1, false, "col1", new 
Types.StringType()))),
+            PartitionSpec.unpartitioned(),
+            "file:///tmp/ns1/my_table",
+            Map.of());
+    try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+      resolvingFileIO.initialize(Map.of());
+      resolvingFileIO.setConf(new Configuration());
+      String fileLocation = 
"file:///tmp/ns1/my_table/metadata/v1.metadata.json";
+      TableMetadataParser.write(tableMetadata, 
resolvingFileIO.newOutputFile(fileLocation));
+
+      Invocation registerInvocation =
+          catalogApi
+              .request("v1/" + currentCatalogName + "/namespaces/ns1/register")
+              .buildPost(
+                  Entity.json(
+                      Map.of("name", "my_etagged_table", "metadata-location", 
fileLocation)));
+      try (Response registerResponse = registerInvocation.invoke()) {
+        
assertThat(registerResponse.getHeaders()).containsKey(HttpHeaders.ETAG);
+        String etag = 
registerResponse.getHeaders().getFirst(HttpHeaders.ETAG).toString();
+
+        Invocation etaggedInvocation =
+            catalogApi
+                .request("v1/" + currentCatalogName + 
"/namespaces/ns1/tables/my_etagged_table")
+                .header(HttpHeaders.IF_NONE_MATCH, etag)
+                .build("GET");
+
+        try (Response etaggedLoadTable = etaggedInvocation.invoke()) {
+          assertThat(etaggedLoadTable.getStatus())
+              .isEqualTo(Response.Status.NOT_MODIFIED.getStatusCode());
+        }
+
+      } finally {
+        resolvingFileIO.deleteFile(fileLocation);
+      }
+    }
+  }
+
+  @Test
+  @Disabled("Enable once ETag support is available in the API for loadTable.")
+  public void testCreateAndLoadTableWithReturnedEtag() {
+    // TODO: Re-enable test once spec is up to date with ETag change for 
loadTable in Iceberg
+
+    Namespace ns1 = Namespace.of("ns1");
+    restCatalog.createNamespace(ns1);
+    TableMetadata tableMetadata =
+        TableMetadata.newTableMetadata(
+            new Schema(List.of(Types.NestedField.of(1, false, "col1", new 
Types.StringType()))),
+            PartitionSpec.unpartitioned(),
+            "file:///tmp/ns1/my_table",
+            Map.of());
+    try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+      resolvingFileIO.initialize(Map.of());
+      resolvingFileIO.setConf(new Configuration());
+      String fileLocation = 
"file:///tmp/ns1/my_table/metadata/v1.metadata.json";
+      TableMetadataParser.write(tableMetadata, 
resolvingFileIO.newOutputFile(fileLocation));
+
+      Invocation createInvocation =
+          catalogApi
+              .request("v1/" + currentCatalogName + "/namespaces/ns1/tables")
+              .buildPost(
+                  Entity.json(
+                      CreateTableRequest.builder()
+                          .withName("my_etagged_table")
+                          .withLocation(tableMetadata.location())
+                          .withPartitionSpec(tableMetadata.spec())
+                          .withSchema(tableMetadata.schema())
+                          .withWriteOrder(tableMetadata.sortOrder())
+                          .build()));
+      try (Response createResponse = createInvocation.invoke()) {
+        assertThat(createResponse.getHeaders()).containsKey(HttpHeaders.ETAG);
+        String etag = 
createResponse.getHeaders().getFirst(HttpHeaders.ETAG).toString();
+
+        Invocation etaggedInvocation =
+            catalogApi
+                .request("v1/" + currentCatalogName + 
"/namespaces/ns1/tables/my_etagged_table")
+                .header(HttpHeaders.IF_NONE_MATCH, etag)
+                .build("GET");
+
+        try (Response etaggedLoadTable = etaggedInvocation.invoke()) {
+          assertThat(etaggedLoadTable.getStatus())
+              .isEqualTo(Response.Status.NOT_MODIFIED.getStatusCode());
+        }
+      } finally {
+        resolvingFileIO.deleteFile(fileLocation);
+      }
+    }
+  }
+
   @Test
   public void testSendNotificationInternalCatalog() {
     Map<String, String> payload =
diff --git 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerWrapperAuthzTest.java
 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerWrapperAuthzTest.java
index e028e5e3b..1e2d05bd6 100644
--- 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerWrapperAuthzTest.java
+++ 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerWrapperAuthzTest.java
@@ -69,6 +69,7 @@ import 
org.apache.polaris.service.catalog.io.DefaultFileIOFactory;
 import org.apache.polaris.service.config.RealmEntityManagerFactory;
 import org.apache.polaris.service.context.CallContextCatalogFactory;
 import org.apache.polaris.service.context.PolarisCallContextCatalogFactory;
+import org.apache.polaris.service.http.IfNoneMatch;
 import org.apache.polaris.service.quarkus.admin.PolarisAuthzTestBase;
 import org.apache.polaris.service.types.NotificationRequest;
 import org.apache.polaris.service.types.NotificationType;
@@ -865,6 +866,35 @@ public class IcebergCatalogHandlerWrapperAuthzTest extends 
PolarisAuthzTestBase
         () -> newWrapper().loadTable(TABLE_NS1A_2, "all"));
   }
 
+  @Test
+  public void testLoadTableIfStaleSufficientPrivileges() {
+    doTestSufficientPrivileges(
+        List.of(
+            PolarisPrivilege.TABLE_READ_PROPERTIES,
+            PolarisPrivilege.TABLE_WRITE_PROPERTIES,
+            PolarisPrivilege.TABLE_READ_DATA,
+            PolarisPrivilege.TABLE_WRITE_DATA,
+            PolarisPrivilege.TABLE_FULL_METADATA,
+            PolarisPrivilege.CATALOG_MANAGE_CONTENT),
+        () ->
+            newWrapper().loadTableIfStale(TABLE_NS1A_2, 
IfNoneMatch.fromHeader("W/\"0:0\""), "all"),
+        null /* cleanupAction */);
+  }
+
+  @Test
+  public void testLoadTableIfStaleInsufficientPermissions() {
+    doTestInsufficientPrivileges(
+        List.of(
+            PolarisPrivilege.NAMESPACE_FULL_METADATA,
+            PolarisPrivilege.VIEW_FULL_METADATA,
+            PolarisPrivilege.TABLE_CREATE,
+            PolarisPrivilege.TABLE_LIST,
+            PolarisPrivilege.TABLE_DROP),
+        () ->
+            newWrapper()
+                .loadTableIfStale(TABLE_NS1A_2, 
IfNoneMatch.fromHeader("W/\"0:0\""), "all"));
+  }
+
   @Test
   public void testLoadTableWithReadAccessDelegationSufficientPrivileges() {
     doTestSufficientPrivileges(
@@ -920,6 +950,73 @@ public class IcebergCatalogHandlerWrapperAuthzTest extends 
PolarisAuthzTestBase
         () -> newWrapper().loadTableWithAccessDelegation(TABLE_NS1A_2, "all"));
   }
 
+  @Test
+  public void 
testLoadTableWithReadAccessDelegationIfStaleSufficientPrivileges() {
+    doTestSufficientPrivileges(
+        List.of(
+            PolarisPrivilege.TABLE_READ_DATA,
+            PolarisPrivilege.TABLE_WRITE_DATA,
+            PolarisPrivilege.CATALOG_MANAGE_CONTENT),
+        () ->
+            newWrapper()
+                .loadTableWithAccessDelegationIfStale(
+                    TABLE_NS1A_2, IfNoneMatch.fromHeader("W/\"0:0\""), "all"),
+        null /* cleanupAction */);
+  }
+
+  @Test
+  public void 
testLoadTableWithReadAccessDelegationIfStaleInsufficientPermissions() {
+    doTestInsufficientPrivileges(
+        List.of(
+            PolarisPrivilege.NAMESPACE_FULL_METADATA,
+            PolarisPrivilege.VIEW_FULL_METADATA,
+            PolarisPrivilege.TABLE_FULL_METADATA,
+            PolarisPrivilege.TABLE_READ_PROPERTIES,
+            PolarisPrivilege.TABLE_WRITE_PROPERTIES,
+            PolarisPrivilege.TABLE_CREATE,
+            PolarisPrivilege.TABLE_LIST,
+            PolarisPrivilege.TABLE_DROP),
+        () ->
+            newWrapper()
+                .loadTableWithAccessDelegationIfStale(
+                    TABLE_NS1A_2, IfNoneMatch.fromHeader("W/\"0:0\""), "all"));
+  }
+
+  @Test
+  public void 
testLoadTableWithWriteAccessDelegationIfStaleSufficientPrivileges() {
+    doTestSufficientPrivileges(
+        List.of(
+            // TODO: Once we give different creds for read/write privilege, 
move this
+            // TABLE_READ_DATA into a special-case test; with only 
TABLE_READ_DATA we'd expet
+            // to receive a read-only credential.
+            PolarisPrivilege.TABLE_READ_DATA,
+            PolarisPrivilege.TABLE_WRITE_DATA,
+            PolarisPrivilege.CATALOG_MANAGE_CONTENT),
+        () ->
+            newWrapper()
+                .loadTableWithAccessDelegationIfStale(
+                    TABLE_NS1A_2, IfNoneMatch.fromHeader("W/\"0:0\""), "all"),
+        null /* cleanupAction */);
+  }
+
+  @Test
+  public void 
testLoadTableWithWriteAccessDelegationIfStaleInsufficientPermissions() {
+    doTestInsufficientPrivileges(
+        List.of(
+            PolarisPrivilege.NAMESPACE_FULL_METADATA,
+            PolarisPrivilege.VIEW_FULL_METADATA,
+            PolarisPrivilege.TABLE_FULL_METADATA,
+            PolarisPrivilege.TABLE_READ_PROPERTIES,
+            PolarisPrivilege.TABLE_WRITE_PROPERTIES,
+            PolarisPrivilege.TABLE_CREATE,
+            PolarisPrivilege.TABLE_LIST,
+            PolarisPrivilege.TABLE_DROP),
+        () ->
+            newWrapper()
+                .loadTableWithAccessDelegationIfStale(
+                    TABLE_NS1A_2, IfNoneMatch.fromHeader("W/\"0:0\""), "all"));
+  }
+
   @Test
   public void testUpdateTableSufficientPrivileges() {
     doTestSufficientPrivileges(
diff --git 
a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
 
b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
index 7cb4f2f95..4da568322 100644
--- 
a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
+++ 
b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
@@ -26,6 +26,8 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import jakarta.enterprise.context.RequestScoped;
 import jakarta.inject.Inject;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.HttpHeaders;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.SecurityContext;
 import java.net.URLEncoder;
@@ -70,6 +72,8 @@ import org.apache.polaris.service.catalog.CatalogPrefixParser;
 import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService;
 import 
org.apache.polaris.service.catalog.api.IcebergRestConfigurationApiService;
 import org.apache.polaris.service.context.CallContextCatalogFactory;
+import org.apache.polaris.service.http.IcebergHttpUtil;
+import org.apache.polaris.service.http.IfNoneMatch;
 import org.apache.polaris.service.types.CommitTableRequest;
 import org.apache.polaris.service.types.CommitViewRequest;
 import org.apache.polaris.service.types.NotificationRequest;
@@ -307,9 +311,21 @@ public class IcebergCatalogAdapter
                   .build();
             }
           } else if (delegationModes.isEmpty()) {
-            return Response.ok(catalog.createTableDirect(ns, 
createTableRequest)).build();
+            LoadTableResponse response = catalog.createTableDirect(ns, 
createTableRequest);
+            return Response.ok(response)
+                .header(
+                    HttpHeaders.ETAG,
+                    IcebergHttpUtil.generateETagForMetadataFileLocation(
+                        response.metadataLocation()))
+                .build();
           } else {
-            return 
Response.ok(catalog.createTableDirectWithWriteDelegation(ns, 
createTableRequest))
+            LoadTableResponse response =
+                catalog.createTableDirectWithWriteDelegation(ns, 
createTableRequest);
+            return Response.ok(response)
+                .header(
+                    HttpHeaders.ETAG,
+                    IcebergHttpUtil.generateETagForMetadataFileLocation(
+                        response.metadataLocation()))
                 .build();
           }
         });
@@ -341,16 +357,38 @@ public class IcebergCatalogAdapter
         parseAccessDelegationModes(accessDelegationMode);
     Namespace ns = decodeNamespace(namespace);
     TableIdentifier tableIdentifier = TableIdentifier.of(ns, 
RESTUtil.decodeString(table));
+
+    // TODO: Populate with header value from parameter once the generated 
interface
+    //  contains the if-none-match header
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader(null);
+
+    if (ifNoneMatch.isWildcard()) {
+      throw new BadRequestException("If-None-Match may not take the value of 
'*'");
+    }
+
     return withCatalog(
         securityContext,
         prefix,
         catalog -> {
+          LoadTableResponse response;
+
           if (delegationModes.isEmpty()) {
-            return Response.ok(catalog.loadTable(tableIdentifier, 
snapshots)).build();
+            response =
+                catalog
+                    .loadTableIfStale(tableIdentifier, ifNoneMatch, snapshots)
+                    .orElseThrow(() -> new 
WebApplicationException(Response.Status.NOT_MODIFIED));
           } else {
-            return 
Response.ok(catalog.loadTableWithAccessDelegation(tableIdentifier, snapshots))
-                .build();
+            response =
+                catalog
+                    .loadTableWithAccessDelegationIfStale(tableIdentifier, 
ifNoneMatch, snapshots)
+                    .orElseThrow(() -> new 
WebApplicationException(Response.Status.NOT_MODIFIED));
           }
+
+          return Response.ok(response)
+              .header(
+                  HttpHeaders.ETAG,
+                  
IcebergHttpUtil.generateETagForMetadataFileLocation(response.metadataLocation()))
+              .build();
         });
   }
 
@@ -406,7 +444,15 @@ public class IcebergCatalogAdapter
     return withCatalog(
         securityContext,
         prefix,
-        catalog -> Response.ok(catalog.registerTable(ns, 
registerTableRequest)).build());
+        catalog -> {
+          LoadTableResponse response = catalog.registerTable(ns, 
registerTableRequest);
+
+          return Response.ok(response)
+              .header(
+                  HttpHeaders.ETAG,
+                  
IcebergHttpUtil.generateETagForMetadataFileLocation(response.metadataLocation()))
+              .build();
+        });
   }
 
   @Override
diff --git 
a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerWrapper.java
 
b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerWrapper.java
index fab2453e7..284001fa5 100644
--- 
a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerWrapper.java
+++ 
b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerWrapper.java
@@ -80,6 +80,7 @@ import org.apache.polaris.core.context.CallContext;
 import org.apache.polaris.core.entity.CatalogEntity;
 import org.apache.polaris.core.entity.PolarisEntitySubType;
 import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.entity.table.IcebergTableLikeEntity;
 import org.apache.polaris.core.persistence.PolarisEntityManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
 import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
@@ -92,6 +93,8 @@ import 
org.apache.polaris.core.persistence.resolver.ResolverStatus;
 import org.apache.polaris.core.storage.PolarisStorageActions;
 import org.apache.polaris.service.catalog.SupportsNotifications;
 import org.apache.polaris.service.context.CallContextCatalogFactory;
+import org.apache.polaris.service.http.IcebergHttpUtil;
+import org.apache.polaris.service.http.IfNoneMatch;
 import org.apache.polaris.service.types.NotificationRequest;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -545,10 +548,17 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
     return CatalogHandlers.listTables(baseCatalog, namespace);
   }
 
+  /**
+   * Create a table.
+   *
+   * @param namespace the namespace to create the table in
+   * @param request the table creation request
+   * @return ETagged {@link LoadTableResponse} to uniquely identify the table 
metadata
+   */
   public LoadTableResponse createTableDirect(Namespace namespace, 
CreateTableRequest request) {
     PolarisAuthorizableOperation op = 
PolarisAuthorizableOperation.CREATE_TABLE_DIRECT;
-    authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
-        op, TableIdentifier.of(namespace, request.name()));
+    TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
+    authorizeCreateTableLikeUnderNamespaceOperationOrThrow(op, identifier);
 
     CatalogEntity catalog =
         CatalogEntity.of(
@@ -562,6 +572,13 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
     return CatalogHandlers.createTable(baseCatalog, namespace, request);
   }
 
+  /**
+   * Create a table.
+   *
+   * @param namespace the namespace to create the table in
+   * @param request the table creation request
+   * @return ETagged {@link LoadTableResponse} to uniquely identify the table 
metadata
+   */
   public LoadTableResponse createTableDirectWithWriteDelegation(
       Namespace namespace, CreateTableRequest request) {
     PolarisAuthorizableOperation op =
@@ -722,6 +739,13 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
     return responseBuilder.build();
   }
 
+  /**
+   * Register a table.
+   *
+   * @param namespace The namespace to register the table in
+   * @param request the register table request
+   * @return ETagged {@link LoadTableResponse} to uniquely identify the table 
metadata
+   */
   public LoadTableResponse registerTable(Namespace namespace, 
RegisterTableRequest request) {
     PolarisAuthorizableOperation op = 
PolarisAuthorizableOperation.REGISTER_TABLE;
     authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
@@ -768,16 +792,67 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
         && notificationCatalog.sendNotification(identifier, request);
   }
 
+  /**
+   * Fetch the metastore table entity for the given table identifier
+   *
+   * @param tableIdentifier The identifier of the table
+   * @return the Polaris table entity for the table
+   */
+  private IcebergTableLikeEntity getTableEntity(TableIdentifier 
tableIdentifier) {
+    PolarisResolvedPathWrapper target =
+        resolutionManifest.getPassthroughResolvedPath(tableIdentifier);
+
+    return IcebergTableLikeEntity.of(target.getRawLeafEntity());
+  }
+
   public LoadTableResponse loadTable(TableIdentifier tableIdentifier, String 
snapshots) {
+    return loadTableIfStale(tableIdentifier, null, snapshots).get();
+  }
+
+  /**
+   * Attempt to perform a loadTable operation only when the specified set of 
eTags do not match the
+   * current state of the table metadata.
+   *
+   * @param tableIdentifier The identifier of the table to load
+   * @param ifNoneMatch set of entity-tags to check the metadata against for 
staleness
+   * @param snapshots
+   * @return {@link Optional#empty()} if the ETag is current, an {@link 
Optional} containing the
+   *     load table response, otherwise
+   */
+  public Optional<LoadTableResponse> loadTableIfStale(
+      TableIdentifier tableIdentifier, IfNoneMatch ifNoneMatch, String 
snapshots) {
     PolarisAuthorizableOperation op = PolarisAuthorizableOperation.LOAD_TABLE;
     authorizeBasicTableLikeOperationOrThrow(
         op, PolarisEntitySubType.ICEBERG_TABLE, tableIdentifier);
 
-    return CatalogHandlers.loadTable(baseCatalog, tableIdentifier);
+    IcebergTableLikeEntity tableEntity = getTableEntity(tableIdentifier);
+    String tableEntityTag =
+        
IcebergHttpUtil.generateETagForMetadataFileLocation(tableEntity.getMetadataLocation());
+
+    if (ifNoneMatch != null && ifNoneMatch.anyMatch(tableEntityTag)) {
+      return Optional.empty();
+    }
+
+    return Optional.of(CatalogHandlers.loadTable(baseCatalog, 
tableIdentifier));
   }
 
   public LoadTableResponse loadTableWithAccessDelegation(
       TableIdentifier tableIdentifier, String snapshots) {
+    return loadTableWithAccessDelegationIfStale(tableIdentifier, null, 
snapshots).get();
+  }
+
+  /**
+   * Attempt to perform a loadTable operation with access delegation only when 
the if none of the
+   * provided eTags match the current state of the table metadata.
+   *
+   * @param tableIdentifier The identifier of the table to load
+   * @param ifNoneMatch set of entity-tags to check the metadata against for 
staleness
+   * @param snapshots
+   * @return {@link Optional#empty()} if the ETag is current, an {@link 
Optional} containing the
+   *     load table response, otherwise
+   */
+  public Optional<LoadTableResponse> loadTableWithAccessDelegationIfStale(
+      TableIdentifier tableIdentifier, IfNoneMatch ifNoneMatch, String 
snapshots) {
     // Here we have a single method that falls through multiple candidate
     // PolarisAuthorizableOperations because instead of identifying the 
desired operation up-front
     // and
@@ -822,6 +897,14 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
           
FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig());
     }
 
+    IcebergTableLikeEntity tableEntity = getTableEntity(tableIdentifier);
+    String tableETag =
+        
IcebergHttpUtil.generateETagForMetadataFileLocation(tableEntity.getMetadataLocation());
+
+    if (ifNoneMatch != null && ifNoneMatch.anyMatch(tableETag)) {
+      return Optional.empty();
+    }
+
     // TODO: Find a way for the configuration or caller to better express 
whether to fail or omit
     // when data-access is specified but access delegation grants are not 
found.
     Table table = baseCatalog.loadTable(tableIdentifier);
@@ -840,7 +923,8 @@ public class IcebergCatalogHandlerWrapper implements 
AutoCloseable {
             credentialDelegation.getCredentialConfig(
                 tableIdentifier, tableMetadata, actionsRequested));
       }
-      return responseBuilder.build();
+
+      return Optional.of(responseBuilder.build());
     } else if (table instanceof BaseMetadataTable) {
       // metadata tables are loaded on the client side, return 
NoSuchTableException for now
       throw new NoSuchTableException("Table does not exist: %s", 
tableIdentifier.toString());
diff --git 
a/service/common/src/main/java/org/apache/polaris/service/http/IcebergHttpUtil.java
 
b/service/common/src/main/java/org/apache/polaris/service/http/IcebergHttpUtil.java
new file mode 100644
index 000000000..88c056aa7
--- /dev/null
+++ 
b/service/common/src/main/java/org/apache/polaris/service/http/IcebergHttpUtil.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.polaris.service.http;
+
+import org.apache.commons.codec.digest.DigestUtils;
+
+/** Utility class that encapsulates logic pertaining to Iceberg REST specific 
concepts. */
+public class IcebergHttpUtil {
+
+  private IcebergHttpUtil() {}
+
+  /**
+   * Generate an ETag from an Iceberg metadata file location
+   *
+   * @param metadataFileLocation
+   * @return the generated ETag
+   */
+  public static String generateETagForMetadataFileLocation(String 
metadataFileLocation) {
+    // Use hash of metadata location since we don't want clients to use the 
ETag to try to extract
+    // the metadata file location
+    String hashedMetadataFileLocation = 
DigestUtils.sha256Hex(metadataFileLocation);
+
+    // always issue a weak ETag since we semantically compare metadata, not 
its content byte-by-byte
+    return "W/\"" + hashedMetadataFileLocation + "\"";
+  }
+}
diff --git 
a/service/common/src/main/java/org/apache/polaris/service/http/IfNoneMatch.java 
b/service/common/src/main/java/org/apache/polaris/service/http/IfNoneMatch.java
new file mode 100644
index 000000000..c8fa10434
--- /dev/null
+++ 
b/service/common/src/main/java/org/apache/polaris/service/http/IfNoneMatch.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.polaris.service.http;
+
+import jakarta.annotation.Nonnull;
+import java.util.List;
+import java.util.regex.MatchResult;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Logical representation of an HTTP compliant If-None-Match header. */
+public record IfNoneMatch(boolean isWildcard, @Nonnull List<String> eTags) {
+
+  private static final String WILDCARD_HEADER_VALUE = "*";
+
+  // Matches but does not capture any content of an ETag
+  private static final String ETAG_REGEX = "(?:W/)?\"(?:[^\"]*)\"";
+
+  // Builds comma separated list of ETags and disallows trailing comma
+  private static final String IF_NONE_MATCH_REGEX =
+      String.format("(?:%s, )*%s", ETAG_REGEX, ETAG_REGEX);
+
+  // Wraps pattern in capture group to capture entire ETag
+  private static final Pattern ETAG_PATTERN = 
Pattern.compile(String.format("(%s)", ETAG_REGEX));
+
+  // Used to validate entire header pattern
+  private static final Pattern IF_NONE_MATCH_PATTERN = 
Pattern.compile(IF_NONE_MATCH_REGEX);
+
+  public static final IfNoneMatch EMPTY = new IfNoneMatch(List.of());
+
+  public static final IfNoneMatch WILDCARD = new IfNoneMatch(true, List.of());
+
+  public IfNoneMatch(List<String> etags) {
+    this(false, etags);
+  }
+
+  public IfNoneMatch {
+    if (isWildcard && !eTags.isEmpty()) {
+      // if the header is a wildcard, it must not be constructed with
+      // any etags, if it is not a wildcard, it can still contain no ETags, so
+      // the converse is not true
+      throw new IllegalArgumentException(
+          "Invalid representation for If-None-Match header. If-None-Match 
cannot contain ETags if it takes "
+              + "the wildcard value '*'");
+    }
+
+    for (String etag : eTags) {
+      Matcher matcher = ETAG_PATTERN.matcher(etag);
+
+      if (!matcher.matches()) {
+        throw new IllegalArgumentException("Invalid ETag representation: " + 
etag);
+      }
+    }
+  }
+
+  /**
+   * Parses the raw content of an If-None-Match header into the logical 
representation
+   *
+   * @param rawValue The raw value of the If-None-Match header
+   * @return A logically equivalent representation of the raw header content
+   */
+  public static IfNoneMatch fromHeader(String rawValue) {
+    // parse null header as an empty header
+    if (rawValue == null) {
+      return EMPTY;
+    }
+
+    rawValue = rawValue.trim();
+    if (rawValue.equals(WILDCARD_HEADER_VALUE)) {
+      return WILDCARD;
+    } else {
+
+      // ensure entire header matches expected pattern
+      if (!IF_NONE_MATCH_PATTERN.matcher(rawValue).matches()) {
+        throw new IllegalArgumentException("Invalid If-None-Match header: " + 
rawValue);
+      }
+
+      // extract out ETags using the capture group
+      List<String> etags =
+          
ETAG_PATTERN.matcher(rawValue).results().map(MatchResult::group).toList();
+
+      return new IfNoneMatch(etags);
+    }
+  }
+
+  /**
+   * If any contained ETag matches the provided etag or the header is a 
wildcard. Only matches weak
+   * eTags to weak eTags and strong eTags to strong eTags.
+   *
+   * @param eTag the etag to compare against.
+   * @return true if any of the contained ETags match the provided etag
+   */
+  public boolean anyMatch(String eTag) {
+    if (this.isWildcard) return true;
+    return eTags.contains(eTag);
+  }
+}
diff --git 
a/service/common/src/test/java/org/apache/polaris/service/http/IfNoneMatchTest.java
 
b/service/common/src/test/java/org/apache/polaris/service/http/IfNoneMatchTest.java
new file mode 100644
index 000000000..8e57d02da
--- /dev/null
+++ 
b/service/common/src/test/java/org/apache/polaris/service/http/IfNoneMatchTest.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.polaris.service.http;
+
+import java.util.List;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class IfNoneMatchTest {
+
+  @Test
+  public void validSingleETag() {
+    String header = "W/\"value\"";
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader(header);
+
+    String parsedETag = ifNoneMatch.eTags().getFirst();
+
+    Assertions.assertEquals(header, parsedETag);
+  }
+
+  @Test
+  public void validMultipleETags() {
+    String etagValue1 = "W/\"etag1\"";
+    String etagValue2 = "W/\"etag2,with,comma\"";
+    String etagValue3 = "W/\"etag3\"";
+
+    String header = etagValue1 + ", " + etagValue2 + ", " + etagValue3;
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader(header);
+
+    Assertions.assertEquals(3, ifNoneMatch.eTags().size());
+
+    String etag1 = ifNoneMatch.eTags().get(0);
+    String etag2 = ifNoneMatch.eTags().get(1);
+    String etag3 = ifNoneMatch.eTags().get(2);
+
+    Assertions.assertEquals(etagValue1, etag1);
+    Assertions.assertEquals(etagValue2, etag2);
+    Assertions.assertEquals(etagValue3, etag3);
+  }
+
+  @Test
+  public void validWildcardIfNoneMatch() {
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader("*");
+    Assertions.assertTrue(ifNoneMatch.isWildcard());
+    Assertions.assertTrue(ifNoneMatch.eTags().isEmpty());
+  }
+
+  @Test
+  public void nullIfNoneMatchIsValidAndMapsToEmptyETags() {
+    // this test is important because we may not know what the representation 
of
+    // the header will be if it is not provided. If it is null, then we should 
default
+    // to an empty list of etags, as that has no footprint for logical 
decisions to be made
+    IfNoneMatch nullIfNoneMatch = IfNoneMatch.fromHeader(null);
+    Assertions.assertTrue(nullIfNoneMatch.eTags().isEmpty());
+  }
+
+  @Test
+  public void invalidETagThrowsException() {
+    String header = "wrong_value";
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
IfNoneMatch.fromHeader(header));
+  }
+
+  @Test
+  public void etagsMatch() {
+    String weakETag = "W/\"weak\"";
+    String strongETag = "\"strong\"";
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader("W/\"weak\", \"strong\"");
+    Assertions.assertTrue(ifNoneMatch.anyMatch(weakETag));
+    Assertions.assertTrue(ifNoneMatch.anyMatch(strongETag));
+  }
+
+  @Test
+  public void weakETagOnlyMatchesWeak() {
+    String weakETag = "W/\"etag\"";
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader("\"etag\"");
+    Assertions.assertFalse(ifNoneMatch.anyMatch(weakETag));
+  }
+
+  @Test
+  public void strongETagOnlyMatchesStrong() {
+    String strongETag = "\"etag\"";
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader("W/\"etag\"");
+    Assertions.assertFalse(ifNoneMatch.anyMatch(strongETag));
+  }
+
+  @Test
+  public void wildCardMatchesEverything() {
+    String strongETag = "\"etag\"";
+    String weakETag = "W/\"etag\"";
+    IfNoneMatch ifNoneMatch = IfNoneMatch.fromHeader("*");
+    Assertions.assertTrue(ifNoneMatch.anyMatch(strongETag));
+    Assertions.assertTrue(ifNoneMatch.anyMatch(weakETag));
+
+    IfNoneMatch canonicallyBuiltWildcard = IfNoneMatch.WILDCARD;
+    Assertions.assertTrue(canonicallyBuiltWildcard.anyMatch(strongETag));
+    Assertions.assertTrue(canonicallyBuiltWildcard.anyMatch(weakETag));
+  }
+
+  @Test
+  public void cantConstructHeaderWithWildcardAndNonEmptyETag() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new IfNoneMatch(true, 
List.of("\"etag\"")));
+  }
+
+  @Test
+  public void cantConstructHeaderWithOneValidAndOneInvalidPart() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
IfNoneMatch.fromHeader("W/\"etag\", W/invalid-etag"));
+  }
+
+  @Test
+  public void invalidHeaderWithOnlyWhitespacesBetween() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
IfNoneMatch.fromHeader("W/\"etag\" \"valid-etag\""));
+  }
+}


Reply via email to