This is an automated email from the ASF dual-hosted git repository.
mchades 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 4b6216d284 [#14140] feat(core): Persist Semantic Model metric
identities (#14141)
4b6216d284 is described below
commit 4b6216d284ec34bf7d192d732efd8ef18055727f
Author: mchades <[email protected]>
AuthorDate: Tue Oct 27 20:43:05 2026 +0800
[#14140] feat(core): Persist Semantic Model metric identities (#14141)
### What changes were proposed in this pull request?
Add `semantic_model_metric_meta` and reconcile metric identities in the
existing model transaction. Exact-name matches retain IDs; removed names
are retired, and reintroduced names receive new IDs. Integrate
model/parent deletion and GC across H2, MySQL, and PostgreSQL.
Skip identity reconciliation when metric names are unchanged, while
preserving duplicate-name validation and complete definition snapshots.
Insert new identities in batches of up to 1,000 rows.
### Why are the changes needed?
Provide the identity foundation for schema-level metric discovery
described in #14038, under #14036.
Builds on #14139, now merged into `main`.
Fixes #14140.
### Does this PR introduce _any_ user-facing change?
No public API changes.
### How was this patch tested?
30 focused H2 tests passed: 16 metric-identity tests and 14 Semantic
Model storage tests. Coverage includes definition replacement, stable
IDs, whitespace-only names, duplicate rejection, 1,001-row insertion,
and rollback across batches.
Ran `spotlessApply` and `:core:coreH2Test` with
`TestSemanticModelMetricIdentities` and `TestSemanticModelJDBCBackend`
filters. Compilation and `git diff --check` passed.
Local MySQL/PostgreSQL tests were not rerun because the container IPs
remain unreachable; these backends are covered by the dedicated Fork CI
lanes.
---
.../mapper/SemanticModelMetricMapper.java | 85 +++
.../SemanticModelMetricSQLProviderFactory.java | 96 ++++
.../provider/DefaultMapperPackageProvider.java | 2 +
.../base/SemanticModelMetricBaseSQLProvider.java | 114 ++++
.../SemanticModelMetricPostgreSQLProvider.java | 43 ++
.../relational/po/SemanticModelMetricPO.java | 69 +++
.../relational/service/CatalogMetaService.java | 7 +-
.../relational/service/MetalakeMetaService.java | 7 +-
.../relational/service/SchemaMetaService.java | 7 +-
.../service/SemanticModelMetaService.java | 102 +++-
.../service/TestSemanticModelMetaService.java | 38 +-
.../service/TestSemanticModelMetricIdentities.java | 628 +++++++++++++++++++++
design-docs/gravitino-semantic-model-design.md | 29 +-
scripts/h2/schema-2.0.0-h2.sql | 15 +
scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql | 15 +
scripts/mysql/schema-2.0.0-mysql.sql | 15 +
scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql | 15 +
scripts/postgresql/schema-2.0.0-postgresql.sql | 23 +
.../upgrade-1.3.0-to-2.0.0-postgresql.sql | 23 +
19 files changed, 1322 insertions(+), 11 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricMapper.java
new file mode 100644
index 0000000000..3bf1319c92
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricMapper.java
@@ -0,0 +1,85 @@
+/*
+ * 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;
+
+import java.util.List;
+import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/** A MyBatis mapper for identities of metrics contained in Semantic Models. */
+public interface SemanticModelMetricMapper {
+ /** The metric identity table name. */
+ String TABLE_NAME = "semantic_model_metric_meta";
+
+ /** Inserts new metric identities in one statement. */
+ @InsertProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "insertSemanticModelMetrics")
+ void insertSemanticModelMetrics(@Param("metrics")
List<SemanticModelMetricPO> metricPOs);
+
+ /** Locks and reads the live identities of a Semantic Model. */
+ @SelectProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "selectSemanticModelMetricsByModelIdForUpdate")
+ List<SemanticModelMetricPO> selectSemanticModelMetricsByModelIdForUpdate(
+ @Param("semanticModelId") Long semanticModelId);
+
+ /** Retires selected live identities belonging to a Semantic Model. */
+ @UpdateProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "softDeleteSemanticModelMetricsByIds")
+ Integer softDeleteSemanticModelMetricsByIds(
+ @Param("semanticModelId") Long semanticModelId, @Param("metricIds")
List<Long> metricIds);
+
+ /** Retires all live metric identities of a Semantic Model. */
+ @UpdateProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "softDeleteSemanticModelMetricsBySemanticModelId")
+ Integer softDeleteSemanticModelMetricsBySemanticModelId(
+ @Param("semanticModelId") Long semanticModelId);
+
+ /** Retires live metric identities under schemas. */
+ @UpdateProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "softDeleteSemanticModelMetricsBySchemaIds")
+ Integer softDeleteSemanticModelMetricsBySchemaIds(@Param("schemaIds")
List<Long> schemaIds);
+
+ /** Retires live metric identities under a catalog. */
+ @UpdateProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "softDeleteSemanticModelMetricsByCatalogId")
+ Integer softDeleteSemanticModelMetricsByCatalogId(@Param("catalogId") Long
catalogId);
+
+ /** Retires live metric identities under a metalake. */
+ @UpdateProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "softDeleteSemanticModelMetricsByMetalakeId")
+ Integer softDeleteSemanticModelMetricsByMetalakeId(@Param("metalakeId") Long
metalakeId);
+
+ /** Permanently deletes retired identities older than a timeline. */
+ @DeleteProvider(
+ type = SemanticModelMetricSQLProviderFactory.class,
+ method = "deleteSemanticModelMetricsByLegacyTimeline")
+ Integer deleteSemanticModelMetricsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricSQLProviderFactory.java
new file mode 100644
index 0000000000..4649b0bcfd
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SemanticModelMetricSQLProviderFactory.java
@@ -0,0 +1,96 @@
+/*
+ * 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;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import
org.apache.gravitino.storage.relational.mapper.provider.base.SemanticModelMetricBaseSQLProvider;
+import
org.apache.gravitino.storage.relational.mapper.provider.postgresql.SemanticModelMetricPostgreSQLProvider;
+import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/** Selects database-specific SQL providers for Semantic Model metric
identities. */
+public class SemanticModelMetricSQLProviderFactory {
+ private static final Map<JDBCBackendType,
SemanticModelMetricBaseSQLProvider> PROVIDERS =
+ ImmutableMap.of(
+ JDBCBackendType.MYSQL, new SemanticModelMetricBaseSQLProvider(),
+ JDBCBackendType.H2, new SemanticModelMetricBaseSQLProvider(),
+ JDBCBackendType.POSTGRESQL, new
SemanticModelMetricPostgreSQLProvider());
+
+ /** Returns the SQL provider for the configured relational backend. */
+ public static SemanticModelMetricBaseSQLProvider getProvider() {
+ String databaseId =
+ SqlSessionFactoryHelper.getInstance()
+ .getSqlSessionFactory()
+ .getConfiguration()
+ .getDatabaseId();
+ return PROVIDERS.get(JDBCBackendType.fromString(databaseId));
+ }
+
+ /** Returns SQL for inserting new metric identities in one statement. */
+ public static String insertSemanticModelMetrics(
+ @Param("metrics") List<SemanticModelMetricPO> metricPOs) {
+ return getProvider().insertSemanticModelMetrics(metricPOs);
+ }
+
+ /** Locks and reads the live identities of a Semantic Model. */
+ public static String selectSemanticModelMetricsByModelIdForUpdate(
+ @Param("semanticModelId") Long semanticModelId) {
+ return
getProvider().selectSemanticModelMetricsByModelIdForUpdate(semanticModelId);
+ }
+
+ /** Retires selected live identities belonging to a Semantic Model. */
+ public static String softDeleteSemanticModelMetricsByIds(
+ @Param("semanticModelId") Long semanticModelId, @Param("metricIds")
List<Long> metricIds) {
+ return getProvider().softDeleteSemanticModelMetricsByIds(semanticModelId,
metricIds);
+ }
+
+ /** Retires all live metric identities of a Semantic Model. */
+ public static String softDeleteSemanticModelMetricsBySemanticModelId(
+ @Param("semanticModelId") Long semanticModelId) {
+ return
getProvider().softDeleteSemanticModelMetricsBySemanticModelId(semanticModelId);
+ }
+
+ /** Retires live metric identities under schemas. */
+ public static String softDeleteSemanticModelMetricsBySchemaIds(
+ @Param("schemaIds") List<Long> schemaIds) {
+ return getProvider().softDeleteSemanticModelMetricsBySchemaIds(schemaIds);
+ }
+
+ /** Retires live metric identities under a catalog. */
+ public static String softDeleteSemanticModelMetricsByCatalogId(
+ @Param("catalogId") Long catalogId) {
+ return getProvider().softDeleteSemanticModelMetricsByCatalogId(catalogId);
+ }
+
+ /** Retires live metric identities under a metalake. */
+ public static String softDeleteSemanticModelMetricsByMetalakeId(
+ @Param("metalakeId") Long metalakeId) {
+ return
getProvider().softDeleteSemanticModelMetricsByMetalakeId(metalakeId);
+ }
+
+ /** Permanently deletes retired identities older than a timeline. */
+ public static String deleteSemanticModelMetricsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
+ return
getProvider().deleteSemanticModelMetricsByLegacyTimeline(legacyTimeline, limit);
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
index ae8773fd75..32f289750c 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/DefaultMapperPackageProvider.java
@@ -44,6 +44,7 @@ import
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -87,6 +88,7 @@ public class DefaultMapperPackageProvider implements
MapperPackageProvider {
SchemaMetaMapper.class,
SecurableObjectMapper.class,
SemanticModelMetaMapper.class,
+ SemanticModelMetricMapper.class,
SemanticModelVersionInfoMapper.class,
StatisticMetaMapper.class,
TableColumnMapper.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelMetricBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelMetricBaseSQLProvider.java
new file mode 100644
index 0000000000..f424a31fb9
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SemanticModelMetricBaseSQLProvider.java
@@ -0,0 +1,114 @@
+/*
+ * 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 static
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper.TABLE_NAME;
+
+import java.util.List;
+import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
+import org.apache.ibatis.annotations.Param;
+
+/** Provides MySQL-compatible SQL for Semantic Model metric identities. */
+public class SemanticModelMetricBaseSQLProvider {
+
+ /** Returns SQL for inserting new identities in one statement. */
+ public String insertSemanticModelMetrics(
+ @Param("metrics") List<SemanticModelMetricPO> metricPOs) {
+ return "<script>INSERT INTO "
+ + TABLE_NAME
+ + " (metric_id, metalake_id, catalog_id, schema_id, semantic_model_id,
metric_name, deleted_at)"
+ + " VALUES "
+ + "<foreach collection='metrics' item='metric' separator=','>"
+ + "(#{metric.metricId}, #{metric.metalakeId}, #{metric.catalogId},"
+ + " #{metric.schemaId}, #{metric.semanticModelId},
#{metric.metricName}, #{metric.deletedAt})"
+ + "</foreach></script>";
+ }
+
+ /** Returns a locking read of live identities after the containing model's
concurrency check. */
+ public String selectSemanticModelMetricsByModelIdForUpdate(
+ @Param("semanticModelId") Long semanticModelId) {
+ return "SELECT metric_id as metricId, metalake_id as metalakeId,
catalog_id as catalogId,"
+ + " schema_id as schemaId, semantic_model_id as semanticModelId,
metric_name as metricName,"
+ + " deleted_at as deletedAt FROM "
+ + TABLE_NAME
+ + " WHERE semantic_model_id = #{semanticModelId} AND deleted_at = 0
FOR UPDATE";
+ }
+
+ /** Returns SQL for retiring selected live identities of a model. */
+ public String softDeleteSemanticModelMetricsByIds(
+ @Param("semanticModelId") Long semanticModelId, @Param("metricIds")
List<Long> metricIds) {
+ return "<script>"
+ + softDeleteBy(
+ "semantic_model_id = #{semanticModelId} AND metric_id IN ("
+ + "<foreach collection='metricIds' item='metricId'
separator=','>"
+ + "#{metricId}</foreach>)")
+ + "</script>";
+ }
+
+ /** Returns SQL for retiring all live identities of a model. */
+ public String softDeleteSemanticModelMetricsBySemanticModelId(
+ @Param("semanticModelId") Long semanticModelId) {
+ return softDeleteBy("semantic_model_id = #{semanticModelId}");
+ }
+
+ /** Returns SQL for retiring live identities under schemas. */
+ public String softDeleteSemanticModelMetricsBySchemaIds(
+ @Param("schemaIds") List<Long> schemaIds) {
+ return "<script>"
+ + softDeleteBy(
+ "schema_id IN ("
+ + "<foreach collection='schemaIds' item='schemaId'
separator=','>"
+ + "#{schemaId}</foreach>)")
+ + "</script>";
+ }
+
+ /** Returns SQL for retiring live identities under a catalog. */
+ public String softDeleteSemanticModelMetricsByCatalogId(@Param("catalogId")
Long catalogId) {
+ return softDeleteBy("catalog_id = #{catalogId}");
+ }
+
+ /** Returns SQL for retiring live identities under a metalake. */
+ public String
softDeleteSemanticModelMetricsByMetalakeId(@Param("metalakeId") Long
metalakeId) {
+ return softDeleteBy("metalake_id = #{metalakeId}");
+ }
+
+ /** Returns SQL for permanently deleting old retired identities. */
+ public String deleteSemanticModelMetricsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
+ return "DELETE FROM "
+ + TABLE_NAME
+ + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit}";
+ }
+
+ /** Returns the database expression for the current time in epoch
milliseconds. */
+ protected String currentTimestampMillis() {
+ return "(UNIX_TIMESTAMP() * 1000.0)"
+ + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000";
+ }
+
+ private String softDeleteBy(String condition) {
+ return "UPDATE "
+ + TABLE_NAME
+ + " SET deleted_at = "
+ + currentTimestampMillis()
+ + " WHERE "
+ + condition
+ + " AND deleted_at = 0";
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelMetricPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelMetricPostgreSQLProvider.java
new file mode 100644
index 0000000000..6eb59bf351
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SemanticModelMetricPostgreSQLProvider.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 static
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper.TABLE_NAME;
+
+import
org.apache.gravitino.storage.relational.mapper.provider.base.SemanticModelMetricBaseSQLProvider;
+import org.apache.ibatis.annotations.Param;
+
+/** Provides PostgreSQL SQL for Semantic Model metric identities. */
+public class SemanticModelMetricPostgreSQLProvider extends
SemanticModelMetricBaseSQLProvider {
+
+ @Override
+ public String deleteSemanticModelMetricsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
+ return "DELETE FROM "
+ + TABLE_NAME
+ + " WHERE metric_id IN (SELECT metric_id FROM "
+ + TABLE_NAME
+ + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit})";
+ }
+
+ @Override
+ protected String currentTimestampMillis() {
+ return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/po/SemanticModelMetricPO.java
b/core/src/main/java/org/apache/gravitino/storage/relational/po/SemanticModelMetricPO.java
new file mode 100644
index 0000000000..453103bb8e
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/po/SemanticModelMetricPO.java
@@ -0,0 +1,69 @@
+/*
+ * 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.po;
+
+import com.google.common.base.Preconditions;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.StringUtils;
+
+/** The persistent identity and lifecycle state of a metric contained in a
Semantic Model. */
+@EqualsAndHashCode
+@Getter
+@ToString
+@Accessors(fluent = true)
+public class SemanticModelMetricPO {
+ private Long metricId;
+ private Long metalakeId;
+ private Long catalogId;
+ private Long schemaId;
+ private Long semanticModelId;
+ private String metricName;
+ private Long deletedAt;
+
+ /** Creates an empty persistent object for MyBatis. */
+ public SemanticModelMetricPO() {}
+
+ @lombok.Builder(setterPrefix = "with")
+ private SemanticModelMetricPO(
+ Long metricId,
+ Long metalakeId,
+ Long catalogId,
+ Long schemaId,
+ Long semanticModelId,
+ String metricName,
+ Long deletedAt) {
+ Preconditions.checkArgument(metricId != null, "Metric id is required");
+ Preconditions.checkArgument(metalakeId != null, "Metalake id is required");
+ Preconditions.checkArgument(catalogId != null, "Catalog id is required");
+ Preconditions.checkArgument(schemaId != null, "Schema id is required");
+ Preconditions.checkArgument(semanticModelId != null, "Semantic Model id is
required");
+ Preconditions.checkArgument(StringUtils.isNotEmpty(metricName), "Metric
name cannot be empty");
+ Preconditions.checkArgument(deletedAt != null, "Deleted at is required");
+ this.metricId = metricId;
+ this.metalakeId = metalakeId;
+ this.catalogId = catalogId;
+ this.schemaId = schemaId;
+ this.semanticModelId = semanticModelId;
+ this.metricName = metricName;
+ this.deletedAt = deletedAt;
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
index 8ef3740548..45b12caf2a 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
@@ -49,6 +49,7 @@ import
org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -362,7 +363,11 @@ public class CatalogMetaService {
() ->
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
- mapper ->
mapper.softDeleteSemanticModelVersionsByCatalogId(catalogId)));
+ mapper ->
mapper.softDeleteSemanticModelVersionsByCatalogId(catalogId)),
+ () ->
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.softDeleteSemanticModelMetricsByCatalogId(catalogId)));
} else {
SessionUtils.doMultipleWithCommit(
() -> {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
index c4eb17fe3f..02f94121ae 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
@@ -55,6 +55,7 @@ import
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -346,7 +347,11 @@ public class MetalakeMetaService {
() ->
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
- mapper ->
mapper.softDeleteSemanticModelVersionsByMetalakeId(metalakeId)));
+ mapper ->
mapper.softDeleteSemanticModelVersionsByMetalakeId(metalakeId)),
+ () ->
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.softDeleteSemanticModelMetricsByMetalakeId(metalakeId)));
} else {
SessionUtils.doMultipleWithCommit(
() -> {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
index 951f3c799b..01ce984a66 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
@@ -57,6 +57,7 @@ import
org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
import org.apache.gravitino.storage.relational.mapper.TableColumnMapper;
@@ -388,7 +389,11 @@ public class SchemaMetaService {
() ->
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
- mapper ->
mapper.softDeleteSemanticModelVersionsBySchemaIds(schemaIds.get())));
+ mapper ->
mapper.softDeleteSemanticModelVersionsBySchemaIds(schemaIds.get())),
+ () ->
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.softDeleteSemanticModelMetricsBySchemaIds(schemaIds.get())));
} else {
SessionUtils.doMultipleWithCommit(
() -> {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
index ce8d32246e..582481f4c7 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SemanticModelMetaService.java
@@ -24,15 +24,21 @@ import static
org.apache.gravitino.storage.relational.po.SemanticModelPO.fromSem
import static
org.apache.gravitino.storage.relational.po.SemanticModelPO.initializeSemanticModelPO;
import com.google.common.base.Preconditions;
+import com.google.common.collect.Lists;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.HasIdentifier;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
@@ -40,9 +46,14 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.meta.SemanticModelEntity;
import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.storage.IdGenerator;
import org.apache.gravitino.storage.relational.EntityChangeLogWriter;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
+import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
import org.apache.gravitino.storage.relational.po.SemanticModelPO;
import org.apache.gravitino.storage.relational.po.SemanticModelVersionInfoPO;
import org.apache.gravitino.storage.relational.po.cache.OperateType;
@@ -57,6 +68,7 @@ import org.slf4j.LoggerFactory;
public class SemanticModelMetaService {
private static final Logger LOG =
LoggerFactory.getLogger(SemanticModelMetaService.class);
+ private static final int METRIC_INSERT_BATCH_SIZE = 1000;
private static final SemanticModelMetaService INSTANCE = new
SemanticModelMetaService();
private final BasePOStorageOps<SemanticModelPO, SemanticModelMetaMapper> ops;
@@ -127,7 +139,7 @@ public class SemanticModelMetaService {
}
/**
- * Inserts or overwrites a Semantic Model identity and its snapshot
atomically.
+ * Inserts or overwrites a Semantic Model, its snapshot, and its metric
identities atomically.
*
* @param semanticModelEntity The Semantic Model entity.
* @param overwrite Whether to overwrite rows for the same stable ID or
natural key.
@@ -173,6 +185,7 @@ public class SemanticModelMetaService {
}
persistedPO.set(replacementPO);
}),
+ () -> reconcileMetricIdentities(persistedPO.get(),
semanticModelEntity.definition()),
() ->
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
@@ -250,6 +263,15 @@ public class SemanticModelMetaService {
throw semanticModelWriteFailure(identifier, oldSemanticModelPO);
}
},
+ () -> {
+ if (!Objects.equals(
+
oldSemanticModelPO.getSemanticModelVersionInfoPO().metrics(),
+
newSemanticModelPO.getSemanticModelVersionInfoPO().metrics())
+ && !metricNames(oldSemanticModelEntity.definition())
+ .equals(metricNames(newEntity.definition()))) {
+ reconcileMetricIdentities(newSemanticModelPO,
newEntity.definition());
+ }
+ },
() ->
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
@@ -273,7 +295,7 @@ public class SemanticModelMetaService {
}
/**
- * Soft-deletes a Semantic Model identity and all of its version snapshots.
+ * Soft-deletes a Semantic Model, its metric identities, and all of its
version snapshots.
*
* @param identifier The Semantic Model identifier.
* @return {@code true} when an active identity was deleted.
@@ -290,7 +312,8 @@ public class SemanticModelMetaService {
}
/**
- * Permanently deletes soft-deleted Semantic Model identities and snapshots
older than a timeline.
+ * Permanently deletes retired metric identities and deleted models and
snapshots older than a
+ * timeline.
*
* @param legacyTimeline The exclusive deletion timeline in epoch
milliseconds.
* @param limit The maximum rows to delete from each table.
@@ -300,6 +323,10 @@ public class SemanticModelMetaService {
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteSemanticModelMetasByLegacyTimeline")
public int deleteSemanticModelMetasByLegacyTimeline(Long legacyTimeline, int
limit) {
+ int metricDeletedCount =
+ SessionUtils.doWithCommitAndFetchResult(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.deleteSemanticModelMetricsByLegacyTimeline(legacyTimeline, limit));
int versionDeletedCount =
SessionUtils.doWithCommitAndFetchResult(
SemanticModelVersionInfoMapper.class,
@@ -308,7 +335,7 @@ public class SemanticModelMetaService {
SessionUtils.doWithCommitAndFetchResult(
SemanticModelMetaMapper.class,
mapper ->
mapper.deleteSemanticModelMetasByLegacyTimeline(legacyTimeline, limit));
- return versionDeletedCount + metaDeletedCount;
+ return metricDeletedCount + versionDeletedCount + metaDeletedCount;
}
/**
@@ -385,11 +412,78 @@ public class SemanticModelMetaService {
SessionUtils.doWithoutCommit(
SemanticModelVersionInfoMapper.class,
mapper ->
mapper.softDeleteSemanticModelVersionsBySemanticModelId(semanticModelId)),
+ () ->
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.softDeleteSemanticModelMetricsBySemanticModelId(semanticModelId)),
() ->
EntityChangeLogWriter.append(
semanticModelIdent, Entity.EntityType.SEMANTIC_MODEL,
OperateType.DROP));
}
+ private static void reconcileMetricIdentities(
+ SemanticModelPO model, SemanticModelDefinition definition) {
+ Set<String> names = metricNames(definition);
+
+ // The caller has already inserted or version-checked the model in this
transaction. A locking
+ // read also observes the latest identities on backends using
repeatable-read isolation.
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper -> {
+ Map<String, SemanticModelMetricPO> live =
+ mapper
+
.selectSemanticModelMetricsByModelIdForUpdate(model.getSemanticModelId())
+ .stream()
+ .collect(
+ Collectors.toMap(SemanticModelMetricPO::metricName,
Function.identity()));
+ List<String> addedNames = new ArrayList<>();
+ for (String name : names) {
+ if (live.remove(name) == null) {
+ addedNames.add(name);
+ }
+ }
+ if (!addedNames.isEmpty()) {
+ IdGenerator generator = GravitinoEnv.getInstance().idGenerator();
+ Preconditions.checkState(
+ generator != null, "Metric identity ID generator is not
initialized");
+ List<SemanticModelMetricPO> addedMetrics = new
ArrayList<>(addedNames.size());
+ for (String name : addedNames) {
+ addedMetrics.add(
+ SemanticModelMetricPO.builder()
+ .withMetricId(generator.nextId())
+ .withMetalakeId(model.getMetalakeId())
+ .withCatalogId(model.getCatalogId())
+ .withSchemaId(model.getSchemaId())
+ .withSemanticModelId(model.getSemanticModelId())
+ .withMetricName(name)
+ .withDeletedAt(0L)
+ .build());
+ }
+ Lists.partition(addedMetrics, METRIC_INSERT_BATCH_SIZE)
+ .forEach(mapper::insertSemanticModelMetrics);
+ }
+ if (!live.isEmpty()) {
+ mapper.softDeleteSemanticModelMetricsByIds(
+ model.getSemanticModelId(),
+ live.values().stream()
+ .map(SemanticModelMetricPO::metricId)
+ .collect(Collectors.toList()));
+ }
+ });
+ }
+
+ private static Set<String> metricNames(SemanticModelDefinition definition) {
+ Set<String> names = new LinkedHashSet<>();
+ Metric[] metrics = definition.metrics();
+ if (metrics != null) {
+ for (Metric metric : metrics) {
+ Preconditions.checkArgument(
+ names.add(metric.name()), "Duplicate metric name: %s",
metric.name());
+ }
+ }
+ return names;
+ }
+
private SemanticModelPO updateSemanticModelPO(
SemanticModelPO oldSemanticModelPO, SemanticModelEntity newEntity) {
int previousVersion =
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetaService.java
index d89969d301..e2f4d94f01 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetaService.java
@@ -52,6 +52,9 @@ import org.apache.gravitino.meta.SemanticModelEntity;
import org.apache.gravitino.semantic.AIContext;
import org.apache.gravitino.semantic.CustomExtension;
import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Metric;
import org.apache.gravitino.semantic.SemanticModelDefinition;
import org.apache.gravitino.storage.RandomIdGenerator;
import
org.apache.gravitino.storage.relational.EntityChangeLogNameIdentifierCodec;
@@ -59,6 +62,7 @@ import
org.apache.gravitino.storage.relational.TestJDBCBackend;
import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SemanticModelMetaMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
import org.apache.gravitino.storage.relational.po.SchemaPO;
import org.apache.gravitino.storage.relational.po.SemanticModelPO;
@@ -306,6 +310,11 @@ public class TestSemanticModelMetaService extends
TestJDBCBackend {
assertEquals(0, countSemanticModelIdentities(semanticModel.id()));
assertTrue(listSemanticModelVersions(semanticModel.id()).isEmpty());
+ assertTrue(
+ SessionUtils.getWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.selectSemanticModelMetricsByModelIdForUpdate(semanticModel.id()))
+ .isEmpty());
}
@TestTemplate
@@ -362,6 +371,11 @@ public class TestSemanticModelMetaService extends
TestJDBCBackend {
Map<Integer, VersionState> versions =
listSemanticModelVersions(original.id());
assertEquals(1, versions.size());
assertTrue(versions.get(1).deletedAt > 0L);
+ assertTrue(
+ SessionUtils.getWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.selectSemanticModelMetricsByModelIdForUpdate(original.id()))
+ .isEmpty());
}
@TestTemplate
@@ -496,7 +510,7 @@ public class TestSemanticModelMetaService extends
TestJDBCBackend {
int deleted =
SemanticModelMetaService.getInstance()
.deleteSemanticModelMetasByLegacyTimeline(Instant.now().toEpochMilli() + 1000,
100);
- assertEquals(4, deleted);
+ assertEquals(7, deleted);
assertEquals(0, listSemanticModelVersions(finalCurrent.id()).size());
assertEquals(0, countSemanticModelIdentities(finalCurrent.id()));
}
@@ -518,6 +532,22 @@ public class TestSemanticModelMetaService extends
TestJDBCBackend {
.withSource(NameIdentifier.of("sales", "mart",
datasetName))
.build()
})
+ .withMetrics(
+ new Metric[] {
+ Metric.builder()
+ .withName("metric_" + datasetName)
+ .withExpression(
+ Expression.builder()
+ .withDialects(
+ new DialectExpression[] {
+ DialectExpression.builder()
+ .withDialect("ansi")
+ .withExpression("COUNT(*)")
+ .build()
+ })
+ .build())
+ .build()
+ })
.withCustomExtensions(
new CustomExtension[] {
CustomExtension.builder()
@@ -576,6 +606,12 @@ public class TestSemanticModelMetaService extends
TestJDBCBackend {
SemanticModelVersionInfoMapper.class,
mapper ->
mapper.softDeleteSemanticModelVersionsBySchemaIds(
+ List.of(observedSchemaPO.getSchemaId()))),
+ () ->
+ SessionUtils.doWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
+ mapper.softDeleteSemanticModelMetricsBySchemaIds(
List.of(observedSchemaPO.getSchemaId()))));
return null;
} catch (Throwable throwable) {
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetricIdentities.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetricIdentities.java
new file mode 100644
index 0000000000..09376b147a
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSemanticModelMetricIdentities.java
@@ -0,0 +1,628 @@
+/*
+ * 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.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+
+import java.io.IOException;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.SemanticModelEntity;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelMetricMapper;
+import
org.apache.gravitino.storage.relational.mapper.SemanticModelVersionInfoMapper;
+import org.apache.gravitino.storage.relational.po.SemanticModelMetricPO;
+import org.apache.gravitino.storage.relational.po.SemanticModelPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.TestTemplate;
+import org.mockito.MockedStatic;
+
+/** Tests metric identity rules, atomic writes, and cleanup on every
relational backend. */
+public class TestSemanticModelMetricIdentities extends TestJDBCBackend {
+
+ @TestTemplate
+ void testExactNamesContentChangesAndReintroduction() throws Exception {
+ Namespace namespace = parents();
+ String longName = "metric_" + "x".repeat(300);
+ SemanticModelEntity original = model(namespace, "sales", "Revenue",
"revenue", "收入'", longName);
+ backend.insert(original, false);
+ Map<String, Long> initial = liveIds(original.id());
+ assertEquals(Set.of("Revenue", "revenue", "收入'", longName),
initial.keySet());
+ assertEquals(4, Set.copyOf(initial.values()).size());
+ SemanticModelPO stored =
service().getSemanticModelPOByIdentifier(original.nameIdentifier());
+ for (SemanticModelMetricPO metric : live(original.id())) {
+ assertEquals(stored.getMetalakeId(), metric.metalakeId());
+ assertEquals(stored.getCatalogId(), metric.catalogId());
+ assertEquals(stored.getSchemaId(), metric.schemaId());
+ assertEquals(original.id(), metric.semanticModelId());
+ assertEquals(0L, metric.deletedAt());
+ }
+
+ Metric[] reordered =
+ Arrays.stream(new String[] {longName, "收入'", "revenue", "Revenue"})
+ .map(
+ name ->
+ Metric.builder()
+ .withName(name)
+ .withExpression(expression("SUM(orders.amount) * 2"))
+ .withDescription("Updated description")
+ .build())
+ .toArray(Metric[]::new);
+ SemanticModelEntity renamed = copy(original, original.id(), "renamed",
reordered);
+ try (MockedStatic<SessionUtils> sessions = mockStatic(SessionUtils.class,
CALLS_REAL_METHODS)) {
+ backend.update(original.nameIdentifier(), original.type(), old ->
renamed);
+ sessions.verify(
+ () ->
SessionUtils.doWithoutCommit(eq(SemanticModelMetricMapper.class), any()),
never());
+ }
+ assertEquals(renamed, backend.get(renamed.nameIdentifier(),
renamed.type()));
+ assertEquals(
+ 2,
service().getSemanticModelPOByIdentifier(renamed.nameIdentifier()).getCurrentVersion());
+ assertEquals(2, count("semantic_model_version_info", original.id()));
+ assertEquals(initial, liveIds(original.id()));
+ assertEquals(4, count("semantic_model_metric_meta", original.id()));
+
+ SemanticModelEntity reduced = copy(renamed, renamed.id(), renamed.name(),
metrics("Revenue"));
+ backend.update(renamed.nameIdentifier(), renamed.type(), old -> reduced);
+ assertEquals(Map.of("Revenue", initial.get("Revenue")),
liveIds(original.id()));
+ SemanticModelEntity reintroduced =
+ copy(reduced, reduced.id(), reduced.name(), metrics("Revenue",
"revenue"));
+ backend.update(reduced.nameIdentifier(), reduced.type(), old ->
reintroduced);
+ Map<String, Long> current = liveIds(original.id());
+ assertEquals(initial.get("Revenue"), current.get("Revenue"));
+ assertNotEquals(initial.get("revenue"), current.get("revenue"));
+ assertEquals(5, count("semantic_model_metric_meta", original.id()));
+ assertSnapshotNames(reintroduced);
+ }
+
+ @TestTemplate
+ void testUnchangedMetricsSkipIdentityReconciliation() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "revenue");
+ backend.insert(original, false);
+ Map<String, Long> initial = liveIds(original.id());
+ SemanticModelEntity replacement =
+ copy(original, original.id(), original.name(),
original.definition().metrics());
+
+ try (MockedStatic<SessionUtils> sessions = mockStatic(SessionUtils.class,
CALLS_REAL_METHODS)) {
+ backend.update(original.nameIdentifier(), original.type(), old ->
replacement);
+ sessions.verify(
+ () ->
SessionUtils.doWithoutCommit(eq(SemanticModelMetricMapper.class), any()),
never());
+ }
+
+ assertEquals(replacement, backend.get(replacement.nameIdentifier(),
replacement.type()));
+ assertEquals(
+ 2,
+
service().getSemanticModelPOByIdentifier(replacement.nameIdentifier()).getCurrentVersion());
+ assertEquals(2, count("semantic_model_version_info", original.id()));
+ assertEquals(initial, liveIds(original.id()));
+ assertEquals(1, count("semantic_model_metric_meta", original.id()));
+ }
+
+ @TestTemplate
+ void testCreateWithMetricsAcrossInsertBatches() throws Exception {
+ String[] names = IntStream.range(0, 1001).mapToObj(i -> "metric_" +
i).toArray(String[]::new);
+ SemanticModelEntity original = model(parents(), "sales", names);
+ backend.insert(original, false);
+
+ Map<String, Long> identities = liveIds(original.id());
+ assertEquals(Set.copyOf(Arrays.asList(names)), identities.keySet());
+ assertEquals(names.length, Set.copyOf(identities.values()).size());
+ assertEquals(names.length, count("semantic_model_metric_meta",
original.id()));
+ assertEquals(original, backend.get(original.nameIdentifier(),
original.type()));
+ }
+
+ @TestTemplate
+ void testReconciliationWithoutNewNamesDoesNotRequireIdGenerator() throws
Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "kept",
"removed");
+ backend.insert(original, false);
+ long keptId = liveIds(original.id()).get("kept");
+ SemanticModelEntity reduced = copy(original, original.id(),
original.name(), metrics("kept"));
+ SemanticModelEntity imported = model(namespace, original.name(), "kept");
+ SemanticModelEntity empty = copy(reduced, reduced.id(), reduced.name(),
null);
+
+ IdGenerator originalGenerator = GravitinoEnv.getInstance().idGenerator();
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator", null,
true);
+ try {
+ backend.update(original.nameIdentifier(), original.type(), old ->
reduced);
+ assertEquals(Map.of("kept", keptId), liveIds(original.id()));
+ backend.insert(imported, true);
+ assertEquals(Map.of("kept", keptId), liveIds(original.id()));
+ backend.update(original.nameIdentifier(), original.type(), old -> empty);
+ } finally {
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator",
originalGenerator, true);
+ }
+
+ assertTrue(liveIds(original.id()).isEmpty());
+ assertEquals(2, count("semantic_model_metric_meta", original.id()));
+ assertEquals(empty, backend.get(original.nameIdentifier(),
original.type()));
+ assertEquals(
+ 4,
service().getSemanticModelPOByIdentifier(original.nameIdentifier()).getCurrentVersion());
+ }
+
+ @TestTemplate
+ void testWhitespaceOnlyNamesOnCreateReplaceAndOverwrite() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", " ");
+ backend.insert(original, false);
+ Map<String, Long> initial = liveIds(original.id());
+ assertEquals(Set.of(" "), initial.keySet());
+ assertSnapshotNames(backend.get(original.nameIdentifier(),
original.type()));
+
+ SemanticModelEntity replacement =
+ copy(original, original.id(), original.name(), metrics(" ", "\t"));
+ backend.update(original.nameIdentifier(), original.type(), old ->
replacement);
+ Map<String, Long> replaced = liveIds(original.id());
+ assertEquals(Set.of(" ", "\t"), replaced.keySet());
+ assertEquals(initial.get(" "), replaced.get(" "));
+ assertSnapshotNames(backend.get(original.nameIdentifier(),
original.type()));
+
+ SemanticModelEntity imported = model(namespace, original.name(), " ",
"\t", "\n");
+ backend.insert(imported, true);
+ Map<String, Long> overwritten = liveIds(original.id());
+ assertEquals(Set.of(" ", "\t", "\n"), overwritten.keySet());
+ assertEquals(replaced.get(" "), overwritten.get(" "));
+ assertEquals(replaced.get("\t"), overwritten.get("\t"));
+ assertEquals(3, Set.copyOf(overwritten.values()).size());
+ assertEquals(0, count("semantic_model_metric_meta", imported.id()));
+ SemanticModelEntity loaded = backend.get(original.nameIdentifier(),
original.type());
+ assertEquals(original.id(), loaded.id());
+ assertSnapshotNames(loaded);
+ }
+
+ @TestTemplate
+ void testAbsentEmptyAndModelScopedNames() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity absent = model(namespace, "absent", (String[]) null);
+ SemanticModelEntity empty = model(namespace, "empty");
+ backend.insert(absent, false);
+ backend.insert(empty, true);
+ assertEquals(0, count("semantic_model_metric_meta", absent.id()));
+ assertEquals(0, count("semantic_model_metric_meta", empty.id()));
+ SemanticModelEntity first = model(namespace, "first", "revenue");
+ SemanticModelEntity second = model(namespace, "second", "revenue");
+ backend.insert(first, false);
+ backend.insert(second, false);
+ assertNotEquals(liveIds(first.id()).get("revenue"),
liveIds(second.id()).get("revenue"));
+ SemanticModelEntity noMetrics = copy(first, first.id(), first.name(),
null);
+ backend.update(first.nameIdentifier(), first.type(), old -> noMetrics);
+ assertTrue(liveIds(first.id()).isEmpty());
+ assertEquals(Set.of("revenue"), liveIds(second.id()).keySet());
+ }
+
+ @TestTemplate
+ void testOverwriteUsesPersistedModelAndMetricIds() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "kept",
"removed");
+ backend.insert(original, true);
+ Map<String, Long> initial = liveIds(original.id());
+ SemanticModelEntity imported = model(namespace, original.name(), "kept",
"added");
+ backend.insert(imported, true);
+ assertEquals(Set.of("kept", "added"), liveIds(original.id()).keySet());
+ assertEquals(initial.get("kept"), liveIds(original.id()).get("kept"));
+ assertEquals(0, count("semantic_model_metric_meta", imported.id()));
+ assertEquals(
+ 2,
service().getSemanticModelPOByIdentifier(original.nameIdentifier()).getCurrentVersion());
+ SemanticModelEntity loaded = backend.get(original.nameIdentifier(),
original.type());
+ assertSnapshotNames(loaded);
+ }
+
+ @TestTemplate
+ void testDropAndRecreateAllocatesNewIdentities() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "revenue");
+ backend.insert(original, false);
+ long previousId = liveIds(original.id()).get("revenue");
+ assertTrue(backend.delete(original.nameIdentifier(), original.type(),
false));
+ assertTrue(live(original.id()).isEmpty());
+ SemanticModelEntity recreated = model(namespace, original.name(),
"revenue");
+ backend.insert(recreated, false);
+ assertNotEquals(previousId, liveIds(recreated.id()).get("revenue"));
+ assertEquals(1, count("semantic_model_metric_meta", original.id()));
+ }
+
+ @TestTemplate
+ void testDuplicateNamesRollBackCreateAndReplacement() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity duplicate = model(namespace, "duplicate", "same",
"same");
+ assertThrows(IllegalArgumentException.class, () ->
backend.insert(duplicate, false));
+ assertEquals(0, count("semantic_model_meta", duplicate.id()));
+ assertEquals(0, count("semantic_model_metric_meta", duplicate.id()));
+ SemanticModelEntity original = model(namespace, "sales", "original");
+ backend.insert(original, false);
+ Map<String, Long> before = liveIds(original.id());
+ for (String duplicateName : new String[] {"same", "original"}) {
+ SemanticModelEntity invalid =
+ copy(original, original.id(), original.name(),
metrics(duplicateName, duplicateName));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> backend.update(original.nameIdentifier(), original.type(), old
-> invalid));
+ assertEquals(original, backend.get(original.nameIdentifier(),
original.type()));
+ assertEquals(before, liveIds(original.id()));
+ assertEquals(1, count("semantic_model_version_info", original.id()));
+ }
+ }
+
+ @TestTemplate
+ void testSnapshotFailureRollsBackCreateReplaceAndOverwrite() throws
Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "kept",
"removed");
+ backend.insert(original, false);
+ Map<String, Long> before = liveIds(original.id());
+ SemanticModelEntity replacement =
+ copy(original, original.id(), original.name(), metrics("kept",
"added"));
+ SemanticModelEntity created = model(namespace, "new_model", "new_metric");
+ try (MockedStatic<SessionUtils> sessions = mockStatic(SessionUtils.class,
CALLS_REAL_METHODS)) {
+ sessions
+ .when(() ->
SessionUtils.doWithoutCommit(eq(SemanticModelVersionInfoMapper.class), any()))
+ .thenThrow(new IllegalStateException("Injected snapshot failure"));
+ assertThrows(IllegalStateException.class, () -> backend.insert(created,
false));
+ assertThrows(
+ IllegalStateException.class,
+ () -> backend.update(original.nameIdentifier(), original.type(), old
-> replacement));
+ assertThrows(IllegalStateException.class, () ->
backend.insert(replacement, true));
+ }
+ assertEquals(original, backend.get(original.nameIdentifier(),
original.type()));
+ assertEquals(before, liveIds(original.id()));
+ assertEquals(2, count("semantic_model_metric_meta", original.id()));
+ assertEquals(1, count("semantic_model_version_info", original.id()));
+ assertEquals(0, count("semantic_model_meta", created.id()));
+ assertEquals(0, count("semantic_model_metric_meta", created.id()));
+ assertEquals(0, count("semantic_model_version_info", created.id()));
+ }
+
+ @TestTemplate
+ void testIdentityInsertFailureRollsBackAllBatches() throws Exception {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "existing");
+ backend.insert(original, false);
+ Map<String, Long> before = liveIds(original.id());
+ IdGenerator originalGenerator = GravitinoEnv.getInstance().idGenerator();
+ for (int metricCount : new int[] {2, 1001}) {
+ String[] names =
+ IntStream.range(0, metricCount).mapToObj(i -> "metric_" +
i).toArray(String[]::new);
+ SemanticModelEntity replacement =
+ copy(original, original.id(), original.name(), metrics(names));
+ AtomicInteger allocated = new AtomicInteger();
+ IdGenerator duplicateGenerator =
+ () ->
+ allocated.incrementAndGet() < metricCount
+ ? originalGenerator.nextId()
+ : before.get("existing");
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator",
duplicateGenerator, true);
+ try {
+ assertThrows(
+ EntityAlreadyExistsException.class,
+ () -> backend.update(original.nameIdentifier(), original.type(),
old -> replacement));
+ } finally {
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator",
originalGenerator, true);
+ }
+ assertEquals(metricCount, allocated.get());
+ assertEquals(before, liveIds(original.id()));
+ assertEquals(1, count("semantic_model_metric_meta", original.id()));
+ assertEquals(original, backend.get(original.nameIdentifier(),
original.type()));
+ assertEquals(1, count("semantic_model_version_info", original.id()));
+ }
+ }
+
+ @TestTemplate
+ void testStaleUpdateAndDropLeaveWinningIdentities() throws Exception {
+ SemanticModelEntity original = model(parents(), "sales", "original",
"kept");
+ backend.insert(original, false);
+ SemanticModelPO stale =
service().getSemanticModelPOByIdentifier(original.nameIdentifier());
+ long keptId = liveIds(original.id()).get("kept");
+ SemanticModelEntity winner =
+ copy(original, original.id(), original.name(), metrics("winner",
"kept"));
+ SemanticModelEntity loser = copy(original, original.id(), original.name(),
metrics("loser"));
+ assertThrows(
+ OptimisticLockException.class,
+ () ->
+ backend.update(
+ original.nameIdentifier(),
+ original.type(),
+ old -> {
+ try {
+ backend.update(original.nameIdentifier(), original.type(),
ignored -> winner);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return loser;
+ }));
+ assertThrows(
+ OptimisticLockException.class,
+ () ->
service().deleteSemanticModelWithVersion(original.nameIdentifier(), stale));
+ assertEquals(Set.of("winner", "kept"), liveIds(original.id()).keySet());
+ assertEquals(keptId, liveIds(original.id()).get("kept"));
+ assertEquals(3, count("semantic_model_metric_meta", original.id()));
+ assertEquals(winner, backend.get(original.nameIdentifier(),
original.type()));
+ }
+
+ @TestTemplate
+ void testConcurrentOverwritesKeepIdentityAndSnapshotConsistent() throws
Exception {
+ SemanticModelEntity original = model(parents(), "sales", "kept",
"original");
+ backend.insert(original, false);
+ long keptId = liveIds(original.id()).get("kept");
+ SemanticModelEntity first =
+ copy(original, original.id(), original.name(), metrics("kept",
"first"));
+ SemanticModelEntity second =
+ copy(original, original.id(), original.name(), metrics("kept",
"second"));
+ CountDownLatch ready = new CountDownLatch(2);
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ List<Future<?>> writes =
+ List.of(first, second).stream()
+ .map(
+ model ->
+ executor.submit(
+ () -> {
+ ready.countDown();
+ assertTrue(start.await(30, TimeUnit.SECONDS));
+ backend.insert(model, true);
+ return null;
+ }))
+ .collect(Collectors.toList());
+ assertTrue(ready.await(30, TimeUnit.SECONDS));
+ start.countDown();
+ for (Future<?> write : writes) {
+ write.get(30, TimeUnit.SECONDS);
+ }
+ } finally {
+ start.countDown();
+ executor.shutdownNow();
+ }
+ SemanticModelEntity current = backend.get(original.nameIdentifier(),
original.type());
+ assertTrue(current.equals(first) || current.equals(second));
+ assertSnapshotNames(current);
+ assertEquals(keptId, liveIds(original.id()).get("kept"));
+ assertEquals(4, count("semantic_model_metric_meta", original.id()));
+ }
+
+ @TestTemplate
+ void testParentCascadesRetireOnlyTheirMetricsAndAllowCleanup() throws
Exception {
+ SemanticModelEntity unrelated = model(parents(), "unrelated", "live");
+ backend.insert(unrelated, false);
+ Map<String, Long> liveBefore = liveIds(unrelated.id());
+ for (Entity.EntityType type :
+ List.of(Entity.EntityType.SCHEMA, Entity.EntityType.CATALOG,
Entity.EntityType.METALAKE)) {
+ Namespace namespace = parents();
+ SemanticModelEntity original = model(namespace, "sales", "revenue");
+ backend.insert(original, false);
+ NameIdentifier parent = parent(namespace, type);
+ assertTrue(backend.delete(parent, type, true));
+ assertTrue(live(original.id()).isEmpty());
+ assertEquals(1, count("semantic_model_metric_meta", original.id()));
+ // Purge the parent first: metric cleanup must not rely on joining a
surviving parent row.
+ backend.hardDeleteLegacyData(type, Long.MAX_VALUE);
+ backend.hardDeleteLegacyData(Entity.EntityType.SEMANTIC_MODEL,
Long.MAX_VALUE);
+ assertEquals(0, count("semantic_model_metric_meta", original.id()));
+ assertEquals(liveBefore, liveIds(unrelated.id()));
+ }
+ }
+
+ @TestTemplate
+ void testCascadeFailureRollsBackModelAndMetricDeletion() throws Exception {
+ SemanticModelEntity original = model(parents(), "sales", "revenue");
+ backend.insert(original, false);
+ Map<String, Long> before = liveIds(original.id());
+ NameIdentifier schema = parent(original.namespace(),
Entity.EntityType.SCHEMA);
+ assertThrows(
+ NonEmptyEntityException.class,
+ () -> backend.delete(schema, Entity.EntityType.SCHEMA, false));
+ try (MockedStatic<SessionUtils> sessions = mockStatic(SessionUtils.class,
CALLS_REAL_METHODS)) {
+ sessions
+ .when(() ->
SessionUtils.doWithoutCommit(eq(SemanticModelMetricMapper.class), any()))
+ .thenThrow(new IllegalStateException("Injected identity retirement
failure"));
+ assertThrows(
+ IllegalStateException.class,
+ () -> backend.delete(schema, Entity.EntityType.SCHEMA, true));
+ assertThrows(
+ IllegalStateException.class,
+ () -> backend.delete(original.nameIdentifier(), original.type(),
false));
+ }
+ assertEquals(original, backend.get(original.nameIdentifier(),
original.type()));
+ assertEquals(before, liveIds(original.id()));
+ }
+
+ @TestTemplate
+ void testGarbageCollectionHonorsTimelineLimitAndLiveIdentities() throws
Exception {
+ SemanticModelEntity original = model(parents(), "sales", "kept",
"removed1", "removed2");
+ backend.insert(original, false);
+ long keptId = liveIds(original.id()).get("kept");
+ SemanticModelEntity updated = copy(original, original.id(),
original.name(), metrics("kept"));
+ backend.update(original.nameIdentifier(), original.type(), old -> updated);
+ try (SqlSession session =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ PreparedStatement statement =
+ session
+ .getConnection()
+ .prepareStatement(
+ "UPDATE semantic_model_metric_meta SET deleted_at = 10
WHERE deleted_at > 0")) {
+ assertEquals(2, statement.executeUpdate());
+ }
+ assertEquals(0, service().deleteSemanticModelMetasByLegacyTimeline(10L,
1));
+ assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L,
1));
+ assertEquals(2, count("semantic_model_metric_meta", original.id()));
+ assertEquals(1, service().deleteSemanticModelMetasByLegacyTimeline(11L,
1));
+ assertEquals(1,
backend.deleteOldVersionData(Entity.EntityType.SEMANTIC_MODEL, 1));
+ backend.hardDeleteLegacyData(Entity.EntityType.SEMANTIC_MODEL,
Long.MAX_VALUE);
+ assertEquals(Map.of("kept", keptId), liveIds(original.id()));
+ assertEquals(1, count("semantic_model_metric_meta", original.id()));
+ assertEquals(1, count("semantic_model_version_info", original.id()));
+ }
+
+ private Namespace parents() throws IOException {
+ String suffix = Long.toUnsignedString(RandomIdGenerator.INSTANCE.nextId());
+ String metalake = "metric_metalake_" + suffix;
+ String catalog = "metric_catalog_" + suffix;
+ createAndInsertMakeLake(metalake);
+ createAndInsertCatalog(metalake, catalog);
+ createAndInsertSchema(metalake, catalog, "schema");
+ return Namespace.of(metalake, catalog, "schema");
+ }
+
+ private static NameIdentifier parent(Namespace namespace, Entity.EntityType
type) {
+ switch (type) {
+ case SCHEMA:
+ return NameIdentifier.of(namespace.levels());
+ case CATALOG:
+ return NameIdentifier.of(namespace.level(0), namespace.level(1));
+ case METALAKE:
+ return NameIdentifier.of(namespace.level(0));
+ default:
+ throw new IllegalArgumentException("Unsupported parent type: " + type);
+ }
+ }
+
+ private static SemanticModelEntity model(
+ Namespace namespace, String name, @Nullable String... names) {
+ return SemanticModelEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(name)
+ .withNamespace(namespace)
+ .withAuditInfo(AUDIT_INFO)
+ .withProperties(Map.of())
+ .withDefinition(
+ SemanticModelDefinition.builder()
+ .withDatasets(
+ new Dataset[] {
+ Dataset.builder()
+ .withName("orders")
+ .withSource(NameIdentifier.of("sales", "orders"))
+ .build()
+ })
+ .withMetrics(names == null ? null : metrics(names))
+ .build())
+ .build();
+ }
+
+ private static SemanticModelEntity copy(
+ SemanticModelEntity source, Long id, String name, @Nullable Metric[]
metrics) {
+ return SemanticModelEntity.builder()
+ .withId(id)
+ .withName(name)
+ .withNamespace(source.namespace())
+ .withComment("updated")
+ .withAuditInfo(AUDIT_INFO)
+ .withProperties(Map.of("revision", "updated"))
+ .withDefinition(
+ SemanticModelDefinition.builder()
+ .withDatasets(source.definition().datasets())
+ .withMetrics(metrics)
+ .build())
+ .build();
+ }
+
+ private static Expression expression(String sql) {
+ return Expression.builder()
+ .withDialects(
+ new DialectExpression[] {
+
DialectExpression.builder().withDialect("ansi").withExpression(sql).build()
+ })
+ .build();
+ }
+
+ private static Metric[] metrics(String... names) {
+ return Arrays.stream(names)
+ .map(
+ name ->
+ Metric.builder()
+ .withName(name)
+ .withExpression(expression("SUM(orders.amount)"))
+ .build())
+ .toArray(Metric[]::new);
+ }
+
+ private static SemanticModelMetaService service() {
+ return SemanticModelMetaService.getInstance();
+ }
+
+ private static List<SemanticModelMetricPO> live(Long modelId) {
+ return SessionUtils.getWithoutCommit(
+ SemanticModelMetricMapper.class,
+ mapper ->
mapper.selectSemanticModelMetricsByModelIdForUpdate(modelId));
+ }
+
+ private static Map<String, Long> liveIds(Long modelId) {
+ return live(modelId).stream()
+ .collect(
+ Collectors.toMap(SemanticModelMetricPO::metricName,
SemanticModelMetricPO::metricId));
+ }
+
+ private static void assertSnapshotNames(SemanticModelEntity model) {
+ Set<String> names =
+
Arrays.stream(model.definition().metrics()).map(Metric::name).collect(Collectors.toSet());
+ assertEquals(names, liveIds(model.id()).keySet());
+ }
+
+ private static int count(String table, long modelId) throws SQLException {
+ try (SqlSession session =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ PreparedStatement statement =
+ session
+ .getConnection()
+ .prepareStatement(
+ "SELECT COUNT(*) FROM " + table + " WHERE
semantic_model_id = ?")) {
+ statement.setLong(1, modelId);
+ try (ResultSet results = statement.executeQuery()) {
+ assertTrue(results.next());
+ return results.getInt(1);
+ }
+ }
+ }
+}
diff --git a/design-docs/gravitino-semantic-model-design.md
b/design-docs/gravitino-semantic-model-design.md
index 117c2a62d5..ac72ab07cc 100644
--- a/design-docs/gravitino-semantic-model-design.md
+++ b/design-docs/gravitino-semantic-model-design.md
@@ -600,14 +600,30 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
COMMENT 'semantic model version information';
+
+CREATE TABLE IF NOT EXISTS `semantic_model_metric_meta` (
+ `metric_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'stable internal metric
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'catalog id',
+ `schema_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'schema id',
+ `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing
semantic model id',
+ `metric_name` MEDIUMTEXT NOT NULL COMMENT 'metric name',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'metric
retired at',
+ PRIMARY KEY (`metric_id`),
+ KEY `idx_smmm_model_live` (`semantic_model_id`, `deleted_at`),
+ KEY `idx_smmm_mid` (`metalake_id`),
+ KEY `idx_smmm_cid` (`catalog_id`),
+ KEY `idx_smmm_sid` (`schema_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model metric identities';
```
Each version row stores the complete definition in five JSON columns:
`datasets`, `relationships`,
`metrics`, `ai_context`, and `custom_extensions`. Typed converters preserve
the existing field
representations and collection order, including string-or-object AI context.
Absent optional fields
use SQL `NULL`, while explicit empty collections use JSON `[]`. Create and
alter still validate and
-persist all definition fields atomically in one snapshot. Child members are
not stored in separate
-tables because they do not have independent identity, lifecycle, or
authorization.
+persist all definition fields atomically in one snapshot. Metric definitions
remain in the snapshot;
+`semantic_model_metric_meta` stores only their internal IDs, exact names,
parent IDs, and retirement
+state. Other child members remain embedded. Editing and authorization remain
model-scoped.
These Semantic Model tables are introduced in 2.0.0. Before that release,
storage changes update the
2.0.0 schema and 1.3.0-to-2.0.0 upgrade scripts in place for all supported
databases. Released versions
@@ -615,7 +631,14 @@ have no Semantic Model data to migrate.
#### Version and Lifecycle Behavior
-- Create writes the identity row and version 1 snapshot in one transaction.
+- Create writes the model identity, version 1 snapshot, and metric identities
in one transaction.
+- Definition replacement and overwrite reconcile metric identities in the
model transaction after
+ the concurrency check. Exact-name matches retain their IDs, added names
receive new IDs, and
+ removed names are retired. Reintroducing a removed name allocates a new ID.
Model renames and
+ metric content or order changes preserve IDs; IDs are not exposed in public
APIs.
+- Model drops and schema/catalog/metalake cascades retire live metric
identities in the same
+ transaction as model deletion. Garbage collection purges retired identities
after the configured
+ retention period; snapshot retention never retires live metric identities.
- Alter follows Gravitino's existing version-based OCC mechanism. It
atomically writes a new
snapshot, increments `last_version`, and advances `current_version` with a
server-internal CAS. A
transaction-level CAS conflict rolls back and returns `409` with
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index 2c41c3e1e6..a3fda62695 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -612,6 +612,21 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB COMMENT 'semantic model version information';
+CREATE TABLE IF NOT EXISTS `semantic_model_metric_meta` (
+ `metric_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'stable internal metric
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'catalog id',
+ `schema_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'schema id',
+ `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing
semantic model id',
+ `metric_name` CLOB NOT NULL COMMENT 'metric name',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'metric
retired at',
+ PRIMARY KEY (`metric_id`),
+ KEY `idx_smmm_model_live` (`semantic_model_id`, `deleted_at`),
+ KEY `idx_smmm_mid` (`metalake_id`),
+ KEY `idx_smmm_cid` (`catalog_id`),
+ KEY `idx_smmm_sid` (`schema_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model metric identities';
+
-- This schema extends version 1.1.0 with partition statistics storage support
-- The partition_statistic_meta table stores partition-level statistics for
tables
diff --git a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
index c979b98762..4a80bab24f 100644
--- a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
+++ b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
@@ -106,6 +106,21 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB COMMENT 'semantic model version information';
+CREATE TABLE IF NOT EXISTS `semantic_model_metric_meta` (
+ `metric_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'stable internal metric
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'catalog id',
+ `schema_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'schema id',
+ `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing
semantic model id',
+ `metric_name` CLOB NOT NULL COMMENT 'metric name',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'metric
retired at',
+ PRIMARY KEY (`metric_id`),
+ KEY `idx_smmm_model_live` (`semantic_model_id`, `deleted_at`),
+ KEY `idx_smmm_mid` (`metalake_id`),
+ KEY `idx_smmm_cid` (`catalog_id`),
+ KEY `idx_smmm_sid` (`schema_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model metric identities';
+
CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
`task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
`task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql
b/scripts/mysql/schema-2.0.0-mysql.sql
index 6790855212..b8e79b9afa 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -603,6 +603,21 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model version information';
+CREATE TABLE IF NOT EXISTS `semantic_model_metric_meta` (
+ `metric_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'stable internal metric
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'catalog id',
+ `schema_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'schema id',
+ `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing
semantic model id',
+ `metric_name` MEDIUMTEXT NOT NULL COMMENT 'metric name',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'metric
retired at',
+ PRIMARY KEY (`metric_id`),
+ KEY `idx_smmm_model_live` (`semantic_model_id`, `deleted_at`),
+ KEY `idx_smmm_mid` (`metalake_id`),
+ KEY `idx_smmm_cid` (`catalog_id`),
+ KEY `idx_smmm_sid` (`schema_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model metric identities';
+
-- This schema extends version 1.1.0 with partition statistics storage support
-- The partition_statistic_meta table stores partition-level statistics for
tables
diff --git a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
index 8f1cc5e977..77ea3e487f 100644
--- a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
@@ -553,6 +553,21 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model version information';
+CREATE TABLE IF NOT EXISTS `semantic_model_metric_meta` (
+ `metric_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'stable internal metric
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `catalog_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'catalog id',
+ `schema_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'schema id',
+ `semantic_model_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'containing
semantic model id',
+ `metric_name` MEDIUMTEXT NOT NULL COMMENT 'metric name',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'metric
retired at',
+ PRIMARY KEY (`metric_id`),
+ KEY `idx_smmm_model_live` (`semantic_model_id`, `deleted_at`),
+ KEY `idx_smmm_mid` (`metalake_id`),
+ KEY `idx_smmm_cid` (`catalog_id`),
+ KEY `idx_smmm_sid` (`schema_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model metric identities';
+
CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
`task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
`task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index 7efb0a60ba..109b8bc456 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -1070,6 +1070,29 @@ COMMENT ON COLUMN semantic_model_version_info.properties
IS 'semantic model prop
COMMENT ON COLUMN semantic_model_version_info.audit_info IS 'semantic model
version audit info';
COMMENT ON COLUMN semantic_model_version_info.deleted_at IS 'version deleted
at';
+CREATE TABLE IF NOT EXISTS semantic_model_metric_meta (
+ metric_id BIGINT NOT NULL,
+ metalake_id BIGINT NOT NULL,
+ catalog_id BIGINT NOT NULL,
+ schema_id BIGINT NOT NULL,
+ semantic_model_id BIGINT NOT NULL,
+ metric_name TEXT NOT NULL,
+ deleted_at BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (metric_id)
+);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_model_live ON
semantic_model_metric_meta (semantic_model_id, deleted_at);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_metalake_id ON
semantic_model_metric_meta (metalake_id);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_catalog_id ON
semantic_model_metric_meta (catalog_id);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_schema_id ON
semantic_model_metric_meta (schema_id);
+COMMENT ON TABLE semantic_model_metric_meta IS 'semantic model metric
identities';
+COMMENT ON COLUMN semantic_model_metric_meta.metric_id IS 'stable internal
metric id';
+COMMENT ON COLUMN semantic_model_metric_meta.metalake_id IS 'metalake id';
+COMMENT ON COLUMN semantic_model_metric_meta.catalog_id IS 'catalog id';
+COMMENT ON COLUMN semantic_model_metric_meta.schema_id IS 'schema id';
+COMMENT ON COLUMN semantic_model_metric_meta.semantic_model_id IS 'containing
semantic model id';
+COMMENT ON COLUMN semantic_model_metric_meta.metric_name IS 'metric name';
+COMMENT ON COLUMN semantic_model_metric_meta.deleted_at IS 'metric retired at';
+
-- This schema extends version 1.1.0 with partition statistics storage support
-- The partition_statistic_meta table stores partition-level statistics for
tables
diff --git a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
index 1bb1bd0556..b5a70c34ed 100644
--- a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
@@ -159,6 +159,29 @@ COMMENT ON COLUMN semantic_model_version_info.properties
IS 'semantic model prop
COMMENT ON COLUMN semantic_model_version_info.audit_info IS 'semantic model
version audit info';
COMMENT ON COLUMN semantic_model_version_info.deleted_at IS 'version deleted
at';
+CREATE TABLE IF NOT EXISTS semantic_model_metric_meta (
+ metric_id BIGINT NOT NULL,
+ metalake_id BIGINT NOT NULL,
+ catalog_id BIGINT NOT NULL,
+ schema_id BIGINT NOT NULL,
+ semantic_model_id BIGINT NOT NULL,
+ metric_name TEXT NOT NULL,
+ deleted_at BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (metric_id)
+);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_model_live ON
semantic_model_metric_meta (semantic_model_id, deleted_at);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_metalake_id ON
semantic_model_metric_meta (metalake_id);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_catalog_id ON
semantic_model_metric_meta (catalog_id);
+CREATE INDEX IF NOT EXISTS semantic_model_metric_meta_idx_schema_id ON
semantic_model_metric_meta (schema_id);
+COMMENT ON TABLE semantic_model_metric_meta IS 'semantic model metric
identities';
+COMMENT ON COLUMN semantic_model_metric_meta.metric_id IS 'stable internal
metric id';
+COMMENT ON COLUMN semantic_model_metric_meta.metalake_id IS 'metalake id';
+COMMENT ON COLUMN semantic_model_metric_meta.catalog_id IS 'catalog id';
+COMMENT ON COLUMN semantic_model_metric_meta.schema_id IS 'schema id';
+COMMENT ON COLUMN semantic_model_metric_meta.semantic_model_id IS 'containing
semantic model id';
+COMMENT ON COLUMN semantic_model_metric_meta.metric_name IS 'metric name';
+COMMENT ON COLUMN semantic_model_metric_meta.deleted_at IS 'metric retired at';
+
CREATE TABLE IF NOT EXISTS scheduled_expand_tasks (
task_name VARCHAR(100) NOT NULL,
task_instance VARCHAR(100) NOT NULL,