Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ public void deleteEntity(@NonNull PolarisCallContext callCtx, @NonNull PolarisBa
throw unimplemented();
}

@Override
public void deleteEntityAndCreateEntities(
@NonNull PolarisCallContext callCtx,
@NonNull PolarisBaseEntity entityToDelete,
@NonNull List<PolarisBaseEntity> entitiesToCreate) {
throw useMetaStoreManager("create/update/rename/delete");
}

@Override
public void deleteFromGrantRecords(
@NonNull PolarisCallContext callCtx, @NonNull PolarisGrantRecord grantRec) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,49 @@ public void deleteEntity(@NonNull PolarisCallContext callCtx, @NonNull PolarisBa
}
}

@Override
public void deleteEntityAndCreateEntities(
@NonNull PolarisCallContext callCtx,
@NonNull PolarisBaseEntity entityToDelete,
@NonNull List<PolarisBaseEntity> entitiesToCreate) {
ModelEntity modelEntity = ModelEntity.fromEntity(entityToDelete, schemaVersion);
Map<String, Object> params =
Map.of(
"id",
modelEntity.getId(),
"catalog_id",
modelEntity.getCatalogId(),
"realm_id",
realmId);
try {
datasourceOperations.runWithinTransaction(
connection -> {
datasourceOperations.execute(
connection,
QueryGenerator.generateDeleteQuery(
ModelEntity.getAllColumnNames(schemaVersion), ModelEntity.TABLE_NAME, params));
for (PolarisBaseEntity entityToCreate : entitiesToCreate) {
PolarisBaseEntity existingEntity =
lookupEntity(
connection,
entityToCreate.getCatalogId(),
entityToCreate.getId(),
entityToCreate.getTypeCode());
if (existingEntity != null) {
continue;
}
persistEntity(
callCtx, entityToCreate, null, connection, datasourceOperations::execute);
}
return true;
});
} catch (SQLException e) {
throw new RuntimeException(
String.format("Failed to delete entity and create entities due to %s", e.getMessage()),
e);
}
}

