This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-domain-dev
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-domain-dev by this push:
new bb3ecc18b9 [#13883] feat(core): Add sub-domain relation storage
(#14350)
bb3ecc18b9 is described below
commit bb3ecc18b9783ed7af48324370fbb9a85de68c11
Author: Jerry Shao <[email protected]>
AuthorDate: Mon Nov 2 16:17:17 2026 +0800
[#13883] feat(core): Add sub-domain relation storage (#14350)
### What changes were proposed in this pull request?
Store the parent of each domain in a new `domain_rel` table and resolve
it on domain reads.
- Add the `domain_rel` table (one live parent per domain, indexed by
parent) to the H2, MySQL and PostgreSQL 2.0.0 schema and upgrade
scripts.
- Add `DomainRelPO`, `DomainRelMapper` and its SQL providers.
- Add `DomainRelService` to insert, re-point and remove a parent edge,
to list sub-domains, and to resolve parents singly or in batch.
Hierarchy changes lock the metalake row first, then the domain rows in
ID order, so they keep the lock order of a metalake drop. The shape of
the hierarchy (one level deep, C1–C6) is enforced by the domain manager
(#13884).
- Fill `DomainEntity.parent` on domain get, list and batch get, and
reject a domain update that changes the parent.
- Remove a domain's own edge when it is deleted, and refuse the delete
while it has live sub-domains.
- Soft-delete edges on metalake delete and hard-delete them in GC.
### Why are the changes needed?
Domains form a hierarchy (epic #13864). The domain manager (#13884)
needs storage for the parent edge with consistent locking before it can
implement attach, move and detach.
Fixes #13883
### Does this PR introduce _any_ user-facing change?
No. It adds the `domain_rel` table to the 2.0.0 schema and upgrade
scripts; no API change.
### How was this patch tested?
- New `TestDomainRelPO` and `TestDomainRelService`, covering edge insert
/ re-point / remove, delete refusal while sub-domains exist, metalake
delete (cascade and non-cascade), GC, and concurrency (parent delete and
attach serialize).
- Updated `TestDomainPO` and `TestDomainMetaService`.
- Regression coverage for cached pre-lock parent reads, consistent
get/list/batch/sub-domain snapshots, response hydration failures, caught
nested delete refusals, and repeated detach/reattach/delete within one
transaction.
- Passed `compileJava compileTestJava spotlessCheck`, `:core:javadoc`,
the complete `:core:coreH2Test` suite (647 tests), and domain
`:core:coreUnitTest` tests (14 tests). Native MySQL and PostgreSQL
verification is left to CI.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../relational/mapper/DomainMetaMapper.java | 46 +-
.../mapper/DomainMetaSQLProviderFactory.java | 19 +-
.../storage/relational/mapper/DomainRelMapper.java | 118 +++
.../mapper/DomainRelSQLProviderFactory.java | 102 +++
.../provider/DefaultMapperPackageProvider.java | 2 +
.../provider/base/DomainMetaBaseSQLProvider.java | 84 +-
.../provider/base/DomainRelBaseSQLProvider.java | 186 +++++
.../postgresql/DomainMetaPostgreSQLProvider.java | 5 +
...vider.java => DomainRelPostgreSQLProvider.java} | 34 +-
.../gravitino/storage/relational/po/DomainPO.java | 36 +-
.../storage/relational/po/DomainRelPO.java | 154 ++++
.../relational/service/DomainMetaService.java | 88 +-
.../relational/service/DomainRelService.java | 350 ++++++++
.../relational/service/MetalakeMetaService.java | 9 +
.../storage/relational/po/TestDomainPO.java | 27 +-
.../storage/relational/po/TestDomainRelPO.java | 112 +++
.../relational/service/TestDomainRelService.java | 921 +++++++++++++++++++++
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 | 24 +
.../upgrade-1.3.0-to-2.0.0-postgresql.sql | 24 +
23 files changed, 2342 insertions(+), 59 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaMapper.java
index 59cf4ad530..daf3774f7f 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaMapper.java
@@ -22,12 +22,19 @@ import java.util.List;
import org.apache.gravitino.storage.relational.po.DomainPO;
import org.apache.ibatis.annotations.DeleteProvider;
import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Options.FlushCachePolicy;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.UpdateProvider;
-/** MyBatis mapper of the {@code domain_meta} table. */
+/**
+ * MyBatis mapper of the {@code domain_meta} table.
+ *
+ * <p>Reads flush the session cache so that a lookup performed after taking a
domain lock observes
+ * committed state even when an enclosing transaction previously read the same
row or edge.
+ */
public interface DomainMetaMapper {
/** The name of the domain table. */
@@ -39,6 +46,7 @@ public interface DomainMetaMapper {
* @param domainId The domain ID.
* @return The number of deleted rows with this ID.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@Select(
"SELECT COUNT(*) FROM "
+ DOMAIN_TABLE_NAME
@@ -51,6 +59,7 @@ public interface DomainMetaMapper {
* @param metalakeName The metalake name.
* @return The active domains.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(type = DomainMetaSQLProviderFactory.class, method =
"listDomainPOsByMetalake")
List<DomainPO> listDomainPOsByMetalake(@Param("metalakeName") String
metalakeName);
@@ -61,6 +70,7 @@ public interface DomainMetaMapper {
* @param domainName The domain name.
* @return The domain, or null if it is not active.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(
type = DomainMetaSQLProviderFactory.class,
method = "selectDomainMetaByMetalakeAndName")
@@ -75,6 +85,7 @@ public interface DomainMetaMapper {
* @param domainName The domain name.
* @return The domain, or null if it is not active.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(
type = DomainMetaSQLProviderFactory.class,
method = "selectDomainMetaByMetalakeIdAndName")
@@ -89,6 +100,7 @@ public interface DomainMetaMapper {
* @param domainName The domain name.
* @return The locked domain, or null if the natural key is not active.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(
type = DomainMetaSQLProviderFactory.class,
method = "selectDomainMetaByMetalakeIdAndNameForUpdate")
@@ -101,6 +113,7 @@ public interface DomainMetaMapper {
* @param domainId The domain ID.
* @return The domain, or null if it is not active.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(type = DomainMetaSQLProviderFactory.class, method =
"selectDomainMetaById")
DomainPO selectDomainMetaById(@Param("domainId") Long domainId);
@@ -111,11 +124,25 @@ public interface DomainMetaMapper {
* @param domainId The domain ID.
* @return The locked domain, or null if it is not active.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(
type = DomainMetaSQLProviderFactory.class,
method = "selectDomainMetaByIdForUpdate")
DomainPO selectDomainMetaByIdForUpdate(@Param("domainId") Long domainId);
+ /**
+ * Selects an active domain by ID and locks it for shared access. The
backing tag name is not
+ * read, so that no tag row is locked.
+ *
+ * @param domainId The domain ID.
+ * @return The locked domain, or null if it is not active.
+ */
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
+ @SelectProvider(
+ type = DomainMetaSQLProviderFactory.class,
+ method = "selectDomainMetaByIdForShare")
+ DomainPO selectDomainMetaByIdForShare(@Param("domainId") Long domainId);
+
/**
* Lists the active domains with the given IDs, for name lookups. The
backing tag name is not
* read.
@@ -123,6 +150,7 @@ public interface DomainMetaMapper {
* @param domainIds The domain IDs.
* @return The active domains.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(type = DomainMetaSQLProviderFactory.class, method =
"listDomainPOsByDomainIds")
List<DomainPO> listDomainPOsByDomainIds(@Param("domainIds") List<Long>
domainIds);
@@ -133,12 +161,28 @@ public interface DomainMetaMapper {
* @param domainNames The domain names.
* @return The active domains.
*/
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
@SelectProvider(
type = DomainMetaSQLProviderFactory.class,
method = "listDomainPOsByMetalakeAndDomainNames")
List<DomainPO> listDomainPOsByMetalakeAndDomainNames(
@Param("metalakeName") String metalakeName, @Param("domainNames")
List<String> domainNames);
+ /**
+ * Lists the active sub-domains of a domain.
+ *
+ * @param metalakeName The metalake name.
+ * @param parentName The parent domain name.
+ * @return The active sub-domains, or one row with a null domain ID for a
childless parent. An
+ * absent parent produces no rows.
+ */
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
+ @SelectProvider(
+ type = DomainMetaSQLProviderFactory.class,
+ method = "listDomainPOsByParentIdentifier")
+ List<DomainPO> listDomainPOsByParentIdentifier(
+ @Param("metalakeName") String metalakeName, @Param("parentName") String
parentName);
+
/**
* Inserts a domain.
*
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaSQLProviderFactory.java
index e81342d898..f9c9eb49cd 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainMetaSQLProviderFactory.java
@@ -56,7 +56,13 @@ public class DomainMetaSQLProviderFactory {
static class DomainMetaMySQLProvider extends DomainMetaBaseSQLProvider {}
- static class DomainMetaH2Provider extends DomainMetaBaseSQLProvider {}
+ static class DomainMetaH2Provider extends DomainMetaBaseSQLProvider {
+ @Override
+ public String selectDomainMetaByIdForShare(Long domainId) {
+ // H2 has no shared row-lock syntax, so H2 backends fall back to an
exclusive lock.
+ return selectDomainMetaByIdForUpdate(domainId);
+ }
+ }
/** Delegates {@link DomainMetaBaseSQLProvider#listDomainPOsByMetalake}. */
public static String listDomainPOsByMetalake(@Param("metalakeName") String
metalakeName) {
@@ -91,6 +97,11 @@ public class DomainMetaSQLProviderFactory {
return getProvider().selectDomainMetaByIdForUpdate(domainId);
}
+ /** Delegates {@link
DomainMetaBaseSQLProvider#selectDomainMetaByIdForShare}. */
+ public static String selectDomainMetaByIdForShare(@Param("domainId") Long
domainId) {
+ return getProvider().selectDomainMetaByIdForShare(domainId);
+ }
+
/** Delegates {@link DomainMetaBaseSQLProvider#listDomainPOsByDomainIds}. */
public static String listDomainPOsByDomainIds(@Param("domainIds") List<Long>
domainIds) {
return getProvider().listDomainPOsByDomainIds(domainIds);
@@ -102,6 +113,12 @@ public class DomainMetaSQLProviderFactory {
return getProvider().listDomainPOsByMetalakeAndDomainNames(metalakeName,
domainNames);
}
+ /** Delegates {@link
DomainMetaBaseSQLProvider#listDomainPOsByParentIdentifier}. */
+ public static String listDomainPOsByParentIdentifier(
+ @Param("metalakeName") String metalakeName, @Param("parentName") String
parentName) {
+ return getProvider().listDomainPOsByParentIdentifier(metalakeName,
parentName);
+ }
+
/** Delegates {@link DomainMetaBaseSQLProvider#insertDomainMeta}. */
public static String insertDomainMeta(@Param("domainMeta") DomainPO
domainPO) {
return getProvider().insertDomainMeta(domainPO);
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelMapper.java
new file mode 100644
index 0000000000..3d1f71899f
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelMapper.java
@@ -0,0 +1,118 @@
+/*
+ * 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.DomainRelPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Options.FlushCachePolicy;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/** MyBatis mapper of the {@code domain_rel} table, which holds the parent
edge of sub-domains. */
+public interface DomainRelMapper {
+
+ /** The name of the domain relation table. */
+ String DOMAIN_REL_TABLE_NAME = "domain_rel";
+
+ /**
+ * Selects the active parent edge of a domain. The parent name is not read.
+ *
+ * @param childId The ID of the domain.
+ * @return The edge, or null if the domain is top-level.
+ */
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
+ @SelectProvider(type = DomainRelSQLProviderFactory.class, method =
"selectDomainRelByChildId")
+ DomainRelPO selectDomainRelByChildId(@Param("childId") Long childId);
+
+ /**
+ * Lists the active parent edges of the given domains, with the name of each
parent.
+ *
+ * @param childIds The IDs of the domains.
+ * @return The edges of the domains that have a parent.
+ */
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
+ @SelectProvider(type = DomainRelSQLProviderFactory.class, method =
"listDomainRelsByChildIds")
+ List<DomainRelPO> listDomainRelsByChildIds(@Param("childIds") List<Long>
childIds);
+
+ /**
+ * Counts the active sub-domains of a domain.
+ *
+ * @param parentId The ID of the parent domain.
+ * @return The number of active edges to the domain.
+ */
+ @Options(useCache = false, flushCache = FlushCachePolicy.TRUE)
+ @SelectProvider(type = DomainRelSQLProviderFactory.class, method =
"countDomainRelsByParentId")
+ int countDomainRelsByParentId(@Param("parentId") Long parentId);
+
+ /**
+ * Inserts a parent edge.
+ *
+ * @param domainRelPO The edge to insert.
+ */
+ @InsertProvider(type = DomainRelSQLProviderFactory.class, method =
"insertDomainRel")
+ void insertDomainRel(@Param("domainRel") DomainRelPO domainRelPO);
+
+ /**
+ * Updates an active parent edge when its version still matches the observed
one.
+ *
+ * @param newDomainRelPO The new edge.
+ * @param oldDomainRelPO The edge observed by the caller.
+ * @return The number of affected rows.
+ */
+ @UpdateProvider(type = DomainRelSQLProviderFactory.class, method =
"updateDomainRel")
+ Integer updateDomainRel(
+ @Param("newDomainRel") DomainRelPO newDomainRelPO,
+ @Param("oldDomainRel") DomainRelPO oldDomainRelPO);
+
+ /**
+ * Soft-deletes the active parent edge of a domain.
+ *
+ * @param childId The ID of the domain.
+ * @return The number of affected rows.
+ */
+ @UpdateProvider(type = DomainRelSQLProviderFactory.class, method =
"softDeleteDomainRelByChildId")
+ Integer softDeleteDomainRelByChildId(@Param("childId") Long childId);
+
+ /**
+ * Soft-deletes all active parent edges of a metalake.
+ *
+ * @param metalakeId The metalake ID.
+ */
+ @UpdateProvider(
+ type = DomainRelSQLProviderFactory.class,
+ method = "softDeleteDomainRelsByMetalakeId")
+ void softDeleteDomainRelsByMetalakeId(@Param("metalakeId") Long metalakeId);
+
+ /**
+ * Hard-deletes parent edges soft-deleted before the given time.
+ *
+ * @param legacyTimeline The time before which soft-deleted rows are removed.
+ * @param limit The maximum number of rows to remove.
+ * @return The number of removed rows.
+ */
+ @DeleteProvider(
+ type = DomainRelSQLProviderFactory.class,
+ method = "deleteDomainRelsByLegacyTimeline")
+ Integer deleteDomainRelsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelSQLProviderFactory.java
new file mode 100644
index 0000000000..f519873ca7
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/DomainRelSQLProviderFactory.java
@@ -0,0 +1,102 @@
+/*
+ * 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.DomainRelBaseSQLProvider;
+import
org.apache.gravitino.storage.relational.mapper.provider.postgresql.DomainRelPostgreSQLProvider;
+import org.apache.gravitino.storage.relational.po.DomainRelPO;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/** Selects the SQL of the {@code domain_rel} table for the configured
backend. */
+public class DomainRelSQLProviderFactory {
+
+ private static final Map<JDBCBackendType, DomainRelBaseSQLProvider>
DOMAIN_REL_SQL_PROVIDER_MAP =
+ ImmutableMap.of(
+ JDBCBackendType.MYSQL, new DomainRelMySQLProvider(),
+ JDBCBackendType.H2, new DomainRelH2Provider(),
+ JDBCBackendType.POSTGRESQL, new DomainRelPostgreSQLProvider());
+
+ /**
+ * Returns the SQL provider of the configured backend.
+ *
+ * @return The SQL provider.
+ */
+ public static DomainRelBaseSQLProvider getProvider() {
+ String databaseId =
+ SqlSessionFactoryHelper.getInstance()
+ .getSqlSessionFactory()
+ .getConfiguration()
+ .getDatabaseId();
+
+ JDBCBackendType jdbcBackendType = JDBCBackendType.fromString(databaseId);
+ return DOMAIN_REL_SQL_PROVIDER_MAP.get(jdbcBackendType);
+ }
+
+ static class DomainRelMySQLProvider extends DomainRelBaseSQLProvider {}
+
+ static class DomainRelH2Provider extends DomainRelBaseSQLProvider {}
+
+ /** Delegates {@link DomainRelBaseSQLProvider#selectDomainRelByChildId}. */
+ public static String selectDomainRelByChildId(@Param("childId") Long
childId) {
+ return getProvider().selectDomainRelByChildId(childId);
+ }
+
+ /** Delegates {@link DomainRelBaseSQLProvider#listDomainRelsByChildIds}. */
+ public static String listDomainRelsByChildIds(@Param("childIds") List<Long>
childIds) {
+ return getProvider().listDomainRelsByChildIds(childIds);
+ }
+
+ /** Delegates {@link DomainRelBaseSQLProvider#countDomainRelsByParentId}. */
+ public static String countDomainRelsByParentId(@Param("parentId") Long
parentId) {
+ return getProvider().countDomainRelsByParentId(parentId);
+ }
+
+ /** Delegates {@link DomainRelBaseSQLProvider#insertDomainRel}. */
+ public static String insertDomainRel(@Param("domainRel") DomainRelPO
domainRelPO) {
+ return getProvider().insertDomainRel(domainRelPO);
+ }
+
+ /** Delegates {@link DomainRelBaseSQLProvider#updateDomainRel}. */
+ public static String updateDomainRel(
+ @Param("newDomainRel") DomainRelPO newDomainRelPO,
+ @Param("oldDomainRel") DomainRelPO oldDomainRelPO) {
+ return getProvider().updateDomainRel(newDomainRelPO, oldDomainRelPO);
+ }
+
+ /** Delegates {@link DomainRelBaseSQLProvider#softDeleteDomainRelByChildId}.
*/
+ public static String softDeleteDomainRelByChildId(@Param("childId") Long
childId) {
+ return getProvider().softDeleteDomainRelByChildId(childId);
+ }
+
+ /** Delegates {@link
DomainRelBaseSQLProvider#softDeleteDomainRelsByMetalakeId}. */
+ public static String softDeleteDomainRelsByMetalakeId(@Param("metalakeId")
Long metalakeId) {
+ return getProvider().softDeleteDomainRelsByMetalakeId(metalakeId);
+ }
+
+ /** Delegates {@link
DomainRelBaseSQLProvider#deleteDomainRelsByLegacyTimeline}. */
+ public static String deleteDomainRelsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
+ return getProvider().deleteDomainRelsByLegacyTimeline(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 dc2c908f9d..c949f4a4ac 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
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableList;
import java.util.List;
import org.apache.gravitino.storage.relational.mapper.CatalogMetaMapper;
import org.apache.gravitino.storage.relational.mapper.DomainMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
import org.apache.gravitino.storage.relational.mapper.FilesetVersionMapper;
@@ -66,6 +67,7 @@ public class DefaultMapperPackageProvider implements
MapperPackageProvider {
return ImmutableList.of(
CatalogMetaMapper.class,
DomainMetaMapper.class,
+ DomainRelMapper.class,
EntityChangeLogMapper.class,
FilesetMetaMapper.class,
FilesetVersionMapper.class,
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainMetaBaseSQLProvider.java
index 0cf22b34ff..9dbf1358dc 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainMetaBaseSQLProvider.java
@@ -21,6 +21,7 @@ package
org.apache.gravitino.storage.relational.mapper.provider.base;
import static
org.apache.gravitino.storage.relational.mapper.DomainMetaMapper.DOMAIN_TABLE_NAME;
import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.TagMetaMapper;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
@@ -46,8 +47,8 @@ public class DomainMetaBaseSQLProvider {
// Reads take the backing tag name from the live tag, so a deleted tag reads
as no backing tag.
// Locking reads do not join: they must lock the domain row only, and
PostgreSQL rejects FOR
// UPDATE on the nullable side of an outer join.
- private static final String DOMAIN_COLUMNS_WITH_BACKING_TAG_NAME =
- DOMAIN_COLUMNS + ", bt.tag_name as backingTagName";
+ private static final String DOMAIN_COLUMNS_WITH_RESOLVED_NAMES =
+ DOMAIN_COLUMNS + ", bt.tag_name as backingTagName, pd.domain_name as
parentName";
private static final String BACKING_TAG_JOIN =
" LEFT JOIN "
@@ -55,6 +56,14 @@ public class DomainMetaBaseSQLProvider {
+ " bt ON dm.backing_tag_id = bt.tag_id AND dm.metalake_id =
bt.metalake_id"
+ " AND bt.deleted_at = 0";
+ private static final String PARENT_JOIN =
+ " LEFT JOIN "
+ + DomainRelMapper.DOMAIN_REL_TABLE_NAME
+ + " pr ON dm.domain_id = pr.child_id AND pr.deleted_at = 0"
+ + " LEFT JOIN "
+ + DOMAIN_TABLE_NAME
+ + " pd ON pr.parent_id = pd.domain_id AND pd.deleted_at = 0";
+
/**
* Returns SQL that lists the active domains of a metalake.
*
@@ -63,13 +72,14 @@ public class DomainMetaBaseSQLProvider {
*/
public String listDomainPOsByMetalake(@Param("metalakeName") String
metalakeName) {
return "SELECT "
- + DOMAIN_COLUMNS_WITH_BACKING_TAG_NAME
+ + DOMAIN_COLUMNS_WITH_RESOLVED_NAMES
+ " FROM "
+ DOMAIN_TABLE_NAME
+ " dm JOIN "
+ MetalakeMetaMapper.TABLE_NAME
+ " mm ON dm.metalake_id = mm.metalake_id"
+ BACKING_TAG_JOIN
+ + PARENT_JOIN
+ " WHERE mm.metalake_name = #{metalakeName} AND dm.deleted_at = 0 AND
mm.deleted_at = 0";
}
@@ -83,13 +93,14 @@ public class DomainMetaBaseSQLProvider {
public String selectDomainMetaByMetalakeAndName(
@Param("metalakeName") String metalakeName, @Param("domainName") String
domainName) {
return "SELECT "
- + DOMAIN_COLUMNS_WITH_BACKING_TAG_NAME
+ + DOMAIN_COLUMNS_WITH_RESOLVED_NAMES
+ " FROM "
+ DOMAIN_TABLE_NAME
+ " dm JOIN "
+ MetalakeMetaMapper.TABLE_NAME
+ " mm ON dm.metalake_id = mm.metalake_id"
+ BACKING_TAG_JOIN
+ + PARENT_JOIN
+ " WHERE mm.metalake_name = #{metalakeName} AND dm.domain_name =
#{domainName}"
+ " AND dm.deleted_at = 0 AND mm.deleted_at = 0";
}
@@ -131,11 +142,12 @@ public class DomainMetaBaseSQLProvider {
*/
public String selectDomainMetaById(@Param("domainId") Long domainId) {
return "SELECT "
- + DOMAIN_COLUMNS_WITH_BACKING_TAG_NAME
+ + DOMAIN_COLUMNS_WITH_RESOLVED_NAMES
+ " FROM "
+ DOMAIN_TABLE_NAME
+ " dm"
+ BACKING_TAG_JOIN
+ + PARENT_JOIN
+ " WHERE dm.domain_id = #{domainId} AND dm.deleted_at = 0";
}
@@ -146,11 +158,17 @@ public class DomainMetaBaseSQLProvider {
* @return The SQL.
*/
public String selectDomainMetaByIdForUpdate(@Param("domainId") Long
domainId) {
- return "SELECT "
- + DOMAIN_COLUMNS
- + " FROM "
- + DOMAIN_TABLE_NAME
- + " dm WHERE dm.domain_id = #{domainId} AND dm.deleted_at = 0 FOR
UPDATE";
+ return selectLockableDomainMetaById(domainId) + " FOR UPDATE";
+ }
+
+ /**
+ * Returns SQL that selects an active domain by ID and locks it for shared
access.
+ *
+ * @param domainId The domain ID.
+ * @return The SQL.
+ */
+ public String selectDomainMetaByIdForShare(@Param("domainId") Long domainId)
{
+ return selectLockableDomainMetaById(domainId) + " LOCK IN SHARE MODE";
}
/**
@@ -185,13 +203,14 @@ public class DomainMetaBaseSQLProvider {
@Param("metalakeName") String metalakeName, @Param("domainNames")
List<String> domainNames) {
return "<script>"
+ "SELECT "
- + DOMAIN_COLUMNS_WITH_BACKING_TAG_NAME
+ + DOMAIN_COLUMNS_WITH_RESOLVED_NAMES
+ " FROM "
+ DOMAIN_TABLE_NAME
+ " dm JOIN "
+ MetalakeMetaMapper.TABLE_NAME
+ " mm ON dm.metalake_id = mm.metalake_id"
+ BACKING_TAG_JOIN
+ + PARENT_JOIN
+ " WHERE mm.metalake_name = #{metalakeName}"
+ " AND dm.domain_name IN ("
+ "<foreach collection='domainNames' item='domainName' separator=','>"
@@ -202,6 +221,34 @@ public class DomainMetaBaseSQLProvider {
+ "</script>";
}
+ /**
+ * Returns SQL that resolves the live parent and lists its sub-domains in
one statement.
+ *
+ * @param metalakeName The metalake name.
+ * @param parentName The parent domain name.
+ * @return The SQL. An existing childless parent produces a row with a null
domain ID.
+ */
+ public String listDomainPOsByParentIdentifier(
+ @Param("metalakeName") String metalakeName, @Param("parentName") String
parentName) {
+ return "SELECT "
+ + DOMAIN_COLUMNS
+ + ", bt.tag_name as backingTagName, lp.domain_name as parentName"
+ + " FROM "
+ + DOMAIN_TABLE_NAME
+ + " lp JOIN "
+ + MetalakeMetaMapper.TABLE_NAME
+ + " mm ON lp.metalake_id = mm.metalake_id AND mm.deleted_at = 0"
+ + " LEFT JOIN "
+ + DomainRelMapper.DOMAIN_REL_TABLE_NAME
+ + " dr ON lp.domain_id = dr.parent_id AND dr.deleted_at = 0"
+ + " LEFT JOIN "
+ + DOMAIN_TABLE_NAME
+ + " dm ON dr.child_id = dm.domain_id AND dm.deleted_at = 0"
+ + BACKING_TAG_JOIN
+ + " WHERE mm.metalake_name = #{metalakeName} AND lp.domain_name =
#{parentName}"
+ + " AND lp.deleted_at = 0";
+ }
+
/**
* Returns SQL that inserts a domain.
*
@@ -299,4 +346,19 @@ public class DomainMetaBaseSQLProvider {
+ DOMAIN_TABLE_NAME
+ " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit}";
}
+
+ /**
+ * Returns SQL that selects an active domain by ID without joining the
backing tag, so that a
+ * locking clause appended to it locks the domain row only.
+ *
+ * @param domainId The domain ID.
+ * @return The SQL.
+ */
+ protected String selectLockableDomainMetaById(Long domainId) {
+ return "SELECT "
+ + DOMAIN_COLUMNS
+ + " FROM "
+ + DOMAIN_TABLE_NAME
+ + " dm WHERE dm.domain_id = #{domainId} AND dm.deleted_at = 0";
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainRelBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainRelBaseSQLProvider.java
new file mode 100644
index 0000000000..2d852ec9b3
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/DomainRelBaseSQLProvider.java
@@ -0,0 +1,186 @@
+/*
+ * 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.DomainRelMapper.DOMAIN_REL_TABLE_NAME;
+
+import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.DomainMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
+import org.apache.gravitino.storage.relational.po.DomainRelPO;
+import org.apache.ibatis.annotations.Param;
+
+/** The SQL of the {@code domain_rel} table shared by all backends. */
+public class DomainRelBaseSQLProvider {
+
+ private static final String DOMAIN_REL_COLUMNS =
+ "dr.metalake_id as metalakeId, dr.parent_id as parentId, dr.child_id as
childId,"
+ + " dr.audit_info as auditInfo,"
+ + " dr.current_version as currentVersion,"
+ + " dr.last_version as lastVersion,"
+ + " dr.deleted_at as deletedAt";
+
+ /**
+ * Returns SQL that selects the active parent edge of a domain.
+ *
+ * @param childId The ID of the domain.
+ * @return The SQL.
+ */
+ public String selectDomainRelByChildId(@Param("childId") Long childId) {
+ return "SELECT "
+ + DOMAIN_REL_COLUMNS
+ + " FROM "
+ + DOMAIN_REL_TABLE_NAME
+ + " dr WHERE dr.child_id = #{childId} AND dr.deleted_at = 0";
+ }
+
+ /**
+ * Returns SQL that lists the active parent edges of the given domains, with
the name of each
+ * parent.
+ *
+ * @param childIds The IDs of the domains.
+ * @return The SQL.
+ */
+ public String listDomainRelsByChildIds(@Param("childIds") List<Long>
childIds) {
+ return "<script>"
+ + "SELECT "
+ + DOMAIN_REL_COLUMNS
+ + ", pd.domain_name as parentName"
+ + " FROM "
+ + DOMAIN_REL_TABLE_NAME
+ + " dr JOIN "
+ + DomainMetaMapper.DOMAIN_TABLE_NAME
+ + " pd ON dr.parent_id = pd.domain_id AND pd.deleted_at = 0"
+ + " WHERE dr.deleted_at = 0"
+ + " AND dr.child_id IN ("
+ + "<foreach collection='childIds' item='childId' separator=','>"
+ + "#{childId}"
+ + "</foreach>"
+ + ")"
+ + "</script>";
+ }
+
+ /**
+ * Returns SQL that counts the active sub-domains of a domain.
+ *
+ * @param parentId The ID of the parent domain.
+ * @return The SQL.
+ */
+ public String countDomainRelsByParentId(@Param("parentId") Long parentId) {
+ return "SELECT COUNT(*) FROM "
+ + DOMAIN_REL_TABLE_NAME
+ + " WHERE parent_id = #{parentId} AND deleted_at = 0";
+ }
+
+ /**
+ * Returns SQL that inserts a parent edge.
+ *
+ * @param domainRelPO The edge to insert.
+ * @return The SQL.
+ */
+ public String insertDomainRel(@Param("domainRel") DomainRelPO domainRelPO) {
+ return "INSERT INTO "
+ + DOMAIN_REL_TABLE_NAME
+ + " (metalake_id, parent_id, child_id, audit_info, current_version,
last_version,"
+ + " deleted_at)"
+ + " VALUES ("
+ + " #{domainRel.metalakeId},"
+ + " #{domainRel.parentId},"
+ + " #{domainRel.childId},"
+ + " #{domainRel.auditInfo},"
+ + " #{domainRel.currentVersion},"
+ + " #{domainRel.lastVersion},"
+ + " #{domainRel.deletedAt}"
+ + " )";
+ }
+
+ /**
+ * Returns SQL that updates an active parent edge when its version still
matches the observed one.
+ *
+ * @param newDomainRelPO The new edge.
+ * @param oldDomainRelPO The edge observed by the caller.
+ * @return The SQL.
+ */
+ public String updateDomainRel(
+ @Param("newDomainRel") DomainRelPO newDomainRelPO,
+ @Param("oldDomainRel") DomainRelPO oldDomainRelPO) {
+ return "UPDATE "
+ + DOMAIN_REL_TABLE_NAME
+ + " SET parent_id = #{newDomainRel.parentId},"
+ + " audit_info = #{newDomainRel.auditInfo},"
+ + " current_version = #{newDomainRel.currentVersion},"
+ + " last_version = #{newDomainRel.lastVersion},"
+ + " deleted_at = #{newDomainRel.deletedAt}"
+ + " WHERE child_id = #{oldDomainRel.childId}"
+ + " AND parent_id = #{oldDomainRel.parentId}"
+ + " AND current_version = #{oldDomainRel.currentVersion}"
+ + " AND deleted_at = 0";
+ }
+
+ /**
+ * Returns SQL that soft-deletes the active parent edge of a domain.
+ *
+ * @param childId The ID of the domain.
+ * @return The SQL.
+ */
+ // Advance beyond existing tombstones as well as database time: PostgreSQL
timestamps stay
+ // constant within a transaction, and millisecond clocks can collide on
every backend. The
+ // aggregate derived table also prevents MySQL from merging a read of the
UPDATE target.
+ public String softDeleteDomainRelByChildId(@Param("childId") Long childId) {
+ return "UPDATE "
+ + DOMAIN_REL_TABLE_NAME
+ + " SET deleted_at = "
+ + "GREATEST("
+ + DatabaseTimeSQL.MYSQL
+ + ", (SELECT COALESCE(last_deleted_at, 0) + 1 FROM"
+ + " (SELECT MAX(deleted_at) AS last_deleted_at FROM domain_rel WHERE
child_id = #{childId}) legacy))"
+ + " WHERE child_id = #{childId} AND deleted_at = 0";
+ }
+
+ /**
+ * Returns SQL that soft-deletes all active parent edges of a metalake.
+ *
+ * @param metalakeId The metalake ID.
+ * @return The SQL.
+ */
+ public String softDeleteDomainRelsByMetalakeId(@Param("metalakeId") Long
metalakeId) {
+ return "UPDATE "
+ + DOMAIN_REL_TABLE_NAME
+ + " SET deleted_at = "
+ + "GREATEST("
+ + DatabaseTimeSQL.MYSQL
+ + ", (SELECT COALESCE(last_deleted_at, 0) + 1 FROM"
+ + " (SELECT MAX(deleted_at) AS last_deleted_at FROM domain_rel WHERE
metalake_id = #{metalakeId}) legacy))"
+ + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
+ }
+
+ /**
+ * Returns SQL that hard-deletes parent edges soft-deleted before the given
time.
+ *
+ * @param legacyTimeline The time before which soft-deleted rows are removed.
+ * @param limit The maximum number of rows to remove.
+ * @return The SQL.
+ */
+ public String deleteDomainRelsByLegacyTimeline(
+ @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
+ return "DELETE FROM "
+ + DOMAIN_REL_TABLE_NAME
+ + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit}";
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
index 6aa2a0b6da..ba60386cee 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
@@ -27,6 +27,11 @@ import org.apache.ibatis.annotations.Param;
/** The SQL of the {@code domain_meta} table for PostgreSQL. */
public class DomainMetaPostgreSQLProvider extends DomainMetaBaseSQLProvider {
+ @Override
+ public String selectDomainMetaByIdForShare(@Param("domainId") Long domainId)
{
+ return selectLockableDomainMetaById(domainId) + " FOR SHARE";
+ }
+
@Override
public String softDeleteDomainMetaByIdAndVersion(
@Param("domainId") Long domainId, @Param("currentVersion") Long
currentVersion) {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainRelPostgreSQLProvider.java
similarity index 60%
copy from
core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
copy to
core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainRelPostgreSQLProvider.java
index 6aa2a0b6da..184b79a6c1 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/DomainRelPostgreSQLProvider.java
@@ -18,42 +18,46 @@
*/
package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
-import static
org.apache.gravitino.storage.relational.mapper.DomainMetaMapper.DOMAIN_TABLE_NAME;
+import static
org.apache.gravitino.storage.relational.mapper.DomainRelMapper.DOMAIN_REL_TABLE_NAME;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
-import
org.apache.gravitino.storage.relational.mapper.provider.base.DomainMetaBaseSQLProvider;
+import
org.apache.gravitino.storage.relational.mapper.provider.base.DomainRelBaseSQLProvider;
import org.apache.ibatis.annotations.Param;
-/** The SQL of the {@code domain_meta} table for PostgreSQL. */
-public class DomainMetaPostgreSQLProvider extends DomainMetaBaseSQLProvider {
+/** The SQL of the {@code domain_rel} table for PostgreSQL. */
+public class DomainRelPostgreSQLProvider extends DomainRelBaseSQLProvider {
@Override
- public String softDeleteDomainMetaByIdAndVersion(
- @Param("domainId") Long domainId, @Param("currentVersion") Long
currentVersion) {
+ public String softDeleteDomainRelByChildId(@Param("childId") Long childId) {
return "UPDATE "
- + DOMAIN_TABLE_NAME
+ + DOMAIN_REL_TABLE_NAME
+ " SET deleted_at = "
+ + "GREATEST("
+ DatabaseTimeSQL.POSTGRESQL
- + " WHERE domain_id = #{domainId} AND current_version =
#{currentVersion}"
- + " AND deleted_at = 0";
+ + ", (SELECT COALESCE(last_deleted_at, 0) + 1 FROM"
+ + " (SELECT MAX(deleted_at) AS last_deleted_at FROM domain_rel WHERE
child_id = #{childId}) legacy))"
+ + " WHERE child_id = #{childId} AND deleted_at = 0";
}
@Override
- public String softDeleteDomainMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
+ public String softDeleteDomainRelsByMetalakeId(@Param("metalakeId") Long
metalakeId) {
return "UPDATE "
- + DOMAIN_TABLE_NAME
+ + DOMAIN_REL_TABLE_NAME
+ " SET deleted_at = "
+ + "GREATEST("
+ DatabaseTimeSQL.POSTGRESQL
+ + ", (SELECT COALESCE(last_deleted_at, 0) + 1 FROM"
+ + " (SELECT MAX(deleted_at) AS last_deleted_at FROM domain_rel WHERE
metalake_id = #{metalakeId}) legacy))"
+ " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
}
@Override
- public String deleteDomainMetasByLegacyTimeline(
+ public String deleteDomainRelsByLegacyTimeline(
@Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit)
{
return "DELETE FROM "
- + DOMAIN_TABLE_NAME
- + " WHERE domain_id IN (SELECT domain_id FROM "
- + DOMAIN_TABLE_NAME
+ + DOMAIN_REL_TABLE_NAME
+ + " WHERE id IN (SELECT id FROM "
+ + DOMAIN_REL_TABLE_NAME
+ " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT
#{limit})";
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainPO.java
b/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainPO.java
index 6afb443ba6..fcf9e703ee 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainPO.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainPO.java
@@ -26,6 +26,7 @@ import com.google.common.base.Preconditions;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import javax.annotation.Nullable;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
@@ -59,7 +60,8 @@ public class DomainPO {
private String comment;
private String status;
private Long backingTagId;
- private String backingTagName;
+ @EqualsAndHashCode.Exclude private String backingTagName;
+ @EqualsAndHashCode.Exclude private String parentName;
private String backingTagValue;
private String properties;
private String auditInfo;
@@ -135,15 +137,29 @@ public class DomainPO {
}
/**
- * Converts a domain persistent object to a domain entity. The parent is not
stored in the domain
- * row, so it is not set. The backing tag name is set only when the read
joined the live tag.
+ * Converts a domain persistent object to a domain entity. Parent and
backing tag names are
+ * populated by reads that join their live rows.
*
* @param domainPO The domain persistent object.
* @param namespace The namespace of the domain.
* @return The domain entity.
*/
- @SuppressWarnings("unchecked")
public static DomainEntity fromDomainPO(DomainPO domainPO, Namespace
namespace) {
+ return fromDomainPO(domainPO, namespace, domainPO.parentName());
+ }
+
+ /**
+ * Converts a domain persistent object to a domain entity with the given
parent. The parent is not
+ * stored in the domain row; it is read from the sub-domain relation.
+ *
+ * @param domainPO The domain persistent object.
+ * @param namespace The namespace of the domain.
+ * @param parentName The name of the parent domain, or null if the domain is
top-level.
+ * @return The domain entity.
+ */
+ @SuppressWarnings("unchecked")
+ public static DomainEntity fromDomainPO(
+ DomainPO domainPO, Namespace namespace, @Nullable String parentName) {
try {
return DomainEntity.builder()
.withId(domainPO.domainId())
@@ -151,6 +167,7 @@ public class DomainPO {
.withNamespace(namespace)
.withComment(domainPO.comment())
.withStatus(Domain.Status.valueOf(domainPO.status()))
+ .withParent(parentName)
.withBackingTagId(domainPO.backingTagId())
.withBackingTagName(domainPO.backingTagName())
.withBackingTagValue(domainPO.backingTagValue())
@@ -164,14 +181,19 @@ public class DomainPO {
}
/**
- * Converts domain persistent objects to domain entities.
+ * Converts domain persistent objects to domain entities with their parents.
*
* @param domainPOs The domain persistent objects.
* @param namespace The namespace of the domains.
+ * @param parentNames The name of the parent of each sub-domain, keyed by
domain ID. Domains that
+ * are not in the map are top-level.
* @return The domain entities.
*/
- public static List<DomainEntity> fromDomainPOs(List<DomainPO> domainPOs,
Namespace namespace) {
- return domainPOs.stream().map(po -> fromDomainPO(po,
namespace)).collect(Collectors.toList());
+ public static List<DomainEntity> fromDomainPOs(
+ List<DomainPO> domainPOs, Namespace namespace, Map<Long, String>
parentNames) {
+ return domainPOs.stream()
+ .map(po -> fromDomainPO(po, namespace, parentNames.get(po.domainId())))
+ .collect(Collectors.toList());
}
// Returns the built PO rather than the lombok builder: javadoc runs before
lombok, so a
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainRelPO.java
b/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainRelPO.java
new file mode 100644
index 0000000000..51b3429132
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/po/DomainRelPO.java
@@ -0,0 +1,154 @@
+/*
+ * 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 static
org.apache.gravitino.storage.relational.utils.POConverters.DEFAULT_DELETED_AT;
+import static
org.apache.gravitino.storage.relational.utils.POConverters.INIT_VERSION;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+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;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.meta.AuditInfo;
+
+/**
+ * The persistent object of a row in {@code domain_rel}, the edge from a
sub-domain to its parent
+ * domain. The parent domain name is not a column of the row: reads that join
the live parent fill
+ * it.
+ */
+@Getter
+@Accessors(fluent = true)
+@EqualsAndHashCode
+@ToString
+public class DomainRelPO {
+
+ private Long metalakeId;
+ private Long parentId;
+ @EqualsAndHashCode.Exclude private String parentName;
+ private Long childId;
+ private String auditInfo;
+ private Long currentVersion;
+ private Long lastVersion;
+ private Long deletedAt;
+
+ /** Creates an empty domain relation persistent object, used by MyBatis to
map result rows. */
+ public DomainRelPO() {}
+
+ @lombok.Builder(setterPrefix = "with")
+ private DomainRelPO(
+ Long metalakeId,
+ Long parentId,
+ Long childId,
+ String auditInfo,
+ Long currentVersion,
+ Long lastVersion,
+ Long deletedAt) {
+ Preconditions.checkArgument(metalakeId != null, "metalakeId cannot be
null");
+ Preconditions.checkArgument(parentId != null, "parentId cannot be null");
+ Preconditions.checkArgument(childId != null, "childId cannot be null");
+ Preconditions.checkArgument(
+ !parentId.equals(childId), "A domain cannot be its own parent: %s",
childId);
+ Preconditions.checkArgument(StringUtils.isNotBlank(auditInfo), "auditInfo
cannot be blank");
+ Preconditions.checkArgument(currentVersion != null, "currentVersion cannot
be null");
+ Preconditions.checkArgument(lastVersion != null, "lastVersion cannot be
null");
+ Preconditions.checkArgument(deletedAt != null, "deletedAt cannot be null");
+
+ this.metalakeId = metalakeId;
+ this.parentId = parentId;
+ this.childId = childId;
+ this.auditInfo = auditInfo;
+ this.currentVersion = currentVersion;
+ this.lastVersion = lastVersion;
+ this.deletedAt = deletedAt;
+ }
+
+ /**
+ * Creates the first version of the persistent object of a new parent edge.
+ *
+ * @param metalakeId The ID of the metalake of both domains.
+ * @param parentId The ID of the parent domain.
+ * @param childId The ID of the sub-domain.
+ * @param auditInfo The audit info of the edge.
+ * @return The domain relation persistent object.
+ */
+ public static DomainRelPO initializeDomainRelPO(
+ Long metalakeId, Long parentId, Long childId, AuditInfo auditInfo) {
+ return builder()
+ .withMetalakeId(metalakeId)
+ .withParentId(parentId)
+ .withChildId(childId)
+ .withAuditInfo(serializeAuditInfo(auditInfo))
+ .withCurrentVersion(INIT_VERSION)
+ .withLastVersion(INIT_VERSION)
+ .withDeletedAt(DEFAULT_DELETED_AT)
+ .build();
+ }
+
+ /**
+ * Creates the next version of the persistent object of an edge that is
re-pointed to another
+ * parent. The edge keeps its creator and creation time and takes the last
modifier and the last
+ * modified time from {@code modifiedAuditInfo}.
+ *
+ * @param oldDomainRelPO The edge observed by the caller.
+ * @param newParentId The ID of the new parent domain.
+ * @param modifiedAuditInfo The audit info of the change.
+ * @return The domain relation persistent object with the next version.
+ */
+ public static DomainRelPO updateDomainRelPO(
+ DomainRelPO oldDomainRelPO, Long newParentId, AuditInfo
modifiedAuditInfo) {
+ AuditInfo oldAuditInfo = deserializeAuditInfo(oldDomainRelPO.auditInfo());
+ AuditInfo newAuditInfo =
+ AuditInfo.builder()
+ .withCreator(oldAuditInfo.creator())
+ .withCreateTime(oldAuditInfo.createTime())
+ .withLastModifier(modifiedAuditInfo.lastModifier())
+ .withLastModifiedTime(modifiedAuditInfo.lastModifiedTime())
+ .build();
+ Long nextVersion = oldDomainRelPO.currentVersion() + 1;
+ return builder()
+ .withMetalakeId(oldDomainRelPO.metalakeId())
+ .withParentId(newParentId)
+ .withChildId(oldDomainRelPO.childId())
+ .withAuditInfo(serializeAuditInfo(newAuditInfo))
+ .withCurrentVersion(nextVersion)
+ .withLastVersion(nextVersion)
+ .withDeletedAt(DEFAULT_DELETED_AT)
+ .build();
+ }
+
+ private static String serializeAuditInfo(AuditInfo auditInfo) {
+ try {
+ return JsonUtils.anyFieldMapper().writeValueAsString(auditInfo);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize domain relation audit
info", e);
+ }
+ }
+
+ private static AuditInfo deserializeAuditInfo(String auditInfo) {
+ try {
+ return JsonUtils.anyFieldMapper().readValue(auditInfo, AuditInfo.class);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to deserialize domain relation audit
info", e);
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainMetaService.java
index ebee5d7bfd..07dde79f3d 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainMetaService.java
@@ -35,9 +35,11 @@ import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.meta.DomainEntity;
import org.apache.gravitino.metrics.Monitored;
import org.apache.gravitino.storage.relational.mapper.DomainMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
@@ -76,7 +78,7 @@ public class DomainMetaService {
List<DomainPO> domainPOs =
SessionUtils.getWithoutCommit(
DomainMetaMapper.class, mapper ->
mapper.listDomainPOsByMetalake(metalakeName));
- return DomainPO.fromDomainPOs(domainPOs, ns);
+ return fromDomainPOsWithParents(domainPOs, ns);
}
/**
@@ -92,7 +94,7 @@ public class DomainMetaService {
public DomainEntity getDomainByIdentifier(NameIdentifier ident) {
String metalakeName = ident.namespace().level(0);
DomainPO domainPO = getDomainPOByMetalakeAndName(metalakeName,
ident.name());
- return DomainPO.fromDomainPO(domainPO, ident.namespace());
+ return fromDomainPOWithParent(domainPO, ident.namespace());
}
/**
@@ -118,7 +120,7 @@ public class DomainMetaService {
SessionUtils.getWithoutCommit(
DomainMetaMapper.class,
mapper ->
mapper.listDomainPOsByMetalakeAndDomainNames(metalakeName, domainNames));
- return DomainPO.fromDomainPOs(domainPOs, firstIdent.namespace());
+ return fromDomainPOsWithParents(domainPOs, firstIdent.namespace());
}
/**
@@ -185,7 +187,8 @@ public class DomainMetaService {
* changing the new domain; a later call with the name acts on the new
domain.
*
* <p>The returned domain is read back in the same transaction, so its
backing tag name comes from
- * the tag it is now bound to rather than from the updater's entity.
+ * the tag it is now bound to rather than from the updater's entity. The
parent cannot be changed
+ * by an update, an updater that changes it is rejected; {@link
DomainRelService} changes it.
*
* @param identifier The domain identifier.
* @param updater The function that returns the updated domain.
@@ -204,7 +207,7 @@ public class DomainMetaService {
try {
DomainPO observedDomainPO = getDomainPOByMetalakeAndName(metalakeName,
identifier.name());
- DomainPO[] storedDomainPO = new DomainPO[1];
+ DomainEntity[] storedDomain = new DomainEntity[1];
SessionUtils.doWithCommit(
DomainMetaMapper.class,
mapper -> {
@@ -216,20 +219,26 @@ public class DomainMetaService {
// name of its backing tag.
DomainPO lockedDomainPO =
mapper.selectDomainMetaById(observedDomainPO.domainId());
DomainEntity oldDomainEntity =
- DomainPO.fromDomainPO(lockedDomainPO, identifier.namespace());
+ fromDomainPOWithParent(lockedDomainPO, identifier.namespace());
DomainEntity updatedDomainEntity = (DomainEntity)
updater.apply((E) oldDomainEntity);
Preconditions.checkArgument(
Objects.equals(oldDomainEntity.id(), updatedDomainEntity.id()),
"The updated domain entity id: %s must have the same id as the
old entity id %s",
updatedDomainEntity.id(),
oldDomainEntity.id());
+ Preconditions.checkArgument(
+ Objects.equals(oldDomainEntity.parent(),
updatedDomainEntity.parent()),
+ "The parent of domain %s cannot be changed by an update, use
DomainRelService",
+ identifier);
DomainPO newDomainPO = DomainPO.updateDomainPO(lockedDomainPO,
updatedDomainEntity);
updateDomainWithVersion(identifier, mapper, lockedDomainPO,
newDomainPO);
- storedDomainPO[0] =
mapper.selectDomainMetaById(lockedDomainPO.domainId());
+ storedDomain[0] =
+ DomainPO.fromDomainPO(
+ mapper.selectDomainMetaById(lockedDomainPO.domainId()),
identifier.namespace());
});
- return DomainPO.fromDomainPO(storedDomainPO[0], identifier.namespace());
+ return storedDomain[0];
} catch (RuntimeException e) {
ExceptionUtils.checkSQLException(e, Entity.EntityType.DOMAIN,
identifier.toString());
throw e;
@@ -237,11 +246,12 @@ public class DomainMetaService {
}
/**
- * Deletes a domain, together with its owner and the privileges granted on
it. The backing tag and
- * its assignments are kept.
+ * Deletes a domain, together with its parent edge, its owner and the
privileges granted on it.
+ * The backing tag and its assignments are kept. A domain with sub-domains
is not deleted.
*
* @param identifier The domain identifier.
* @return True if the domain is deleted, false if it does not exist.
+ * @throws NonEmptyEntityException If the domain has sub-domains.
*/
@Monitored(
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
@@ -257,19 +267,32 @@ public class DomainMetaService {
}
/**
- * Hard-deletes the domains soft-deleted before the given time.
+ * Hard-deletes the domains and the parent edges soft-deleted before the
given time.
*
- * @param legacyTimeline The time before which soft-deleted domains are
removed.
- * @param limit The maximum number of domains to remove.
- * @return The number of removed domains.
+ * @param legacyTimeline The time before which soft-deleted rows are removed.
+ * @param limit The maximum number of rows to remove from each table.
+ * @return The number of removed rows.
*/
@Monitored(
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteDomainMetasByLegacyTimeline")
public int deleteDomainMetasByLegacyTimeline(long legacyTimeline, int limit)
{
- return SessionUtils.doWithCommitAndFetchResult(
- DomainMetaMapper.class,
- mapper -> mapper.deleteDomainMetasByLegacyTimeline(legacyTimeline,
limit));
+ int[] domainDeletedCount = new int[] {0};
+ int[] domainRelDeletedCount = new int[] {0};
+
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ domainDeletedCount[0] =
+ SessionUtils.getWithoutCommit(
+ DomainMetaMapper.class,
+ mapper ->
mapper.deleteDomainMetasByLegacyTimeline(legacyTimeline, limit)),
+ () ->
+ domainRelDeletedCount[0] =
+ SessionUtils.getWithoutCommit(
+ DomainRelMapper.class,
+ mapper ->
mapper.deleteDomainRelsByLegacyTimeline(legacyTimeline, limit)));
+
+ return domainDeletedCount[0] + domainRelDeletedCount[0];
}
/**
@@ -311,15 +334,23 @@ public class DomainMetaService {
// The metalake lock comes first. Dropping the metalake soft-deletes the
privileges before the
// domains, while this delete changes the domain row before its
privileges; without the lock
- // the two could deadlock on a domain with privileges. The domain row is
locked next, and a row
- // that is gone or renamed makes the delete report the domain as missing
before any dependent
- // row is touched. The version-checked delete keeps the version as a
backstop. The cleanups that
- // follow are blanket soft deletes for this domain ID, so their row counts
carry no information
- // and are not checked.
+ // the two could deadlock on a domain with privileges. The domain row is
locked next, which also
+ // keeps a sub-domain from being attached while the sub-domain check runs.
A row that is gone or
+ // renamed makes the delete report the domain as missing before any
dependent row is touched.
+ // The version-checked delete keeps the version as a backstop. The
cleanups that follow are
+ // blanket soft deletes for this domain ID, so their row counts carry no
information and are not
+ // checked.
try {
SessionUtils.doMultipleWithCommit(
() -> lockMetalakeForDomainWrite(identifier.namespace().level(0),
domainPO.metalakeId()),
- () -> deleteDomainWithVersion(identifier,
lockAndGetDomainPO(identifier, domainPO)),
+ () -> {
+ DomainPO lockedDomainPO = lockAndGetDomainPO(identifier, domainPO);
+
DomainRelService.getInstance().checkDomainHasNoSubDomains(identifier, domainId);
+ deleteDomainWithVersion(identifier, lockedDomainPO);
+ },
+ () ->
+ SessionUtils.doWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.softDeleteDomainRelByChildId(domainId)),
() ->
SessionUtils.doWithoutCommit(
OwnerMetaMapper.class,
@@ -482,4 +513,15 @@ public class DomainMetaService {
}
return domainPO;
}
+
+ private DomainEntity fromDomainPOWithParent(DomainPO domainPO, Namespace
namespace) {
+ return DomainPO.fromDomainPO(domainPO, namespace);
+ }
+
+ private List<DomainEntity> fromDomainPOsWithParents(
+ List<DomainPO> domainPOs, Namespace namespace) {
+ return domainPOs.stream()
+ .map(po -> DomainPO.fromDomainPO(po, namespace))
+ .collect(Collectors.toList());
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainRelService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainRelService.java
new file mode 100644
index 0000000000..a2718d553a
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/DomainRelService.java
@@ -0,0 +1,350 @@
+/*
+ * 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.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.DomainEntity;
+import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.relational.mapper.DomainMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
+import org.apache.gravitino.storage.relational.po.DomainPO;
+import org.apache.gravitino.storage.relational.po.DomainRelPO;
+import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/**
+ * The service that stores the parent edge of sub-domains in the {@code
domain_rel} table. A domain
+ * has at most one active edge, enforced by the unique key on {@code
(child_id, deleted_at)}.
+ *
+ * <p>For parent-name-only lookups, this service provides the shared resolver
through {@link
+ * #getParentDomainName(Long)} and {@link #batchGetParentDomainNames(List)}.
Future inherited
+ * authorization checks can reuse these methods. Domain entity reads instead
join the live parent
+ * into the same query as the domain metadata, preserving a consistent
statement snapshot without a
+ * separate resolver lookup.
+ *
+ * <p>Every change of the hierarchy first locks the metalake row, then locks
the {@code domain_meta}
+ * rows of the domains involved in domain ID order: the sub-domain exclusively
and the parent
+ * shared. Taking the metalake first keeps the order of a metalake drop, which
locks the metalake
+ * row exclusively before its domain rows, so the two cannot deadlock. All
writers of an edge lock
+ * the sub-domain, so the edge read under that lock is current. Each locked
row is re-read and must
+ * still have the observed name, otherwise the change fails as if it did not
exist.
+ *
+ * <p>This service does not enforce the shape of the hierarchy. The domain
manager keeps it one
+ * level deep, which also keeps it acyclic.
+ */
+public class DomainRelService {
+
+ private static final DomainRelService INSTANCE = new DomainRelService();
+
+ private DomainRelService() {}
+
+ /**
+ * Returns the singleton service instance.
+ *
+ * @return The domain relation service.
+ */
+ public static DomainRelService getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Returns the name of the current parent of a domain.
+ *
+ * @param domainId The domain ID.
+ * @return The name of the parent domain, or empty if the domain is
top-level.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "getParentDomainName")
+ public Optional<String> getParentDomainName(Long domainId) {
+ return Optional.ofNullable(
+
batchGetParentDomainNames(Collections.singletonList(domainId)).get(domainId));
+ }
+
+ /**
+ * Returns the names of the current parents of the given domains.
+ *
+ * @param domainIds The domain IDs.
+ * @return The name of the parent of each domain that has one, keyed by
domain ID. Top-level
+ * domains are not in the map.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "batchGetParentDomainNames")
+ public Map<Long, String> batchGetParentDomainNames(List<Long> domainIds) {
+ if (domainIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List<DomainRelPO> domainRelPOs =
+ SessionUtils.getWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.listDomainRelsByChildIds(domainIds));
+ return domainRelPOs.stream()
+ .collect(Collectors.toMap(DomainRelPO::childId,
DomainRelPO::parentName));
+ }
+
+ /**
+ * Lists the sub-domains of a domain.
+ *
+ * @param parentIdent The identifier of the parent domain.
+ * @return The sub-domains, each with the parent set.
+ * @throws NoSuchEntityException If the parent domain does not exist.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "listSubDomains")
+ public List<DomainEntity> listSubDomains(NameIdentifier parentIdent) {
+ List<DomainPO> subDomainPOs =
+ SessionUtils.getWithoutCommit(
+ DomainMetaMapper.class,
+ mapper ->
+ mapper.listDomainPOsByParentIdentifier(
+ parentIdent.namespace().level(0), parentIdent.name()));
+ if (subDomainPOs.isEmpty()) {
+ throw new NoSuchEntityException("The domain %s does not exist",
parentIdent);
+ }
+ return subDomainPOs.stream()
+ .filter(po -> po.domainId() != null)
+ .map(po -> DomainPO.fromDomainPO(po, parentIdent.namespace()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Inserts the parent edge of a top-level domain, which makes it a
sub-domain of {@code
+ * parentIdent}.
+ *
+ * @param childIdent The identifier of the domain.
+ * @param parentIdent The identifier of the parent domain, in the same
metalake.
+ * @param auditInfo The audit info of the edge.
+ * @throws IOException If the edge cannot be stored.
+ * @throws NoSuchEntityException If either domain does not exist.
+ * @throws EntityAlreadyExistsException If the domain already has a parent.
+ * @throws IllegalArgumentException If the parent is the domain itself or is
in another metalake.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "insertDomainRel")
+ public void insertDomainRel(
+ NameIdentifier childIdent, NameIdentifier parentIdent, AuditInfo
auditInfo)
+ throws IOException {
+ checkSameMetalakeAndDistinct(childIdent, parentIdent);
+ try {
+ DomainPO childPO = getDomainPO(childIdent);
+ DomainPO parentPO = getDomainPO(parentIdent);
+ SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeAndDomains(childIdent, childPO, parentPO),
+ () -> {
+ if (selectDomainRel(childPO.domainId()) != null) {
+ throw new EntityAlreadyExistsException(
+ "The domain %s already has a parent", childIdent);
+ }
+ DomainRelPO domainRelPO =
+ DomainRelPO.initializeDomainRelPO(
+ childPO.metalakeId(), parentPO.domainId(),
childPO.domainId(), auditInfo);
+ SessionUtils.doWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.insertDomainRel(domainRelPO));
+ });
+ } catch (RuntimeException e) {
+ ExceptionUtils.checkSQLException(e, Entity.EntityType.DOMAIN,
childIdent.toString());
+ throw e;
+ }
+ }
+
+ /**
+ * Re-points the parent edge of a sub-domain to another parent. Re-pointing
it to its current
+ * parent changes nothing.
+ *
+ * @param childIdent The identifier of the sub-domain.
+ * @param newParentIdent The identifier of the new parent domain, in the
same metalake.
+ * @param auditInfo The audit info of the change; the edge keeps its creator.
+ * @throws IOException If the edge cannot be stored.
+ * @throws NoSuchEntityException If either domain does not exist, or the
domain has no parent.
+ * @throws IllegalArgumentException If the new parent is the domain itself
or is in another
+ * metalake.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "updateDomainRel")
+ public void updateDomainRel(
+ NameIdentifier childIdent, NameIdentifier newParentIdent, AuditInfo
auditInfo)
+ throws IOException {
+ checkSameMetalakeAndDistinct(childIdent, newParentIdent);
+ try {
+ DomainPO childPO = getDomainPO(childIdent);
+ DomainPO newParentPO = getDomainPO(newParentIdent);
+ SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeAndDomains(childIdent, childPO, newParentPO),
+ () -> {
+ DomainRelPO oldDomainRelPO = selectDomainRel(childPO.domainId());
+ if (oldDomainRelPO == null) {
+ throw new NoSuchEntityException("The domain %s has no parent",
childIdent);
+ }
+ if (Objects.equals(oldDomainRelPO.parentId(),
newParentPO.domainId())) {
+ return;
+ }
+ DomainRelPO newDomainRelPO =
+ DomainRelPO.updateDomainRelPO(oldDomainRelPO,
newParentPO.domainId(), auditInfo);
+ OccWriteSupport.updateWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ DomainRelMapper.class,
+ mapper -> mapper.updateDomainRel(newDomainRelPO,
oldDomainRelPO)),
+ () ->
ExceptionUtils.concurrentModification(Entity.EntityType.DOMAIN, childIdent));
+ });
+ } catch (RuntimeException e) {
+ ExceptionUtils.checkSQLException(e, Entity.EntityType.DOMAIN,
childIdent.toString());
+ throw e;
+ }
+ }
+
+ /**
+ * Removes the parent edge of a domain, which makes it top-level.
+ *
+ * @param childIdent The identifier of the domain.
+ * @return True if the edge is removed, false if the domain is already
top-level.
+ * @throws IOException If the edge cannot be removed.
+ * @throws NoSuchEntityException If the domain does not exist.
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "deleteDomainRel")
+ public boolean deleteDomainRel(NameIdentifier childIdent) throws IOException
{
+ int[] deleted = new int[1];
+ try {
+ DomainPO childPO = getDomainPO(childIdent);
+ SessionUtils.doMultipleWithCommit(
+ () -> lockMetalakeAndDomains(childIdent, childPO, null),
+ () -> deleted[0] = softDeleteDomainRel(childPO.domainId()));
+ } catch (RuntimeException e) {
+ ExceptionUtils.checkSQLException(e, Entity.EntityType.DOMAIN,
childIdent.toString());
+ throw e;
+ }
+ return deleted[0] > 0;
+ }
+
+ /**
+ * Checks that a domain being deleted has no sub-domains, in the caller's
transaction. The caller
+ * must hold the exclusive lock on the domain row, so no sub-domain can be
attached to it
+ * meanwhile.
+ *
+ * <p>The sub-domain count is a plain read. It still sees every edge
attached before the lock was
+ * granted, because the store runs at read committed, where each statement
reads the data
+ * committed before it starts.
+ *
+ * @param identifier The identifier of the domain.
+ * @param domainId The domain ID.
+ * @throws NonEmptyEntityException If the domain has sub-domains.
+ */
+ void checkDomainHasNoSubDomains(NameIdentifier identifier, long domainId) {
+ int subDomainCount =
+ SessionUtils.getWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.countDomainRelsByParentId(domainId));
+ if (subDomainCount > 0) {
+ throw new NonEmptyEntityException(
+ "The domain %s has %d sub-domain(s), remove them first", identifier,
subDomainCount);
+ }
+ }
+
+ private void lockMetalakeAndDomains(
+ NameIdentifier childIdent, DomainPO childPO, @Nullable DomainPO
parentPO) {
+ MetalakeMetaService.getInstance()
+ .lockMetalakeForChildWrite(childIdent.namespace().level(0),
childPO.metalakeId());
+ if (parentPO == null) {
+ lockDomain(childPO, true);
+ } else if (childPO.domainId() < parentPO.domainId()) {
+ lockDomain(childPO, true);
+ lockDomain(parentPO, false);
+ } else {
+ lockDomain(parentPO, false);
+ lockDomain(childPO, true);
+ }
+ }
+
+ private void lockDomain(DomainPO observedDomainPO, boolean exclusive) {
+ OccWriteSupport.lockParentForChildWrite(
+ observedDomainPO.domainName(),
+ Entity.EntityType.DOMAIN,
+ () ->
+ SessionUtils.getWithoutCommit(
+ DomainMetaMapper.class,
+ mapper ->
+ exclusive
+ ?
mapper.selectDomainMetaByIdForUpdate(observedDomainPO.domainId())
+ :
mapper.selectDomainMetaByIdForShare(observedDomainPO.domainId())),
+ null,
+ current ->
+ Objects.equals(current.domainName(), observedDomainPO.domainName())
+ && Objects.equals(current.metalakeId(),
observedDomainPO.metalakeId()));
+ }
+
+ @Nullable
+ private DomainRelPO selectDomainRel(Long childId) {
+ return SessionUtils.getWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.selectDomainRelByChildId(childId));
+ }
+
+ private int softDeleteDomainRel(Long childId) {
+ return SessionUtils.getWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.softDeleteDomainRelByChildId(childId));
+ }
+
+ private DomainPO getDomainPO(NameIdentifier identifier) {
+ DomainPO domainPO =
+ SessionUtils.getWithoutCommit(
+ DomainMetaMapper.class,
+ mapper ->
+ mapper.selectDomainMetaByMetalakeAndName(
+ identifier.namespace().level(0), identifier.name()));
+ if (domainPO == null) {
+ throw new NoSuchEntityException(
+ NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+ Entity.EntityType.DOMAIN.name().toLowerCase(),
+ identifier.name());
+ }
+ return domainPO;
+ }
+
+ private static void checkSameMetalakeAndDistinct(
+ NameIdentifier childIdent, NameIdentifier parentIdent) {
+ Preconditions.checkArgument(
+ Objects.equals(childIdent.namespace(), parentIdent.namespace()),
+ "The domain %s and its parent %s must be in the same metalake",
+ childIdent,
+ parentIdent);
+ Preconditions.checkArgument(
+ !Objects.equals(childIdent.name(), parentIdent.name()),
+ "The domain %s cannot be its own parent",
+ childIdent);
+ }
+}
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 44346eedfd..000b564116 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
@@ -36,6 +36,7 @@ import org.apache.gravitino.meta.BaseMetalake;
import org.apache.gravitino.metrics.Monitored;
import org.apache.gravitino.storage.relational.mapper.CatalogMetaMapper;
import org.apache.gravitino.storage.relational.mapper.DomainMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
import org.apache.gravitino.storage.relational.mapper.FilesetVersionMapper;
import org.apache.gravitino.storage.relational.mapper.FunctionMetaMapper;
@@ -293,6 +294,10 @@ public class MetalakeMetaService {
SessionUtils.doWithoutCommit(
DomainMetaMapper.class,
mapper ->
mapper.softDeleteDomainMetasByMetalakeId(metalakeId)),
+ () ->
+ SessionUtils.doWithoutCommit(
+ DomainRelMapper.class,
+ mapper ->
mapper.softDeleteDomainRelsByMetalakeId(metalakeId)),
() ->
SessionUtils.doWithoutCommit(
TagMetadataObjectRelMapper.class,
@@ -403,6 +408,10 @@ public class MetalakeMetaService {
SessionUtils.doWithoutCommit(
DomainMetaMapper.class,
mapper ->
mapper.softDeleteDomainMetasByMetalakeId(metalakeId)),
+ () ->
+ SessionUtils.doWithoutCommit(
+ DomainRelMapper.class,
+ mapper ->
mapper.softDeleteDomainRelsByMetalakeId(metalakeId)),
() ->
SessionUtils.doWithoutCommit(
TagMetadataObjectRelMapper.class,
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainPO.java
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainPO.java
index b110212735..5e23801079 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainPO.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainPO.java
@@ -37,6 +37,20 @@ public class TestDomainPO {
private static final AuditInfo AUDIT_INFO =
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+ @Test
+ public void testDerivedNamesDoNotChangeEquality() throws Exception {
+ DomainPO plain = new DomainPO();
+ DomainPO joined = new DomainPO();
+ var parentName = DomainPO.class.getDeclaredField("parentName");
+ parentName.setAccessible(true);
+ parentName.set(joined, "derived name");
+ var backingTagName = DomainPO.class.getDeclaredField("backingTagName");
+ backingTagName.setAccessible(true);
+ backingTagName.set(joined, "derived name");
+ Assertions.assertEquals(plain, joined);
+ Assertions.assertEquals(plain.hashCode(), joined.hashCode());
+ }
+
@Test
public void testInitializeAndConvertDomainPO() {
DomainEntity domain = newDomain(10L, "Finance/Trading", "comment",
ImmutableMap.of("k", "v"));
@@ -86,6 +100,15 @@ public class TestDomainPO {
domain.toBuilder().withParent(null).withBackingTagName(null).build(),
converted);
}
+ @Test
+ public void testConvertDomainPOWithParent() {
+ DomainEntity domain = newDomain(10L, "Trading", null, null);
+ DomainEntity converted =
+ DomainPO.fromDomainPO(DomainPO.initializeDomainPO(domain, 1L),
NAMESPACE, "Finance");
+ Assertions.assertEquals("Finance", converted.parent().orElse(null));
+ Assertions.assertEquals(domain.toBuilder().withParent("Finance").build(),
converted);
+ }
+
@Test
public void testUpdateDomainPO() {
DomainPO oldDomainPO =
@@ -116,8 +139,10 @@ public class TestDomainPO {
Lists.newArrayList(
DomainPO.initializeDomainPO(finance, 1L),
DomainPO.initializeDomainPO(markets, 1L));
+ // Only sub-domains are in the parent map; the others are top-level.
Assertions.assertEquals(
- Lists.newArrayList(finance, markets),
DomainPO.fromDomainPOs(domainPOs, NAMESPACE));
+ Lists.newArrayList(finance,
markets.toBuilder().withParent("Finance").build()),
+ DomainPO.fromDomainPOs(domainPOs, NAMESPACE, ImmutableMap.of(11L,
"Finance")));
}
@Test
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainRelPO.java
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainRelPO.java
new file mode 100644
index 0000000000..f09ffe39b4
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestDomainRelPO.java
@@ -0,0 +1,112 @@
+/*
+ * 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.fasterxml.jackson.core.JsonProcessingException;
+import java.time.Instant;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.meta.AuditInfo;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestDomainRelPO {
+
+ private static final Instant CREATE_TIME = Instant.ofEpochMilli(1000L);
+
+ private static final AuditInfo AUDIT_INFO =
+
AuditInfo.builder().withCreator("creator").withCreateTime(CREATE_TIME).build();
+
+ @Test
+ public void testDerivedNamesDoNotChangeEquality() throws Exception {
+ DomainRelPO plain = new DomainRelPO();
+ DomainRelPO joined = new DomainRelPO();
+ var parentName = DomainRelPO.class.getDeclaredField("parentName");
+ parentName.setAccessible(true);
+ parentName.set(joined, "derived name");
+ Assertions.assertEquals(plain, joined);
+ Assertions.assertEquals(plain.hashCode(), joined.hashCode());
+ }
+
+ @Test
+ public void testInitializeDomainRelPO() throws JsonProcessingException {
+ DomainRelPO domainRelPO = DomainRelPO.initializeDomainRelPO(1L, 10L, 11L,
AUDIT_INFO);
+
+ Assertions.assertEquals(1L, domainRelPO.metalakeId());
+ Assertions.assertEquals(10L, domainRelPO.parentId());
+ Assertions.assertEquals(11L, domainRelPO.childId());
+ Assertions.assertNull(domainRelPO.parentName());
+ Assertions.assertEquals(1L, domainRelPO.currentVersion());
+ Assertions.assertEquals(1L, domainRelPO.lastVersion());
+ Assertions.assertEquals(0L, domainRelPO.deletedAt());
+ Assertions.assertEquals(AUDIT_INFO, readAuditInfo(domainRelPO));
+ }
+
+ @Test
+ public void testUpdateDomainRelPOKeepsCreator() throws
JsonProcessingException {
+ DomainRelPO oldDomainRelPO = DomainRelPO.initializeDomainRelPO(1L, 10L,
11L, AUDIT_INFO);
+ Instant modifiedTime = Instant.ofEpochMilli(2000L);
+ AuditInfo modifiedAuditInfo =
+ AuditInfo.builder()
+ .withCreator("ignored")
+ .withCreateTime(Instant.ofEpochMilli(3000L))
+ .withLastModifier("modifier")
+ .withLastModifiedTime(modifiedTime)
+ .build();
+
+ DomainRelPO newDomainRelPO =
+ DomainRelPO.updateDomainRelPO(oldDomainRelPO, 12L, modifiedAuditInfo);
+ Assertions.assertEquals(1L, newDomainRelPO.metalakeId());
+ Assertions.assertEquals(12L, newDomainRelPO.parentId());
+ Assertions.assertEquals(11L, newDomainRelPO.childId());
+ Assertions.assertEquals(2L, newDomainRelPO.currentVersion());
+ Assertions.assertEquals(2L, newDomainRelPO.lastVersion());
+ Assertions.assertEquals(0L, newDomainRelPO.deletedAt());
+
+ AuditInfo storedAuditInfo = readAuditInfo(newDomainRelPO);
+ Assertions.assertEquals("creator", storedAuditInfo.creator());
+ Assertions.assertEquals(CREATE_TIME, storedAuditInfo.createTime());
+ Assertions.assertEquals("modifier", storedAuditInfo.lastModifier());
+ Assertions.assertEquals(modifiedTime, storedAuditInfo.lastModifiedTime());
+ }
+
+ @Test
+ public void testDomainRelPOValidation() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> DomainRelPO.initializeDomainRelPO(1L, 10L, 10L, AUDIT_INFO));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> DomainRelPO.initializeDomainRelPO(null, 10L, 11L, AUDIT_INFO));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> DomainRelPO.initializeDomainRelPO(1L, null, 11L, AUDIT_INFO));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> DomainRelPO.initializeDomainRelPO(1L, 10L, null, AUDIT_INFO));
+
+ DomainRelPO domainRelPO = DomainRelPO.initializeDomainRelPO(1L, 10L, 11L,
AUDIT_INFO);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> DomainRelPO.updateDomainRelPO(domainRelPO, 11L, AUDIT_INFO));
+ }
+
+ private AuditInfo readAuditInfo(DomainRelPO domainRelPO) throws
JsonProcessingException {
+ return JsonUtils.anyFieldMapper().readValue(domainRelPO.auditInfo(),
AuditInfo.class);
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestDomainRelService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestDomainRelService.java
new file mode 100644
index 0000000000..dd53110914
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestDomainRelService.java
@@ -0,0 +1,921 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.Collections;
+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 java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.domain.Domain;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.DomainEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.mapper.DomainRelMapper;
+import org.apache.gravitino.storage.relational.po.DomainRelPO;
+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.exceptions.PersistenceException;
+import org.apache.ibatis.executor.Executor;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.plugin.Interceptor;
+import org.apache.ibatis.plugin.Intercepts;
+import org.apache.ibatis.plugin.Invocation;
+import org.apache.ibatis.plugin.Signature;
+import org.apache.ibatis.session.ResultHandler;
+import org.apache.ibatis.session.RowBounds;
+import org.apache.ibatis.session.SqlSession;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+public class TestDomainRelService extends TestJDBCBackend {
+
+ private static final String METALAKE_NAME =
"metalake_for_domain_rel_service_test";
+
+ private final DomainMetaService domainMetaService =
DomainMetaService.getInstance();
+
+ private final DomainRelService domainRelService =
DomainRelService.getInstance();
+
+ @TestTemplate
+ public void testInsertDomainRelAndResolveParent() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ DomainEntity tax = insertDomain(METALAKE_NAME, "Tax");
+ DomainEntity markets = insertDomain(METALAKE_NAME, "Markets");
+
+
Assertions.assertFalse(domainRelService.getParentDomainName(trading.id()).isPresent());
+
Assertions.assertTrue(domainRelService.listSubDomains(finance.nameIdentifier()).isEmpty());
+
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ domainRelService.insertDomainRel(tax.nameIdentifier(),
finance.nameIdentifier(), AUDIT_INFO);
+
+ Assertions.assertEquals("Finance",
domainRelService.getParentDomainName(trading.id()).get());
+
Assertions.assertFalse(domainRelService.getParentDomainName(finance.id()).isPresent());
+ Assertions.assertEquals(
+ ImmutableMap.of(trading.id(), "Finance", tax.id(), "Finance"),
+ domainRelService.batchGetParentDomainNames(
+ Lists.newArrayList(finance.id(), trading.id(), tax.id(),
markets.id())));
+ Assertions.assertTrue(
+
domainRelService.batchGetParentDomainNames(Lists.newArrayList()).isEmpty());
+
+ DomainEntity tradingWithParent =
trading.toBuilder().withParent("Finance").build();
+ DomainEntity taxWithParent = tax.toBuilder().withParent("Finance").build();
+ List<DomainEntity> subDomains =
domainRelService.listSubDomains(finance.nameIdentifier());
+ Assertions.assertEquals(2, subDomains.size());
+ Assertions.assertTrue(subDomains.contains(tradingWithParent));
+ Assertions.assertTrue(subDomains.contains(taxWithParent));
+
Assertions.assertTrue(domainRelService.listSubDomains(trading.nameIdentifier()).isEmpty());
+
+ DomainRelPO domainRelPO = selectDomainRel(trading.id());
+ Assertions.assertEquals(finance.id(), domainRelPO.parentId());
+ Assertions.assertEquals(selectMetalakeId(trading),
domainRelPO.metalakeId());
+ Assertions.assertEquals(1L, domainRelPO.currentVersion());
+ Assertions.assertEquals(
+ AUDIT_INFO,
JsonUtils.anyFieldMapper().readValue(domainRelPO.auditInfo(), AuditInfo.class));
+ }
+
+ @TestTemplate
+ public void testDomainReadsFillParent() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ DomainEntity tradingWithParent =
trading.toBuilder().withParent("Finance").build();
+
+ Assertions.assertEquals(
+ tradingWithParent,
domainMetaService.getDomainByIdentifier(trading.nameIdentifier()));
+ Assertions.assertEquals(
+ tradingWithParent, backend.get(trading.nameIdentifier(),
Entity.EntityType.DOMAIN));
+ Assertions.assertEquals(
+ finance,
domainMetaService.getDomainByIdentifier(finance.nameIdentifier()));
+
+ List<DomainEntity> domains =
+
domainMetaService.listDomainsByNamespace(NamespaceUtil.ofDomain(METALAKE_NAME));
+ Assertions.assertEquals(2, domains.size());
+ Assertions.assertTrue(domains.contains(finance));
+ Assertions.assertTrue(domains.contains(tradingWithParent));
+ Assertions.assertEquals(
+ Lists.newArrayList(tradingWithParent),
+
domainMetaService.batchGetDomainByIdentifier(Lists.newArrayList(trading.nameIdentifier())));
+
+ // An update keeps the parent, which only the relation changes.
+ DomainEntity updated =
+ domainMetaService.updateDomain(
+ trading.nameIdentifier(),
+ entity -> ((DomainEntity)
entity).toBuilder().withComment("updated").build());
+ Assertions.assertEquals("Finance", updated.parent().orElse(null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ domainMetaService.updateDomain(
+ trading.nameIdentifier(),
+ entity -> ((DomainEntity)
entity).toBuilder().withParent(null).build()));
+ Assertions.assertEquals(
+ "Finance",
+
domainMetaService.getDomainByIdentifier(trading.nameIdentifier()).parent().orElse(null));
+
+ // The edge refers to the parent by ID, so a renamed parent is resolved by
its new name.
+ domainMetaService.updateDomain(
+ finance.nameIdentifier(),
+ entity -> ((DomainEntity)
entity).toBuilder().withName("Finance/Global").build());
+ Assertions.assertEquals(
+ "Finance/Global",
+
domainMetaService.getDomainByIdentifier(trading.nameIdentifier()).parent().orElse(null));
+ }
+
+ @TestTemplate
+ public void testInsertDomainRelIsRejected() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ String anotherMetalakeName = METALAKE_NAME + "_another";
+ createAndInsertMakeLake(anotherMetalakeName);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ DomainEntity markets = insertDomain(METALAKE_NAME, "Markets");
+ DomainEntity elsewhere = insertDomain(anotherMetalakeName, "Elsewhere");
+ NameIdentifier missing = NameIdentifierUtil.ofDomain(METALAKE_NAME,
"missing");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ domainRelService.insertDomainRel(
+ finance.nameIdentifier(), finance.nameIdentifier(),
AUDIT_INFO));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ domainRelService.insertDomainRel(
+ elsewhere.nameIdentifier(), finance.nameIdentifier(),
AUDIT_INFO));
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () -> domainRelService.insertDomainRel(missing,
finance.nameIdentifier(), AUDIT_INFO));
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () -> domainRelService.insertDomainRel(trading.nameIdentifier(),
missing, AUDIT_INFO));
+ Assertions.assertThrows(
+ NoSuchEntityException.class, () ->
domainRelService.listSubDomains(missing));
+
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ // A domain has at most one parent; moving it is a re-point.
+ Assertions.assertThrows(
+ EntityAlreadyExistsException.class,
+ () ->
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), markets.nameIdentifier(),
AUDIT_INFO));
+ Assertions.assertEquals(finance.id(),
selectDomainRel(trading.id()).parentId());
+
+ // The unique key keeps one active edge per domain even without the check.
+ DomainRelPO secondEdge =
+ DomainRelPO.initializeDomainRelPO(
+ selectMetalakeId(trading), markets.id(), trading.id(), AUDIT_INFO);
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ SessionUtils.doWithCommit(
+ DomainRelMapper.class, mapper ->
mapper.insertDomainRel(secondEdge)));
+ }
+
+ @TestTemplate
+ public void testUpdateDomainRel() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity markets = insertDomain(METALAKE_NAME, "Markets");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ domainRelService.updateDomainRel(
+ trading.nameIdentifier(), markets.nameIdentifier(),
AUDIT_INFO));
+
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ Instant modifiedTime = Instant.now();
+ AuditInfo modifiedAuditInfo =
+
AuditInfo.builder().withLastModifier("mover").withLastModifiedTime(modifiedTime).build();
+ domainRelService.updateDomainRel(
+ trading.nameIdentifier(), markets.nameIdentifier(), modifiedAuditInfo);
+
+ Assertions.assertEquals("Markets",
domainRelService.getParentDomainName(trading.id()).get());
+
Assertions.assertTrue(domainRelService.listSubDomains(finance.nameIdentifier()).isEmpty());
+ Assertions.assertEquals(
+ Lists.newArrayList(trading.toBuilder().withParent("Markets").build()),
+ domainRelService.listSubDomains(markets.nameIdentifier()));
+
+ DomainRelPO domainRelPO = selectDomainRel(trading.id());
+ Assertions.assertEquals(2L, domainRelPO.currentVersion());
+ AuditInfo storedAuditInfo =
+ JsonUtils.anyFieldMapper().readValue(domainRelPO.auditInfo(),
AuditInfo.class);
+ Assertions.assertEquals(AUDIT_INFO.creator(), storedAuditInfo.creator());
+ Assertions.assertEquals("mover", storedAuditInfo.lastModifier());
+ Assertions.assertEquals(modifiedTime, storedAuditInfo.lastModifiedTime());
+ Assertions.assertEquals(1, countDomainRelRows(trading.id()));
+
+ // Re-pointing to the current parent changes nothing.
+ domainRelService.updateDomainRel(
+ trading.nameIdentifier(), markets.nameIdentifier(), modifiedAuditInfo);
+ Assertions.assertEquals(2L,
selectDomainRel(trading.id()).currentVersion());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ domainRelService.updateDomainRel(
+ trading.nameIdentifier(), trading.nameIdentifier(),
AUDIT_INFO));
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ domainRelService.updateDomainRel(
+ trading.nameIdentifier(),
+ NameIdentifierUtil.ofDomain(METALAKE_NAME, "missing"),
+ AUDIT_INFO));
+ }
+
+ @TestTemplate
+ public void testDeleteDomainRel() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+
+
Assertions.assertFalse(domainRelService.deleteDomainRel(trading.nameIdentifier()));
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+
Assertions.assertTrue(domainRelService.deleteDomainRel(trading.nameIdentifier()));
+
Assertions.assertFalse(domainRelService.deleteDomainRel(trading.nameIdentifier()));
+
Assertions.assertFalse(domainRelService.getParentDomainName(trading.id()).isPresent());
+
Assertions.assertTrue(domainRelService.listSubDomains(finance.nameIdentifier()).isEmpty());
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ domainRelService.deleteDomainRel(
+ NameIdentifierUtil.ofDomain(METALAKE_NAME, "missing")));
+
+ // A detached domain can be attached again.
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ Assertions.assertEquals("Finance",
domainRelService.getParentDomainName(trading.id()).get());
+ }
+
+ @TestTemplate
+ public void testDeleteDomainWithSubDomains() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+
+ Assertions.assertThrows(
+ NonEmptyEntityException.class,
+ () -> domainMetaService.deleteDomain(finance.nameIdentifier()));
+ Assertions.assertThrows(
+ NonEmptyEntityException.class,
+ () -> backend.delete(finance.nameIdentifier(),
Entity.EntityType.DOMAIN, true));
+ Assertions.assertTrue(backend.exists(finance.nameIdentifier(),
Entity.EntityType.DOMAIN));
+ Assertions.assertEquals("Finance",
domainRelService.getParentDomainName(trading.id()).get());
+
+ // Deleting the sub-domain removes its own edge, after which the parent
can be deleted.
+
Assertions.assertTrue(domainMetaService.deleteDomain(trading.nameIdentifier()));
+ Assertions.assertNull(selectDomainRel(trading.id()));
+ Assertions.assertEquals(1, countDomainRelRows(trading.id()));
+
Assertions.assertTrue(domainMetaService.deleteDomain(finance.nameIdentifier()));
+ }
+
+ @TestTemplate
+ public void testDomainRelWithMetalakeDeleteAndGarbageCollection() throws
IOException {
+ BaseMetalake metalake = createAndInsertMakeLake(METALAKE_NAME);
+ String anotherMetalakeName = METALAKE_NAME + "_another";
+ createAndInsertMakeLake(anotherMetalakeName);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ DomainEntity anotherFinance = insertDomain(anotherMetalakeName, "Finance");
+ DomainEntity anotherTrading = insertDomain(anotherMetalakeName, "Trading");
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+ domainRelService.insertDomainRel(
+ anotherTrading.nameIdentifier(), anotherFinance.nameIdentifier(),
AUDIT_INFO);
+
+ Assertions.assertTrue(
+
MetalakeMetaService.getInstance().deleteMetalake(metalake.nameIdentifier(),
true));
+ Assertions.assertNull(selectDomainRel(trading.id()));
+ Assertions.assertEquals(1, countDomainRelRows(trading.id()));
+ Assertions.assertEquals(
+ "Finance",
domainRelService.getParentDomainName(anotherTrading.id()).get());
+
+ Assertions.assertTrue(
+ backend.hardDeleteLegacyData(Entity.EntityType.DOMAIN,
Instant.now().toEpochMilli() + 1000)
+ >= 3);
+ Assertions.assertEquals(0, countDomainRelRows(trading.id()));
+ Assertions.assertEquals(1, countDomainRelRows(anotherTrading.id()));
+ Assertions.assertEquals(
+ "Finance",
domainRelService.getParentDomainName(anotherTrading.id()).get());
+ }
+
+ @TestTemplate
+ public void testDomainRelWithNonCascadeMetalakeDelete() throws IOException {
+ BaseMetalake metalake = createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+ domainRelService.insertDomainRel(
+ trading.nameIdentifier(), finance.nameIdentifier(), AUDIT_INFO);
+
+ Assertions.assertTrue(
+
MetalakeMetaService.getInstance().deleteMetalake(metalake.nameIdentifier(),
false));
+ Assertions.assertNull(selectDomainRel(trading.id()));
+ Assertions.assertEquals(1, countDomainRelRows(trading.id()));
+ }
+
+ @TestTemplate
+ public void testDeleteParentWaitsForConcurrentAttach() throws Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+
+ CountDownLatch attachWritten = new CountDownLatch(1);
+ CountDownLatch allowAttachCommit = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> attachResult =
+ executor.submit(
+ () -> {
+ try {
+ SessionUtils.doMultipleWithCommit(
+ () -> insertDomainRel(trading, finance),
+ () -> {
+ attachWritten.countDown();
+ await(allowAttachCommit);
+ });
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+
+ try {
+ Assertions.assertTrue(attachWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> deleteResult =
+ executor.submit(
+ () -> {
+ try {
+ domainMetaService.deleteDomain(finance.nameIdentifier());
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+ // The attach holds the parent row, so the delete waits for it instead
of missing the edge.
+ Assertions.assertThrows(
+ TimeoutException.class, () -> deleteResult.get(500,
TimeUnit.MILLISECONDS));
+
+ allowAttachCommit.countDown();
+ Assertions.assertNull(attachResult.get(30, TimeUnit.SECONDS));
+ Throwable deleteFailure = deleteResult.get(30, TimeUnit.SECONDS);
+ Assertions.assertTrue(
+ deleteFailure instanceof NonEmptyEntityException,
String.valueOf(deleteFailure));
+ } finally {
+ allowAttachCommit.countDown();
+ executor.shutdownNow();
+ }
+
+ Assertions.assertTrue(backend.exists(finance.nameIdentifier(),
Entity.EntityType.DOMAIN));
+ Assertions.assertEquals("Finance",
domainRelService.getParentDomainName(trading.id()).get());
+ }
+
+ @TestTemplate
+ public void testAttachWaitsForConcurrentParentDelete() throws Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity finance = insertDomain(METALAKE_NAME, "Finance");
+ DomainEntity trading = insertDomain(METALAKE_NAME, "Trading");
+
+ CountDownLatch deleteWritten = new CountDownLatch(1);
+ CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<Throwable> deleteResult =
+ executor.submit(
+ () -> {
+ try {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ Assertions.assertTrue(
+
domainMetaService.deleteDomain(finance.nameIdentifier())),
+ () -> {
+ deleteWritten.countDown();
+ await(allowDeleteCommit);
+ });
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+
+ try {
+ Assertions.assertTrue(deleteWritten.await(30, TimeUnit.SECONDS));
+ Future<Throwable> attachResult =
+ executor.submit(
+ () -> {
+ try {
+ insertDomainRel(trading, finance);
+ return null;
+ } catch (Throwable throwable) {
+ return throwable;
+ }
+ });
+ // The delete holds the parent row, so the attach waits and then sees
the parent is gone.
+ Assertions.assertThrows(
+ TimeoutException.class, () -> attachResult.get(500,
TimeUnit.MILLISECONDS));
+
+ allowDeleteCommit.countDown();
+ Assertions.assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+ Throwable attachFailure = attachResult.get(30, TimeUnit.SECONDS);
+ Assertions.assertTrue(
+ attachFailure instanceof NoSuchEntityException,
String.valueOf(attachFailure));
+ } finally {
+ allowDeleteCommit.countDown();
+ executor.shutdownNow();
+ }
+
+ Assertions.assertFalse(backend.exists(finance.nameIdentifier(),
Entity.EntityType.DOMAIN));
+ Assertions.assertNull(selectDomainRel(trading.id()));
+ Assertions.assertEquals(0, countDomainRelRows(trading.id()));
+ }
+
+ @TestTemplate
+ public void testCaughtNonEmptyDeleteDoesNotMutateParent() throws IOException
{
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity parent = insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ insertDomainRel(child, parent);
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ Assertions.assertThrows(
+ NonEmptyEntityException.class,
+ () -> domainMetaService.deleteDomain(parent.nameIdentifier())),
+ () -> {
+ try {
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity -> ((DomainEntity)
entity).toBuilder().withComment("changed").build());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ Assertions.assertTrue(backend.exists(parent.nameIdentifier(),
Entity.EntityType.DOMAIN));
+ Assertions.assertEquals(
+ "Parent",
domainRelService.getParentDomainName(child.id()).orElse(null));
+ Assertions.assertEquals(
+ "changed",
domainMetaService.getDomainByIdentifier(child.nameIdentifier()).comment());
+ }
+
+ @TestTemplate
+ public void testRepeatedDetachWithinTransaction() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity parent = insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ SessionUtils.doMultipleWithCommit(
+ () -> {
+ try {
+ for (int i = 0; i < 3; i++) {
+ insertDomainRel(child, parent);
+
Assertions.assertTrue(domainRelService.deleteDomainRel(child.nameIdentifier()));
+ }
+ insertDomainRel(child, parent);
+
Assertions.assertTrue(domainMetaService.deleteDomain(child.nameIdentifier()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ Assertions.assertEquals(4, countDomainRelRows(child.id()));
+ Assertions.assertNull(selectDomainRel(child.id()));
+ }
+
+ @TestTemplate
+ public void testLockedUpdateReadsPastCachedParent() throws Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity first = insertDomain(METALAKE_NAME, "First");
+ DomainEntity second = insertDomain(METALAKE_NAME, "Second");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ insertDomainRel(child, first);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ SessionUtils.beginTransaction();
+ try {
+ Assertions.assertEquals(
+ "First",
+
domainMetaService.getDomainByIdentifier(child.nameIdentifier()).parent().orElse(null));
+ Assertions.assertEquals(
+ "First",
domainRelService.getParentDomainName(child.id()).orElse(null));
+ executor
+ .submit(
+ () -> {
+ try {
+ domainRelService.updateDomainRel(
+ child.nameIdentifier(), second.nameIdentifier(),
AUDIT_INFO);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .get(30, TimeUnit.SECONDS);
+ Assertions.assertEquals(
+ "Second",
domainRelService.getParentDomainName(child.id()).orElse(null));
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity -> {
+ DomainEntity domain = (DomainEntity) entity;
+ Assertions.assertEquals("Second", domain.parent().orElse(null));
+ return entity;
+ });
+ SessionUtils.commitTransaction();
+ } finally {
+ if (SessionUtils.isInTransaction()) {
+ SessionUtils.rollbackTransaction();
+ }
+ executor.shutdownNow();
+ }
+ }
+
+ @TestTemplate
+ public void testResponseReadFailureRollsBackUpdate() throws IOException {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ installReadProbe();
+ AtomicBoolean written = new AtomicBoolean();
+ ReadProbe.AFTER_WRITE.set(
+ id -> {
+ if (id.endsWith(".updateDomainMeta")) {
+ written.set(true);
+ }
+ });
+ ReadProbe.BEFORE_READ.set(
+ id -> {
+ if (written.get()) {
+ throw new IllegalStateException("response read failed");
+ }
+ });
+ try {
+ Assertions.assertThrows(
+ PersistenceException.class,
+ () ->
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity ->
+ ((DomainEntity) entity).toBuilder().withComment("must
roll back").build()));
+ } finally {
+ ReadProbe.clear();
+ }
+ Assertions.assertTrue(written.get());
+ Assertions.assertNull(
+
domainMetaService.getDomainByIdentifier(child.nameIdentifier()).comment());
+ }
+
+ @TestTemplate
+ public void testReadPathsKeepMetadataAndParentTogether() throws Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity first = insertDomain(METALAKE_NAME, "First");
+ DomainEntity second = insertDomain(METALAKE_NAME, "Second");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ insertDomainRel(child, first);
+ installReadProbe();
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ for (String path : new String[] {"get", "list", "batch", "subdomains"}) {
+ domainRelService.updateDomainRel(
+ child.nameIdentifier(), first.nameIdentifier(), AUDIT_INFO);
+ domainMetaService.updateDomain(first.nameIdentifier(), entity ->
entity);
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity -> ((DomainEntity)
entity).toBuilder().withComment("old").build());
+ AtomicBoolean triggered = new AtomicBoolean();
+ String method =
+ path.equals("get")
+ ? "selectDomainMetaByMetalakeAndName"
+ : path.equals("list")
+ ? "listDomainPOsByMetalake"
+ : path.equals("batch")
+ ? "listDomainPOsByMetalakeAndDomainNames"
+ : "listDomainPOsByParentIdentifier";
+ ReadProbe.AFTER_READ.set(
+ id -> {
+ if (id.endsWith("." + method) && triggered.compareAndSet(false,
true)) {
+ try {
+ executor
+ .submit(
+ () ->
+ SessionUtils.doMultipleWithCommit(
+ () -> {
+ try {
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity ->
+ ((DomainEntity) entity)
+
.toBuilder().withComment("new").build());
+ domainRelService.updateDomainRel(
+ child.nameIdentifier(),
+ second.nameIdentifier(),
+ AUDIT_INFO);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }))
+ .get(30, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+ DomainEntity read;
+ try {
+ if (path.equals("get")) {
+ read =
domainMetaService.getDomainByIdentifier(child.nameIdentifier());
+ } else {
+ List<DomainEntity> domains =
+ path.equals("list")
+ ?
domainMetaService.listDomainsByNamespace(child.namespace())
+ : path.equals("batch")
+ ? domainMetaService.batchGetDomainByIdentifier(
+ Collections.singletonList(child.nameIdentifier()))
+ :
domainRelService.listSubDomains(first.nameIdentifier());
+ read = domains.stream().filter(d ->
d.id().equals(child.id())).findFirst().get();
+ }
+ } finally {
+ ReadProbe.clear();
+ }
+ Assertions.assertTrue(triggered.get(), path);
+ Assertions.assertEquals("old", read.comment(), path);
+ Assertions.assertEquals("First", read.parent().orElse(null), path);
+ }
+ } finally {
+ ReadProbe.clear();
+ executor.shutdownNow();
+ }
+ }
+
+ @TestTemplate
+ public void testSubDomainReadUsesCurrentParentName() throws Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity parent = insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ insertDomainRel(child, parent);
+ installReadProbe();
+ AtomicBoolean triggered = new AtomicBoolean();
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ ReadProbe.AFTER_READ.set(
+ id -> {
+ if (id.endsWith(".listDomainPOsByParentIdentifier")
+ && triggered.compareAndSet(false, true)) {
+ try {
+ executor
+ .submit(
+ () ->
+ SessionUtils.doMultipleWithCommit(
+ () -> {
+ try {
+ domainMetaService.updateDomain(
+ parent.nameIdentifier(),
+ entity ->
+ ((DomainEntity) entity)
+
.toBuilder().withName("Renamed").build());
+ domainMetaService.updateDomain(
+ child.nameIdentifier(),
+ entity ->
+ ((DomainEntity) entity)
+
.toBuilder().withComment("new").build());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }))
+ .get(30, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+ try {
+ DomainEntity read =
domainRelService.listSubDomains(parent.nameIdentifier()).get(0);
+ Assertions.assertTrue(triggered.get());
+ Assertions.assertNull(read.comment());
+ Assertions.assertEquals("Parent", read.parent().orElse(null));
+ } finally {
+ ReadProbe.clear();
+ executor.shutdownNow();
+ }
+ }
+
+ @TestTemplate
+ public void testRepeatedDetachThenMetalakeDelete() throws IOException {
+ BaseMetalake metalake = createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity parent = insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ SessionUtils.doMultipleWithCommit(
+ () -> {
+ insertDomainRel(child, parent);
+ try {
+
Assertions.assertTrue(domainRelService.deleteDomainRel(child.nameIdentifier()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ insertDomainRel(child, parent);
+ Assertions.assertTrue(
+
MetalakeMetaService.getInstance().deleteMetalake(metalake.nameIdentifier(),
false));
+ });
+ Assertions.assertEquals(2, countDomainRelRows(child.id()));
+ Assertions.assertNull(selectDomainRel(child.id()));
+ }
+
+ @TestTemplate
+ public void testSubDomainsResolveRecreatedParentInOneStatement() throws
Exception {
+ createAndInsertMakeLake(METALAKE_NAME);
+ DomainEntity parent = insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity child = insertDomain(METALAKE_NAME, "Child");
+ insertDomainRel(child, parent);
+ installReadProbe();
+ AtomicBoolean triggered = new AtomicBoolean();
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ ReadProbe.BEFORE_READ.set(
+ id -> {
+ // Replace the parent after any preliminary lookup but before the
children query.
+ // A two-statement implementation would keep the original parent's
ID.
+ if (id.endsWith(".listDomainPOsByParentIdentifier")
+ && triggered.compareAndSet(false, true)) {
+ try {
+ executor
+ .submit(
+ () ->
+ SessionUtils.doMultipleWithCommit(
+ () -> {
+ Assertions.assertTrue(
+
domainMetaService.deleteDomain(child.nameIdentifier()));
+ Assertions.assertTrue(
+
domainMetaService.deleteDomain(parent.nameIdentifier()));
+ try {
+ DomainEntity replacement =
insertDomain(METALAKE_NAME, "Parent");
+ DomainEntity replacementChild =
+ insertDomain(METALAKE_NAME,
"ReplacementChild");
+ insertDomainRel(replacementChild,
replacement);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }))
+ .get(30, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+ try {
+ List<DomainEntity> children =
domainRelService.listSubDomains(parent.nameIdentifier());
+ Assertions.assertTrue(triggered.get());
+ Assertions.assertEquals(1, children.size());
+ Assertions.assertEquals("ReplacementChild", children.get(0).name());
+ Assertions.assertEquals("Parent", children.get(0).parent().orElse(null));
+ } finally {
+ ReadProbe.clear();
+ executor.shutdownNow();
+ }
+ }
+
+ private void installReadProbe() {
+ var configuration =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().getConfiguration();
+ if (configuration.getInterceptors().stream().noneMatch(i -> i instanceof
ReadProbe)) {
+ configuration.addInterceptor(new ReadProbe());
+ }
+ }
+
+ /** Injects controlled interleavings and failures without changing
production mapper code. */
+ @Intercepts({
+ @Signature(
+ type = Executor.class,
+ method = "query",
+ args = {MappedStatement.class, Object.class, RowBounds.class,
ResultHandler.class}),
+ @Signature(
+ type = Executor.class,
+ method = "update",
+ args = {MappedStatement.class, Object.class})
+ })
+ public static class ReadProbe implements Interceptor {
+ private static final ThreadLocal<Consumer<String>> BEFORE_READ = new
ThreadLocal<>();
+ private static final ThreadLocal<Consumer<String>> AFTER_READ = new
ThreadLocal<>();
+ private static final ThreadLocal<Consumer<String>> AFTER_WRITE = new
ThreadLocal<>();
+
+ /** Creates an inactive probe. */
+ public ReadProbe() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public Object intercept(Invocation invocation) throws Throwable {
+ String id = ((MappedStatement) invocation.getArgs()[0]).getId();
+ boolean read = invocation.getMethod().getName().equals("query");
+ if (read && BEFORE_READ.get() != null) {
+ BEFORE_READ.get().accept(id);
+ }
+ Object result = invocation.proceed();
+ Consumer<String> after = read ? AFTER_READ.get() : AFTER_WRITE.get();
+ if (after != null) {
+ after.accept(id);
+ }
+ return result;
+ }
+
+ private static void clear() {
+ BEFORE_READ.remove();
+ AFTER_READ.remove();
+ AFTER_WRITE.remove();
+ }
+ }
+
+ private DomainEntity insertDomain(String metalake, String name) throws
IOException {
+ Map<String, String> props = ImmutableMap.of("k1", "v1");
+ DomainEntity domain =
+ DomainEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(name)
+ .withNamespace(NamespaceUtil.ofDomain(metalake))
+ .withProperties(props)
+ .withStatus(Domain.Status.DRAFT)
+ .withAuditInfo(AUDIT_INFO)
+ .build();
+ domainMetaService.insertDomain(domain, false);
+ return domain;
+ }
+
+ private void insertDomainRel(DomainEntity child, DomainEntity parent) {
+ try {
+ domainRelService.insertDomainRel(child.nameIdentifier(),
parent.nameIdentifier(), AUDIT_INFO);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ Assertions.assertTrue(latch.await(30, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private DomainRelPO selectDomainRel(long childId) {
+ return SessionUtils.getWithoutCommit(
+ DomainRelMapper.class, mapper ->
mapper.selectDomainRelByChildId(childId));
+ }
+
+ private long selectMetalakeId(DomainEntity domain) {
+ return
MetalakeMetaService.getInstance().getMetalakeIdByName(domain.namespace().level(0));
+ }
+
+ private int countDomainRelRows(long childId) {
+ try (SqlSession sqlSession =
+
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+ Connection connection = sqlSession.getConnection();
+ Statement statement = connection.createStatement();
+ ResultSet rs =
+ statement.executeQuery(
+ String.format("SELECT count(*) FROM domain_rel WHERE child_id
= %d", childId))) {
+ if (rs.next()) {
+ return rs.getInt(1);
+ }
+ throw new RuntimeException("Doesn't contain data");
+ } catch (SQLException se) {
+ throw new RuntimeException("SQL execution failed", se);
+ }
+ }
+}
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index 7c943490fa..553862039f 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -783,3 +783,18 @@ CREATE TABLE IF NOT EXISTS `domain_meta` (
UNIQUE KEY `uk_mi_dn_del` (`metalake_id`, `domain_name`, `deleted_at`),
KEY `domain_meta_idx_backing_tag` (`metalake_id`, `backing_tag_id`,
`backing_tag_value`, `deleted_at`)
) ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS `domain_rel` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `parent_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'parent domain id',
+ `child_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'sub-domain id',
+ `audit_info` CLOB NOT NULL COMMENT 'domain relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
last version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'domain
relation deleted at',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_dr_ci_del` (`child_id`, `deleted_at`),
+ KEY `domain_rel_idx_parent` (`parent_id`, `deleted_at`),
+ KEY `domain_rel_idx_metalake` (`metalake_id`, `deleted_at`)
+) ENGINE=InnoDB;
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 ec4f85d1a5..72c2df350b 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
@@ -229,3 +229,18 @@ CREATE TABLE IF NOT EXISTS `domain_meta` (
UNIQUE KEY `uk_mi_dn_del` (`metalake_id`, `domain_name`, `deleted_at`),
KEY `domain_meta_idx_backing_tag` (`metalake_id`, `backing_tag_id`,
`backing_tag_value`, `deleted_at`)
) ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS `domain_rel` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `parent_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'parent domain id',
+ `child_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'sub-domain id',
+ `audit_info` CLOB NOT NULL COMMENT 'domain relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
last version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'domain
relation deleted at',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_dr_ci_del` (`child_id`, `deleted_at`),
+ KEY `domain_rel_idx_parent` (`parent_id`, `deleted_at`),
+ KEY `domain_rel_idx_metalake` (`metalake_id`, `deleted_at`)
+) ENGINE=InnoDB;
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql
b/scripts/mysql/schema-2.0.0-mysql.sql
index 9fc8275771..8bd3766d31 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -770,3 +770,18 @@ CREATE TABLE IF NOT EXISTS `domain_meta` (
UNIQUE KEY `uk_mi_dn_del` (`metalake_id`, `domain_name`, `deleted_at`),
KEY `domain_meta_idx_backing_tag` (`metalake_id`, `backing_tag_id`,
`backing_tag_value`, `deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'domain
metadata';
+
+CREATE TABLE IF NOT EXISTS `domain_rel` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `parent_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'parent domain id',
+ `child_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'sub-domain id',
+ `audit_info` MEDIUMTEXT NOT NULL COMMENT 'domain relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
last version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'domain
relation deleted at',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_dr_ci_del` (`child_id`, `deleted_at`),
+ KEY `domain_rel_idx_parent` (`parent_id`, `deleted_at`),
+ KEY `domain_rel_idx_metalake` (`metalake_id`, `deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'domain
parent relation';
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 bdf4290e0e..8ff0f66857 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
@@ -688,3 +688,18 @@ CREATE TABLE IF NOT EXISTS `domain_meta` (
UNIQUE KEY `uk_mi_dn_del` (`metalake_id`, `domain_name`, `deleted_at`),
KEY `domain_meta_idx_backing_tag` (`metalake_id`, `backing_tag_id`,
`backing_tag_value`, `deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'domain
metadata';
+
+CREATE TABLE IF NOT EXISTS `domain_rel` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
+ `parent_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'parent domain id',
+ `child_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'sub-domain id',
+ `audit_info` MEDIUMTEXT NOT NULL COMMENT 'domain relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'domain relation
last version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'domain
relation deleted at',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_dr_ci_del` (`child_id`, `deleted_at`),
+ KEY `domain_rel_idx_parent` (`parent_id`, `deleted_at`),
+ KEY `domain_rel_idx_metalake` (`metalake_id`, `deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'domain
parent relation';
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index 31797b3540..ed53d5e69e 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -1345,3 +1345,27 @@ COMMENT ON COLUMN domain_meta.audit_info IS 'domain
audit info';
COMMENT ON COLUMN domain_meta.current_version IS 'domain current version';
COMMENT ON COLUMN domain_meta.last_version IS 'domain last version';
COMMENT ON COLUMN domain_meta.deleted_at IS 'domain deleted at';
+
+CREATE TABLE IF NOT EXISTS domain_rel (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY,
+ metalake_id BIGINT NOT NULL,
+ parent_id BIGINT NOT NULL,
+ child_id BIGINT NOT NULL,
+ audit_info TEXT NOT NULL,
+ current_version INT NOT NULL DEFAULT 1,
+ last_version INT NOT NULL DEFAULT 1,
+ deleted_at BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ CONSTRAINT uk_dr_ci_del UNIQUE (child_id, deleted_at)
+);
+CREATE INDEX IF NOT EXISTS domain_rel_idx_parent ON domain_rel (parent_id,
deleted_at);
+CREATE INDEX IF NOT EXISTS domain_rel_idx_metalake ON domain_rel (metalake_id,
deleted_at);
+COMMENT ON TABLE domain_rel IS 'domain parent relation';
+COMMENT ON COLUMN domain_rel.id IS 'auto increment id';
+COMMENT ON COLUMN domain_rel.metalake_id IS 'metalake id';
+COMMENT ON COLUMN domain_rel.parent_id IS 'parent domain id';
+COMMENT ON COLUMN domain_rel.child_id IS 'sub-domain id';
+COMMENT ON COLUMN domain_rel.audit_info IS 'domain relation audit info';
+COMMENT ON COLUMN domain_rel.current_version IS 'domain relation current
version';
+COMMENT ON COLUMN domain_rel.last_version IS 'domain relation last version';
+COMMENT ON COLUMN domain_rel.deleted_at IS 'domain relation deleted at';
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 9b9ba62b8d..ce63038efa 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
@@ -345,3 +345,27 @@ COMMENT ON COLUMN domain_meta.audit_info IS 'domain audit
info';
COMMENT ON COLUMN domain_meta.current_version IS 'domain current version';
COMMENT ON COLUMN domain_meta.last_version IS 'domain last version';
COMMENT ON COLUMN domain_meta.deleted_at IS 'domain deleted at';
+
+CREATE TABLE IF NOT EXISTS domain_rel (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY,
+ metalake_id BIGINT NOT NULL,
+ parent_id BIGINT NOT NULL,
+ child_id BIGINT NOT NULL,
+ audit_info TEXT NOT NULL,
+ current_version INT NOT NULL DEFAULT 1,
+ last_version INT NOT NULL DEFAULT 1,
+ deleted_at BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ CONSTRAINT uk_dr_ci_del UNIQUE (child_id, deleted_at)
+);
+CREATE INDEX IF NOT EXISTS domain_rel_idx_parent ON domain_rel (parent_id,
deleted_at);
+CREATE INDEX IF NOT EXISTS domain_rel_idx_metalake ON domain_rel (metalake_id,
deleted_at);
+COMMENT ON TABLE domain_rel IS 'domain parent relation';
+COMMENT ON COLUMN domain_rel.id IS 'auto increment id';
+COMMENT ON COLUMN domain_rel.metalake_id IS 'metalake id';
+COMMENT ON COLUMN domain_rel.parent_id IS 'parent domain id';
+COMMENT ON COLUMN domain_rel.child_id IS 'sub-domain id';
+COMMENT ON COLUMN domain_rel.audit_info IS 'domain relation audit info';
+COMMENT ON COLUMN domain_rel.current_version IS 'domain relation current
version';
+COMMENT ON COLUMN domain_rel.last_version IS 'domain relation last version';
+COMMENT ON COLUMN domain_rel.deleted_at IS 'domain relation deleted at';