This is an automated email from the ASF dual-hosted git repository.

zhoujinsong pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git


The following commit(s) were added to refs/heads/master by this push:
     new da51a2d58 [AMORO-4240][AMS] Support namespace properties in REST 
catalog (#4283)
da51a2d58 is described below

commit da51a2d582deedab65b36c9c831e3a3f566caab0
Author: Power John <[email protected]>
AuthorDate: Thu Jul 23 14:15:19 2026 +0800

    [AMORO-4240][AMS] Support namespace properties in REST catalog (#4283)
---
 .../apache/amoro/server/RestCatalogService.java    |  53 ++++++-
 .../amoro/server/catalog/DatabaseMetadata.java     |  52 +++++++
 .../amoro/server/catalog/InternalCatalog.java      |  58 ++++++-
 .../amoro/server/catalog/InternalCatalogImpl.java  |   7 +-
 .../server/persistence/mapper/TableMetaMapper.java |  48 +++++-
 .../table/internal/InternalIcebergCreator.java     |  27 +++-
 .../internal/InternalMixedIcebergCreator.java      |   9 ++
 .../src/main/resources/derby/ams-derby-init.sql    |   3 +-
 amoro-ams/src/main/resources/derby/upgrade.sql     |  16 ++
 .../src/main/resources/mysql/ams-mysql-init.sql    |   1 +
 amoro-ams/src/main/resources/mysql/upgrade.sql     |   3 +
 .../main/resources/postgres/ams-postgres-init.sql  |   4 +-
 amoro-ams/src/main/resources/postgres/upgrade.sql  |   6 +-
 .../server/TestInternalIcebergCatalogService.java  | 126 ++++++++++++++++
 .../amoro/server/TestRestCatalogService.java       |  43 ++++++
 .../amoro/server/persistence/TestDerbyUpgrade.java |  80 ++++++++++
 .../persistence/mapper/TestTableMetaMapper.java    | 166 +++++++++++++++++++++
 17 files changed, 682 insertions(+), 20 deletions(-)

diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/RestCatalogService.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/RestCatalogService.java
index aa342beb9..d197021a8 100644
--- a/amoro-ams/src/main/java/org/apache/amoro/server/RestCatalogService.java
+++ b/amoro-ams/src/main/java/org/apache/amoro/server/RestCatalogService.java
@@ -60,15 +60,18 @@ import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.UnprocessableEntityException;
 import org.apache.iceberg.rest.RESTResponse;
 import org.apache.iceberg.rest.RESTSerializers;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.ReportMetricsRequest;
 import org.apache.iceberg.rest.requests.ReportMetricsRequestParser;
+import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.ConfigResponse;
 import org.apache.iceberg.rest.responses.CreateNamespaceResponse;
@@ -77,6 +80,7 @@ import org.apache.iceberg.rest.responses.GetNamespaceResponse;
 import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -131,7 +135,9 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
             post("/v1/catalogs/{catalog}/namespaces", this::createNamespace);
             get("/v1/catalogs/{catalog}/namespaces/{namespace}", 
this::getNamespace);
             delete("/v1/catalogs/{catalog}/namespaces/{namespace}", 
this::dropNamespace);
-            post("/v1/catalogs/{catalog}/namespaces/{namespace}", 
this::setNamespaceProperties);
+            post(
+                "/v1/catalogs/{catalog}/namespaces/{namespace}/properties",
+                this::setNamespaceProperties);
             get(
                 "/v1/catalogs/{catalog}/namespaces/{namespace}/tables",
                 this::listTablesInNamespace);
@@ -229,12 +235,16 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
         ctx,
         catalog -> {
           CreateNamespaceRequest request = bodyAsClass(ctx, 
CreateNamespaceRequest.class);
+          request.validate();
           Namespace ns = request.namespace();
           checkUnsupported(ns.length() == 1, "multi-level namespace is not 
supported now");
           String database = ns.level(0);
           checkAlreadyExists(!catalog.databaseExists(database), "Database", 
database);
-          catalog.createDatabase(database);
-          return 
CreateNamespaceResponse.builder().withNamespace(Namespace.of(database)).build();
+          catalog.createDatabase(database, request.properties());
+          return CreateNamespaceResponse.builder()
+              .withNamespace(Namespace.of(database))
+              .setProperties(catalog.getDatabaseProperties(database))
+              .build();
         });
   }
 
@@ -243,7 +253,10 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
     handleNamespace(
         ctx,
         (catalog, database) ->
-            
GetNamespaceResponse.builder().withNamespace(Namespace.of(database)).build());
+            GetNamespaceResponse.builder()
+                .withNamespace(Namespace.of(database))
+                .setProperties(catalog.getDatabaseProperties(database))
+                .build());
   }
 
   /** DELETE PREFIX/v1/catalogs/{catalog}/namespaces/{namespace} */
