github-actions[bot] commented on code in PR #66345:
URL: https://github.com/apache/doris/pull/66345#discussion_r3701866818


##########
fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:
##########
@@ -735,6 +735,39 @@ private List<Column> 
appendSyntheticWriteColumns(List<Column> schema) {
         return result;
     }
 
+    /** Immutable write-facing views captured from one schema-cache 
generation. */
+    public static final class WriteSchemaSnapshot {
+        private final List<Column> fullSchema;
+        private final List<Column> partitionColumns;
+
+        private WriteSchemaSnapshot(List<Column> fullSchema, List<Column> 
partitionColumns) {
+            this.fullSchema = Collections.unmodifiableList(new 
ArrayList<>(fullSchema));
+            this.partitionColumns = Collections.unmodifiableList(new 
ArrayList<>(partitionColumns));
+        }
+
+        public List<Column> getFullSchema() {
+            return fullSchema;
+        }
+
+        public List<Column> getPartitionColumns() {
+            return partitionColumns;
+        }
+    }
+
+    /**
+     * Captures schema and partition identities from one cache value for write 
binding. Reading them through
+     * separate table APIs can straddle a concurrent refresh and make the 
planner hash an older output by a
+     * newer column ordinal.
+     */
+    public WriteSchemaSnapshot getWriteSchemaSnapshot() {

Review Comment:
   [P1] Bind the remote table identity together with this write snapshot. If 
Iceberg table U0 is dropped and recreated as U1 after `BindSink` captures these 
columns but before `getWriteMetadataIdentity()` performs the first 
`sharedWritableTable` load, every later step sees U1. With identical field 
IDs/schema/defaults, format, sort order, and partition spec, all current 
comparisons pass and the INSERT that was bound to U0 commits into U1. Iceberg's 
operations-level UUID refresh check only helps after that object has already 
loaded U0, so please carry the bind-time table UUID/generation in this 
snapshot/write handle and compare it at begin (with a 
drop/recreate-before-first-load regression test).



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java:
##########
@@ -230,6 +234,121 @@ public ConnectorSinkPlan planWrite(ConnectorSession 
session, ConnectorWriteHandl
         }
     }
 
+    private void validateBoundWriteColumns(Table table, ConnectorWriteHandle 
handle,
+            WriteOperation writeOperation) {
+        List<ConnectorColumn> boundTargetColumns = 
handle.getBoundTargetColumns();
+        if (!boundTargetColumns.isEmpty()
+                && (writeOperation == WriteOperation.REWRITE
+                        || writeOperation == WriteOperation.UPDATE
+                        || writeOperation == WriteOperation.MERGE)) {
+            long boundLineageColumns = boundTargetColumns.stream()
+                    .filter(ConnectorColumn::isReservedPassthrough)
+                    .count();
+            long currentLineageColumns = 
IcebergWriterHelper.getFormatVersion(table) >= 3 ? 2 : 0;
+            // Format version changes the physical sink arity without changing 
table.schema(); the reserved
+            // columns are the bind-time witness that the output and v3 
schema-json belong to one generation.
+            if (boundLineageColumns != currentLineageColumns) {
+                throw new DorisConnectorException(
+                        "Iceberg write metadata changed after the write was 
bound; retry the statement");
+            }
+        }
+        // V3 row-lineage columns are engine-generated metadata, not fields in 
table.schema(). Excluding
+        // their neutral marker keeps the comparison on the complete user 
schema for INSERT and MERGE.
+        List<ConnectorColumn> boundColumns = boundTargetColumns.stream()
+                .filter(column -> !column.isReservedPassthrough())
+                .collect(Collectors.toList());
+        if (writeOperation == WriteOperation.DELETE || boundColumns.isEmpty()) 
{
+            return;
+        }
+        List<NestedField> currentColumns = table.schema().columns();
+        boolean hasSyntheticRowId = boundColumns.size() == 
currentColumns.size() + 1
+                && 
DORIS_ICEBERG_ROWID_COL.equals(boundColumns.get(boundColumns.size() - 
1).getName());
+        if (boundColumns.size() != currentColumns.size() && 
!hasSyntheticRowId) {
+            throw new DorisConnectorException("Iceberg table schema changed 
after the write was bound; retry the "
+                    + "statement with the latest schema");
+        }
+        boolean enableVarbinary = Boolean.parseBoolean(properties.getOrDefault(
+                IcebergConnectorProperties.ENABLE_MAPPING_VARBINARY, "false"));
+        boolean enableTimestampTz = 
Boolean.parseBoolean(properties.getOrDefault(
+                IcebergConnectorProperties.ENABLE_MAPPING_TIMESTAMP_TZ, 
"false"));
+        for (int i = 0; i < currentColumns.size(); ++i) {
+            NestedField current = currentColumns.get(i);
+            ConnectorColumn bound = boundColumns.get(i);
+            ConnectorType currentType = IcebergTypeMapping.fromIcebergType(
+                    current.type(), enableVarbinary, enableTimestampTz);
+            // Do not compare top-level nullability: Doris widens Iceberg 
required columns in its read schema
+            // so evolution default-fill may yield NULL. Nested requiredness 
remains authoritative in
+            // sameBoundType, while current schema JSON enforces writes at the 
root.
+            if (!current.name().equalsIgnoreCase(bound.getName())

Review Comment:
   [P1] Compare the bound write default here as part of the generation fence. 
Doris has already expanded omitted columns (and explicit `DEFAULT`) from the 
bind-time `ConnectorColumn.getDefaultValue()`. If another writer changes an 
Iceberg field's `writeDefault` from, say, 42 to 7 before `beginWrite()` 
refreshes the table, the name/type/field-id and format/sort/spec checks all 
still pass, so this statement writes 42 into the refreshed table instead of 
retrying against the current default. `parseSchema()` deliberately exposes 
`NestedField.writeDefault()` for this materialization, so it needs to be 
included in this comparison (with a regression test for default-only schema 
evolution between bind and begin).



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java:
##########
@@ -607,6 +632,12 @@ private List<ConnectorScanRange> planScanInternal(
             Optional<ConnectorExpression> filter,
             boolean countPushdown) {
         IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        if (iceHandle.isResolvedEmptySnapshot()) {

Review Comment:
   [P1] Please do not apply the empty-data-snapshot shortcut to every system 
table. `getSysTableHandle()` preserves the resolved-empty flag, so a newly 
created table now returns no ranges even for `$metadata_log_entries`. Iceberg 
builds that table from the metadata history and always includes the current 
metadata file, so it has a creation row before any data snapshot exists. Keep 
the empty MVCC fence for the snapshot-dependent data/file tables, but let 
snapshot-independent metadata tables plan normally (and add a resolved-empty 
system-table test).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to