Repository: apache/iceberg (language: python)

Issue:
Use `HEAD` operation for `tableExists` in REST-Catalog
### Feature Request / Improvement

It looks like we just load the table and fail if it raises an exception:

https://github.com/apache/iceberg/blob/f2d62757e210df5498f147aa126926477ac6c156/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java#L180-L187

Instead we would love to override this for the REST-Catalog, and use the `HEAD` call instead:

https://github.com/apache/iceberg/blob/97e034b2cec9408a6f792c410a8eb8dddb452e14/open-api/rest-catalog-open-api.yaml#L833-L864

This will be more friendly on the catalog since it doesn't have to load the metadata, and will also return a faster response. We also use this when creating a view to double-check if there is not a table with the same name: https://github.com/apache/iceberg/blob/f2d62757e210df5498f147aa126926477ac6c156/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java#L1303

The HEAD operation will be much faster than trying to load the table.

### Query engine

None

### Willingness to contribute

- [ ] I can contribute this improvement/feature independently
- [ ] I would be willing to contribute this improvement/feature with guidance from the Iceberg community
- [ ] I cannot contribute this improvement/feature at this time


Discussion / hints:

Fix patch:
diff --git a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
index 76fafe48f5b6..563853e3f033 100644
--- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
@@ -311,6 +311,13 @@ public static void purgeTable(Catalog catalog, TableIdentifier ident) {
     }
   }
 
+  public static void tableExists(Catalog catalog, TableIdentifier ident) {
+    boolean exists = catalog.tableExists(ident);
+    if (!exists) {
+      throw new NoSuchTableException("Table does not exist: %s", ident);
+    }
+  }
+
   public static LoadTableResponse loadTable(Catalog catalog, TableIdentifier ident) {
     Table table = catalog.loadTable(ident);
 
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index cc42604f700d..a96dd5f764ac 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -432,6 +432,18 @@ public void renameTable(SessionContext context, TableIdentifier from, TableIdent
     client.post(paths.rename(), request, null, headers(context), ErrorHandlers.tableErrorHandler());
   }
 
+  @Override
+  public boolean tableExists(SessionContext context, TableIdentifier identifier) {
+    checkIdentifierIsValid(identifier);
+
+    try {
+      client.head(paths.table(identifier), headers(context), ErrorHandlers.tableErrorHandler());
+      return true;
+    } catch (NoSuchTableException e) {
+      return false;
+    }
+  }
+
   private LoadTableResponse loadInternal(
       SessionContext context, TableIdentifier identifier, SnapshotMode mode) {
     Endpoint.check(endpoints, Endpoint.V1_LOAD_TABLE);


Test patch:
diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
index 6477dfcd00eb..1e1da49caee3 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -137,6 +137,7 @@ enum Route {
         ResourcePaths.V1_TABLES,
         CreateTableRequest.class,
         LoadTableResponse.class),
+    TABLE_EXISTS(HTTPMethod.HEAD, ResourcePaths.V1_TABLE),
     LOAD_TABLE(HTTPMethod.GET, ResourcePaths.V1_TABLE, null, LoadTableResponse.class),
     REGISTER_TABLE(
         HTTPMethod.POST,
@@ -388,6 +389,13 @@ public <T extends RESTResponse> T handleRequest(
           return null;
         }
 
+      case TABLE_EXISTS:
+        {
+          TableIdentifier ident = tableIdentFromPathVars(vars);
+          CatalogHandlers.tableExists(catalog, ident);
+          return null;
+        }
+
       case LOAD_TABLE:
         {
           TableIdentifier ident = tableIdentFromPathVars(vars);
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
index 06008761eac1..fb2b9410a21c 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
@@ -340,11 +340,11 @@ public void testCatalogBasicBearerToken() {
             any());
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(catalogHeaders),
             any());
   }
@@ -387,11 +387,11 @@ public void testCatalogCredentialNoOauth2ServerUri() {
     // use the catalog token for all interactions
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(catalogHeaders),
             any());
   }
@@ -442,11 +442,11 @@ public void testCatalogCredential(String oauth2ServerUri) {
     // use the catalog token for all interactions
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(catalogHeaders),
             any());
   }
@@ -500,14 +500,14 @@ public void testCatalogBearerTokenWithClientCredential(String oauth2ServerUri) {
             eq(OAuthTokenResponse.class),
             eq(catalogHeaders),
             any());
-    // use the context token for table load
+    // use the context token for table existence check
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(contextHeaders),
             any());
   }
@@ -573,14 +573,14 @@ public void testCatalogCredentialWithClientCredential(String oauth2ServerUri) {
             eq(OAuthTokenResponse.class),
             eq(catalogHeaders),
             any());
-    // use the context token for table load
+    // use the context token for table existence check
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(contextHeaders),
             any());
   }
@@ -648,14 +648,14 @@ public void testCatalogBearerTokenAndCredentialWithClientCredential(String oauth
             eq(OAuthTokenResponse.class),
             eq(catalogHeaders),
             any());
-    // use the context token for table load
+    // use the context token for table existence check
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(contextHeaders),
             any());
   }
@@ -839,11 +839,11 @@ private void testClientAuth(
     }
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(expectedHeaders),
             any());
     if (!optionalOAuthParams.isEmpty()) {
@@ -1606,18 +1606,18 @@ public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) {
                       eq(catalogHeaders),
                       any());
 
-              // use the refreshed context token for table load
+              // use the refreshed context token for table existence check
               Map<String, String> refreshedCatalogHeader =
                   ImmutableMap.of(
                       "Authorization",
                       "Bearer token-exchange-token:sub=client-credentials-token:sub=catalog");
               Mockito.verify(adapter)
                   .execute(
-                      eq(HTTPMethod.GET),
+                      eq(HTTPMethod.HEAD),
                       eq("v1/namespaces/ns/tables/table"),
                       any(),
                       any(),
-                      eq(LoadTableResponse.class),
+                      any(),
                       eq(refreshedCatalogHeader),
                       any());
             });
@@ -1784,11 +1784,11 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2
 
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(ImmutableMap.of("Authorization", "Bearer token-exchange-token:sub=" + token)),
             any());
   }
@@ -1826,11 +1826,11 @@ public void testCatalogValidBearerTokenIsNotRefreshed() {
 
     Mockito.verify(adapter)
         .execute(
-            eq(HTTPMethod.GET),
+            eq(HTTPMethod.HEAD),
             eq("v1/namespaces/ns/tables/table"),
             any(),
             any(),
-            eq(LoadTableResponse.class),
+            any(),
             eq(OAuth2Util.authHeaders(token)),
             any());
   }
@@ -1961,18 +1961,18 @@ public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh(String oauth
                       eq(basicHeaders),
                       any());
 
-              // use the refreshed context token for table load
+              // use the refreshed context token for table existence check
               Map<String, String> refreshedCatalogHeader =
                   ImmutableMap.of(
                       "Authorization",
                       "Bearer token-exchange-token:sub=client-credentials-token:sub=catalog");
               Mockito.verify(adapter)
                   .execute(
-                      eq(HTTPMethod.GET),
+                      eq(HTTPMethod.HEAD),
                       eq("v1/namespaces/ns/tables/table"),
                       any(),
                       any(),
-                      eq(LoadTableResponse.class),
+                      any(),
                       eq(refreshedCatalogHeader),
                       any());
             });