morningman commented on code in PR #69536:
URL: https://github.com/apache/doris/pull/69536#discussion_r4366909872


##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java:
##########
@@ -2327,8 +2341,40 @@ private static List<Schema> metadataSchemaHistory(Table 
table) {
         return new ArrayList<>(table.schemas().values());
     }
 
-    private static void addHistoricalEqualityFields(
-            List<NestedField> fields, Set<Integer> missingFieldIds, Schema 
historicalSchema) {
+    /**
+     * Field IDs are never reused and a field's type only changes by 
promotion, which is monotonic, so the widest
+     * type a field has in any retained schema covers every file written for 
it.
+     */
+    private static Map<Integer, Type> widestPromotedTypes(Set<Integer> 
fieldIds, List<Schema> schemas) {
+        Map<Integer, Type> widestTypes = new HashMap<>();
+        for (Schema schema : schemas) {
+            for (Integer fieldId : fieldIds) {
+                Type type = schema.findType(fieldId);
+                if (type == null) {
+                    continue;
+                }
+                Type widest = widestTypes.get(fieldId);
+                if (widest == null || TypeUtil.isPromotionAllowed(widest, 
type.asPrimitiveType())) {

Review Comment:
   Confirmed, and fixed in `cbf21fb0585`. The widening added in 71f51edf754 
relied on `TypeUtil.isPromotionAllowed`, which in Iceberg 1.12.0 (and on main) 
knows only INT→LONG, FLOAT→DOUBLE and decimal precision. For a v3 key promoted 
from DATE to TIMESTAMP, the first retained type therefore won and replaced the 
TIMESTAMP definition the lineage had already found. FileScannerV2 then cast the 
noon tombstone to DATE with an ordinary truncating cast and deleted the 
midnight row. V1 compares in the delete file's own type and was not affected.
   
   Each field of the selected definition now starts from its own type and moves 
only along a recognized promotion: the SDK rules plus DATE→unzoned 
TIMESTAMP/TIMESTAMP_NS. It can no longer narrow a key or switch it to an 
unrelated type, and the result does not depend on the order of the retained 
schemas. `equalityCarrierKeepsLineageTimestampOfPromotedDateKey` replays this 
sequence, and `equalityCarrierWidensDateKeyPromotedWithoutASnapshot` covers a 
schema-only DATE→TIMESTAMP_NS promotion followed by a pending delete and a 
drop. Both publish the promotion with `TableMetadata.Builder.setCurrentSchema`, 
as `promotedDateBoundsDoNotPruneNanosecondMatches` does, because 
`SchemaUpdate.updateColumn` in 1.12.0 still rejects it; PyIceberg and 
iceberg-go reject it too unless incompatible changes are allowed, while a REST 
catalog's `add-schema` update only checks the new schema on its own.
   
   Master before this PR and branch-4.2 do not widen, so neither has this 
regression. branch-4.2 does share the schema-only variant, because its lineage 
yields DATE; that case goes into the note for the 4.x maintainers.
   



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java:
##########
@@ -2280,38 +2280,52 @@ private Set<Integer> 
cachedApplicableEqualityDeleteFieldIds(Table table, TableSc
      * delete manifests does not enumerate data files or byte-split tasks, and 
retained state is bounded by the
      * number of equality keys rather than the table's entire schema history.
      *
-     * <p>The metadata schema list is searched in reverse chronology up to the 
selected schema (schema IDs are
-     * identifiers, not a sequence), so the latest definition of each stable 
field ID wins. Current fields remain
-     * first, allowing a dropped/re-added name to resolve the projected field 
by name while a live historical
-     * equality key resolves by its stable field ID.</p>
+     * <p>Schema IDs are identifiers, not a sequence: Iceberg reuses an ID 
when evolution returns to an earlier
+     * schema, so a schema's position in the metadata list does not tell when 
it was last active, and the list may
+     * also hold schemas committed after a time-travel or branch target. The 
scan snapshot's parent chain is
+     * therefore searched first, so the definition active on that lineage 
wins; the whole metadata list, newest
+     * first, is the fallback for schema-only changes and expired ancestors. A 
dropped key nevertheless takes the
+     * widest type it has in any retained schema: a delete committed after a 
schema-only promotion holds values of
+     * the promoted type even when no snapshot records it. Current fields 
remain first, allowing a dropped/re-added
+     * name to resolve the projected field by name while a live historical 
equality key resolves by its stable
+     * field ID.</p>
      */
     @VisibleForTesting
     static List<NestedField> schemaForPotentialEqualityDeletes(
             Table table, TableScan scan, Schema scanSchema) {
         return schemaForPotentialEqualityDeletes(
-                table, scanSchema, applicableEqualityDeleteFieldIds(table, 
scan));
+                table, scan.snapshot(), scanSchema, 
applicableEqualityDeleteFieldIds(table, scan));
     }
 
-    private static List<NestedField> schemaForPotentialEqualityDeletes(
-            Table table, Schema scanSchema, Set<Integer> equalityFieldIds) {
-        List<Schema> metadataSchemas = metadataSchemaHistory(table);
-        int selectedSchemaIndex = -1;
-        for (int index = 0; index < metadataSchemas.size(); index++) {
-            if (metadataSchemas.get(index).schemaId() == 
scanSchema.schemaId()) {
-                selectedSchemaIndex = index;
-            }
-        }
-        int lastRelevantIndex = selectedSchemaIndex >= 0
-                ? selectedSchemaIndex : metadataSchemas.size() - 1;
+    @VisibleForTesting
+    static List<NestedField> schemaForPotentialEqualityDeletes(
+            Table table, Snapshot scanSnapshot, Schema scanSchema, 
Set<Integer> equalityFieldIds) {
         Set<Integer> missing = new HashSet<>(equalityFieldIds);
         missing.removeAll(TypeUtil.indexById(scanSchema.asStruct()).keySet());
         if (missing.isEmpty()) {
             return scanSchema.columns();
         }
 
         List<NestedField> fields = new ArrayList<>(scanSchema.columns());
-        for (int index = lastRelevantIndex; index >= 0 && !missing.isEmpty(); 
index--) {
-            addHistoricalEqualityFields(fields, missing, 
metadataSchemas.get(index));
+        List<Schema> metadataSchemas = metadataSchemaHistory(table);
+        Map<Integer, Type> widestTypes = widestPromotedTypes(missing, 
metadataSchemas);
+        // Snapshots on one lineage mostly share a schema, so each schema is 
indexed at most once.
+        Set<Integer> lineageSchemaIds = new HashSet<>();
+        for (Snapshot snapshot = scanSnapshot; snapshot != null && 
!missing.isEmpty();
+                snapshot = snapshot.parentId() == null ? null : 
table.snapshot(snapshot.parentId())) {
+            Integer schemaId = snapshot.schemaId();
+            if (schemaId == null || !lineageSchemaIds.add(schemaId)) {
+                continue;
+            }
+            Schema lineageSchema = table.schemas().get(schemaId);
+            if (lineageSchema == null) {
+                throw new IllegalStateException(
+                        "Iceberg snapshot schema " + schemaId + " is absent 
from table metadata");
+            }
+            addHistoricalEqualityFields(fields, missing, lineageSchema, 
widestTypes);

Review Comment:
   Confirmed, and fixed in `cbf21fb0585`. The pending delete and the id-only 
append both commit with the post-drop schema, so the lineage walk reaches the 
original REQUIRED definition and never sees the schema-only 
`makeColumnOptional`. Both readers then reject the id-only file: FileScannerV2 
with `Missing required field: k`, and V1 with `Required Iceberg field 'k' is 
missing from the data file and has no initial default`. Master's lookup found 
OPTIONAL and read NULL. Column metrics do not prevent this: Iceberg's own 
`DeleteFileIndex` resolves k from the highest schema ID, where it is optional, 
and attaches the NULL tombstone because the new file has no stats for k.
   
   A dropped key is now optional when any retained schema has it optional. 
Required→optional is the compatible direction (the reverse needs 
`allowIncompatibleChanges`), and reading NULL for the missing key is safe 
either way. A key that is required in every retained schema stays required, so 
the missing-required check does not change for it. 
`equalityCarrierKeepsSchemaOnlyOptionalityOfDroppedKey` replays the 
pending-delete/drop/append sequence, and 
`equalityCarrierKeepsAlwaysRequiredDroppedKeyRequired` checks that an 
always-required dropped key stays required.
   
   branch-4.2 also resolves keys lineage-first in 
`IcebergScanNode.getSchemaFieldsForScan`, and its readers reject the file the 
same way, so it has the same defect; it goes into the note for the 4.x 
maintainers.
   



-- 
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