@@ -258,7 +271,30 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
 
   /** POST PREFIX/v1/catalogs/{catalog}/namespaces/{namespace}/properties */
   public void setNamespaceProperties(Context ctx) {
-    throw new UnsupportedOperationException("namespace properties is not 
supported");
+    handleNamespace(
+        ctx,
+        (catalog, database) -> {
+          UpdateNamespacePropertiesRequest request =
+              bodyAsClass(ctx, UpdateNamespacePropertiesRequest.class);
+          request.validate();
+
+          Map<String, String> properties =
+              catalog.applyDatabasePropertiesUpdate(
+                  database, request.updates(), request.removals());
+          List<String> removed =
+              request.removals().stream()
+                  .filter(properties::containsKey)
+                  .collect(Collectors.toList());
+          List<String> missing =
+              request.removals().stream()
+                  .filter(key -> !properties.containsKey(key))
+                  .collect(Collectors.toList());
+          return UpdateNamespacePropertiesResponse.builder()
+              .addUpdated(request.updates().keySet())
+              .addRemoved(removed)
+              .addMissing(missing)
+              .build();
+        });
   }
 
   /** GET PREFIX/v1/catalogs/{catalog}/namespaces/{namespace}/tables */
@@ -488,6 +524,7 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
     AuthenticationTimeout(419),
     NotFound(404),
     Conflict(409),
+    UnprocessableEntity(422),
     InternalServerError(500),
     ServiceUnavailable(503);
     public final int code;
@@ -497,7 +534,11 @@ public class RestCatalogService extends PersistentBase 
implements RestExtension
     }
 
     public static IcebergRestErrorCode exceptionToCode(Exception e) {
-      if (e instanceof UnsupportedOperationException) {
+      if (e instanceof BadRequestException || e instanceof 
IllegalArgumentException) {
+        return BadRequest;
+      } else if (e instanceof UnprocessableEntityException) {
+        return UnprocessableEntity;
+      } else if (e instanceof UnsupportedOperationException) {
         return UnsupportedOperation;
       } else if (e instanceof ObjectNotExistsException) {
         return NotFound;
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DatabaseMetadata.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DatabaseMetadata.java
new file mode 100644
index 000000000..a35410f18
--- /dev/null
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DatabaseMetadata.java
@@ -0,0 +1,52 @@
+/*
+ * 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.amoro.server.catalog;
+
+import java.util.Map;
+
+public class DatabaseMetadata {
+
+  private String catalogName;
+  private String databaseName;
+  private Map<String, String> properties;
+
+  public String getCatalogName() {
+    return catalogName;
+  }
+
+  public void setCatalogName(String catalogName) {
+    this.catalogName = catalogName;
+  }
+
+  public String getDatabaseName() {
+    return databaseName;
+  }
+
+  public void setDatabaseName(String databaseName) {
+    this.databaseName = databaseName;
+  }
+
+  public Map<String, String> getProperties() {
+    return properties;
+  }
+
+  public void setProperties(Map<String, String> properties) {
+    this.properties = properties;
+  }
+}
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalog.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalog.java
index d6a6bd989..a85ef80b5 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalog.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalog.java
@@ -34,7 +34,10 @@ import 
org.apache.amoro.server.table.internal.InternalTableHandler;
 import org.apache.amoro.table.TableIdentifier;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
@@ -56,6 +59,12 @@ public abstract class InternalCatalog extends ServerCatalog {
   }
 
   public void createDatabase(String databaseName) {
+    createDatabase(databaseName, Collections.emptyMap());
+  }
+
+  public void createDatabase(String databaseName, Map<String, String> 
properties) {
+    Map<String, String> databaseProperties =
+        properties == null ? Collections.emptyMap() : new 
HashMap<>(properties);
     if (!databaseExists(databaseName)) {
       doAsTransaction(
           // make sure catalog existed in database
@@ -67,13 +76,60 @@ public abstract class InternalCatalog extends ServerCatalog 
{
           () ->
               doAs(
                   TableMetaMapper.class,
-                  mapper -> 
mapper.insertDatabase(getMetadata().getCatalogName(), databaseName)),
+                  mapper ->
+                      mapper.insertDatabase(
+                          getMetadata().getCatalogName(), databaseName, 
databaseProperties)),
           () -> createDatabaseInternal(databaseName));
     } else {
       throw new AlreadyExistsException("Database " + databaseName);
     }
   }
 
+  public Map<String, String> getDatabaseProperties(String databaseName) {
+    DatabaseMetadata databaseMetadata =
+        getAs(TableMetaMapper.class, mapper -> 
mapper.selectDatabaseMetadata(name(), databaseName));
+    if (databaseMetadata == null) {
+      throw new ObjectNotExistsException(getDatabaseDesc(databaseName));
+    }
+
+    Map<String, String> properties = databaseMetadata.getProperties();
+    return properties == null ? new HashMap<>() : new HashMap<>(properties);
+  }
+
+  public Map<String, String> applyDatabasePropertiesUpdate(
+      String databaseName, Map<String, String> updates, List<String> removals) 
{
+    Map<String, String> requestedUpdates =
+        updates == null ? Collections.emptyMap() : new HashMap<>(updates);
+    List<String> requestedRemovals =
+        removals == null ? Collections.emptyList() : List.copyOf(removals);
+    Map<String, String> previousProperties = new HashMap<>();
+
+    doAsTransaction(
+        () -> {
+          DatabaseMetadata metadata =
+              getAs(
+                  TableMetaMapper.class,
+                  mapper -> mapper.selectDatabaseMetadataForUpdate(name(), 
databaseName));
+          if (metadata == null) {
+            throw new ObjectNotExistsException(getDatabaseDesc(databaseName));
+          }
+
+          Map<String, String> properties =
+              metadata.getProperties() == null
+                  ? new HashMap<>()
+                  : new HashMap<>(metadata.getProperties());
+          previousProperties.putAll(properties);
+          requestedRemovals.forEach(properties::remove);
+          properties.putAll(requestedUpdates);
+          doAsExisted(
+              TableMetaMapper.class,
+              mapper -> mapper.updateDatabaseProperties(name(), databaseName, 
properties),
+              () -> new 
ObjectNotExistsException(getDatabaseDesc(databaseName)));
+        });
+
+    return previousProperties;
+  }
+
   public void dropDatabase(String databaseName) {
     if (databaseExists(databaseName)) {
       doAsTransaction(
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalogImpl.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalogImpl.java
index 70e52a60b..5310fb432 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalogImpl.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/InternalCatalogImpl.java
@@ -193,10 +193,13 @@ public class InternalCatalogImpl extends InternalCatalog {
       throw new AlreadyExistsException(
           "Table " + name() + "." + database + "." + tableName + " already 
exists.");
     }
+    String namespaceLocation = getDatabaseProperties(database).get("location");
     if (TableFormat.ICEBERG.equals(format)) {
-      return new InternalIcebergCreator(getMetadata(), database, tableName, 
creatorArguments);
+      return new InternalIcebergCreator(
+          getMetadata(), database, tableName, creatorArguments, 
namespaceLocation);
     } else if (TableFormat.MIXED_ICEBERG.equals(format)) {
-      return new InternalMixedIcebergCreator(getMetadata(), database, 
tableName, creatorArguments);
+      return new InternalMixedIcebergCreator(
+          getMetadata(), database, tableName, creatorArguments, 
namespaceLocation);
     } else {
       throw new IllegalArgumentException("Unsupported table format:" + format);
     }
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableMetaMapper.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableMetaMapper.java
index 0dae33222..2ad5a1ad3 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableMetaMapper.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/TableMetaMapper.java
@@ -19,6 +19,7 @@
 package org.apache.amoro.server.persistence.mapper;
 
 import org.apache.amoro.ServerTableIdentifier;
+import org.apache.amoro.server.catalog.DatabaseMetadata;
 import org.apache.amoro.server.persistence.converter.Map2StringConverter;
 import org.apache.amoro.server.persistence.converter.TableFormatConverter;
 import 
org.apache.amoro.server.persistence.extension.InListExtendedLanguageDriver;
@@ -29,16 +30,24 @@ import org.apache.ibatis.annotations.Lang;
 import org.apache.ibatis.annotations.Options;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Result;
+import org.apache.ibatis.annotations.ResultMap;
 import org.apache.ibatis.annotations.Results;
 import org.apache.ibatis.annotations.Select;
 import org.apache.ibatis.annotations.Update;
 
 import java.util.List;
+import java.util.Map;
 
 public interface TableMetaMapper {
 
-  @Insert("INSERT INTO database_metadata(catalog_name, db_name) VALUES( 
#{catalogName}, #{dbName})")
-  void insertDatabase(@Param("catalogName") String catalogName, 
@Param("dbName") String dbName);
+  @Insert(
+      "INSERT INTO database_metadata(catalog_name, db_name, properties)"
+          + " VALUES(#{catalogName}, #{dbName},"
+          + " #{properties, 
typeHandler=org.apache.amoro.server.persistence.converter.Map2StringConverter})")
+  void insertDatabase(
+      @Param("catalogName") String catalogName,
+      @Param("dbName") String dbName,
+      @Param("properties") Map<String, String> properties);
 
   @Select("SELECT db_name FROM database_metadata WHERE catalog_name = 
#{catalogName}")
   List<String> selectDatabases(@Param("catalogName") String catalogName);
@@ -47,6 +56,41 @@ public interface TableMetaMapper {
       "SELECT db_name FROM database_metadata WHERE catalog_name = 
#{catalogName} AND db_name=#{dbName}")
   String selectDatabase(@Param("catalogName") String catalogName, 
@Param("dbName") String dbName);
 
+  @Select(
+      "SELECT catalog_name, db_name, properties FROM database_metadata"
+          + " WHERE catalog_name = #{catalogName} AND db_name = #{dbName}")
+  @Results(
+      id = "databaseMetadata",
+      value = {
+        @Result(property = "catalogName", column = "catalog_name"),
+        @Result(property = "databaseName", column = "db_name"),
+        @Result(
+            property = "properties",
+            column = "properties",
+            typeHandler = Map2StringConverter.class)
+      })
+  DatabaseMetadata selectDatabaseMetadata(
+      @Param("catalogName") String catalogName, @Param("dbName") String 
dbName);
+
+  @Select(
+      "<script>"
+          + "SELECT catalog_name, db_name, properties FROM database_metadata"
+          + " WHERE catalog_name = #{catalogName} AND db_name = #{dbName} FOR 
UPDATE"
+          + "<if test=\"_databaseId == 'derby'\"> WITH RS</if>"
+          + "</script>")
+  @ResultMap("databaseMetadata")
+  DatabaseMetadata selectDatabaseMetadataForUpdate(
+      @Param("catalogName") String catalogName, @Param("dbName") String 
dbName);
+
+  @Update(
+      "UPDATE database_metadata SET properties ="
+          + " #{properties, 
typeHandler=org.apache.amoro.server.persistence.converter.Map2StringConverter}"
+          + " WHERE catalog_name = #{catalogName} AND db_name = #{dbName}")
+  Integer updateDatabaseProperties(
+      @Param("catalogName") String catalogName,
+      @Param("dbName") String dbName,
+      @Param("properties") Map<String, String> properties);
+
   @Delete(
       "DELETE FROM database_metadata WHERE catalog_name = #{catalogName} AND 
db_name = #{dbName}"
           + " AND table_count = 0")
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalIcebergCreator.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalIcebergCreator.java
index 90255a30f..31f3c2389 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalIcebergCreator.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalIcebergCreator.java
@@ -42,6 +42,7 @@ public class InternalIcebergCreator implements 
InternalTableCreator {
   protected final AuthenticatedFileIO io;
   protected final CreateTableRequest request;
   private final CatalogMeta catalogMeta;
+  private final String namespaceLocation;
   protected final String database;
   protected final String tableName;
   private boolean closed = false;
@@ -51,8 +52,18 @@ public class InternalIcebergCreator implements 
InternalTableCreator {
 
   public InternalIcebergCreator(
       CatalogMeta catalog, String database, String tableName, 
CreateTableRequest request) {
+    this(catalog, database, tableName, request, null);
+  }
+
+  public InternalIcebergCreator(
+      CatalogMeta catalog,
+      String database,
+      String tableName,
+      CreateTableRequest request,
+      String namespaceLocation) {
     this.io = InternalTableUtil.newIcebergFileIo(catalog);
     this.catalogMeta = catalog;
+    this.namespaceLocation = namespaceLocation;
     this.database = database;
     this.tableName = tableName;
     this.request = request;
@@ -127,12 +138,16 @@ public class InternalIcebergCreator implements 
InternalTableCreator {
   private String tableLocation() {
     String location = this.request.location();
     if (StringUtils.isBlank(location)) {
-      String warehouse =
-          
catalogMeta.getCatalogProperties().get(CatalogMetaProperties.KEY_WAREHOUSE);
-      Preconditions.checkState(
-          StringUtils.isNotBlank(warehouse), "catalog warehouse is not 
configured");
-      warehouse = LocationUtil.stripTrailingSlash(warehouse);
-      location = warehouse + "/" + database + "/" + tableName;
+      if (StringUtils.isNotBlank(namespaceLocation)) {
+        location = LocationUtil.stripTrailingSlash(namespaceLocation) + "/" + 
tableName;
+      } else {
+        String warehouse =
+            
catalogMeta.getCatalogProperties().get(CatalogMetaProperties.KEY_WAREHOUSE);
+        Preconditions.checkState(
+            StringUtils.isNotBlank(warehouse), "catalog warehouse is not 
configured");
+        warehouse = LocationUtil.stripTrailingSlash(warehouse);
+        location = warehouse + "/" + database + "/" + tableName;
+      }
     } else {
       location = LocationUtil.stripTrailingSlash(location);
     }
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalMixedIcebergCreator.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalMixedIcebergCreator.java
index 4953f27ca..34a58ad16 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalMixedIcebergCreator.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/internal/InternalMixedIcebergCreator.java
@@ -44,6 +44,15 @@ public class InternalMixedIcebergCreator extends 
InternalIcebergCreator {
     super(catalog, database, tableName, request);
   }
 
+  public InternalMixedIcebergCreator(
+      CatalogMeta catalog,
+      String database,
+      String tableName,
+      CreateTableRequest request,
+      String namespaceLocation) {
+    super(catalog, database, tableName, request, namespaceLocation);
+  }
+
   @Override
   protected TableFormat format() {
     return TableFormat.MIXED_ICEBERG;
diff --git a/amoro-ams/src/main/resources/derby/ams-derby-init.sql 
b/amoro-ams/src/main/resources/derby/ams-derby-init.sql
index 32d663288..72c43f4d8 100644
--- a/amoro-ams/src/main/resources/derby/ams-derby-init.sql
+++ b/amoro-ams/src/main/resources/derby/ams-derby-init.sql
@@ -29,6 +29,7 @@ CREATE TABLE catalog_metadata (
 CREATE TABLE database_metadata (
     catalog_name           VARCHAR(64) NOT NULL,
     db_name                VARCHAR(128) NOT NULL,
+    properties             CLOB(64m),
     table_count            INT NOT NULL DEFAULT 0,
     PRIMARY KEY (catalog_name, db_name)
 );
@@ -274,4 +275,4 @@ CREATE TABLE bucket_assignments (
   last_update_time   BIGINT        NOT NULL DEFAULT 0,
   node_heartbeat_ts  BIGINT        NOT NULL DEFAULT 0,
   PRIMARY KEY (cluster_name, node_key)
-);
\ No newline at end of file
+);
diff --git a/amoro-ams/src/main/resources/derby/upgrade.sql 
b/amoro-ams/src/main/resources/derby/upgrade.sql
new file mode 100644
index 000000000..0282c32ec
--- /dev/null
+++ b/amoro-ams/src/main/resources/derby/upgrade.sql
@@ -0,0 +1,16 @@
+-- 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.
+
+ALTER TABLE database_metadata ADD COLUMN properties CLOB(64m);
diff --git a/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql 
b/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql
index f202695c8..b7c423776 100644
--- a/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql
+++ b/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql
@@ -31,6 +31,7 @@ CREATE TABLE `database_metadata`
 (
     `catalog_name`           varchar(64) NOT NULL COMMENT 'catalog name',
     `db_name`                varchar(128) NOT NULL COMMENT 'database name',
+    `properties`             mediumtext COMMENT 'Database properties',
     `table_count`            int(11) NOT NULL default 0,
     PRIMARY KEY (`catalog_name`, `db_name`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT 'database metadata';
diff --git a/amoro-ams/src/main/resources/mysql/upgrade.sql 
b/amoro-ams/src/main/resources/mysql/upgrade.sql
index c67807751..e8752b1ca 100644
--- a/amoro-ams/src/main/resources/mysql/upgrade.sql
+++ b/amoro-ams/src/main/resources/mysql/upgrade.sql
@@ -175,3 +175,6 @@ CREATE TABLE IF NOT EXISTS bucket_assignments (
 -- ADD node_heartbeat_ts to table bucket_assignments
 ALTER TABLE `bucket_assignments` ADD COLUMN `node_heartbeat_ts` BIGINT NOT 
NULL DEFAULT 0 COMMENT 'Per-node heartbeat timestamp updated only by the owning 
node (ms since epoch)';
 
+-- ADD properties to table database_metadata
+ALTER TABLE `database_metadata` ADD COLUMN `properties` MEDIUMTEXT COMMENT 
'Database properties';
+
diff --git a/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql 
b/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql
index 245e37744..5f1494bc0 100644
--- a/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql
+++ b/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql
@@ -38,12 +38,14 @@ CREATE TABLE database_metadata
 (
     catalog_name varchar(64) NOT NULL,
     db_name varchar(128) NOT NULL,
+    properties text,
     table_count integer NOT NULL DEFAULT 0,
     PRIMARY KEY (catalog_name, db_name)
 );
 COMMENT ON TABLE database_metadata IS 'Database metadata';
 COMMENT ON COLUMN database_metadata.catalog_name IS 'Catalog name';
 COMMENT ON COLUMN database_metadata.db_name IS 'Database name';
+COMMENT ON COLUMN database_metadata.properties IS 'Database properties';
 COMMENT ON COLUMN database_metadata.table_count IS 'Number of tables in the 
database';
 
 CREATE TABLE optimizer
@@ -474,4 +476,4 @@ COMMENT ON COLUMN node_ip IS 'Node IP address';
 COMMENT ON COLUMN server_info_json IS 'JSON encoded server info 
(AmsServerInfo)';
 COMMENT ON COLUMN lease_expire_ts IS 'Lease expiration timestamp (ms since 
epoch)';
 COMMENT ON COLUMN version IS 'Optimistic lock version of the lease row';
-COMMENT ON COLUMN updated_at IS 'Last update timestamp (ms since epoch)';
\ No newline at end of file
+COMMENT ON COLUMN updated_at IS 'Last update timestamp (ms since epoch)';
diff --git a/amoro-ams/src/main/resources/postgres/upgrade.sql 
b/amoro-ams/src/main/resources/postgres/upgrade.sql
index 8f26023f0..8c290769e 100644
--- a/amoro-ams/src/main/resources/postgres/upgrade.sql
+++ b/amoro-ams/src/main/resources/postgres/upgrade.sql
@@ -235,4 +235,8 @@ CREATE TABLE IF NOT EXISTS bucket_assignments (
 CREATE INDEX IF NOT EXISTS idx_bucket_assignments_cluster ON 
bucket_assignments (cluster_name);
 
 -- ADD node_heartbeat_ts to table bucket_assignments
-ALTER TABLE bucket_assignments ADD COLUMN node_heartbeat_ts BIGINT NOT NULL 
DEFAULT 0;
\ No newline at end of file
+ALTER TABLE bucket_assignments ADD COLUMN node_heartbeat_ts BIGINT NOT NULL 
DEFAULT 0;
+
+-- ADD properties to table database_metadata
+ALTER TABLE database_metadata ADD COLUMN properties text;
+COMMENT ON COLUMN database_metadata.properties IS 'Database properties';
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestInternalIcebergCatalogService.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestInternalIcebergCatalogService.java
index 19d4ace8a..5fa41d72b 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestInternalIcebergCatalogService.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestInternalIcebergCatalogService.java
@@ -24,10 +24,15 @@ import org.apache.amoro.io.IcebergDataTestHelpers;
 import org.apache.amoro.io.MixedDataTestHelpers;
 import org.apache.amoro.io.reader.GenericUnkeyedDataReader;
 import org.apache.amoro.properties.CatalogMetaProperties;
+import org.apache.amoro.server.persistence.SqlSessionFactoryProvider;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Lists;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
+import org.apache.amoro.shade.guava32.com.google.common.collect.Sets;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Streams;
+import org.apache.amoro.shade.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import 
org.apache.amoro.shade.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.amoro.table.MixedTable;
+import org.apache.ibatis.session.SqlSession;
 import org.apache.iceberg.AppendFiles;
 import org.apache.iceberg.DataFile;
 import org.apache.iceberg.FileScanTask;
@@ -50,9 +55,16 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 public class TestInternalIcebergCatalogService extends 
RestCatalogServiceTestBase {
@@ -102,15 +114,112 @@ public class TestInternalIcebergCatalogService extends 
RestCatalogServiceTestBas
 
   @Nested
   public class NamespaceTests {
+    @AfterEach
+    public void clean() {
+      if (serverCatalog.databaseExists(database)) {
+        serverCatalog.dropDatabase(database);
+      }
+    }
+
     @Test
     public void testNamespaceOperations() throws IOException {
       Assertions.assertTrue(nsCatalog.listNamespaces().isEmpty());
       nsCatalog.createNamespace(Namespace.of(database));
       Assertions.assertEquals(1, nsCatalog.listNamespaces().size());
       Assertions.assertEquals(0, 
nsCatalog.listNamespaces(Namespace.of(database)).size());
+      Assertions.assertTrue(nsCatalog.loadNamespaceMetadata(ns).isEmpty());
       nsCatalog.dropNamespace(Namespace.of(database));
       Assertions.assertTrue(nsCatalog.listNamespaces().isEmpty());
     }
+
+    @Test
+    public void testNamespaceProperties() {
+      Map<String, String> properties = Maps.newHashMap();
+      properties.put("location", "s3://warehouse/test_ns");
+      properties.put("owner", "analytics");
+
+      nsCatalog.createNamespace(ns, properties);
+
+      Assertions.assertEquals(properties, nsCatalog.loadNamespaceMetadata(ns));
+      Assertions.assertEquals(properties, 
serverCatalog.getDatabaseProperties(database));
+    }
+
+    @Test
+    public void testUpdateNamespaceProperties() {
+      Map<String, String> properties = Maps.newHashMap();
+      properties.put("location", "s3://warehouse/test_ns");
+      properties.put("owner", "analytics");
+      nsCatalog.createNamespace(ns, properties);
+
+      Map<String, String> updates = Maps.newHashMap();
+      updates.put("owner", "platform");
+      updates.put("retention-days", "30");
+      nsCatalog.setProperties(ns, updates);
+      nsCatalog.removeProperties(ns, Sets.newHashSet("owner", "missing"));
+
+      Map<String, String> expected = Maps.newHashMap();
+      expected.put("location", "s3://warehouse/test_ns");
+      expected.put("retention-days", "30");
+      Assertions.assertEquals(expected, nsCatalog.loadNamespaceMetadata(ns));
+    }
+
+    @Test
+    public void testUpdateNamespacePropertiesResponse() throws IOException, 
InterruptedException {
+      Map<String, String> properties = Maps.newHashMap();
+      properties.put("location", "s3://warehouse/test_ns");
+      properties.put("owner", "analytics");
+      nsCatalog.createNamespace(ns, properties);
+
+      String requestBody =
+          "{\"updates\":{\"retention-days\":\"30\"}," + 
"\"removals\":[\"owner\",\"missing\"]}";
+      HttpRequest request =
+          HttpRequest.newBuilder(
+                  URI.create(
+                      ams.getHttpUrl()
+                          + restCatalogUri
+                          + "/v1/catalogs/"
+                          + catalogName()
+                          + "/namespaces/"
+                          + database
+                          + "/properties"))
+              .header("Content-Type", "application/json")
+              .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+              .build();
+      HttpResponse<String> response =
+          HttpClient.newHttpClient().send(request, 
HttpResponse.BodyHandlers.ofString());
+
+      Assertions.assertEquals(200, response.statusCode());
+      JsonNode responseBody = new ObjectMapper().readTree(response.body());
+      Assertions.assertEquals(
+          Sets.newHashSet("retention-days"), 
stringSet(responseBody.get("updated")));
+      Assertions.assertEquals(Sets.newHashSet("owner"), 
stringSet(responseBody.get("removed")));
+      Assertions.assertEquals(Sets.newHashSet("missing"), 
stringSet(responseBody.get("missing")));
+    }
+
+    @Test
+    public void testNullNamespaceProperties() throws SQLException {
+      nsCatalog.createNamespace(ns);
+      try (SqlSession session = 
SqlSessionFactoryProvider.getInstance().get().openSession(true);
+          PreparedStatement statement =
+              session
+                  .getConnection()
+                  .prepareStatement(
+                      "UPDATE database_metadata SET properties = NULL"
+                          + " WHERE catalog_name = ? AND db_name = ?")) {
+        statement.setString(1, catalogName());
+        statement.setString(2, database);
+        Assertions.assertEquals(1, statement.executeUpdate());
+      }
+
+      
Assertions.assertTrue(serverCatalog.getDatabaseProperties(database).isEmpty());
+      Assertions.assertTrue(nsCatalog.loadNamespaceMetadata(ns).isEmpty());
+    }
+
+    private Set<String> stringSet(JsonNode values) {
+      Set<String> result = Sets.newHashSet();
+      values.forEach(value -> result.add(value.asText()));
+      return result;
+    }
   }
 
   @Nested
@@ -158,6 +267,23 @@ public class TestInternalIcebergCatalogService extends 
RestCatalogServiceTestBas
       Assertions.assertFalse(nsCatalog.tableExists(identifier));
     }
 
+    @Test
+    public void testCreateTableInNamespaceLocation() {
+      String namespaceLocation =
+          serverCatalog
+                  .getMetadata()
+                  .getCatalogProperties()
+                  .get(CatalogMetaProperties.KEY_WAREHOUSE)
+              + "/namespace-location";
+      Map<String, String> properties = Maps.newHashMap();
+      properties.put("location", namespaceLocation);
+      nsCatalog.setProperties(ns, properties);
+
+      Table created = nsCatalog.createTable(identifier, schema);
+
+      Assertions.assertEquals(namespaceLocation + "/" + table, 
created.location());
+    }
+
     @Test
     public void testTableWriteAndCommit() throws IOException {
       Table tbl = nsCatalog.createTable(identifier, schema);
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestRestCatalogService.java 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestRestCatalogService.java
new file mode 100644
index 000000000..26c39dc41
--- /dev/null
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestRestCatalogService.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.amoro.server;
+
+import org.apache.iceberg.exceptions.BadRequestException;
+import org.apache.iceberg.exceptions.UnprocessableEntityException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestRestCatalogService {
+
+  @Test
+  public void testRequestErrorCodes() {
+    Assertions.assertEquals(
+        RestCatalogService.IcebergRestErrorCode.BadRequest,
+        RestCatalogService.IcebergRestErrorCode.exceptionToCode(
+            new BadRequestException("invalid request")));
+    Assertions.assertEquals(
+        RestCatalogService.IcebergRestErrorCode.BadRequest,
+        RestCatalogService.IcebergRestErrorCode.exceptionToCode(
+            new IllegalArgumentException("invalid argument")));
+    Assertions.assertEquals(
+        RestCatalogService.IcebergRestErrorCode.UnprocessableEntity,
+        RestCatalogService.IcebergRestErrorCode.exceptionToCode(
+            new UnprocessableEntityException("conflicting property changes")));
+  }
+}
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/persistence/TestDerbyUpgrade.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/persistence/TestDerbyUpgrade.java
new file mode 100644
index 000000000..ea3d23044
--- /dev/null
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/persistence/TestDerbyUpgrade.java
@@ -0,0 +1,80 @@
+/*
+ * 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.amoro.server.persistence;
+
+import org.apache.ibatis.jdbc.ScriptRunner;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.UUID;
+
+public class TestDerbyUpgrade {
+
+  @Test
+  public void testAddDatabasePropertiesColumn() throws Exception {
+    String databaseName = "upgrade_" + 
UUID.randomUUID().toString().replace("-", "");
+    String connectionUrl = "jdbc:derby:memory:" + databaseName + 
";create=true";
+
+    try (Connection connection = DriverManager.getConnection(connectionUrl);
+        Statement statement = connection.createStatement()) {
+      statement.execute(
+          "CREATE TABLE database_metadata ("
+              + "catalog_name VARCHAR(64) NOT NULL, "
+              + "db_name VARCHAR(128) NOT NULL, "
+              + "table_count INT NOT NULL DEFAULT 0, "
+              + "PRIMARY KEY (catalog_name, db_name))");
+      statement.execute(
+          "INSERT INTO database_metadata(catalog_name, db_name) VALUES 
('catalog', 'database')");
+
+      try (InputStream script =
+          
TestDerbyUpgrade.class.getClassLoader().getResourceAsStream("derby/upgrade.sql"))
 {
+        Assertions.assertNotNull(script);
+        new ScriptRunner(connection)
+            .runScript(new InputStreamReader(script, StandardCharsets.UTF_8));
+      }
+
+      statement.execute(
+          "UPDATE database_metadata SET properties = 
'{\"location\":\"file:/warehouse\"}'"
+              + " WHERE catalog_name = 'catalog' AND db_name = 'database'");
+      try (ResultSet resultSet =
+          statement.executeQuery(
+              "SELECT properties FROM database_metadata"
+                  + " WHERE catalog_name = 'catalog' AND db_name = 
'database'")) {
+        Assertions.assertTrue(resultSet.next());
+        Assertions.assertEquals("{\"location\":\"file:/warehouse\"}", 
resultSet.getString(1));
+      }
+      connection.commit();
+    } finally {
+      try {
+        DriverManager.getConnection("jdbc:derby:memory:" + databaseName + 
";drop=true");
+        Assertions.fail("Derby did not drop the database");
+      } catch (SQLException e) {
+        Assertions.assertEquals("08006", e.getSQLState());
+      }
+    }
+  }
+}
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/persistence/mapper/TestTableMetaMapper.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/persistence/mapper/TestTableMetaMapper.java
new file mode 100644
index 000000000..3333654ee
--- /dev/null
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/persistence/mapper/TestTableMetaMapper.java
@@ -0,0 +1,166 @@
+/*
+ * 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.amoro.server.persistence.mapper;
+
+import org.apache.amoro.config.Configurations;
+import org.apache.amoro.server.AmoroManagementConf;
+import org.apache.amoro.server.catalog.DatabaseMetadata;
+import org.apache.amoro.server.persistence.DataSourceFactory;
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.apache.ibatis.session.SqlSessionFactoryBuilder;
+import org.apache.ibatis.transaction.TransactionFactory;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+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;
+
+public class TestTableMetaMapper {
+
+  private static BasicDataSource dataSource;
+  private static SqlSessionFactory sqlSessionFactory;
+
+  @BeforeAll
+  public static void initialize() {
+    Configurations configurations = new Configurations();
+    configurations.set(
+        AmoroManagementConf.DB_CONNECTION_URL,
+        String.format("jdbc:derby:memory:%s;create=true", UUID.randomUUID()));
+    configurations.set(AmoroManagementConf.DB_TYPE, 
AmoroManagementConf.DB_TYPE_DERBY);
+    configurations.set(
+        AmoroManagementConf.DB_DRIVER_CLASS_NAME, 
"org.apache.derby.jdbc.EmbeddedDriver");
+    dataSource = (BasicDataSource) 
DataSourceFactory.createDataSource(configurations);
+
+    TransactionFactory transactionFactory = new JdbcTransactionFactory();
+    Environment environment = new Environment("test", transactionFactory, 
dataSource);
+    org.apache.ibatis.session.Configuration configuration =
+        new org.apache.ibatis.session.Configuration(environment);
+    configuration.setDatabaseId("derby");
+    configuration.addMapper(TableMetaMapper.class);
+    sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
+  }
+
+  @AfterAll
+  public static void clean() throws SQLException {
+    dataSource.close();
+  }
+
+  @Test
+  public void testDatabaseProperties() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("location", "s3://warehouse/test_db");
+    properties.put("owner", "analytics");
+
+    try (SqlSession session = sqlSessionFactory.openSession(true)) {
+      TableMetaMapper mapper = session.getMapper(TableMetaMapper.class);
+      mapper.insertDatabase("test_catalog", "test_db", properties);
+
+      DatabaseMetadata metadata = 
mapper.selectDatabaseMetadata("test_catalog", "test_db");
+      Assertions.assertEquals(properties, metadata.getProperties());
+
+      Map<String, String> updatedProperties = new HashMap<>();
+      updatedProperties.put("location", "s3://warehouse/test_db");
+      updatedProperties.put("retention-days", "30");
+      Assertions.assertEquals(
+          Integer.valueOf(1),
+          mapper.updateDatabaseProperties("test_catalog", "test_db", 
updatedProperties));
+      Assertions.assertEquals(
+          updatedProperties,
+          mapper.selectDatabaseMetadata("test_catalog", 
"test_db").getProperties());
+    }
+  }
+
+  @Test
+  public void testDatabasePropertiesUpdateIsSerialized() throws Exception {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("base", "value");
+    try (SqlSession session = sqlSessionFactory.openSession(true)) {
+      session
+          .getMapper(TableMetaMapper.class)
+          .insertDatabase("lock_catalog", "lock_db", properties);
+    }
+
+    CountDownLatch secondUpdateStarted = new CountDownLatch(1);
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try (SqlSession firstSession = sqlSessionFactory.openSession(false)) {
+      TableMetaMapper firstMapper = 
firstSession.getMapper(TableMetaMapper.class);
+      DatabaseMetadata firstMetadata =
+          firstMapper.selectDatabaseMetadataForUpdate("lock_catalog", 
"lock_db");
+
+      Future<Map<String, String>> secondUpdate =
+          executor.submit(
+              () -> {
+                try (SqlSession secondSession = 
sqlSessionFactory.openSession(false)) {
+                  TableMetaMapper secondMapper = 
secondSession.getMapper(TableMetaMapper.class);
+                  secondUpdateStarted.countDown();
+                  DatabaseMetadata secondMetadata =
+                      
secondMapper.selectDatabaseMetadataForUpdate("lock_catalog", "lock_db");
+                  Map<String, String> secondProperties =
+                      new HashMap<>(secondMetadata.getProperties());
+                  secondProperties.put("second", "2");
+                  secondMapper.updateDatabaseProperties(
+                      "lock_catalog", "lock_db", secondProperties);
+                  secondSession.commit();
+                  return secondProperties;
+                }
+              });
+
+      Assertions.assertTrue(secondUpdateStarted.await(5, TimeUnit.SECONDS));
+      Assertions.assertThrows(
+          TimeoutException.class, () -> secondUpdate.get(500, 
TimeUnit.MILLISECONDS));
+
+      Map<String, String> firstProperties = new 
HashMap<>(firstMetadata.getProperties());
+      firstProperties.put("first", "1");
+      firstMapper.updateDatabaseProperties("lock_catalog", "lock_db", 
firstProperties);
+      firstSession.commit();
+
+      Map<String, String> mergedProperties = secondUpdate.get(5, 
TimeUnit.SECONDS);
+      Assertions.assertEquals("1", mergedProperties.get("first"));
+      Assertions.assertEquals("2", mergedProperties.get("second"));
+    } finally {
+      executor.shutdownNow();
+      Assertions.assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+    }
+
+    try (SqlSession session = sqlSessionFactory.openSession(true)) {
+      Map<String, String> storedProperties =
+          session
+              .getMapper(TableMetaMapper.class)
+              .selectDatabaseMetadata("lock_catalog", "lock_db")
+              .getProperties();
+      Assertions.assertEquals("value", storedProperties.get("base"));
+      Assertions.assertEquals("1", storedProperties.get("first"));
+      Assertions.assertEquals("2", storedProperties.get("second"));
+    }
+  }
+}

Reply via email to