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 1b2b9c0735 [#12738] improvement(core): add OCC for group writes
(#12745)
1b2b9c0735 is described below
commit 1b2b9c0735d35e7da5b8c8642ae698d83eef894e
Author: Qi Yu <[email protected]>
AuthorDate: Tue Sep 1 11:00:25 2026 +0800
[#12738] improvement(core): add OCC for group writes (#12745)
### What changes were proposed in this pull request?
Add version-CAS optimistic concurrency control and atomic service
operations for managed groups.
- Increment the group version on every update and match updates by group
ID, expected version, and active-row state.
- Make soft deletes version checked and classify a zero-row result as
either a missing group or an OCC conflict.
- Execute the group CAS before group-role and ownership mutations in one
transaction.
- Fence the parent metalake while creating a group and advance the
version on overwrite upserts.
- Add coverage for metadata-only and ID updates, stale deletes,
overwrite versioning, parent fencing, and relationship rollback.
### Why are the changes needed?
Concurrent group mutations could otherwise overwrite each other or leave
partially updated relationship state.
Fix: #12738
### Does this PR introduce _any_ user-facing change?
Concurrent managed-group 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:test --tests TestGroupMetaService --tests
TestAuthMappers --tests TestPOConverters -PskipITs
-PskipDockerTests=true`
- `./gradlew :core:spotlessCheck :core:compileTestJava`
- Added a converter regression where `currentVersion` and `lastVersion`
differ.
- GitHub Backend Integration Test matrix: H2, MySQL, and PostgreSQL.
---
.../storage/relational/mapper/GroupMetaMapper.java | 12 +-
.../mapper/GroupMetaSQLProviderFactory.java | 10 +-
.../provider/base/GroupMetaBaseSQLProvider.java | 27 ++-
.../postgresql/GroupMetaPostgreSQLProvider.java | 14 +-
.../relational/service/GroupMetaService.java | 224 ++++++++++++++----
.../storage/relational/utils/POConverters.java | 5 +-
.../mapper/provider/base/TestAuthMappers.java | 12 +-
.../relational/service/TestGroupMetaService.java | 250 ++++++++++++++++++++-
.../storage/relational/utils/TestPOConverters.java | 28 +++
9 files changed, 507 insertions(+), 75 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
index 8c30152d25..16bec4d7d2 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
@@ -53,6 +53,10 @@ public interface GroupMetaMapper {
GroupPO selectGroupMetaByMetalakeIdAndName(
@Param("metalakeId") Long metalakeId, @Param("groupName") String name);
+ /** Returns and locks an active group by ID for the current transaction. */
+ @SelectProvider(type = GroupMetaSQLProviderFactory.class, method =
"selectGroupMetaByIdForUpdate")
+ GroupPO selectGroupMetaByIdForUpdate(@Param("groupId") Long groupId);
+
@SelectProvider(
type = GroupMetaSQLProviderFactory.class,
method = "listExtendedGroupPOsByMetalakeIdAndNames")
@@ -88,8 +92,14 @@ public interface GroupMetaMapper {
method = "insertGroupMetaOnDuplicateKeyUpdate")
void insertGroupMetaOnDuplicateKeyUpdate(@Param("groupMeta") GroupPO
groupPO);
+ /**
+ * Soft-deletes an active group only when its OCC version still matches.
+ *
+ * @return the number of deleted rows
+ */
@UpdateProvider(type = GroupMetaSQLProviderFactory.class, method =
"softDeleteGroupMetaByGroupId")
- void softDeleteGroupMetaByGroupId(@Param("groupId") Long groupId);
+ Integer softDeleteGroupMetaByGroupId(
+ @Param("groupId") Long groupId, @Param("currentVersion") Long
currentVersion);
@UpdateProvider(
type = GroupMetaSQLProviderFactory.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
index 9fa4eece50..a938a5e5ed 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
@@ -59,6 +59,11 @@ public class GroupMetaSQLProviderFactory {
return getProvider().selectGroupMetaByMetalakeIdAndName(metalakeId, name);
}
+ /** Returns SQL that selects and locks an active group by ID. */
+ public static String selectGroupMetaByIdForUpdate(@Param("groupId") Long
groupId) {
+ return getProvider().selectGroupMetaByIdForUpdate(groupId);
+ }
+
public static String listExtendedGroupPOsByMetalakeIdAndNames(
@Param("metalakeId") Long metalakeId, @Param("groupNames") List<String>
groupNames) {
return getProvider().listExtendedGroupPOsByMetalakeIdAndNames(metalakeId,
groupNames);
@@ -72,8 +77,9 @@ public class GroupMetaSQLProviderFactory {
return getProvider().insertGroupMetaOnDuplicateKeyUpdate(groupPO);
}
- public static String softDeleteGroupMetaByGroupId(@Param("groupId") Long
groupId) {
- return getProvider().softDeleteGroupMetaByGroupId(groupId);
+ public static String softDeleteGroupMetaByGroupId(
+ @Param("groupId") Long groupId, @Param("currentVersion") Long
currentVersion) {
+ return getProvider().softDeleteGroupMetaByGroupId(groupId, currentVersion);
}
public static String softDeleteGroupMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
index 852e48cc42..cbc1a6a8d3 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
@@ -138,6 +138,17 @@ public class GroupMetaBaseSQLProvider {
+ " AND deleted_at = 0";
}
+ /** Returns SQL that selects and locks an active group by ID. */
+ public String selectGroupMetaByIdForUpdate(@Param("groupId") Long groupId) {
+ return "SELECT group_id as groupId, group_name as groupName,"
+ + " metalake_id as metalakeId, external_id as externalId, audit_info
as auditInfo,"
+ + " current_version as currentVersion, last_version as lastVersion,"
+ + " deleted_at as deletedAt"
+ + " FROM "
+ + GROUP_TABLE_NAME
+ + " WHERE group_id = #{groupId} AND deleted_at = 0 FOR UPDATE";
+ }
+
public String selectGroupMetaByMetalakeNameAndExternalId(
@Param("metalakeName") String metalakeName, @Param("externalId") String
externalId) {
return "SELECT gt.group_id as groupId, gt.group_name as groupName,"
@@ -245,17 +256,21 @@ public class GroupMetaBaseSQLProvider {
+ " metalake_id = #{groupMeta.metalakeId},"
+ " audit_info = #{groupMeta.auditInfo},"
+ " external_id = #{groupMeta.externalId},"
- + " current_version = #{groupMeta.currentVersion},"
- + " last_version = #{groupMeta.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 = #{groupMeta.deletedAt}";
}
- public String softDeleteGroupMetaByGroupId(@Param("groupId") Long groupId) {
+ public String softDeleteGroupMetaByGroupId(
+ @Param("groupId") Long groupId, @Param("currentVersion") Long
currentVersion) {
return "UPDATE "
+ GROUP_TABLE_NAME
+ " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+ " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
- + " WHERE group_id = #{groupId} AND deleted_at = 0";
+ + " WHERE group_id = #{groupId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
public String softDeleteGroupMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
@@ -278,11 +293,7 @@ public class GroupMetaBaseSQLProvider {
+ " last_version = #{newGroupMeta.lastVersion},"
+ " deleted_at = #{newGroupMeta.deletedAt}"
+ " WHERE group_id = #{oldGroupMeta.groupId}"
- + " AND group_name = #{oldGroupMeta.groupName}"
- + " AND metalake_id = #{oldGroupMeta.metalakeId}"
- + " AND audit_info = #{oldGroupMeta.auditInfo}"
+ " AND current_version = #{oldGroupMeta.currentVersion}"
- + " AND last_version = #{oldGroupMeta.lastVersion}"
+ " AND deleted_at = 0";
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
index 450e26cef2..a5cef46358 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
@@ -30,11 +30,12 @@ import org.apache.ibatis.annotations.Param;
public class GroupMetaPostgreSQLProvider extends GroupMetaBaseSQLProvider {
@Override
- public String softDeleteGroupMetaByGroupId(Long groupId) {
+ public String softDeleteGroupMetaByGroupId(Long groupId, Long
currentVersion) {
return "UPDATE "
+ GROUP_TABLE_NAME
+ " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
- + " WHERE group_id = #{groupId} AND deleted_at = 0";
+ + " WHERE group_id = #{groupId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
@Override
@@ -67,8 +68,13 @@ public class GroupMetaPostgreSQLProvider extends
GroupMetaBaseSQLProvider {
+ " metalake_id = #{groupMeta.metalakeId},"
+ " external_id = #{groupMeta.externalId},"
+ " audit_info = #{groupMeta.auditInfo},"
- + " current_version = #{groupMeta.currentVersion},"
- + " last_version = #{groupMeta.lastVersion},"
+ // PostgreSQL requires the stored-row column to be qualified in ON
CONFLICT assignments.
+ + " current_version = "
+ + GROUP_TABLE_NAME
+ + ".current_version + 1,"
+ + " last_version = "
+ + GROUP_TABLE_NAME
+ + ".current_version + 1,"
+ " deleted_at = #{groupMeta.deletedAt}";
}
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 1229fc0c49..4902fa181c 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
@@ -29,6 +29,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
@@ -43,10 +44,12 @@ import org.apache.gravitino.meta.RoleEntity;
import org.apache.gravitino.metrics.Monitored;
import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
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.po.ExtendedGroupPO;
import org.apache.gravitino.storage.relational.po.GroupPO;
import org.apache.gravitino.storage.relational.po.GroupRoleRelPO;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
import org.apache.gravitino.storage.relational.po.RolePO;
import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
import org.apache.gravitino.storage.relational.utils.POConverters;
@@ -172,24 +175,34 @@ public class GroupMetaService {
NameIdentifier metalakeIdent =
NameIdentifier.of(NameIdentifierUtil.getMetalake(groupEntity.nameIdentifier()));
- Long metalakeId = EntityIdService.getEntityId(metalakeIdent,
Entity.EntityType.METALAKE);
-
- GroupPO.Builder builder = GroupPO.builder().withMetalakeId(metalakeId);
- GroupPO GroupPO = POConverters.initializeGroupPOWithVersion(groupEntity,
builder);
+ MetalakePO metalakePO =
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper -> mapper.selectMetalakeMetaByName(metalakeIdent.name()));
+ if (metalakePO == null) {
+ throw new NoSuchEntityException(
+ NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+ Entity.EntityType.METALAKE.name().toLowerCase(),
+ metalakeIdent.name());
+ }
+
+ GroupPO.Builder builder =
GroupPO.builder().withMetalakeId(metalakePO.getMetalakeId());
+ GroupPO groupPO = POConverters.initializeGroupPOWithVersion(groupEntity,
builder);
List<Long> roleIds =
Optional.ofNullable(groupEntity.roleIds()).orElse(Lists.newArrayList());
List<GroupRoleRelPO> groupRoleRelPOS =
POConverters.initializeGroupRoleRelsPOWithVersion(groupEntity,
roleIds);
SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeForGroupCreate(metalakePO),
() ->
SessionUtils.doWithoutCommit(
GroupMetaMapper.class,
mapper -> {
if (overwritten) {
- mapper.insertGroupMetaOnDuplicateKeyUpdate(GroupPO);
+ mapper.insertGroupMetaOnDuplicateKeyUpdate(groupPO);
} else {
- mapper.insertGroupMeta(GroupPO);
+ mapper.insertGroupMeta(groupPO);
}
}),
() -> {
@@ -215,12 +228,35 @@ public class GroupMetaService {
public boolean deleteGroup(NameIdentifier identifier) {
AuthorizationUtils.checkGroup(identifier);
- Long groupId = EntityIdService.getEntityId(identifier,
Entity.EntityType.GROUP);
+ Long metalakeId =
+
MetalakeMetaService.getInstance().getMetalakeIdByName(identifier.namespace().level(0));
+ GroupPO groupPO = getGroupPOByMetalakeIdAndName(metalakeId,
identifier.name());
+ deleteGroupWithVersion(identifier, groupPO);
+ return true;
+ }
+ /**
+ * Deletes the group whose version matches {@code observedGroupPO}, 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 #deleteGroup(NameIdentifier)}, which reads
the row first.
+ *
+ * @param identifier the group being deleted, used only to build the error
+ * @param observedGroupPO the group row the caller observed, carrying the
version to match
+ */
+ void deleteGroupWithVersion(NameIdentifier identifier, GroupPO
observedGroupPO) {
+ Long groupId = observedGroupPO.getGroupId();
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- GroupMetaMapper.class, mapper ->
mapper.softDeleteGroupMetaByGroupId(groupId)),
+ () -> {
+ int deleted =
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.softDeleteGroupMetaByGroupId(
+ groupId, observedGroupPO.getCurrentVersion()));
+ if (deleted == 0) {
+ throw groupWriteFailure(identifier, observedGroupPO,
GroupLookup.NAME);
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
GroupRoleRelMapper.class,
@@ -231,7 +267,6 @@ public class GroupMetaService {
mapper ->
mapper.softDeleteOwnerRelByOwnerIdAndType(
groupId, Entity.EntityType.GROUP.name())));
- return true;
}
@Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "updateGroup")
@@ -265,18 +300,24 @@ public class GroupMetaService {
Set<Long> insertRoleIds = Sets.difference(newRoleIds, oldRoleIds);
Set<Long> deleteRoleIds = Sets.difference(oldRoleIds, newRoleIds);
- if (insertRoleIds.isEmpty() && deleteRoleIds.isEmpty()) {
- return newEntity;
- }
+ // Every update runs the compare-and-set, including one that leaves the
roles 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.
try {
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- GroupMetaMapper.class,
- mapper ->
- mapper.updateGroupMeta(
- POConverters.updateGroupPOWithVersion(oldGroupPO,
newEntity),
- oldGroupPO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.updateGroupMeta(
+ POConverters.updateGroupPOWithVersion(oldGroupPO,
newEntity),
+ oldGroupPO));
+ if (updated == 0) {
+ throw groupWriteFailure(identifier, oldGroupPO,
GroupLookup.NAME);
+ }
+ },
() -> {
if (insertRoleIds.isEmpty()) {
return;
@@ -440,13 +481,20 @@ public class GroupMetaService {
try {
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- GroupMetaMapper.class,
- mapper ->
- mapper.updateGroupMeta(
- POConverters.updateGroupPOWithVersion(oldGroupPO,
newEntity),
- oldGroupPO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.updateGroupMeta(
+ POConverters.updateGroupPOWithVersion(oldGroupPO,
newEntity),
+ oldGroupPO));
+ if (updated == 0) {
+ NameIdentifier groupIdIdentifier =
+ AuthorizationUtils.ofGroup(metalake,
String.valueOf(groupId));
+ throw groupWriteFailure(groupIdIdentifier, oldGroupPO,
GroupLookup.ID);
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
GroupMetaMapper.class,
@@ -463,27 +511,45 @@ public class GroupMetaService {
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteGroupById")
public boolean deleteGroupById(String metalake, long groupId) {
+ GroupPO groupPO;
try {
- getGroupPOByMetalakeNameAndId(metalake, groupId);
+ groupPO = getGroupPOByMetalakeNameAndId(metalake, groupId);
} catch (NoSuchEntityException e) {
return false;
}
+ NameIdentifier identifier = AuthorizationUtils.ofGroup(metalake,
groupPO.getGroupName());
+
+ // Starts false so that any path that does not reach the child cleanup
reports "nothing was
+ // deleted here" rather than claiming a delete it did not perform.
+ AtomicBoolean deletedGroup = new AtomicBoolean(false);
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- GroupMetaMapper.class, mapper ->
mapper.softDeleteGroupMetaByGroupId(groupId)),
- () ->
- SessionUtils.doWithoutCommit(
- GroupRoleRelMapper.class,
- mapper -> mapper.softDeleteGroupRoleRelByGroupId(groupId)),
- () ->
- SessionUtils.doWithoutCommit(
- OwnerMetaMapper.class,
- mapper ->
- mapper.softDeleteOwnerRelByOwnerIdAndType(
- groupId, Entity.EntityType.GROUP.name())));
- return true;
+ () -> {
+ int deleted =
+ SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.softDeleteGroupMetaByGroupId(groupId,
groupPO.getCurrentVersion()));
+ if (deleted == 0) {
+ // The compare-and-set matched no row for one of two reasons.
Either the row is already
+ // gone, and a delete that has nothing left to delete is a no-op
rather than an error,
+ // or the row is still there under a newer version, which is a
genuine conflict.
+ if (getGroupPOByIdForUpdate(groupId) == null) {
+ return;
+ }
+ throw
ExceptionUtils.concurrentModification(Entity.EntityType.GROUP, identifier);
+ }
+
+ deletedGroup.set(true);
+ SessionUtils.doWithoutCommit(
+ GroupRoleRelMapper.class, mapper ->
mapper.softDeleteGroupRoleRelByGroupId(groupId));
+ SessionUtils.doWithoutCommit(
+ OwnerMetaMapper.class,
+ mapper ->
+ mapper.softDeleteOwnerRelByOwnerIdAndType(
+ groupId, Entity.EntityType.GROUP.name()));
+ });
+ return deletedGroup.get();
}
@Monitored(
@@ -523,4 +589,76 @@ public class GroupMetaService {
.collect(Collectors.toList());
return new PagedResult<>(totalCount, groups);
}
+
+ /**
+ * Holds the parent metalake row for the rest of the transaction, so the
group cannot be created
+ * under a metalake that is going away.
+ *
+ * <p>The lock is shared, not exclusive: many groups 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 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());
+ }
+ }
+
+ private RuntimeException groupWriteFailure(
+ NameIdentifier identifier, GroupPO observedGroupPO, GroupLookup lookup) {
+ // 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.
+ 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);
+ }
+
+ private GroupPO getGroupPOByIdForUpdate(long groupId) {
+ return SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class, mapper ->
mapper.selectGroupMetaByIdForUpdate(groupId));
+ }
+
+ /**
+ * How the caller addressed the group, which decides what counts as "the
same group" when a failed
+ * compare-and-set is classified. A caller that used the name is looking for
that name, so a
+ * rename means the group it asked for is gone. A caller that used the ID
addressed the row
+ * itself, so a rename leaves it addressing the same group and only the
metalake has to still
+ * match.
+ */
+ private enum GroupLookup {
+ NAME,
+ ID
+ }
}
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 c05cd50d9b..45390dc926 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
@@ -1243,10 +1243,7 @@ public class POConverters {
* @return GroupPO object with updated version
*/
public static GroupPO updateGroupPOWithVersion(GroupPO oldGroupPO,
GroupEntity newGroup) {
- Long lastVersion = oldGroupPO.getLastVersion();
- // TODO: set the version to the last version + 1 when having some fields
need be multiple
- // version
- Long nextVersion = lastVersion;
+ Long nextVersion = oldGroupPO.getCurrentVersion() + 1;
try {
return GroupPO.builder()
.withGroupId(oldGroupPO.getGroupId())
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 550ad3c54c..86bfd5026c 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
@@ -264,7 +264,7 @@ public class TestAuthMappers {
void testGroupMetaTouchUpdatedAtSkipsSoftDeleted() {
insertMetalake(1L, "metalake1");
insertGroup(31L, "group31", 1L);
- groupMetaMapper.softDeleteGroupMetaByGroupId(31L);
+ groupMetaMapper.softDeleteGroupMetaByGroupId(31L, 1L);
long beforeUpdatedAt = queryUpdatedAt("group_meta", "group_id", 31L);
groupMetaMapper.touchGroupUpdatedAt(31L);
@@ -287,6 +287,16 @@ public class TestAuthMappers {
Assertions.assertEquals(expected, info.getUpdatedAt());
}
+ @Test
+ void testGroupDeleteUsesCurrentVersion() {
+ insertMetalake(1L, "metalake1");
+ insertGroup(33L, "group33", 1L);
+
+ Assertions.assertEquals(0,
groupMetaMapper.softDeleteGroupMetaByGroupId(33L, 2L));
+
Assertions.assertNotNull(groupMetaMapper.selectGroupMetaByMetalakeIdAndName(1L,
"group33"));
+ Assertions.assertEquals(1,
groupMetaMapper.softDeleteGroupMetaByGroupId(33L, 1L));
+ }
+
@Test
void testUserDeleteUsesCurrentVersion() {
insertMetalake(1L, "metalake1");
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
index e14f91a4c3..90f850c709 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
@@ -44,6 +44,7 @@ import org.apache.gravitino.Namespace;
import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.PagedResult;
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.GroupEntity;
@@ -51,7 +52,10 @@ import org.apache.gravitino.meta.RoleEntity;
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.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.auth.GroupUpdatedAt;
import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
@@ -709,8 +713,8 @@ class TestGroupMetaService extends TestJDBCBackend {
Assertions.assertEquals("creator", grantRevokeGroup.auditInfo().creator());
Assertions.assertEquals("grantRevokeUser",
grantRevokeGroup.auditInfo().lastModifier());
- // no update
- Function<GroupEntity, GroupEntity> noUpdater =
+ // metadata-only update
+ Function<GroupEntity, GroupEntity> metadataUpdater =
group -> {
AuditInfo updateAuditInfo =
AuditInfo.builder()
@@ -732,19 +736,20 @@ class TestGroupMetaService extends TestJDBCBackend {
.withAuditInfo(updateAuditInfo)
.build();
};
- long beforeNoUpdate = getGroupUpdatedAt(group1.name()).getUpdatedAt();
-
Assertions.assertNotNull(groupMetaService.updateGroup(group1.nameIdentifier(),
noUpdater));
- Assertions.assertEquals(beforeNoUpdate,
getGroupUpdatedAt(group1.name()).getUpdatedAt());
- GroupEntity noUpdaterGroup =
+ long beforeMetadataUpdate =
getGroupUpdatedAt(group1.name()).getUpdatedAt();
+ Assertions.assertNotNull(
+ groupMetaService.updateGroup(group1.nameIdentifier(),
metadataUpdater));
+ Assertions.assertTrue(getGroupUpdatedAt(group1.name()).getUpdatedAt() >=
beforeMetadataUpdate);
+ GroupEntity metadataUpdatedGroup =
GroupMetaService.getInstance().getGroupByIdentifier(group1.nameIdentifier());
- Assertions.assertEquals(group1.id(), noUpdaterGroup.id());
- Assertions.assertEquals(group1.name(), noUpdaterGroup.name());
+ Assertions.assertEquals(group1.id(), metadataUpdatedGroup.id());
+ Assertions.assertEquals(group1.name(), metadataUpdatedGroup.name());
Assertions.assertEquals(
- Sets.newHashSet("role1", "role4"),
Sets.newHashSet(noUpdaterGroup.roleNames()));
+ Sets.newHashSet("role1", "role4"),
Sets.newHashSet(metadataUpdatedGroup.roleNames()));
Assertions.assertEquals(
- Sets.newHashSet(role1.id(), role4.id()),
Sets.newHashSet(noUpdaterGroup.roleIds()));
- Assertions.assertEquals("creator", noUpdaterGroup.auditInfo().creator());
- Assertions.assertEquals("grantRevokeUser",
noUpdaterGroup.auditInfo().lastModifier());
+ Sets.newHashSet(role1.id(), role4.id()),
Sets.newHashSet(metadataUpdatedGroup.roleIds()));
+ Assertions.assertEquals("creator",
metadataUpdatedGroup.auditInfo().creator());
+ Assertions.assertEquals("noUpdateUser",
metadataUpdatedGroup.auditInfo().lastModifier());
// Delete a role, the group entity won't contain this role.
RoleMetaService.getInstance().deleteRole(role1.nameIdentifier());
@@ -1081,6 +1086,64 @@ class TestGroupMetaService extends TestJDBCBackend {
IllegalArgumentException.class, () ->
svc.getGroupByExternalId(groupExtIdent("")));
}
+ @TestTemplate
+ void testConcurrentUpdateDoesNotChangeRolesOnConflict() throws IOException {
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, catalogName);
+ RoleEntity role1 =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(metalakeName),
+ "role1",
+ AUDIT_INFO,
+ catalogName);
+ RoleEntity role2 =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(metalakeName),
+ "role2",
+ AUDIT_INFO,
+ catalogName);
+ RoleMetaService.getInstance().insertRole(role1, false);
+ RoleMetaService.getInstance().insertRole(role2, false);
+ GroupEntity group =
+ createGroupEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofGroupNamespace(metalakeName),
+ "concurrent-group",
+ AUDIT_INFO,
+ Lists.newArrayList(role1.name()),
+ Lists.newArrayList(role1.id()));
+ GroupMetaService.getInstance().insertGroup(group, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ GroupMetaService.getInstance()
+ .updateGroup(
+ group.nameIdentifier(),
+ (GroupEntity oldGroup) -> {
+ advanceGroupVersion(group.id());
+ List<String> roleNames =
Lists.newArrayList(oldGroup.roleNames());
+ List<Long> roleIds =
Lists.newArrayList(oldGroup.roleIds());
+ roleNames.add(role2.name());
+ roleIds.add(role2.id());
+ return GroupEntity.builder()
+ .withId(oldGroup.id())
+ .withName(oldGroup.name())
+ .withNamespace(oldGroup.namespace())
+ .withExternalId(oldGroup.externalId())
+ .withRoleNames(roleNames)
+ .withRoleIds(roleIds)
+ .withAuditInfo(oldGroup.auditInfo())
+ .build();
+ }));
+
+ GroupEntity storedGroup =
+
GroupMetaService.getInstance().getGroupByIdentifier(group.nameIdentifier());
+ assertEquals(Sets.newHashSet(role1.id()),
Sets.newHashSet(storedGroup.roleIds()));
+ }
+
@TestTemplate
void testExtDup() throws IOException {
GroupMetaService svc = groupMetaService();
@@ -1090,6 +1153,130 @@ class TestGroupMetaService extends TestJDBCBackend {
() -> svc.insertGroup(groupWithExtId("g2", "ext-1"), false));
}
+ @TestTemplate
+ void testCreateLocksMetalakeWithoutChangingVersion() throws IOException {
+ createAndInsertMakeLake(metalakeName);
+ GroupMetaService service = GroupMetaService.getInstance();
+ MetalakePO beforeCreate = getMetalakePO();
+ GroupEntity group = groupWithExtId("fenced-group", "fenced-group-ext-id");
+
+ service.insertGroup(group, false);
+
+ MetalakePO afterCreate = getMetalakePO();
+ assertEquals(beforeCreate.getCurrentVersion(),
afterCreate.getCurrentVersion());
+ assertEquals(beforeCreate.getLastVersion(), afterCreate.getLastVersion());
+
+ GroupEntity duplicate = groupWithExtId(group.name(), "another-ext-id");
+ Assertions.assertThrows(
+ EntityAlreadyExistsException.class, () ->
service.insertGroup(duplicate, false));
+
+ MetalakePO afterFailedCreate = getMetalakePO();
+ assertEquals(afterCreate.getCurrentVersion(),
afterFailedCreate.getCurrentVersion());
+ assertEquals(afterCreate.getLastVersion(),
afterFailedCreate.getLastVersion());
+ }
+
+ @TestTemplate
+ void testOverwriteInsertAdvancesVersion() throws IOException {
+ GroupMetaService service = groupMetaService();
+ GroupEntity group = groupWithExtId("overwrite-group",
"overwrite-group-ext-id");
+ service.insertGroup(group, false);
+ GroupPO initialPO = getGroupPO(group.name());
+
+ service.insertGroup(group, true);
+
+ GroupPO overwrittenPO = getGroupPO(group.name());
+ assertEquals(initialPO.getCurrentVersion() + 1,
overwrittenPO.getCurrentVersion());
+ assertEquals(overwrittenPO.getCurrentVersion(),
overwrittenPO.getLastVersion());
+ int staleDelete =
+ SessionUtils.doWithCommitAndFetchResult(
+ GroupMetaMapper.class,
+ mapper ->
+ mapper.softDeleteGroupMetaByGroupId(group.id(),
initialPO.getCurrentVersion()));
+ assertEquals(0, staleDelete);
+ }
+
+ @TestTemplate
+ void testMetadataOnlyUpdateUsesOcc() throws IOException {
+ GroupMetaService service = groupMetaService();
+ GroupEntity group = groupWithExtId("metadata-only-group",
"metadata-only-ext-id");
+ service.insertGroup(group, false);
+ GroupPO beforeUpdate = getGroupPO(group.name());
+
+ service.updateGroup(
+ group.nameIdentifier(), (GroupEntity oldGroup) -> copyGroup(oldGroup,
"updated-ext-id"));
+
+ GroupPO afterUpdate = getGroupPO(group.name());
+ assertEquals(beforeUpdate.getCurrentVersion() + 1,
afterUpdate.getCurrentVersion());
+ assertEquals(
+ "updated-ext-id",
service.getGroupByIdentifier(group.nameIdentifier()).externalId());
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateGroup(
+ group.nameIdentifier(),
+ (GroupEntity oldGroup) -> {
+ advanceGroupVersion(group.id());
+ return copyGroup(oldGroup, "conflicting-ext-id");
+ }));
+ assertEquals(
+ "updated-ext-id",
service.getGroupByIdentifier(group.nameIdentifier()).externalId());
+ }
+
+ @TestTemplate
+ void testStaleDeleteReportsConflict() throws IOException {
+ GroupMetaService service = groupMetaService();
+ GroupEntity group = groupWithExtId("stale-delete-group",
"stale-delete-group-ext-id");
+ service.insertGroup(group, false);
+ GroupPO staleGroupPO = getGroupPO(group.name());
+ advanceGroupVersion(group.id());
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> service.deleteGroupWithVersion(group.nameIdentifier(),
staleGroupPO));
+ assertEquals(group.id(),
service.getGroupByIdentifier(group.nameIdentifier()).id());
+ }
+
+ @TestTemplate
+ void testAlterReportsNoSuchWhenGroupIsDeletedConcurrently() throws
IOException {
+ GroupMetaService service = groupMetaService();
+ GroupEntity group = groupWithExtId("deleted-during-alter",
"deleted-during-alter-ext-id");
+ service.insertGroup(group, false);
+
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ service.updateGroup(
+ group.nameIdentifier(),
+ (GroupEntity oldGroup) -> {
+ service.deleteGroup(group.nameIdentifier());
+ return copyGroup(oldGroup, "ignored-ext-id");
+ }));
+ }
+
+ @TestTemplate
+ void testByIdWritesUseOcc() throws IOException {
+ GroupMetaService service = groupMetaService();
+ GroupEntity group = groupWithExtId("by-id-group", "by-id-group-ext-id");
+ service.insertGroup(group, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateGroupById(
+ metalakeName,
+ group.id(),
+ (GroupEntity oldGroup) -> {
+ advanceGroupVersion(group.id());
+ return copyGroup(oldGroup, "conflicting-by-id-ext-id");
+ }));
+ assertEquals(
+ "by-id-group-ext-id",
service.getGroupByIdentifier(group.nameIdentifier()).externalId());
+
+ assertTrue(service.deleteGroupById(metalakeName, group.id()));
+ assertFalse(service.deleteGroupById(metalakeName, group.id()));
+ }
+
@TestTemplate
void testGroupExtDel() throws IOException {
GroupMetaService svc = groupMetaService();
@@ -1240,6 +1427,30 @@ class TestGroupMetaService extends TestJDBCBackend {
return GroupMetaService.getInstance();
}
+ private MetalakePO getMetalakePO() {
+ return SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class, mapper ->
mapper.selectMetalakeMetaByName(metalakeName));
+ }
+
+ private GroupPO getGroupPO(String groupName) {
+ MetalakePO metalakePO = getMetalakePO();
+ return SessionUtils.getWithoutCommit(
+ GroupMetaMapper.class,
+ mapper ->
mapper.selectGroupMetaByMetalakeIdAndName(metalakePO.getMetalakeId(),
groupName));
+ }
+
+ private GroupEntity copyGroup(GroupEntity group, String externalId) {
+ return GroupEntity.builder()
+ .withId(group.id())
+ .withName(group.name())
+ .withNamespace(group.namespace())
+ .withExternalId(externalId)
+ .withRoleNames(group.roleNames())
+ .withRoleIds(group.roleIds())
+ .withAuditInfo(group.auditInfo())
+ .build();
+ }
+
private void assertThrowsExt(Class<? extends Exception> type, Executable
executable) {
Assertions.assertThrows(type, executable);
}
@@ -1265,4 +1476,19 @@ class TestGroupMetaService extends TestJDBCBackend {
.withAuditInfo(auditInfo)
.build();
}
+
+ private void advanceGroupVersion(long groupId) {
+ try (SqlSession sqlSession =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ Connection connection = sqlSession.getConnection();
+ Statement statement = connection.createStatement()) {
+ assertEquals(
+ 1,
+ statement.executeUpdate(
+ "UPDATE group_meta SET current_version = current_version + 1
WHERE group_id = "
+ + groupId));
+ } catch (SQLException e) {
+ throw new RuntimeException("Advance group 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 8e1c563073..afb28a80f9 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
@@ -51,6 +51,7 @@ import org.apache.gravitino.meta.BaseMetalake;
import org.apache.gravitino.meta.CatalogEntity;
import org.apache.gravitino.meta.ColumnEntity;
import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.GroupEntity;
import org.apache.gravitino.meta.ModelEntity;
import org.apache.gravitino.meta.ModelVersionEntity;
import org.apache.gravitino.meta.PolicyEntity;
@@ -80,6 +81,7 @@ import org.apache.gravitino.storage.relational.po.CatalogPO;
import org.apache.gravitino.storage.relational.po.ColumnPO;
import org.apache.gravitino.storage.relational.po.FilesetPO;
import org.apache.gravitino.storage.relational.po.FilesetVersionPO;
+import org.apache.gravitino.storage.relational.po.GroupPO;
import org.apache.gravitino.storage.relational.po.MetalakePO;
import org.apache.gravitino.storage.relational.po.ModelPO;
import org.apache.gravitino.storage.relational.po.ModelVersionAliasRelPO;
@@ -843,6 +845,32 @@ public class TestPOConverters {
assertEquals(8, updatePO3.getFilesetVersionPOs().get(0).getVersion());
}
+ @Test
+ public void testUpdateGroupPOVersionUsesCurrentVersion() {
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("creator").withCreateTime(FIX_INSTANT).build();
+ GroupEntity group =
+
GroupEntity.builder().withId(2L).withName("group").withAuditInfo(auditInfo).build();
+ GroupPO initialGroupPO =
+ POConverters.initializeGroupPOWithVersion(group,
GroupPO.builder().withMetalakeId(1L));
+ GroupPO groupPO =
+ GroupPO.builder()
+ .withGroupId(initialGroupPO.getGroupId())
+ .withGroupName(initialGroupPO.getGroupName())
+ .withMetalakeId(initialGroupPO.getMetalakeId())
+ .withExternalId(initialGroupPO.getExternalId())
+ .withAuditInfo(initialGroupPO.getAuditInfo())
+ .withCurrentVersion(7L)
+ .withLastVersion(3L)
+ .withDeletedAt(initialGroupPO.getDeletedAt())
+ .build();
+
+ GroupPO updatedGroupPO = POConverters.updateGroupPOWithVersion(groupPO,
group);
+
+ assertEquals(8, updatedGroupPO.getCurrentVersion());
+ assertEquals(8, updatedGroupPO.getLastVersion());
+ }
+
@Test
public void testUpdateUserPOVersionUsesCurrentVersion() {
AuditInfo auditInfo =