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 ff3a9e7655 [#12739] improvement(core): add OCC for user writes (#12744)
ff3a9e7655 is described below
commit ff3a9e765522bd8c8b8e36efdd5c5dfae846c457
Author: Qi Yu <[email protected]>
AuthorDate: Tue Sep 1 08:52:11 2026 +0800
[#12739] improvement(core): add OCC for user writes (#12744)
### What changes were proposed in this pull request?
Add version-CAS optimistic concurrency control and atomic service
operations for managed users.
- Increment the user version on every update and match updates by user
ID, expected version, and active-row state.
- Make soft deletes version checked and classify a zero-row result as
either a missing user or an OCC conflict.
- Execute the user CAS before user-role and ownership mutations in one
transaction.
- Fence the parent metalake while creating a user and advance the
version on overwrite upserts.
- Add coverage for metadata-only updates, external-ID and ID updates,
stale deletes, overwrite versioning, parent fencing, and relationship
rollback.
### Why are the changes needed?
Concurrent user mutations could otherwise overwrite each other or leave
partially updated relationship state.
Fix: #12739
### Does this PR introduce _any_ user-facing change?
Concurrent managed-user 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 TestUserMetaService --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/UserMetaMapper.java | 12 +-
.../mapper/UserMetaSQLProviderFactory.java | 10 +-
.../provider/base/UserMetaBaseSQLProvider.java | 32 ++-
.../postgresql/UserMetaPostgreSQLProvider.java | 14 +-
.../relational/service/UserMetaService.java | 232 ++++++++++++++----
.../storage/relational/utils/POConverters.java | 7 +-
.../mapper/provider/base/TestAuthMappers.java | 12 +-
.../relational/service/TestUserMetaService.java | 272 ++++++++++++++++++++-
.../storage/relational/utils/TestPOConverters.java | 29 +++
9 files changed, 542 insertions(+), 78 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
index ff503326bb..dcf0800e26 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
@@ -54,6 +54,10 @@ public interface UserMetaMapper {
UserPO selectUserMetaByMetalakeIdAndName(
@Param("metalakeId") Long metalakeId, @Param("userName") String name);
+ /** Returns and locks an active user by ID for the current transaction. */
+ @SelectProvider(type = UserMetaSQLProviderFactory.class, method =
"selectUserMetaByIdForUpdate")
+ UserPO selectUserMetaByIdForUpdate(@Param("userId") Long userId);
+
@InsertProvider(type = UserMetaSQLProviderFactory.class, method =
"insertUserMeta")
void insertUserMeta(@Param("userMeta") UserPO userPO);
@@ -81,8 +85,14 @@ public interface UserMetaMapper {
method = "insertUserMetaOnDuplicateKeyUpdate")
void insertUserMetaOnDuplicateKeyUpdate(@Param("userMeta") UserPO userPO);
+ /**
+ * Soft-deletes an active user only when its OCC version still matches.
+ *
+ * @return the number of deleted rows
+ */
@UpdateProvider(type = UserMetaSQLProviderFactory.class, method =
"softDeleteUserMetaByUserId")
- void softDeleteUserMetaByUserId(@Param("userId") Long userId);
+ Integer softDeleteUserMetaByUserId(
+ @Param("userId") Long userId, @Param("currentVersion") Long
currentVersion);
@UpdateProvider(
type = UserMetaSQLProviderFactory.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
index 57b4afa3cb..ff40d94dfa 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
@@ -61,6 +61,11 @@ public class UserMetaSQLProviderFactory {
return getProvider().selectUserMetaByMetalakeIdAndName(metalakeId, name);
}
+ /** Returns SQL that selects and locks an active user by ID. */
+ public static String selectUserMetaByIdForUpdate(@Param("userId") Long
userId) {
+ return getProvider().selectUserMetaByIdForUpdate(userId);
+ }
+
public static String insertUserMeta(@Param("userMeta") UserPO userPO) {
return getProvider().insertUserMeta(userPO);
}
@@ -69,8 +74,9 @@ public class UserMetaSQLProviderFactory {
return getProvider().insertUserMetaOnDuplicateKeyUpdate(userPO);
}
- public static String softDeleteUserMetaByUserId(@Param("userId") Long
userId) {
- return getProvider().softDeleteUserMetaByUserId(userId);
+ public static String softDeleteUserMetaByUserId(
+ @Param("userId") Long userId, @Param("currentVersion") Long
currentVersion) {
+ return getProvider().softDeleteUserMetaByUserId(userId, currentVersion);
}
public static String softDeleteUserMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
index 387c5676e0..0b5b484f22 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
@@ -53,6 +53,17 @@ public class UserMetaBaseSQLProvider {
+ " AND deleted_at = 0";
}
+ /** Returns SQL that selects and locks an active user by ID. */
+ public String selectUserMetaByIdForUpdate(@Param("userId") Long userId) {
+ return "SELECT user_id as userId, user_name as userName,"
+ + " metalake_id as metalakeId, external_id as externalId, enabled as
enabled,"
+ + " audit_info as auditInfo, current_version as currentVersion,"
+ + " last_version as lastVersion, deleted_at as deletedAt"
+ + " FROM "
+ + USER_TABLE_NAME
+ + " WHERE user_id = #{userId} AND deleted_at = 0 FOR UPDATE";
+ }
+
public String selectUserMetaByMetalakeNameAndExternalId(
@Param("metalakeName") String metalakeName, @Param("externalId") String
externalId) {
return "SELECT ut.user_id as userId, ut.user_name as userName,"
@@ -99,11 +110,8 @@ public class UserMetaBaseSQLProvider {
+ " current_version = #{newUserMeta.currentVersion},"
+ " last_version = #{newUserMeta.lastVersion},"
+ " deleted_at = #{newUserMeta.deletedAt}"
- + " WHERE external_id = #{oldUserMeta.externalId}"
- + " AND metalake_id = #{oldUserMeta.metalakeId}"
- + " AND audit_info = #{oldUserMeta.auditInfo}"
+ + " WHERE user_id = #{oldUserMeta.userId}"
+ " AND current_version = #{oldUserMeta.currentVersion}"
- + " AND last_version = #{oldUserMeta.lastVersion}"
+ " AND deleted_at = 0";
}
@@ -147,17 +155,21 @@ public class UserMetaBaseSQLProvider {
+ " audit_info = #{userMeta.auditInfo},"
+ " external_id = #{userMeta.externalId},"
+ " enabled = #{userMeta.enabled},"
- + " current_version = #{userMeta.currentVersion},"
- + " last_version = #{userMeta.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 = #{userMeta.deletedAt}";
}
- public String softDeleteUserMetaByUserId(@Param("userId") Long userId) {
+ public String softDeleteUserMetaByUserId(
+ @Param("userId") Long userId, @Param("currentVersion") Long
currentVersion) {
return "UPDATE "
+ USER_TABLE_NAME
+ " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+ " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
- + " WHERE user_id = #{userId} AND deleted_at = 0";
+ + " WHERE user_id = #{userId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
public String softDeleteUserMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
@@ -181,11 +193,7 @@ public class UserMetaBaseSQLProvider {
+ " last_version = #{newUserMeta.lastVersion},"
+ " deleted_at = #{newUserMeta.deletedAt}"
+ " WHERE user_id = #{oldUserMeta.userId}"
- + " AND user_name = #{oldUserMeta.userName}"
- + " AND metalake_id = #{oldUserMeta.metalakeId}"
- + " AND audit_info = #{oldUserMeta.auditInfo}"
+ " AND current_version = #{oldUserMeta.currentVersion}"
- + " AND last_version = #{oldUserMeta.lastVersion}"
+ " AND deleted_at = 0";
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
index a454efa6c6..bfd53c73ef 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
@@ -29,11 +29,12 @@ import org.apache.ibatis.annotations.Param;
public class UserMetaPostgreSQLProvider extends UserMetaBaseSQLProvider {
@Override
- public String softDeleteUserMetaByUserId(Long userId) {
+ public String softDeleteUserMetaByUserId(Long userId, Long currentVersion) {
return "UPDATE "
+ USER_TABLE_NAME
+ " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000
AS BIGINT)"
- + " WHERE user_id = #{userId} AND deleted_at = 0";
+ + " WHERE user_id = #{userId}"
+ + " AND current_version = #{currentVersion} AND deleted_at = 0";
}
@Override
@@ -68,8 +69,13 @@ public class UserMetaPostgreSQLProvider extends
UserMetaBaseSQLProvider {
+ " external_id = #{userMeta.externalId},"
+ " enabled = #{userMeta.enabled},"
+ " audit_info = #{userMeta.auditInfo},"
- + " current_version = #{userMeta.currentVersion},"
- + " last_version = #{userMeta.lastVersion},"
+ // PostgreSQL requires the stored-row column to be qualified in ON
CONFLICT assignments.
+ + " current_version = "
+ + USER_TABLE_NAME
+ + ".current_version + 1,"
+ + " last_version = "
+ + USER_TABLE_NAME
+ + ".current_version + 1,"
+ " deleted_at = #{userMeta.deletedAt}";
}
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 3eacd9850f..2ba8d38c9e 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
@@ -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;
@@ -41,10 +42,12 @@ import
org.apache.gravitino.exceptions.NoSuchEntityException;
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.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserRoleRelMapper;
import org.apache.gravitino.storage.relational.po.ExtendedUserPO;
+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.po.UserRoleRelPO;
@@ -132,9 +135,18 @@ public class UserMetaService {
try {
AuthorizationUtils.checkUser(userEntity.nameIdentifier());
- Long metalakeId =
-
MetalakeMetaService.getInstance().getMetalakeIdByName(userEntity.namespace().level(0));
- UserPO.Builder builder = UserPO.builder().withMetalakeId(metalakeId);
+ String metalakeName = userEntity.namespace().level(0);
+ MetalakePO metalakePO =
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class, mapper ->
mapper.selectMetalakeMetaByName(metalakeName));
+ if (metalakePO == null) {
+ throw new NoSuchEntityException(
+ NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+ Entity.EntityType.METALAKE.name().toLowerCase(),
+ metalakeName);
+ }
+
+ UserPO.Builder builder =
UserPO.builder().withMetalakeId(metalakePO.getMetalakeId());
UserPO userPO = POConverters.initializeUserPOWithVersion(userEntity,
builder);
List<Long> roleIds =
Optional.ofNullable(userEntity.roleIds()).orElse(Lists.newArrayList());
@@ -142,6 +154,7 @@ public class UserMetaService {
POConverters.initializeUserRoleRelsPOWithVersion(userEntity,
roleIds);
SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeForUserCreate(metalakePO),
() ->
SessionUtils.doWithoutCommit(
UserMetaMapper.class,
@@ -175,12 +188,36 @@ public class UserMetaService {
public boolean deleteUser(NameIdentifier identifier) {
AuthorizationUtils.checkUser(identifier);
- Long userId = EntityIdService.getEntityId(identifier,
Entity.EntityType.USER);
+ Long metalakeId =
+
MetalakeMetaService.getInstance().getMetalakeIdByName(identifier.namespace().level(0));
+ UserPO userPO = getUserPOByMetalakeIdAndName(metalakeId,
identifier.name());
+
+ deleteUserWithVersion(identifier, userPO);
+ return true;
+ }
+ /**
+ * Deletes the user whose version matches {@code observedUserPO}, 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 #deleteUser(NameIdentifier)}, which reads the row
first.
+ *
+ * @param identifier the user being deleted, used only to build the error
+ * @param observedUserPO the user row the caller observed, carrying the
version to match
+ */
+ void deleteUserWithVersion(NameIdentifier identifier, UserPO observedUserPO)
{
+ Long userId = observedUserPO.getUserId();
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- UserMetaMapper.class, mapper ->
mapper.softDeleteUserMetaByUserId(userId)),
+ () -> {
+ int deleted =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
+ mapper.softDeleteUserMetaByUserId(
+ userId, observedUserPO.getCurrentVersion()));
+ if (deleted == 0) {
+ throw userWriteFailure(identifier, observedUserPO,
UserLookup.NAME);
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
UserRoleRelMapper.class, mapper ->
mapper.softDeleteUserRoleRelByUserId(userId)),
@@ -190,7 +227,6 @@ public class UserMetaService {
mapper ->
mapper.softDeleteOwnerRelByOwnerIdAndType(
userId, Entity.EntityType.USER.name())));
- return true;
}
@Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "updateUser")
@@ -221,18 +257,23 @@ public class UserMetaService {
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(
- UserMetaMapper.class,
- mapper ->
- mapper.updateUserMeta(
- POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
+ mapper.updateUserMeta(
+ POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO));
+ if (updated == 0) {
+ throw userWriteFailure(identifier, oldUserPO, UserLookup.NAME);
+ }
+ },
() -> {
if (insertRoleIds.isEmpty()) {
return;
@@ -371,12 +412,17 @@ public class UserMetaService {
try {
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- UserMetaMapper.class,
- mapper ->
- mapper.updateUserMetaByExternalId(
- POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
+ mapper.updateUserMetaByExternalId(
+ POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO));
+ if (updated == 0) {
+ throw userWriteFailure(ident, oldUserPO, UserLookup.EXTERNAL_ID);
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
UserMetaMapper.class,
@@ -430,12 +476,19 @@ public class UserMetaService {
try {
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- UserMetaMapper.class,
- mapper ->
- mapper.updateUserMeta(
- POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO)),
+ () -> {
+ int updated =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
+ mapper.updateUserMeta(
+ POConverters.updateUserPOWithVersion(oldUserPO,
newEntity), oldUserPO));
+ if (updated == 0) {
+ NameIdentifier userIdIdentifier =
+ AuthorizationUtils.ofUser(metalake, String.valueOf(userId));
+ throw userWriteFailure(userIdIdentifier, oldUserPO,
UserLookup.ID);
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
UserMetaMapper.class,
@@ -452,26 +505,42 @@ public class UserMetaService {
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteUserById")
public boolean deleteUserById(String metalake, long userId) {
+ UserPO userPO;
try {
- getUserPOByMetalakeNameAndId(metalake, userId);
+ userPO = getUserPOByMetalakeNameAndId(metalake, userId);
} catch (NoSuchEntityException e) {
return false;
}
+ NameIdentifier identifier = AuthorizationUtils.ofUser(metalake,
userPO.getUserName());
+ // 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 deletedUser = new AtomicBoolean(false);
SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- UserMetaMapper.class, mapper ->
mapper.softDeleteUserMetaByUserId(userId)),
- () ->
- SessionUtils.doWithoutCommit(
- UserRoleRelMapper.class, mapper ->
mapper.softDeleteUserRoleRelByUserId(userId)),
- () ->
- SessionUtils.doWithoutCommit(
- OwnerMetaMapper.class,
- mapper ->
- mapper.softDeleteOwnerRelByOwnerIdAndType(
- userId, Entity.EntityType.USER.name())));
- return true;
+ () -> {
+ int deleted =
+ SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper -> mapper.softDeleteUserMetaByUserId(userId,
userPO.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 (getUserPOByIdForUpdate(userId) == null) {
+ return;
+ }
+ throw
ExceptionUtils.concurrentModification(Entity.EntityType.USER, identifier);
+ }
+
+ deletedUser.set(true);
+ SessionUtils.doWithoutCommit(
+ UserRoleRelMapper.class, mapper ->
mapper.softDeleteUserRoleRelByUserId(userId));
+ SessionUtils.doWithoutCommit(
+ OwnerMetaMapper.class,
+ mapper ->
+ mapper.softDeleteOwnerRelByOwnerIdAndType(userId,
Entity.EntityType.USER.name()));
+ });
+ return deletedUser.get();
}
@Monitored(
@@ -511,4 +580,79 @@ public class UserMetaService {
.collect(Collectors.toList());
return new PagedResult<>(totalCount, users);
}
+
+ /**
+ * Holds the parent metalake row for the rest of the transaction, so the
user cannot be created
+ * under a metalake that is going away.
+ *
+ * <p>The lock is shared, not exclusive: many users 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 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());
+ }
+ }
+
+ private RuntimeException userWriteFailure(
+ NameIdentifier identifier, UserPO observedUserPO, UserLookup 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.
+ 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);
+ }
+
+ private UserPO getUserPOByIdForUpdate(long userId) {
+ return SessionUtils.getWithoutCommit(
+ UserMetaMapper.class, mapper ->
mapper.selectUserMetaByIdForUpdate(userId));
+ }
+
+ /**
+ * How the caller addressed the user, which decides what counts as "the same
user" when a failed
+ * compare-and-set is classified. A caller that used the name is looking for
that name, so a
+ * rename means the user it asked for is gone; the same holds for the
external ID. A caller that
+ * used the ID addressed the row itself, so a rename leaves it addressing
the same user and only
+ * the metalake has to still match.
+ */
+ private enum UserLookup {
+ NAME,
+ EXTERNAL_ID,
+ 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 889aa0d6c4..c05cd50d9b 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
@@ -960,14 +960,11 @@ public class POConverters {
* Update UserPO version
*
* @param oldUserPO the old UserPO object
- * @param newUser the new TableEntity object
+ * @param newUser the new UserEntity object
* @return UserPO object with updated version
*/
public static UserPO updateUserPOWithVersion(UserPO oldUserPO, UserEntity
newUser) {
- Long lastVersion = oldUserPO.getLastVersion();
- // TODO: set the version to the last version + 1 when having some fields
need be multiple
- // version
- Long nextVersion = lastVersion;
+ Long nextVersion = oldUserPO.getCurrentVersion() + 1;
try {
return UserPO.builder()
.withUserId(oldUserPO.getUserId())
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 e26613c790..550ad3c54c 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
@@ -219,7 +219,7 @@ public class TestAuthMappers {
void testUserMetaTouchUpdatedAtSkipsSoftDeleted() {
insertMetalake(1L, "metalake1");
insertUser(22L, "user22", 1L);
- userMetaMapper.softDeleteUserMetaByUserId(22L);
+ userMetaMapper.softDeleteUserMetaByUserId(22L, 1L);
long beforeUpdatedAt = queryUpdatedAt("user_meta", "user_id", 22L);
userMetaMapper.touchUserUpdatedAt(22L);
@@ -287,6 +287,16 @@ public class TestAuthMappers {
Assertions.assertEquals(expected, info.getUpdatedAt());
}
+ @Test
+ void testUserDeleteUsesCurrentVersion() {
+ insertMetalake(1L, "metalake1");
+ insertUser(23L, "user23", 1L);
+
+ Assertions.assertEquals(0, userMetaMapper.softDeleteUserMetaByUserId(23L,
2L));
+
Assertions.assertNotNull(userMetaMapper.selectUserMetaByMetalakeIdAndName(1L,
"user23"));
+ Assertions.assertEquals(1, userMetaMapper.softDeleteUserMetaByUserId(23L,
1L));
+ }
+
@Test
void testOwnerMetaSelectOwnerByMetadataObjectIdAndType() {
insertMetalake(1L, "metalake1");
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
index bb377e7c38..59e72f1412 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
@@ -45,6 +45,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.CatalogEntity;
@@ -57,9 +58,12 @@ import org.apache.gravitino.meta.TopicEntity;
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.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
+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.po.auth.AuthPrefetchRow;
import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -833,7 +837,7 @@ class TestUserMetaService extends TestJDBCBackend {
Assertions.assertEquals("creator", grantRevokeUser.auditInfo().creator());
Assertions.assertEquals("grantRevokeUser",
grantRevokeUser.auditInfo().lastModifier());
- Function<UserEntity, UserEntity> noUpdater =
+ Function<UserEntity, UserEntity> metadataUpdater =
user -> {
AuditInfo updateAuditInfo =
AuditInfo.builder()
@@ -855,17 +859,17 @@ class TestUserMetaService extends TestJDBCBackend {
.withAuditInfo(updateAuditInfo)
.build();
};
-
Assertions.assertNotNull(userMetaService.updateUser(user1.nameIdentifier(),
noUpdater));
- UserEntity noUpdaterUser =
+
Assertions.assertNotNull(userMetaService.updateUser(user1.nameIdentifier(),
metadataUpdater));
+ UserEntity metadataUpdatedUser =
UserMetaService.getInstance().getUserByIdentifier(user1.nameIdentifier());
- Assertions.assertEquals(user1.id(), noUpdaterUser.id());
- Assertions.assertEquals(user1.name(), noUpdaterUser.name());
+ Assertions.assertEquals(user1.id(), metadataUpdatedUser.id());
+ Assertions.assertEquals(user1.name(), metadataUpdatedUser.name());
Assertions.assertEquals(
- Sets.newHashSet("role1", "role4"),
Sets.newHashSet(noUpdaterUser.roleNames()));
+ Sets.newHashSet("role1", "role4"),
Sets.newHashSet(metadataUpdatedUser.roleNames()));
Assertions.assertEquals(
- Sets.newHashSet(role1.id(), role4.id()),
Sets.newHashSet(noUpdaterUser.roleIds()));
- Assertions.assertEquals("creator", noUpdaterUser.auditInfo().creator());
- Assertions.assertEquals("grantRevokeUser",
noUpdaterUser.auditInfo().lastModifier());
+ Sets.newHashSet(role1.id(), role4.id()),
Sets.newHashSet(metadataUpdatedUser.roleIds()));
+ Assertions.assertEquals("creator",
metadataUpdatedUser.auditInfo().creator());
+ Assertions.assertEquals("noUpdateUser",
metadataUpdatedUser.auditInfo().lastModifier());
// Delete a role, the user entity won't contain this role.
RoleMetaService.getInstance().deleteRole(role1.nameIdentifier());
@@ -1467,11 +1471,246 @@ class TestUserMetaService extends TestJDBCBackend {
() -> svc.insertUser(userWithExtId("u2", "ext-1"), false));
}
+ @TestTemplate
+ void testCreateLocksMetalakeWithoutChangingVersion() throws IOException {
+ createAndInsertMakeLake(metalakeName);
+ UserMetaService service = UserMetaService.getInstance();
+ MetalakePO beforeCreate = getMetalakePO();
+ UserEntity user = userWithExtId("fenced-user", "fenced-user-ext-id");
+
+ service.insertUser(user, false);
+
+ MetalakePO afterCreate = getMetalakePO();
+ assertEquals(beforeCreate.getCurrentVersion(),
afterCreate.getCurrentVersion());
+ assertEquals(beforeCreate.getLastVersion(), afterCreate.getLastVersion());
+
+ UserEntity duplicate = userWithExtId(user.name(), "another-ext-id");
+ Assertions.assertThrows(
+ EntityAlreadyExistsException.class, () ->
service.insertUser(duplicate, false));
+
+ MetalakePO afterFailedCreate = getMetalakePO();
+ assertEquals(afterCreate.getCurrentVersion(),
afterFailedCreate.getCurrentVersion());
+ assertEquals(afterCreate.getLastVersion(),
afterFailedCreate.getLastVersion());
+ }
+
+ @TestTemplate
+ void testOverwriteInsertAdvancesVersion() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("overwrite-user", "overwrite-user-ext-id");
+ service.insertUser(user, false);
+ UserPO initialPO = getUserPO(user.name());
+
+ service.insertUser(user, true);
+
+ UserPO overwrittenPO = getUserPO(user.name());
+ assertEquals(initialPO.getCurrentVersion() + 1,
overwrittenPO.getCurrentVersion());
+ assertEquals(overwrittenPO.getCurrentVersion(),
overwrittenPO.getLastVersion());
+ int staleDelete =
+ SessionUtils.doWithCommitAndFetchResult(
+ UserMetaMapper.class,
+ mapper -> mapper.softDeleteUserMetaByUserId(user.id(),
initialPO.getCurrentVersion()));
+ assertEquals(0, staleDelete);
+ }
+
+ @TestTemplate
+ void testMetadataOnlyUpdateUsesOcc() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("metadata-only-user",
"metadata-only-ext-id");
+ service.insertUser(user, false);
+ UserPO beforeUpdate = getUserPO(user.name());
+
+ service.updateUser(user.nameIdentifier(), enabledUpdater(false));
+
+ UserPO afterUpdate = getUserPO(user.name());
+ assertEquals(beforeUpdate.getCurrentVersion() + 1,
afterUpdate.getCurrentVersion());
+ assertFalse(service.getUserByIdentifier(user.nameIdentifier()).enabled());
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateUser(
+ user.nameIdentifier(),
+ (UserEntity oldUser) -> {
+ advanceUserVersion(user.id());
+ return enabledUpdater(true).apply(oldUser);
+ }));
+ assertFalse(service.getUserByIdentifier(user.nameIdentifier()).enabled());
+ }
+
+ @TestTemplate
+ void testStaleDeleteReportsConflict() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("stale-delete-user",
"stale-delete-ext-id");
+ service.insertUser(user, false);
+ UserPO staleUserPO = getUserPO(user.name());
+ advanceUserVersion(user.id());
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> service.deleteUserWithVersion(user.nameIdentifier(),
staleUserPO));
+ assertEquals(user.id(),
service.getUserByIdentifier(user.nameIdentifier()).id());
+ }
+
+ @TestTemplate
+ void testAlterReportsNoSuchWhenUserIsDeletedConcurrently() throws
IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("deleted-during-alter",
"deleted-during-alter-ext-id");
+ service.insertUser(user, false);
+
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ service.updateUser(
+ user.nameIdentifier(),
+ (UserEntity oldUser) -> {
+ service.deleteUser(user.nameIdentifier());
+ return enabledUpdater(false).apply(oldUser);
+ }));
+ }
+
+ @TestTemplate
+ void testConcurrentUpdateDoesNotChangeRolesOnConflict() throws IOException {
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, "catalog");
+ RoleEntity role1 =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(metalakeName),
+ "role1",
+ AUDIT_INFO,
+ "catalog");
+ RoleEntity role2 =
+ createRoleEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofRoleNamespace(metalakeName),
+ "role2",
+ AUDIT_INFO,
+ "catalog");
+ RoleMetaService.getInstance().insertRole(role1, false);
+ RoleMetaService.getInstance().insertRole(role2, false);
+ UserEntity user =
+ createUserEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ AuthorizationUtils.ofUserNamespace(metalakeName),
+ "concurrent-user",
+ AUDIT_INFO,
+ Lists.newArrayList(role1.name()),
+ Lists.newArrayList(role1.id()));
+ UserMetaService.getInstance().insertUser(user, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ UserMetaService.getInstance()
+ .updateUser(
+ user.nameIdentifier(),
+ (UserEntity oldUser) -> {
+ advanceUserVersion(user.id());
+ List<String> roleNames =
Lists.newArrayList(oldUser.roleNames());
+ List<Long> roleIds =
Lists.newArrayList(oldUser.roleIds());
+ roleNames.add(role2.name());
+ roleIds.add(role2.id());
+ return UserEntity.builder()
+ .withId(oldUser.id())
+ .withName(oldUser.name())
+ .withNamespace(oldUser.namespace())
+ .withExternalId(oldUser.externalId())
+ .withEnabled(oldUser.enabled())
+ .withRoleNames(roleNames)
+ .withRoleIds(roleIds)
+ .withAuditInfo(oldUser.auditInfo())
+ .build();
+ }));
+
+ UserEntity storedUser =
+
UserMetaService.getInstance().getUserByIdentifier(user.nameIdentifier());
+ assertEquals(Sets.newHashSet(role1.id()),
Sets.newHashSet(storedUser.roleIds()));
+ }
+
+ @TestTemplate
+ void testConcurrentExternalIdUpdateRollsBackOnConflict() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("concurrent-user", "concurrent-ext-id");
+ service.insertUser(user, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateUserByExternalId(
+ userExtIdent(user.externalId()),
+ (UserEntity oldUser) -> {
+ advanceUserVersion(user.id());
+ return UserEntity.builder()
+ .withId(oldUser.id())
+ .withName(oldUser.name())
+ .withNamespace(oldUser.namespace())
+ .withExternalId(oldUser.externalId())
+ .withEnabled(false)
+ .withRoleNames(oldUser.roleNames())
+ .withRoleIds(oldUser.roleIds())
+ .withAuditInfo(oldUser.auditInfo())
+ .build();
+ }));
+
+ assertTrue(queryEnabledByExtId(user.externalId()));
+ }
+
+ @TestTemplate
+ void testConcurrentByIdUpdateRollsBackOnConflict() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("concurrent-by-id-user",
"concurrent-by-id-ext-id");
+ service.insertUser(user, false);
+
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ service.updateUserById(
+ metalakeName,
+ user.id(),
+ (UserEntity oldUser) -> {
+ advanceUserVersion(user.id());
+ return UserEntity.builder()
+ .withId(oldUser.id())
+ .withName(oldUser.name())
+ .withNamespace(oldUser.namespace())
+ .withExternalId(oldUser.externalId())
+ .withEnabled(false)
+ .withRoleNames(oldUser.roleNames())
+ .withRoleIds(oldUser.roleIds())
+ .withAuditInfo(oldUser.auditInfo())
+ .build();
+ }));
+
+ assertTrue(queryEnabledByExtId(user.externalId()));
+ }
+
+ @TestTemplate
+ void testDeleteUserById() throws IOException {
+ UserMetaService service = userMetaService();
+ UserEntity user = userWithExtId("delete-by-id-user",
"delete-by-id-ext-id");
+ service.insertUser(user, false);
+
+ assertTrue(service.deleteUserById(metalakeName, user.id()));
+ assertFalse(service.deleteUserById(metalakeName, user.id()));
+ }
+
private UserMetaService userMetaService() throws IOException {
createAndInsertMakeLake(metalakeName);
return UserMetaService.getInstance();
}
+ private MetalakePO getMetalakePO() {
+ return SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class, mapper ->
mapper.selectMetalakeMetaByName(metalakeName));
+ }
+
+ private UserPO getUserPO(String userName) {
+ MetalakePO metalakePO = getMetalakePO();
+ return SessionUtils.getWithoutCommit(
+ UserMetaMapper.class,
+ mapper ->
mapper.selectUserMetaByMetalakeIdAndName(metalakePO.getMetalakeId(), userName));
+ }
+
private void assertThrowsExt(Class<? extends Exception> type, Executable
executable) {
Assertions.assertThrows(type, executable);
}
@@ -1577,4 +1816,19 @@ class TestUserMetaService extends TestJDBCBackend {
}
return count;
}
+
+ private void advanceUserVersion(long userId) {
+ try (SqlSession sqlSession =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ Connection connection = sqlSession.getConnection();
+ Statement statement = connection.createStatement()) {
+ assertEquals(
+ 1,
+ statement.executeUpdate(
+ "UPDATE user_meta SET current_version = current_version + 1
WHERE user_id = "
+ + userId));
+ } catch (SQLException e) {
+ throw new RuntimeException("Advance user 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 e3121b74c5..8e1c563073 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
@@ -61,6 +61,7 @@ import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.meta.TableStatisticEntity;
import org.apache.gravitino.meta.TagEntity;
import org.apache.gravitino.meta.TopicEntity;
+import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.policy.Policy;
import org.apache.gravitino.policy.PolicyContent;
import org.apache.gravitino.policy.PolicyContents;
@@ -93,6 +94,7 @@ import org.apache.gravitino.storage.relational.po.TablePO;
import org.apache.gravitino.storage.relational.po.TagMetadataObjectRelPO;
import org.apache.gravitino.storage.relational.po.TagPO;
import org.apache.gravitino.storage.relational.po.TopicPO;
+import org.apache.gravitino.storage.relational.po.UserPO;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
import org.junit.jupiter.api.Assertions;
@@ -841,6 +843,33 @@ public class TestPOConverters {
assertEquals(8, updatePO3.getFilesetVersionPOs().get(0).getVersion());
}
+ @Test
+ public void testUpdateUserPOVersionUsesCurrentVersion() {
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("creator").withCreateTime(FIX_INSTANT).build();
+ UserEntity user =
+
UserEntity.builder().withId(1L).withName("user").withAuditInfo(auditInfo).build();
+ UserPO initialUserPO =
+ POConverters.initializeUserPOWithVersion(user,
UserPO.builder().withMetalakeId(1L));
+ UserPO userPO =
+ UserPO.builder()
+ .withUserId(initialUserPO.getUserId())
+ .withUserName(initialUserPO.getUserName())
+ .withMetalakeId(initialUserPO.getMetalakeId())
+ .withExternalId(initialUserPO.getExternalId())
+ .withEnabled(initialUserPO.getEnabled())
+ .withAuditInfo(initialUserPO.getAuditInfo())
+ .withCurrentVersion(7L)
+ .withLastVersion(3L)
+ .withDeletedAt(initialUserPO.getDeletedAt())
+ .build();
+
+ UserPO updatedUserPO = POConverters.updateUserPOWithVersion(userPO, user);
+
+ assertEquals(8, updatedUserPO.getCurrentVersion());
+ assertEquals(8, updatedUserPO.getLastVersion());
+ }
+
@Test
public void testFromPolicyPO() throws JsonProcessingException {
ImmutableSet<MetadataObject.Type> supportedObjectTypes =