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


##########
be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp:
##########
@@ -85,6 +87,59 @@ size_t 
VIcebergSortWriter::get_reserve_mem_size(RuntimeState* state, bool eos) c
     return _sorter == nullptr ? 0 : _sorter->get_reserve_mem_size(state, eos);
 }
 
+SorterReserveMemory 
VIcebergSortWriter::get_reserve_mem_size_components(RuntimeState* state,
+                                                                        bool 
eos) const {
+    std::lock_guard<std::mutex> lock(_sorter_mutex);
+    if (_sorter == nullptr) {
+        return {};
+    }
+    const size_t target = _target_file_size_bytes >= 0
+                                  ? 
static_cast<size_t>(_target_file_size_bytes)
+                                  : std::numeric_limits<size_t>::max();
+    auto reservation = _sorter->get_reserve_mem_size_components(state, eos, 
target);
+    _include_merge_reservation(state, eos, &reservation);
+    return reservation;
+}
+
+SorterReserveMemory VIcebergSortWriter::get_reserve_mem_size_components(
+        RuntimeState* state, bool eos, size_t incoming_rows, size_t 
incoming_bytes) const {
+    std::lock_guard<std::mutex> lock(_sorter_mutex);
+    if (_sorter == nullptr) {
+        return {};
+    }
+    const size_t target = _target_file_size_bytes >= 0
+                                  ? 
static_cast<size_t>(_target_file_size_bytes)
+                                  : std::numeric_limits<size_t>::max();
+    auto reservation = _sorter->get_reserve_mem_size_components(state, eos, 
incoming_rows,
+                                                                
incoming_bytes, target);
+    _include_merge_reservation(state, eos, &reservation);
+    return reservation;
+}
+
+void VIcebergSortWriter::_include_merge_reservation(RuntimeState* state, bool 
eos,
+                                                    SorterReserveMemory* 
reservation) const {
+    if (!eos) {

Review Comment:
   [P1] Reserve merge output for target rollovers
   
   This returns before adding merge workspace for every non-EOS block, but an 
append that crosses `_target_file_size_bytes` immediately calls 
`_flush_to_file()`. After `do_sort()` and `prepare_for_read(false)`, 
`_write_sorted_data()` materializes the same byte-bounded merge-output block 
that the EOS branch reserves while the sorted runs remain retained. A tiny 
block can cross the target for a sorter whose earlier rows set `_max_row_bytes` 
above the operator floor, so neither the incoming-block allowance nor the sort 
destination/permutation reservation covers that output and the async writer can 
cross the hard limit. Please include `iceberg_merge_output_workspace(...)` 
whenever the predicted append reaches the target, and test a non-EOS rollover 
with retained runs, an over-floor wide row, and a tiny tail.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSTransaction.java:
##########
@@ -175,17 +181,53 @@ public List<THivePartitionUpdate> 
mergePartitions(List<THivePartitionUpdate> hiv
 
     private void 
collectUncompletedMpuPendingUploads(List<THivePartitionUpdate> hivePUs) {
         for (THivePartitionUpdate pu : hivePUs) {
-            if (pu.getS3MpuPendingUploads() != null) {
-                for (TS3MPUPendingUpload s3MPUPendingUpload : 
pu.getS3MpuPendingUploads()) {
-                    uncompletedMpuPendingUploads.add(
-                            new 
UncompletedMpuPendingUpload(s3MPUPendingUpload, 
pu.getLocation().getWritePath()));
+            List<TS3MPUPendingUpload> uploads = pu.getS3MpuPendingUploads();
+            if (uploads == null) {
+                continue;
+            }
+            String writePath = pu.getLocation() == null ? null : 
pu.getLocation().getWritePath();
+            if (Strings.isNullOrEmpty(writePath)) {
+                // One malformed record must not prevent valid sibling uploads 
from being cleaned up.
+                LOG.warn("Skipping MPU cleanup record without a write path");
+                continue;
+            }
+            for (TS3MPUPendingUpload upload : uploads) {
+                if (!isCompleteObjectStoreUpload(upload)) {
+                    LOG.warn("Skipping incomplete MPU cleanup record for write 
path {}", writePath);
+                    continue;
                 }
+                uncompletedMpuPendingUploads.add(new 
UncompletedMpuPendingUpload(upload, writePath));
+            }
+        }
+    }
+
+    private static boolean isCompleteObjectStoreUpload(TS3MPUPendingUpload 
upload) {
+        return upload != null && !Strings.isNullOrEmpty(upload.getUploadId())
+                && !Strings.isNullOrEmpty(upload.getBucket()) && 
!Strings.isNullOrEmpty(upload.getKey());
+    }
+
+    private void validateObjectStoreCommitRecords() {
+        if (fileType != TFileType.FILE_S3) {
+            return;
+        }
+        for (THivePartitionUpdate update : hivePartitionUpdates) {
+            int fileCount = update.getFileNames() == null ? 0 : 
update.getFileNames().size();
+            List<TS3MPUPendingUpload> uploads = 
update.getS3MpuPendingUploads();
+            int uploadCount = uploads == null ? 0 : uploads.size();
+            boolean completeRecords = uploads != null

Review Comment:
   [P1] Reject partial deferred multipart reports
   
   This predicate treats an upload as complete when only its ID/bucket/key are 
present, but the producing path can return such a record with only a subset of 
its parts. `S3FileWriter::_complete()` checks `_failed` before waiting; after 
`_wait_until_finish()` it returns `OK` immediately for `_used_by_s3_committer`, 
before the later `_failed` and expected-part-count checks. If an async part 
fails during that wait, `_build_s3_mpu_pending_upload()` therefore reports the 
surviving `completed_parts()` map and this validation accepts it. Azure then 
commits exactly those listed block IDs (and S3 can complete the supplied 
subset), publishing a truncated object while Hive records the full row 
count/file size. Please run the post-wait failure/count validation before the 
committer return and cover a missing middle/tail part; validating 
nonempty/contiguous part maps here would also provide defense in depth.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java:
##########
@@ -309,6 +322,302 @@ void 
checkVariantBackendCompatibilityForCurrentScan(Iterable<Backend> backends)
         checkVariantBackendCompatibility(projectsVariant, backends);
     }
 
+    private boolean requiresIcebergScanSemanticsV2() throws UserException {
+        if (isSystemTable) {
+            // position_deletes already has its stricter dedicated 
mixed-version gate.
+            return false;
+        }
+        TableScan scan = createTableScan();
+        Snapshot snapshot = scan.snapshot();
+        if (snapshot == null) {
+            return false;
+        }
+        if (hasApplicableEqualityDeletes(scan)) {
+            return true;
+        }
+        Schema scanSchema = scan.schema();
+        Set<Integer> projectedFieldIds = projectedFieldIds(scanSchema);
+        Set<Integer> topLevelIds = new HashSet<>();
+        for (NestedField field : scanSchema.columns()) {
+            topLevelIds.add(field.fieldId());
+        }
+        Map<Integer, NestedField> fieldsById = 
TypeUtil.indexById(scanSchema.asStruct());
+        for (Integer fieldId : projectedFieldIds) {
+            NestedField field = fieldsById.get(fieldId);
+            if (field.initialDefault() != null
+                    && (!topLevelIds.contains(field.fieldId()) || 
field.type().isNestedType())) {
+                return true;
+            }
+        }
+        if (hasProjectedNameAliasCollision(scanSchema, projectedFieldIds, 
extractNameMapping())) {
+            return true;
+        }
+        return schemaHistoryRequiresMissingRequiredFieldRejection(
+                scanSchema, projectedFieldIds, reachableSchemas(icebergTable, 
snapshot));
+    }
+
+    private boolean hasApplicableEqualityDeletes(TableScan scan) throws 
UserException {
+        Snapshot snapshot = scan.snapshot();
+        if (snapshot == null) {
+            return false;
+        }
+        String equalityDeleteCount = 
snapshot.summary().get("total-equality-deletes");
+        if (equalityDeleteCount != null) {
+            try {
+                // A positive snapshot total proves V2 semantics are required 
without opening every
+                // delete manifest; only old summaries that omit the counter 
need the fallback.
+                return Long.parseLong(equalityDeleteCount) > 0;
+            } catch (NumberFormatException ignored) {
+                // Fall through for non-standard summaries instead of 
weakening compatibility.
+            }
+        }
+        // Inspect only delete manifests, not data tasks: equality-delete 
semantics are snapshot-wide
+        // compatibility state even when the current predicate happens to 
prune their partitions.
+        for (ManifestFile manifest : 
snapshot.deleteManifests(icebergTable.io())) {
+            if (!manifest.hasAddedFiles() && !manifest.hasExistingFiles()) {
+                continue;
+            }
+            try (ManifestReader<DeleteFile> deletes = 
ManifestFiles.readDeleteManifest(
+                    manifest, icebergTable.io(), icebergTable.specs())) {
+                for (DeleteFile delete : deletes) {
+                    if (delete.content() == FileContent.EQUALITY_DELETES) {
+                        return true;
+                    }
+                }
+            } catch (IOException e) {
+                throw new UserException(
+                        "Failed to inspect Iceberg delete manifest " + 
manifest.path(), e);
+            }
+        }
+        return false;
+    }
+
+    private static boolean hasSmoothUpgradeSource(Iterable<Backend> backends) {
+        for (Backend backend : backends) {
+            if (backend.isSmoothUpgradeSrc()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Set<Integer> projectedFieldIds(Schema scanSchema) {
+        return projectedFieldIds(scanSchema, desc.getSlots());
+    }
+
+    @VisibleForTesting
+    static Set<Integer> projectedFieldIds(Schema scanSchema, 
Iterable<SlotDescriptor> slots) {
+        Set<Integer> projected = new HashSet<>();
+        for (SlotDescriptor slot : slots) {
+            int fieldId = slot.getColumn().getUniqueId();
+            // Stable Iceberg IDs prevent a dropped-and-readded name from 
selecting the wrong history.
+            NestedField field = fieldId >= 0 ? scanSchema.findField(fieldId)
+                    : 
scanSchema.caseInsensitiveFindField(slot.getColumn().getName());
+            if (field != null) {
+                collectProjectedFieldIds(field, slot.getType(), projected);
+            }
+        }
+        return projected;
+    }
+
+    private static void collectProjectedFieldIds(
+            NestedField field, org.apache.doris.catalog.Type projectedType,
+            Set<Integer> projected) {
+        projected.add(field.fieldId());
+        if (projectedType instanceof StructType && 
field.type().isStructType()) {
+            for (StructField projectedChild : ((StructType) 
projectedType).getFields()) {
+                NestedField icebergChild = 
field.type().asStructType().fields().stream()
+                        .filter(child -> 
child.name().equalsIgnoreCase(projectedChild.getName()))
+                        .findFirst().orElse(null);
+                if (icebergChild != null) {
+                    collectProjectedFieldIds(icebergChild, 
projectedChild.getType(), projected);
+                }
+            }
+        } else if (projectedType instanceof ArrayType && 
field.type().isListType()) {
+            collectProjectedFieldIds(field.type().asListType().fields().get(0),
+                    ((ArrayType) projectedType).getItemType(), projected);
+        } else if (projectedType instanceof MapType && 
field.type().isMapType()) {
+            collectProjectedFieldIds(field.type().asMapType().fields().get(0),
+                    ((MapType) projectedType).getKeyType(), projected);
+            collectProjectedFieldIds(field.type().asMapType().fields().get(1),
+                    ((MapType) projectedType).getValueType(), projected);
+        }
+    }
+
+    @VisibleForTesting
+    static Iterable<Schema> reachableSchemas(Table table, Snapshot 
selectedSnapshot) {
+        Map<Integer, Schema> schemas = table.schemas();
+        List<Schema> reachable = new ArrayList<>();
+        Set<Long> visitedSnapshots = new HashSet<>();
+        Set<Integer> visitedSchemaIds = new HashSet<>();
+        Snapshot snapshot = selectedSnapshot;
+        while (snapshot != null && 
visitedSnapshots.add(snapshot.snapshotId())) {
+            Schema schema = schemas.get(snapshot.schemaId());
+            if (schema != null && visitedSchemaIds.add(snapshot.schemaId())) {
+                reachable.add(schema);
+            }
+            Map<String, String> summary = snapshot.summary();
+            String sourceSnapshotId = summary == null
+                    ? null : 
summary.get(SnapshotSummary.SOURCE_SNAPSHOT_ID_PROP);
+            if (sourceSnapshotId != null) {
+                Snapshot sourceSnapshot;
+                try {
+                    sourceSnapshot = 
table.snapshot(Long.parseLong(sourceSnapshotId));
+                } catch (NumberFormatException e) {
+                    sourceSnapshot = null;
+                }
+                if (sourceSnapshot == null || 
!schemas.containsKey(sourceSnapshot.schemaId())) {
+                    // A cherry-picked snapshot can contribute live files 
outside the selected
+                    // parent chain; unverifiable provenance must 
conservatively gate all schemas.
+                    for (Schema historicalSchema : schemas.values()) {
+                        if (visitedSchemaIds.add(historicalSchema.schemaId())) 
{
+                            reachable.add(historicalSchema);
+                        }
+                    }
+                } else if (visitedSchemaIds.add(sourceSnapshot.schemaId())) {
+                    reachable.add(schemas.get(sourceSnapshot.schemaId()));
+                }
+            }
+            Long parentId = snapshot.parentId();
+            snapshot = parentId == null ? null : table.snapshot(parentId);
+            if (parentId != null && snapshot == null) {
+                // Expiration may remove the parent metadata while descendants 
still inherit its
+                // files, so an incomplete lineage must gate against every 
surviving schema.
+                for (Schema historicalSchema : schemas.values()) {
+                    if (visitedSchemaIds.add(historicalSchema.schemaId())) {
+                        reachable.add(historicalSchema);
+                    }
+                }
+            }
+        }
+        // Only ancestors of the selected ref can have produced files visible 
to this scan.
+        return reachable;
+    }
+
+    @VisibleForTesting
+    static void checkIcebergScanSemanticsV2Compatibility(
+            boolean requiresV2, Iterable<Backend> backends) throws 
UserException {
+        if (!requiresV2) {
+            return;
+        }
+        for (Backend backend : backends) {
+            if (backend.isSmoothUpgradeSrc()) {
+                // A V1 BE accepts the Thrift field but does not enforce 
nested defaults/requiredness.
+                throw new UserException("Current Iceberg scan semantics are 
unavailable while backend "
+                        + backend.getId() + " is a smooth upgrade source");
+            }
+        }
+    }
+
+    @VisibleForTesting
+    static boolean schemaHistoryRequiresMissingRequiredFieldRejection(
+            Schema scanSchema, Iterable<Schema> historicalSchemas) {
+        return schemaHistoryRequiresMissingRequiredFieldRejection(
+                scanSchema, 
TypeUtil.indexById(scanSchema.asStruct()).keySet(), historicalSchemas);
+    }
+
+    private static boolean schemaHistoryRequiresMissingRequiredFieldRejection(
+            Schema scanSchema, Set<Integer> projectedFieldIds, 
Iterable<Schema> historicalSchemas) {
+        Map<Integer, NestedField> currentFields = 
TypeUtil.indexById(scanSchema.asStruct());
+        Map<Integer, Integer> parentById = 
TypeUtil.indexParents(scanSchema.asStruct());
+        Set<Integer> collectionWrapperIds = new HashSet<>();
+        collectCollectionWrapperFieldIds(scanSchema.asStruct(), 
collectionWrapperIds);
+        for (Schema historicalSchema : historicalSchemas) {
+            Map<Integer, NestedField> historicalFields = 
TypeUtil.indexById(historicalSchema.asStruct());
+            for (Integer fieldId : projectedFieldIds) {
+                NestedField field = currentFields.get(fieldId);
+                if (field == null || 
collectionWrapperIds.contains(field.fieldId())

Review Comment:
   [P1] Enforce required collection entries
   
   This skips list-element and map-value IDs from the requiredness-history 
gate, but those wrapper fields can change from optional to required while 
retaining the same ID, and reachable old files may still contain NULL entries. 
FE now transports `is_optional=false`, yet `icebergTypeToDorisType()` keeps 
collection children nullable; BE validates present children from `table_type` 
(and ARRAY explicitly makes the element mapping nullable) and consults 
`is_optional` only when a field is physically missing. The V2 reader therefore 
returns the old NULL under the current required schema, and this skip also 
permits a V1 smooth-upgrade BE. Please gate optional-to-required element/value 
history and validate present entries from the transported optionality, with 
historical-NULL coverage on both V1 and V2 paths.



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