You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by di...@apache.org on 2026/05/20 02:27:32 UTC

(polaris) branch main updated: Make location overlap checks more robust (#4408)

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

dimas-b 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 4cc8663900 Make location overlap checks more robust (#4408)
4cc8663900 is described below

commit 4cc8663900026192adddb76fd16255c761339e71
Author: Dmitri Bourlatchkov <dm...@gmail.com>
AuthorDate: Tue May 19 22:27:27 2026 -0400

    Make location overlap checks more robust (#4408)
    
    Feed sibling paths to the `Resolver` as optional and ignore non-found cases.
    
    Note: it is normal for an entity to be deleted during the sibling resolution process. Ignoring unresolved entities is to compensate for data changes between the "list" operation and the "resolveAll" operation.
    
    Throw a `CommitConflictException` if coherent data about all siblings cannot be resolved.
    
    Subsequently, sibling resolution failures lead to 409 (Conflict) errors at the REST API level instead of 500 (Server Failure) now.
    
    Fixes #4407
---
 .../polaris/core/entity/PolarisEntityUtils.java    |  19 +++
 .../core/exceptions/CommitConflictException.java   |   4 +
 .../core/entity/PolarisEntityUtilsTest.java        |  22 ++++
 .../CatalogGenericTableEventServiceDelegator.java  |  16 +++
 .../service/catalog/iceberg/IcebergCatalog.java    | 128 +++++++++++--------
 .../admin/PolarisS3InteroperabilityTest.java       |   2 +-
 .../catalog/iceberg/IcebergCatalogTest.java        | 142 +++++++++++++++++++++
 .../iceberg/IcebergOverlappingTableTest.java       |  32 +++++
 .../org/apache/polaris/service/TestServices.java   |  44 +++++++
 9 files changed, 352 insertions(+), 57 deletions(-)

diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityUtils.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityUtils.java
index f8c7c5440c..e9a2132c5d 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityUtils.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityUtils.java
@@ -25,8 +25,11 @@ import com.google.common.collect.Iterables;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.Optional;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.rest.RESTUtil;
+import org.apache.polaris.core.entity.table.GenericTableEntity;
+import org.apache.polaris.core.entity.table.IcebergTableLikeEntity;
 
 /**
  * Polaris-owned encoding/decoding for namespaces stored in entity internal properties.
@@ -75,4 +78,20 @@ public final class PolarisEntityUtils {
     }
     return Namespace.of(levels);
   }
+
+  public static Optional<LocationBasedEntity> asLocationBasedEntity(PolarisEntity entity) {
+    return Optional.ofNullable(
+        switch (entity.getType()) {
+          case CATALOG -> CatalogEntity.of(entity);
+          case NAMESPACE -> NamespaceEntity.of(entity);
+          case TABLE_LIKE ->
+              switch (entity.getSubType()) {
+                case GENERIC_TABLE -> GenericTableEntity.of(entity);
+                case ICEBERG_TABLE, ICEBERG_VIEW -> IcebergTableLikeEntity.of(entity);
+                default -> null;
+              };
+
+          default -> null;
+        });
+  }
 }
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/exceptions/CommitConflictException.java b/polaris-core/src/main/java/org/apache/polaris/core/exceptions/CommitConflictException.java
index 75a61204e2..a1e11cd003 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/exceptions/CommitConflictException.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/exceptions/CommitConflictException.java
@@ -22,6 +22,10 @@ package org.apache.polaris.core.exceptions;
 import com.google.errorprone.annotations.FormatMethod;
 
 public class CommitConflictException extends PolarisException {
+  public CommitConflictException(String message) {
+    super(message);
+  }
+
   @FormatMethod
   public CommitConflictException(String message, Object... args) {
     super(String.format(message, args));
diff --git a/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisEntityUtilsTest.java b/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisEntityUtilsTest.java
index ccbf824db9..21400389f2 100644
--- a/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisEntityUtilsTest.java
+++ b/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisEntityUtilsTest.java
@@ -21,6 +21,7 @@ package org.apache.polaris.core.entity;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
+import java.util.Map;
 import java.util.stream.Stream;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.rest.RESTUtil;
@@ -118,4 +119,25 @@ public class PolarisEntityUtilsTest {
     assertThat(PolarisEntityUtils.decodeNamespace(PolarisEntityUtils.encodeNamespace(ns)))
         .isEqualTo(ns);
   }
+
+  public static Stream<Arguments> asLocationBasedEntityCases() {
+    return Stream.of(
+        Arguments.of(PolarisEntityType.FILE, PolarisEntitySubType.ANY_SUBTYPE, true),
+        Arguments.of(PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE, true),
+        Arguments.of(PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.NULL_SUBTYPE, true),
+        Arguments.of(PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.GENERIC_TABLE, false),
+        Arguments.of(PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ICEBERG_TABLE, false),
+        Arguments.of(PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ICEBERG_VIEW, false),
+        Arguments.of(PolarisEntityType.CATALOG, PolarisEntitySubType.NULL_SUBTYPE, false),
+        Arguments.of(PolarisEntityType.NAMESPACE, PolarisEntitySubType.NULL_SUBTYPE, false));
+  }
+
+  @ParameterizedTest
+  @MethodSource("asLocationBasedEntityCases")
+  public void testAsLocationBasedEntity(
+      PolarisEntityType type, PolarisEntitySubType subType, boolean empty) {
+    PolarisEntity sample =
+        new PolarisEntity(0, type, subType, 1, 2, "name", 0, 0, 0, 0, Map.of(), Map.of(), 0, 1);
+    assertThat(PolarisEntityUtils.asLocationBasedEntity(sample).isEmpty()).isEqualTo(empty);
+  }
 }
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/CatalogGenericTableEventServiceDelegator.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/CatalogGenericTableEventServiceDelegator.java
index 3d7701551f..79916d3935 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/CatalogGenericTableEventServiceDelegator.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/CatalogGenericTableEventServiceDelegator.java
@@ -19,6 +19,7 @@
 
 package org.apache.polaris.service.catalog.generic;
 
+import com.google.common.annotations.VisibleForTesting;
 import jakarta.annotation.Priority;
 import jakarta.decorator.Decorator;
 import jakarta.decorator.Delegate;
@@ -48,6 +49,21 @@ public class CatalogGenericTableEventServiceDelegator
   @Inject PolarisEventMetadataFactory eventMetadataFactory;
   @Inject CatalogPrefixParser prefixParser;
 
+  // Default constructor for CDI
+  public CatalogGenericTableEventServiceDelegator() {}
+
+  @VisibleForTesting
+  public CatalogGenericTableEventServiceDelegator(
+      GenericTableCatalogAdapter delegate,
+      PolarisEventDispatcher polarisEventDispatcher,
+      PolarisEventMetadataFactory eventMetadataFactory,
+      CatalogPrefixParser prefixParser) {
+    this.delegate = delegate;
+    this.polarisEventDispatcher = polarisEventDispatcher;
+    this.eventMetadataFactory = eventMetadataFactory;
+    this.prefixParser = prefixParser;
+  }
+
   @Override
   public Response createGenericTable(
       String prefix,
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
index ae6b23e98a..e7e6009c40 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
@@ -37,6 +37,7 @@ import java.io.Closeable;
 import java.io.IOException;
 import java.net.URLEncoder;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -51,7 +52,6 @@ import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
@@ -109,8 +109,8 @@ import org.apache.polaris.core.entity.PolarisEntity;
 import org.apache.polaris.core.entity.PolarisEntityConstants;
 import org.apache.polaris.core.entity.PolarisEntitySubType;
 import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.entity.PolarisEntityUtils;
 import org.apache.polaris.core.entity.PolarisTaskConstants;
-import org.apache.polaris.core.entity.table.GenericTableEntity;
 import org.apache.polaris.core.entity.table.IcebergTableLikeEntity;
 import org.apache.polaris.core.exceptions.CommitConflictException;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
@@ -1242,70 +1242,86 @@ public class IcebergCatalog extends BaseMetastoreViewCatalog
                   return Namespace.of(newLevels);
                 })
             .toList();
-    LOGGER.debug(
-        "Resolving {} sibling entities to validate location",
-        siblingTables.size() + siblingNamespaces.size());
-    PolarisResolutionManifest resolutionManifest =
-        new PolarisResolutionManifest(
-            diagnostics,
-            callContext.getRealmContext(),
-            resolverFactory,
-            principal,
-            parentPath.getFirst().getName());
+    List<ResolvedPathKey> pathsToResolve =
+        new ArrayList<>(siblingTables.size() + siblingNamespaces.size());
     siblingTables.forEach(
-        tbl ->
-            resolutionManifest.addPath(
-                new ResolverPath(
-                    PolarisCatalogHelpers.tableIdentifierToList(tbl),
-                    PolarisEntityType.TABLE_LIKE)));
+        tbl -> {
+          if (!tbl.name().equals(name)) {
+            pathsToResolve.add(ResolvedPathKey.ofTableLike(tbl));
+          }
+        });
     siblingNamespaces.forEach(
-        ns ->
-            resolutionManifest.addPath(
-                new ResolverPath(Arrays.asList(ns.levels()), PolarisEntityType.NAMESPACE)));
+        ns -> {
+          if (!ns.level(ns.length() - 1).equals(name)) {
+            pathsToResolve.add(ResolvedPathKey.ofNamespace(ns));
+          }
+        });
+
+    StorageLocation targetLocation = StorageLocation.of(location);
+    for (PolarisEntity entityToCheck :
+        resolveOptionalPaths(pathsToResolve, parentPath.getFirst().getName())) {
+      PolarisEntityUtils.asLocationBasedEntity(entityToCheck)
+          .map(LocationBasedEntity::getBaseLocation)
+          .map(StorageLocation::of)
+          .ifPresent(
+              siblingLocation -> {
+                if (targetLocation.isChildOf(siblingLocation)
+                    || siblingLocation.isChildOf(targetLocation)) {
+                  throw new ForbiddenException(
+                      "Unable to create entity at location '%s' because it conflicts with existing table or namespace at "
+                          + "location '%s'",
+                      targetLocation, siblingLocation);
+                }
+              });
+    }
+  }
+
+  @VisibleForTesting
+  List<PolarisEntity> resolveOptionalPaths(List<ResolvedPathKey> keys, String catalogName) {
+    LOGGER.debug("Resolving {} sibling entities to validate location", keys.size());
+
+    PolarisResolutionManifest resolutionManifest =
+        new PolarisResolutionManifest(
+            diagnostics, callContext.getRealmContext(), resolverFactory, principal, catalogName);
+
+    keys.forEach(
+        k -> {
+          resolutionManifest.addPath(new ResolverPath(k, true)); // optional path
+        });
+
     ResolverStatus status = resolutionManifest.resolveAll();
-    if (!status.getStatus().equals(ResolverStatus.StatusEnum.SUCCESS)) {
+
+    if (status.getStatus() != ResolverStatus.StatusEnum.SUCCESS) {
       String message =
           "Unable to resolve sibling entities to validate location - " + status.getStatus();
       if (status.getStatus().equals(ResolverStatus.StatusEnum.ENTITY_COULD_NOT_BE_RESOLVED)) {
         message += ". Could not resolve entity: " + status.getFailedToResolvedEntityName();
+      } else if (status
+          .getStatus()
+          .equals(ResolverStatus.StatusEnum.PATH_COULD_NOT_BE_FULLY_RESOLVED)) {
+        ResolverPath path = status.getFailedToResolvePath();
+        if (path != null) {
+          message += ". path: " + String.join(".", path.entityNames());
+          message += ", failed index: " + status.getFailedToResolvedEntityIndex();
+        }
       }
-      throw new IllegalStateException(message);
+
+      throw new CommitConflictException(message);
     }
 
-    StorageLocation targetLocation = StorageLocation.of(location);
-    Stream.concat(
-            siblingTables.stream()
-                .filter(tbl -> !tbl.name().equals(name))
-                .map(
-                    tbl -> {
-                      PolarisResolvedPathWrapper resolveTablePath =
-                          resolutionManifest.getResolvedPath(ResolvedPathKey.ofTableLike(tbl));
-                      PolarisEntity tableEntity = resolveTablePath.getRawLeafEntity();
-                      if (tableEntity.getSubType() == PolarisEntitySubType.GENERIC_TABLE) {
-                        return GenericTableEntity.of(tableEntity).getBaseLocation();
-                      }
-                      return IcebergTableLikeEntity.of(tableEntity).getBaseLocation();
-                    }),
-            siblingNamespaces.stream()
-                .filter(ns -> !ns.level(ns.length() - 1).equals(name))
-                .map(
-                    ns -> {
-                      PolarisResolvedPathWrapper resolveNamespacePath =
-                          resolutionManifest.getResolvedPath(ResolvedPathKey.ofNamespace(ns));
-                      return NamespaceEntity.of(resolveNamespacePath.getRawLeafEntity())
-                          .getBaseLocation();
-                    }))
-        .filter(java.util.Objects::nonNull)
-        .map(StorageLocation::of)
-        .forEach(
-            siblingLocation -> {
-              if (targetLocation.isChildOf(siblingLocation)
-                  || siblingLocation.isChildOf(targetLocation)) {
-                throw new org.apache.iceberg.exceptions.ForbiddenException(
-                    "Unable to create table at location '%s' because it conflicts with existing table or namespace at location '%s'",
-                    targetLocation, siblingLocation);
-              }
-            });
+    List<PolarisEntity> result = new ArrayList<>(keys.size());
+    keys.forEach(
+        k -> {
+          PolarisResolvedPathWrapper path = resolutionManifest.getResolvedPath(k);
+          if (path != null) {
+            PolarisEntity entity = path.getRawLeafEntity();
+            if (entity != null) {
+              result.add(entity);
+            }
+          }
+        });
+
+    return result;
   }
 
   private class PolarisIcebergCatalogTableBuilder
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisS3InteroperabilityTest.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisS3InteroperabilityTest.java
index d984234a68..60031bbc5d 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisS3InteroperabilityTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisS3InteroperabilityTest.java
@@ -249,7 +249,7 @@ public class PolarisS3InteroperabilityTest {
                     "tbl2",
                     table2Scheme,
                     makeTableLocation(catalogName, "ns1", "tbl1", table2Scheme)));
-    assertThat(ex.getMessage()).contains("Unable to create table at location");
+    assertThat(ex.getMessage()).contains("Unable to create entity at location");
     assertThat(ex.getMessage()).contains(table1Scheme);
     assertThat(ex.getMessage()).contains(table2Scheme);
   }
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogTest.java
new file mode 100644
index 0000000000..b57bc2b9e2
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.catalog.iceberg;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.polaris.core.PolarisDiagnostics;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.context.CallContext;
+import org.apache.polaris.core.entity.CatalogEntity;
+import org.apache.polaris.core.entity.PolarisEntity;
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.exceptions.CommitConflictException;
+import org.apache.polaris.core.persistence.ResolvedPolarisEntity;
+import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifestCatalogView;
+import org.apache.polaris.core.persistence.resolver.ResolvedPathKey;
+import org.apache.polaris.core.persistence.resolver.Resolver;
+import org.apache.polaris.core.persistence.resolver.ResolverFactory;
+import org.apache.polaris.core.persistence.resolver.ResolverPath;
+import org.apache.polaris.core.persistence.resolver.ResolverStatus;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class IcebergCatalogTest {
+
+  @Mock ResolverFactory resolverFactory;
+  @Mock CallContext callContext;
+  @Mock PolarisResolutionManifestCatalogView resolvedEntityView;
+  @Mock CatalogEntity catalogEntity;
+  @Mock PolarisDiagnostics diagnostics;
+  @Mock Resolver resolver;
+
+  @Mock PolarisEntity ns1;
+  @Mock PolarisEntity ns2;
+  @Mock PolarisEntity table3;
+
+  private final PolarisPrincipal principal = PolarisPrincipal.of("test", Map.of(), Set.of());
+
+  private IcebergCatalog catalog;
+  private ResolvedPolarisEntity rns1 = new ResolvedPolarisEntity(ns1, List.of(), List.of());
+  private ResolvedPolarisEntity rns2 = new ResolvedPolarisEntity(ns2, List.of(), List.of());
+  private ResolvedPolarisEntity rt3 = new ResolvedPolarisEntity(table3, List.of(), List.of());
+
+  @BeforeEach
+  void initMocks() {
+    when(resolvedEntityView.getResolvedCatalogEntity()).thenReturn(catalogEntity);
+    when(resolverFactory.createResolver(any(), any())).thenReturn(resolver);
+    when(resolver.resolveAll()).thenReturn(new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS));
+
+    rns1 = new ResolvedPolarisEntity(ns1, List.of(), List.of());
+    rns2 = new ResolvedPolarisEntity(ns2, List.of(), List.of());
+    rt3 = new ResolvedPolarisEntity(table3, List.of(), List.of());
+
+    catalog =
+        new IcebergCatalog(
+            diagnostics,
+            resolverFactory,
+            null,
+            callContext,
+            resolvedEntityView,
+            principal,
+            null,
+            null,
+            null,
+            null,
+            null);
+  }
+
+  @Test
+  void testFailedLocationSiblingResolutionException() {
+    when(resolver.resolveAll())
+        .thenReturn(new ResolverStatus(PolarisEntityType.TABLE_LIKE, "test-name"));
+    Assertions.assertThatThrownBy(() -> catalog.resolveOptionalPaths(List.of(), "test"))
+        .isInstanceOf(CommitConflictException.class)
+        .hasMessageContaining("Unable to resolve sibling entities to validate location")
+        .hasMessageContaining("test-name")
+        .hasMessageContaining(ResolverStatus.StatusEnum.ENTITY_COULD_NOT_BE_RESOLVED.name());
+
+    when(resolver.resolveAll())
+        .thenReturn(
+            new ResolverStatus(
+                new ResolverPath(
+                    List.of("ns1", "ns2", "table3"), PolarisEntityType.TABLE_LIKE, true),
+                123));
+    Assertions.assertThatThrownBy(() -> catalog.resolveOptionalPaths(List.of(), "test"))
+        .isInstanceOf(CommitConflictException.class)
+        .hasMessageContaining("Unable to resolve sibling entities to validate location")
+        .hasMessageContaining("ns1.ns2.table3")
+        .hasMessageContaining("failed index: 123")
+        .hasMessageContaining(ResolverStatus.StatusEnum.PATH_COULD_NOT_BE_FULLY_RESOLVED.name());
+  }
+
+  @Test
+  void testLocationSiblingResolution() {
+    ResolvedPathKey k1 = ResolvedPathKey.ofTableLike(TableIdentifier.of("ns1", "ns2", "t3"));
+    ResolvedPathKey k2 = ResolvedPathKey.ofTableLike(TableIdentifier.of("missing"));
+    ResolvedPathKey k3 = ResolvedPathKey.ofNamespace(Namespace.of("ns1", "ns2"));
+    ResolvedPathKey k4 = ResolvedPathKey.ofNamespace(Namespace.of("ns1", "ns2", "partial"));
+
+    List<List<ResolvedPolarisEntity>> resolved = new ArrayList<>();
+    resolved.add(List.of(rns1, rns2, rt3));
+    resolved.add(List.of());
+    resolved.add(List.of(rns1, rns2));
+    resolved.add(List.of(rns1, rns2));
+
+    when(resolver.getResolvedPaths()).thenReturn(resolved);
+    List<PolarisEntity> entities = catalog.resolveOptionalPaths(List.of(k1, k2, k3, k4), "test");
+    assertThat(entities).hasSize(2);
+    assertThat(entities.get(0)).isSameAs(table3);
+    assertThat(entities.get(1)).isSameAs(ns2);
+  }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergOverlappingTableTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergOverlappingTableTest.java
index 12d613f2b3..da1792b4d0 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergOverlappingTableTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergOverlappingTableTest.java
@@ -45,6 +45,7 @@ import org.apache.polaris.core.admin.model.FileStorageConfigInfo;
 import org.apache.polaris.core.admin.model.StorageConfigInfo;
 import org.apache.polaris.service.TestServices;
 import org.apache.polaris.service.catalog.common.LocationUtils;
+import org.apache.polaris.service.types.CreateGenericTableRequest;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -86,6 +87,28 @@ public class IcebergOverlappingTableTest {
     }
   }
 
+  private int createGenericTable(TestServices services, String location) {
+    CreateGenericTableRequest createTableRequest =
+        CreateGenericTableRequest.builder()
+            .setName(getTableName())
+            .setBaseLocation(location)
+            .build();
+    try (Response response =
+        services
+            .genericTableApi()
+            .createGenericTable(
+                catalog,
+                namespace,
+                createTableRequest,
+                null,
+                services.realmContext(),
+                services.securityContext())) {
+      return response.getStatus();
+    } catch (ForbiddenException e) {
+      return Response.Status.FORBIDDEN.getStatusCode();
+    }
+  }
+
   /** Attempt to stage-create a table at a given location, and return the response code. */
   private int createTableStaged(TestServices services, String location) {
     CreateTableRequest createTableRequest =
@@ -246,6 +269,11 @@ public class IcebergOverlappingTableTest {
                 services, String.format("%s/%s/%s/table_1", baseLocation, catalog, namespace)))
         .isEqualTo(Response.Status.OK.getStatusCode());
 
+    assertThat(
+            createGenericTable(
+                services, String.format("%s/%s/%s/generic_1", baseLocation, catalog, namespace)))
+        .isEqualTo(Response.Status.OK.getStatusCode());
+
     // Unrelated path
     assertThat(
             createTable(
@@ -263,6 +291,10 @@ public class IcebergOverlappingTableTest {
             createTable(
                 services, String.format("%s/%s/%s/table_100", baseLocation, catalog, namespace)))
         .isEqualTo(expectedStatusForOverlaps);
+    assertThat(
+            createTable(
+                services, String.format("%s/%s/%s/generic_1", baseLocation, catalog, namespace)))
+        .isEqualTo(expectedStatusForOverlaps);
 
     // Parent of existing location
     assertThat(createTable(services, String.format("%s/%s/%s", baseLocation, catalog, namespace)))
diff --git a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
index 3cac9befee..23fbadbf16 100644
--- a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
+++ b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
@@ -72,6 +72,13 @@ import org.apache.polaris.service.catalog.api.IcebergRestCatalogApi;
 import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService;
 import org.apache.polaris.service.catalog.api.IcebergRestConfigurationApi;
 import org.apache.polaris.service.catalog.api.IcebergRestConfigurationApiService;
+import org.apache.polaris.service.catalog.api.PolarisCatalogGenericTableApi;
+import org.apache.polaris.service.catalog.api.PolarisCatalogGenericTableApiService;
+import org.apache.polaris.service.catalog.generic.CatalogGenericTableEventServiceDelegator;
+import org.apache.polaris.service.catalog.generic.GenericTableCatalogAdapter;
+import org.apache.polaris.service.catalog.generic.GenericTableCatalogHandler;
+import org.apache.polaris.service.catalog.generic.GenericTableCatalogHandlerFactory;
+import org.apache.polaris.service.catalog.generic.ImmutableGenericTableCatalogHandler;
 import org.apache.polaris.service.catalog.iceberg.CatalogHandlerUtils;
 import org.apache.polaris.service.catalog.iceberg.IcebergCatalogAdapter;
 import org.apache.polaris.service.catalog.iceberg.IcebergCatalogHandler;
@@ -107,6 +114,7 @@ public record TestServices(
     Clock clock,
     PolarisCatalogsApi catalogsApi,
     IcebergRestCatalogApi restApi,
+    PolarisCatalogGenericTableApi genericTableApi,
     IcebergRestConfigurationApi restConfigurationApi,
     IcebergCatalogAdapter catalogAdapter,
     RealmConfigurationSource configurationSource,
@@ -379,6 +387,41 @@ public record TestServices(
       IcebergRestConfigurationApi restConfigurationApi =
           new IcebergRestConfigurationApi(finalRestConfigurationService);
 
+      GenericTableCatalogHandlerFactory genericHandlerFactory =
+          new GenericTableCatalogHandlerFactory() {
+            @Override
+            public GenericTableCatalogHandler createHandler(
+                String catalogName, PolarisPrincipal principal) {
+              return ImmutableGenericTableCatalogHandler.builder()
+                  .catalogName(catalogName)
+                  .polarisPrincipal(principal)
+                  .callContext(callContext)
+                  .resolutionManifestFactory(resolutionManifestFactory)
+                  .metaStoreManager(metaStoreManager)
+                  .authorizer(authorizer)
+                  .credentialManager(credentialManager)
+                  .federatedCatalogFactories(federatedCatalogFactory)
+                  .build();
+            }
+          };
+      GenericTableCatalogAdapter genericTableCatalogAdapter =
+          new GenericTableCatalogAdapter(
+              callContext,
+              new DefaultCatalogPrefixParser(),
+              reservedProperties,
+              genericHandlerFactory);
+      PolarisCatalogGenericTableApiService genericTableService = genericTableCatalogAdapter;
+      if (useEventDelegator) {
+        genericTableService =
+            new CatalogGenericTableEventServiceDelegator(
+                genericTableCatalogAdapter,
+                polarisEventDispatcher,
+                eventMetadataFactory,
+                new DefaultCatalogPrefixParser());
+      }
+      PolarisCatalogGenericTableApi genericTableApi =
+          new PolarisCatalogGenericTableApi(genericTableService);
+
       PolarisAdminService adminService =
           new PolarisAdminService(
               callContext,
@@ -398,6 +441,7 @@ public record TestServices(
           clock,
           catalogsApi,
           restApi,
+          genericTableApi,
           restConfigurationApi,
           catalogService,
           configurationSource,