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

roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 93778817fa [Cherry-pick to branch-1.3] [#14438] fix(iceberg-rest): 
validate commits and fix pagination and purge (#14533)
93778817fa is described below

commit 93778817fad5e05a4da5064b49c0b72fe5d1a6cc
Author: roryqi <[email protected]>
AuthorDate: Mon Nov 2 21:29:52 2026 +0800

    [Cherry-pick to branch-1.3] [#14438] fix(iceberg-rest): validate commits 
and fix pagination and purge (#14533)
    
    ### What changes were proposed in this pull request?
    
    Backport #14530 (merged commit `c150be2456`) to `branch-1.3`, covering
    #14439, #14455, #14458, and #14466.
    
    - Validate namespace names, registration UUIDs, snapshots,
    schema/partition evolution, and default transforms before committing.
    Preserve staged-create validation and accept partition specs without
    `spec-id`.
    - Return all list results without a page token, serialize direct/staged
    table creation with namespace drop, and make explicit purge clean up
    reachable files while respecting `gc.enabled=false`.
    - Preserve import errors without automatically deleting tables or
    namespaces.
    
    Resolve conflicts while retaining branch-1.3's scan-filter deserializer,
    deletion-vector checks, audit/ownership updates, and namespace identity
    handling. Adapt the registration test to the OSS JDBC backend's existing
    rejection of overwrite requests.
    
    ### Why are the changes needed?
    
    Bring the consolidated validation, pagination, and purge fixes from
    #14530 to the 1.3 release branch.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, the same behavior changes as #14530: invalid metadata is rejected
    before commit, lists without a token are complete, and import failures
    do not trigger deletion. Explicit purge preserves content files when GC
    is disabled.
    
    ### How was this patch tested?
    
    - `./gradlew :iceberg:iceberg-common:spotlessApply
    :iceberg:iceberg-rest-server:spotlessApply`
    - `./gradlew :iceberg:iceberg-common:test
    :iceberg:iceberg-rest-server:test -PskipITs`
    - 68 common tests and 485 REST server tests passed, with no failures or
    skips.
    - `git diff --check`
    
    Co-authored-by: Mark Hoerth <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../iceberg/common/ops/IcebergCatalogWrapper.java  |  27 +-
 .../iceberg/common/ops/IcebergPurgeUtil.java       | 124 +++++
 .../common/ops/TestIcebergCatalogWrapper.java      |  54 ++
 .../iceberg/service/CatalogWrapperForREST.java     |  60 +-
 .../iceberg/service/IcebergCommitValidation.java   | 446 +++++++++++++++
 .../iceberg/service/IcebergObjectMapper.java       |  54 +-
 .../iceberg/service/IcebergRESTUtils.java          |  23 +-
 .../dispatcher/IcebergNamespaceHookDispatcher.java |  34 +-
 .../dispatcher/IcebergTableHookDispatcher.java     |  25 +-
 .../service/rest/IcebergPaginationHelper.java      |  30 +-
 .../iceberg/service/TestCatalogWrapperForREST.java |  72 +++
 .../service/TestIcebergCommitValidation.java       | 613 +++++++++++++++++++++
 .../iceberg/service/TestIcebergRESTUtils.java      |  17 +
 .../TestIcebergNamespaceHookDispatcher.java        |  45 ++
 .../dispatcher/TestIcebergTableHookDispatcher.java | 100 ++++
 .../rest/TestIcebergNamespaceOperations.java       |  17 +-
 .../service/rest/TestIcebergPaginationHelper.java  |  81 ++-
 .../service/rest/TestIcebergTableOperations.java   |  15 +-
 .../service/rest/TestIcebergViewOperations.java    |  15 +-
 19 files changed, 1813 insertions(+), 39 deletions(-)

diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index 231bc58424..d60bb881b2 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -44,11 +44,13 @@ import org.apache.hadoop.security.UserGroupInformation;
 import org.apache.iceberg.BaseMetadataTable;
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.Transaction;
 import org.apache.iceberg.catalog.Catalog;
@@ -286,7 +288,30 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
 
   public void purgeTable(TableIdentifier tableIdentifier) {
     getMetadataCache().invalidate(tableIdentifier);
-    CatalogHandlers.purgeTable(getCatalog(), tableIdentifier);
+    Table table = getCatalog().loadTable(tableIdentifier);
+    if (!(table instanceof HasTableOperations)) {
+      CatalogHandlers.purgeTable(getCatalog(), tableIdentifier);
+      return;
+    }
+    // Iceberg drops the catalog entry first and then deletes the files, 
giving up on the first
+    // file it cannot read (for example a missing manifest list). The request 
then fails although
+    // the table is gone, and no request is left that could delete the files. 
Read the metadata
+    // first, drop the entry, then delete every file that can still be reached.
+    TableOperations ops = ((HasTableOperations) table).operations();
+    TableMetadata metadata = ops.current();
+    FileIO io = ops.io();
+    if (!getCatalog().dropTable(tableIdentifier, false)) {
+      throw new NoSuchTableException("Table does not exist: %s", 
tableIdentifier);
+    }
+    try {
+      CatalogUtil.dropTableData(io, metadata);
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Purge of {} could not read all table files ({}); deleting the files 
that can be read",
+          tableIdentifier,
+          e.getMessage());
+      IcebergPurgeUtil.deleteReachableFiles(io, metadata);
+    }
   }
 
   /**
diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergPurgeUtil.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergPurgeUtil.java
new file mode 100644
index 0000000000..1b30307376
--- /dev/null
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergPurgeUtil.java
@@ -0,0 +1,124 @@
+/*
+ * 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.iceberg.common.ops;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.ManifestContent;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.StatisticsFile;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.util.PropertyUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Deletes a dropped table's files when some of them can no longer be read. */
+final class IcebergPurgeUtil {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergPurgeUtil.class);
+
+  private IcebergPurgeUtil() {}
+
+  /**
+   * Deletes every data, delete, manifest, manifest list, statistics and 
metadata file reachable
+   * from the metadata, skipping the parts that cannot be read. A missing 
manifest list only loses
+   * the files listed in it. Content files are preserved when {@code 
gc.enabled} is false.
+   *
+   * @param io the table's FileIO
+   * @param metadata the table metadata read before the table was dropped
+   */
+  static void deleteReachableFiles(FileIO io, TableMetadata metadata) {
+    boolean gcEnabled =
+        PropertyUtil.propertyAsBoolean(
+            metadata.properties(), TableProperties.GC_ENABLED, 
TableProperties.GC_ENABLED_DEFAULT);
+    Set<String> manifestLists = new HashSet<>();
+    Set<String> manifests = new HashSet<>();
+    Set<String> contentFiles = new HashSet<>();
+    for (Snapshot snapshot : metadata.snapshots()) {
+      if (snapshot.manifestListLocation() != null) {
+        manifestLists.add(snapshot.manifestListLocation());
+      }
+      try {
+        for (ManifestFile manifest : snapshot.allManifests(io)) {
+          if (manifests.add(manifest.path()) && gcEnabled) {
+            collectContentFiles(io, metadata, manifest, contentFiles);
+          }
+        }
+      } catch (RuntimeException e) {
+        LOG.warn(
+            "Cannot read the manifest list of snapshot {}; its files are not 
deleted: {}",
+            snapshot.snapshotId(),
+            e.getMessage());
+      }
+    }
+    Set<String> metadataFiles = new HashSet<>();
+    metadata.previousFiles().forEach(entry -> metadataFiles.add(entry.file()));
+    if (metadata.metadataFileLocation() != null) {
+      metadataFiles.add(metadata.metadataFileLocation());
+    }
+    for (StatisticsFile stats : metadata.statisticsFiles()) {
+      metadataFiles.add(stats.path());
+    }
+    metadata.partitionStatisticsFiles().forEach(stats -> 
metadataFiles.add(stats.path()));
+
+    deleteAll(io, contentFiles);
+    deleteAll(io, manifests);
+    deleteAll(io, manifestLists);
+    deleteAll(io, metadataFiles);
+  }
+
+  private static void collectContentFiles(
+      FileIO io, TableMetadata metadata, ManifestFile manifest, Set<String> 
paths) {
+    try {
+      if (manifest.content() == ManifestContent.DATA) {
+        try (ManifestReader<DataFile> reader =
+            ManifestFiles.read(manifest, io, metadata.specsById())) {
+          reader.forEach(file -> paths.add(file.location()));
+        }
+      } else {
+        try (ManifestReader<DeleteFile> reader =
+            ManifestFiles.readDeleteManifest(manifest, io, 
metadata.specsById())) {
+          reader.forEach(file -> paths.add(file.location()));
+        }
+      }
+    } catch (Exception e) {
+      LOG.warn(
+          "Cannot read manifest {}; its files are not deleted: {}",
+          manifest.path(),
+          e.getMessage());
+    }
+  }
+
+  private static void deleteAll(FileIO io, Set<String> paths) {
+    for (String path : paths) {
+      try {
+        io.deleteFile(path);
+      } catch (RuntimeException e) {
+        LOG.warn("Cannot delete {}: {}", path, e.getMessage());
+      }
+    }
+  }
+}
diff --git 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/ops/TestIcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/ops/TestIcebergCatalogWrapper.java
index c21e951fa3..e0dfe9dd98 100644
--- 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/ops/TestIcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/ops/TestIcebergCatalogWrapper.java
@@ -21,9 +21,11 @@ package org.apache.gravitino.iceberg.common.ops;
 import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
 import java.lang.reflect.Method;
+import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -37,9 +39,12 @@ import 
org.apache.gravitino.iceberg.common.cache.SupportsMetadataLocation;
 import org.apache.gravitino.iceberg.common.cache.TableMetadataCache;
 import org.apache.gravitino.iceberg.common.utils.IcebergCatalogUtil;
 import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableMetadataParser;
 import org.apache.iceberg.catalog.Catalog;
@@ -59,6 +64,8 @@ import org.apache.iceberg.view.ViewOperations;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mockito;
 
 public class TestIcebergCatalogWrapper {
@@ -306,6 +313,53 @@ public class TestIcebergCatalogWrapper {
     }
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void testPurgeDeletesWhatCanBeReadWhenAManifestListIsMissing(
+      boolean gcEnabled, @TempDir Path warehouseDir) throws Exception {
+    try (IcebergCatalogWrapper wrapper =
+        new IcebergCatalogWrapper(new 
IcebergConfig(sqliteConfig(warehouseDir)))) {
+      Namespace namespace = Namespace.of("db");
+      TableIdentifier ident = TableIdentifier.of(namespace, "purged");
+      
wrapper.createNamespace(CreateNamespaceRequest.builder().withNamespace(namespace).build());
+      Table table =
+          wrapper
+              .getCatalog()
+              .createTable(
+                  ident,
+                  new Schema(Types.NestedField.required(1, "id", 
Types.LongType.get())),
+                  PartitionSpec.unpartitioned(),
+                  ImmutableMap.of("gc.enabled", Boolean.toString(gcEnabled)));
+      Path first = Files.createFile(warehouseDir.resolve("first.parquet"));
+      Path second = Files.createFile(warehouseDir.resolve("second.parquet"));
+      for (Path data : new Path[] {first, second}) {
+        table
+            .newFastAppend()
+            .appendFile(
+                DataFiles.builder(table.spec())
+                    .withPath(data.toUri().toString())
+                    .withFileSizeInBytes(1)
+                    .withRecordCount(1)
+                    .build())
+            .commit();
+      }
+      table.refresh();
+      String metadataFile =
+          ((HasTableOperations) 
table).operations().current().metadataFileLocation();
+      // The first snapshot's manifest list is gone.
+      Files.delete(
+          
Paths.get(URI.create(table.snapshots().iterator().next().manifestListLocation())));
+
+      wrapper.purgeTable(ident);
+
+      Assertions.assertFalse(wrapper.getCatalog().tableExists(ident));
+      // Files reachable through the remaining manifest list, and the 
metadata, are deleted.
+      Assertions.assertEquals(!gcEnabled, Files.exists(first));
+      Assertions.assertEquals(!gcEnabled, Files.exists(second));
+      
Assertions.assertFalse(Files.exists(Paths.get(URI.create(metadataFile))));
+    }
+  }
+
   private static TableMetadataCache 
invokeGetMetadataCache(IcebergCatalogWrapper wrapper)
       throws Exception {
     Method method = 
IcebergCatalogWrapper.class.getDeclaredMethod("getMetadataCache");
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index b5b877564e..b9f7be6fa3 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -59,6 +59,7 @@ import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.TableScan;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.exceptions.ServiceFailureException;
 import org.apache.iceberg.exceptions.ServiceUnavailableException;
 import org.apache.iceberg.exceptions.ValidationException;
@@ -68,6 +69,7 @@ import org.apache.iceberg.rest.PlanStatus;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -138,9 +140,41 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     return loadTableResponse;
   }
 
+  /**
+   * Registers a table. An overwrite with a metadata file that belongs to a 
different table (another
+   * table UUID) is refused before anything is written: Iceberg would replace 
the table and then
+   * fail the request when it reads the replaced table back.
+   *
+   * @param namespace the namespace of the table
+   * @param request the register request
+   * @return the registered table
+   */
+  @Override
+  public LoadTableResponse registerTable(Namespace namespace, 
RegisterTableRequest request) {
+    checkOverwriteKeepsTableUuid(namespace, request);
+    return super.registerTable(namespace, request);
+  }
+
+  /**
+   * Commits table updates, with {@link IcebergCommitValidation} checking the 
updates against the
+   * metadata they are applied to.
+   *
+   * @param tableIdentifier the table to update
+   * @param updateTableRequest the updates and requirements
+   * @return the updated table
+   */
+  @Override
+  public LoadTableResponse updateTable(
+      TableIdentifier tableIdentifier, UpdateTableRequest updateTableRequest) {
+    return super.updateTable(
+        tableIdentifier,
+        IcebergCommitValidation.withValidation(
+            updateTableRequest, () -> 
getCatalog().loadTable(tableIdentifier).io()));
+  }
+
   public LoadTableResponse registerTable(
       Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
-    LoadTableResponse loadTableResponse = super.registerTable(namespace, 
request);
+    LoadTableResponse loadTableResponse = registerTable(namespace, request);
     if (shouldGenerateCredential(loadTableResponse, requestCredential)) {
       // Vend WRITE credentials: the registering user becomes the table owner
       // (IcebergNamespaceHookDispatcher.setTableOwner runs after this call
@@ -697,4 +731,28 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     // Iceberg's binding errors append the whole table struct; it is not 
useful to a client.
     return message == null ? null : message.replaceAll(" in struct: .*$", "");
   }
+
+  private void checkOverwriteKeepsTableUuid(Namespace namespace, 
RegisterTableRequest request) {
+    if (request == null || !request.overwrite() || request.metadataLocation() 
== null) {
+      return;
+    }
+    TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
+    if (!tableExists(identifier)) {
+      return;
+    }
+    String currentUuid = super.loadTable(identifier).tableMetadata().uuid();
+    String newUuid;
+    try {
+      newUuid = 
loadTableMetadataFromLocation(request.metadataLocation()).uuid();
+    } catch (RuntimeException e) {
+      // A metadata file that cannot be read is reported by the register 
itself.
+      return;
+    }
+    if (currentUuid != null && newUuid != null && 
!currentUuid.equals(newUuid)) {
+      throw new BadRequestException(
+          "Cannot overwrite table %s with metadata of a different table: 
current table UUID %s,"
+              + " metadata location %s has table UUID %s",
+          identifier, currentUuid, request.metadataLocation(), newUuid);
+    }
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
new file mode 100644
index 0000000000..d59271d6ab
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
@@ -0,0 +1,446 @@
+/*
+ * 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.iceberg.service;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+import org.apache.iceberg.DataOperations;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.PartitionField;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.RetryableValidationException;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.SnapshotRefType;
+import org.apache.iceberg.SortField;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+
+/**
+ * Checks a table commit against the metadata it is applied to, before Iceberg 
applies it.
+ *
+ * <p>Iceberg applies some metadata updates without checking them, and the 
result is a table that
+ * clients can no longer write or load. The checks run as an extra {@link 
UpdateRequirement}, so
+ * Iceberg evaluates them against the exact metadata the commit is built on, 
again on every retry. A
+ * check that depends on the table's current refs therefore cannot be bypassed 
by a concurrent
+ * commit.
+ */
+public final class IcebergCommitValidation implements UpdateRequirement {
+
+  private static final Set<String> SNAPSHOT_OPERATIONS =
+      ImmutableSet.of(
+          DataOperations.APPEND,
+          DataOperations.REPLACE,
+          DataOperations.OVERWRITE,
+          DataOperations.DELETE);
+
+  /** Transforms the table spec defines; anything else is an unknown 
transform. */
+  private static final Pattern KNOWN_TRANSFORM =
+      
Pattern.compile("identity|year|month|day|hour|void|bucket\\[\\d+]|truncate\\[\\d+]");
+
+  private static final int PARTITION_DATA_ID_START = 1000;
+
+  private final List<MetadataUpdate> updates;
+  private final Supplier<FileIO> fileIO;
+
+  private IcebergCommitValidation(List<MetadataUpdate> updates, 
Supplier<FileIO> fileIO) {
+    this.updates = updates;
+    this.fileIO = fileIO;
+  }
+
+  /**
+   * Returns the request with the commit checks added to its requirements. The 
request itself is not
+   * changed.
+   *
+   * @param request the table commit request
+   * @return a request with the same updates and the checks as an additional 
requirement
+   */
+  @Nullable
+  public static UpdateTableRequest withValidation(@Nullable UpdateTableRequest 
request) {
+    return withValidation(request, () -> null);
+  }
+
+  /**
+   * Returns the request with commit checks that use the table's FileIO to 
read manifest lists.
+   *
+   * @param request the table commit request
+   * @param fileIO supplies the table's FileIO only when a check reads 
manifest lists
+   * @return a request with validation, or the original request when it has no 
updates
+   */
+  @Nullable
+  public static UpdateTableRequest withValidation(
+      @Nullable UpdateTableRequest request, Supplier<FileIO> fileIO) {
+    if (request == null || request.updates() == null || 
request.updates().isEmpty()) {
+      return request;
+    }
+    ImmutableList.Builder<UpdateRequirement> requirements = 
ImmutableList.builder();
+    boolean create = false;
+    for (UpdateRequirement requirement : request.requirements()) {
+      if (requirement instanceof UpdateRequirement.AssertTableDoesNotExist) {
+        // CatalogHandlers accepts only AssertTableDoesNotExist requirements 
for a staged create.
+        requirements.add(new CreateValidation(request.updates(), fileIO));
+        create = true;
+      } else {
+        requirements.add(requirement);
+      }
+    }
+    if (!create) {
+      requirements.add(new IcebergCommitValidation(request.updates(), fileIO));
+    }
+    return new UpdateTableRequest(requirements.build(), request.updates());
+  }
+
+  @Override
+  public void validate(@Nullable TableMetadata base) {
+    TableMetadata updated = validate(base, updates);
+    checkRemovedSpecsAreUnused(updated);
+    if (base != null) {
+      checkSchemas(base, updated);
+    }
+    checkPartitionSpecs(base, updated);
+  }
+
+  @VisibleForTesting
+  static TableMetadata validate(@Nullable TableMetadata base, 
List<MetadataUpdate> updates) {
+    Set<Long> removedSnapshots = new HashSet<>();
+    Map<String, Long> remainingRefs = new HashMap<>();
+    if (base != null) {
+      base.refs().forEach((name, ref) -> remainingRefs.put(name, 
ref.snapshotId()));
+    }
+    for (MetadataUpdate update : updates) {
+      if (update instanceof MetadataUpdate.RemoveSnapshots) {
+        removedSnapshots.addAll(((MetadataUpdate.RemoveSnapshots) 
update).snapshotIds());
+      } else if (update instanceof MetadataUpdate.RemoveSnapshotRef) {
+        remainingRefs.remove(((MetadataUpdate.RemoveSnapshotRef) 
update).name());
+      } else if (update instanceof MetadataUpdate.SetSnapshotRef) {
+        MetadataUpdate.SetSnapshotRef setRef = (MetadataUpdate.SetSnapshotRef) 
update;
+        checkMainIsBranch(setRef);
+        remainingRefs.put(setRef.name(), setRef.snapshotId());
+      }
+    }
+
+    // Iceberg drops every ref that points at a removed snapshot. Removing a 
snapshot that a branch
+    // or tag still references is only valid when the same commit also moves 
or removes that ref.
+    for (Map.Entry<String, Long> ref : remainingRefs.entrySet()) {
+      if (removedSnapshots.contains(ref.getValue())) {
+        throw new CommitFailedException(
+            "Cannot remove snapshot %s: it is referenced by ref %s", 
ref.getValue(), ref.getKey());
+      }
+    }
+
+    // Apply only in memory. Iceberg assigns schema ids while applying 
AddSchema, so checking the
+    // resulting metadata avoids accepting an arbitrary id just because the 
request adds a schema.
+    TableMetadata.Builder builder;
+    if (base == null) {
+      builder =
+          updates.stream()
+              .filter(MetadataUpdate.UpgradeFormatVersion.class::isInstance)
+              .map(MetadataUpdate.UpgradeFormatVersion.class::cast)
+              .map(MetadataUpdate.UpgradeFormatVersion::formatVersion)
+              .findFirst()
+              .map(TableMetadata::buildFromEmpty)
+              .orElseGet(TableMetadata::buildFromEmpty);
+    } else {
+      builder = TableMetadata.buildFrom(base);
+    }
+    try {
+      updates.forEach(update -> update.applyTo(builder));
+    } catch (RetryableValidationException e) {
+      // Preserve CatalogHandlers' conflict response for stale sequence 
numbers and row ids.
+      throw new CommitFailedException(e, "Validation failed, please retry: 
%s", e.getMessage());
+    }
+    TableMetadata updated = builder.build();
+    for (MetadataUpdate update : updates) {
+      if (update instanceof MetadataUpdate.AddSnapshot) {
+        checkSnapshot(updated, ((MetadataUpdate.AddSnapshot) 
update).snapshot());
+      }
+    }
+    return updated;
+  }
+
+  /**
+   * A partition spec can only be removed when no manifest of any snapshot was 
written with it:
+   * readers look up every manifest's spec in the table metadata.
+   */
+  private void checkRemovedSpecsAreUnused(TableMetadata base) {
+    Set<Integer> removed = new HashSet<>();
+    for (MetadataUpdate update : updates) {
+      if (update instanceof MetadataUpdate.RemovePartitionSpecs) {
+        removed.addAll(((MetadataUpdate.RemovePartitionSpecs) 
update).specIds());
+      }
+    }
+    if (removed.isEmpty() || base.snapshots().isEmpty()) {
+      return;
+    }
+    FileIO io = fileIO.get();
+    if (io == null) {
+      throw new ValidationException(
+          "Cannot remove partition specs %s: their use by existing manifests 
cannot be checked",
+          removed);
+    }
+    for (Snapshot snapshot : base.snapshots()) {
+      for (ManifestFile manifest : snapshot.allManifests(io)) {
+        if (removed.contains(manifest.partitionSpecId())) {
+          throw new ValidationException(
+              "Cannot remove partition spec %s: snapshot %s has manifests 
written with it",
+              manifest.partitionSpecId(), snapshot.snapshotId());
+        }
+      }
+    }
+  }
+
+  /**
+   * An added schema must keep the data already written readable: a field id 
that exists in the
+   * current schema keeps its kind and may only change type by an allowed 
promotion, a field id not
+   * in the current schema must be new (above last-column-id) or identical to 
the field that had the
+   * id before, and a new required field needs a default. A replace (which 
removes main) starts from
+   * no data, so only the id rule applies to it.
+   */
+  private void checkSchemas(TableMetadata base, TableMetadata updated) {
+    boolean replacesTable =
+        updates.stream()
+                .anyMatch(
+                    u ->
+                        u instanceof MetadataUpdate.RemoveSnapshotRef
+                            && SnapshotRef.MAIN_BRANCH.equals(
+                                ((MetadataUpdate.RemoveSnapshotRef) u).name()))
+            && (updated.currentSnapshot() == null
+                || base.snapshot(updated.currentSnapshot().snapshotId()) == 
null);
+    boolean hasData = !base.snapshots().isEmpty();
+    Map<Integer, Types.NestedField> current = 
TypeUtil.indexById(base.schema().asStruct());
+    for (MetadataUpdate update : updates) {
+      if (!(update instanceof MetadataUpdate.AddSchema)) {
+        continue;
+      }
+      Schema schema = ((MetadataUpdate.AddSchema) update).schema();
+      for (Types.NestedField field : 
TypeUtil.indexById(schema.asStruct()).values()) {
+        Types.NestedField existing = current.get(field.fieldId());
+        if (existing == null) {
+          checkNewField(base, field, hasData && !replacesTable);
+        } else if (hasData && !replacesTable) {
+          checkTypeChange(existing, field);
+        }
+      }
+    }
+  }
+
+  private static void checkNewField(
+      TableMetadata base, Types.NestedField field, boolean checkRequired) {
+    if (field.fieldId() <= base.lastColumnId()) {
+      Types.NestedField previous = previousField(base, field.fieldId());
+      boolean sameField =
+          previous != null
+              && previous.name().equals(field.name())
+              && previous.type().equals(field.type());
+      if (!sameField) {
+        throw new ValidationException(
+            "Invalid schema: field %s reuses id %s, which the table assigned 
before (last column"
+                + " id %s); new fields need new ids",
+            field.name(), field.fieldId(), base.lastColumnId());
+      }
+    }
+    if (checkRequired && field.isRequired() && field.initialDefault() == null) 
{
+      throw new ValidationException(
+          "Invalid schema: required field %s has no initial default, so 
existing rows cannot be"
+              + " read",
+          field.name());
+    }
+  }
+
+  private static Types.NestedField previousField(TableMetadata base, int 
fieldId) {
+    for (Schema schema : base.schemas()) {
+      Types.NestedField field = schema.findField(fieldId);
+      if (field != null) {
+        return field;
+      }
+    }
+    return null;
+  }
+
+  private static void checkTypeChange(Types.NestedField existing, 
Types.NestedField field) {
+    Type from = existing.type();
+    Type to = field.type();
+    if (from.equals(to)) {
+      return;
+    }
+    if (from.isPrimitiveType() && to.isPrimitiveType()) {
+      if (!TypeUtil.isPromotionAllowed(from, to.asPrimitiveType())) {
+        throw new ValidationException(
+            "Invalid schema: field %s cannot change type from %s to %s", 
field.name(), from, to);
+      }
+    } else if (from.typeId() != to.typeId()) {
+      throw new ValidationException(
+          "Invalid schema: field %s cannot change from %s to %s", 
field.name(), from, to);
+    }
+  }
+
+  /**
+   * Partition field ids identify partition data across specs (v2+): a field 
id another spec uses
+   * for a different field, or a new field id at or below last-partition-id, 
would mix up partition
+   * values. A spec or sort order with a transform the table spec does not 
define cannot be the
+   * default: writers are not allowed to commit data with it.
+   */
+  private void checkPartitionSpecs(@Nullable TableMetadata base, TableMetadata 
updated) {
+    boolean setsDefaultSpec = false;
+    boolean setsDefaultOrder = false;
+    for (MetadataUpdate update : updates) {
+      if (update instanceof MetadataUpdate.SetDefaultPartitionSpec) {
+        setsDefaultSpec = true;
+      } else if (update instanceof MetadataUpdate.SetDefaultSortOrder) {
+        setsDefaultOrder = true;
+      }
+    }
+
+    // Check the actual defaults, not every spec/order added by the request. 
An unknown transform
+    // is allowed in a retained, non-default spec/order.
+    if (setsDefaultSpec || base == null) {
+      checkKnownTransforms(updated.spec().fields(), "partition spec " + 
updated.defaultSpecId());
+    }
+    if (setsDefaultOrder || base == null) {
+      checkKnownSortTransforms(updated.sortOrder(), "sort order " + 
updated.defaultSortOrderId());
+    }
+
+    if (updated.formatVersion() < 2) {
+      return;
+    }
+    Map<Integer, PartitionField> existingFields = new HashMap<>();
+    if (base != null) {
+      for (PartitionSpec existing : base.specs()) {
+        for (PartitionField field : existing.fields()) {
+          existingFields.putIfAbsent(field.fieldId(), field);
+        }
+      }
+    }
+    int lastAssigned =
+        base == null
+            ? PARTITION_DATA_ID_START - 1
+            : Math.max(base.lastAssignedPartitionId(), PARTITION_DATA_ID_START 
- 1);
+    for (PartitionSpec spec : updated.specs()) {
+      if (base == null || !base.specsById().containsKey(spec.specId())) {
+        lastAssigned = checkPartitionFieldIds(existingFields, lastAssigned, 
spec);
+      }
+    }
+  }
+
+  private static int checkPartitionFieldIds(
+      Map<Integer, PartitionField> existingFields, int lastAssigned, 
PartitionSpec spec) {
+    int highestId = lastAssigned;
+    for (PartitionField field : spec.fields()) {
+      PartitionField existing = existingFields.get(field.fieldId());
+      if (existing != null) {
+        if (existing.sourceId() != field.sourceId()
+            || 
!existing.transform().toString().equals(field.transform().toString())) {
+          throw new ValidationException(
+              "Invalid partition spec: field id %s is already used for %s, not 
%s",
+              field.fieldId(), existing, field);
+        }
+      } else if (field.fieldId() <= lastAssigned) {
+        throw new ValidationException(
+            "Invalid partition spec: new field %s needs an id above 
last-partition-id %s",
+            field, lastAssigned);
+      }
+      existingFields.putIfAbsent(field.fieldId(), field);
+      highestId = Math.max(highestId, field.fieldId());
+    }
+    return highestId;
+  }
+
+  private static void checkKnownTransforms(List<PartitionField> fields, String 
what) {
+    for (PartitionField field : fields) {
+      checkKnownTransform(field.transform().toString(), field.name(), what);
+    }
+  }
+
+  private static void checkKnownSortTransforms(SortOrder order, String what) {
+    for (SortField field : order.fields()) {
+      checkKnownTransform(field.transform().toString(), "source " + 
field.sourceId(), what);
+    }
+  }
+
+  private static void checkKnownTransform(String transform, String field, 
String what) {
+    if (!KNOWN_TRANSFORM.matcher(transform).matches()) {
+      throw new ValidationException(
+          "Cannot make %s the default: field %s uses transform %s, which the 
table spec does not"
+              + " define, and writers cannot commit data with it",
+          what, field, transform);
+    }
+  }
+
+  private static void checkMainIsBranch(MetadataUpdate.SetSnapshotRef setRef) {
+    if (SnapshotRef.MAIN_BRANCH.equals(setRef.name())
+        && SnapshotRefType.TAG.name().equalsIgnoreCase(setRef.type())) {
+      throw new ValidationException(
+          "Cannot set %s to a tag: it must be a branch", 
SnapshotRef.MAIN_BRANCH);
+    }
+  }
+
+  private static void checkSnapshot(TableMetadata base, Snapshot snapshot) {
+    // The summary and its operation are required from format version 2 on.
+    String operation = snapshot.operation();
+    if (operation == null) {
+      if (base.formatVersion() >= 2) {
+        throw new ValidationException(
+            "Invalid snapshot %s: a summary with an operation is required", 
snapshot.snapshotId());
+      }
+    } else if (!SNAPSHOT_OPERATIONS.contains(operation)) {
+      throw new ValidationException(
+          "Invalid snapshot %s: unknown operation '%s', must be one of %s",
+          snapshot.snapshotId(), operation, SNAPSHOT_OPERATIONS);
+    }
+    Integer schemaId = snapshot.schemaId();
+    if (schemaId != null && !base.schemasById().containsKey(schemaId)) {
+      throw new ValidationException(
+          "Invalid snapshot %s: the table has no schema with id %s",
+          snapshot.snapshotId(), schemaId);
+    }
+  }
+
+  private static final class CreateValidation extends 
UpdateRequirement.AssertTableDoesNotExist {
+    private final IcebergCommitValidation validation;
+
+    private CreateValidation(List<MetadataUpdate> updates, Supplier<FileIO> 
fileIO) {
+      this.validation = new IcebergCommitValidation(updates, fileIO);
+    }
+
+    @Override
+    public void validate(@Nullable TableMetadata base) {
+      super.validate(base);
+      validation.validate(base);
+    }
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergObjectMapper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergObjectMapper.java
index ed530ecef3..d909ffb6c1 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergObjectMapper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergObjectMapper.java
@@ -31,11 +31,17 @@ import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.databind.node.BooleanNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.io.IOException;
+import org.apache.iceberg.PartitionSpecParser;
+import org.apache.iceberg.UnboundPartitionSpec;
 import org.apache.iceberg.rest.RESTSerializers;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequestParser;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequestParser;
 
 public class IcebergObjectMapper {
+  private static final String SPEC_ID = "spec-id";
+
   private static final ObjectMapper objectMapper = createIcebergObjectMapper();
 
   private IcebergObjectMapper() {}
@@ -46,10 +52,12 @@ public class IcebergObjectMapper {
     mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
     mapper.setPropertyNamingStrategy(new 
PropertyNamingStrategies.KebabCaseStrategy());
     RESTSerializers.registerAll(mapper);
-    // Registered after Iceberg's serializers, so it takes precedence for the 
same type.
-    mapper.registerModule(
-        new SimpleModule("gravitino-iceberg-scan-request")
-            .addDeserializer(PlanTableScanRequest.class, new 
PlanTableScanRequestDeserializer()));
+    // Registered after Iceberg's serializers, so these take precedence for 
the same types.
+    SimpleModule module = new SimpleModule("gravitino-iceberg-rest");
+    module.addDeserializer(PlanTableScanRequest.class, new 
PlanTableScanRequestDeserializer());
+    module.addDeserializer(UnboundPartitionSpec.class, new 
PartitionSpecDeserializer());
+    module.addDeserializer(UpdateTableRequest.class, new 
UpdateTableRequestDeserializer());
+    mapper.registerModule(module);
     return mapper;
   }
 
@@ -92,6 +100,44 @@ public class IcebergObjectMapper {
     }
   }
 
+  /**
+   * The REST spec makes a partition spec's {@code spec-id} read-only, so 
clients may leave it out,
+   * but Iceberg's parser requires it. The server assigns the id of a new spec 
itself, so a missing
+   * id is read as 0.
+   */
+  private static void defaultSpecId(JsonNode spec) {
+    if (spec instanceof ObjectNode && !spec.has(SPEC_ID)) {
+      ((ObjectNode) spec).put(SPEC_ID, 0);
+    }
+  }
+
+  private static class PartitionSpecDeserializer extends 
JsonDeserializer<UnboundPartitionSpec> {
+    @Override
+    public UnboundPartitionSpec deserialize(JsonParser parser, 
DeserializationContext context)
+        throws IOException {
+      JsonNode node = parser.getCodec().readTree(parser);
+      defaultSpecId(node);
+      return PartitionSpecParser.fromJson(node);
+    }
+  }
+
+  private static class UpdateTableRequestDeserializer extends 
JsonDeserializer<UpdateTableRequest> {
+    @Override
+    public UpdateTableRequest deserialize(JsonParser parser, 
DeserializationContext context)
+        throws IOException {
+      JsonNode node = parser.getCodec().readTree(parser);
+      JsonNode updates = node == null ? null : node.get("updates");
+      if (updates != null && updates.isArray()) {
+        for (JsonNode update : updates) {
+          if ("add-spec".equals(update.path("action").asText())) {
+            defaultSpecId(update.get("spec"));
+          }
+        }
+      }
+      return UpdateTableRequestParser.fromJson(node);
+    }
+  }
+
   public static ObjectMapper getInstance() {
     return objectMapper;
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index 7b2998caaa..1568696a52 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -472,12 +472,33 @@ public class IcebergRESTUtils {
     return catalogName;
   }
 
+  /**
+   * Copies an Iceberg REST request or response through its JSON form, so 
event listeners get an
+   * object detached from the one the server works on.
+   *
+   * <p>Events are built after the operation has been applied, so a copy that 
fails must not turn a
+   * completed operation into an error. Some valid requests cannot be 
serialized again by Iceberg,
+   * for example a partition field sent without its optional {@code field-id}; 
for those the
+   * original object, which Iceberg does not modify, is returned instead.
+   *
+   * @param message the object to copy
+   * @param className the class of the copy
+   * @param <T> the type of the copy
+   * @return the copy, or the original object if it cannot be copied
+   */
   public static <T> T cloneIcebergRESTObject(Object message, Class<T> 
className) {
     ObjectMapper icebergObjectMapper = IcebergObjectMapper.getInstance();
     try {
       byte[] values = icebergObjectMapper.writeValueAsBytes(message);
       return icebergObjectMapper.readValue(values, className);
-    } catch (IOException e) {
+    } catch (IOException | RuntimeException e) {
+      if (className.isInstance(message)) {
+        LOG.warn(
+            "Cannot copy {} for an event, the event carries the original 
object: {}",
+            className.getSimpleName(),
+            e.getMessage());
+        return className.cast(message);
+      }
       throw new RuntimeException(e);
     }
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
index 6cc7169a82..3210fb06f8 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
@@ -18,10 +18,12 @@
  */
 package org.apache.gravitino.iceberg.service.dispatcher;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityFieldLimits;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.catalog.SchemaDispatcher;
@@ -34,6 +36,7 @@ import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
@@ -64,6 +67,7 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
       IcebergRequestContext context, CreateNamespaceRequest createRequest) {
     String catalogName = context.catalogName();
     Namespace leaf = createRequest.namespace();
+    checkGravitinoSchemaName(leaf);
     List<Namespace> newlyOwned = new ArrayList<>();
     // Lock the top-level branch root rather than the whole catalog: any race 
on shared ancestor
     // ownership has to share leaf.level(0), so serializing per top-level 
branch is sufficient
@@ -81,7 +85,8 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
     // SchemaMetaService.insertSchema splits the leaf's logical name and 
auto-creates a
     // Gravitino entity row for each ancestor, so a single leaf import covers 
the branch.
     // Failures propagate intentionally: swallowing would leave a namespace in 
Iceberg
-    // that Gravitino doesn't know about.
+    // that Gravitino doesn't know about. Do not drop the namespace on import 
failure: another
+    // client may already have created objects in it.
     importSchema(catalogName, leaf);
 
     // getMissingAncestors() only returns the not-yet-existing ancestors; 
append the leaf so that
@@ -103,6 +108,33 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
     return createNamespaceResponse;
   }
 
+  /**
+   * Rejects a namespace Gravitino cannot hold as a schema before anything is 
created: a level
+   * containing '.', which is reserved as the qualified-name separator (and 
which the JDBC backend
+   * would store as a different, nested namespace), or a full name longer than 
the schema name
+   * column.
+   */
+  @VisibleForTesting
+  static void checkGravitinoSchemaName(Namespace namespace) {
+    if (GravitinoEnv.getInstance().internalSchemaDispatcher() == null) {
+      return;
+    }
+    for (String level : namespace.levels()) {
+      if (level.contains(".")) {
+        throw new BadRequestException(
+            "Invalid namespace %s: the level '%s' contains '.', which is 
reserved as the"
+                + " qualified-name separator",
+            namespace, level);
+      }
+    }
+    String name = String.join(HierarchicalSchemaUtil.schemaSeparator(), 
namespace.levels());
+    if (name.codePointCount(0, name.length()) > 
EntityFieldLimits.MAX_NAME_LENGTH) {
+      throw new BadRequestException(
+          "Invalid namespace %s: the full name must not exceed %s characters",
+          namespace, EntityFieldLimits.MAX_NAME_LENGTH);
+    }
+  }
+
   /**
    * Returns all ancestor namespaces of {@code namespace} that do not 
currently exist in the
    * catalog. Uses {@link HierarchicalSchemaUtil#getAncestorNames} (the same 
utility as the
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
index 9a6689bdc4..5da70a00e2 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
@@ -31,6 +31,8 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.gravitino.lock.LockType;
+import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
@@ -69,7 +71,13 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
   @Override
   public LoadTableResponse createTable(
       IcebergRequestContext context, Namespace namespace, CreateTableRequest 
createTableRequest) {
-    LoadTableResponse response = dispatcher.createTable(context, namespace, 
createTableRequest);
+    // Hold the namespace's tree lock in read mode, as dropNamespace holds it 
in write mode, so a
+    // create and a drop of the same namespace on this server do not both 
succeed.
+    LoadTableResponse response =
+        TreeLockUtils.doWithTreeLock(
+            NameIdentifier.of(metalake, context.catalogName(), 
namespace.level(0)),
+            LockType.READ,
+            () -> dispatcher.createTable(context, namespace, 
createTableRequest));
     // Skip import and ownership for staged creates (credential vending).
     // The table will be imported when the staged table is committed via 
updateTable.
     if (!createTableRequest.stageCreate()) {
@@ -84,14 +92,20 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
       IcebergRequestContext context,
       TableIdentifier tableIdentifier,
       UpdateTableRequest updateTableRequest) {
-    LoadTableResponse response =
-        dispatcher.updateTable(context, tableIdentifier, updateTableRequest);
     // Import the table and set ownership only when committing a staged table 
create.
     // A staged create commit is identified by the AssertTableDoesNotExist 
requirement,
     // which is set by UpdateRequirements.forCreateTable().
     boolean isStagedCreateCommit =
         updateTableRequest.requirements().stream()
             
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance);
+    LoadTableResponse response =
+        isStagedCreateCommit
+            ? TreeLockUtils.doWithTreeLock(
+                NameIdentifier.of(
+                    metalake, context.catalogName(), 
tableIdentifier.namespace().level(0)),
+                LockType.READ,
+                () -> dispatcher.updateTable(context, tableIdentifier, 
updateTableRequest))
+            : dispatcher.updateTable(context, tableIdentifier, 
updateTableRequest);
     if (isStagedCreateCommit) {
       importTableAndSetOwner(context, tableIdentifier.namespace(), 
tableIdentifier.name());
     } else if (changesSchema(updateTableRequest)) {
@@ -221,9 +235,8 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
 
   private void importTableAndSetOwner(
       IcebergRequestContext context, Namespace namespace, String tableName) {
-    // Import is intentionally NOT wrapped in try-catch: if it fails the table 
exists in Iceberg
-    // but not in Gravitino, and silently swallowing that would mislead 
callers into thinking the
-    // entity is registered. Surface the failure so the caller can react.
+    // Validate request metadata before the backend operation. An unexpected 
import failure must
+    // propagate without deleting a table that another client may already have 
updated or recreated.
     importTableEntity(context.catalogName(), namespace, tableName);
     IcebergEntityAuditUtils.recordCreator(
         IcebergIdentifierUtils.toGravitinoTableIdentifier(
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergPaginationHelper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergPaginationHelper.java
index 625a342562..69a4c5c49c 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergPaginationHelper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergPaginationHelper.java
@@ -49,6 +49,8 @@ import org.apache.iceberg.rest.responses.ListTablesResponse;
  *       implementations and is generally acceptable.
  *   <li>When {@code pageToken} is provided without {@code pageSize}, the 
response returns all items
  *       after the cursor with no {@code nextPageToken}.
+ *   <li>Without a {@code pageToken} parameter all items are returned, even 
when {@code pageSize} is
+ *       set, as the REST spec requires; an empty {@code pageToken} requests 
the first page.
  * </ul>
  */
 class IcebergPaginationHelper {
@@ -59,8 +61,8 @@ class IcebergPaginationHelper {
    * Paginate a {@link ListNamespacesResponse}.
    *
    * @param response the full (unpaginated) response
-   * @param pageToken cursor-based page token (name of last item on previous 
page), or empty for the
-   *     first page
+   * @param pageToken cursor-based page token (name of last item on previous 
page), an empty string
+   *     for the first page, or absent to return all results
    * @param pageSize maximum items per page, or empty for no limit
    * @return a new response containing only the requested page
    */
@@ -77,8 +79,8 @@ class IcebergPaginationHelper {
    * Paginate a {@link ListTablesResponse}. Works for both table and view list 
responses.
    *
    * @param response the full (unpaginated) response
-   * @param pageToken cursor-based page token (name of last item on previous 
page), or empty for the
-   *     first page
+   * @param pageToken cursor-based page token (name of last item on previous 
page), an empty string
+   *     for the first page, or absent to return all results
    * @param pageSize maximum items per page, or empty for no limit
    * @return a new response containing only the requested page
    */
@@ -98,7 +100,8 @@ class IcebergPaginationHelper {
    * cursor token. The next-page token is the key of the last item on the 
returned page.
    *
    * @param items the complete list of items to paginate
-   * @param pageToken cursor string (key of last item on previous page), or 
empty for the first page
+   * @param pageToken cursor string (key of last item on previous page), an 
empty string for the
+   *     first page, or absent when the request has no pageToken parameter
    * @param pageSize maximum items per page, or empty for no limit
    * @param keyExtractor function to extract a comparable cursor key from each 
item
    * @return a {@link PaginatedPage} containing the requested page of items 
and optional next token
@@ -108,13 +111,22 @@ class IcebergPaginationHelper {
       Optional<String> pageToken,
       Optional<Integer> pageSize,
       Function<T, String> keyExtractor) {
-    String token = pageToken.orElse("");
-    if (!pageSize.isPresent() && token.isEmpty()) {
+    pageSize.ifPresent(
+        size -> Preconditions.checkArgument(size > 0, "pageSize must be 
positive, got: %s", size));
+
+    // The REST spec: a server that supports pagination returns all results, 
with no next-page
+    // token, when the request has no pageToken parameter. A client starts 
paging by sending an
+    // empty pageToken, so pageSize alone does not start paging.
+    if (!pageToken.isPresent()) {
       return new PaginatedPage<>(items, Optional.empty());
     }
+    String token = pageToken.get();
 
-    pageSize.ifPresent(
-        size -> Preconditions.checkArgument(size > 0, "pageSize must be 
positive, got: %s", size));
+    // An empty pageToken with no pageSize asks for every item from the start, 
which is the
+    // unsorted list as given; skip the sort.
+    if (token.isEmpty() && !pageSize.isPresent()) {
+      return new PaginatedPage<>(items, Optional.empty());
+    }
 
     List<T> sorted =
         
items.stream().sorted(Comparator.comparing(keyExtractor)).collect(Collectors.toList());
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
index 9d94ef4942..1ba31b8f05 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
@@ -32,6 +32,7 @@ import com.sun.net.httpserver.HttpServer;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -45,6 +46,7 @@ import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.service.extension.DummyCredentialProvider;
+import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.BaseTransaction;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.MetadataUpdate;
@@ -59,6 +61,7 @@ import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.exceptions.NotAuthorizedException;
@@ -77,6 +80,7 @@ import 
org.apache.iceberg.rest.responses.LoadTableResponseParser;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 public class TestCatalogWrapperForREST {
 
@@ -433,6 +437,74 @@ public class TestCatalogWrapperForREST {
             withProvider.loadTable(table, false, CredentialPrivilege.READ), 
true));
   }
 
+  @Test
+  void testRegisterOverwriteWithAnotherTablesMetadataIsRefused(@TempDir Path 
warehouse)
+      throws Exception {
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.<String, String>builder()
+                .put(IcebergConstants.CATALOG_BACKEND, "jdbc")
+                .put(IcebergConstants.URI, "jdbc:sqlite:" + 
warehouse.resolve("catalog.db"))
+                .put(IcebergConstants.WAREHOUSE, warehouse.toUri().toString())
+                .put(IcebergConstants.GRAVITINO_JDBC_DRIVER, "org.sqlite.JDBC")
+                .put(IcebergConstants.ICEBERG_JDBC_USER, "test")
+                .put(IcebergConstants.ICEBERG_JDBC_PASSWORD, "test")
+                .put(IcebergConstants.ICEBERG_JDBC_INITIALIZE, "true")
+                .build());
+    try (CatalogWrapperForREST wrapper = new 
CatalogWrapperForREST("register-test", config)) {
+      Namespace namespace = Namespace.of("db");
+      Catalog catalog = wrapper.getCatalog();
+      ((SupportsNamespaces) catalog).createNamespace(namespace);
+      Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
+      TableIdentifier target = TableIdentifier.of(namespace, "target");
+      TableIdentifier other = TableIdentifier.of(namespace, "other");
+      catalog.createTable(target, schema);
+      catalog.createTable(other, schema);
+      String targetMetadata =
+          ((BaseTable) 
catalog.loadTable(target)).operations().current().metadataFileLocation();
+      String otherMetadata =
+          ((BaseTable) 
catalog.loadTable(other)).operations().current().metadataFileLocation();
+
+      RegisterTableRequest otherTable =
+          ImmutableRegisterTableRequest.builder()
+              .name("target")
+              .metadataLocation(otherMetadata)
+              .overwrite(true)
+              .build();
+      BadRequestException e =
+          Assertions.assertThrows(
+              BadRequestException.class, () -> 
wrapper.registerTable(namespace, otherTable));
+      Assertions.assertTrue(e.getMessage().contains("different table"), 
e.getMessage());
+      // The REST executor uses the overload carrying the credential-vending 
flag.
+      for (boolean requestCredential : new boolean[] {false, true}) {
+        Assertions.assertThrows(
+            BadRequestException.class,
+            () -> wrapper.registerTable(namespace, otherTable, 
requestCredential));
+      }
+      Assertions.assertEquals(
+          targetMetadata,
+          ((BaseTable) 
catalog.loadTable(target)).operations().current().metadataFileLocation());
+
+      // Matching metadata passes UUID validation. The OSS JDBC backend on 
branch-1.3 still rejects
+      // overwrite, so preserve that backend response rather than adding 
overwrite support here.
+      RegisterTableRequest sameTable =
+          ImmutableRegisterTableRequest.builder()
+              .name("target")
+              .metadataLocation(targetMetadata)
+              .overwrite(true)
+              .build();
+      UnsupportedOperationException unsupported =
+          Assertions.assertThrows(
+              UnsupportedOperationException.class,
+              () -> wrapper.registerTable(namespace, sameTable));
+      Assertions.assertEquals(
+          "Registering tables with overwrite is not supported", 
unsupported.getMessage());
+      Assertions.assertEquals(
+          targetMetadata,
+          ((BaseTable) 
catalog.loadTable(target)).operations().current().metadataFileLocation());
+    }
+  }
+
   @Test
   void testLocalTableCredentials() {
     IcebergConfig config =
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
new file mode 100644
index 0000000000..238941d98f
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
@@ -0,0 +1,613 @@
+/*
+ * 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.iceberg.service;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.List;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.MetadataUpdateParser;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.SnapshotParser;
+import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.SnapshotRefType;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.rest.CatalogHandlers;
+import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergCommitValidation {
+
+  private static final Schema SCHEMA =
+      new Schema(Types.NestedField.required(1, "id", Types.LongType.get()));
+  private static final TableIdentifier IDENT = 
TableIdentifier.of(Namespace.of("db"), "t");
+
+  private InMemoryCatalog catalog;
+  private long firstSnapshot;
+  private long secondSnapshot;
+
+  @BeforeEach
+  public void setUp() {
+    catalog = new InMemoryCatalog();
+    catalog.initialize("memory", ImmutableMap.of());
+    catalog.createNamespace(Namespace.of("db"));
+    Table table =
+        catalog.createTable(
+            IDENT, SCHEMA, PartitionSpec.unpartitioned(), 
ImmutableMap.of("format-version", "2"));
+    append(table, "/data/f1.parquet");
+    firstSnapshot = table.currentSnapshot().snapshotId();
+    append(table, "/data/f2.parquet");
+    secondSnapshot = table.currentSnapshot().snapshotId();
+  }
+
+  @Test
+  public void testMainCannotBecomeATag() {
+    commitFails(
+        ValidationException.class,
+        new MetadataUpdate.SetSnapshotRef(
+            SnapshotRef.MAIN_BRANCH, secondSnapshot, SnapshotRefType.TAG, 
null, null, null));
+    
Assertions.assertTrue(catalog.loadTable(IDENT).refs().get(SnapshotRef.MAIN_BRANCH).isBranch());
+
+    // Other tags, and main as a branch, are unaffected.
+    commit(
+        new MetadataUpdate.SetSnapshotRef(
+            "v1", firstSnapshot, SnapshotRefType.TAG, null, null, null));
+    Assertions.assertTrue(catalog.loadTable(IDENT).refs().get("v1").isTag());
+  }
+
+  @Test
+  public void testAddSnapshotIsChecked() {
+    // An operation outside the spec's values.
+    commitFails(
+        ValidationException.class, addSnapshot(", \"summary\": {\"operation\": 
\"bogus\"}"));
+    // No summary on a v2 table.
+    commitFails(ValidationException.class, addSnapshot(""));
+    // A schema id the table does not have.
+    commitFails(
+        ValidationException.class,
+        addSnapshot(", \"summary\": {\"operation\": \"append\"}, 
\"schema-id\": 99"));
+
+    Assertions.assertEquals(
+        secondSnapshot, 
catalog.loadTable(IDENT).currentSnapshot().snapshotId());
+    Assertions.assertNull(catalog.loadTable(IDENT).snapshot(999L));
+
+    // A valid snapshot is still accepted.
+    commit(addSnapshot(", \"summary\": {\"operation\": \"append\"}, 
\"schema-id\": 0"));
+    Assertions.assertNotNull(catalog.loadTable(IDENT).snapshot(999L));
+  }
+
+  @Test
+  public void testRemovingReferencedSnapshotIsRefused() {
+    // The current snapshot of main.
+    commitFails(CommitFailedException.class, new 
MetadataUpdate.RemoveSnapshots(secondSnapshot));
+    // A snapshot a tag references.
+    commit(
+        new MetadataUpdate.SetSnapshotRef(
+            "keep", firstSnapshot, SnapshotRefType.TAG, null, null, null));
+    commitFails(CommitFailedException.class, new 
MetadataUpdate.RemoveSnapshots(firstSnapshot));
+
+    Table table = catalog.loadTable(IDENT);
+    Assertions.assertEquals(secondSnapshot, 
table.currentSnapshot().snapshotId());
+    Assertions.assertTrue(table.refs().containsKey("keep"));
+    Assertions.assertNotNull(table.snapshot(firstSnapshot));
+
+    // Removing the tag in the same commit makes the removal valid.
+    commit(
+        new MetadataUpdate.RemoveSnapshotRef("keep"),
+        new MetadataUpdate.RemoveSnapshots(ImmutableSet.of(firstSnapshot)));
+    Assertions.assertNull(catalog.loadTable(IDENT).snapshot(firstSnapshot));
+  }
+
+  @Test
+  public void testAddingSchemaDoesNotBypassSnapshotSchemaValidation() {
+    Schema schema =
+        new Schema(
+            99,
+            Types.NestedField.required(1, "id", Types.LongType.get()),
+            Types.NestedField.optional(2, "name", Types.StringType.get()));
+    commitFails(
+        ValidationException.class,
+        new MetadataUpdate.AddSchema(schema),
+        addSnapshot(", \"summary\": {\"operation\": \"append\"}, 
\"schema-id\": 99"));
+    Assertions.assertEquals(1, catalog.loadTable(IDENT).schemas().size());
+    Assertions.assertNull(catalog.loadTable(IDENT).snapshot(999L));
+
+    // Iceberg assigns id 1 to the new schema, regardless of the id supplied 
in add-schema.
+    commit(
+        new MetadataUpdate.AddSchema(schema),
+        addSnapshot(", \"summary\": {\"operation\": \"append\"}, 
\"schema-id\": 1"));
+    Assertions.assertNotNull(catalog.loadTable(IDENT).schemas().get(1));
+    Assertions.assertEquals(1, 
catalog.loadTable(IDENT).snapshot(999L).schemaId());
+  }
+
+  @Test
+  public void testStaleSnapshotRemainsACommitConflict() {
+    MetadataUpdate snapshot =
+        new MetadataUpdate.AddSnapshot(
+            SnapshotParser.fromJson(
+                "{\"snapshot-id\": 999, \"parent-snapshot-id\": "
+                    + secondSnapshot
+                    + ", \"sequence-number\": 1, \"timestamp-ms\": "
+                    + System.currentTimeMillis()
+                    + ", \"manifest-list\": \"/data/snap-999.avro\","
+                    + " \"summary\": {\"operation\": \"append\"}, 
\"schema-id\": 0}"));
+    commitFails(CommitFailedException.class, snapshot);
+    Assertions.assertEquals(
+        secondSnapshot, 
catalog.loadTable(IDENT).currentSnapshot().snapshotId());
+    Assertions.assertNull(catalog.loadTable(IDENT).snapshot(999L));
+  }
+
+  @Test
+  public void testChangingRefMustActuallyMoveItOffRemovedSnapshot() {
+    commit(
+        new MetadataUpdate.SetSnapshotRef(
+            "keep", firstSnapshot, SnapshotRefType.TAG, null, null, null));
+    commitFails(
+        CommitFailedException.class,
+        new MetadataUpdate.SetSnapshotRef(
+            "keep", firstSnapshot, SnapshotRefType.TAG, null, null, null),
+        new MetadataUpdate.RemoveSnapshots(firstSnapshot));
+    Assertions.assertNotNull(catalog.loadTable(IDENT).snapshot(firstSnapshot));
+    Assertions.assertEquals(
+        firstSnapshot, 
catalog.loadTable(IDENT).refs().get("keep").snapshotId());
+
+    commit(
+        new MetadataUpdate.SetSnapshotRef(
+            "keep", secondSnapshot, SnapshotRefType.TAG, null, null, null),
+        new MetadataUpdate.RemoveSnapshots(firstSnapshot));
+    Assertions.assertNull(catalog.loadTable(IDENT).snapshot(firstSnapshot));
+    Assertions.assertEquals(
+        secondSnapshot, 
catalog.loadTable(IDENT).refs().get("keep").snapshotId());
+  }
+
+  @Test
+  public void testStagedCreateCommitIsAccepted() {
+    TableIdentifier staged = TableIdentifier.of("db", "staged");
+    UpdateTableRequest request = stagedCreateRequest(staged);
+    Assertions.assertFalse(catalog.tableExists(staged));
+
+    CatalogHandlers.updateTable(catalog, staged, 
IcebergCommitValidation.withValidation(request));
+    Assertions.assertTrue(catalog.tableExists(staged));
+    
Assertions.assertTrue(SCHEMA.sameSchema(catalog.loadTable(staged).schema()));
+
+    // Keep the original existence requirement: retrying the create cannot 
replace the table.
+    Assertions.assertThrows(
+        CommitFailedException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog, staged, 
IcebergCommitValidation.withValidation(request)));
+  }
+
+  @Test
+  public void testStagedCreateIsValidatedBeforeCommit() {
+    TableIdentifier staged = TableIdentifier.of("db", "invalid_staged");
+    UpdateTableRequest create = stagedCreateRequest(staged);
+    List<MetadataUpdate> updates =
+        ImmutableList.<MetadataUpdate>builder()
+            .addAll(create.updates())
+            .add(
+                new MetadataUpdate.SetSnapshotRef(
+                    SnapshotRef.MAIN_BRANCH, 999L, SnapshotRefType.TAG, null, 
null, null))
+            .build();
+    UpdateTableRequest invalid = new UpdateTableRequest(create.requirements(), 
updates);
+    Assertions.assertThrows(
+        ValidationException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog, staged, 
IcebergCommitValidation.withValidation(invalid)));
+    Assertions.assertFalse(catalog.tableExists(staged));
+  }
+
+  @Test
+  public void testStagedCreateRejectsSnapshotWithoutSummary() {
+    TableIdentifier staged = TableIdentifier.of("db", "invalid_snapshot");
+    UpdateTableRequest create = stagedCreateRequest(staged);
+    MetadataUpdate snapshot =
+        new MetadataUpdate.AddSnapshot(
+            SnapshotParser.fromJson(
+                "{\"snapshot-id\": 999, \"sequence-number\": 1, 
\"timestamp-ms\": "
+                    + System.currentTimeMillis()
+                    + ", \"manifest-list\": \"/data/snap-999.avro\", 
\"schema-id\": 0}"));
+    List<MetadataUpdate> updates =
+        
ImmutableList.<MetadataUpdate>builder().addAll(create.updates()).add(snapshot).build();
+    UpdateTableRequest invalid = new UpdateTableRequest(create.requirements(), 
updates);
+
+    ValidationException thrown =
+        Assertions.assertThrows(
+            ValidationException.class,
+            () ->
+                CatalogHandlers.updateTable(
+                    catalog, staged, 
IcebergCommitValidation.withValidation(invalid)));
+    Assertions.assertTrue(thrown.getMessage().contains("summary"));
+    Assertions.assertFalse(catalog.tableExists(staged));
+  }
+
+  @Test
+  public void testRefCreatedAfterTheClientReadIsProtected() {
+    // An expire job read the table before a user tagged the snapshot it is 
about to remove: the
+    // commit is checked against the metadata it is applied to, so the tag is 
not lost.
+    UpdateTableRequest expire =
+        new UpdateTableRequest(
+            ImmutableList.of(
+                new 
UpdateRequirement.AssertTableUUID(catalog.loadTable(IDENT).uuid().toString())),
+            ImmutableList.of(new 
MetadataUpdate.RemoveSnapshots(firstSnapshot)));
+    catalog.loadTable(IDENT).manageSnapshots().createTag("audit", 
firstSnapshot).commit();
+
+    Assertions.assertThrows(
+        CommitFailedException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog, IDENT, 
IcebergCommitValidation.withValidation(expire)));
+    
Assertions.assertTrue(catalog.loadTable(IDENT).refs().containsKey("audit"));
+  }
+
+  @Test
+  public void testWithValidationKeepsTheRequest() {
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            ImmutableList.of(new UpdateRequirement.AssertTableUUID("uuid")),
+            ImmutableList.of(new 
MetadataUpdate.RemoveSnapshots(firstSnapshot)));
+    UpdateTableRequest validated = 
IcebergCommitValidation.withValidation(request);
+
+    Assertions.assertEquals(1, request.requirements().size());
+    Assertions.assertEquals(2, validated.requirements().size());
+    Assertions.assertEquals(request.updates(), validated.updates());
+    Assertions.assertInstanceOf(IcebergCommitValidation.class, 
validated.requirements().get(1));
+  }
+
+  @Test
+  public void testSchemaChangesMustKeepDataReadable() {
+    // id 1 is "id long" and the table has data.
+    commitFails(
+        ValidationException.class,
+        
addSchema("{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"int\"}"));
+    commitFails(
+        ValidationException.class,
+        
addSchema("{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"string\"}"));
+    commitFails(
+        ValidationException.class,
+        addSchema(
+            
"{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":{\"type\":\"struct\","
+                + 
"\"fields\":[{\"id\":9,\"name\":\"z\",\"required\":false,\"type\":\"long\"}]}}"));
+    // A new required field without a default.
+    commitFails(
+        ValidationException.class,
+        addSchema(
+            "{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"long\"},"
+                + 
"{\"id\":2,\"name\":\"r\",\"required\":true,\"type\":\"string\"}"));
+    Assertions.assertEquals(1, catalog.loadTable(IDENT).schemas().size());
+
+    // Allowed: an optional new field with a new id.
+    commit(
+        addSchema(
+            "{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"long\"},"
+                + 
"{\"id\":2,\"name\":\"s\",\"required\":false,\"type\":\"string\"}"));
+    Assertions.assertEquals(2, catalog.loadTable(IDENT).schemas().size());
+  }
+
+  @Test
+  public void testReusingADroppedFieldIdIsRefused() {
+    // Schema 1 adds b (id 2); schema 2 drops it; a later schema may not give 
id 2 to another field.
+    commit(
+        addSchema(
+            "{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"long\"},"
+                + 
"{\"id\":2,\"name\":\"b\",\"required\":false,\"type\":\"int\"}"),
+        new MetadataUpdate.SetCurrentSchema(-1));
+    // Going back to the original schema drops b again.
+    commit(new MetadataUpdate.SetCurrentSchema(0));
+    commitFails(
+        ValidationException.class,
+        addSchema(
+            "{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"long\"},"
+                + 
"{\"id\":2,\"name\":\"c\",\"required\":false,\"type\":\"string\"}"));
+  }
+
+  @Test
+  public void testReplaceMayRedefineColumns() {
+    // A replace removes main, so no existing data is read with the new schema.
+    commit(
+        new MetadataUpdate.RemoveSnapshotRef(SnapshotRef.MAIN_BRANCH),
+        
addSchema("{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"string\"}"),
+        new MetadataUpdate.SetCurrentSchema(-1));
+    Assertions.assertEquals(
+        Types.StringType.get(), 
catalog.loadTable(IDENT).schema().findType("id"));
+  }
+
+  @Test
+  public void testPartitionFieldIdsAreChecked() {
+    Table partitioned = createPartitionedTable("p");
+    TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), "p");
+    // Spec 0 has 1000: s identity(2). The same id for another source field is 
refused.
+    Assertions.assertThrows(
+        ValidationException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog,
+                ident,
+                request(
+                    addSpec(
+                        
"{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":1000}"))));
+    // A new field with an id below last-partition-id is refused.
+    Assertions.assertThrows(
+        ValidationException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog,
+                ident,
+                request(
+                    addSpec(
+                        
"{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":999}"))));
+    // Keeping 1000 for the same field and adding 1001 is valid.
+    CatalogHandlers.updateTable(
+        catalog,
+        ident,
+        request(
+            addSpec(
+                
"{\"name\":\"s\",\"transform\":\"identity\",\"source-id\":2,\"field-id\":1000},"
+                    + 
"{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":1001}")));
+    Assertions.assertEquals(2, catalog.loadTable(ident).specs().size());
+    Assertions.assertNotNull(partitioned);
+  }
+
+  @Test
+  public void testRemovingASpecWithLiveManifestsIsRefused() {
+    Table partitioned = createPartitionedTable("rm");
+    TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), "rm");
+    append(partitioned, "/data/rm1.parquet", "s=a");
+    // Make a new default spec, so spec 0 is no longer the default but is 
still used by data.
+    CatalogHandlers.updateTable(
+        catalog,
+        ident,
+        request(
+            addSpec(
+                
"{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":1001}"),
+            new MetadataUpdate.SetDefaultPartitionSpec(-1)));
+    Assertions.assertThrows(
+        ValidationException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog,
+                ident,
+                IcebergCommitValidation.withValidation(
+                    new UpdateTableRequest(
+                        ImmutableList.of(),
+                        ImmutableList.of(
+                            new 
MetadataUpdate.RemovePartitionSpecs(ImmutableSet.of(0)))),
+                    () -> catalog.loadTable(ident).io())));
+    Assertions.assertNotNull(catalog.loadTable(ident).specs().get(0));
+
+    // An unused spec can be removed.
+    CatalogHandlers.updateTable(
+        catalog,
+        ident,
+        request(
+            addSpec(
+                
"{\"name\":\"s_b\",\"transform\":\"bucket[4]\",\"source-id\":2,\"field-id\":1002}")));
+    CatalogHandlers.updateTable(
+        catalog,
+        ident,
+        IcebergCommitValidation.withValidation(
+            new UpdateTableRequest(
+                ImmutableList.of(),
+                ImmutableList.of(new 
MetadataUpdate.RemovePartitionSpecs(ImmutableSet.of(2)))),
+            () -> catalog.loadTable(ident).io()));
+    Assertions.assertNull(catalog.loadTable(ident).specs().get(2));
+  }
+
+  @Test
+  public void testUnknownTransformCannotBecomeTheDefault() {
+    MetadataUpdate zorder =
+        MetadataUpdateParser.fromJson(
+            
"{\"action\":\"add-sort-order\",\"sort-order\":{\"order-id\":1,\"fields\":["
+                + 
"{\"transform\":\"zorder\",\"source-id\":1,\"direction\":\"asc\","
+                + "\"null-order\":\"nulls-first\"}]}}");
+    commitFails(ValidationException.class, zorder, new 
MetadataUpdate.SetDefaultSortOrder(-1));
+    Assertions.assertTrue(catalog.loadTable(IDENT).sortOrder().isUnsorted());
+    // Adding it without making it the default is allowed by the table spec.
+    commit(zorder);
+  }
+
+  @Test
+  public void testPartitionSpecWithoutSpecIdIsAccepted() throws Exception {
+    // The REST spec makes spec-id read-only; the server assigns the id.
+    CreateTableRequest create =
+        IcebergObjectMapper.getInstance()
+            .readValue(
+                "{\"name\":\"t\",\"schema\":"
+                    + SchemaParser.toJson(SCHEMA)
+                    + ",\"partition-spec\":{\"fields\":[{\"name\":\"id_b\","
+                    + "\"transform\":\"bucket[4]\",\"source-id\":1}]}}",
+                CreateTableRequest.class);
+    Assertions.assertEquals(1, create.spec().fields().size());
+
+    UpdateTableRequest update =
+        IcebergObjectMapper.getInstance()
+            .readValue(
+                
"{\"requirements\":[],\"updates\":[{\"action\":\"add-spec\",\"spec\":"
+                    + 
"{\"fields\":[{\"name\":\"id_b\",\"transform\":\"bucket[4]\","
+                    + "\"source-id\":1,\"field-id\":1000}]}}]}",
+                UpdateTableRequest.class);
+    CatalogHandlers.updateTable(catalog, IDENT, 
IcebergCommitValidation.withValidation(update));
+    // The new spec gets the next free id, not the placeholder.
+    Assertions.assertEquals(2, catalog.loadTable(IDENT).specs().size());
+    Assertions.assertTrue(catalog.loadTable(IDENT).specs().containsKey(1));
+  }
+
+  @Test
+  void testPartitionIdsCannotCollideWithinOneCommit() {
+    commitFails(
+        ValidationException.class,
+        
addSpec("{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":1000}"),
+        addSpec(
+            
"{\"name\":\"id_bucket\",\"transform\":\"bucket[4]\",\"source-id\":1,\"field-id\":1000}"));
+    Assertions.assertEquals(1, catalog.loadTable(IDENT).specs().size());
+  }
+
+  @Test
+  void testUnusedUnknownSortOrderDoesNotBlockSettingKnownDefault() {
+    MetadataUpdate unknown =
+        MetadataUpdateParser.fromJson(
+            
"{\"action\":\"add-sort-order\",\"sort-order\":{\"order-id\":1,\"fields\":["
+                + 
"{\"transform\":\"zorder\",\"source-id\":1,\"direction\":\"asc\","
+                + "\"null-order\":\"nulls-first\"}]}}");
+    commit(unknown, new MetadataUpdate.SetDefaultSortOrder(0));
+    Assertions.assertTrue(catalog.loadTable(IDENT).sortOrder().isUnsorted());
+    Assertions.assertEquals(2, catalog.loadTable(IDENT).sortOrders().size());
+  }
+
+  @Test
+  void testStagedCreateCannotUseUnknownDefaultTransform() {
+    TableIdentifier staged = TableIdentifier.of("db", "unknown_transform");
+    UpdateTableRequest create = stagedCreateRequest(staged);
+    List<MetadataUpdate> updates =
+        ImmutableList.<MetadataUpdate>builder()
+            .addAll(create.updates())
+            .add(
+                addSpec(
+                    
"{\"name\":\"unknown\",\"transform\":\"custom\",\"source-id\":1,\"field-id\":1000}"))
+            .add(new MetadataUpdate.SetDefaultPartitionSpec(-1))
+            .build();
+    UpdateTableRequest request = new UpdateTableRequest(create.requirements(), 
updates);
+
+    Assertions.assertThrows(
+        ValidationException.class,
+        () ->
+            CatalogHandlers.updateTable(
+                catalog, staged, 
IcebergCommitValidation.withValidation(request)));
+    Assertions.assertFalse(catalog.tableExists(staged));
+  }
+
+  @Test
+  void testRemovingAndRestoringMainCannotBypassSchemaChecks() {
+    commitFails(
+        ValidationException.class,
+        new MetadataUpdate.RemoveSnapshotRef(SnapshotRef.MAIN_BRANCH),
+        
addSchema("{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"string\"}"),
+        new MetadataUpdate.SetCurrentSchema(-1),
+        new MetadataUpdate.SetSnapshotRef(
+            SnapshotRef.MAIN_BRANCH, secondSnapshot, SnapshotRefType.BRANCH, 
null, null, null));
+    Assertions.assertEquals(Types.LongType.get(), 
catalog.loadTable(IDENT).schema().findType("id"));
+    Assertions.assertEquals(
+        secondSnapshot, 
catalog.loadTable(IDENT).currentSnapshot().snapshotId());
+  }
+
+  private Table createPartitionedTable(String name) {
+    Schema schema =
+        new Schema(
+            Types.NestedField.required(1, "id", Types.LongType.get()),
+            Types.NestedField.optional(2, "s", Types.StringType.get()));
+    return catalog.createTable(
+        TableIdentifier.of(Namespace.of("db"), name),
+        schema,
+        PartitionSpec.builderFor(schema).identity("s").build(),
+        ImmutableMap.of("format-version", "2"));
+  }
+
+  private static MetadataUpdate addSchema(String fields) {
+    return new MetadataUpdate.AddSchema(
+        
SchemaParser.fromJson("{\"type\":\"struct\",\"schema-id\":7,\"fields\":[" + 
fields + "]}"));
+  }
+
+  private static MetadataUpdate addSpec(String fields) {
+    return MetadataUpdateParser.fromJson(
+        "{\"action\":\"add-spec\",\"spec\":{\"spec-id\":0,\"fields\":[" + 
fields + "]}}");
+  }
+
+  private static void append(Table table, String path, String partition) {
+    table
+        .newFastAppend()
+        .appendFile(
+            DataFiles.builder(table.spec())
+                .withPath(path)
+                .withFileSizeInBytes(10)
+                .withRecordCount(1)
+                .withPartitionPath(partition)
+                .build())
+        .commit();
+  }
+
+  private MetadataUpdate addSnapshot(String extraFields) {
+    String json =
+        "{\"snapshot-id\": 999, \"parent-snapshot-id\": "
+            + secondSnapshot
+            + ", \"sequence-number\": 3, \"timestamp-ms\": "
+            + System.currentTimeMillis()
+            + ", \"manifest-list\": \"/data/snap-999.avro\""
+            + extraFields
+            + "}";
+    return new MetadataUpdate.AddSnapshot(SnapshotParser.fromJson(json));
+  }
+
+  private UpdateTableRequest stagedCreateRequest(TableIdentifier identifier) {
+    TableMetadata metadata =
+        CatalogHandlers.stageTableCreate(
+                catalog,
+                identifier.namespace(),
+                CreateTableRequest.builder()
+                    .withName(identifier.name())
+                    .withSchema(SCHEMA)
+                    .setProperties(ImmutableMap.of("format-version", "2"))
+                    .stageCreate()
+                    .build())
+            .tableMetadata();
+    return new UpdateTableRequest(
+        ImmutableList.of(new UpdateRequirement.AssertTableDoesNotExist()), 
metadata.changes());
+  }
+
+  private void commit(MetadataUpdate... updates) {
+    CatalogHandlers.updateTable(catalog, IDENT, request(updates));
+  }
+
+  private void commitFails(Class<? extends RuntimeException> expected, 
MetadataUpdate... updates) {
+    Assertions.assertThrows(
+        expected, () -> CatalogHandlers.updateTable(catalog, IDENT, 
request(updates)));
+  }
+
+  private static UpdateTableRequest request(MetadataUpdate... updates) {
+    List<MetadataUpdate> list = ImmutableList.copyOf(updates);
+    return IcebergCommitValidation.withValidation(new 
UpdateTableRequest(ImmutableList.of(), list));
+  }
+
+  private static void append(Table table, String path) {
+    table
+        .newFastAppend()
+        .appendFile(
+            DataFiles.builder(PartitionSpec.unpartitioned())
+                .withPath(path)
+                .withFileSizeInBytes(10)
+                .withRecordCount(1)
+                .build())
+        .commit();
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index 6253f57edc..41973deebc 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -50,6 +50,7 @@ import org.apache.iceberg.io.SupportsStorageCredentials;
 import org.apache.iceberg.rest.RESTRequest;
 import org.apache.iceberg.rest.credentials.Credential;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -452,6 +453,22 @@ public class TestIcebergRESTUtils {
         
rewritten.credentials().get(0).config().get("client.refresh-credentials-endpoint"));
   }
 
+  @Test
+  void testCloneFallsBackToOriginalWhenIcebergCannotSerialize() throws 
Exception {
+    // A partition field without its optional field-id parses, but Iceberg 
cannot write it again.
+    UpdateTableRequest request =
+        IcebergObjectMapper.getInstance()
+            .readValue(
+                "{\"requirements\": [], \"updates\": [{\"action\": 
\"add-spec\", \"spec\":"
+                    + " {\"spec-id\": 1, \"fields\": [{\"name\": 
\"id_bucket\", \"transform\": \"bucket[4]\","
+                    + " \"source-id\": 1}]}}]}",
+                UpdateTableRequest.class);
+    UpdateTableRequest copy =
+        Assertions.assertDoesNotThrow(
+            () -> IcebergRESTUtils.cloneIcebergRESTObject(request, 
UpdateTableRequest.class));
+    Assertions.assertEquals(request.updates().size(), copy.updates().size());
+  }
+
   @Test
   void testValidateRequestRejectsMissingOrInvalidBody() {
     BadRequestException missing =
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
index 3a7b4ea66e..45f31c6713 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
@@ -50,6 +50,7 @@ import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.lock.TreeLock;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.BadRequestException;
 import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
 import org.apache.iceberg.rest.responses.CreateNamespaceResponse;
@@ -204,6 +205,50 @@ public class TestIcebergNamespaceHookDispatcher {
     verify(mockOwnerDispatcher, never()).setOwners(any(), any(), any(), any());
   }
 
+  @Test
+  public void testCreateNamespaceWithDottedLevelIsRejectedBeforeCreating() {
+    CreateNamespaceRequest request = mock(CreateNamespaceRequest.class);
+    when(request.namespace()).thenReturn(Namespace.of("gi_n2l.dot"));
+
+    Assertions.assertThrows(
+        BadRequestException.class, () -> 
hookDispatcher.createNamespace(mockContext, request));
+    verify(mockDispatcher, never()).createNamespace(any(), any());
+  }
+
+  @Test
+  public void testCreateNamespaceWithTooLongNameIsRejectedBeforeCreating() {
+    // Ten levels of 20 characters: each level is short, the full name is 209 
characters.
+    String[] levels = new String[10];
+    for (int i = 0; i < levels.length; i++) {
+      levels[i] = String.format("level_%014d", i);
+    }
+    CreateNamespaceRequest request = mock(CreateNamespaceRequest.class);
+    when(request.namespace()).thenReturn(Namespace.of(levels));
+
+    Assertions.assertThrows(
+        BadRequestException.class, () -> 
hookDispatcher.createNamespace(mockContext, request));
+    verify(mockDispatcher, never()).createNamespace(any(), any());
+  }
+
+  @Test
+  public void testCreateNamespaceIsNotDroppedWhenImportFails() {
+    Namespace namespace = Namespace.of("test_schema");
+    CreateNamespaceRequest request = mock(CreateNamespaceRequest.class);
+    when(request.namespace()).thenReturn(namespace);
+    when(mockDispatcher.createNamespace(mockContext, request))
+        .thenReturn(mock(CreateNamespaceResponse.class));
+    RuntimeException importFailure =
+        new RuntimeException("Failed to import schema entity to the store");
+    
when(mockInternalSchemaDispatcher.loadSchema(any())).thenThrow(importFailure);
+
+    RuntimeException thrown =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> 
hookDispatcher.createNamespace(mockContext, request));
+    Assertions.assertSame(importFailure, thrown);
+    verify(mockDispatcher, never()).dropNamespace(any(), any());
+    verify(mockInternalOwnerDispatcher, never()).setOwners(any(), any(), 
any(), any());
+  }
+
   @Test
   public void testRegisterTableThrowsWhenSetOwnerFails() {
     Namespace namespace = Namespace.of("test_schema");
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
index e0ad27dfc6..9b873c47ce 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
@@ -19,8 +19,10 @@
 package org.apache.gravitino.iceberg.service.dispatcher;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -48,6 +50,9 @@ import 
org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
 import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.gravitino.lock.LockManager;
+import org.apache.gravitino.lock.LockType;
+import org.apache.gravitino.lock.TreeLock;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.iceberg.MetadataUpdate;
@@ -66,6 +71,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 
 public class TestIcebergTableHookDispatcher {
 
@@ -76,6 +82,9 @@ public class TestIcebergTableHookDispatcher {
       new Schema(NestedField.required(1, "test_field", StringType.get()));
 
   private IcebergTableHookDispatcher hookDispatcher;
+  private LockManager mockLockManager;
+  private TreeLock mockTreeLock;
+  private LockManager previousLockManager;
   private IcebergTableOperationDispatcher mockDispatcher;
   private IcebergNamespaceOperationDispatcher mockNamespaceDispatcher;
   private EntityStore mockEntityStore;
@@ -129,6 +138,13 @@ public class TestIcebergTableHookDispatcher {
     FieldUtils.writeField(
         GravitinoEnv.getInstance(), "internalOwnerDispatcher", 
mockInternalOwnerDispatcher, true);
 
+    previousLockManager =
+        (LockManager) FieldUtils.readField(GravitinoEnv.getInstance(), 
"lockManager", true);
+    mockLockManager = mock(LockManager.class);
+    mockTreeLock = mock(TreeLock.class);
+    when(mockLockManager.createTreeLock(any())).thenReturn(mockTreeLock);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", 
mockLockManager, true);
+
     // Create mock IcebergRESTServerContext
     IcebergConfigProvider mockConfigProvider = 
mock(IcebergConfigProvider.class);
     when(mockConfigProvider.getMetalakeName()).thenReturn(TEST_METALAKE);
@@ -165,6 +181,8 @@ public class TestIcebergTableHookDispatcher {
         previousInternalOwnerDispatcher,
         true);
 
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", 
previousLockManager, true);
+
     // Reset IcebergRESTServerContext singleton
     Class<?> holderClass =
         Arrays.stream(IcebergRESTServerContext.class.getDeclaredClasses())
@@ -174,6 +192,47 @@ public class TestIcebergTableHookDispatcher {
     FieldUtils.writeStaticField(holderClass, "INSTANCE", null, true);
   }
 
+  @Test
+  public void testCreateTableHoldsTheNamespaceBranchLockForReading() {
+    // dropNamespace holds the same lock for writing, so a create and a drop 
of one namespace are
+    // serialized on this server.
+    Namespace namespace = Namespace.of("A", "B");
+    CreateTableRequest request =
+        
CreateTableRequest.builder().withName("t").withSchema(TABLE_SCHEMA).build();
+    when(mockDispatcher.createTable(mockContext, namespace, request))
+        .thenReturn(mock(LoadTableResponse.class));
+
+    hookDispatcher.createTable(mockContext, namespace, request);
+
+    ArgumentCaptor<NameIdentifier> lockId = 
ArgumentCaptor.forClass(NameIdentifier.class);
+    verify(mockLockManager).createTreeLock(lockId.capture());
+    Assertions.assertArrayEquals(
+        new String[] {TEST_METALAKE, TEST_CATALOG}, 
lockId.getValue().namespace().levels());
+    Assertions.assertEquals("A", lockId.getValue().name());
+    InOrder order = inOrder(mockTreeLock, mockDispatcher);
+    order.verify(mockTreeLock).lock(LockType.READ);
+    order.verify(mockDispatcher).createTable(mockContext, namespace, request);
+    order.verify(mockTreeLock).unlock();
+  }
+
+  @Test
+  void testStagedCreateCommitHoldsNamespaceBranchLock() {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("A", "B"), 
"staged");
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            List.of(new UpdateRequirement.AssertTableDoesNotExist()), 
Collections.emptyList());
+    when(mockDispatcher.updateTable(mockContext, table, request))
+        .thenReturn(mock(LoadTableResponse.class));
+
+    hookDispatcher.updateTable(mockContext, table, request);
+
+    verify(mockLockManager).createTreeLock(NameIdentifier.of(TEST_METALAKE, 
TEST_CATALOG, "A"));
+    InOrder order = inOrder(mockTreeLock, mockDispatcher);
+    order.verify(mockTreeLock).lock(LockType.READ);
+    order.verify(mockDispatcher).updateTable(mockContext, table, request);
+    order.verify(mockTreeLock).unlock();
+  }
+
   @Test
   public void testCreateTableCallsImportAndOwnership() {
     Namespace namespace = Namespace.of("test_schema");
@@ -203,6 +262,47 @@ public class TestIcebergTableHookDispatcher {
     Assertions.assertEquals(TEST_USER, userCaptor.getValue());
   }
 
+  @Test
+  public void testCreateTableIsNotDroppedWhenImportFails() {
+    Namespace namespace = Namespace.of("test_schema");
+    CreateTableRequest request =
+        
CreateTableRequest.builder().withName("test_table").withSchema(TABLE_SCHEMA).build();
+    when(mockDispatcher.createTable(mockContext, namespace, request))
+        .thenReturn(mock(LoadTableResponse.class));
+    RuntimeException importFailure = new RuntimeException("Failed to import 
table entity");
+    
when(mockInternalTableDispatcher.loadTable(any())).thenThrow(importFailure);
+
+    RuntimeException thrown =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () -> hookDispatcher.createTable(mockContext, namespace, request));
+    Assertions.assertSame(importFailure, thrown);
+
+    verify(mockDispatcher, never()).dropTable(any(), any(), anyBoolean());
+    verify(mockInternalOwnerDispatcher, never()).setOwner(any(), any(), any(), 
any());
+  }
+
+  @Test
+  public void testStagedCreateCommitIsNotDroppedWhenImportFails() {
+    TableIdentifier tableId = TableIdentifier.of("test_schema", 
"staged_table");
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            Collections.singletonList(new 
UpdateRequirement.AssertTableDoesNotExist()),
+            Collections.emptyList());
+    when(mockDispatcher.updateTable(mockContext, tableId, request))
+        .thenReturn(mock(LoadTableResponse.class));
+    RuntimeException importFailure = new RuntimeException("Failed to import 
table entity");
+    
when(mockInternalTableDispatcher.loadTable(any())).thenThrow(importFailure);
+
+    RuntimeException thrown =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () -> hookDispatcher.updateTable(mockContext, tableId, request));
+    Assertions.assertSame(importFailure, thrown);
+    verify(mockDispatcher, never()).dropTable(any(), any(), anyBoolean());
+    verify(mockInternalOwnerDispatcher, never()).setOwner(any(), any(), any(), 
any());
+  }
+
   @Test
   public void testDropTableDeletesEntity() throws IOException {
     TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
index 156539ffa0..dc4c890ae3 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
@@ -323,10 +323,23 @@ public class TestIcebergNamespaceOperations extends 
IcebergNamespaceTestBase {
     doCreateNamespace(Namespace.of("page_ns2"));
     doCreateNamespace(Namespace.of("page_ns3"));
 
-    // First page: pageSize=2, no pageToken
-    Response firstPageResponse =
+    // pageSize alone must not truncate the list.
+    try (Response response =
         getNamespaceClientBuilder(
                 Optional.empty(), Optional.empty(), 
Optional.of(ImmutableMap.of("pageSize", "2")))
+            .get()) {
+      Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+      ListNamespacesResponse all = 
response.readEntity(ListNamespacesResponse.class);
+      Assertions.assertEquals(3, all.namespaces().size());
+      Assertions.assertNull(all.nextPageToken());
+    }
+
+    // First page: an empty pageToken starts paging
+    Response firstPageResponse =
+        getNamespaceClientBuilder(
+                Optional.empty(),
+                Optional.empty(),
+                Optional.of(ImmutableMap.of("pageToken", "", "pageSize", "2")))
             .get();
     Assertions.assertEquals(Status.OK.getStatusCode(), 
firstPageResponse.getStatus());
     ListNamespacesResponse firstPage = 
firstPageResponse.readEntity(ListNamespacesResponse.class);
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergPaginationHelper.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergPaginationHelper.java
index 63804c9752..5558d98d4a 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergPaginationHelper.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergPaginationHelper.java
@@ -50,7 +50,7 @@ class TestIcebergPaginationHelper {
             .add(Namespace.of("ns3"))
             .build();
     ListNamespacesResponse result =
-        IcebergPaginationHelper.paginateNamespaces(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateNamespaces(response, Optional.of(""), 
Optional.of(2));
     Assertions.assertEquals(2, result.namespaces().size());
     Assertions.assertNotNull(result.nextPageToken());
   }
@@ -65,7 +65,7 @@ class TestIcebergPaginationHelper {
             .build();
     // First page
     ListNamespacesResponse firstPage =
-        IcebergPaginationHelper.paginateNamespaces(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateNamespaces(response, Optional.of(""), 
Optional.of(2));
     // Second page using cursor from first page
     ListNamespacesResponse secondPage =
         IcebergPaginationHelper.paginateNamespaces(
@@ -84,7 +84,7 @@ class TestIcebergPaginationHelper {
             .add(Namespace.of("ns2"))
             .build();
     ListNamespacesResponse result =
-        IcebergPaginationHelper.paginateNamespaces(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateNamespaces(response, Optional.of(""), 
Optional.of(2));
     List<String> names =
         
result.namespaces().stream().map(Namespace::toString).collect(Collectors.toList());
     Assertions.assertEquals(Arrays.asList("ns1", "ns2"), names);
@@ -125,7 +125,7 @@ class TestIcebergPaginationHelper {
     Assertions.assertThrows(
         IllegalArgumentException.class,
         () ->
-            IcebergPaginationHelper.paginateNamespaces(response, 
Optional.empty(), Optional.of(0)));
+            IcebergPaginationHelper.paginateNamespaces(response, 
Optional.of(""), Optional.of(0)));
   }
 
   @Test
@@ -135,15 +135,14 @@ class TestIcebergPaginationHelper {
     Assertions.assertThrows(
         IllegalArgumentException.class,
         () ->
-            IcebergPaginationHelper.paginateNamespaces(
-                response, Optional.empty(), Optional.of(-1)));
+            IcebergPaginationHelper.paginateNamespaces(response, 
Optional.of(""), Optional.of(-1)));
   }
 
   @Test
   void testPaginateNamespacesEmptyList() {
     ListNamespacesResponse response = ListNamespacesResponse.builder().build();
     ListNamespacesResponse result =
-        IcebergPaginationHelper.paginateNamespaces(response, Optional.empty(), 
Optional.of(10));
+        IcebergPaginationHelper.paginateNamespaces(response, Optional.of(""), 
Optional.of(10));
     Assertions.assertEquals(0, result.namespaces().size());
     Assertions.assertNull(result.nextPageToken());
   }
@@ -158,7 +157,7 @@ class TestIcebergPaginationHelper {
             .add(TableIdentifier.of(ns, "t3"))
             .build();
     ListTablesResponse result =
-        IcebergPaginationHelper.paginateTables(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateTables(response, Optional.of(""), 
Optional.of(2));
     Assertions.assertEquals(2, result.identifiers().size());
     Assertions.assertNotNull(result.nextPageToken());
   }
@@ -177,7 +176,7 @@ class TestIcebergPaginationHelper {
 
     // Walk all pages with pageSize=2
     ListTablesResponse page1 =
-        IcebergPaginationHelper.paginateTables(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateTables(response, Optional.of(""), 
Optional.of(2));
     Assertions.assertEquals(2, page1.identifiers().size());
     Assertions.assertNotNull(page1.nextPageToken());
 
@@ -209,8 +208,70 @@ class TestIcebergPaginationHelper {
             .build();
     // pageSize equals total items
     ListTablesResponse result =
-        IcebergPaginationHelper.paginateTables(response, Optional.empty(), 
Optional.of(2));
+        IcebergPaginationHelper.paginateTables(response, Optional.of(""), 
Optional.of(2));
     Assertions.assertEquals(2, result.identifiers().size());
     Assertions.assertNull(result.nextPageToken());
   }
+
+  @Test
+  void testPageSizeWithoutPageTokenReturnsAllResults() {
+    // The REST spec: without a pageToken parameter the server returns every 
result and no
+    // next-page token, whatever pageSize says.
+    ListNamespacesResponse namespaces =
+        ListNamespacesResponse.builder()
+            .addAll(Arrays.asList(Namespace.of("c"), Namespace.of("a"), 
Namespace.of("b")))
+            .build();
+    ListNamespacesResponse all =
+        IcebergPaginationHelper.paginateNamespaces(namespaces, 
Optional.empty(), Optional.of(1));
+    Assertions.assertEquals(3, all.namespaces().size());
+    Assertions.assertNull(all.nextPageToken());
+
+    ListTablesResponse tables =
+        ListTablesResponse.builder()
+            .add(TableIdentifier.of("db", "t1"))
+            .add(TableIdentifier.of("db", "t2"))
+            .build();
+    ListTablesResponse allTables =
+        IcebergPaginationHelper.paginateTables(tables, Optional.empty(), 
Optional.of(1));
+    Assertions.assertEquals(2, allTables.identifiers().size());
+    Assertions.assertNull(allTables.nextPageToken());
+
+    // An empty pageToken starts paging.
+    ListNamespacesResponse firstPage =
+        IcebergPaginationHelper.paginateNamespaces(namespaces, 
Optional.of(""), Optional.of(1));
+    Assertions.assertEquals(1, firstPage.namespaces().size());
+    Assertions.assertEquals("a", firstPage.nextPageToken());
+  }
+
+  @Test
+  void testEmptyPageTokenWithoutPageSizeReturnsAllUnsorted() {
+    ListNamespacesResponse namespaces =
+        ListNamespacesResponse.builder()
+            .addAll(Arrays.asList(Namespace.of("c"), Namespace.of("a"), 
Namespace.of("b")))
+            .build();
+    ListNamespacesResponse all =
+        IcebergPaginationHelper.paginateNamespaces(namespaces, 
Optional.of(""), Optional.empty());
+    Assertions.assertEquals(
+        Arrays.asList(Namespace.of("c"), Namespace.of("a"), 
Namespace.of("b")), all.namespaces());
+    Assertions.assertNull(all.nextPageToken());
+  }
+
+  @Test
+  void testInvalidPageSizeWithoutPageTokenIsRejected() {
+    ListNamespacesResponse namespaces =
+        ListNamespacesResponse.builder().add(Namespace.of("db")).build();
+    ListTablesResponse tables =
+        ListTablesResponse.builder().add(TableIdentifier.of("db", 
"table")).build();
+    for (int size : new int[] {0, -1}) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              IcebergPaginationHelper.paginateNamespaces(
+                  namespaces, Optional.empty(), Optional.of(size)));
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              IcebergPaginationHelper.paginateTables(tables, Optional.empty(), 
Optional.of(size)));
+    }
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
index 5226ee10ef..8800da59d2 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
@@ -336,11 +336,22 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
 
     dummyEventListener.clearEvent();
 
-    // First page: pageSize=2
     String tablePath =
         IcebergRestTestUtil.NAMESPACE_PATH + "/" + 
RESTUtil.encodeNamespace(namespace) + "/tables";
+    // pageSize alone must not truncate the list.
+    try (Response response =
+        getIcebergClientBuilder(tablePath, 
Optional.of(ImmutableMap.of("pageSize", "2"))).get()) {
+      Assertions.assertEquals(Status.OK.getStatusCode(), response.getStatus());
+      ListTablesResponse all = response.readEntity(ListTablesResponse.class);
+      Assertions.assertEquals(3, all.identifiers().size());
+      Assertions.assertNull(all.nextPageToken());
+    }
+
+    // An empty pageToken starts paging.
     Response firstPageResponse =
-        getIcebergClientBuilder(tablePath, 
Optional.of(ImmutableMap.of("pageSize", "2"))).get();
+        getIcebergClientBuilder(
+                tablePath, Optional.of(ImmutableMap.of("pageToken", "", 
"pageSize", "2")))
+            .get();
     Assertions.assertEquals(Status.OK.getStatusCode(), 
firstPageResponse.getStatus());
     ListTablesResponse firstPage = 
firstPageResponse.readEntity(ListTablesResponse.class);
     Assertions.assertEquals(2, firstPage.identifiers().size());
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergViewOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergViewOperations.java
index 4cb65b64ce..a1f2994009 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergViewOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergViewOperations.java
@@ -147,11 +147,22 @@ public class TestIcebergViewOperations extends 
IcebergNamespaceTestBase {
 
     dummyEventListener.clearEvent();
 
-    // First page: pageSize=2
     String viewPath =
         IcebergRestTestUtil.NAMESPACE_PATH + "/" + 
RESTUtil.encodeNamespace(namespace) + "/views";
+    // pageSize alone must not truncate the list.
+    try (Response response =
+        getIcebergClientBuilder(viewPath, 
Optional.of(ImmutableMap.of("pageSize", "2"))).get()) {
+      Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+      ListTablesResponse all = response.readEntity(ListTablesResponse.class);
+      Assertions.assertEquals(3, all.identifiers().size());
+      Assertions.assertNull(all.nextPageToken());
+    }
+
+    // An empty pageToken starts paging.
     Response firstPageResponse =
-        getIcebergClientBuilder(viewPath, 
Optional.of(ImmutableMap.of("pageSize", "2"))).get();
+        getIcebergClientBuilder(
+                viewPath, Optional.of(ImmutableMap.of("pageToken", "", 
"pageSize", "2")))
+            .get();
     Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
firstPageResponse.getStatus());
     ListTablesResponse firstPage = 
firstPageResponse.readEntity(ListTablesResponse.class);
     Assertions.assertEquals(2, firstPage.identifiers().size());

Reply via email to