This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new c7449e812b [#12344] improvement(core): add OCC for role writes (#12746)
c7449e812b is described below
commit c7449e812bfd9919309dcc56167e89aaf89c4ad4
Author: Qi Yu <[email protected]>
AuthorDate: Tue Sep 1 16:24:16 2026 +0800
[#12344] improvement(core): add OCC for role writes (#12746)
### What changes were proposed in this pull request?
Add version-CAS optimistic concurrency control and atomic service
operations for managed roles.
- Increment the role version on every update and guard updates and soft
deletes with the observed version.
- Execute the role CAS before privilege, relationship, ownership, and
cleanup mutations in one transaction.
- Fence the parent metalake during role creation and preserve monotonic
versions on overwrite.
- Classify failed writes as either missing entities or optimistic-lock
conflicts.
- Reuse `OccWriteSupport` for role OCC operations.
- Migrate the equivalent duplicated OCC helpers in topic, model,
fileset, user, and group services.
- Preserve model fully qualified names and the existing
fileset/user/group conflict-classification behavior.
### Why are the changes needed?
Concurrent role mutations could otherwise overwrite each other or leave
partially updated privilege and relationship state.
The topic, model, fileset, user, group, and role OCC implementations
also landed around the shared-helper work in #12639, leaving duplicated
write-failure, version-delete, and parent-lock logic that can now use
`OccWriteSupport`.
Fix: #12344
### Does this PR introduce _any_ user-facing change?
Concurrent managed-role writes now report the existing optimistic-lock
conflict response (HTTP 409). No API or configuration keys are changed.
### How was this patch tested?
- `./gradlew :core:spotlessApply`
- `env dockerTest=false ./gradlew :core:test --tests TestRoleMetaService
--tests TestTopicMetaService --tests TestModelMetaService --tests
TestFilesetMetaService --tests TestUserMetaService --tests
TestGroupMetaService --tests TestOccWriteSupport --tests TestAuthMappers
--tests TestPOConverters -PskipITs -PskipDockerTests=true`
- `git diff --check`
- GitHub Backend Integration Test matrix for H2, MySQL, and PostgreSQL.
---
.../storage/relational/mapper/RoleMetaMapper.java | 12 +-
.../mapper/RoleMetaSQLProviderFactory.java | 10 +-
.../provider/base/RoleMetaBaseSQLProvider.java | 25 ++-
.../postgresql/RoleMetaPostgreSQLProvider.java | 15 +-
.../relational/service/FilesetMetaService.java | 41 ++--
.../relational/service/GroupMetaService.java | 65 +++---
.../relational/service/ModelMetaService.java | 46 ++--
.../relational/service/OccWriteSupport.java | 38 +++-
.../relational/service/RoleMetaService.java | 138 +++++++++---
.../relational/service/TopicMetaService.java | 52 +++--
.../relational/service/UserMetaService.java | 69 +++---
.../storage/relational/utils/POConverters.java | 12 +-
.../mapper/provider/base/TestAuthMappers.java | 12 +-
.../relational/service/TestOccWriteSupport.java | 10 +
.../relational/service/TestRoleMetaService.java | 233 +++++++++++++++++++++
.../storage/relational/utils/TestPOConverters.java | 28 +++
16 files changed, 606 insertions(+), 200 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaMapper.java
index 78fcc921d2..1f03e5df6d 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaMapper.java
@@ -47,6 +47,10 @@ public interface RoleMetaMapper {
RolePO selectRoleMetaByMetalakeIdAndName(
@Param("metalakeId") Long metalakeId, @Param("roleName") String
roleName);
+ /** Returns and locks an active role by ID for the current transaction. */
+ @SelectProvider(type = RoleMetaSQLProviderFactory.class, method =
"selectRoleMetaByIdForUpdate")
+ RolePO selectRoleMetaByIdForUpdate(@Param("roleId") Long roleId);
+
@SelectProvider(
type = RoleMetaSQLProviderFactory.class,
method = "selectRoleIdByMetalakeIdAndName")
@@ -81,8 +85,14 @@ public interface RoleMetaMapper {
Integer updateRoleMeta(
@Param("newRoleMeta") RolePO newRolePO, @Param("oldRoleMeta") RolePO
oldRolePO);
+ /**
+ * Soft-deletes an active role only when its OCC version still matches.
+ *
+ * @return the number of deleted rows
+ */
@UpdateProvider(type = RoleMetaSQLProviderFactory.class, method =
"softDeleteRoleMetaByRoleId")
- void softDeleteRoleMetaByRoleId(@Param("roleId") Long roleId);
+ Integer softDeleteRoleMetaByRoleId(
+ @Param("roleId") Long roleId, @Param("currentVersion") Long
currentVersion);
@UpdateProvider(
type = RoleMetaSQLProviderFactory.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaSQLProviderFactory.java
index 649c897f68..3cc763e1ba 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/RoleMetaSQLProviderFactory.java
@@ -55,6 +55,11 @@ public class RoleMetaSQLProviderFactory {
return getProvider().selectRoleMetaByMetalakeIdAndName(metalakeId,
roleName);
}
+ /** Returns SQL that selects and locks an active role by ID. */
+ public static String selectRoleMetaByIdForUpdate(@Param("roleId") Long
roleId) {
+ return getProvider().selectRoleMetaByIdForUpdate(roleId);
+ }
+
public static String selectRoleIdByMetalakeIdAndName(
@Param("metalakeId") Long metalakeId, @Param("roleName") String name) {
return getProvider().selectRoleIdByMetalakeIdAndName(metalakeId, name);
@@ -90,8 +95,9 @@ public class RoleMetaSQLProviderFactory {
return getProvider().updateRoleMeta(newRolePO, oldRolePO);
}
- public static String softDeleteRoleMetaByRoleId(@Param("roleId") Long
roleId) {
- return getProvider().softDeleteRoleMetaByRoleId(roleId);
+ public static String softDeleteRoleMetaByRoleId(
+ @Param("roleId") Long roleId, @Param("currentVersion") Long
currentVersion) {
+ return getProvider().softDeleteRoleMetaByRoleId(roleId, currentVersion);
}
public static String softDeleteRoleMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/RoleMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/RoleMetaBaseSQLProvider.java
index d3a7127a32..a63a0682dd 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/RoleMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/RoleMetaBaseSQLProvider.java
@@ -42,6 +42,16 @@ public class RoleMetaBaseSQLProvider {
+ " AND deleted_at = 0";
}
+ /** Returns SQL that selects and locks an active role by ID. */
+ public String selectRoleMetaByIdForUpdate(@Param("roleId") Long roleId) {
+ return "SELECT role_id as roleId, role_name as roleName, metalake_id as
metalakeId,"
+ + " properties, audit_info as auditInfo, current_version as
currentVersion,"
+ + " last_version as lastVersion, deleted_at as deletedAt"
+ + " FROM "
+ + ROLE_TABLE_NAME
+ + " WHERE role_id = #{roleId} AND deleted_at = 0 FOR UPDATE";
+ }
+
public String selectRoleIdByMetalakeIdAndName(
@Param("metalakeId") Long metalakeId, @Param("roleName") String name) {
return "SELECT role_id as roleId FROM "
@@ -147,8 +157,10 @@ public class RoleMetaBaseSQLProvider {
+ " metalake_id = #{roleMeta.metalakeId},"
+ " properties = #{roleMeta.properties},"
+ " audit_info = #{roleMeta.auditInfo},"
- + " current_version = #{roleMeta.currentVersion},"
- + " last_version = #{roleMeta.lastVersion},"
+ // Advance rather than reset the OCC token so a writer holding a
pre-overwrite snapshot
+ // cannot pass a later compare-and-set (an ABA conflict).
+ + " last_version = current_version + 1,"
+ + " current_version = current_version + 1,"
+ " deleted_at = #{roleMeta.deletedAt}";
}
@@ -164,19 +176,18 @@ public class RoleMetaBaseSQLProvider {
+ " last_version = #{newRoleMeta.lastVersion},"
+ " deleted_at = #{newRoleMeta.deletedAt}"
+ " WHERE role_id = #{oldRoleMeta.roleId}"
- + " AND role_name = #{oldRoleMeta.roleName}"
- + " AND metalake_id = #{oldRoleMeta.metalakeId}"
+ " AND current_version = #{oldRoleMeta.currentVersion}"
- + " AND last_version = #{oldRoleMeta.lastVersion}"
+ " AND deleted_at = 0";
}
- public String softDeleteRoleMetaByRoleId(@Param("roleId") Long roleId) {
+ public String softDeleteRoleMetaByRoleId(
+ @Param("roleId") Long roleId, @Param("currentVersion") Long
currentVersion) {
return "UPDATE "
+ ROLE_TABLE_NAME
+ " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+ " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
- + " WHERE role_id = #{roleId} AND deleted_at = 0";
+ + " WHERE role_id = #{roleId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
public String softDeleteRoleMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/RoleMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/RoleMetaPostgreSQLProvider.java
index 44de0a3bab..92b8fd8c92 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/RoleMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/RoleMetaPostgreSQLProvider.java
@@ -26,11 +26,13 @@ import org.apache.ibatis.annotations.Param;
public class RoleMetaPostgreSQLProvider extends RoleMetaBaseSQLProvider {
@Override
- public String softDeleteRoleMetaByRoleId(@Param("roleId") Long roleId) {
+ public String softDeleteRoleMetaByRoleId(
+ @Param("roleId") Long roleId, @Param("currentVersion") Long
currentVersion) {
return "UPDATE "
+ ROLE_TABLE_NAME
+ " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
- + " WHERE role_id = #{roleId} AND deleted_at = 0";
+ + " WHERE role_id = #{roleId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
@Override
@@ -62,8 +64,13 @@ public class RoleMetaPostgreSQLProvider extends
RoleMetaBaseSQLProvider {
+ " metalake_id = #{roleMeta.metalakeId},"
+ " properties = #{roleMeta.properties},"
+ " audit_info = #{roleMeta.auditInfo},"
- + " current_version = #{roleMeta.currentVersion},"
- + " last_version = #{roleMeta.lastVersion},"
+ // PostgreSQL requires the stored-row column to be qualified in ON
CONFLICT assignments.
+ + " current_version = "
+ + ROLE_TABLE_NAME
+ + ".current_version + 1,"
+ + " last_version = "
+ + ROLE_TABLE_NAME
+ + ".current_version + 1,"
+ " deleted_at = #{roleMeta.deletedAt}";
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
index 70ce93113a..22e37f1ce5 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
@@ -466,15 +466,14 @@ public class FilesetMetaService {
* @param observedFilesetPO the fileset row and OCC version observed by the
caller
*/
void deleteFilesetWithVersion(NameIdentifier identifier, FilesetPO
observedFilesetPO) {
- int deleted =
- SessionUtils.getWithoutCommit(
- FilesetMetaMapper.class,
- mapper ->
- mapper.softDeleteFilesetMetasByFilesetId(
- observedFilesetPO.getFilesetId(),
observedFilesetPO.getCurrentVersion()));
- if (deleted == 0) {
- throw filesetWriteFailure(identifier, observedFilesetPO);
- }
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ FilesetMetaMapper.class,
+ mapper ->
+ mapper.softDeleteFilesetMetasByFilesetId(
+ observedFilesetPO.getFilesetId(),
observedFilesetPO.getCurrentVersion())),
+ () -> filesetWriteFailure(identifier, observedFilesetPO));
}
private boolean tryUpdateFileset(FilesetPO newFilesetPO, FilesetPO
oldFilesetPO) {
@@ -553,18 +552,16 @@ public class FilesetMetaService {
// The failed CAS has already serialized with an in-flight writer. A
non-locking natural-key
// lookup is enough to distinguish a disappeared name from one that still
names either the
// modified fileset or a replacement, without holding another row lock on
the failure path.
- Long currentFilesetId =
- SessionUtils.getWithoutCommit(
- FilesetMetaMapper.class,
- mapper ->
- mapper.selectFilesetIdBySchemaIdAndName(
- observedFilesetPO.getSchemaId(),
observedFilesetPO.getFilesetName()));
- if (currentFilesetId == null) {
- return new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.FILESET.name().toLowerCase(),
- identifier.name());
- }
- return ExceptionUtils.concurrentModification(Entity.EntityType.FILESET,
identifier);
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.FILESET,
+ () ->
+ SessionUtils.getWithoutCommit(
+ FilesetMetaMapper.class,
+ mapper ->
+ mapper.selectFilesetIdBySchemaIdAndName(
+ observedFilesetPO.getSchemaId(),
observedFilesetPO.getFilesetName())),
+ null,
+ null);
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
index 4902fa181c..b431cccf6f 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
@@ -246,17 +246,15 @@ public class GroupMetaService {
void deleteGroupWithVersion(NameIdentifier identifier, GroupPO
observedGroupPO) {
Long groupId = observedGroupPO.getGroupId();
SessionUtils.doMultipleWithCommit(
- () -> {
- int deleted =
- SessionUtils.getWithoutCommit(
- GroupMetaMapper.class,
- mapper ->
- mapper.softDeleteGroupMetaByGroupId(
- groupId, observedGroupPO.getCurrentVersion()));
- if (deleted == 0) {
- throw groupWriteFailure(identifier, observedGroupPO,
GroupLookup.NAME);
- }
- },
+ () ->
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.softDeleteGroupMetaByGroupId(
+ groupId, observedGroupPO.getCurrentVersion())),
+ () -> groupWriteFailure(identifier, observedGroupPO,
GroupLookup.NAME)),
() ->
SessionUtils.doWithoutCommit(
GroupRoleRelMapper.class,
@@ -609,18 +607,16 @@ public class GroupMetaService {
* create for no reason.
*/
private void lockMetalakeForGroupCreate(MetalakePO observedMetalakePO) {
- MetalakePO currentMetalakePO =
- SessionUtils.getWithoutCommit(
- MetalakeMetaMapper.class,
- mapper ->
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId()));
- if (currentMetalakePO == null
- || !Objects.equals(
- currentMetalakePO.getMetalakeName(),
observedMetalakePO.getMetalakeName())) {
- throw new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.METALAKE.name().toLowerCase(),
- observedMetalakePO.getMetalakeName());
- }
+ OccWriteSupport.lockParentForChildWrite(
+ observedMetalakePO.getMetalakeName(),
+ Entity.EntityType.METALAKE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper ->
+
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId())),
+ null,
+ current -> Objects.equals(current.getMetalakeName(),
observedMetalakePO.getMetalakeName()));
}
private RuntimeException groupWriteFailure(
@@ -629,20 +625,15 @@ public class GroupMetaService {
// The locking read additionally waits for a writer that is still in
flight, so a rename or
// delete that has not committed yet is classified as not-found instead of
as a stale-version
// conflict. The lock is taken on the error path of a transaction that is
about to roll back.
- GroupPO currentGroupPO =
getGroupPOByIdForUpdate(observedGroupPO.getGroupId());
- boolean missing =
- currentGroupPO == null
- || !Objects.equals(currentGroupPO.getMetalakeId(),
observedGroupPO.getMetalakeId());
- if (!missing && lookup == GroupLookup.NAME) {
- missing = !Objects.equals(currentGroupPO.getGroupName(),
observedGroupPO.getGroupName());
- }
- if (missing) {
- return new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.GROUP.name().toLowerCase(),
- identifier.name());
- }
- return ExceptionUtils.concurrentModification(Entity.EntityType.GROUP,
identifier);
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.GROUP,
+ () -> getGroupPOByIdForUpdate(observedGroupPO.getGroupId()),
+ null,
+ current ->
+ Objects.equals(current.getMetalakeId(),
observedGroupPO.getMetalakeId())
+ && (lookup != GroupLookup.NAME
+ || Objects.equals(current.getGroupName(),
observedGroupPO.getGroupName())));
}
private GroupPO getGroupPOByIdForUpdate(long groupId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java
index e49ad97328..a482cfb3ce 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java
@@ -412,15 +412,14 @@ public class ModelMetaService {
* data. This allows any later cleanup failure to restore the model row as
well.
*/
void deleteModelWithVersion(NameIdentifier ident, ModelPO observedModelPO) {
- int deleted =
- SessionUtils.getWithoutCommit(
- ModelMetaMapper.class,
- mapper ->
- mapper.softDeleteModelMetaByIdAndVersion(
- observedModelPO.getModelId(),
observedModelPO.getCurrentVersion()));
- if (deleted == 0) {
- throw modelWriteFailure(ident, observedModelPO);
- }
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ ModelMetaMapper.class,
+ mapper ->
+ mapper.softDeleteModelMetaByIdAndVersion(
+ observedModelPO.getModelId(),
observedModelPO.getCurrentVersion())),
+ () -> modelWriteFailure(ident, observedModelPO));
}
/**
@@ -475,21 +474,20 @@ public class ModelMetaService {
* deleted, renamed, or moved, the requested model no longer exists.
*/
private RuntimeException modelWriteFailure(NameIdentifier ident, ModelPO
observedModelPO) {
- ModelPO currentModelPO =
- SessionUtils.getWithoutCommit(
- ModelMetaMapper.class,
- mapper ->
mapper.selectModelMetaByModelIdForUpdate(observedModelPO.getModelId()));
- if (currentModelPO == null
- || !Objects.equals(currentModelPO.getModelName(),
observedModelPO.getModelName())
- || !Objects.equals(currentModelPO.getSchemaId(),
observedModelPO.getSchemaId())
- || !Objects.equals(currentModelPO.getCatalogId(),
observedModelPO.getCatalogId())
- || !Objects.equals(currentModelPO.getMetalakeId(),
observedModelPO.getMetalakeId())) {
- return new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.MODEL.name().toLowerCase(Locale.ROOT),
- ident.toString());
- }
- return ExceptionUtils.concurrentModification(Entity.EntityType.MODEL,
ident);
+ return OccWriteSupport.writeFailure(
+ ident,
+ Entity.EntityType.MODEL,
+ ident.toString(),
+ () ->
+ SessionUtils.getWithoutCommit(
+ ModelMetaMapper.class,
+ mapper ->
mapper.selectModelMetaByModelIdForUpdate(observedModelPO.getModelId())),
+ null,
+ current ->
+ Objects.equals(current.getModelName(),
observedModelPO.getModelName())
+ && Objects.equals(current.getSchemaId(),
observedModelPO.getSchemaId())
+ && Objects.equals(current.getCatalogId(),
observedModelPO.getCatalogId())
+ && Objects.equals(current.getMetalakeId(),
observedModelPO.getMetalakeId()));
}
private void deleteModelDependents(ModelPO modelPO) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
index 3e5004a896..f9797e0646 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
@@ -42,8 +42,9 @@ public class OccWriteSupport {
/**
* Classifies a write-failure for an entity during an optimistic concurrency
control operation.
*
- * <p>Executes a locking lookup to re-read the target entity. If the entity
no longer exists or
- * its natural key fields do not match the expected identity, returns a
{@link
+ * <p>Executes a lookup to re-read the target entity. The caller is
responsible for choosing the
+ * lookup semantics needed by its transaction, usually a locking read by
stable ID. If the entity
+ * no longer exists or its natural key fields do not match the expected
identity, returns a {@link
* NoSuchEntityException}. Otherwise, returns an {@link
* org.apache.gravitino.exceptions.OptimisticLockException} via {@link
* ExceptionUtils#concurrentModification(Entity.EntityType, NameIdentifier)}.
@@ -51,7 +52,7 @@ public class OccWriteSupport {
* @param <T> the persistent object (PO) type of the entity
* @param identifier the name identifier of the entity
* @param type the entity type
- * @param lockingLookup a supplier that retrieves the current entity while
locking its row
+ * @param currentLookup a supplier that retrieves the current entity
* @param poMapper an optional function to transform the retrieved PO (e.g.
physical to logical)
* @param sameIdentity a predicate comparing the retrieved PO against
expected natural key values
* @return the classified RuntimeException to be thrown
@@ -59,10 +60,35 @@ public class OccWriteSupport {
public static <T> RuntimeException writeFailure(
NameIdentifier identifier,
Entity.EntityType type,
- Supplier<T> lockingLookup,
+ Supplier<T> currentLookup,
@Nullable Function<T, T> poMapper,
@Nullable Predicate<T> sameIdentity) {
- T currentPO = lockingLookup.get();
+ return writeFailure(identifier, type, identifier.name(), currentLookup,
poMapper, sameIdentity);
+ }
+
+ /**
+ * Classifies a write-failure while preserving a caller-specific entity name
in not-found errors.
+ *
+ * <p>This overload is useful for services whose existing error contract
reports a fully qualified
+ * name rather than {@link NameIdentifier#name()}.
+ *
+ * @param <T> the persistent object (PO) type of the entity
+ * @param identifier the name identifier used for an optimistic-lock error
+ * @param type the entity type
+ * @param entityName the entity name used for a not-found error
+ * @param currentLookup a supplier that retrieves the current entity
+ * @param poMapper an optional function to transform the retrieved PO (e.g.
physical to logical)
+ * @param sameIdentity a predicate comparing the retrieved PO against
expected natural key values
+ * @return the classified RuntimeException to be thrown
+ */
+ public static <T> RuntimeException writeFailure(
+ NameIdentifier identifier,
+ Entity.EntityType type,
+ String entityName,
+ Supplier<T> currentLookup,
+ @Nullable Function<T, T> poMapper,
+ @Nullable Predicate<T> sameIdentity) {
+ T currentPO = currentLookup.get();
if (currentPO != null && poMapper != null) {
currentPO = poMapper.apply(currentPO);
}
@@ -70,7 +96,7 @@ public class OccWriteSupport {
return new NoSuchEntityException(
NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
type.name().toLowerCase(Locale.ROOT),
- identifier.name());
+ entityName);
}
return ExceptionUtils.concurrentModification(type, identifier);
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/RoleMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/RoleMetaService.java
index 803d4949df..08d69202db 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/RoleMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/RoleMetaService.java
@@ -45,10 +45,12 @@ import org.apache.gravitino.meta.RoleEntity;
import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.metrics.Monitored;
import org.apache.gravitino.storage.relational.mapper.GroupRoleRelMapper;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import org.apache.gravitino.storage.relational.mapper.UserRoleRelMapper;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
import org.apache.gravitino.storage.relational.po.RolePO;
import org.apache.gravitino.storage.relational.po.SecurableObjectPO;
import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
@@ -154,8 +156,17 @@ public class RoleMetaService {
AuthorizationUtils.checkRole(roleEntity.nameIdentifier());
String metalake =
NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier());
- Long metalakeId =
MetalakeMetaService.getInstance().getMetalakeIdByName(metalake);
- RolePO.Builder builder = RolePO.builder().withMetalakeId(metalakeId);
+ MetalakePO metalakePO =
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class, mapper ->
mapper.selectMetalakeMetaByName(metalake));
+ if (metalakePO == null) {
+ throw new NoSuchEntityException(
+ NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+ Entity.EntityType.METALAKE.name().toLowerCase(),
+ metalake);
+ }
+
+ RolePO.Builder builder =
RolePO.builder().withMetalakeId(metalakePO.getMetalakeId());
RolePO rolePO = POConverters.initializeRolePOWithVersion(roleEntity,
builder);
List<SecurableObjectPO> securableObjectPOs = Lists.newArrayList();
for (SecurableObject object : roleEntity.securableObjects()) {
@@ -168,26 +179,30 @@ public class RoleMetaService {
securableObjectPOs.add(objectBuilder.build());
}
+ // The role row is written before its securable objects. Concurrent
overwrites then contend
+ // on the role row first and replace the child rows only after they are
serialized. The role
+ // and its securable objects therefore change atomically, with the last
overwrite winning.
SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeForRoleCreate(metalakePO),
() ->
SessionUtils.doWithoutCommit(
- SecurableObjectMapper.class,
+ RoleMetaMapper.class,
mapper -> {
if (overwritten) {
-
mapper.softDeleteSecurableObjectsByRoleId(rolePO.getRoleId());
- }
- if (!securableObjectPOs.isEmpty()) {
- mapper.batchInsertSecurableObjects(securableObjectPOs);
+ mapper.insertRoleMetaOnDuplicateKeyUpdate(rolePO);
+ } else {
+ mapper.insertRoleMeta(rolePO);
}
}),
() ->
SessionUtils.doWithoutCommit(
- RoleMetaMapper.class,
+ SecurableObjectMapper.class,
mapper -> {
if (overwritten) {
- mapper.insertRoleMetaOnDuplicateKeyUpdate(rolePO);
- } else {
- mapper.insertRoleMeta(rolePO);
+
mapper.softDeleteSecurableObjectsByRoleId(rolePO.getRoleId());
+ }
+ if (!securableObjectPOs.isEmpty()) {
+ mapper.batchInsertSecurableObjects(securableObjectPOs);
}
}));
@@ -225,10 +240,10 @@ public class RoleMetaService {
Set<SecurableObject> insertObjects = Sets.difference(newObjects,
oldObjects);
Set<SecurableObject> deleteObjects = Sets.difference(oldObjects,
newObjects);
- if (insertObjects.isEmpty() && deleteObjects.isEmpty()) {
- return newRoleEntity;
- }
-
+ // Every update runs the compare-and-set, including one that leaves the
securable objects
+ // untouched. The short-circuit that used to return early here would
skip the version check,
+ // so a caller whose snapshot was already stale would be told the update
succeeded. It also
+ // has to run because a metadata-only change, such as the audit info,
still has to be written.
List<SecurableObjectPO> deleteSecurableObjectPOs =
toSecurableObjectPOs(deleteObjects, oldRoleEntity, metalake);
@@ -236,12 +251,17 @@ public class RoleMetaService {
toSecurableObjectPOs(insertObjects, oldRoleEntity, metalake);
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- RoleMetaMapper.class,
- mapper ->
- mapper.updateRoleMeta(
- POConverters.updateRolePOWithVersion(rolePO,
newRoleEntity), rolePO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ RoleMetaMapper.class,
+ mapper ->
+ mapper.updateRoleMeta(
+ POConverters.updateRolePOWithVersion(rolePO,
newRoleEntity), rolePO));
+ if (updated == 0) {
+ throw roleWriteFailure(identifier, rolePO);
+ }
+ },
() -> {
if (deleteSecurableObjectPOs.isEmpty()) {
return;
@@ -308,12 +328,32 @@ public class RoleMetaService {
Long metalakeId =
MetalakeMetaService.getInstance().getMetalakeIdByName(identifier.namespace().level(0));
- Long roleId = getRoleIdByMetalakeIdAndName(metalakeId, identifier.name());
+ RolePO rolePO = getRolePOByMetalakeIdAndName(metalakeId,
identifier.name());
+
+ deleteRoleWithVersion(identifier, rolePO);
+ return true;
+ }
+ /**
+ * Deletes the role whose version matches {@code observedRolePO}, together
with its role and owner
+ * relations. Package-private so tests can hand in a deliberately stale PO;
callers outside this
+ * class go through {@link #deleteRole(NameIdentifier)}, which reads the row
first.
+ *
+ * @param identifier the role being deleted, used only to build the error
+ * @param observedRolePO the role row the caller observed, carrying the
version to match
+ */
+ void deleteRoleWithVersion(NameIdentifier identifier, RolePO observedRolePO)
{
+ Long roleId = observedRolePO.getRoleId();
SessionUtils.doMultipleWithCommit(
() ->
- SessionUtils.doWithoutCommit(
- RoleMetaMapper.class, mapper ->
mapper.softDeleteRoleMetaByRoleId(roleId)),
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ RoleMetaMapper.class,
+ mapper ->
+ mapper.softDeleteRoleMetaByRoleId(
+ roleId, observedRolePO.getCurrentVersion())),
+ () -> roleWriteFailure(identifier, observedRolePO)),
() ->
SessionUtils.doWithoutCommit(
UserRoleRelMapper.class, mapper ->
mapper.softDeleteUserRoleRelByRoleId(roleId)),
@@ -330,7 +370,6 @@ public class RoleMetaService {
mapper ->
mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
roleId, MetadataObject.Type.ROLE.name())));
- return true;
}
@Monitored(
@@ -455,6 +494,55 @@ public class RoleMetaService {
return rolePO;
}
+ /**
+ * Holds the parent metalake row for the rest of the transaction, so the
role cannot be created
+ * under a metalake that is going away.
+ *
+ * <p>The lock is shared, not exclusive: many roles can be created under the
same metalake at the
+ * same time. Dropping a metalake takes an exclusive lock on this row, so a
drop and a create
+ * cannot overlap. Whoever gets the row first wins, and the loser either
sees the metalake gone or
+ * inserts under a metalake that is still there.
+ *
+ * <p>The name is compared again because the ID alone cannot tell a rename
apart: the caller
+ * looked the metalake up by name, so a renamed row means the name in the
request no longer
+ * exists.
+ *
+ * <p>The metalake's version is deliberately not compared, matching {@code
CatalogMetaService}.
+ * Holding the row is what makes the create safe. An unrelated metalake edit
that commits in
+ * between bumps the version without making this create wrong, so comparing
it would reject the
+ * create for no reason.
+ */
+ private void lockMetalakeForRoleCreate(MetalakePO observedMetalakePO) {
+ OccWriteSupport.lockParentForChildWrite(
+ observedMetalakePO.getMetalakeName(),
+ Entity.EntityType.METALAKE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper ->
+
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId())),
+ null,
+ current -> Objects.equals(current.getMetalakeName(),
observedMetalakePO.getMetalakeName()));
+ }
+
+ private RuntimeException roleWriteFailure(NameIdentifier identifier, RolePO
observedRolePO) {
+ // Sessions run at READ_COMMITTED, so a plain read would already see the
latest committed row.
+ // The locking read additionally waits for a writer that is still in
flight, so a rename or
+ // delete that has not committed yet is classified as not-found instead of
as a stale-version
+ // conflict. The lock is taken on the error path of a transaction that is
about to roll back.
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.ROLE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ RoleMetaMapper.class,
+ mapper ->
mapper.selectRoleMetaByIdForUpdate(observedRolePO.getRoleId())),
+ null,
+ current ->
+ Objects.equals(current.getRoleName(), observedRolePO.getRoleName())
+ && Objects.equals(current.getMetalakeId(),
observedRolePO.getMetalakeId()));
+ }
+
private static MetadataObject.Type getType(String type) {
return MetadataObject.Type.valueOf(type);
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
index 367c33de63..fbe9c87565 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
@@ -345,19 +345,17 @@ public class TopicMetaService {
*/
void deleteTopicWithVersion(NameIdentifier identifier, TopicPO
observedTopicPO) {
SessionUtils.doMultipleWithCommit(
- () -> {
- // Check the root version before touching relationships. If this
snapshot is stale,
- // throwing here stops the transaction before any dependent row can
be deleted.
- int deleted =
- SessionUtils.getWithoutCommit(
- TopicMetaMapper.class,
- mapper ->
- mapper.softDeleteTopicMetasByTopicId(
- observedTopicPO.getTopicId(),
observedTopicPO.getCurrentVersion()));
- if (deleted == 0) {
- throw topicWriteFailure(identifier, observedTopicPO);
- }
- },
+ // Check the root version before touching relationships. If this
snapshot is stale,
+ // throwing here stops the transaction before any dependent row can be
deleted.
+ () ->
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ TopicMetaMapper.class,
+ mapper ->
+ mapper.softDeleteTopicMetasByTopicId(
+ observedTopicPO.getTopicId(),
observedTopicPO.getCurrentVersion())),
+ () -> topicWriteFailure(identifier, observedTopicPO)),
() -> deleteTopicDependents(observedTopicPO.getTopicId()));
}
@@ -391,20 +389,18 @@ public class TopicMetaService {
// A zero-row CAS means either the same topic has a newer version, or the
topic disappeared
// from the name the caller used. The stable-ID lock waits for an
in-flight writer to finish so
// the result is classified from committed identity data.
- TopicPO currentTopicPO =
- SessionUtils.getWithoutCommit(
- TopicMetaMapper.class,
- mapper ->
mapper.selectTopicMetaByIdForUpdate(observedTopicPO.getTopicId()));
- if (currentTopicPO == null
- || !Objects.equals(currentTopicPO.getTopicName(),
observedTopicPO.getTopicName())
- || !Objects.equals(currentTopicPO.getSchemaId(),
observedTopicPO.getSchemaId())
- || !Objects.equals(currentTopicPO.getCatalogId(),
observedTopicPO.getCatalogId())
- || !Objects.equals(currentTopicPO.getMetalakeId(),
observedTopicPO.getMetalakeId())) {
- return new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.TOPIC.name().toLowerCase(),
- identifier.name());
- }
- return ExceptionUtils.concurrentModification(Entity.EntityType.TOPIC,
identifier);
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.TOPIC,
+ () ->
+ SessionUtils.getWithoutCommit(
+ TopicMetaMapper.class,
+ mapper ->
mapper.selectTopicMetaByIdForUpdate(observedTopicPO.getTopicId())),
+ null,
+ current ->
+ Objects.equals(current.getTopicName(),
observedTopicPO.getTopicName())
+ && Objects.equals(current.getSchemaId(),
observedTopicPO.getSchemaId())
+ && Objects.equals(current.getCatalogId(),
observedTopicPO.getCatalogId())
+ && Objects.equals(current.getMetalakeId(),
observedTopicPO.getMetalakeId()));
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
index 2ba8d38c9e..a4b088b4da 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
@@ -207,17 +207,15 @@ public class UserMetaService {
void deleteUserWithVersion(NameIdentifier identifier, UserPO observedUserPO)
{
Long userId = observedUserPO.getUserId();
SessionUtils.doMultipleWithCommit(
- () -> {
- int deleted =
- SessionUtils.getWithoutCommit(
- UserMetaMapper.class,
- mapper ->
- mapper.softDeleteUserMetaByUserId(
- userId, observedUserPO.getCurrentVersion()));
- if (deleted == 0) {
- throw userWriteFailure(identifier, observedUserPO,
UserLookup.NAME);
- }
- },
+ () ->
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
+ mapper.softDeleteUserMetaByUserId(
+ userId, observedUserPO.getCurrentVersion())),
+ () -> userWriteFailure(identifier, observedUserPO,
UserLookup.NAME)),
() ->
SessionUtils.doWithoutCommit(
UserRoleRelMapper.class, mapper ->
mapper.softDeleteUserRoleRelByUserId(userId)),
@@ -600,18 +598,16 @@ public class UserMetaService {
* create for no reason.
*/
private void lockMetalakeForUserCreate(MetalakePO observedMetalakePO) {
- MetalakePO currentMetalakePO =
- SessionUtils.getWithoutCommit(
- MetalakeMetaMapper.class,
- mapper ->
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId()));
- if (currentMetalakePO == null
- || !Objects.equals(
- currentMetalakePO.getMetalakeName(),
observedMetalakePO.getMetalakeName())) {
- throw new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.METALAKE.name().toLowerCase(),
- observedMetalakePO.getMetalakeName());
- }
+ OccWriteSupport.lockParentForChildWrite(
+ observedMetalakePO.getMetalakeName(),
+ Entity.EntityType.METALAKE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper ->
+
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId())),
+ null,
+ current -> Objects.equals(current.getMetalakeName(),
observedMetalakePO.getMetalakeName()));
}
private RuntimeException userWriteFailure(
@@ -620,22 +616,17 @@ public class UserMetaService {
// The locking read additionally waits for a writer that is still in
flight, so a rename or
// delete that has not committed yet is classified as not-found instead of
as a stale-version
// conflict. The lock is taken on the error path of a transaction that is
about to roll back.
- UserPO currentUserPO = getUserPOByIdForUpdate(observedUserPO.getUserId());
- boolean missing =
- currentUserPO == null
- || !Objects.equals(currentUserPO.getMetalakeId(),
observedUserPO.getMetalakeId());
- if (!missing && lookup == UserLookup.NAME) {
- missing = !Objects.equals(currentUserPO.getUserName(),
observedUserPO.getUserName());
- } else if (!missing && lookup == UserLookup.EXTERNAL_ID) {
- missing = !Objects.equals(currentUserPO.getExternalId(),
observedUserPO.getExternalId());
- }
- if (missing) {
- return new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.USER.name().toLowerCase(),
- identifier.name());
- }
- return ExceptionUtils.concurrentModification(Entity.EntityType.USER,
identifier);
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.USER,
+ () -> getUserPOByIdForUpdate(observedUserPO.getUserId()),
+ null,
+ current ->
+ Objects.equals(current.getMetalakeId(),
observedUserPO.getMetalakeId())
+ && (lookup != UserLookup.NAME
+ || Objects.equals(current.getUserName(),
observedUserPO.getUserName()))
+ && (lookup != UserLookup.EXTERNAL_ID
+ || Objects.equals(current.getExternalId(),
observedUserPO.getExternalId())));
}
private UserPO getUserPOByIdForUpdate(long userId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
index 45390dc926..1fec61c1d7 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
@@ -1367,11 +1367,15 @@ public class POConverters {
}
}
+ /**
+ * Updates a role PO and advances its OCC version.
+ *
+ * @param oldRolePO the role PO carrying the current version
+ * @param newRole the updated role entity
+ * @return a role PO whose current and last versions are advanced
+ */
public static RolePO updateRolePOWithVersion(RolePO oldRolePO, RoleEntity
newRole) {
- Long lastVersion = oldRolePO.getLastVersion();
- // TODO: set the version to the last version + 1 when having some fields
need be multiple
- // version
- Long nextVersion = lastVersion;
+ Long nextVersion = oldRolePO.getCurrentVersion() + 1;
try {
return RolePO.builder()
.withRoleId(oldRolePO.getRoleId())
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
index 86bfd5026c..39aa1e3f02 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
@@ -188,7 +188,7 @@ public class TestAuthMappers {
void testRoleMetaTouchUpdatedAtSkipsSoftDeleted() {
insertMetalake(1L, "metalake1");
insertRole(13L, "role13", 1L);
- roleMetaMapper.softDeleteRoleMetaByRoleId(13L);
+ roleMetaMapper.softDeleteRoleMetaByRoleId(13L, 1L);
long beforeUpdatedAt = queryUpdatedAt("role_meta", "role_id", 13L);
roleMetaMapper.touchRoleUpdatedAt(13L);
@@ -197,6 +197,16 @@ public class TestAuthMappers {
Assertions.assertEquals(beforeUpdatedAt, afterUpdatedAt);
}
+ @Test
+ void testRoleDeleteUsesCurrentVersion() {
+ insertMetalake(1L, "metalake1");
+ insertRole(14L, "role14", 1L);
+
+ Assertions.assertEquals(0, roleMetaMapper.softDeleteRoleMetaByRoleId(14L,
2L));
+
Assertions.assertNotNull(roleMetaMapper.selectRoleMetaByMetalakeIdAndName(1L,
"role14"));
+ Assertions.assertEquals(1, roleMetaMapper.softDeleteRoleMetaByRoleId(14L,
1L));
+ }
+
@Test
void testUserMetaTouchUpdatedAt() {
insertMetalake(1L, "metalake1");
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
index fb4a533b95..e4432034b8 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
@@ -75,6 +75,16 @@ public class TestOccWriteSupport {
assertInstanceOf(NoSuchEntityException.class, ex);
}
+ @Test
+ void testWriteFailureUsesExplicitEntityName() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema",
"model");
+ RuntimeException ex =
+ OccWriteSupport.writeFailure(
+ ident, Entity.EntityType.MODEL, ident.toString(), () -> null,
null, po -> true);
+
+ assertEquals("No such model entity: metalake.catalog.schema.model",
ex.getMessage());
+ }
+
@Test
void testWriteFailureReturnsOptimisticLockExceptionWhenMatch() {
NameIdentifier ident = NameIdentifier.of("metalake_test");
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestRoleMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestRoleMetaService.java
index 287b346413..ecc034918c 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestRoleMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestRoleMetaService.java
@@ -46,6 +46,7 @@ import org.apache.gravitino.authorization.Privileges;
import org.apache.gravitino.authorization.SecurableObject;
import org.apache.gravitino.authorization.SecurableObjects;
import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.BaseMetalake;
import org.apache.gravitino.meta.CatalogEntity;
@@ -58,8 +59,12 @@ import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.storage.RandomIdGenerator;
import org.apache.gravitino.storage.relational.TestJDBCBackend;
import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
import org.apache.gravitino.storage.relational.po.GroupPO;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
+import org.apache.gravitino.storage.relational.po.RolePO;
import org.apache.gravitino.storage.relational.po.UserPO;
import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -835,6 +840,196 @@ class TestRoleMetaService extends TestJDBCBackend {
Assertions.assertTrue(revokeMultipleRole.securableObjects().isEmpty());
}
+ @TestTemplate
+ void testConcurrentUpdateDoesNotChangeSecurableObjectsOnConflict() throws
IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "concurrent-role",
+ AUDIT_INFO,
+ "catalog");
+ RoleMetaService.getInstance().insertRole(role, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ RoleMetaService.getInstance()
+ .updateRole(
+ role.nameIdentifier(),
+ (RoleEntity oldRole) -> {
+ advanceRoleVersion(role.id());
+ List<SecurableObject> securableObjects =
+ Lists.newArrayList(oldRole.securableObjects());
+ securableObjects.add(
+ SecurableObjects.ofMetalake(
+ METALAKE_NAME,
Lists.newArrayList(Privileges.CreateTable.allow())));
+ return RoleEntity.builder()
+ .withId(oldRole.id())
+ .withName(oldRole.name())
+ .withNamespace(oldRole.namespace())
+ .withProperties(oldRole.properties())
+ .withSecurableObjects(securableObjects)
+ .withAuditInfo(oldRole.auditInfo())
+ .build();
+ }));
+
+ RoleEntity storedRole =
+
RoleMetaService.getInstance().getRoleByIdentifier(role.nameIdentifier());
+ assertTrue(
+ CollectionUtils.isEqualCollection(
+ Lists.newArrayList(
+ SecurableObjects.ofCatalog(
+ "catalog",
Lists.newArrayList(Privileges.UseCatalog.allow()))),
+ storedRole.securableObjects()));
+ }
+
+ @TestTemplate
+ void testCreateLocksMetalakeWithoutChangingVersion() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleMetaService service = RoleMetaService.getInstance();
+ MetalakePO beforeCreate = getMetalakePO();
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "fenced-role",
+ AUDIT_INFO,
+ "catalog");
+
+ service.insertRole(role, false);
+
+ MetalakePO afterCreate = getMetalakePO();
+ assertEquals(beforeCreate.getCurrentVersion(),
afterCreate.getCurrentVersion());
+ assertEquals(beforeCreate.getLastVersion(), afterCreate.getLastVersion());
+
+ RoleEntity duplicate =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ role.name(),
+ AUDIT_INFO,
+ "catalog");
+ Assertions.assertThrows(
+ EntityAlreadyExistsException.class, () ->
service.insertRole(duplicate, false));
+
+ MetalakePO afterFailedCreate = getMetalakePO();
+ assertEquals(afterCreate.getCurrentVersion(),
afterFailedCreate.getCurrentVersion());
+ assertEquals(afterCreate.getLastVersion(),
afterFailedCreate.getLastVersion());
+ }
+
+ @TestTemplate
+ void testOverwriteInsertAdvancesVersion() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleMetaService service = RoleMetaService.getInstance();
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "overwrite-role",
+ AUDIT_INFO,
+ "catalog");
+ service.insertRole(role, false);
+ RolePO initialPO = getRolePO(role.name());
+
+ service.insertRole(role, true);
+
+ RolePO overwrittenPO = getRolePO(role.name());
+ assertEquals(initialPO.getCurrentVersion() + 1,
overwrittenPO.getCurrentVersion());
+ assertEquals(overwrittenPO.getCurrentVersion(),
overwrittenPO.getLastVersion());
+ int staleDelete =
+ SessionUtils.doWithCommitAndFetchResult(
+ RoleMetaMapper.class,
+ mapper -> mapper.softDeleteRoleMetaByRoleId(role.id(),
initialPO.getCurrentVersion()));
+ assertEquals(0, staleDelete);
+ }
+
+ @TestTemplate
+ void testMetadataOnlyUpdateUsesOcc() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleMetaService service = RoleMetaService.getInstance();
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "metadata-only-role",
+ AUDIT_INFO,
+ "catalog");
+ service.insertRole(role, false);
+ RolePO beforeUpdate = getRolePO(role.name());
+
+ service.updateRole(role.nameIdentifier(), (RoleEntity oldRole) ->
copyRole(oldRole, "value-1"));
+
+ RolePO afterUpdate = getRolePO(role.name());
+ assertEquals(beforeUpdate.getCurrentVersion() + 1,
afterUpdate.getCurrentVersion());
+ assertEquals(
+ "value-1",
service.getRoleByIdentifier(role.nameIdentifier()).properties().get("key"));
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateRole(
+ role.nameIdentifier(),
+ (RoleEntity oldRole) -> {
+ advanceRoleVersion(role.id());
+ return copyRole(oldRole, "value-2");
+ }));
+ assertEquals(
+ "value-1",
service.getRoleByIdentifier(role.nameIdentifier()).properties().get("key"));
+ }
+
+ @TestTemplate
+ void testStaleDeleteReportsConflict() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleMetaService service = RoleMetaService.getInstance();
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "stale-delete-role",
+ AUDIT_INFO,
+ "catalog");
+ service.insertRole(role, false);
+ RolePO staleRolePO = getRolePO(role.name());
+ advanceRoleVersion(role.id());
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> service.deleteRoleWithVersion(role.nameIdentifier(),
staleRolePO));
+ assertEquals(role.id(),
service.getRoleByIdentifier(role.nameIdentifier()).id());
+ }
+
+ @TestTemplate
+ void testAlterReportsNoSuchWhenRoleIsDeletedConcurrently() throws
IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ createAndInsertCatalog(METALAKE_NAME, "catalog");
+ RoleMetaService service = RoleMetaService.getInstance();
+ RoleEntity role =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+ "deleted-during-alter",
+ AUDIT_INFO,
+ "catalog");
+ service.insertRole(role, false);
+
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ service.updateRole(
+ role.nameIdentifier(),
+ (RoleEntity oldRole) -> {
+ service.deleteRole(role.nameIdentifier());
+ return copyRole(oldRole, "ignored-value");
+ }));
+ }
+
@TestTemplate
void testDeleteMetalakeCascade() throws IOException {
BaseMetalake metalake = createAndInsertMakeLake(METALAKE_NAME);
@@ -1059,6 +1254,29 @@ class TestRoleMetaService extends TestJDBCBackend {
return count;
}
+ private MetalakePO getMetalakePO() {
+ return SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class, mapper ->
mapper.selectMetalakeMetaByName(METALAKE_NAME));
+ }
+
+ private RolePO getRolePO(String roleName) {
+ MetalakePO metalakePO = getMetalakePO();
+ return SessionUtils.getWithoutCommit(
+ RoleMetaMapper.class,
+ mapper ->
mapper.selectRoleMetaByMetalakeIdAndName(metalakePO.getMetalakeId(), roleName));
+ }
+
+ private RoleEntity copyRole(RoleEntity role, String propertyValue) {
+ return RoleEntity.builder()
+ .withId(role.id())
+ .withName(role.name())
+ .withNamespace(role.namespace())
+ .withProperties(ImmutableMap.of("key", propertyValue))
+ .withSecurableObjects(role.securableObjects())
+ .withAuditInfo(role.auditInfo())
+ .build();
+ }
+
private Integer countUserRoleRels() {
int count = 0;
try (SqlSession sqlSession =
@@ -1127,4 +1345,19 @@ class TestRoleMetaService extends TestJDBCBackend {
}
return count;
}
+
+ private void advanceRoleVersion(long roleId) {
+ try (SqlSession sqlSession =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ Connection connection = sqlSession.getConnection();
+ Statement statement = connection.createStatement()) {
+ assertEquals(
+ 1,
+ statement.executeUpdate(
+ "UPDATE role_meta SET current_version = current_version + 1
WHERE role_id = "
+ + roleId));
+ } catch (SQLException e) {
+ throw new RuntimeException("Advance role version failed", e);
+ }
+ }
}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index afb28a80f9..bce2ac19d8 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -55,6 +55,7 @@ import org.apache.gravitino.meta.GroupEntity;
import org.apache.gravitino.meta.ModelEntity;
import org.apache.gravitino.meta.ModelVersionEntity;
import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.meta.RoleEntity;
import org.apache.gravitino.meta.SchemaEntity;
import org.apache.gravitino.meta.SchemaVersion;
import org.apache.gravitino.meta.StatisticEntity;
@@ -89,6 +90,7 @@ import
org.apache.gravitino.storage.relational.po.ModelVersionPO;
import org.apache.gravitino.storage.relational.po.OwnerRelPO;
import org.apache.gravitino.storage.relational.po.PolicyPO;
import org.apache.gravitino.storage.relational.po.PolicyVersionPO;
+import org.apache.gravitino.storage.relational.po.RolePO;
import org.apache.gravitino.storage.relational.po.SchemaPO;
import org.apache.gravitino.storage.relational.po.SecurableObjectPO;
import org.apache.gravitino.storage.relational.po.StatisticPO;
@@ -765,6 +767,32 @@ public class TestPOConverters {
assertEquals("test", updatePO.getTableName());
}
+ @Test
+ public void testUpdateRolePOVersionUsesCurrentVersion() {
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("creator").withCreateTime(FIX_INSTANT).build();
+ RoleEntity role =
+
RoleEntity.builder().withId(1L).withName("role").withAuditInfo(auditInfo).build();
+ RolePO initialRolePO =
+ POConverters.initializeRolePOWithVersion(role,
RolePO.builder().withMetalakeId(1L));
+ RolePO rolePO =
+ RolePO.builder()
+ .withRoleId(initialRolePO.getRoleId())
+ .withRoleName(initialRolePO.getRoleName())
+ .withMetalakeId(initialRolePO.getMetalakeId())
+ .withProperties(initialRolePO.getProperties())
+ .withAuditInfo(initialRolePO.getAuditInfo())
+ .withCurrentVersion(7L)
+ .withLastVersion(3L)
+ .withDeletedAt(initialRolePO.getDeletedAt())
+ .build();
+
+ RolePO updatedRolePO = POConverters.updateRolePOWithVersion(rolePO, role);
+
+ assertEquals(8, updatedRolePO.getCurrentVersion());
+ assertEquals(8, updatedRolePO.getLastVersion());
+ }
+
@Test
public void testUpdateFilesetPOVersion() throws JsonProcessingException {
Map<String, String> properties = new HashMap<>();