@Override
public void deleteFromGrantRecords(
@NonNull PolarisCallContext callCtx, @NonNull PolarisGrantRecord grantRec) {
Expand Down Expand Up @@ -421,11 +464,25 @@ public void deleteAll(@NonNull PolarisCallContext callCtx) {
@Override
public PolarisBaseEntity lookupEntity(
@NonNull PolarisCallContext callCtx, long catalogId, long entityId, int typeCode) {
return getPolarisBaseEntity(entityLookupQuery(catalogId, entityId, typeCode));
}

private PolarisBaseEntity lookupEntity(
@NonNull Connection connection, long catalogId, long entityId, int typeCode)
throws SQLException {
return getPolarisBaseEntity(
datasourceOperations.executeSelect(
connection,
entityLookupQuery(catalogId, entityId, typeCode),
new ModelEntity(schemaVersion)));
}

private QueryGenerator.PreparedQuery entityLookupQuery(
long catalogId, long entityId, int typeCode) {
Map<String, Object> params =
Map.of("catalog_id", catalogId, "id", entityId, "type_code", typeCode, "realm_id", realmId);
return getPolarisBaseEntity(
QueryGenerator.generateSelectQuery(
ModelEntity.getAllColumnNames(schemaVersion), ModelEntity.TABLE_NAME, params));
return QueryGenerator.generateSelectQuery(
ModelEntity.getAllColumnNames(schemaVersion), ModelEntity.TABLE_NAME, params);
}

@Override
Expand Down Expand Up @@ -455,23 +512,28 @@ public PolarisBaseEntity lookupEntityByName(
@Nullable
private PolarisBaseEntity getPolarisBaseEntity(QueryGenerator.PreparedQuery query) {
try {
var results = datasourceOperations.executeSelect(query, new ModelEntity(schemaVersion));
if (results.isEmpty()) {
return null;
} else if (results.size() > 1) {
throw new IllegalStateException(
String.format(
"More than one(%s) entities were found for a given type code : %s",
results.size(), results.getFirst().getTypeCode()));
} else {
return results.getFirst();
}
return getPolarisBaseEntity(
datasourceOperations.executeSelect(query, new ModelEntity(schemaVersion)));
} catch (SQLException e) {
throw new RuntimeException(
String.format("Failed to retrieve polaris entity due to %s", e.getMessage()), e);
}
}

@Nullable
private PolarisBaseEntity getPolarisBaseEntity(List<PolarisBaseEntity> results) {
if (results.isEmpty()) {
return null;
} else if (results.size() > 1) {
throw new IllegalStateException(
String.format(
"More than one(%s) entities were found for a given type code : %s",
results.size(), results.getFirst().getTypeCode()));
} else {
return results.getFirst();
}
}

@NonNull
@Override
public List<PolarisBaseEntity> lookupEntities(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@
public abstract class AtomicMetastoreManagerWithJdbcBasePersistenceImplTest
extends BasePolarisMetaStoreManagerTest {

@Test
void testCleanupTaskCreationFailureRollsBackEntityDrop() {
assertCleanupTaskCreationFailureRollsBackEntityDrop();
}

@Test
void testCleanupTaskCreationRetryIsIdempotent() {
assertCleanupTaskCreationRetryIsIdempotent();
}

protected DatabaseType databaseType() {
return DatabaseType.H2;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ private void dropEntity(
@NonNull PolarisCallContext callCtx,
@NonNull BasePersistence ms,
@NonNull PolarisBaseEntity entity) {
dropEntity(callCtx, ms, entity, List.of());
}

private void dropEntity(
@NonNull PolarisCallContext callCtx,
@NonNull BasePersistence ms,
@NonNull PolarisBaseEntity entity,
@NonNull List<PolarisBaseEntity> entitiesToCreateAtomically) {

// validate the entity type and subtype
getDiagnostics().checkNotNull(entity, "unexpected_null_dpo");
Expand All @@ -187,7 +195,11 @@ private void dropEntity(

// Remove the main entity itself first-thing; once its id no longer resolves successfully
// it will be pruned out of any grant-record lookups anyways.
ms.deleteEntity(callCtx, entity);
if (entitiesToCreateAtomically.isEmpty()) {
ms.deleteEntity(callCtx, entity);
} else {
ms.deleteEntityAndCreateEntities(callCtx, entity, entitiesToCreateAtomically);
}

// Best-effort cleanup - drop grant records, update grantRecordVersions for affected
// other entities.
Expand Down Expand Up @@ -1092,6 +1104,18 @@ public void deletePrincipalSecrets(

// if this entity was not found, return failure
if (refreshEntityToDrop == null) {
if (cleanup && entityToDrop.getType() != PolarisEntityType.POLICY) {
PolarisBaseEntity cleanupTask =
ms.lookupEntityByName(
callCtx,
PolarisEntityConstants.getNullId(),
PolarisEntityConstants.getNullId(),
PolarisEntityType.TASK.getCode(),
"entityCleanup_" + entityToDrop.getId());
if (cleanupTask != null) {
return new DropEntityResult(cleanupTask.getId());
}
}
return new DropEntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null);
}

Expand Down Expand Up @@ -1182,10 +1206,6 @@ public void deletePrincipalSecrets(
}
}

// simply delete that entity. Will be removed from entities_active, added to the
// entities_dropped and its version will be changed.
this.dropEntity(callCtx, ms, refreshEntityToDrop);

// if cleanup, schedule a cleanup task for the entity. do this here, so that drop and scheduling
// the cleanup task is transactional. Otherwise, we'll be unable to schedule the cleanup task
// later
Expand All @@ -1201,22 +1221,33 @@ public void deletePrincipalSecrets(
.propertiesAsMap(properties)
.id(ms.generateNewId(callCtx))
.catalogId(0L)
.name("entityCleanup_" + entityToDrop.getId())
.name("entityCleanup_" + refreshEntityToDrop.getId())
.typeCode(PolarisEntityType.TASK.getCode())
.subTypeCode(PolarisEntitySubType.NULL_SUBTYPE.getCode())
.createTimestamp(clock.millis());
if (cleanupProperties != null) {
taskEntityBuilder.internalPropertiesAsMap(cleanupProperties);
}
// TODO: Add a way to create the task entities atomically with dropping the entity;
// in the meantime, if the server fails partway through a dropEntity, it's possible that
// the entity is dropped but we don't have any persisted task records that will carry
// out the cleanup.
PolarisBaseEntity taskEntity = taskEntityBuilder.build();
createEntityIfNotExists(callCtx, null, taskEntity);
PolarisBaseEntity taskEntity =
prepareToPersistNewEntity(callCtx, ms, taskEntityBuilder.build());
try {
this.dropEntity(callCtx, ms, refreshEntityToDrop, List.of(taskEntity));
} catch (EntityAlreadyExistsException e) {
return new DropEntityResult(
BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS,
String.format(
"Existing entity id: '%s', type %s subtype %s",
e.getExistingEntity().getId(),
e.getExistingEntity().getTypeCode(),
e.getExistingEntity().getSubTypeCode()));
}
return new DropEntityResult(taskEntity.getId());
}

// simply delete that entity. Will be removed from entities_active, added to the
// entities_dropped and its version will be changed.
this.dropEntity(callCtx, ms, refreshEntityToDrop);

// done, return success
return new DropEntityResult();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ void writeToGrantRecords(
*/
void deleteEntity(@NonNull PolarisCallContext callCtx, @NonNull PolarisBaseEntity entity);

/**
* Delete one entity and create the supplied entities in one atomic persistence operation. If the
* operation succeeds, the deleted entity must be durably removed and every created entity must be
* durably visible; if it fails, none of those changes may be applied.
*
* <p>The created entities use the same create semantics as {@link
* #writeEntities(PolarisCallContext, List, List)} with {@code originalEntities == null}.
*
* @param callCtx call context
* @param entityToDelete entity to delete
* @param entitiesToCreate entities to create atomically with the delete
*/
void deleteEntityAndCreateEntities(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be related to the bigger multi-entity change discussion on dev: https://lists.apache.org/thread/vx0k8ow4k87m4y7cxpmojb0zy17t5ldy

I think we need to reach consensus on that thread "in general" before making code-level improvements,

@NonNull PolarisCallContext callCtx,
@NonNull PolarisBaseEntity entityToDelete,
@NonNull List<PolarisBaseEntity> entitiesToCreate);

/**
* Delete the specified grantRecord to the grant_records table.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,32 @@ public void deleteEntity(@NonNull PolarisCallContext callCtx, @NonNull PolarisBa
runActionInTransaction(callCtx, () -> this.deleteEntityInCurrentTxn(callCtx, entity));
}

/** {@inheritDoc} */
@Override
public void deleteEntityAndCreateEntities(
@NonNull PolarisCallContext callCtx,
@NonNull PolarisBaseEntity entityToDelete,
@NonNull List<PolarisBaseEntity> entitiesToCreate) {
runActionInTransaction(
callCtx,
() -> {
this.deleteEntityInCurrentTxn(callCtx, entityToDelete);
for (PolarisBaseEntity entityToCreate : entitiesToCreate) {
try {
this.checkConditionsForWriteEntityInCurrentTxn(callCtx, entityToCreate, null);
} catch (EntityAlreadyExistsException e) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a behavior change here, as it can now escape dropEntityIfExists.

Previously, the code ignored the result of createEntityIfNotExists, so a name collision on the cleanup task silently "succeeded".
Now, EntityAlreadyExistsException propagates.
I think it's more "correct", but:

  1. It's inconsistent with this class's convention of translating EntityAlreadyExistsException into a result status. We should consider returning a DropEntityResult error status instead of throwing a raw persistence exception through the manager API.
  2. The realistic trigger is a concurrent double-drop of the same entity (two cleanup tasks named entityCleanup_<sameId> with different task ids). The earlier existence check narrows this window but doesn't fully close it. It's worth confirming the REST/service layer degrades gracefully (maps to 409, not 500) rather than leaking a stack trace.

@iting0321 iting0321 Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jbonofre, Thanks for your feedback!
I agreed with the points.

I updated the drop path so EntityAlreadyExistsException no longer escapes from dropEntityIfExists; cleanup-task name collisions are now translated into a DropEntityResult(ENTITY_ALREADY_EXISTS).

The same-ID case is still treated as an idempotent retry, while the same-name/different-ID case is returned as a conflict. I also added the retry recovery path where, if the entity is already dropped but entityCleanup_<entityId> exists, we return success with the existing cleanup task ID.

At the service boundary, ENTITY_ALREADY_EXISTS is now translated to CommitConflictException, which maps to HTTP 409 for the affected Iceberg and admin drop paths.

// Matching ids indicate a retried create whose id was already reserved for the same
// entity. Treat that as idempotent, matching writeEntities.
if (e.getExistingEntity().getId() != entityToCreate.getId()) {
throw e;
}
continue;
}
this.writeEntityInCurrentTxn(callCtx, entityToCreate, true, null);
}
});
}

/** {@inheritDoc} */
@Override
public void deleteFromGrantRecords(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.apache.polaris.core.entity.PolarisTaskConstants;
import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.persistence.BaseMetaStoreManager;
import org.apache.polaris.core.persistence.EntityAlreadyExistsException;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.PolarisObjectMapperUtil;
import org.apache.polaris.core.persistence.PolicyMappingAlreadyExistsException;
Expand Down Expand Up @@ -1316,6 +1317,26 @@ public void deletePrincipalSecrets(
// entity cannot be null
getDiagnostics().checkNotNull(entityToDrop, "unexpected_null_entity");

// Resolve the entity by id before validating its path so a retry after a successful commit can
// recover the cleanup task even though the dropped entity no longer resolves.
PolarisBaseEntity refreshEntityToDrop =
ms.lookupEntityInCurrentTxn(
callCtx, entityToDrop.getCatalogId(), entityToDrop.getId(), entityToDrop.getTypeCode());
if (refreshEntityToDrop == null
&& cleanup
&& entityToDrop.getType() != PolarisEntityType.POLICY) {
PolarisBaseEntity cleanupTask =
ms.lookupEntityByNameInCurrentTxn(
callCtx,
PolarisEntityConstants.getNullId(),
PolarisEntityConstants.getNullId(),
PolarisEntityType.TASK.getCode(),
"entityCleanup_" + entityToDrop.getId());
if (cleanupTask != null) {
return new DropEntityResult(cleanupTask.getId());
}
}

// re-resolve everything including that entity
PolarisEntityResolver resolver =
new PolarisEntityResolver(getDiagnostics(), callCtx, ms, catalogPath, entityToDrop);
Expand All @@ -1325,11 +1346,6 @@ public void deletePrincipalSecrets(
return new DropEntityResult(BaseResult.ReturnStatus.CATALOG_PATH_CANNOT_BE_RESOLVED, null);
}

// first find the entity to drop
PolarisBaseEntity refreshEntityToDrop =
ms.lookupEntityInCurrentTxn(
callCtx, entityToDrop.getCatalogId(), entityToDrop.getId(), entityToDrop.getTypeCode());

// if this entity was not found, return failure
if (refreshEntityToDrop == null) {
return new DropEntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null);
Expand Down Expand Up @@ -1443,7 +1459,12 @@ public void deletePrincipalSecrets(
taskEntityBuilder.internalPropertiesAsMap(cleanupProperties);
}
PolarisBaseEntity taskEntity = taskEntityBuilder.build();
createEntityIfNotExists(callCtx, ms, null, taskEntity);
EntityResult createTaskResult = createEntityIfNotExists(callCtx, ms, null, taskEntity);
if (!createTaskResult.isSuccess()) {
ms.rollback();
return new DropEntityResult(
createTaskResult.getReturnStatus(), createTaskResult.getExtraInformation());
}
return new DropEntityResult(taskEntity.getId());
}

Expand All @@ -1463,11 +1484,21 @@ public void deletePrincipalSecrets(
TransactionalPersistence ms = ((TransactionalPersistence) callCtx.getMetaStore());

// need to run inside a read/write transaction
return ms.runInTransaction(
callCtx,
() ->
this.dropEntityIfExists(
callCtx, ms, catalogPath, entityToDrop, cleanupProperties, cleanup));
try {
return ms.runInTransaction(
callCtx,
() ->
this.dropEntityIfExists(
callCtx, ms, catalogPath, entityToDrop, cleanupProperties, cleanup));
} catch (EntityAlreadyExistsException e) {
return new DropEntityResult(
BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS,
String.format(
"Existing entity id: '%s', type %s subtype %s",
e.getExistingEntity().getId(),
e.getExistingEntity().getTypeCode(),
e.getExistingEntity().getSubTypeCode()));
}
}

/**
Expand Down
Loading