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


##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java:
##########
@@ -164,6 +176,7 @@ public void beginRewrite(ExternalTable dorisTable, Table 
targetTable) throws Use
         // For rewrite operations, we work directly on the main table
         this.branchName = null;
         this.isRewriteMode = true;
+        acquireCommitFence(targetTable);

Review Comment:
   [P1] Release the rewrite fence on every failed execution
   
   After this read stamp is acquired, 
`RewriteDataFileExecutor.executeGroupsConcurrently()` can still throw while 
discovering backends, submitting only part of the task set, waiting for tasks 
(timeout/interruption/worker error), or running `finishRewrite()`. That caller 
has no rollback/finally path; its only `transaction.commit()` is after all of 
those operations. Because the transaction remains registered and this guard is 
released only by `commit()`/`rollback()`, any such rewrite failure permanently 
holds the stripe's read lock, so a later `remove_orphan_files` blocks forever 
acquiring the maintenance write lock (and a hash-colliding table can be blocked 
too). Please put the rewrite transaction under a success/finally scope, cancel 
or quiesce any submitted rewrite tasks, and roll it back through the 
transaction manager on every non-committed exit.



##########
be/src/format_v2/table_reader.h:
##########
@@ -1658,9 +1867,10 @@ class TableReader {
         // storage invariant, so add it only at the materialization boundary.
         element_mapping.table_type = make_nullable(element_mapping.table_type);
         NullMap descendant_parent_null_map;
-        const NullMap* descendant_parent_null_map_ptr = 
_project_collection_parent_null_map(
-                parent_null_map, nullable_parent_null_map, rows, 
file_array->get_offsets(),
-                nested_column->size(), &descendant_parent_null_map);
+        const NullMap* descendant_parent_null_map_ptr =
+                _project_collection_parent_null_map_for_hidden_entries(

Review Comment:
   [P1] Probe consumers before allocating the collection mask
   
   The new fast path in `_align_column_nullability()` uses 
`_requires_collection_parent_null_map()` before projecting an entry-coordinate 
mask, but this nontrivial ARRAY path (and the MAP path below) still projects 
unconditionally. If one ancestor-hidden row has even one physical entry, the 
helper allocates and zero-fills `child_rows` bytes, even when all mapped 
descendants are optional or already protected by nearer nullable wrappers and 
cannot consume that mask. A valid evolved collection can have a tiny hidden 
span plus millions of visible entries, so this remains an avoidable unbounded 
transient allocation/OOM path. Please add a mapping-aware consumer check for 
the present element/key/value descendants before projecting (the existing probe 
cannot be applied blindly to a differently shaped evolved STRUCT), and add a 
nontrivial large-collection test.



##########
be/src/exec/sink/writer/paimon/paimon_table_writer.cpp:
##########
@@ -24,6 +24,39 @@
 
 namespace doris {
 
+PaimonPreparedCommitOwner::PaimonPreparedCommitOwner(std::unique_ptr<IPaimonWriter>
 writer,
+                                                     
std::unique_ptr<IPaimonWriteBackend> backend)
+        : _writer(std::move(writer)), _backend(std::move(backend)) {}
+
+PaimonPreparedCommitOwner::~PaimonPreparedCommitOwner() {
+    _close();
+}
+
+void PaimonPreparedCommitOwner::finalize(ExternalFileReportOutcome outcome) {
+    if (_finalized || outcome == ExternalFileReportOutcome::AMBIGUOUS) {
+        return;
+    }
+    _finalized = true;
+    if (outcome == ExternalFileReportOutcome::REJECTED && _writer) {
+        Status abort_status = _writer->abort();
+        if (!abort_status.ok()) {
+            LOG(WARNING) << "Paimon prepared writer abort failed: " << 
abort_status.to_string();
+        }
+    }
+    _close();
+}
+
+void PaimonPreparedCommitOwner::_close() {
+    _writer.reset();
+    if (_backend) {
+        Status close_status = _backend->close();

Review Comment:
   [P1] Do not discard the prepared backend's close failure after ACK
   
   This close now runs only from the report finalizer. On the ACK path FE has 
already stored the Paimon payloads and replied that ownership transferred, so 
reducing a failure here to a warning leaves no way to fail or roll back the 
transaction. That is a regression from the prior close path, which treated 
Java/backend close as the authoritative SDK shutdown boundary and published 
messages only after it succeeded; `JniPaimonWriteBackend::close()` can fail 
while SDK users may still be active and deliberately retains native memory in 
that case. The transaction can now commit those payloads anyway. Please make 
successful backend shutdown part of the acceptance protocol (while retaining an 
abort-capable owner for rejection), or otherwise propagate a post-accept close 
failure to the coordinator so the transaction cannot commit, and cover a 
failing ACK-path close.



##########
be/src/exec/sort/sorter.cpp:
##########
@@ -184,35 +199,62 @@ bool FullSorter::has_enough_capacity(Block* input_block, 
Block* unsorted_block)
 }
 
 size_t FullSorter::get_reserve_mem_size(RuntimeState* state, bool eos) const {
-    size_t size_to_reserve = 0;
+    return get_reserve_mem_size_components(state, eos).total();
+}
+
+SorterReserveMemory FullSorter::get_reserve_mem_size_components(RuntimeState* 
state,
+                                                                bool eos) 
const {
+    const auto rows = _state->unsorted_block()->rows();
+    const auto bytes = _state->unsorted_block()->bytes();
+    const auto bytes_per_row = rows == 0 ? 0 : bytes / rows;
+    return get_reserve_mem_size_components(
+            state, eos, state->batch_size(),
+            saturating_multiply_size(bytes_per_row, state->batch_size()));
+}
+
+SorterReserveMemory FullSorter::get_reserve_mem_size_components(RuntimeState* 
state, bool eos,
+                                                                size_t 
incoming_rows,
+                                                                size_t 
incoming_bytes) const {
+    SorterReserveMemory reserve;
     const auto rows = _state->unsorted_block()->rows();
     if (rows != 0) {
         const auto bytes = _state->unsorted_block()->bytes();
         const auto allocated_bytes = 
_state->unsorted_block()->allocated_bytes();
-        const auto bytes_per_row = bytes / rows;
-        const auto estimated_size_of_next_block = bytes_per_row * 
state->batch_size();
-        auto new_block_bytes = estimated_size_of_next_block + bytes;
-        auto new_rows = rows + state->batch_size();
+        auto new_block_bytes = saturating_add_size(bytes, incoming_bytes);
+        auto new_rows = saturating_add_size(rows, incoming_rows);
         // If the new size is greater than 85% of allocalted bytes, it maybe 
need to realloc.
-        if ((new_block_bytes * 100 / allocated_bytes) >= 85) {
-            size_to_reserve += (size_t)(allocated_bytes * 1.15);
+        const auto growth_threshold = static_cast<size_t>(
+                (static_cast<unsigned __int128>(allocated_bytes) * 85 + 99) / 
100);
+        const size_t growth_trigger_bytes = growth_threshold > bytes ? 
growth_threshold - bytes : 0;
+        if (incoming_rows > 0 && growth_trigger_bytes <= incoming_bytes) {
+            reserve.retained_growth = static_cast<size_t>(std::min<unsigned 
__int128>(
+                    (static_cast<unsigned __int128>(allocated_bytes) * 115 + 
99) / 100,
+                    std::numeric_limits<size_t>::max()));
+            reserve.retained_growth_trigger_bytes = growth_trigger_bytes;
         }
-        auto sort = new_rows > _buffered_block_size || new_block_bytes > 
_buffered_block_bytes;
+        // Iceberg close forces every nonempty pending run to sort at EOS, 
even when the generic
+        // append thresholds are not reached, so admission must cover that 
final allocation too.
+        auto sort = (eos && new_rows > 0) || new_rows > _buffered_block_size ||

Review Comment:
   [P1] Reserve for target-size-triggered sorts too
   
   This predicate misses another immediate `do_sort()` path: 
`VIcebergSortWriter::write()` calls `_flush_to_file()` as soon as `data_size()` 
reaches the user-supplied target file size, and that target accepts any 
positive value. For example, a run below 4M rows can cross a 128 MiB target 
while remaining below this 256 MiB condition; the estimator then admits only 
append growth, but `_flush_to_file()` immediately materializes the full sorted 
destination plus permutation while the source stays live. Under memory pressure 
that allocation escapes the reservation and can hit the hard limit/OOM. Please 
include the same `current data_size + incoming >= target` rollover predicate in 
the reservation (or pass the target into this estimator), with a below-256-MiB 
test.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java:
##########
@@ -298,6 +306,224 @@ 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(

Review Comment:
   [P2] Limit the V2 gate to schemas reachable by this scan
   
   `createTableScan()` projects the schema bound to the selected snapshot/ref, 
but this call checks it against every schema in the table metadata. The helper 
returns true if a projected required field is absent or optional in any of 
them, including a schema created only after a time-traveled snapshot or only on 
another branch. Those schemas cannot have produced a file reachable by this 
scan, so V1 semantics are safe, yet the query is rejected for the whole 
rolling-upgrade window whenever an old BE is present. Please derive the 
relevant schema IDs from the selected snapshot/ref's reachable 
history/manifests instead of using the table-wide schema map, and cover a 
later/unrelated schema in the upgrade test.



##########
be/src/exec/operator/spill_iceberg_table_sink_operator.cpp:
##########
@@ -26,12 +28,42 @@
 namespace doris {
 #include "common/compile_check_begin.h"
 
+size_t iceberg_cold_writer_reserve_size(const Block& block, size_t 
writer_workspace_bytes) {
+    const size_t block_bytes = block.allocated_bytes();
+    const size_t row_index_bytes =
+            std::min(std::numeric_limits<size_t>::max() / sizeof(size_t), 
block.rows()) *
+            sizeof(size_t);
+    const size_t dispatch_copies = block_bytes > 
std::numeric_limits<size_t>::max() / 4
+                                           ? std::numeric_limits<size_t>::max()
+                                           : block_bytes * 4;
+    size_t reserve = iceberg_saturating_add(writer_workspace_bytes, 
dispatch_copies);
+    if (block.rows() > 0) {
+        size_t minimum_selected_block_bytes = 0;
+        for (const auto& column : block.get_columns_with_type_and_name()) {
+            minimum_selected_block_bytes =
+                    iceberg_saturating_add(minimum_selected_block_bytes,
+                                           
column.column->clone_resized(1)->allocated_bytes());

Review Comment:
   [P1] Keep reserve estimation allocation-free
   
   `PipelineTask::execute()` calls this estimator before 
`_try_to_reserve_memory()`, but `clone_resized(1)` copies the complete first 
value: STRING copies all bytes through its first offset, ARRAY copies every 
element in the first array, and MAP copies every first-row key/value. A legal 
block with a tens- or hundreds-of-MiB first value can therefore allocate and 
copy that payload under pressure before the returned reserve can 
pause/revoke/admit the sink, defeating the admission boundary and potentially 
hitting the hard limit/OOM. The full-block `dispatch_copies` term already 
accounts for the actual input payload. Please derive the minimum 
selected-column capacity without cloning real data (or otherwise make this 
estimator allocation-free), and test a huge first STRING/ARRAY/MAP value under 
memory pressure.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java:
##########
@@ -298,6 +306,224 @@ 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, 
icebergTable.schemas().values());
+    }
+
+    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) {
+        Set<Integer> projected = new HashSet<>();
+        for (SlotDescriptor slot : desc.getSlots()) {
+            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) {
+                projected.addAll(TypeUtil.indexById(

Review Comment:
   [P2] Gate only on descendants this scan actually projects
   
   Nested-column pruning replaces the effective slot type and retains the 
actual access paths, but this rebuilds IDs from the full Iceberg root field and 
therefore adds every schema descendant. For example, `SELECT payload.keep` can 
be rejected during rolling upgrade solely because an unrequested sibling such 
as `payload.added` has a nested initial default, alias collision, or 
requiredness history: `requiresV2` becomes true even though BE will not 
materialize that sibling. This is independent of limiting history to schemas 
reachable by the selected snapshot/ref. Please derive descendant IDs from the 
slot's pruned type/access paths while retaining the stable Iceberg root ID, and 
add a mixed-version test where only the unrequested sibling requires V2 
semantics.



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