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

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


The following commit(s) were added to refs/heads/master by this push:
     new 1386e1eac91 [fix](iceberg) Reject row-level DML on files whose 
partition source column was dropped (#69229)
1386e1eac91 is described below

commit 1386e1eac91a2389a4a07c8b375450b0ea9092ae
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 19 08:11:10 2026 +0100

    [fix](iceberg) Reject row-level DML on files whose partition source column 
was dropped (#69229)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68481, #68124
    
    Problem Summary:
    
    **In short.** Ports the rest of branch-4.1 #68481 to master. When the
    source column of a historical Iceberg partition field has been dropped,
    DELETE and UPDATE on master commit a delete file under partition NULL,
    after which Doris can no longer read the table. They now fail with a
    clear error before anything is written, ordinary reads keep working, and
    TIME partition values can be parsed back at commit.
    
    **Background.** #68481 fixed Iceberg row-level DML after a table evolves
    to an unpartitioned spec. Its main part, keeping each file's own spec ID
    and partition tuple in the scan range once any spec is partitioned,
    reached master with #68124; the regression added here confirms that
    REPLACE to an unpartitioned table and dropping the last partition field
    work on master. Row IDs carry each file's `partition_data_json`, and BE
    writes the position-delete file of a DELETE, UPDATE or MERGE under that
    partition.
    
    **The problem, and what it cost.**
    
    - Dropping the source column of a historical partition field leaves the
    field with no type, so the file's partition tuple cannot be serialized.
    `IcebergPartitionUtils.getPartitionValues` swallowed the
    `UnsupportedOperationException` and wrote a null entry. A DELETE on such
    a table then committed a delete file under partition NULL for a data
    file in partition 7. Spark still reads the table, but every later Doris
    query of it fails:
    
    `Mismatched partition tuples (PartitionData{partition_key=null},
    PartitionData{partition_key=7}) for delete file
    s3://.../delete_pos_....parquet and data file
    s3a://.../partition_key=7/00000-....parquet`
    
    - `parsePartitionValueFromString` had no TIME branch, so the value
    `serializePartitionValue` renders for a TIME partition could not be
    parsed back when the delete file is committed
    (`convertToPartitionData`).
    
    **How this PR fixes it.**
    
    - `getPartitionValues` throws for a non-null value it cannot serialize
    instead of returning a null entry. A null needs no type and stays null,
    so a historical NULL partition of the dropped column, and the `void(p)`
    field a v1 table keeps for a removed partition field, still serialize.
    - When a data file's partition tuple cannot be serialized, its range
    carries no `partition_data_json`. It keeps the raw values instead
    (binary as hex), which still identify the file's partition for EXPLAIN
    `partition=N/M` and `sql_block_rule` `partition_num`, as legacy
    `IcebergScanNode` counted distinct `PartitionData`. Ordinary reads do
    not use the payload, so they are unaffected.
    - A scan that produces row IDs over such a range is rejected: `Cannot
    produce Iceberg row IDs with unsupported partition types in spec N.
    Rewrite historical data files before DELETE or UPDATE.` The check runs
    on every `planScan` call, including one served by the statement scan
    reuse (its key leaves out the projection, as for the metadata-column
    reader check), and on each range of a streaming split source.
    - `getColumnHandles` returns a handle for the hidden row-id column, as
    it does for `_file` and `_pos`, so a DML scan request carries it.
    Before, the column had no handle and never reached the connector. The
    field-id schema dictionary leaves it out, since it is not a table
    column.
    - `parsePartitionValueFromString` parses TIME (microseconds since
    midnight).
    
    Branch-4.x renders `partition_data_json` only for scans that produce row
    IDs. Master keeps rendering it for every scan, because
    `specId|partitionDataJson` is the partition identity behind EXPLAIN
    `partition=N/M` and `sql_block_rule` `partition_num`.
    
    **Results.** Local cluster with the Iceberg docker environment (tables
    written by Spark):
    
    | Case | Before | After |
    |---|---|---|
    | Spark REPLACE a partitioned table with an unpartitioned one, then two
    Doris DELETEs | Works (#68124) | Same |
    | Drop the last partition field, then DELETE and UPDATE across both
    specs | Works | Same |
    | Drop the partition field, then its source column; Doris DELETE
    (Parquet and ORC) | Commits a delete file under partition NULL; Doris
    reads of the table then fail with `Mismatched partition tuples` |
    Rejected with the error above; no delete file, rows unchanged in Doris
    and Spark |
    | Same table, Doris UPDATE | Same as DELETE | Rejected |
    | Same table, Doris DELETE of the row in the `partition_key = NULL` file
    | Works | Same |
    | DELETE on historical binary partitions; DELETE and UPDATE on pre-1970
    timestamp partitions | Works | Same |
    
    Co-authored-by: Gabriel <[email protected]>
---
 .../iceberg/IcebergConnectorMetadata.java          |   8 +-
 .../connector/iceberg/IcebergPartitionUtils.java   |  60 ++++--
 .../connector/iceberg/IcebergScanPlanProvider.java |  67 ++++++-
 .../doris/connector/iceberg/IcebergScanRange.java  |  31 +++-
 .../iceberg/IcebergConnectorMetadataTest.java      |   4 +-
 .../iceberg/IcebergPartitionUtilsTest.java         |  73 ++++++++
 .../iceberg/IcebergScanPlanProviderTest.java       | 181 ++++++++++++++++++
 ...t_iceberg_delete_unpartitioned_evolution.groovy | 204 +++++++++++++++++++++
 8 files changed, 596 insertions(+), 32 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index a1259061585..a2f63a69239 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -125,6 +125,9 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
     private static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = 
"_last_updated_sequence_number";
     private static final String ICEBERG_FILE_PATH_COL = "_file";
     private static final String ICEBERG_ROW_POSITION_COL = "_pos";
+    // The hidden DML row-id column, fe-core Column.ICEBERG_ROWID_COL (pinned 
by
+    // IcebergScanPlanProviderClassifyColumnTest through the scan provider's 
copy).
+    private static final String DORIS_ICEBERG_ROWID_COL = 
"__DORIS_ICEBERG_ROWID_COL__";
     private static final int ICEBERG_ROW_ID_FIELD_ID = 2147483540;
     private static final int ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_FIELD_ID = 
2147483539;
     private static final int ICEBERG_ROW_LINEAGE_MIN_VERSION = 3;
@@ -783,7 +786,7 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
             rejectReservedMetadataColumns(schema);
         }
         Map<String, ConnectorColumnHandle> handles = new LinkedHashMap<>(
-                fields.size() + (appendDataFileMetadataColumns ? 2 : 0));
+                fields.size() + (appendDataFileMetadataColumns ? 3 : 0));
         for (Types.NestedField field : fields) {
             String name = field.name();
             handles.put(name, new IcebergColumnHandle(name, field.fieldId()));
@@ -791,6 +794,9 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
         if (appendDataFileMetadataColumns) {
             handles.put(ICEBERG_FILE_PATH_COL, new 
IcebergColumnHandle(ICEBERG_FILE_PATH_COL, -1));
             handles.put(ICEBERG_ROW_POSITION_COL, new 
IcebergColumnHandle(ICEBERG_ROW_POSITION_COL, -1));
+            // Synthesized by the reader for DML and never in the schema. Its 
handle tells the scan provider that a
+            // scan produces row IDs 
(IcebergScanPlanProvider#rejectRowIdScanWithoutPartitionData).
+            handles.put(DORIS_ICEBERG_ROWID_COL, new 
IcebergColumnHandle(DORIS_ICEBERG_ROWID_COL, -1));
         }
         return handles;
     }
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
index 46f7659f698..24094ea76da 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
@@ -195,9 +195,12 @@ final class IcebergPartitionUtils {
 
     /**
      * The serialized value for <b>every</b> partition field (identity + 
transform), in spec order, used for
-     * {@code partition_data_json}. Mirrors legacy {@code 
IcebergUtils.getPartitionValues}. A field whose
-     * value cannot be serialized (BINARY/FIXED) yields a {@code null} entry 
(not dropped) to keep positional
-     * alignment with the spec.
+     * {@code partition_data_json}. Mirrors legacy {@code 
IcebergUtils.getPartitionValues}. A non-null value whose
+     * type cannot be serialized (a historical field whose source column was 
dropped) throws
+     * {@link UnsupportedOperationException}: these values also identify the 
partition of the delete files
+     * BE writes for DML, so an unsupported value must never become a {@code 
null} entry. A null needs no type,
+     * so the void field a v1 table keeps for a removed partition field stays 
{@code null} after its source
+     * column is dropped.
      */
     static List<String> getPartitionValues(PartitionData partitionData, 
PartitionSpec partitionSpec, ZoneId zone) {
         List<NestedField> fields = 
partitionData.getPartitionType().asNestedType().fields();
@@ -206,15 +209,8 @@ final class IcebergPartitionUtils {
 
         List<String> partitionValues = new ArrayList<>(fields.size());
         for (int i = 0; i < fields.size(); i++) {
-            NestedField field = fields.get(i);
             Object value = partitionData.get(i);
-            try {
-                partitionValues.add(serializePartitionValue(field.type(), 
value, zone));
-            } catch (UnsupportedOperationException e) {
-                LOG.warn("Failed to serialize Iceberg partition value for 
field {}: {}", field.name(),
-                        e.getMessage());
-                partitionValues.add(null);
-            }
+            partitionValues.add(value == null ? null : 
serializePartitionValue(fields.get(i).type(), value, zone));
         }
         return partitionValues;
     }
@@ -225,9 +221,32 @@ final class IcebergPartitionUtils {
      * content is identical.
      */
     static String getPartitionDataJson(PartitionData partitionData, 
PartitionSpec partitionSpec, ZoneId zone) {
-        List<String> partitionValues = getPartitionValues(partitionData, 
partitionSpec, zone);
+        return toJsonArray(getPartitionValues(partitionData, partitionSpec, 
zone));
+    }
+
+    /**
+     * Identifies a partition tuple {@link #getPartitionValues} cannot 
serialize, for the scanned-partition count
+     * only: a JSON array of the raw values in spec order (binary as hex), so 
each distinct tuple counts once, as
+     * legacy {@code IcebergScanNode} keyed the count by the {@link 
PartitionData} itself.
+     */
+    static String getRawPartitionKey(PartitionData partitionData) {
+        List<String> values = new ArrayList<>(partitionData.size());
+        for (int i = 0; i < partitionData.size(); i++) {
+            Object value = partitionData.get(i);
+            if (value == null) {
+                values.add(null);
+            } else if (value instanceof java.nio.ByteBuffer) {
+                values.add(binaryHex((java.nio.ByteBuffer) value));
+            } else {
+                values.add(value.toString());
+            }
+        }
+        return toJsonArray(values);
+    }
+
+    private static String toJsonArray(List<String> values) {
         try {
-            return JsonUtil.mapper().writeValueAsString(partitionValues);
+            return JsonUtil.mapper().writeValueAsString(values);
         } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
             throw new RuntimeException("Failed to serialize iceberg partition 
data to JSON, error message is:"
                     + e.getMessage(), e);
@@ -382,10 +401,7 @@ final class IcebergPartitionUtils {
                 if (value == null) {
                     return null;
                 }
-                java.nio.ByteBuffer bytes = ((java.nio.ByteBuffer) 
value).duplicate();
-                byte[] copy = new byte[bytes.remaining()];
-                bytes.get(copy);
-                return "0x" + java.util.HexFormat.of().formatHex(copy);
+                return binaryHex((java.nio.ByteBuffer) value);
             case DATE:
                 if (value == null) {
                     return null;
@@ -430,6 +446,13 @@ final class IcebergPartitionUtils {
         }
     }
 
+    private static String binaryHex(java.nio.ByteBuffer value) {
+        java.nio.ByteBuffer bytes = value.duplicate();
+        byte[] copy = new byte[bytes.remaining()];
+        bytes.get(copy);
+        return "0x" + java.util.HexFormat.of().formatHex(copy);
+    }
+
     // Canonical partition-timestamp format ("yyyy-MM-dd HH:mm:ss" with an 
optional micro/nano fraction),
     // the form BE renders human-readable partition values in. Legacy parsed 
via the nereids multi-format
     // DateLiteral.parseDateTime (connector-forbidden); the canonical form is 
the only one BE emits, so this
@@ -491,6 +514,9 @@ final class IcebergPartitionUtils {
                 case DATE:
                     // Iceberg date is days since epoch (1970-01-01).
                     return (int) LocalDate.parse(valueStr, 
DateTimeFormatter.ISO_LOCAL_DATE).toEpochDay();
+                case TIME:
+                    // Iceberg time is microseconds since midnight, the 
inverse of serializePartitionValue.
+                    return LocalTime.parse(valueStr, 
DateTimeFormatter.ISO_LOCAL_TIME).toNanoOfDay() / 1000;
                 case TIMESTAMP_NANO:
                     // Match the sink cast: a date-only static partition 
denotes midnight.
                     if (valueStr.length() == 10) {
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index 515d67b5f56..2f07b5ac0a7 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -441,8 +441,8 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
             // validate every call before a cached range list can be returned.
             validateMetadataColumnReader(session, request.getColumns());
             if (session == null || !session.isExternalScanTaskReuseEnabled()) {
-                return planScanInternal(session, icebergHandle, 
request.getColumns(),
-                        request.getFilter(), request.isCountPushdown());
+                return 
rejectRowIdScanWithoutPartitionData(request.getColumns(), 
planScanInternal(session,
+                        icebergHandle, request.getColumns(), 
request.getFilter(), request.isCountPushdown()));
             }
             if (icebergHandle.isSystemTable()) {
                 // System tables read connector metadata through their own 
readers; never reuse them.
@@ -469,10 +469,10 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
             Map<IcebergScanReuseKey, List<ConnectorScanRange>> scanReuse = 
session.getStatementScope()
                     .computeIfAbsentForPlanning(memoKey, () -> new 
ConcurrentHashMap<>());
             IcebergScanReuseKey reuseKey = new 
IcebergScanReuseKey(icebergHandle, request);
-            return scanReuse.computeIfAbsent(reuseKey,
+            return rejectRowIdScanWithoutPartitionData(request.getColumns(), 
scanReuse.computeIfAbsent(reuseKey,
                     k -> Collections.unmodifiableList(planScanInternal(session,
                             icebergHandle, request.getColumns(), 
request.getFilter(),
-                            request.isCountPushdown())));
+                            request.isCountPushdown()))));
         } catch (RuntimeException e) {
             // Normal data scans and native position_deletes run on File 
Scanner V2. Keep the serialized JNI
             // system-table route untouched because its deferred reads belong 
to the V1 scanner contract.
@@ -599,7 +599,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
                     scan, session, table, filter, sliceSize);
             return new IcebergStreamingSplitSource(tasks, table, 
formatVersion, partitioned,
                     orderedPartitionKeys, zone, uriNormalizer, sliceSize,
-                    iceHandle.getRewriteFileScope(), iceHandle);
+                    iceHandle.getRewriteFileScope(), iceHandle, 
requiresRowId(columns));
         } catch (RuntimeException e) {
             throw IcebergExceptionUtils.wrapMetadataReadFailure(iceHandle, e);
         }
@@ -664,6 +664,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         private final long sliceSize;
         private final Set<String> rewriteScope;
         private final IcebergTableHandle handle;
+        private final boolean requiresRowId;
         // Lazily opened on first hasNext() so the ctor never throws — 
iceberg's ParallelIterable submits
         // manifest readers in tasks.iterator(), which can fail; opening it 
eagerly here would throw out of
         // streamSplits() BEFORE the source is returned, leaking the 
planFiles() iterable (the engine pump's
@@ -679,7 +680,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         IcebergStreamingSplitSource(CloseableIterable<FileScanTask> tasks, 
Table table, int formatVersion,
                 boolean partitioned, List<String> orderedPartitionKeys, ZoneId 
zone,
                 UnaryOperator<String> uriNormalizer, long sliceSize, 
Set<String> rewriteScope,
-                IcebergTableHandle handle) {
+                IcebergTableHandle handle, boolean requiresRowId) {
             this.tasks = tasks;
             this.table = table;
             this.formatVersion = formatVersion;
@@ -690,6 +691,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
             this.sliceSize = sliceSize;
             this.rewriteScope = rewriteScope;
             this.handle = handle;
+            this.requiresRowId = requiresRowId;
         }
 
         @Override
@@ -705,6 +707,9 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
                     IcebergScanRange range = 
buildRangeForTask(iterator.next(), table, formatVersion, partitioned,
                             orderedPartitionKeys, zone, uriNormalizer, 
sliceSize, rewriteScope, null, scratch);
                     if (range != null) {
+                        if (requiresRowId) {
+                            rejectRowIdRangeWithoutPartitionData(range);
+                        }
                         buffered = range;
                         return true;
                     }
@@ -1622,6 +1627,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         private DataFile file;
         private Integer partitionSpecId;
         private String partitionDataJson;
+        private String rawPartitionKey;
         private Map<String, String> partitionValues = Collections.emptyMap();
         // The table's CURRENT spec isPartitioned() — scan-invariant, memoized 
with the rest so the display
         // gate (see IcebergScanRange#getScannedPartitionKey) costs one 
table.spec() per file, not per slice.
@@ -1676,6 +1682,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
                 .formatVersion(formatVersion)
                 .partitionSpecId(file.partitionSpecId)
                 .partitionDataJson(file.partitionDataJson)
+                .rawPartitionKey(file.rawPartitionKey)
                 .firstRowId(file.firstRowId)
                 .lastUpdatedSequenceNumber(file.lastUpdatedSequenceNumber)
                 .partitionValues(file.partitionValues)
@@ -1701,13 +1708,21 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         perFileInvariantComputeCount++;
         Integer partitionSpecId = null;
         String partitionDataJson = null;
+        String rawPartitionKey = null;
         Map<String, String> partitionValues = Collections.emptyMap();
         if (partitioned && dataFile.partition() instanceof PartitionData) {
             PartitionData partitionData = (PartitionData) dataFile.partition();
             int specId = dataFile.specId();
             PartitionSpec spec = table.specs().get(specId);
             partitionSpecId = specId;
-            partitionDataJson = 
IcebergPartitionUtils.getPartitionDataJson(partitionData, spec, zone);
+            try {
+                partitionDataJson = 
IcebergPartitionUtils.getPartitionDataJson(partitionData, spec, zone);
+            } catch (UnsupportedOperationException e) {
+                // A historical partition field whose source column was 
dropped has no serializable type. Only
+                // row IDs need the tuple, so the file stays readable and a 
row-ID scan is rejected per call; the
+                // raw values still count the file's partition in EXPLAIN 
partition=N/M and sql_block_rule.
+                rawPartitionKey = 
IcebergPartitionUtils.getRawPartitionKey(partitionData);
+            }
             // Order the identity values as the path_partition_keys list 
(legacy getOrderedPathPartitionKeys),
             // filtered to keys this file carries — so columns-from-path 
matches legacy ordering exactly.
             Map<String, String> identityMap =
@@ -1747,6 +1762,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         file.file = dataFile;
         file.partitionSpecId = partitionSpecId;
         file.partitionDataJson = partitionDataJson;
+        file.rawPartitionKey = rawPartitionKey;
         file.partitionValues = partitionValues;
         // Display-only gate (see IcebergScanRange#getScannedPartitionKey): a 
table whose CURRENT spec is
         // unpartitioned reports no partitions, so the old-spec partitions its 
files still carry must not
@@ -2160,8 +2176,9 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
      * The lowercased names of the requested (pruned) columns — the 
authoritative Doris scan slots the field-id
      * dictionary keys its {@code -1} entry off (so its top-level names == the 
BE scan-slot names BY
      * CONSTRUCTION; CI #969249). The names come straight from the {@link 
IcebergColumnHandle}s
-     * {@code IcebergConnectorMetadata.getColumnHandles} produced (already 
lowercased). An empty list (count-only
-     * scan / no column handles) makes the dictionary fall back to all 
top-level columns.
+     * {@code IcebergConnectorMetadata.getColumnHandles} produced (already 
lowercased), minus the synthesized
+     * {@code _file} / {@code _pos} / hidden row-id columns, which are not in 
the table schema. An empty list
+     * (count-only scan / no column handles) makes the dictionary fall back to 
all top-level columns.
      */
     private static List<String> 
requestedLowerNames(List<ConnectorColumnHandle> columns) {
         if (columns == null || columns.isEmpty()) {
@@ -2171,7 +2188,8 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         for (ConnectorColumnHandle column : columns) {
             String name = ((IcebergColumnHandle) column).getName();
             if (!ICEBERG_FILE_PATH_COL.equalsIgnoreCase(name)
-                    && !ICEBERG_ROW_POSITION_COL.equalsIgnoreCase(name)) {
+                    && !ICEBERG_ROW_POSITION_COL.equalsIgnoreCase(name)
+                    && !DORIS_ICEBERG_ROWID_COL.equalsIgnoreCase(name)) {
                 names.add(name);
             }
         }
@@ -3093,6 +3111,35 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         return Boolean.parseBoolean(raw.trim());
     }
 
+    private static boolean requiresRowId(List<ConnectorColumnHandle> columns) {
+        return columns.stream()
+                .filter(column -> column instanceof IcebergColumnHandle)
+                .anyMatch(column -> 
DORIS_ICEBERG_ROWID_COL.equalsIgnoreCase(((IcebergColumnHandle) 
column).getName()));
+    }
+
+    /**
+     * Rejects a scan that produces Iceberg row IDs (DELETE, UPDATE, MERGE) 
when a file's partition tuple could not
+     * be serialized. Row IDs carry the partition of the delete files BE 
writes, and a missing value must not become
+     * a NULL partition. Checked on every call, like {@link 
#validateMetadataColumnReader}, because the scan reuse
+     * key leaves the projection out.
+     */
+    private static List<ConnectorScanRange> 
rejectRowIdScanWithoutPartitionData(
+            List<ConnectorColumnHandle> columns, List<ConnectorScanRange> 
ranges) {
+        if (requiresRowId(columns)) {
+            for (ConnectorScanRange range : ranges) {
+                rejectRowIdRangeWithoutPartitionData((IcebergScanRange) range);
+            }
+        }
+        return ranges;
+    }
+
+    private static void rejectRowIdRangeWithoutPartitionData(IcebergScanRange 
range) {
+        if (range.isPartitionDataUnavailable()) {
+            throw new DorisConnectorException("Cannot produce Iceberg row IDs 
with unsupported partition types in spec "
+                    + range.getPartitionSpecId() + ". Rewrite historical data 
files before DELETE or UPDATE.");
+        }
+    }
+
     private static boolean requiresMetadataColumns(List<ConnectorColumnHandle> 
columns) {
         return columns.stream()
                 .filter(column -> column instanceof IcebergColumnHandle)
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
index a22ac634122..ef48c73ed2d 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
@@ -62,6 +62,11 @@ public class IcebergScanRange implements ConnectorScanRange {
     private final int formatVersion;
     private final Integer partitionSpecId;
     private final String partitionDataJson;
+    // FE-only, set when the file's partition tuple has a value that cannot be 
serialized (a historical partition
+    // field whose source column was dropped), so partitionDataJson is absent: 
the raw values identify the
+    // partition for getScannedPartitionKey, and a scan that produces row IDs 
is rejected
+    // (IcebergScanPlanProvider#rejectRowIdScanWithoutPartitionData). Ordinary 
reads need neither.
+    private final String rawPartitionKey;
     private final Long firstRowId;
     private final Long lastUpdatedSequenceNumber;
     // Identity partition column (lowercased) -> serialized value, already 
ordered as the path_partition_keys
@@ -134,6 +139,7 @@ public class IcebergScanRange implements ConnectorScanRange 
{
         this.formatVersion = builder.formatVersion;
         this.partitionSpecId = builder.partitionSpecId;
         this.partitionDataJson = builder.partitionDataJson;
+        this.rawPartitionKey = builder.rawPartitionKey;
         this.firstRowId = builder.firstRowId;
         this.lastUpdatedSequenceNumber = builder.lastUpdatedSequenceNumber;
         this.partitionValues = builder.partitionValues != null
@@ -233,17 +239,30 @@ public class IcebergScanRange implements 
ConnectorScanRange {
      * A distinct-faithful key for the native iceberg partition this file 
belongs to (FIX-L12), or
      * {@code null} when the file carries no {@code PartitionData} 
(unpartitioned / current spec
      * unpartitioned). Combines the partition-spec id with the serialized 
{@code PartitionData}
-     * ({@code partitionDataJson}) so that two files are keyed equal iff they 
share the same spec and
+     * ({@code partitionDataJson}), or with its raw values when they cannot be 
serialized
+     * ({@code rawPartitionKey}), so that two files are keyed equal iff they 
share the same spec and
      * partition tuple — mirroring legacy {@code IcebergScanNode}'s de-dup by
      * {@code (PartitionData) file().partition()}, and disambiguating 
cross-spec value collisions
      * (e.g. {@code identity(id)=2} vs {@code bucket(id)=2}) that the 
value-only json would merge.
      * {@code IcebergScanPlanProvider} counts distinct non-null keys for 
{@code selectedPartitionNum}.
      */
     String getScannedPartitionKey() {
-        if (partitionDataJson == null || !countsAsScannedPartition) {
+        if (!countsAsScannedPartition) {
             return null;
         }
-        return partitionSpecId + "|" + partitionDataJson;
+        if (partitionDataJson != null) {
+            return partitionSpecId + "|" + partitionDataJson;
+        }
+        // The marker keeps a raw tuple apart from a serialized one of the 
same spec.
+        return rawPartitionKey == null ? null : partitionSpecId + "|raw" + 
rawPartitionKey;
+    }
+
+    Integer getPartitionSpecId() {
+        return partitionSpecId;
+    }
+
+    boolean isPartitionDataUnavailable() {
+        return rawPartitionKey != null;
     }
 
     /**
@@ -478,6 +497,7 @@ public class IcebergScanRange implements ConnectorScanRange 
{
         private int formatVersion = 2;
         private Integer partitionSpecId;
         private String partitionDataJson;
+        private String rawPartitionKey;
         private Long firstRowId;
         private Long lastUpdatedSequenceNumber;
         private Map<String, String> partitionValues;
@@ -569,6 +589,11 @@ public class IcebergScanRange implements 
ConnectorScanRange {
             return this;
         }
 
+        public Builder rawPartitionKey(String rawPartitionKey) {
+            this.rawPartitionKey = rawPartitionKey;
+            return this;
+        }
+
         /**
          * Whether this range counts toward the scanned-partition display 
(default {@code true}); pass the
          * table's CURRENT spec {@code isPartitioned()} — see {@link 
IcebergScanRange#getScannedPartitionKey()}.
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
index 2e174c1ff99..6d3efdbfe42 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
@@ -1395,11 +1395,13 @@ public class IcebergConnectorMetadataTest {
         Map<String, ConnectorColumnHandle> handles =
                 metadataWith(ops).getColumnHandles(null, new 
IcebergTableHandle("db1", "t1"));
 
-        Assertions.assertEquals(4, handles.size());
+        Assertions.assertEquals(5, handles.size());
         Assertions.assertTrue(handles.containsKey("ID"));
         Assertions.assertTrue(handles.containsKey("Name"));
         Assertions.assertTrue(handles.containsKey("_file"));
         Assertions.assertTrue(handles.containsKey("_pos"));
+        // The hidden DML row-id column, so the scan provider can tell a scan 
that produces row IDs.
+        Assertions.assertEquals(-1, ((IcebergColumnHandle) 
handles.get("__DORIS_ICEBERG_ROWID_COL__")).getFieldId());
         Assertions.assertFalse(handles.containsKey("id"), "post-#65094 the 
handle key keeps the iceberg case");
         Assertions.assertEquals(7, ((IcebergColumnHandle) 
handles.get("ID")).getFieldId());
         Assertions.assertEquals(9, ((IcebergColumnHandle) 
handles.get("Name")).getFieldId());
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
index 323d28a73d8..cb0bd8d2aee 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
@@ -506,6 +506,68 @@ public class IcebergPartitionUtilsTest {
                 IcebergPartitionUtils.getPartitionDataJson(pd, spec, 
ZoneOffset.UTC));
     }
 
+    @Test
+    public void partitionDataJsonRejectsFieldWhoseSourceColumnWasDropped() {
+        // From branch-4.x #68481: once the source column of a historical 
identity field is dropped, the field has no
+        // type and its value cannot be serialized. The JSON is the partition 
of the delete files BE writes for DML,
+        // so it must fail rather than carry a null entry. MUTATION: restoring 
the null fallback -> "[null]" -> red.
+        Schema schema = new Schema(
+                Types.NestedField.required(1, "id", Types.IntegerType.get()),
+                Types.NestedField.optional(2, "p", Types.IntegerType.get()));
+        Table table = tableWith(schema, 
PartitionSpec.builderFor(schema).identity("p").build());
+        table.updateSpec().removeField("p").commit();
+        table.updateSchema().deleteColumn("p").commit();
+        PartitionSpec historical = table.specs().get(0);
+        PartitionData pd = new PartitionData(historical.partitionType());
+        pd.set(0, 7);
+
+        Assertions.assertThrows(UnsupportedOperationException.class,
+                () -> IcebergPartitionUtils.getPartitionDataJson(pd, 
historical, ZoneOffset.UTC));
+    }
+
+    @Test
+    public void partitionDataJsonKeepsNullOfFieldWhoseSourceColumnWasDropped() 
{
+        // A null needs no type: the void field a v1 table keeps for a removed 
partition field is always null, and a
+        // historical NULL partition is one, so both still serialize after the 
source column is dropped.
+        // MUTATION: serializing a null by its type -> 
UnsupportedOperationException -> red.
+        Schema schema = new Schema(
+                Types.NestedField.required(1, "id", Types.IntegerType.get()),
+                Types.NestedField.optional(2, "p", Types.IntegerType.get()));
+        Table table = tableWith(schema, 
PartitionSpec.builderFor(schema).identity("p").build());
+        table.updateSpec().removeField("p").commit();
+        table.updateSchema().deleteColumn("p").commit();
+        PartitionSpec historical = table.specs().get(0);
+        PartitionData pd = new PartitionData(historical.partitionType());
+        pd.set(0, null);
+
+        Assertions.assertEquals("[null]", 
IcebergPartitionUtils.getPartitionDataJson(pd, historical, ZoneOffset.UTC));
+    }
+
+    @Test
+    public void rawPartitionKeyIdentifiesTupleThatCannotBeSerialized() {
+        // The scanned-partition count keys such a tuple by its raw values: 
binary as hex (a ByteBuffer has no
+        // content-based toString), and a null apart from the string "null".
+        Schema schema = new Schema(
+                Types.NestedField.optional(1, "p", Types.IntegerType.get()),
+                Types.NestedField.optional(2, "b", Types.BinaryType.get()),
+                Types.NestedField.optional(3, "s", Types.StringType.get()));
+        Table table = tableWith(schema, 
PartitionSpec.builderFor(schema).identity("p").identity("b").identity("s")
+                .build());
+        table.updateSpec().removeField("p").removeField("b").commit();
+        table.updateSchema().deleteColumn("p").deleteColumn("b").commit();
+        PartitionSpec historical = table.specs().get(0);
+        PartitionData pd = new PartitionData(historical.partitionType());
+        pd.set(0, 7);
+        pd.set(1, ByteBuffer.wrap(new byte[] {0x61, 0x62}));
+        pd.set(2, null);
+
+        Assertions.assertThrows(UnsupportedOperationException.class,
+                () -> IcebergPartitionUtils.getPartitionDataJson(pd, 
historical, ZoneOffset.UTC));
+        Assertions.assertEquals("[\"7\",\"0x6162\",null]", 
IcebergPartitionUtils.getRawPartitionKey(pd));
+        pd.set(2, "null");
+        Assertions.assertEquals("[\"7\",\"0x6162\",\"null\"]", 
IcebergPartitionUtils.getRawPartitionKey(pd));
+    }
+
     // ---- parsePartitionValueFromString: legacy 
IcebergUtils.parsePartitionValueFromString matrix (T04) ----
     // The write-direction inverse of serializePartitionValue: BE sends 
human-readable partition strings, the
     // connector converts them to iceberg internal partition objects for 
PartitionData. Mirrors legacy cell-by-cell.
@@ -596,6 +658,17 @@ public class IcebergPartitionUtilsTest {
                 "2021-01-01 00:00:00.123456", 
Types.TimestampType.withoutZone(), ZoneOffset.UTC));
     }
 
+    @Test
+    public void parseTimeIsInverseOfSerialize() {
+        // From branch-4.x #68481: a DELETE on a file of a historical TIME 
partition commits its delete file under
+        // the partition parsed back from partition_data_json, so the parse 
must accept what serialize renders.
+        long micros = 3661_123_456L;
+        String serialized = 
IcebergPartitionUtils.serializePartitionValue(Types.TimeType.get(), micros, 
ZoneOffset.UTC);
+        Assertions.assertEquals("01:01:01.123456", serialized);
+        Assertions.assertEquals(Long.valueOf(micros), 
IcebergPartitionUtils.parsePartitionValueFromString(
+                serialized, Types.TimeType.get(), ZoneOffset.UTC));
+    }
+
     @Test
     public void parseUnsupportedTypeThrows() {
         Assertions.assertThrows(IllegalArgumentException.class, () ->
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 37135377acc..633595a680a 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -1139,6 +1139,167 @@ public class IcebergScanPlanProviderTest {
         Assertions.assertEquals("[\"7\"]", 
desc.getTableFormatParams().getIcebergParams().getPartitionDataJson());
     }
 
+    @Test
+    public void rowIdScanRejectsFilesWhosePartitionSourceColumnWasDropped() {
+        // From branch-4.x #68481: dropping the source column of a historical 
partition field leaves the field with
+        // no type, so the file's partition tuple cannot be serialized. An 
ordinary read does not need it and keeps
+        // working. A scan producing row IDs (DELETE / UPDATE / MERGE) is 
rejected: the row IDs carry the partition
+        // of the delete files BE writes, and a NULL partition there leaves a 
delete file Doris cannot read back.
+        IcebergScanPlanProvider provider = 
providerOver(droppedPartitionSourceTable());
+        ConnectorSession session = new FakeScanSession("UTC", 
Collections.emptyMap());
+        IcebergTableHandle handle = new IcebergTableHandle("db1", "pt");
+
+        List<ConnectorScanRange> ranges = provider.planScan(session, 
scanOf(handle).build());
+
+        Assertions.assertEquals(1, ranges.size());
+        TFileRangeDesc desc = populate(ranges.get(0));
+        Assertions.assertEquals(0, 
desc.getTableFormatParams().getIcebergParams().getPartitionSpecId());
+        
Assertions.assertFalse(desc.getTableFormatParams().getIcebergParams().isSetPartitionDataJson());
+        DorisConnectorException ex = 
Assertions.assertThrows(DorisConnectorException.class,
+                () -> provider.planScan(session, rowIdScanOf(handle).build()));
+        Assertions.assertEquals("Cannot produce Iceberg row IDs with 
unsupported partition types in spec 0. "
+                + "Rewrite historical data files before DELETE or UPDATE.", 
ex.getMessage());
+    }
+
+    @Test
+    public void statementReuseStillRejectsRowIdScanWithoutPartitionData() {
+        // The reuse key leaves the projection out, so a row-ID scan served 
from an ordinary scan's memoized ranges
+        // must still be checked. MUTATION: checking only freshly planned 
ranges -> the second call returns -> red.
+        IcebergScanPlanProvider provider = 
providerOver(droppedPartitionSourceTable());
+        ConnectorSession session = reuseSession(new TestStatementScope(), 0L);
+        IcebergTableHandle handle = new IcebergTableHandle("db1", "pt");
+
+        provider.planScan(session, scanOf(handle).build());
+
+        Assertions.assertThrows(DorisConnectorException.class,
+                () -> provider.planScan(session, rowIdScanOf(handle).build()));
+    }
+
+    @Test
+    public void streamSplitsRejectsRowIdScanWithoutPartitionData() throws 
IOException {
+        // Batch mode builds its ranges lazily on the split-pump thread, so it 
checks each range as it is built.
+        IcebergScanPlanProvider provider = 
providerOver(droppedPartitionSourceTable());
+        try (ConnectorSplitSource source = provider.streamSplits(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                rowIdScanOf(new IcebergTableHandle("db1", 
"pt")).filter(Optional.empty()).limit(-1L).build())) {
+            DorisConnectorException ex = 
Assertions.assertThrows(DorisConnectorException.class, source::hasNext);
+            Assertions.assertTrue(ex.getMessage().contains("unsupported 
partition types in spec 0"), ex.getMessage());
+        }
+    }
+
+    /** A file written under identity(p), after p is removed from the spec and 
then dropped from the schema. */
+    private static Table droppedPartitionSourceTable() {
+        PartitionSpec spec = 
PartitionSpec.builderFor(PART_SCHEMA).identity("p").build();
+        Table table = createTable("pt", PART_SCHEMA, spec);
+        table.newAppend()
+                .appendFile(dataFile(spec, "s3://b/db/pt/p=7/old.parquet", 
1024, null, "p=7"))
+                .commit();
+        table.updateSpec().removeField("p").commit();
+        table.updateSchema().deleteColumn("p").commit();
+        return table;
+    }
+
+    private static ConnectorScanRequest.Builder rowIdScanOf(IcebergTableHandle 
handle) {
+        return ConnectorScanRequest.builder(handle, Arrays.asList(new 
IcebergColumnHandle("id", 1),
+                new IcebergColumnHandle("__DORIS_ICEBERG_ROWID_COL__", -1)));
+    }
+
+    @Test
+    public void 
rowIdScanKeepsNullOfRetainedVoidFieldWhoseSourceColumnWasDropped() {
+        // A v1 table keeps a removed partition field as void(p), whose value 
is always null. Dropping p leaves the
+        // field without a type, but a null needs none: DML must keep working 
on files written under the void spec,
+        // which rewriting historical files cannot get rid of. MUTATION: 
serializing a null by its type -> red.
+        Table table = retainedVoidFieldTable("pv", true);
+        IcebergScanPlanProvider provider = providerOver(table);
+
+        List<ConnectorScanRange> ranges = provider.planScan(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                rowIdScanOf(new IcebergTableHandle("db1", "pv")).build());
+
+        Assertions.assertEquals(1, ranges.size());
+        TIcebergFileDesc desc = 
populate(ranges.get(0)).getTableFormatParams().getIcebergParams();
+        Assertions.assertEquals(1, desc.getPartitionSpecId());
+        Assertions.assertEquals("[null,\"5\"]", desc.getPartitionDataJson());
+    }
+
+    @Test
+    public void rowIdScanAcceptsAllVoidSpecWhoseSourceColumnWasDropped() {
+        // The same evolution when void(p) is the only field left: the spec is 
no longer partitioned.
+        Table table = retainedVoidFieldTable("pav", false);
+        IcebergScanPlanProvider provider = providerOver(table);
+
+        List<ConnectorScanRange> ranges = provider.planScan(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                rowIdScanOf(new IcebergTableHandle("db1", "pav")).build());
+
+        Assertions.assertEquals(1, ranges.size());
+        TIcebergFileDesc desc = 
populate(ranges.get(0)).getTableFormatParams().getIcebergParams();
+        Assertions.assertEquals(1, desc.getPartitionSpecId());
+    }
+
+    @Test
+    public void 
scannedPartitionCountKeepsPartitionsWhoseSourceColumnWasDropped() {
+        // Files of a historical identity(p) whose source column was dropped 
have no partition_data_json, but they
+        // are still scanned partitions: EXPLAIN partition=N/M and 
sql_block_rule partition_num must count them,
+        // each distinct tuple once, as legacy IcebergScanNode did by keying 
on PartitionData.
+        // MUTATION: counting only ranges with partition_data_json -> 1 -> red.
+        Schema schema = new Schema(
+                Types.NestedField.required(1, "id", Types.IntegerType.get()),
+                Types.NestedField.optional(2, "p", Types.IntegerType.get()),
+                Types.NestedField.optional(3, "q", Types.IntegerType.get()));
+        PartitionSpec byP = 
PartitionSpec.builderFor(schema).identity("p").build();
+        Table table = createTable("pc", schema, byP);
+        table.newAppend()
+                .appendFile(dataFile(byP, "s3://b/db/pc/p=7/a.parquet", 1024, 
null, "p=7"))
+                .appendFile(dataFile(byP, "s3://b/db/pc/p=7/b.parquet", 1024, 
null, "p=7"))
+                .appendFile(dataFile(byP, "s3://b/db/pc/p=8/c.parquet", 1024, 
null, "p=8"))
+                .commit();
+        table.updateSpec().removeField("p").addField("q").commit();
+        table.updateSchema().deleteColumn("p").commit();
+        table.newAppend()
+                .appendFile(dataFile(table.spec(), 
"s3://b/db/pc/q=1/d.parquet", 1024, null, "q=1"))
+                .commit();
+        IcebergScanPlanProvider provider = providerOver(table);
+
+        List<ConnectorScanRange> ranges = provider.planScan(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                scanOf(new IcebergTableHandle("db1", "pc")).build());
+
+        Assertions.assertEquals(4, ranges.size());
+        Assertions.assertEquals(OptionalLong.of(3), 
provider.scannedPartitionCount(ranges));
+    }
+
+    /**
+     * A v1 table partitioned by p (and q when {@code keepQ}): p is removed 
from the spec, which v1 keeps as
+     * void(p), a file is written under that spec, p is dropped from the 
schema and the table is upgraded to v2.
+     */
+    private static Table retainedVoidFieldTable(String name, boolean keepQ) {
+        Schema schema = new Schema(
+                Types.NestedField.required(1, "id", Types.IntegerType.get()),
+                Types.NestedField.optional(2, "p", Types.IntegerType.get()),
+                Types.NestedField.optional(3, "q", Types.IntegerType.get()));
+        PartitionSpec.Builder spec = 
PartitionSpec.builderFor(schema).identity("p");
+        if (keepQ) {
+            spec.identity("q");
+        }
+        Table table = createTable(name, schema, spec.build(),
+                Collections.singletonMap(TableProperties.FORMAT_VERSION, "1"));
+        table.updateSpec().removeField("p").commit();
+        PartitionSpec voidSpec = table.spec();
+        PartitionData partition = new PartitionData(voidSpec.partitionType());
+        if (keepQ) {
+            partition.set(1, 5);
+        }
+        table.newAppend()
+                .appendFile(DataFiles.builder(voidSpec)
+                        .withPath("s3://b/db/" + name + "/new.parquet")
+                        .withFileSizeInBytes(1024)
+                        .withRecordCount(10)
+                        .withFormat(FileFormat.PARQUET)
+                        .withPartition(partition)
+                        .build())
+                .commit();
+        table.updateSchema().deleteColumn("p").commit();
+        table.updateProperties().set(TableProperties.FORMAT_VERSION, 
"2").commit();
+        return table;
+    }
+
     @Test
     public void 
planScanKeepsUnpartitionedSpecIdentityAfterEvolvingToPartitioned() {
         // Guard for the DML $row_id contract: a file written before 
identity(p) was added must still report
@@ -1533,6 +1694,26 @@ public class IcebergScanPlanProviderTest {
                 .getFields().get(0).getFieldPtr().getName());
     }
 
+    @Test
+    public void getScanNodePropertiesLeavesHiddenRowIdOutOfDict() throws 
Exception {
+        // getColumnHandles returns a handle for the hidden DML row-id column 
so a DML scan request carries it, but
+        // the column is synthesized by the reader and not in the schema. 
MUTATION: keeping it in the requested
+        // names -> "requested column ... not found in the table schema" -> 
red.
+        Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+        IcebergScanPlanProvider provider = providerOver(table);
+        List<ConnectorColumnHandle> columns = Arrays.asList(new 
IcebergColumnHandle("id", 1),
+                new IcebergColumnHandle("__DORIS_ICEBERG_ROWID_COL__", -1));
+
+        Map<String, String> props = provider.getScanNodeProperties(
+                null, new IcebergTableHandle("db1", "t1"), columns, 
Optional.empty());
+
+        TFileScanRangeParams params = new TFileScanRangeParams();
+        provider.populateScanLevelParams(params, props);
+        Assertions.assertEquals(1, 
params.getHistorySchemaInfo().get(0).getRootField().getFieldsSize());
+        Assertions.assertEquals("id", 
params.getHistorySchemaInfo().get(0).getRootField()
+                .getFields().get(0).getFieldPtr().getName());
+    }
+
     @Test
     public void populateScanLevelParamsAdvertisesIcebergScanSemanticsV2() 
throws Exception {
         // #65784: every iceberg scan this connector plans must stamp 
iceberg_scan_semantics_version=2 on the
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
new file mode 100644
index 00000000000..7ae07744445
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
@@ -0,0 +1,204 @@
+// 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.
+
+suite("test_iceberg_delete_unpartitioned_evolution",
+        "p0,external,iceberg,external_docker,external_docker_iceberg") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
 {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_iceberg_delete_unpartitioned_evolution"
+    String dbName = "iceberg_delete_unpartitioned_evolution_db"
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            "type" = "iceberg",
+            "iceberg.catalog.type" = "rest",
+            "uri" = "http://${externalEnvIp}:${restPort}";,
+            "s3.access_key" = "admin",
+            "s3.secret_key" = "password",
+            "s3.endpoint" = "http://${externalEnvIp}:${minioPort}";,
+            "s3.region" = "us-east-1"
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """create database if not exists ${dbName}"""
+    sql """use ${dbName}"""
+
+    def checkRows = { String table, def expected ->
+        sql """refresh table ${dbName}.${table}"""
+        spark_iceberg """refresh table demo.${dbName}.${table}"""
+        def actual = sql """select record_key, metric from ${table} order by 
record_key"""
+        assertSparkDorisResultEquals(expected, actual)
+        assertSparkDorisResultEquals(spark_iceberg("""
+            select record_key, metric from demo.${dbName}.${table} order by 
record_key
+        """), actual)
+    }
+
+    // REPLACE retains historical spec 0 while writing new files under an 
unpartitioned spec.
+    // Spark's replace transaction exercises the same Iceberg metadata 
transition as Trino RTAS.
+    spark_iceberg """drop table if exists 
demo.${dbName}.replace_to_unpartitioned"""
+    spark_iceberg """
+        create table demo.${dbName}.replace_to_unpartitioned (record_key int, 
metric int)
+        using iceberg partitioned by (record_key)
+        tblproperties ('format-version' = '2', 'write.format.default' = 
'parquet')
+    """
+    sql """refresh database ${dbName}"""
+    sql """insert into replace_to_unpartitioned values (1, 10), (2, 20), (3, 
30)"""
+    spark_iceberg """refresh table demo.${dbName}.replace_to_unpartitioned"""
+    spark_iceberg """
+        create or replace table demo.${dbName}.replace_to_unpartitioned using 
iceberg
+        tblproperties ('format-version' = '2', 'write.delete.mode' = 
'merge-on-read')
+        as select * from demo.${dbName}.replace_to_unpartitioned
+    """
+    sql """refresh table ${dbName}.replace_to_unpartitioned"""
+    def replacedSpecs = sql """select distinct spec_id from 
replace_to_unpartitioned\$data_files"""
+    assertEquals(1, replacedSpecs.size())
+    assertTrue((replacedSpecs[0][0] as int) > 0)
+    def expectedAfterReplaceDelete = spark_iceberg """
+        select record_key, metric from demo.${dbName}.replace_to_unpartitioned
+        where record_key <> 1 order by record_key
+    """
+    sql """delete from replace_to_unpartitioned where record_key = 1"""
+    checkRows("replace_to_unpartitioned", expectedAfterReplaceDelete)
+    assertEquals(replacedSpecs, sql("""
+        select distinct spec_id from replace_to_unpartitioned\$delete_files
+    """))
+    def expectedAfterRefreshDelete = spark_iceberg """
+        select record_key, metric from demo.${dbName}.replace_to_unpartitioned
+        where record_key <> 2 order by record_key
+    """
+    sql """delete from replace_to_unpartitioned where record_key = 2"""
+    checkRows("replace_to_unpartitioned", expectedAfterRefreshDelete)
+
+    // Dropping the last partition field leaves both old partitioned and new 
unpartitioned files live.
+    spark_iceberg """drop table if exists 
demo.${dbName}.mixed_partition_specs"""
+    spark_iceberg """
+        create table demo.${dbName}.mixed_partition_specs (record_key int, 
metric int)
+        using iceberg partitioned by (record_key)
+        tblproperties ('format-version' = '2', 'write.format.default' = 'orc',
+                       'write.delete.mode' = 'merge-on-read', 
'write.update.mode' = 'merge-on-read')
+    """
+    spark_iceberg """insert into demo.${dbName}.mixed_partition_specs values 
(1, 10), (2, 20)"""
+    spark_iceberg """alter table demo.${dbName}.mixed_partition_specs drop 
partition field record_key"""
+    spark_iceberg """insert into demo.${dbName}.mixed_partition_specs values 
(3, 30), (4, 40)"""
+    sql """refresh database ${dbName}"""
+    def mixedSpecs = sql """select distinct spec_id from 
mixed_partition_specs\$data_files order by spec_id"""
+    assertEquals(2, mixedSpecs.size())
+    def expectedAfterMixedDelete = spark_iceberg """
+        select record_key, metric from demo.${dbName}.mixed_partition_specs
+        where record_key not in (1, 3) order by record_key
+    """
+    sql """delete from mixed_partition_specs where record_key in (1, 3)"""
+    checkRows("mixed_partition_specs", expectedAfterMixedDelete)
+    assertEquals(mixedSpecs, sql("""
+        select distinct spec_id from mixed_partition_specs\$delete_files order 
by spec_id
+    """))
+    def expectedAfterUpdate = spark_iceberg """
+        select record_key, metric + 100 from 
demo.${dbName}.mixed_partition_specs order by record_key
+    """
+    sql """update mixed_partition_specs set metric = metric + 100"""
+    checkRows("mixed_partition_specs", expectedAfterUpdate)
+
+    // Dropping a source column makes its historical partition type unknown, 
even for non-null values.
+    for (String fileFormat : ["parquet", "orc"]) {
+        String table = "dropped_partition_source_${fileFormat}"
+        spark_iceberg """drop table if exists demo.${dbName}.${table}"""
+        spark_iceberg """
+            create table demo.${dbName}.${table} (record_key int, 
partition_key int, metric int)
+            using iceberg partitioned by (partition_key)
+            tblproperties ('format-version' = '2', 'write.format.default' = 
'${fileFormat}',
+                           'write.delete.mode' = 'merge-on-read', 
'write.update.mode' = 'merge-on-read')
+        """
+        spark_iceberg """insert into demo.${dbName}.${table} values (1, 7, 
10), (2, 7, 20), (3, null, 30)"""
+        spark_iceberg """alter table demo.${dbName}.${table} drop partition 
field partition_key"""
+        spark_iceberg """alter table demo.${dbName}.${table} drop column 
partition_key"""
+        spark_iceberg """insert into demo.${dbName}.${table} values (4, 40)"""
+        sql """refresh database ${dbName}"""
+        def expected = spark_iceberg """
+            select record_key, metric from demo.${dbName}.${table} order by 
record_key
+        """
+        checkRows(table, expected)
+        test {
+            sql """delete from ${table} where record_key = 1"""
+            exception "Cannot produce Iceberg row IDs with unsupported 
partition types"
+        }
+        test {
+            sql """update ${table} set metric = metric + 100 where record_key 
= 2"""
+            exception "Cannot produce Iceberg row IDs with unsupported 
partition types"
+        }
+        checkRows(table, expected)
+        assertEquals([[0L]], sql("""select count(*) from 
${table}\$delete_files"""))
+        // A NULL partition value needs no type, so the file of partition_key 
= NULL still takes DML.
+        def expectedAfterNullPartitionDelete = spark_iceberg """
+            select record_key, metric from demo.${dbName}.${table} where 
record_key <> 3 order by record_key
+        """
+        sql """delete from ${table} where record_key = 3"""
+        checkRows(table, expectedAfterNullPartitionDelete)
+    }
+
+    // Historical binary partitions must retain their bytes, not become NULL 
delete partitions.
+    spark_iceberg """drop table if exists demo.${dbName}.historical_binary"""
+    spark_iceberg """
+        create table demo.${dbName}.historical_binary (record_key int, 
partition_key binary, metric int)
+        using iceberg partitioned by (partition_key)
+        tblproperties ('format-version' = '2', 'write.delete.mode' = 
'merge-on-read')
+    """
+    spark_iceberg """insert into demo.${dbName}.historical_binary values
+        (1, unhex('00ff802f'), 10), (2, unhex('00ff802f'), 20), (3, null, 30), 
(4, unhex(''), 40)"""
+    spark_iceberg """alter table demo.${dbName}.historical_binary drop 
partition field partition_key"""
+    spark_iceberg """insert into demo.${dbName}.historical_binary values (5, 
unhex('ff'), 50)"""
+    sql """refresh database ${dbName}"""
+    def expectedAfterBinaryDelete = spark_iceberg """
+        select record_key, metric from demo.${dbName}.historical_binary
+        where record_key not in (1, 3, 4, 5) order by record_key
+    """
+    sql """delete from historical_binary where record_key in (1, 3, 4, 5)"""
+    checkRows("historical_binary", expectedAfterBinaryDelete)
+
+    // Negative fractional epochs require floor-based splitting into seconds 
and nanoseconds.
+    spark_iceberg """drop table if exists 
demo.${dbName}.historical_timestamp"""
+    spark_iceberg """
+        create table demo.${dbName}.historical_timestamp
+            (record_key int, partition_key timestamp_ntz, metric int)
+        using iceberg partitioned by (partition_key)
+        tblproperties ('format-version' = '2', 'write.delete.mode' = 
'merge-on-read',
+                       'write.update.mode' = 'merge-on-read')
+    """
+    spark_iceberg """insert into demo.${dbName}.historical_timestamp values
+        (1, cast('1969-12-31 23:59:59.999999' as timestamp_ntz), 10),
+        (2, cast('1969-12-31 23:59:58.999999' as timestamp_ntz), 20), (3, 
null, 30)"""
+    spark_iceberg """alter table demo.${dbName}.historical_timestamp drop 
partition field partition_key"""
+    spark_iceberg """insert into demo.${dbName}.historical_timestamp values
+        (4, cast('1970-01-01 00:00:00.000001' as timestamp_ntz), 40)"""
+    sql """refresh database ${dbName}"""
+    def expectedAfterTimestampDelete = spark_iceberg """
+        select record_key, metric from demo.${dbName}.historical_timestamp
+        where record_key not in (1, 4) order by record_key
+    """
+    sql """delete from historical_timestamp where record_key in (1, 4)"""
+    checkRows("historical_timestamp", expectedAfterTimestampDelete)
+    def expectedAfterTimestampUpdate = spark_iceberg """
+        select record_key, metric + 100 from 
demo.${dbName}.historical_timestamp order by record_key
+    """
+    sql """update historical_timestamp set metric = metric + 100"""
+    checkRows("historical_timestamp", expectedAfterTimestampUpdate)
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to