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 a7b8096318 [#12652] improvement(core): add OCC for function writes
(#12823)
a7b8096318 is described below
commit a7b809631877fc17d811ee28d846087562f37e16
Author: Qi Yu <[email protected]>
AuthorDate: Tue Sep 8 18:06:17 2026 +0800
[#12652] improvement(core): add OCC for function writes (#12823)
### What changes were proposed in this pull request?
- Add version-CAS updates and deletes for managed functions.
- Execute root metadata CAS before writing function versions and
dependent rows.
- Classify stale-version conflicts and identity changes through
stable-ID locking reads.
- Use a dedicated root-only locking query for overwrite while keeping
normal reads on the strict current-version INNER JOIN.
- Preserve stored function IDs and monotonic versions for natural-key
overwrite on H2, MySQL, and PostgreSQL.
- Fence parent schemas during create and the correct target ancestry
during cross-catalog moves.
- Add real two-transaction coverage for parent deletion,
overwrite/rename, and target-schema deletion races.
### Why are the changes needed?
Concurrent writers could create competing function-version rows, stale
deletes could remove relationships belonging to newer functions, and
overwrite or move operations could violate identity, version, or parent
invariants.
Fix: #12652
### Does this PR introduce _any_ user-facing change?
No API or configuration changes. Concurrent function writes now report
optimistic-lock or not-found errors consistently.
### How was this patch tested?
- `./gradlew :core:spotlessApply :core:compileTestJava :core:javadoc`
- Ran `TestFunctionMetaService`, `TestFunctionMetaBaseSQLProvider`, and
`TestFunctionMetaPostgreSQLProvider`.
- Service tests ran against H2, MySQL, and PostgreSQL.
---------
Co-authored-by: Jerry Shao <[email protected]>
---
.../relational/mapper/FunctionMetaMapper.java | 52 +-
.../mapper/FunctionMetaSQLProviderFactory.java | 33 +-
.../mapper/FunctionVersionMetaMapper.java | 6 -
.../FunctionVersionMetaSQLProviderFactory.java | 5 -
.../provider/base/FunctionMetaBaseSQLProvider.java | 93 ++-
.../base/FunctionVersionMetaBaseSQLProvider.java | 33 +-
.../postgresql/FunctionMetaPostgreSQLProvider.java | 38 +-
.../FunctionVersionMetaPostgreSQLProvider.java | 33 +-
.../relational/service/FunctionMetaService.java | 302 ++++++--
.../relational/service/FunctionPOStorageOps.java | 17 +-
.../base/TestFunctionMetaBaseSQLProvider.java | 74 ++
.../TestFunctionMetaPostgreSQLProvider.java | 43 ++
.../service/TestFunctionMetaService.java | 790 ++++++++++++++++++++-
13 files changed, 1295 insertions(+), 224 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaMapper.java
index 100c2683c3..92f3cea359 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaMapper.java
@@ -57,11 +57,6 @@ public interface FunctionMetaMapper {
@InsertProvider(type = FunctionMetaSQLProviderFactory.class, method =
"insertFunctionMeta")
void insertFunctionMeta(@Param("functionMeta") FunctionPO functionPO);
- @InsertProvider(
- type = FunctionMetaSQLProviderFactory.class,
- method = "insertFunctionMetaOnDuplicateKeyUpdate")
- void insertFunctionMetaOnDuplicateKeyUpdate(@Param("functionMeta")
FunctionPO functionPO);
-
@Results({
@Result(property = "functionId", column = "function_id", id = true),
@Result(property = "functionName", column = "function_name"),
@@ -142,16 +137,61 @@ public interface FunctionMetaMapper {
FunctionPO selectFunctionMetaBySchemaIdAndName(
@Param("schemaId") Long schemaId, @Param("functionName") String
functionName);
+ /**
+ * Selects and exclusively locks an active function by its natural key
without joining a version
+ * row.
+ *
+ * @param schemaId the schema ID
+ * @param functionName the function name
+ * @return the locked function root, or {@code null} when it does not exist
+ */
+ @SelectProvider(
+ type = FunctionMetaSQLProviderFactory.class,
+ method = "selectFunctionMetaBySchemaIdAndNameForUpdate")
+ FunctionPO selectFunctionMetaBySchemaIdAndNameForUpdate(
+ @Param("schemaId") Long schemaId, @Param("functionName") String
functionName);
+
+ /**
+ * Selects and exclusively locks an active function metadata row.
+ *
+ * @param functionId the function ID
+ * @return the active function metadata, or {@code null} when it no longer
exists
+ */
+ @SelectProvider(
+ type = FunctionMetaSQLProviderFactory.class,
+ method = "selectFunctionMetaByIdForUpdate")
+ FunctionPO selectFunctionMetaByIdForUpdate(@Param("functionId") Long
functionId);
+
+ /**
+ * Checks whether a soft-deleted function still owns the requested primary
key.
+ *
+ * @param functionId the function ID
+ * @return one if a deleted row reserves the ID, otherwise zero
+ */
+ @Select(
+ "SELECT COUNT(*) FROM "
+ + TABLE_NAME
+ + " WHERE function_id = #{functionId} AND deleted_at > 0")
+ int countDeletedFunctionMetasById(@Param("functionId") Long functionId);
+
@SelectProvider(
type = FunctionMetaSQLProviderFactory.class,
method = "selectFunctionIdBySchemaIdAndFunctionName")
Long selectFunctionIdBySchemaIdAndFunctionName(
@Param("schemaId") Long schemaId, @Param("functionName") String
functionName);
+ /**
+ * Soft-deletes a function only if its version has not changed since the
caller read it.
+ *
+ * @param functionId the function ID
+ * @param currentVersion the version observed by the caller
+ * @return the number of deleted rows; zero means the function changed or
disappeared
+ */
@UpdateProvider(
type = FunctionMetaSQLProviderFactory.class,
method = "softDeleteFunctionMetaByFunctionId")
- Integer softDeleteFunctionMetaByFunctionId(@Param("functionId") Long
functionId);
+ Integer softDeleteFunctionMetaByFunctionId(
+ @Param("functionId") Long functionId, @Param("currentVersion") Integer
currentVersion);
@UpdateProvider(
type = FunctionMetaSQLProviderFactory.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaSQLProviderFactory.java
index 2e2ad59129..421ca32cb2 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionMetaSQLProviderFactory.java
@@ -56,11 +56,6 @@ public class FunctionMetaSQLProviderFactory {
return getProvider().insertFunctionMeta(functionPO);
}
- public static String insertFunctionMetaOnDuplicateKeyUpdate(
- @Param("functionMeta") FunctionPO functionPO) {
- return getProvider().insertFunctionMetaOnDuplicateKeyUpdate(functionPO);
- }
-
public static String listFunctionPOsBySchemaId(@Param("schemaId") Long
schemaId) {
return getProvider().listFunctionPOsBySchemaId(schemaId);
}
@@ -90,13 +85,37 @@ public class FunctionMetaSQLProviderFactory {
return getProvider().selectFunctionMetaBySchemaIdAndName(schemaId,
functionName);
}
+ /** Delegates a root-only locking lookup by function natural key. */
+ public static String selectFunctionMetaBySchemaIdAndNameForUpdate(
+ @Param("schemaId") Long schemaId, @Param("functionName") String
functionName) {
+ return
getProvider().selectFunctionMetaBySchemaIdAndNameForUpdate(schemaId,
functionName);
+ }
+
+ /**
+ * Returns SQL that selects and exclusively locks an active function
metadata row.
+ *
+ * @param functionId the function ID
+ * @return the locking select SQL
+ */
+ public static String selectFunctionMetaByIdForUpdate(@Param("functionId")
Long functionId) {
+ return getProvider().selectFunctionMetaByIdForUpdate(functionId);
+ }
+
public static String selectFunctionIdBySchemaIdAndFunctionName(
@Param("schemaId") Long schemaId, @Param("functionName") String
functionName) {
return getProvider().selectFunctionIdBySchemaIdAndFunctionName(schemaId,
functionName);
}
- public static String softDeleteFunctionMetaByFunctionId(@Param("functionId")
Long functionId) {
- return getProvider().softDeleteFunctionMetaByFunctionId(functionId);
+ /**
+ * Returns SQL that soft-deletes a function with a version check.
+ *
+ * @param functionId the function ID
+ * @param currentVersion the version observed by the caller
+ * @return the version-checked delete SQL
+ */
+ public static String softDeleteFunctionMetaByFunctionId(
+ @Param("functionId") Long functionId, @Param("currentVersion") Integer
currentVersion) {
+ return getProvider().softDeleteFunctionMetaByFunctionId(functionId,
currentVersion);
}
public static String softDeleteFunctionMetasByCatalogId(@Param("catalogId")
Long catalogId) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaMapper.java
index 9a40934ca0..7b6dde6c09 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaMapper.java
@@ -37,12 +37,6 @@ public interface FunctionVersionMetaMapper {
method = "insertFunctionVersionMeta")
void insertFunctionVersionMeta(@Param("functionVersionMeta")
FunctionVersionPO functionVersionPO);
- @InsertProvider(
- type = FunctionVersionMetaSQLProviderFactory.class,
- method = "insertFunctionVersionMetaOnDuplicateKeyUpdate")
- void insertFunctionVersionMetaOnDuplicateKeyUpdate(
- @Param("functionVersionMeta") FunctionVersionPO functionVersionPO);
-
@UpdateProvider(
type = FunctionVersionMetaSQLProviderFactory.class,
method = "softDeleteFunctionVersionMetasBySchemaIds")
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaSQLProviderFactory.java
index a5f846798b..db8e917dfc 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FunctionVersionMetaSQLProviderFactory.java
@@ -57,11 +57,6 @@ public class FunctionVersionMetaSQLProviderFactory {
return getProvider().insertFunctionVersionMeta(functionVersionPO);
}
- public static String insertFunctionVersionMetaOnDuplicateKeyUpdate(
- @Param("functionVersionMeta") FunctionVersionPO functionVersionPO) {
- return
getProvider().insertFunctionVersionMetaOnDuplicateKeyUpdate(functionVersionPO);
- }
-
public static String softDeleteFunctionVersionMetasBySchemaIds(
@Param("schemaIds") List<Long> schemaIds) {
return getProvider().softDeleteFunctionVersionMetasBySchemaIds(schemaIds);
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionMetaBaseSQLProvider.java
index 4d01ddd63d..b7b4b7ad51 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionMetaBaseSQLProvider.java
@@ -101,32 +101,6 @@ public class FunctionMetaBaseSQLProvider {
+ " #{functionMeta.deletedAt})";
}
- public String insertFunctionMetaOnDuplicateKeyUpdate(
- @Param("functionMeta") FunctionPO functionPO) {
- return "INSERT INTO "
- + TABLE_NAME
- + " (function_id, function_name, metalake_id, catalog_id, schema_id,"
- + " function_type, `deterministic`,"
- + " function_current_version, function_latest_version, audit_info,
deleted_at)"
- + " VALUES (#{functionMeta.functionId}, #{functionMeta.functionName},"
- + " #{functionMeta.metalakeId}, #{functionMeta.catalogId},
#{functionMeta.schemaId},"
- + " #{functionMeta.functionType}, #{functionMeta.deterministic},"
- + " #{functionMeta.functionCurrentVersion},"
- + " #{functionMeta.functionLatestVersion}, #{functionMeta.auditInfo},"
- + " #{functionMeta.deletedAt})"
- + " ON DUPLICATE KEY UPDATE"
- + " function_name = #{functionMeta.functionName},"
- + " metalake_id = #{functionMeta.metalakeId},"
- + " catalog_id = #{functionMeta.catalogId},"
- + " schema_id = #{functionMeta.schemaId},"
- + " function_type = #{functionMeta.functionType},"
- + " `deterministic` = #{functionMeta.deterministic},"
- + " function_current_version = #{functionMeta.functionCurrentVersion},"
- + " function_latest_version = #{functionMeta.functionLatestVersion},"
- + " audit_info = #{functionMeta.auditInfo},"
- + " deleted_at = #{functionMeta.deletedAt}";
- }
-
public String selectFunctionMetaByFullQualifiedName(
@Param("metalakeName") String metalakeName,
@Param("catalogName") String catalogName,
@@ -202,6 +176,26 @@ public class FunctionMetaBaseSQLProvider {
+ " WHERE fm.schema_id = #{schemaId} AND fm.deleted_at = 0 AND
vi.deleted_at = 0";
}
+ /**
+ * Returns the active function metadata row and holds it exclusively for the
transaction.
+ *
+ * <p>The version table is deliberately not joined: PostgreSQL rejects
locking the nullable side
+ * of an outer join, and conflict classification only needs the root row's
identity and version.
+ *
+ * @param functionId the function ID
+ * @return the locking select SQL
+ */
+ public String selectFunctionMetaByIdForUpdate(@Param("functionId") Long
functionId) {
+ return "SELECT function_id as functionId, function_name as functionName,"
+ + " metalake_id as metalakeId, catalog_id as catalogId, schema_id as
schemaId,"
+ + " function_current_version as functionCurrentVersion,"
+ + " function_latest_version as functionLatestVersion,"
+ + " audit_info as auditInfo, deleted_at as deletedAt"
+ + " FROM "
+ + TABLE_NAME
+ + " WHERE function_id = #{functionId} AND deleted_at = 0 FOR UPDATE";
+ }
+
public String listFunctionPOsByFunctionIds(@Param("functionIds") List<Long>
functionIds) {
return "<script>"
+ " SELECT function_id, function_name, schema_id"
@@ -235,6 +229,26 @@ public class FunctionMetaBaseSQLProvider {
+ " AND fm.deleted_at = 0 AND vi.deleted_at = 0";
}
+ /**
+ * Returns SQL that locks an active function by natural key without joining
its version row.
+ *
+ * <p>This query is reserved for overwrite decisions. Normal reads keep
using the inner-joined
+ * query above so a broken current-version invariant is reported as missing
instead of producing a
+ * partially populated {@code FunctionPO}.
+ */
+ public String selectFunctionMetaBySchemaIdAndNameForUpdate(
+ @Param("schemaId") Long schemaId, @Param("functionName") String
functionName) {
+ return "SELECT function_id as functionId, function_name as functionName,"
+ + " metalake_id as metalakeId, catalog_id as catalogId, schema_id as
schemaId,"
+ + " function_current_version as functionCurrentVersion,"
+ + " function_latest_version as functionLatestVersion,"
+ + " deleted_at as deletedAt"
+ + " FROM "
+ + TABLE_NAME
+ + " WHERE schema_id = #{schemaId} AND function_name = #{functionName}"
+ + " AND deleted_at = 0 FOR UPDATE";
+ }
+
public String selectFunctionIdBySchemaIdAndFunctionName(
@Param("schemaId") Long schemaId, @Param("functionName") String
functionName) {
return "SELECT function_id"
@@ -243,12 +257,21 @@ public class FunctionMetaBaseSQLProvider {
+ " WHERE schema_id = #{schemaId} AND function_name = #{functionName}
AND deleted_at = 0";
}
- public String softDeleteFunctionMetaByFunctionId(@Param("functionId") Long
functionId) {
+ /**
+ * Returns SQL that deletes only the function version observed by the caller.
+ *
+ * @param functionId the function ID
+ * @param currentVersion the version observed by the caller
+ * @return the version-checked delete SQL
+ */
+ public String softDeleteFunctionMetaByFunctionId(
+ @Param("functionId") Long functionId, @Param("currentVersion") Integer
currentVersion) {
return "UPDATE "
+ TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.MYSQL
- + " WHERE function_id = #{functionId} AND deleted_at = 0";
+ + " WHERE function_id = #{functionId}"
+ + " AND function_current_version = #{currentVersion} AND deleted_at =
0";
}
public String softDeleteFunctionMetasByCatalogId(@Param("catalogId") Long
catalogId) {
@@ -288,6 +311,13 @@ public class FunctionMetaBaseSQLProvider {
+ " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit}";
}
+ /**
+ * Returns SQL that updates a function only while its OCC version is
unchanged.
+ *
+ * @param newFunctionPO the function values to write
+ * @param oldFunctionPO the function values and OCC version read by the
caller
+ * @return the version-checked update SQL
+ */
public String updateFunctionMeta(
@Param("newFunctionMeta") FunctionPO newFunctionPO,
@Param("oldFunctionMeta") FunctionPO oldFunctionPO) {
@@ -304,14 +334,7 @@ public class FunctionMetaBaseSQLProvider {
+ " audit_info = #{newFunctionMeta.auditInfo},"
+ " deleted_at = #{newFunctionMeta.deletedAt}"
+ " WHERE function_id = #{oldFunctionMeta.functionId}"
- + " AND function_name = #{oldFunctionMeta.functionName}"
- + " AND metalake_id = #{oldFunctionMeta.metalakeId}"
- + " AND catalog_id = #{oldFunctionMeta.catalogId}"
- + " AND schema_id = #{oldFunctionMeta.schemaId}"
- + " AND function_type = #{oldFunctionMeta.functionType}"
+ " AND function_current_version =
#{oldFunctionMeta.functionCurrentVersion}"
- + " AND function_latest_version =
#{oldFunctionMeta.functionLatestVersion}"
- + " AND audit_info = #{oldFunctionMeta.auditInfo}"
+ " AND deleted_at = 0";
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionVersionMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionVersionMetaBaseSQLProvider.java
index 961c8f1ef3..aee3a42bb7 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionVersionMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FunctionVersionMetaBaseSQLProvider.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.storage.relational.mapper.provider.base;
import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.FunctionMetaMapper;
import
org.apache.gravitino.storage.relational.mapper.FunctionVersionMetaMapper;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
import org.apache.gravitino.storage.relational.po.FunctionVersionPO;
@@ -39,24 +40,6 @@ public class FunctionVersionMetaBaseSQLProvider {
+ " #{functionVersionMeta.deletedAt})";
}
- public String insertFunctionVersionMetaOnDuplicateKeyUpdate(
- @Param("functionVersionMeta") FunctionVersionPO functionVersionPO) {
- return "INSERT INTO "
- + FunctionVersionMetaMapper.TABLE_NAME
- + " (metalake_id, catalog_id, schema_id, function_id, version,"
- + " function_comment, definitions, audit_info, deleted_at)"
- + " VALUES (#{functionVersionMeta.metalakeId},
#{functionVersionMeta.catalogId},"
- + " #{functionVersionMeta.schemaId},
#{functionVersionMeta.functionId},"
- + " #{functionVersionMeta.functionVersion},
#{functionVersionMeta.functionComment},"
- + " #{functionVersionMeta.definitions},
#{functionVersionMeta.auditInfo},"
- + " #{functionVersionMeta.deletedAt})"
- + " ON DUPLICATE KEY UPDATE"
- + " function_comment = #{functionVersionMeta.functionComment},"
- + " definitions = #{functionVersionMeta.definitions},"
- + " audit_info = #{functionVersionMeta.auditInfo},"
- + " deleted_at = #{functionVersionMeta.deletedAt}";
- }
-
public String softDeleteFunctionVersionMetasBySchemaIds(
@Param("schemaIds") List<Long> schemaIds) {
return "<script>"
@@ -64,11 +47,15 @@ public class FunctionVersionMetaBaseSQLProvider {
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.MYSQL
+ // History follows the stable entity ID, not the parent recorded in
each snapshot.
+ // Include deleted roots: cascade cleanup soft-deletes roots before
their versions.
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ " WHERE schema_id IN ("
+ "<foreach collection='schemaIds' item='schemaId' separator=','>"
+ "#{schemaId}"
+ "</foreach>"
- + ") AND deleted_at = 0"
+ + ")) AND deleted_at = 0"
+ "</script>";
}
@@ -77,7 +64,9 @@ public class FunctionVersionMetaBaseSQLProvider {
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.MYSQL
- + " WHERE catalog_id = #{catalogId} AND deleted_at = 0";
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ + " WHERE catalog_id = #{catalogId}) AND deleted_at = 0";
}
public String
softDeleteFunctionVersionMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
@@ -85,7 +74,9 @@ public class FunctionVersionMetaBaseSQLProvider {
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.MYSQL
- + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ + " WHERE metalake_id = #{metalakeId}) AND deleted_at = 0";
}
public String deleteFunctionVersionMetasByLegacyTimeline(
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionMetaPostgreSQLProvider.java
index df8f83f012..8da7a70402 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionMetaPostgreSQLProvider.java
@@ -40,31 +40,6 @@ public class FunctionMetaPostgreSQLProvider extends
FunctionMetaBaseSQLProvider
+ " #{functionMeta.deletedAt})";
}
- @Override
- public String insertFunctionMetaOnDuplicateKeyUpdate(
- @Param("functionMeta") FunctionPO functionPO) {
- return "INSERT INTO "
- + FunctionMetaMapper.TABLE_NAME
- + " (function_id, function_name, metalake_id, catalog_id, schema_id,"
- + " function_type, \"deterministic\", function_current_version,
function_latest_version, audit_info, deleted_at)"
- + " VALUES (#{functionMeta.functionId}, #{functionMeta.functionName},
#{functionMeta.metalakeId},"
- + " #{functionMeta.catalogId}, #{functionMeta.schemaId},
#{functionMeta.functionType},"
- + " #{functionMeta.deterministic},"
- + " #{functionMeta.functionCurrentVersion},
#{functionMeta.functionLatestVersion}, #{functionMeta.auditInfo},"
- + " #{functionMeta.deletedAt})"
- + " ON CONFLICT (function_id) DO UPDATE SET"
- + " function_name = #{functionMeta.functionName},"
- + " metalake_id = #{functionMeta.metalakeId},"
- + " catalog_id = #{functionMeta.catalogId},"
- + " schema_id = #{functionMeta.schemaId},"
- + " function_type = #{functionMeta.functionType},"
- + " \"deterministic\" = #{functionMeta.deterministic},"
- + " function_current_version = #{functionMeta.functionCurrentVersion},"
- + " function_latest_version = #{functionMeta.functionLatestVersion},"
- + " audit_info = #{functionMeta.auditInfo},"
- + " deleted_at = #{functionMeta.deletedAt}";
- }
-
@Override
public String listFunctionPOsBySchemaId(@Param("schemaId") Long schemaId) {
return "SELECT fm.function_id, fm.function_name, fm.metalake_id,
fm.catalog_id, fm.schema_id,"
@@ -102,12 +77,14 @@ public class FunctionMetaPostgreSQLProvider extends
FunctionMetaBaseSQLProvider
}
@Override
- public String softDeleteFunctionMetaByFunctionId(@Param("functionId") Long
functionId) {
+ public String softDeleteFunctionMetaByFunctionId(
+ @Param("functionId") Long functionId, @Param("currentVersion") Integer
currentVersion) {
return "UPDATE "
+ FunctionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.POSTGRESQL
- + " WHERE function_id = #{functionId} AND deleted_at = 0";
+ + " WHERE function_id = #{functionId}"
+ + " AND function_current_version = #{currentVersion} AND deleted_at =
0";
}
@Override
@@ -170,14 +147,7 @@ public class FunctionMetaPostgreSQLProvider extends
FunctionMetaBaseSQLProvider
+ " audit_info = #{newFunctionMeta.auditInfo},"
+ " deleted_at = #{newFunctionMeta.deletedAt}"
+ " WHERE function_id = #{oldFunctionMeta.functionId}"
- + " AND function_name = #{oldFunctionMeta.functionName}"
- + " AND metalake_id = #{oldFunctionMeta.metalakeId}"
- + " AND catalog_id = #{oldFunctionMeta.catalogId}"
- + " AND schema_id = #{oldFunctionMeta.schemaId}"
- + " AND function_type = #{oldFunctionMeta.functionType}"
+ " AND function_current_version =
#{oldFunctionMeta.functionCurrentVersion}"
- + " AND function_latest_version =
#{oldFunctionMeta.functionLatestVersion}"
- + " AND audit_info = #{oldFunctionMeta.auditInfo}"
+ " AND deleted_at = 0";
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionVersionMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionVersionMetaPostgreSQLProvider.java
index 2132332947..ae8bb55c72 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionVersionMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FunctionVersionMetaPostgreSQLProvider.java
@@ -19,33 +19,14 @@
package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.FunctionMetaMapper;
import
org.apache.gravitino.storage.relational.mapper.FunctionVersionMetaMapper;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
import
org.apache.gravitino.storage.relational.mapper.provider.base.FunctionVersionMetaBaseSQLProvider;
-import org.apache.gravitino.storage.relational.po.FunctionVersionPO;
import org.apache.ibatis.annotations.Param;
public class FunctionVersionMetaPostgreSQLProvider extends
FunctionVersionMetaBaseSQLProvider {
- @Override
- public String insertFunctionVersionMetaOnDuplicateKeyUpdate(
- @Param("functionVersionMeta") FunctionVersionPO functionVersionPO) {
- return "INSERT INTO "
- + FunctionVersionMetaMapper.TABLE_NAME
- + " (metalake_id, catalog_id, schema_id, function_id, version,"
- + " function_comment, definitions, audit_info, deleted_at)"
- + " VALUES (#{functionVersionMeta.metalakeId},
#{functionVersionMeta.catalogId},"
- + " #{functionVersionMeta.schemaId},
#{functionVersionMeta.functionId},"
- + " #{functionVersionMeta.functionVersion},
#{functionVersionMeta.functionComment},"
- + " #{functionVersionMeta.definitions},
#{functionVersionMeta.auditInfo},"
- + " #{functionVersionMeta.deletedAt})"
- + " ON CONFLICT (function_id, version, deleted_at) DO UPDATE SET"
- + " function_comment = #{functionVersionMeta.functionComment},"
- + " definitions = #{functionVersionMeta.definitions},"
- + " audit_info = #{functionVersionMeta.auditInfo},"
- + " deleted_at = #{functionVersionMeta.deletedAt}";
- }
-
@Override
public String softDeleteFunctionVersionMetasBySchemaIds(
@Param("schemaIds") List<Long> schemaIds) {
@@ -54,11 +35,13 @@ public class FunctionVersionMetaPostgreSQLProvider extends
FunctionVersionMetaBa
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.POSTGRESQL
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ " WHERE schema_id IN ("
+ "<foreach collection='schemaIds' item='schemaId' separator=','>"
+ "#{schemaId}"
+ "</foreach>"
- + ") AND deleted_at = 0"
+ + ")) AND deleted_at = 0"
+ "</script>";
}
@@ -68,7 +51,9 @@ public class FunctionVersionMetaPostgreSQLProvider extends
FunctionVersionMetaBa
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.POSTGRESQL
- + " WHERE catalog_id = #{catalogId} AND deleted_at = 0";
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ + " WHERE catalog_id = #{catalogId}) AND deleted_at = 0";
}
@Override
@@ -77,7 +62,9 @@ public class FunctionVersionMetaPostgreSQLProvider extends
FunctionVersionMetaBa
+ FunctionVersionMetaMapper.TABLE_NAME
+ " SET deleted_at = "
+ DatabaseTimeSQL.POSTGRESQL
- + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
+ + " WHERE function_id IN (SELECT function_id FROM "
+ + FunctionMetaMapper.TABLE_NAME
+ + " WHERE metalake_id = #{metalakeId}) AND deleted_at = 0";
}
@Override
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionMetaService.java
index 2d6fcfe444..31fe233741 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionMetaService.java
@@ -29,10 +29,10 @@ import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.HasIdentifier;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
@@ -49,6 +49,7 @@ import
org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import
org.apache.gravitino.storage.relational.mapper.TagMetadataObjectRelMapper;
import org.apache.gravitino.storage.relational.po.FunctionMaxVersionPO;
import org.apache.gravitino.storage.relational.po.FunctionPO;
+import org.apache.gravitino.storage.relational.po.FunctionVersionPO;
import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
import org.apache.gravitino.storage.relational.utils.SessionUtils;
import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -125,22 +126,20 @@ public class FunctionMetaService {
po.schemaId(),
po.catalogId(),
po.metalakeId()),
- () ->
- SessionUtils.doWithoutCommit(
- FunctionMetaMapper.class, mapper -> ops.insertPO(mapper, po,
overwrite)),
- () ->
- SessionUtils.doWithoutCommit(
- FunctionVersionMetaMapper.class,
- mapper -> {
- if (overwrite) {
-
mapper.insertFunctionVersionMetaOnDuplicateKeyUpdate(po.functionVersionPO());
- } else {
- mapper.insertFunctionVersionMeta(po.functionVersionPO());
- }
- }));
+ () -> insertFunctionWithoutCommit(functionEntity, po, overwrite));
} catch (RuntimeException re) {
- ExceptionUtils.checkSQLException(
- re, Entity.EntityType.FUNCTION,
functionEntity.nameIdentifier().toString());
+ try {
+ ExceptionUtils.checkSQLException(
+ re, Entity.EntityType.FUNCTION,
functionEntity.nameIdentifier().toString());
+ } catch (EntityAlreadyExistsException duplicate) {
+ if (overwrite) {
+ // A missing-row locking read cannot fence a concurrent insert at
READ_COMMITTED.
+ // Propagate the conflict so the whole transaction is rolled back
before retrying.
+ throw ExceptionUtils.concurrentModification(
+ Entity.EntityType.FUNCTION, functionEntity.nameIdentifier());
+ }
+ throw duplicate;
+ }
throw re;
}
}
@@ -150,47 +149,31 @@ public class FunctionMetaService {
baseMetricName = "deleteFunction")
public boolean deleteFunction(NameIdentifier ident) {
FunctionPO functionPO = getFunctionPOByIdentifier(ident);
- Long functionId = functionPO.functionId();
- AtomicInteger functionDeletedCount = new AtomicInteger();
+ deleteFunctionWithVersion(ident, functionPO);
+ return true;
+ }
+
+ /**
+ * Deletes the observed function and its dependent rows in one transaction.
+ *
+ * <p>Package-private access lets concurrency tests submit a deliberately
stale snapshot while
+ * exercising the same root-first ordering as the public delete path.
+ */
+ void deleteFunctionWithVersion(NameIdentifier identifier, FunctionPO
observedFunctionPO) {
SessionUtils.doMultipleWithCommit(
- // delete function meta
+ // Check the root version before touching relationships. A stale drop
stops here.
() ->
- functionDeletedCount.set(
- SessionUtils.getWithoutCommit(
- FunctionMetaMapper.class,
- mapper ->
mapper.softDeleteFunctionMetaByFunctionId(functionId))),
-
- // delete function versions, owner rels, and securable object rels
after meta deletion
- () -> {
- if (functionDeletedCount.get() > 0) {
- SessionUtils.doWithoutCommit(
- FunctionVersionMetaMapper.class,
- mapper ->
mapper.softDeleteFunctionVersionsByFunctionId(functionId));
- SessionUtils.doWithoutCommit(
- OwnerMetaMapper.class,
- mapper ->
- mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
- functionId, MetadataObject.Type.FUNCTION.name()));
- SessionUtils.doWithoutCommit(
- SecurableObjectMapper.class,
- mapper ->
- mapper.softDeleteObjectRelsByMetadataObject(
- functionId, MetadataObject.Type.FUNCTION.name()));
- SessionUtils.doWithoutCommit(
- TagMetadataObjectRelMapper.class,
- mapper ->
- mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
- functionId, MetadataObject.Type.FUNCTION.name()));
- SessionUtils.doWithoutCommit(
- PolicyMetadataObjectRelMapper.class,
- mapper ->
- mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
- functionId, MetadataObject.Type.FUNCTION.name()));
- }
- });
-
- return functionDeletedCount.get() > 0;
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper ->
+ mapper.softDeleteFunctionMetaByFunctionId(
+ observedFunctionPO.functionId(),
+ observedFunctionPO.functionCurrentVersion())),
+ () -> functionWriteFailure(identifier, observedFunctionPO)),
+ () -> deleteFunctionDependents(observedFunctionPO.functionId()));
}
@Monitored(
@@ -274,36 +257,44 @@ public class FunctionMetaService {
newEntity.id(),
oldFunctionEntity.id());
+ boolean isSchemaChanged =
!newEntity.namespace().equals(oldFunctionEntity.namespace());
+ NamespacedEntityId targetSchemaIds =
+ isSchemaChanged
+ ? EntityIdService.getEntityIds(
+ NameIdentifier.of(newEntity.namespace().levels()),
Entity.EntityType.SCHEMA)
+ : null;
+ Long newSchemaId = isSchemaChanged ? targetSchemaIds.entityId() :
oldFunctionPO.schemaId();
+ Long newCatalogId =
+ isSchemaChanged ? targetSchemaIds.namespaceIds()[1] :
oldFunctionPO.catalogId();
+ Long newMetalakeId =
+ isSchemaChanged ? targetSchemaIds.namespaceIds()[0] :
oldFunctionPO.metalakeId();
+
try {
- FunctionPO newFunctionPO = updateFunctionPO(oldFunctionPO, newEntity);
- // Insert a new version and update function meta
+ FunctionPO newFunctionPO =
+ updateFunctionPO(oldFunctionPO, newEntity, newSchemaId,
newCatalogId, newMetalakeId);
SessionUtils.doMultipleWithCommit(
- // The function was read before this transaction started. Lock its
observed parent again
- // before writing, so a schema drop cannot finish its function
cleanup and then let this
- // update add a new version below the deleted schema.
- () ->
+ () -> {
+ if (isSchemaChanged) {
SchemaMetaService.getInstance()
.lockSchemaForEntityWrite(
- identifier,
- oldFunctionPO.schemaId(),
- oldFunctionPO.catalogId(),
- oldFunctionPO.metalakeId()),
- () ->
- SessionUtils.doWithoutCommit(
- FunctionVersionMetaMapper.class,
- mapper ->
mapper.insertFunctionVersionMeta(newFunctionPO.functionVersionPO())),
+ newEntity.nameIdentifier(), newSchemaId, newCatalogId,
newMetalakeId);
+ }
+ },
() -> {
+ // function_current_version is the sole OCC token. The root CAS is
the transaction's
+ // decision point and must run before the unguarded version-row
insert below.
int updated =
SessionUtils.getWithoutCommit(
FunctionMetaMapper.class,
mapper -> ops.updatePO(mapper, newFunctionPO,
oldFunctionPO));
if (updated == 0) {
- // The version row was inserted earlier in this transaction.
Throwing here rolls the
- // whole transaction back instead of leaving that version
without an active function
- // metadata row.
- throw
ExceptionUtils.concurrentModification(Entity.EntityType.FUNCTION, identifier);
+ throw functionWriteFailure(identifier, oldFunctionPO);
}
- });
+ },
+ () ->
+ SessionUtils.doWithoutCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
mapper.insertFunctionVersionMeta(newFunctionPO.functionVersionPO())));
return newEntity;
} catch (RuntimeException re) {
@@ -333,15 +324,170 @@ public class FunctionMetaService {
builder.withSchemaId(namespacedEntityId.entityId());
}
- private FunctionPO updateFunctionPO(FunctionPO oldFunctionPO, FunctionEntity
newFunction) {
- Integer newVersion = oldFunctionPO.functionLatestVersion() + 1;
+ private FunctionPO updateFunctionPO(
+ FunctionPO oldFunctionPO,
+ FunctionEntity newFunction,
+ Long newSchemaId,
+ Long newCatalogId,
+ Long newMetalakeId) {
+ // Both version columns always advance together, but deriving the next
version from
+ // the higher of the two keeps it above every version row that exists,
even if a
+ // future path ever leaves the current version behind the latest one.
+ Integer newVersion = nextVersion(oldFunctionPO);
FunctionPO.FunctionPOBuilder builder =
FunctionPO.builder()
- .withMetalakeId(oldFunctionPO.metalakeId())
- .withCatalogId(oldFunctionPO.catalogId())
- .withSchemaId(oldFunctionPO.schemaId())
+ .withMetalakeId(newMetalakeId)
+ .withCatalogId(newCatalogId)
+ .withSchemaId(newSchemaId)
.withFunctionLatestVersion(newVersion)
.withFunctionCurrentVersion(newVersion);
return buildFunctionPO(newFunction, builder, newVersion);
}
+
+ /**
+ * Writes a new function or replaces the active function selected by natural
key or stable ID.
+ * Locking the root before choosing the next version makes overwrite
behavior identical across
+ * databases and keeps standard reads strict about the matching version-row
invariant.
+ */
+ private void insertFunctionWithoutCommit(
+ FunctionEntity functionEntity, FunctionPO initializedFunctionPO, boolean
overwrite) {
+ if (!overwrite) {
+ insertNewFunctionWithoutCommit(initializedFunctionPO);
+ return;
+ }
+
+ FunctionPO existingFunctionPO =
findAndLockFunctionForOverwrite(initializedFunctionPO);
+ if (existingFunctionPO == null) {
+ if (SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper ->
mapper.countDeletedFunctionMetasById(initializedFunctionPO.functionId()))
+ > 0) {
+ throw new EntityAlreadyExistsException(
+ "The function ID %s is reserved by a deleted function; use a new
ID",
+ initializedFunctionPO.functionId());
+ }
+ insertNewFunctionWithoutCommit(initializedFunctionPO);
+ return;
+ }
+
+ FunctionPO replacementPO = functionPOForOverwrite(initializedFunctionPO,
existingFunctionPO);
+ int updated =
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper -> ops.updatePO(mapper, replacementPO, existingFunctionPO));
+ if (updated == 0) {
+ throw functionWriteFailure(functionEntity.nameIdentifier(),
existingFunctionPO);
+ }
+ SessionUtils.doWithoutCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
mapper.insertFunctionVersionMeta(replacementPO.functionVersionPO()));
+ }
+
+ private void insertNewFunctionWithoutCommit(FunctionPO functionPO) {
+ SessionUtils.doWithoutCommit(
+ FunctionMetaMapper.class, mapper -> ops.insertPO(mapper, functionPO,
false));
+ SessionUtils.doWithoutCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
mapper.insertFunctionVersionMeta(functionPO.functionVersionPO()));
+ }
+
+ private FunctionPO findAndLockFunctionForOverwrite(FunctionPO
initializedFunctionPO) {
+ return OccWriteSupport.findAndLockForOverwrite(
+ () ->
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper ->
+ mapper.selectFunctionMetaBySchemaIdAndNameForUpdate(
+ initializedFunctionPO.schemaId(),
initializedFunctionPO.functionName())),
+ () ->
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper ->
+
mapper.selectFunctionMetaByIdForUpdate(initializedFunctionPO.functionId())),
+ current -> Objects.equals(current.schemaId(),
initializedFunctionPO.schemaId()));
+ }
+
+ /**
+ * Returns the version to write next, one above every version this function
has ever had.
+ *
+ * @param functionPO the function row observed by the caller
+ * @return the next monotonic version
+ */
+ private static int nextVersion(FunctionPO functionPO) {
+ return Math.max(functionPO.functionCurrentVersion(),
functionPO.functionLatestVersion()) + 1;
+ }
+
+ private FunctionPO functionPOForOverwrite(FunctionPO incomingPO, FunctionPO
persistedPO) {
+ int nextVersion = nextVersion(persistedPO);
+ FunctionVersionPO incomingVersionPO = incomingPO.functionVersionPO();
+ FunctionVersionPO persistedVersionPO =
+ FunctionVersionPO.builder()
+ .withFunctionId(persistedPO.functionId())
+ .withMetalakeId(incomingVersionPO.metalakeId())
+ .withCatalogId(incomingVersionPO.catalogId())
+ .withSchemaId(incomingVersionPO.schemaId())
+ .withFunctionVersion(nextVersion)
+ .withFunctionComment(incomingVersionPO.functionComment())
+ .withDefinitions(incomingVersionPO.definitions())
+ .withAuditInfo(incomingVersionPO.auditInfo())
+ .withDeletedAt(incomingVersionPO.deletedAt())
+ .build();
+ return FunctionPO.builder()
+ .withFunctionId(persistedPO.functionId())
+ .withFunctionName(incomingPO.functionName())
+ .withMetalakeId(incomingPO.metalakeId())
+ .withCatalogId(incomingPO.catalogId())
+ .withSchemaId(incomingPO.schemaId())
+ .withFunctionType(incomingPO.functionType())
+ .withDeterministic(incomingPO.deterministic())
+ .withFunctionLatestVersion(nextVersion)
+ .withFunctionCurrentVersion(nextVersion)
+ .withAuditInfo(incomingPO.auditInfo())
+ .withDeletedAt(incomingPO.deletedAt())
+ .withFunctionVersionPO(persistedVersionPO)
+ .build();
+ }
+
+ private void deleteFunctionDependents(Long functionId) {
+ SessionUtils.doWithoutCommit(
+ FunctionVersionMetaMapper.class,
+ mapper -> mapper.softDeleteFunctionVersionsByFunctionId(functionId));
+ SessionUtils.doWithoutCommit(
+ OwnerMetaMapper.class,
+ mapper ->
+ mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+ functionId, MetadataObject.Type.FUNCTION.name()));
+ SessionUtils.doWithoutCommit(
+ SecurableObjectMapper.class,
+ mapper ->
+ mapper.softDeleteObjectRelsByMetadataObject(
+ functionId, MetadataObject.Type.FUNCTION.name()));
+ SessionUtils.doWithoutCommit(
+ TagMetadataObjectRelMapper.class,
+ mapper ->
+ mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
+ functionId, MetadataObject.Type.FUNCTION.name()));
+ SessionUtils.doWithoutCommit(
+ PolicyMetadataObjectRelMapper.class,
+ mapper ->
+ mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
+ functionId, MetadataObject.Type.FUNCTION.name()));
+ }
+
+ private RuntimeException functionWriteFailure(
+ NameIdentifier identifier, FunctionPO observedFunctionPO) {
+ return OccWriteSupport.writeFailure(
+ identifier,
+ Entity.EntityType.FUNCTION,
+ () ->
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper ->
mapper.selectFunctionMetaByIdForUpdate(observedFunctionPO.functionId())),
+ null,
+ current ->
+ Objects.equals(current.functionName(),
observedFunctionPO.functionName())
+ && Objects.equals(current.schemaId(),
observedFunctionPO.schemaId())
+ && Objects.equals(current.catalogId(),
observedFunctionPO.catalogId())
+ && Objects.equals(current.metalakeId(),
observedFunctionPO.metalakeId()));
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionPOStorageOps.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionPOStorageOps.java
index 45f22a6b3e..ef3155cce9 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionPOStorageOps.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FunctionPOStorageOps.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.storage.relational.service;
+import com.google.common.base.Preconditions;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
@@ -31,13 +32,19 @@ public class FunctionPOStorageOps extends
BasePOStorageOps<FunctionPO, FunctionM
public FunctionPOStorageOps() {}
+ /**
+ * {@inheritDoc}
+ *
+ * <p>Overwrite is not supported here. {@link FunctionMetaService} replaces
an existing function
+ * by locking its root row and advancing it with a version compare-and-set,
which keeps the stored
+ * ID and the version sequence identical on every database. A
database-specific upsert would
+ * bypass that and is rejected instead of silently taking a second code path.
+ */
@Override
public void insertPO(FunctionMetaMapper mapper, FunctionPO functionPO,
boolean overwrite) {
- if (overwrite) {
- mapper.insertFunctionMetaOnDuplicateKeyUpdate(functionPO);
- } else {
- mapper.insertFunctionMeta(functionPO);
- }
+ Preconditions.checkArgument(
+ !overwrite, "Function overwrite is handled by FunctionMetaService, not
by an upsert");
+ mapper.insertFunctionMeta(functionPO);
}
@Override
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFunctionMetaBaseSQLProvider.java
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFunctionMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..0e0aeb452b
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFunctionMetaBaseSQLProvider.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.base;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestFunctionMetaBaseSQLProvider {
+
+ private static final FunctionMetaBaseSQLProvider PROVIDER = new
FunctionMetaBaseSQLProvider();
+
+ @Test
+ void testUpdateUsesOnlyIdVersionAndActiveStateForCas() {
+ String sql = PROVIDER.updateFunctionMeta(null, null);
+ String whereClause = sql.substring(sql.indexOf(" WHERE"));
+
+ Assertions.assertEquals(
+ " WHERE function_id = #{oldFunctionMeta.functionId}"
+ + " AND function_current_version =
#{oldFunctionMeta.functionCurrentVersion}"
+ + " AND deleted_at = 0",
+ whereClause);
+ }
+
+ @Test
+ void testDirectDeleteUsesVersionCas() {
+ String sql = PROVIDER.softDeleteFunctionMetaByFunctionId(null, null);
+
+ Assertions.assertTrue(sql.contains("AND function_current_version =
#{currentVersion}"));
+ Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+ }
+
+ @Test
+ void testConflictReadLocksActiveRow() {
+ String sql = PROVIDER.selectFunctionMetaByIdForUpdate(null);
+
+ Assertions.assertTrue(sql.contains("function_name as functionName"));
+ Assertions.assertTrue(sql.contains("function_current_version as
functionCurrentVersion"));
+ Assertions.assertTrue(sql.contains("WHERE function_id = #{functionId} AND
deleted_at = 0"));
+ Assertions.assertTrue(sql.endsWith("FOR UPDATE"));
+ }
+
+ @Test
+ void testNormalReadRequiresCurrentVersionRow() {
+ String sql = PROVIDER.selectFunctionMetaBySchemaIdAndName(null, null);
+
+ Assertions.assertTrue(sql.contains("fm INNER JOIN function_version_info
vi"));
+ Assertions.assertTrue(sql.endsWith("AND fm.deleted_at = 0 AND
vi.deleted_at = 0"));
+ }
+
+ @Test
+ void testOverwriteLookupLocksOnlyFunctionRoot() {
+ String sql = PROVIDER.selectFunctionMetaBySchemaIdAndNameForUpdate(null,
null);
+
+ Assertions.assertFalse(sql.contains("function_version_info"));
+ Assertions.assertTrue(sql.contains("function_current_version as
functionCurrentVersion"));
+ Assertions.assertTrue(sql.endsWith("AND deleted_at = 0 FOR UPDATE"));
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFunctionMetaPostgreSQLProvider.java
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFunctionMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..a8e4518fd4
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFunctionMetaPostgreSQLProvider.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestFunctionMetaPostgreSQLProvider {
+
+ @Test
+ void testDirectDeleteUsesVersionCas() {
+ String sql =
+ new
FunctionMetaPostgreSQLProvider().softDeleteFunctionMetaByFunctionId(null, null);
+
+ Assertions.assertTrue(sql.contains("AND function_current_version =
#{currentVersion}"));
+ Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+ }
+
+ @Test
+ void testNormalReadRequiresCurrentVersionRow() {
+ String sql =
+ new
FunctionMetaPostgreSQLProvider().selectFunctionMetaBySchemaIdAndName(null,
null);
+
+ Assertions.assertTrue(sql.contains("fm INNER JOIN function_version_info
vi"));
+ Assertions.assertTrue(sql.endsWith("AND fm.deleted_at = 0 AND
vi.deleted_at = 0"));
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFunctionMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFunctionMetaService.java
index 9cc3e039d0..4bc84df448 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFunctionMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFunctionMetaService.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.storage.relational.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -35,6 +36,12 @@ import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.NameIdentifier;
@@ -52,7 +59,13 @@ import org.apache.gravitino.meta.TagEntity;
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.FunctionMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.FunctionVersionMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
+import org.apache.gravitino.storage.relational.po.FunctionPO;
+import org.apache.gravitino.storage.relational.po.SchemaPO;
import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
import org.apache.ibatis.session.SqlSession;
@@ -71,6 +84,22 @@ public class TestFunctionMetaService extends TestJDBCBackend
{
createAndInsertSchema(metalakeName, catalogName, schemaName);
}
+ /** Dropping the old parent after a move must preserve every historical
version. */
+ @TestTemplate
+ public void testMovedFunctionHistorySurvivesSourceCascade() throws
IOException {
+ for (String parent : new String[] {"schema", "catalog", "metalake"}) {
+ assertMovedFunctionHistoryCascade(parent, true);
+ }
+ }
+
+ /** Dropping the current parent must delete versions created under previous
parents too. */
+ @TestTemplate
+ public void testMovedFunctionHistoryIsDeletedWithDestination() throws
IOException {
+ for (String parent : new String[] {"schema", "catalog", "metalake"}) {
+ assertMovedFunctionHistoryCascade(parent, false);
+ }
+ }
+
@TestTemplate
public void testInsertAlreadyExistsException() throws IOException {
FunctionEntity function =
@@ -242,6 +271,79 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
assertTrue(versions.containsKey(2));
}
+ @TestTemplate
+ public void testCreateFunctionWaitsForConcurrentSchemaDelete() throws
Exception {
+ SchemaPO observedSchemaPO =
+ SessionUtils.getWithoutCommit(
+ SchemaMetaMapper.class,
+ mapper ->
+ mapper.selectSchemaByFullQualifiedName(metalakeName,
catalogName, schemaName));
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ namespace,
+ GravitinoITUtils.genRandomName("function_parent_delete_race"),
+ AUDIT_INFO);
+
+ CountDownLatch deleteWritten = new CountDownLatch(1);
+ CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+ CountDownLatch createStarted = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> deleteResult =
+ executor.submit(
+ () -> {
+ try {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ assertEquals(
+ Integer.valueOf(1),
+ SessionUtils.getWithoutCommit(
+ SchemaMetaMapper.class,
+ mapper ->
+
mapper.softDeleteSchemaMetaBySchemaIdAndVersion(
+ observedSchemaPO.getSchemaId(),
+
observedSchemaPO.getCurrentVersion()))),
+ () -> {
+ deleteWritten.countDown();
+ await(allowDeleteCommit);
+ });
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+
+ try {
+ assertTrue(deleteWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> createResult =
+ executor.submit(
+ () -> {
+ createStarted.countDown();
+ try {
+ FunctionMetaService.getInstance().insertFunction(function,
false);
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+ assertTrue(createStarted.await(30, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> createResult.get(500,
TimeUnit.MILLISECONDS));
+
+ allowDeleteCommit.countDown();
+ assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+ Throwable createFailure = createResult.get(30, TimeUnit.SECONDS);
+ assertTrue(createFailure instanceof NoSuchEntityException,
String.valueOf(createFailure));
+ } finally {
+ allowDeleteCommit.countDown();
+ executor.shutdownNow();
+ }
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier()));
+ }
+
@TestTemplate
public void testUpdateFunctionFailsWhenSchemaIsDeletedConcurrently() throws
IOException {
String functionName = GravitinoITUtils.genRandomName("test_function");
@@ -276,7 +378,7 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
}
@TestTemplate
- public void testUpdateFunctionRollsBackNewVersionAfterConcurrentDelete()
throws IOException {
+ public void testUpdateFunctionReportsNoSuchAfterConcurrentDelete() throws
IOException {
String functionName = GravitinoITUtils.genRandomName("test_function");
Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
FunctionEntity function =
@@ -289,7 +391,7 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
FunctionEntity updatedFunction = copyFunctionWithComment(function,
"updated comment");
assertThrows(
- OptimisticLockException.class,
+ NoSuchEntityException.class,
() ->
FunctionMetaService.getInstance()
.updateFunction(
@@ -308,6 +410,333 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
assertFalse(versions.containsKey(2));
}
+ @TestTemplate
+ public void testAlterReportsOptimisticLockConflictAndKeepsWinnerVersion()
throws IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_alter_conflict");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+
+ assertThrows(
+ OptimisticLockException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ entity -> {
+ try {
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ competing ->
+ copyFunctionWithComment(
+ (FunctionEntity) competing, "competing
update"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return copyFunctionWithComment((FunctionEntity) entity,
"requested update");
+ }));
+
+ FunctionEntity current =
+
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier());
+ assertEquals("competing update", current.comment());
+ Map<Integer, Long> versions = listFunctionVersions(function.id());
+ assertEquals(2, versions.size());
+ assertTrue(versions.containsKey(2));
+ }
+
+ @TestTemplate
+ public void testAlterRollsBackRootCasWhenVersionInsertFails() throws
IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_version_insert_failure");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ FunctionPO originalPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(function.nameIdentifier());
+ FunctionEntity conflictingVersion = copyFunctionWithComment(function,
"conflicting version");
+ FunctionPO conflictingPO =
+ FunctionPO.buildFunctionPO(
+ conflictingVersion,
+ FunctionPO.builder()
+ .withMetalakeId(originalPO.metalakeId())
+ .withCatalogId(originalPO.catalogId())
+ .withSchemaId(originalPO.schemaId())
+ .withFunctionLatestVersion(2)
+ .withFunctionCurrentVersion(2),
+ 2);
+ SessionUtils.doWithCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
mapper.insertFunctionVersionMeta(conflictingPO.functionVersionPO()));
+
+ assertThrows(
+ EntityAlreadyExistsException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ entity -> copyFunctionWithComment((FunctionEntity) entity,
"must roll back")));
+
+ FunctionPO currentPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(function.nameIdentifier());
+ assertEquals(1, currentPO.functionCurrentVersion());
+ assertEquals(1, currentPO.functionLatestVersion());
+ assertEquals(
+ function.comment(),
+ FunctionMetaService.getInstance()
+ .getFunctionByIdentifier(function.nameIdentifier())
+ .comment());
+ }
+
+ @TestTemplate
+ public void testAlterReportsNoSuchWhenRenamedConcurrently() throws
IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_alter_renamed");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ NameIdentifier renamedIdentifier = NameIdentifier.of(namespace,
functionName + "_winner");
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ entity -> {
+ try {
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ competing ->
+ copyFunction(
+ (FunctionEntity) competing,
+ renamedIdentifier.name(),
+ namespace,
+ "renamed winner"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return copyFunctionWithComment((FunctionEntity) entity,
"stale update");
+ }));
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier()));
+ assertEquals(
+ "renamed winner",
+
FunctionMetaService.getInstance().getFunctionByIdentifier(renamedIdentifier).comment());
+ }
+
+ @TestTemplate
+ public void testAlterReportsNoSuchWhenMovedConcurrently() throws IOException
{
+ String targetCatalogName =
GravitinoITUtils.genRandomName("function_target_catalog");
+ String targetSchemaName =
GravitinoITUtils.genRandomName("function_target_schema");
+ createAndInsertCatalog(metalakeName, targetCatalogName);
+ createAndInsertSchema(metalakeName, targetCatalogName, targetSchemaName);
+ String functionName =
GravitinoITUtils.genRandomName("function_alter_moved");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ Namespace movedNamespace =
+ NamespaceUtil.ofFunction(metalakeName, targetCatalogName,
targetSchemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ NameIdentifier movedIdentifier = NameIdentifier.of(movedNamespace,
functionName);
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ entity -> {
+ try {
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ competing ->
+ copyFunction(
+ (FunctionEntity) competing,
+ functionName,
+ movedNamespace,
+ "moved winner"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return copyFunctionWithComment((FunctionEntity) entity,
"stale update");
+ }));
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier()));
+ assertEquals(
+ "moved winner",
+
FunctionMetaService.getInstance().getFunctionByIdentifier(movedIdentifier).comment());
+ SchemaPO targetSchemaPO =
+ SessionUtils.getWithoutCommit(
+ SchemaMetaMapper.class,
+ mapper ->
+ mapper.selectSchemaByFullQualifiedName(
+ metalakeName, targetCatalogName, targetSchemaName));
+ FunctionPO movedPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(movedIdentifier);
+ assertEquals(targetSchemaPO.getSchemaId(), movedPO.schemaId());
+ assertEquals(targetSchemaPO.getCatalogId(), movedPO.catalogId());
+ assertEquals(targetSchemaPO.getMetalakeId(), movedPO.metalakeId());
+ }
+
+ @TestTemplate
+ public void testMoveWaitsForConcurrentTargetSchemaDelete() throws Exception {
+ String targetCatalogName =
GravitinoITUtils.genRandomName("function_deleted_target_catalog");
+ String targetSchemaName =
GravitinoITUtils.genRandomName("function_deleted_target_schema");
+ createAndInsertCatalog(metalakeName, targetCatalogName);
+ createAndInsertSchema(metalakeName, targetCatalogName, targetSchemaName);
+ SchemaPO targetSchemaPO =
+ SessionUtils.getWithoutCommit(
+ SchemaMetaMapper.class,
+ mapper ->
+ mapper.selectSchemaByFullQualifiedName(
+ metalakeName, targetCatalogName, targetSchemaName));
+ String functionName =
GravitinoITUtils.genRandomName("function_target_delete_race");
+ Namespace sourceNamespace = NamespaceUtil.ofFunction(metalakeName,
catalogName, schemaName);
+ Namespace targetNamespace =
+ NamespaceUtil.ofFunction(metalakeName, targetCatalogName,
targetSchemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), sourceNamespace,
functionName, AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ FunctionEntity moved = copyFunction(function, functionName,
targetNamespace, "must not move");
+
+ CountDownLatch deleteWritten = new CountDownLatch(1);
+ CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+ CountDownLatch moveStarted = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> deleteResult =
+ executor.submit(
+ () -> {
+ try {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ assertEquals(
+ Integer.valueOf(1),
+ SessionUtils.getWithoutCommit(
+ SchemaMetaMapper.class,
+ mapper ->
+
mapper.softDeleteSchemaMetaBySchemaIdAndVersion(
+ targetSchemaPO.getSchemaId(),
+ targetSchemaPO.getCurrentVersion()))),
+ () -> {
+ deleteWritten.countDown();
+ await(allowDeleteCommit);
+ });
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+
+ try {
+ assertTrue(deleteWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> moveResult =
+ executor.submit(
+ () -> {
+ moveStarted.countDown();
+ try {
+ FunctionMetaService.getInstance()
+ .updateFunction(function.nameIdentifier(), ignored ->
moved);
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+ assertTrue(moveStarted.await(30, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> moveResult.get(500,
TimeUnit.MILLISECONDS));
+
+ allowDeleteCommit.countDown();
+ assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+ Throwable moveFailure = moveResult.get(30, TimeUnit.SECONDS);
+ assertTrue(moveFailure instanceof NoSuchEntityException,
String.valueOf(moveFailure));
+ } finally {
+ allowDeleteCommit.countDown();
+ executor.shutdownNow();
+ }
+
+ assertEquals(
+ function.comment(),
+ FunctionMetaService.getInstance()
+ .getFunctionByIdentifier(function.nameIdentifier())
+ .comment());
+ assertEquals(1, listFunctionVersions(function.id()).size());
+ }
+
+ @TestTemplate
+ public void testDeleteRejectsStaleVersion() throws IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_stale_delete");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ TagEntity tag =
+ TagEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName("function_occ_tag")
+ .withNamespace(NamespaceUtil.ofTag(metalakeName))
+ .withAuditInfo(AUDIT_INFO)
+ .build();
+ TagMetaService.getInstance().insertTag(tag, false);
+ TagMetaService.getInstance()
+ .associateTagsWithMetadataObject(
+ function.nameIdentifier(),
+ function.type(),
+ new NameIdentifier[] {tag.nameIdentifier()},
+ new NameIdentifier[0]);
+ FunctionPO stalePO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(function.nameIdentifier());
+
+ FunctionMetaService.getInstance()
+ .updateFunction(
+ function.nameIdentifier(),
+ entity -> copyFunctionWithComment((FunctionEntity) entity,
"winning update"));
+
+ assertThrows(
+ OptimisticLockException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .deleteFunctionWithVersion(function.nameIdentifier(),
stalePO));
+ assertEquals(
+ "winning update",
+ FunctionMetaService.getInstance()
+ .getFunctionByIdentifier(function.nameIdentifier())
+ .comment());
+ assertEquals(1, countActiveTagRelForMetadataObject(function.id(),
"FUNCTION"));
+ }
+
+ @TestTemplate
+ public void testDeleteReportsNoSuchWhenDeletedConcurrently() throws
IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_delete_deleted");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+ FunctionPO stalePO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(function.nameIdentifier());
+
+
FunctionMetaService.getInstance().deleteFunction(function.nameIdentifier());
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ FunctionMetaService.getInstance()
+ .deleteFunctionWithVersion(function.nameIdentifier(),
stalePO));
+ }
+
@TestTemplate
public void testDeleteFunction() throws IOException {
String functionName = GravitinoITUtils.genRandomName("test_function");
@@ -612,6 +1041,280 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
FunctionMetaService.getInstance().getFunctionByIdentifier(functionIdent);
assertEquals("overwritten comment", loadedFunction.comment());
assertTrue(loadedFunction.deterministic());
+ FunctionPO overwrittenPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(functionIdent);
+ assertEquals(2, overwrittenPO.functionCurrentVersion());
+ assertEquals(2, overwrittenPO.functionLatestVersion());
+ assertEquals(2, listFunctionVersions(function.id()).size());
+ }
+
+ @TestTemplate
+ public void testOverwriteDoesNotAdoptFunctionFromAnotherSchema() throws
IOException {
+ String otherSchemaName =
GravitinoITUtils.genRandomName("tst_fn_schema_other");
+ createAndInsertSchema(metalakeName, catalogName, otherSchemaName);
+
+ String functionName =
GravitinoITUtils.genRandomName("function_cross_schema_overwrite");
+ Namespace otherNamespace = NamespaceUtil.ofFunction(metalakeName,
catalogName, otherSchemaName);
+ FunctionEntity foreign =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), otherNamespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(foreign, false);
+
+ // The overwrite targets a schema that holds no function with this name,
but it carries the ID
+ // of a function stored under another schema. Adopting that row would move
it out of its own
+ // schema without ever fencing that schema, so the write is rejected
instead.
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity replacement = copyFunction(foreign, functionName,
namespace, "replacement");
+ assertThrows(
+ EntityAlreadyExistsException.class,
+ () -> FunctionMetaService.getInstance().insertFunction(replacement,
true));
+
+ FunctionEntity stored =
+
FunctionMetaService.getInstance().getFunctionByIdentifier(foreign.nameIdentifier());
+ FunctionPO storedPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(foreign.nameIdentifier());
+ assertEquals(otherNamespace, stored.namespace());
+ assertEquals(foreign.comment(), stored.comment());
+ assertEquals(1, storedPO.functionCurrentVersion());
+ assertEquals(1, listFunctionVersions(foreign.id()).size());
+ }
+
+ @TestTemplate
+ public void testNaturalKeyOverwriteUsesPersistedFunctionId() throws
IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_natural_key_overwrite");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity original =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(original, false);
+ FunctionEntity replacement =
+ copyFunction(
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO),
+ functionName,
+ namespace,
+ "replacement");
+
+ FunctionMetaService.getInstance().insertFunction(replacement, true);
+
+ FunctionEntity stored =
+
FunctionMetaService.getInstance().getFunctionByIdentifier(original.nameIdentifier());
+ FunctionPO storedPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(original.nameIdentifier());
+ assertEquals(original.id(), stored.id());
+ assertEquals("replacement", stored.comment());
+ assertEquals(2, storedPO.functionCurrentVersion());
+ assertEquals(2, listFunctionVersions(original.id()).size());
+ assertTrue(listFunctionVersions(replacement.id()).isEmpty());
+ }
+
+ @TestTemplate
+ public void testNormalReadRequiresCurrentVersionRow() throws IOException {
+ String functionName =
GravitinoITUtils.genRandomName("function_missing_current_version");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(function, false);
+
+ SessionUtils.doWithCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
mapper.softDeleteFunctionVersionsByFunctionId(function.id()));
+
+ assertThrows(
+ NoSuchEntityException.class,
+ () ->
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier()));
+ }
+
+ /** A deleted ID is a permanent conflict until GC, rather than a retryable
concurrent write. */
+ @TestTemplate
+ public void testOverwriteRejectsDeletedFunctionId() throws IOException {
+ Namespace ns = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity function =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), ns, "deleted_function_id",
AUDIT_INFO);
+ FunctionMetaService service = FunctionMetaService.getInstance();
+ service.insertFunction(function, false);
+ assertTrue(service.deleteFunction(function.nameIdentifier()));
+
+ EntityAlreadyExistsException failure =
+ assertThrows(
+ EntityAlreadyExistsException.class, () ->
service.insertFunction(function, true));
+ assertTrue(failure.getMessage().contains("use a new ID"));
+ assertThrows(
+ NoSuchEntityException.class,
+ () -> service.getFunctionByIdentifier(function.nameIdentifier()));
+ listFunctionVersions(function.id()).values().forEach(deletedAt ->
assertTrue(deletedAt > 0));
+
+ FunctionEntity replacement =
+ createFunctionEntity(RandomIdGenerator.INSTANCE.nextId(), ns,
function.name(), AUDIT_INFO);
+ service.insertFunction(replacement, true);
+ assertEquals(replacement.id(),
service.getFunctionByIdentifier(function.nameIdentifier()).id());
+ }
+
+ /** Verifies first-time overwrites either serialize or report a retryable
insert conflict. */
+ @TestTemplate
+ public void testConcurrentOverwriteOfMissingFunction() throws Exception {
+ String name = GravitinoITUtils.genRandomName("function_first_overwrite");
+ Namespace ns = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity first =
+ createFunctionEntity(RandomIdGenerator.INSTANCE.nextId(), ns, name,
AUDIT_INFO);
+ FunctionEntity second =
+ copyFunction(
+ createFunctionEntity(RandomIdGenerator.INSTANCE.nextId(), ns,
name, AUDIT_INFO),
+ name,
+ ns,
+ "second overwrite");
+ CountDownLatch firstWritten = new CountDownLatch(1);
+ CountDownLatch allowCommit = new CountDownLatch(1);
+ CountDownLatch secondStarted = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> firstResult =
+ executor.submit(
+ () -> {
+ SessionUtils.beginTransaction();
+ try {
+ FunctionMetaService.getInstance().insertFunction(first, true);
+ firstWritten.countDown();
+ await(allowCommit);
+ SessionUtils.commitTransaction();
+ return null;
+ } catch (Throwable failure) {
+ SessionUtils.rollbackTransaction();
+ return failure;
+ }
+ });
+ try {
+ assertTrue(firstWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> secondResult =
+ executor.submit(
+ () -> {
+ secondStarted.countDown();
+ try {
+ FunctionMetaService.getInstance().insertFunction(second,
true);
+ return null;
+ } catch (Throwable failure) {
+ return failure;
+ }
+ });
+ assertTrue(secondStarted.await(30, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> secondResult.get(500,
TimeUnit.MILLISECONDS));
+ allowCommit.countDown();
+ assertNull(firstResult.get(30, TimeUnit.SECONDS));
+ Throwable failure = secondResult.get(30, TimeUnit.SECONDS);
+ // H2 may serialize at the parent lock; MySQL/PostgreSQL can reach the
missing-row INSERT.
+ if (failure != null) {
+ assertTrue(
+ failure instanceof OptimisticLockException, () -> "Unexpected
failure: " + failure);
+ assertEquals(1, listFunctionVersions(first.id()).size());
+ assertTrue(listFunctionVersions(second.id()).isEmpty());
+ FunctionMetaService.getInstance().insertFunction(second, true);
+ }
+ FunctionEntity stored =
+
FunctionMetaService.getInstance().getFunctionByIdentifier(first.nameIdentifier());
+ assertEquals(first.id(), stored.id());
+ assertEquals("second overwrite", stored.comment());
+ assertEquals(
+ 2,
+ FunctionMetaService.getInstance()
+ .getFunctionPOByIdentifier(first.nameIdentifier())
+ .functionCurrentVersion());
+ assertEquals(2, listFunctionVersions(first.id()).size());
+ assertTrue(listFunctionVersions(second.id()).isEmpty());
+ } finally {
+ allowCommit.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @TestTemplate
+ public void testNaturalKeyOverwriteWaitsForConcurrentRename() throws
Exception {
+ String functionName =
GravitinoITUtils.genRandomName("function_overwrite_rename_race");
+ Namespace namespace = NamespaceUtil.ofFunction(metalakeName, catalogName,
schemaName);
+ FunctionEntity original =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+ FunctionMetaService.getInstance().insertFunction(original, false);
+ FunctionPO observedPO =
+
FunctionMetaService.getInstance().getFunctionPOByIdentifier(original.nameIdentifier());
+ FunctionEntity renamed =
+ copyFunction(original, functionName + "_winner", namespace, "rename
winner");
+ FunctionPO renamedPO =
+ FunctionPO.buildFunctionPO(
+ renamed,
+ FunctionPO.builder()
+ .withMetalakeId(observedPO.metalakeId())
+ .withCatalogId(observedPO.catalogId())
+ .withSchemaId(observedPO.schemaId())
+ .withFunctionLatestVersion(2)
+ .withFunctionCurrentVersion(2),
+ 2);
+ FunctionEntity replacement =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(), namespace, functionName,
AUDIT_INFO);
+
+ CountDownLatch renameWritten = new CountDownLatch(1);
+ CountDownLatch allowRenameCommit = new CountDownLatch(1);
+ CountDownLatch overwriteStarted = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> renameResult =
+ executor.submit(
+ () -> {
+ try {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ assertEquals(
+ Integer.valueOf(1),
+ SessionUtils.getWithoutCommit(
+ FunctionMetaMapper.class,
+ mapper -> mapper.updateFunctionMeta(renamedPO,
observedPO))),
+ () ->
+ SessionUtils.doWithoutCommit(
+ FunctionVersionMetaMapper.class,
+ mapper ->
+
mapper.insertFunctionVersionMeta(renamedPO.functionVersionPO())),
+ () -> {
+ renameWritten.countDown();
+ await(allowRenameCommit);
+ });
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+
+ try {
+ assertTrue(renameWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> overwriteResult =
+ executor.submit(
+ () -> {
+ overwriteStarted.countDown();
+ try {
+
FunctionMetaService.getInstance().insertFunction(replacement, true);
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+ assertTrue(overwriteStarted.await(30, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> overwriteResult.get(500,
TimeUnit.MILLISECONDS));
+
+ allowRenameCommit.countDown();
+ assertNull(renameResult.get(30, TimeUnit.SECONDS));
+ assertNull(overwriteResult.get(30, TimeUnit.SECONDS));
+ } finally {
+ allowRenameCommit.countDown();
+ executor.shutdownNow();
+ }
+
+ assertEquals(
+ original.id(),
+
FunctionMetaService.getInstance().getFunctionByIdentifier(renamed.nameIdentifier()).id());
+ assertEquals(
+ replacement.id(),
+ FunctionMetaService.getInstance()
+ .getFunctionByIdentifier(replacement.nameIdentifier())
+ .id());
}
private int countActiveOwnerRelForMetadataObject(
@@ -677,6 +1380,71 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
}
}
+ private void assertMovedFunctionHistoryCascade(String parent, boolean
sourceFirst)
+ throws IOException {
+ String sourceMetalake = "source_function_" + parent + "_" + sourceFirst;
+ String destinationMetalake = "destination_function_" + parent + "_" +
sourceFirst;
+ String catalog = "history_catalog";
+ String schema = "history_schema";
+ for (String metalake : new String[] {sourceMetalake, destinationMetalake})
{
+ createAndInsertMakeLake(metalake);
+ createAndInsertCatalog(metalake, catalog);
+ createAndInsertSchema(metalake, catalog, schema);
+ }
+ FunctionMetaService service = FunctionMetaService.getInstance();
+ FunctionEntity original =
+ createFunctionEntity(
+ RandomIdGenerator.INSTANCE.nextId(),
+ NamespaceUtil.ofFunction(sourceMetalake, catalog, schema),
+ "moved_history",
+ AUDIT_INFO);
+ service.insertFunction(original, false);
+ FunctionEntity moved =
+ copyFunction(
+ original,
+ original.name(),
+ NamespaceUtil.ofFunction(destinationMetalake, catalog, schema),
+ "moved");
+ service.updateFunction(original.nameIdentifier(), ignored -> moved);
+ assertEquals(2, listFunctionVersions(original.id()).size());
+
+ if (sourceFirst) {
+ deleteHistoryParent(parent, sourceMetalake, catalog, schema);
+ assertEquals(original.id(),
service.getFunctionByIdentifier(moved.nameIdentifier()).id());
+ assertEquals(2, listFunctionVersions(original.id()).size());
+ listFunctionVersions(original.id())
+ .values()
+ .forEach(deletedAt -> assertEquals(0L, deletedAt.longValue()));
+ }
+
+ deleteHistoryParent(parent, destinationMetalake, catalog, schema);
+ assertThrows(
+ NoSuchEntityException.class, () ->
service.getFunctionByIdentifier(moved.nameIdentifier()));
+ assertEquals(2, listFunctionVersions(original.id()).size());
+ listFunctionVersions(original.id()).values().forEach(deletedAt ->
assertTrue(deletedAt > 0));
+ }
+
+ private void deleteHistoryParent(String parent, String metalake, String
catalog, String schema) {
+ switch (parent) {
+ case "schema":
+ assertTrue(
+ SchemaMetaService.getInstance()
+ .deleteSchema(NameIdentifier.of(metalake, catalog, schema),
true));
+ break;
+ case "catalog":
+ assertTrue(
+ CatalogMetaService.getInstance()
+ .deleteCatalog(NameIdentifier.of(metalake, catalog), true));
+ break;
+ case "metalake":
+ assertTrue(
+
MetalakeMetaService.getInstance().deleteMetalake(NameIdentifier.of(metalake),
true));
+ break;
+ default:
+ throw new AssertionError("Unexpected parent: " + parent);
+ }
+ }
+
private Map<Integer, Long> listFunctionVersions(Long functionId) {
Map<Integer, Long> versionDeletedTime = new HashMap<>();
try (SqlSession sqlSession =
@@ -698,10 +1466,15 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
}
private FunctionEntity copyFunctionWithComment(FunctionEntity function,
String comment) {
+ return copyFunction(function, function.name(), function.namespace(),
comment);
+ }
+
+ private FunctionEntity copyFunction(
+ FunctionEntity function, String name, Namespace namespace, String
comment) {
return FunctionEntity.builder()
.withId(function.id())
- .withName(function.name())
- .withNamespace(function.namespace())
+ .withName(name)
+ .withNamespace(namespace)
.withComment(comment)
.withFunctionType(function.functionType())
.withDeterministic(function.deterministic())
@@ -710,6 +1483,15 @@ public class TestFunctionMetaService extends
TestJDBCBackend {
.build();
}
+ private void await(CountDownLatch latch) {
+ try {
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
private void assertVersionActive(Map<Integer, Long> versionDeletedMap, int
version) {
assertTrue(versionDeletedMap.containsKey(version));
assertEquals(0L, versionDeletedMap.get(version));