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

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


The following commit(s) were added to refs/heads/master by this push:
     new 22daae7378 [iceberg] Refuse nanosecond timestamps while Iceberg 
metadata is enabled (#9455)
22daae7378 is described below

commit 22daae737895063be2ec50538f9cf725876dcdf6
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Aug 29 20:54:37 2026 +0800

    [iceberg] Refuse nanosecond timestamps while Iceberg metadata is enabled 
(#9455)
---
 docs/docs/iceberg/index.md                         |   8 +-
 .../java/org/apache/paimon/AbstractFileStore.java  |   5 +
 .../paimon/iceberg/IcebergCommitCallback.java      |   4 +-
 .../paimon/iceberg/IcebergPreCommitValidation.java |  70 +++++++++
 .../paimon/iceberg/metadata/IcebergDataField.java  |  15 +-
 .../org/apache/paimon/schema/SchemaManager.java    |  17 +--
 .../org/apache/paimon/schema/SchemaValidation.java |  59 ++++++++
 .../paimon/table/AbstractFileStoreTable.java       |  10 ++
 .../paimon/iceberg/IcebergCompatibilityTest.java   | 156 +++++++++++++++++++++
 .../iceberg/metadata/IcebergDataFieldTest.java     |  69 ++++-----
 .../apache/paimon/schema/SchemaManagerTest.java    | 102 ++++++++++++++
 11 files changed, 445 insertions(+), 70 deletions(-)

diff --git a/docs/docs/iceberg/index.md b/docs/docs/iceberg/index.md
index f2878bcd68..a0bc183b73 100644
--- a/docs/docs/iceberg/index.md
+++ b/docs/docs/iceberg/index.md
@@ -96,8 +96,8 @@ Paimon Iceberg compatibility currently supports the following 
data types.
 | `DATE`         | `date`            |
 | `TIMESTAMP` (precision 3-6)   | `timestamp`       |
 | `TIMESTAMP_LTZ` (precision 3-6) | `timestamptz`     |
-| `TIMESTAMP` (precision 7-9)  | `timestamp_ns`    |
-| `TIMESTAMP_LTZ` (precision 7-9) | `timestamptz_ns`  |
+| `TIMESTAMP` (other precisions)  | not supported     |
+| `TIMESTAMP_LTZ` (other precisions) | not supported  |
 | `GEOMETRY(crs)` | `geometry(crs)` |
 | `GEOGRAPHY(crs, algorithm)` | `geography(crs, algorithm)` |
 | `ARRAY`        | `list`            |
@@ -108,7 +108,9 @@ Paimon Iceberg compatibility currently supports the 
following data types.
 
 **Note on Timestamp Types:**
 - `TIMESTAMP` and `TIMESTAMP_LTZ` types with precision from 3 to 6 are mapped 
to standard Iceberg timestamp types
-- `TIMESTAMP` and `TIMESTAMP_LTZ` types with precision from 7 to 9 use 
nanosecond precision and require Iceberg v3 format
+- Any other precision is rejected while Iceberg metadata is enabled. A 
precision above 6 is written
+  as Parquet INT96, which Iceberg reads as a microsecond zoned timestamp 
rather than the
+  nanoseconds the column declares. Use a precision from 3 to 6.
 
 **Note on Geospatial Types:**
 - `GEOMETRY` and `GEOGRAPHY` values use OGC Well-Known Binary (WKB). The 
default CRS is `OGC:CRS84`, and the default geography edge algorithm is 
`spherical`.
diff --git a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
index 25c64029ad..b88798b924 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
@@ -29,6 +29,7 @@ import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.iceberg.IcebergCommitCallback;
 import org.apache.paimon.iceberg.IcebergOptions;
+import org.apache.paimon.iceberg.IcebergPreCommitValidation;
 import org.apache.paimon.index.IndexFileHandler;
 import org.apache.paimon.manifest.IndexManifestFile;
 import org.apache.paimon.manifest.ManifestFile;
@@ -397,6 +398,10 @@ abstract class AbstractFileStore<T> implements 
FileStore<T> {
         if (options.isChainTable()) {
             callbacks.add(new ChainTableCommitPreCallback(table));
         }
+        if 
(options.toConfiguration().get(IcebergOptions.METADATA_ICEBERG_STORAGE)
+                != IcebergOptions.StorageType.DISABLED) {
+            callbacks.add(new IcebergPreCommitValidation(table));
+        }
         return callbacks;
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
index bb44905625..d4c25a734e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
@@ -2137,7 +2137,7 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
 
     private class SchemaCache {
 
-        SchemaManager schemaManager = new SchemaManager(table.fileIO(), 
table.location());
+        SchemaManager schemaManager = table.schemaManager();
         Map<Long, IcebergSchema> schemas = new HashMap<>();
 
         private IcebergSchema get(long schemaId) {
@@ -2149,6 +2149,8 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                         checkVariantNotPublishable(schema.logicalRowType());
                         SchemaValidation.validateIcebergGeospatialTypes(
                                 schema.logicalRowType(), table.coreOptions());
+                        SchemaValidation.validateIcebergTimestampPrecisions(
+                                schema.logicalRowType(), table.coreOptions());
                         return IcebergSchema.create(schema);
                     });
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergPreCommitValidation.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergPreCommitValidation.java
new file mode 100644
index 0000000000..956134b409
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergPreCommitValidation.java
@@ -0,0 +1,70 @@
+/*
+ * 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.paimon.iceberg;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.SimpleFileEntry;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.SchemaValidation;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitPreCallback;
+
+import java.util.List;
+
+/**
+ * Vetoes a commit whose schemas the Iceberg mirror cannot publish, before the 
snapshot becomes
+ * visible. The mirror emits every schema from 0 to the latest, so a table 
that was already enabled
+ * when an unsupported type entered its history keeps failing on the metadata 
it produces rather
+ * than on the commit that produced it.
+ *
+ * <p>Memoized on the latest schema, so steady-state commits only read the 
latest schema file. The
+ * comparison is by content: a rollback lets a later alteration reuse an id.
+ */
+public class IcebergPreCommitValidation implements CommitPreCallback {
+
+    private final FileStoreTable table;
+
+    private TableSchema validatedSchema;
+
+    public IcebergPreCommitValidation(FileStoreTable table) {
+        this.table = table;
+    }
+
+    @Override
+    public void call(
+            List<SimpleFileEntry> baseFiles,
+            List<ManifestEntry> deltaFiles,
+            List<IndexManifestEntry> indexFiles,
+            Snapshot snapshot) {
+        SchemaManager schemaManager = table.schemaManager();
+        TableSchema latest = schemaManager.latest().get();
+        if (latest.equals(validatedSchema)) {
+            return;
+        }
+        SchemaValidation.validateHistoricalIcebergTypes(
+                schemaManager::listAll, table.coreOptions());
+        validatedSchema = latest;
+    }
+
+    @Override
+    public void close() {}
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/metadata/IcebergDataField.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/metadata/IcebergDataField.java
index d69fef3fb3..890954aea3 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/metadata/IcebergDataField.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/metadata/IcebergDataField.java
@@ -187,16 +187,19 @@ public class IcebergDataField {
                         "decimal(%d, %d)", decimalType.getPrecision(), 
decimalType.getScale());
             case TIMESTAMP_WITHOUT_TIME_ZONE:
                 int timestampPrecision = ((TimestampType) 
dataType).getPrecision();
+                // Paimon writes these as Parquet INT96, which Iceberg reads 
as microseconds
                 Preconditions.checkArgument(
-                        timestampPrecision >= 3 && timestampPrecision <= 9,
-                        "Paimon Iceberg compatibility only support timestamp 
type with precision from 3 to 9.");
-                return timestampPrecision >= 7 ? "timestamp_ns" : "timestamp";
+                        timestampPrecision >= 3 && timestampPrecision <= 6,
+                        "Paimon Iceberg compatibility only supports timestamp 
types with a "
+                                + "precision from 3 to 6.");
+                return "timestamp";
             case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
                 int timestampLtzPrecision = ((LocalZonedTimestampType) 
dataType).getPrecision();
                 Preconditions.checkArgument(
-                        timestampLtzPrecision >= 3 && timestampLtzPrecision <= 
9,
-                        "Paimon Iceberg compatibility only support timestamp 
type with precision from 3 to 9.");
-                return timestampLtzPrecision >= 7 ? "timestamptz_ns" : 
"timestamptz";
+                        timestampLtzPrecision >= 3 && timestampLtzPrecision <= 
6,
+                        "Paimon Iceberg compatibility only supports timestamp 
types with a "
+                                + "precision from 3 to 6.");
+                return "timestamptz";
             case VARIANT:
                 return "variant";
             case GEOMETRY:
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
index d730f332ae..1a76668448 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
@@ -25,7 +25,6 @@ import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
-import org.apache.paimon.iceberg.IcebergOptions;
 import org.apache.paimon.schema.ColumnDirectiveUtils.ConvertedColumn;
 import org.apache.paimon.schema.SchemaChange.AddColumn;
 import org.apache.paimon.schema.SchemaChange.DropColumn;
@@ -1217,25 +1216,13 @@ public class SchemaManager implements Serializable {
     @VisibleForTesting
     public boolean commit(TableSchema newSchema) throws Exception {
         SchemaValidation.validateTableSchema(newSchema);
-        validateHistoricalIcebergGeospatialTypes(newSchema);
+        SchemaValidation.validateHistoricalIcebergTypes(
+                this::listAll, new CoreOptions(newSchema.options()));
         SchemaValidation.validateFallbackBranch(this, newSchema);
         Path schemaPath = toSchemaPath(newSchema.id());
         return fileIO.tryToWriteAtomic(schemaPath, newSchema.toString());
     }
 
-    private void validateHistoricalIcebergGeospatialTypes(TableSchema 
newSchema) {
-        CoreOptions options = new CoreOptions(newSchema.options());
-        IcebergOptions.StorageType storage =
-                
options.toConfiguration().get(IcebergOptions.METADATA_ICEBERG_STORAGE);
-        if (storage == IcebergOptions.StorageType.DISABLED) {
-            return;
-        }
-
-        for (TableSchema schema : listAll()) {
-            
SchemaValidation.validateIcebergGeospatialTypes(schema.logicalRowType(), 
options);
-        }
-    }
-
     /** Read schema for schema id. */
     public TableSchema schema(long id) {
         return fromPath(fileIO, toSchemaPath(id));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index cc0ee88ad7..f90f5a84c9 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -70,6 +70,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 import static org.apache.paimon.CoreOptions.BUCKET_KEY;
@@ -123,6 +124,11 @@ import static 
org.apache.paimon.utils.Preconditions.checkState;
 /** Validation utilities for {@link TableSchema}. */
 public class SchemaValidation {
 
+    /** The precisions {@code IcebergDataField} maps to the Iceberg timestamp 
types. */
+    private static final int MIN_ICEBERG_TIMESTAMP_PRECISION = 3;
+
+    private static final int MAX_ICEBERG_TIMESTAMP_PRECISION = 6;
+
     public static final List<Class<? extends DataType>> 
PRIMARY_KEY_UNSUPPORTED_LOGICAL_TYPES =
             Arrays.asList(
                     MapType.class,
@@ -234,6 +240,7 @@ public class SchemaValidation {
                 FileFormat.fromIdentifier(options.formatType(), new 
Options(schema.options()));
         RowType tableRowType = new RowType(schema.fields());
         validateGeospatialTypes(schema, options, tableRowType);
+        validateIcebergTimestampPrecisions(tableRowType, options);
         validateBlobFields(tableRowType, options);
         Set<String> blobDescriptorFields = 
validateBlobDescriptorFields(tableRowType, options);
         Set<String> blobViewFields =
@@ -531,6 +538,58 @@ public class SchemaValidation {
                 geospatialSequenceFields);
     }
 
+    /**
+     * Refuses the timestamp precisions the Iceberg mirror cannot publish, 
matching the range {@link
+     * org.apache.paimon.iceberg.metadata.IcebergDataField} converts. A higher 
precision is written
+     * as Parquet INT96, which Iceberg reads as a microsecond zoned timestamp 
rather than the
+     * nanoseconds the column declares, so the two disagree about the data.
+     */
+    public static void validateIcebergTimestampPrecisions(DataType dataType, 
CoreOptions options) {
+        if 
(options.toConfiguration().get(IcebergOptions.METADATA_ICEBERG_STORAGE)
+                == IcebergOptions.StorageType.DISABLED) {
+            return;
+        }
+        checkArgument(
+                !containsType(dataType, 
SchemaValidation::isUnpublishableTimestamp),
+                "Timestamp columns must have a precision from %s to %s when 
Iceberg metadata is "
+                        + "enabled, the only precisions Iceberg compatibility 
can publish. Use a "
+                        + "precision from %s to %s, or disable '%s'.",
+                MIN_ICEBERG_TIMESTAMP_PRECISION,
+                MAX_ICEBERG_TIMESTAMP_PRECISION,
+                MIN_ICEBERG_TIMESTAMP_PRECISION,
+                MAX_ICEBERG_TIMESTAMP_PRECISION,
+                IcebergOptions.METADATA_ICEBERG_STORAGE.key());
+    }
+
+    private static boolean isUnpublishableTimestamp(DataType dataType) {
+        if (dataType instanceof TimestampType) {
+            return isUnpublishablePrecision(((TimestampType) 
dataType).getPrecision());
+        }
+        return dataType instanceof LocalZonedTimestampType
+                && isUnpublishablePrecision(((LocalZonedTimestampType) 
dataType).getPrecision());
+    }
+
+    private static boolean isUnpublishablePrecision(int precision) {
+        return precision < MIN_ICEBERG_TIMESTAMP_PRECISION
+                || precision > MAX_ICEBERG_TIMESTAMP_PRECISION;
+    }
+
+    /**
+     * The mirror emits historical schemas too, so enabling it has to judge 
all of them. The history
+     * is read lazily, so a disabled mirror costs no listing.
+     */
+    public static void validateHistoricalIcebergTypes(
+            Supplier<List<TableSchema>> history, CoreOptions options) {
+        if 
(options.toConfiguration().get(IcebergOptions.METADATA_ICEBERG_STORAGE)
+                == IcebergOptions.StorageType.DISABLED) {
+            return;
+        }
+        for (TableSchema schema : history.get()) {
+            validateIcebergGeospatialTypes(schema.logicalRowType(), options);
+            validateIcebergTimestampPrecisions(schema.logicalRowType(), 
options);
+        }
+    }
+
     /** Validate geospatial types in a schema that will be published as 
Iceberg metadata. */
     public static void validateIcebergGeospatialTypes(DataType dataType, 
CoreOptions options) {
         boolean hasGeospatial =
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
index 97058f2b8e..ef21981661 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
@@ -25,6 +25,7 @@ import org.apache.paimon.consumer.ConsumerManager;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.iceberg.IcebergCommitCallback;
+import org.apache.paimon.iceberg.IcebergOptions;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.manifest.ManifestFileMeta;
@@ -367,6 +368,15 @@ abstract class AbstractFileStoreTable implements 
FileStoreTable {
 
         // validate schema with new options
         SchemaValidation.validateTableSchema(newTableSchema, 
dynamicOptions.keySet());
+        if (new CoreOptions(tableSchema.options())
+                        .toConfiguration()
+                        .get(IcebergOptions.METADATA_ICEBERG_STORAGE)
+                == IcebergOptions.StorageType.DISABLED) {
+            // turning the mirror on here publishes the schemas already on 
disk, which no commit
+            // has judged under these options
+            SchemaValidation.validateHistoricalIcebergTypes(
+                    () -> schemaManager().listAll(), new 
CoreOptions(newTableSchema.options()));
+        }
 
         return copy(newTableSchema);
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
index ca90e7a608..acaf154edc 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
@@ -38,6 +38,7 @@ import 
org.apache.paimon.iceberg.manifest.IcebergManifestEntry;
 import org.apache.paimon.iceberg.manifest.IcebergManifestFile;
 import org.apache.paimon.iceberg.manifest.IcebergManifestFileMeta;
 import org.apache.paimon.iceberg.manifest.IcebergManifestList;
+import org.apache.paimon.iceberg.metadata.IcebergDataField;
 import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.iceberg.metadata.IcebergRef;
 import org.apache.paimon.iceberg.metadata.IcebergSchema;
@@ -49,7 +50,9 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.sink.TableCommitImpl;
 import org.apache.paimon.table.sink.TableWriteImpl;
@@ -78,6 +81,8 @@ import org.apache.iceberg.hadoop.HadoopCatalog;
 import org.apache.iceberg.io.CloseableIterable;
 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 java.io.File;
 import java.math.BigDecimal;
@@ -1650,6 +1655,157 @@ public class IcebergCompatibilityTest {
         commit.close();
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = {2, 9})
+    public void 
testDynamicallyEnablingIcebergRefusesHistoricalTimestampPrecisions(int 
precision)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path path = new Path(tempDir.toString());
+        Options options = new Options();
+        options.set(CoreOptions.BUCKET, 1);
+        options.set(CoreOptions.FILE_FORMAT, "parquet");
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), 
DataTypes.TIMESTAMP(precision)},
+                        new String[] {"k", "ts"});
+        Schema schema =
+                new Schema(
+                        rowType.getFields(),
+                        Collections.emptyList(),
+                        Collections.singletonList("k"),
+                        options.toMap(),
+                        "");
+
+        FileStoreTable table;
+        Identifier identifier = Identifier.create("mydb", "t");
+        try (FileSystemCatalog paimonCatalog = new FileSystemCatalog(fileIO, 
path)) {
+            paimonCatalog.createDatabase("mydb", false);
+            paimonCatalog.createTable(identifier, schema, false);
+            table = (FileStoreTable) paimonCatalog.getTable(identifier);
+
+            String commitUser = UUID.randomUUID().toString();
+            try (TableWriteImpl<?> write = table.newWrite(commitUser);
+                    TableCommitImpl commit = table.newCommit(commitUser)) {
+                write.write(GenericRow.of(1, Timestamp.fromEpochMillis(0)));
+                commit.commit(1, write.prepareCommit(false, 1));
+            }
+
+            paimonCatalog.alterTable(identifier, 
SchemaChange.dropColumn("ts"), false);
+            table = (FileStoreTable) paimonCatalog.getTable(identifier);
+        }
+
+        FileStoreTable currentTable = table;
+        assertThatThrownBy(
+                        () ->
+                                currentTable.copy(
+                                        Collections.singletonMap(
+                                                
IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
+                                                
IcebergOptions.StorageType.TABLE_LOCATION
+                                                        .toString())))
+                .hasMessageContaining("precision from 3 to 6");
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {2, 9})
+    public void 
testExistingTableWithUnpublishableHistoricalTimestampsRefusesToCommit(int 
precision)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path warehouse = new Path(tempDir.toString());
+        Options options = new Options();
+        options.set(CoreOptions.BUCKET, 1);
+        options.set(CoreOptions.FILE_FORMAT, "parquet");
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), 
DataTypes.TIMESTAMP(precision)},
+                        new String[] {"k", "ts"});
+        Schema schema =
+                new Schema(
+                        rowType.getFields(),
+                        Collections.emptyList(),
+                        Collections.singletonList("k"),
+                        options.toMap(),
+                        "");
+
+        Identifier identifier = Identifier.create("mydb", "t");
+        try (FileSystemCatalog paimonCatalog = new FileSystemCatalog(fileIO, 
warehouse)) {
+            paimonCatalog.createDatabase("mydb", false);
+            paimonCatalog.createTable(identifier, schema, false);
+            FileStoreTable table = (FileStoreTable) 
paimonCatalog.getTable(identifier);
+
+            String commitUser = UUID.randomUUID().toString();
+            try (TableWriteImpl<?> write = table.newWrite(commitUser);
+                    TableCommitImpl commit = table.newCommit(commitUser)) {
+                write.write(GenericRow.of(1, Timestamp.fromEpochMillis(0)));
+                commit.commit(1, write.prepareCommit(false, 1));
+            }
+
+            paimonCatalog.alterTable(identifier, 
SchemaChange.dropColumn("ts"), false);
+        }
+
+        Path tablePath = new Path(warehouse, "mydb.db/t");
+        TableSchema latest = new SchemaManager(fileIO, 
tablePath).latest().get();
+        Map<String, String> upgraded = new HashMap<>(latest.options());
+        upgraded.put(
+                IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
+                IcebergOptions.StorageType.TABLE_LOCATION.toString());
+        FileStoreTable table =
+                FileStoreTableFactory.create(fileIO, tablePath, 
latest.copy(upgraded));
+
+        String commitUser = UUID.randomUUID().toString();
+        assertThatThrownBy(
+                        () -> {
+                            try (TableWriteImpl<?> write = 
table.newWrite(commitUser);
+                                    TableCommitImpl commit = 
table.newCommit(commitUser)) {
+                                write.write(GenericRow.of(2));
+                                commit.commit(2, write.prepareCommit(false, 
2));
+                            }
+                        })
+                .hasMessageContaining("precision from 3 to 6");
+        assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(1L);
+        assertThat(fileIO.exists(new Path(tablePath, "metadata"))).isFalse();
+    }
+
+    @Test
+    public void testCommitOnBranchMirrorsTheBranchSchema() throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType, Collections.emptyList(), 
Collections.singletonList("k"), 1);
+
+        String commitUser = UUID.randomUUID().toString();
+        try (TableWriteImpl<?> write = table.newWrite(commitUser);
+                TableCommitImpl commit = table.newCommit(commitUser)) {
+            write.write(GenericRow.of(1, 10));
+            commit.commit(1, write.prepareCommit(false, 1));
+        }
+
+        table.branchManager().createBranch("b1");
+        new SchemaManager(table.fileIO(), table.location(), "b1")
+                .commitChanges(SchemaChange.addColumn("branch_only", 
DataTypes.INT()));
+
+        FileStoreTable branchTable = table.switchToBranch("b1");
+        try (TableWriteImpl<?> write = branchTable.newWrite(commitUser);
+                TableCommitImpl commit = branchTable.newCommit(commitUser)) {
+            write.write(GenericRow.of(2, 20, 200));
+            commit.commit(2, write.prepareCommit(false, 2));
+        }
+
+        IcebergMetadata metadata =
+                IcebergMetadata.fromPath(
+                        branchTable.fileIO(),
+                        new Path(
+                                branchTable.location(),
+                                "metadata/v"
+                                        + 
branchTable.snapshotManager().latestSnapshotId()
+                                        + ".metadata.json"));
+        assertThat(
+                        
metadata.schemas().get(metadata.currentSchemaId()).fields().stream()
+                                .map(IcebergDataField::name))
+                .containsExactly("k", "v", "branch_only");
+    }
+
     /*
     Create snapshots
     Create tags
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/iceberg/metadata/IcebergDataFieldTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/iceberg/metadata/IcebergDataFieldTest.java
index f1daf206f4..eafa732d20 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/iceberg/metadata/IcebergDataFieldTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/iceberg/metadata/IcebergDataFieldTest.java
@@ -226,38 +226,20 @@ class IcebergDataFieldTest {
         IcebergDataField icebergTimestampLtz = new 
IcebergDataField(timestampLtzField);
         assertThat(icebergTimestampLtz.type()).isEqualTo("timestamptz");
 
-        // Test timestamp_ns (precision 7)
-        DataField timestampNs7Field = new DataField(3, "timestamp_ns", new 
TimestampType(false, 7));
-        IcebergDataField icebergTimestampNs7 = new 
IcebergDataField(timestampNs7Field);
-        assertThat(icebergTimestampNs7.type()).isEqualTo("timestamp_ns");
-
-        // Test timestamp_ns (precision 8)
-        DataField timestampNs8Field = new DataField(4, "timestamp_ns", new 
TimestampType(false, 8));
-        IcebergDataField icebergTimestampNs8 = new 
IcebergDataField(timestampNs8Field);
-        assertThat(icebergTimestampNs8.type()).isEqualTo("timestamp_ns");
-
-        // Test timestamp_ns (precision 9)
-        DataField timestampNs9Field = new DataField(5, "timestamp_ns", new 
TimestampType(false, 9));
-        IcebergDataField icebergTimestampNs9 = new 
IcebergDataField(timestampNs9Field);
-        assertThat(icebergTimestampNs9.type()).isEqualTo("timestamp_ns");
-
-        // Test timestamptz_ns (precision 7)
-        DataField timestampLtzNs7Field =
-                new DataField(6, "timestamptz_ns", new 
LocalZonedTimestampType(false, 7));
-        IcebergDataField icebergTimestampLtzNs7 = new 
IcebergDataField(timestampLtzNs7Field);
-        assertThat(icebergTimestampLtzNs7.type()).isEqualTo("timestamptz_ns");
-
-        // Test timestamptz_ns (precision 8)
-        DataField timestampLtzNs8Field =
-                new DataField(7, "timestamptz_ns", new 
LocalZonedTimestampType(false, 8));
-        IcebergDataField icebergTimestampLtzNs8 = new 
IcebergDataField(timestampLtzNs8Field);
-        assertThat(icebergTimestampLtzNs8.type()).isEqualTo("timestamptz_ns");
-
-        // Test timestamptz_ns (precision 9)
-        DataField timestampLtzNs9Field =
-                new DataField(8, "timestamptz_ns", new 
LocalZonedTimestampType(false, 9));
-        IcebergDataField icebergTimestampLtzNs9 = new 
IcebergDataField(timestampLtzNs9Field);
-        assertThat(icebergTimestampLtzNs9.type()).isEqualTo("timestamptz_ns");
+        for (int precision = 7; precision <= 9; precision++) {
+            DataField nanosField =
+                    new DataField(3, "timestamp_ns", new TimestampType(false, 
precision));
+            assertThatThrownBy(() -> new IcebergDataField(nanosField))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("precision from 3 to 6");
+
+            DataField nanosLtzField =
+                    new DataField(
+                            4, "timestamptz_ns", new 
LocalZonedTimestampType(false, precision));
+            assertThatThrownBy(() -> new IcebergDataField(nanosLtzField))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("precision from 3 to 6");
+        }
     }
 
     @Test
@@ -268,43 +250,40 @@ class IcebergDataFieldTest {
                 new DataField(1, "timestamp", new TimestampType(false, 2));
         assertThatThrownBy(() -> new IcebergDataField(invalidTimestampField))
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining(
-                        "Paimon Iceberg compatibility only support timestamp 
type with precision from 3 to 9");
+                .hasMessageContaining("precision from 3 to 6");
 
         // Test invalid precision (<= 3)
         DataField invalidTimestampField2 =
                 new DataField(2, "timestamp", new TimestampType(false, 2));
         assertThatThrownBy(() -> new IcebergDataField(invalidTimestampField2))
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining(
-                        "Paimon Iceberg compatibility only support timestamp 
type with precision from 3 to 9");
+                .hasMessageContaining("precision from 3 to 6");
 
         // Test invalid local timezone timestamp precision (<= 3)
         DataField invalidTimestampLtzField =
                 new DataField(3, "timestamptz", new 
LocalZonedTimestampType(false, 2));
         assertThatThrownBy(() -> new 
IcebergDataField(invalidTimestampLtzField))
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining(
-                        "Paimon Iceberg compatibility only support timestamp 
type with precision from 3 to 9");
+                .hasMessageContaining("precision from 3 to 6");
 
         // Test valid precision boundaries
         DataField validTimestamp4 = new DataField(4, "timestamp", new 
TimestampType(false, 4));
         IcebergDataField icebergTimestamp4 = new 
IcebergDataField(validTimestamp4);
         assertThat(icebergTimestamp4.type()).isEqualTo("timestamp");
 
-        DataField validTimestamp9 = new DataField(5, "timestamp", new 
TimestampType(false, 9));
-        IcebergDataField icebergTimestamp9 = new 
IcebergDataField(validTimestamp9);
-        assertThat(icebergTimestamp9.type()).isEqualTo("timestamp_ns");
+        DataField validTimestamp6 = new DataField(5, "timestamp", new 
TimestampType(false, 6));
+        IcebergDataField icebergTimestamp6 = new 
IcebergDataField(validTimestamp6);
+        assertThat(icebergTimestamp6.type()).isEqualTo("timestamp");
 
         DataField validTimestampLtz4 =
                 new DataField(6, "timestamptz", new 
LocalZonedTimestampType(false, 4));
         IcebergDataField icebergTimestampLtz4 = new 
IcebergDataField(validTimestampLtz4);
         assertThat(icebergTimestampLtz4.type()).isEqualTo("timestamptz");
 
-        DataField validTimestampLtz9 =
-                new DataField(7, "timestamptz", new 
LocalZonedTimestampType(false, 9));
-        IcebergDataField icebergTimestampLtz9 = new 
IcebergDataField(validTimestampLtz9);
-        assertThat(icebergTimestampLtz9.type()).isEqualTo("timestamptz_ns");
+        DataField validTimestampLtz6 =
+                new DataField(7, "timestamptz", new 
LocalZonedTimestampType(false, 6));
+        IcebergDataField icebergTimestampLtz6 = new 
IcebergDataField(validTimestampLtz6);
+        assertThat(icebergTimestampLtz6.type()).isEqualTo("timestamptz");
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
index cb39ea112d..b3094e7fc8 100644
--- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
@@ -176,6 +176,108 @@ public class SchemaManagerTest {
         assertThat(latest.get().options()).containsEntry("new_k", "new_v");
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = {2, 9})
+    public void testIcebergMetadataRefusesUnsupportedTimestampPrecisions(int 
precision)
+            throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+        options.put(IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"table-location");
+        Schema nanos =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", DataTypes.INT()),
+                                new DataField(1, "ts", 
DataTypes.TIMESTAMP(precision))),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options,
+                        "");
+
+        assertThatThrownBy(() -> retryArtificialException(() -> 
manager.createTable(nanos)))
+                .hasStackTraceContaining("precision from 3 to 6");
+    }
+
+    @Test
+    public void testIcebergMetadataAllowsMicrosecondTimestamps() throws 
Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+        options.put(IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"table-location");
+        Schema micros =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", DataTypes.INT()),
+                                new DataField(1, "ts", 
DataTypes.TIMESTAMP(6))),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options,
+                        "");
+
+        assertThatCode(() -> retryArtificialException(() -> 
manager.createTable(micros)))
+                .doesNotThrowAnyException();
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {2, 9})
+    public void 
testEnablingIcebergMetadataRefusesUnsupportedTimestampPrecisions(int precision)
+            throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+        Schema nanos =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", DataTypes.INT()),
+                                new DataField(1, "ts", 
DataTypes.TIMESTAMP(precision))),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options,
+                        "");
+        retryArtificialException(() -> manager.createTable(nanos));
+
+        assertThatThrownBy(
+                        () ->
+                                retryArtificialException(
+                                        () ->
+                                                manager.commitChanges(
+                                                        SchemaChange.setOption(
+                                                                IcebergOptions
+                                                                        
.METADATA_ICEBERG_STORAGE
+                                                                        .key(),
+                                                                
"table-location"))))
+                .hasStackTraceContaining("precision from 3 to 6");
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {2, 9})
+    public void 
testEnableIcebergMetadataValidatesHistoricalTimestampPrecisions(int precision)
+            throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+        Schema nanos =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", DataTypes.INT()),
+                                new DataField(1, "ts", 
DataTypes.TIMESTAMP(precision))),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options,
+                        "");
+
+        retryArtificialException(() -> manager.createTable(nanos));
+        retryArtificialException(() -> 
manager.commitChanges(SchemaChange.dropColumn("ts")));
+
+        assertThatThrownBy(
+                        () ->
+                                retryArtificialException(
+                                        () ->
+                                                manager.commitChanges(
+                                                        SchemaChange.setOption(
+                                                                IcebergOptions
+                                                                        
.METADATA_ICEBERG_STORAGE
+                                                                        .key(),
+                                                                
"table-location"))))
+                .hasStackTraceContaining("precision from 3 to 6");
+    }
+
     @Test
     public void 
testEnableIcebergMetadataValidatesHistoricalGeospatialSchemas() throws 
Exception {
         Map<String, String> geospatialOptions = new HashMap<>();

Reply via email to