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 3fea897c0 Fix NPE in listCatalogs (#1949) 3fea897c0 is described below commit 3fea897c04b51da29eea1a2553822bfd0623b644 Author: Andrew Guterman <andrew.guterm...@gmail.com> AuthorDate: Thu Jun 26 14:23:38 2025 -0700 Fix NPE in listCatalogs (#1949) listCatalogs is non-atomic. It first atomically lists all entities and then iterates through each one and does an individual loadEntity call. This causes an NPE when calling `CatalogEntity::new`. I don't think it's ever useful for listCatalogsUnsafe to return null since the caller isn't expecting a certain length of elements, so I just filtered it there. --- .../quarkus/admin/ManagementServiceTest.java | 72 ++++++++++++++++++++++ .../polaris/service/admin/PolarisAdminService.java | 16 +++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java index 47128c86d..3cb8216d8 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java @@ -21,11 +21,13 @@ package org.apache.polaris.service.quarkus.admin; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import jakarta.annotation.Nonnull; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.SecurityContext; import java.security.Principal; import java.time.Clock; import java.time.Instant; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -43,11 +45,19 @@ import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.context.CallContext; import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.PolarisBaseEntity; +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.PrincipalEntity; import org.apache.polaris.core.entity.PrincipalRoleEntity; import org.apache.polaris.core.persistence.MetaStoreManagerFactory; import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.CreateCatalogResult; import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.core.persistence.transactional.TransactionalMetaStoreManagerImpl; import org.apache.polaris.core.secrets.UnsafeInMemorySecretsManager; import org.apache.polaris.service.TestServices; import org.apache.polaris.service.admin.PolarisAdminService; @@ -276,4 +286,66 @@ public class ManagementServiceTest { () -> polarisAdminService.assignPrincipalRole(principal.getName(), role.getName())) .isInstanceOf(ValidationException.class); } + + /** Simulates the case when a catalog is dropped after being found while listing all catalogs. */ + @Test + public void testCatalogNotReturnedWhenDeletedAfterListBeforeGet() { + TestPolarisMetaStoreManager metaStoreManager = new TestPolarisMetaStoreManager(); + PolarisCallContext callContext = setupCallContext(metaStoreManager); + PolarisAdminService polarisAdminService = + setupPolarisAdminService(metaStoreManager, callContext); + + CreateCatalogResult catalog1 = + metaStoreManager.createCatalog( + callContext, + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + metaStoreManager.generateNewEntityId(callContext).getId(), + PolarisEntityType.CATALOG, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + "my-catalog-1"), + List.of()); + CreateCatalogResult catalog2 = + metaStoreManager.createCatalog( + callContext, + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + metaStoreManager.generateNewEntityId(callContext).getId(), + PolarisEntityType.CATALOG, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + "my-catalog-2"), + List.of()); + + metaStoreManager.setFakeEntityNotFoundIds(Set.of(catalog1.getCatalog().getId())); + List<PolarisEntity> catalogs = polarisAdminService.listCatalogs(); + assertThat(catalogs.size()).isEqualTo(1); + assertThat(catalogs.getFirst().getId()).isEqualTo(catalog2.getCatalog().getId()); + } + + /** + * Intended to be a delegate to TransactionalMetaStoreManagerImpl with the ability to inject + * faults. Currently, you can force loadEntity() to return ENTITY_NOT_FOUND for a set of entity + * IDs. + */ + public static class TestPolarisMetaStoreManager extends TransactionalMetaStoreManagerImpl { + private Set<Long> fakeEntityNotFoundIds = new HashSet<>(); + + public void setFakeEntityNotFoundIds(Set<Long> ids) { + fakeEntityNotFoundIds = new HashSet<>(ids); + } + + @Override + public @Nonnull EntityResult loadEntity( + @Nonnull PolarisCallContext callCtx, + long entityCatalogId, + long entityId, + @Nonnull PolarisEntityType entityType) { + if (fakeEntityNotFoundIds.contains(entityId)) { + return new EntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, ""); + } + return super.loadEntity(callCtx, entityCatalogId, entityId, entityType); + } + } } diff --git a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java index 1a1914ad1..164d92d8c 100644 --- a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java +++ b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java @@ -614,7 +614,6 @@ public class PolarisAdminService { Set<String> newCatalogLocations = getCatalogLocations(catalogEntity); return listCatalogsUnsafe().stream() - .filter(Objects::nonNull) .map(CatalogEntity::new) .anyMatch( existingCatalog -> { @@ -923,18 +922,24 @@ public class PolarisAdminService { return returnedEntity; } + /** + * List all catalogs after checking for permission. Nulls due to non-atomic list-then-get are + * filtered out. + */ public List<PolarisEntity> listCatalogs() { authorizeBasicRootOperationOrThrow(PolarisAuthorizableOperation.LIST_CATALOGS); return listCatalogsUnsafe(); } /** - * List all catalogs without checking for permission. May contain NULLs due to multiple non-atomic - * API calls to the persistence layer. Specifically, this can happen when a PolarisEntity is - * returned by listCatalogs, but cannot be loaded afterward because it was purged by another - * process before it could be loaded. + * List all catalogs without checking for permission. Nulls due to non-atomic list-then-get are + * filtered out. */ private List<PolarisEntity> listCatalogsUnsafe() { + // loadEntity may return null due to multiple non-atomic + // API calls to the persistence layer. Specifically, this can happen when a PolarisEntity is + // returned by listCatalogs, but cannot be loaded afterward because it was purged by another + // process before it could be loaded. return metaStoreManager .listEntities( getCurrentPolarisContext(), @@ -949,6 +954,7 @@ public class PolarisAdminService { PolarisEntity.of( metaStoreManager.loadEntity( getCurrentPolarisContext(), 0, nameAndId.getId(), nameAndId.getType()))) + .filter(Objects::nonNull) .toList(); }