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


##########
be/src/io/fs/azure_obj_storage_client.cpp:
##########
@@ -247,6 +256,41 @@ ObjectStorageResponse 
AzureObjStorageClient::complete_multipart_upload(
             opts, _tls_debug_context);
 }
 
+ObjectStorageResponse AzureObjStorageClient::abort_multipart_upload(
+        const ObjectStoragePathOptions& opts) {
+    auto client = _client->GetBlockBlobClient(opts.key);
+    auto response = do_azure_client_call(
+            [&]() {
+                GetBlockListOptions get_options;
+                get_options.ListType = Models::BlockListType::All;
+                auto block_list = client.GetBlockList(get_options);
+                const bool has_committed_blob = 
azure_block_list_has_committed_blob(
+                        block_list.Value.CommittedBlocks.size(), 
block_list.Value.ETag.HasValue());
+                if (!has_committed_blob) {
+                    // Uncommitted blocks are invisible and expire without 
deleting a racing commit.
+                    return;
+                }
+                if (block_list.Value.CommittedBlocks.empty()) {
+                    // Azure cannot selectively discard staged blocks without 
replacing Put Blob content.
+                    return;
+                }
+                std::vector<std::string> committed_ids;
+                committed_ids.reserve(block_list.Value.CommittedBlocks.size());
+                std::ranges::transform(block_list.Value.CommittedBlocks,
+                                       std::back_inserter(committed_ids),
+                                       [](const Models::BlobBlock& block) { 
return block.Name; });
+                CommitBlockListOptions commit_options;
+                commit_options.AccessConditions.IfMatch = 
block_list.Value.ETag;
+                // Recommitting only the old IDs discards this writer's unique 
staged blocks.
+                client.CommitBlockList(committed_ids, commit_options);

Review Comment:
   [P1] Do not recommit the prior block list while aborting. This SDK sends 
these IDs as `Latest`, so if a failed pre-upgrade writer left an uncommitted 
block with the same deterministic ID as a committed block, Azure selects the 
staged bytes; staging does not change the ETag, so `IfMatch` still succeeds and 
this abort silently rewrites the valid blob. Put Block List also resets omitted 
metadata/properties. Please leave this writer's unique blocks to expire or use 
explicit committed-version entries without mutating the object, and test a 
committed legacy block plus an uncommitted same-ID replacement.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java:
##########
@@ -1120,6 +1120,9 @@ private TableScan buildScan(Table table, 
IcebergTableHandle handle, Optional<Con
 
     /** Whether this table type's scan can actually honor Iceberg's 
snapshot/ref selection APIs. */
     private static boolean supportsSnapshotSelection(IcebergTableHandle 
handle) {
+        if (!handle.hasSnapshotSelection()) {

Review Comment:
   [P1] Preserve an explicit empty-table MVCC pin in the scan. A cached `-1` 
hit can skip statement-table materialization while the table cache misses or is 
disabled (for example, REST vended credentials). If S1 commits before scan 
planning, the scan freshly loads S1; this returns false and `table.newScan()` 
reads post-begin rows. A plain SELECT is not statement-consistent, and 
INSERT-SELECT can diverge from the explicit-empty write/OCC handle. Please 
short-circuit pinned empty to zero tasks and test cached-empty/first-commit 
interleaving.



##########
be/src/io/fs/azure_obj_storage_client.cpp:
##########
@@ -238,7 +247,7 @@ ObjectStorageResponse 
AzureObjStorageClient::complete_multipart_upload(
     std::vector<std::string> string_block_ids;
     std::ranges::transform(
             completed_parts, std::back_inserter(string_block_ids),
-            [](const ObjectCompleteMultiPart& i) { return 
base64_encode_part_num(i.part_num); });
+            [&opts](const ObjectCompleteMultiPart& i) { return 
azure_block_id(opts, i.part_num); });

Review Comment:
   [P1] Do not treat UUID block IDs as isolated Azure upload sessions. These 
IDs are still staged in the blob-wide namespace, and committing this writer's 
list deletes every uncommitted block omitted from it. Thus if same-key writers 
A and B stage blocks, A's completion (or abort recommit below) discards B's 
blocks and B later fails despite its uploads succeeding; the FE-deferred 
completion path has the same behavior. Please fence same-key writes with a 
provider-visible conditional protocol or use per-writer temporary blobs, and 
cover interleaved complete/abort cases.



##########
be/src/exec/operator/spill_iceberg_table_sink_operator.cpp:
##########
@@ -55,47 +55,60 @@ size_t 
SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
     if (!_writer) {
         return 0;
     }
-    auto current_writer = _writer->current_writer();
-    auto* sort_writer = 
dynamic_cast<VIcebergSortWriter*>(current_writer.get());
-    if (!sort_writer) {
-        return 0;
+    std::vector<IcebergSorterReserveMemory> per_partition_reservations;
+    auto active_writers = _writer->active_writers();

Review Comment:
   [P1] Reserve memory for partitions this block will create. On the first 
block (or any block introducing new partitions), `active_writers` is empty or 
incomplete because the async consumer publishes a writer only after admission, 
and an empty sorter also reports zero. The admitted block can then allocate 
every per-partition selection and grow those sorters without 
`_try_to_reserve_memory` covering them. This is separate from the existing 
aggregation comments for already-active sorters. Please make admission 
block-aware or conservatively cover not-yet-published sorters, and add a cold 
many-partition test that verifies nonzero reservation before the first append.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java:
##########
@@ -0,0 +1,317 @@
+// 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.
+
+package org.apache.doris.connector.iceberg.action;
+
+import org.apache.doris.connector.api.ConnectorColumn;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorType;
+import org.apache.doris.connector.api.DorisConnectorException;
+import org.apache.doris.connector.api.pushdown.ConnectorPredicate;
+import org.apache.doris.foundation.util.ArgumentParsers;
+
+import com.google.common.collect.Lists;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.ManifestContent;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
+import org.apache.iceberg.ReachableFileUtil;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.io.FileInfo;
+import org.apache.iceberg.io.SupportsPrefixOperations;
+import org.apache.iceberg.util.PropertyUtil;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/** Safely lists or deletes old files that are unreachable from every retained 
snapshot. */
+public class IcebergRemoveOrphanFilesAction extends BaseIcebergAction {
+    private static final long MIN_RETENTION_MS = 
Duration.ofHours(24).toMillis();
+    public static final String OLDER_THAN = "older_than";
+    public static final String LOCATION = "location";
+    public static final String DRY_RUN = "dry_run";
+
+    public IcebergRemoveOrphanFilesAction(Map<String, String> properties, 
List<String> partitionNames,
+            ConnectorPredicate whereCondition) {
+        super("remove_orphan_files", properties, partitionNames, 
whereCondition);
+    }
+
+    @Override
+    protected void registerIcebergArguments() {
+        namedArguments.registerRequiredArgument(OLDER_THAN, "Creation time 
cutoff in milliseconds",
+                ArgumentParsers.nonNegativeLong(OLDER_THAN));
+        namedArguments.registerOptionalArgument(LOCATION, "Prefix within the 
table location",
+                null, ArgumentParsers.nonEmptyString(LOCATION));
+        namedArguments.registerOptionalArgument(DRY_RUN, "Only count orphan 
files", true,
+                ArgumentParsers.booleanValue(DRY_RUN));
+    }
+
+    @Override
+    protected void validateIcebergAction() {
+        validateNoPartitions();
+        validateNoWhereCondition();
+        String location = namedArguments.getString(LOCATION);
+        if (location != null) {
+            try {
+                normalizeLocation(location);
+            } catch (IllegalArgumentException e) {
+                throw new DorisConnectorException("Invalid location URI: " + 
location, e);
+            }
+        }
+    }
+
+    @Override
+    protected List<String> executeAction(Table table, ConnectorSession 
session) {
+        if (!(table.io() instanceof SupportsPrefixOperations)) {
+            throw new DorisConnectorException("remove_orphan_files requires 
FileIO prefix listing support");
+        }
+        if (!PropertyUtil.propertyAsBoolean(table.properties(), 
TableProperties.GC_ENABLED,
+                TableProperties.GC_ENABLED_DEFAULT)) {
+            // A GC-disabled table may share files with another table, so no 
destructive scan is safe.
+            throw new DorisConnectorException("Cannot remove orphan files: 
Iceberg GC is disabled");
+        }
+        List<String> scanLocations = resolveScanLocations(table);
+
+        try {
+            ReachableIndex reachable = new 
ReachableIndex(collectReachableFiles(table));
+            long orphanCount = 0;
+            long deletedCount = 0;
+            long olderThan = namedArguments.getLong(OLDER_THAN);
+            // The SQL procedure needs a retention fence because concurrent 
uploads are not reachable until commit.
+            if (olderThan > System.currentTimeMillis() - MIN_RETENTION_MS) {
+                throw new DorisConnectorException(
+                        "older_than must retain at least 24 hours of files");
+            }
+            boolean dryRun = namedArguments.getBoolean(DRY_RUN);
+            Set<String> visitedFiles = new HashSet<>();
+            for (String scanLocation : scanLocations) {
+                // Object stores use raw prefix matching, so the separator 
excludes sibling prefixes.
+                String listingPrefix = scanLocation.endsWith("/") ? 
scanLocation : scanLocation + "/";
+                for (FileInfo file : ((SupportsPrefixOperations) 
table.io()).listPrefix(listingPrefix)) {
+                    if (visitedFiles.add(file.location()) && 
file.createdAtMillis() < olderThan
+                            && !isReachable(file.location(), reachable)) {
+                        orphanCount++;
+                        if (!dryRun) {
+                            table.io().deleteFile(file.location());
+                            deletedCount++;
+                        }
+                    }
+                }
+            }
+            return Lists.newArrayList(String.valueOf(orphanCount), 
String.valueOf(deletedCount));
+        } catch (Exception e) {
+            throw new DorisConnectorException("Failed to remove orphan files: 
" + e.getMessage(), e);
+        }
+    }
+
+    private List<String> resolveScanLocations(Table table) {
+        String tableRoot = normalizeLocation(table.location());
+        String dataRoot = normalizeLocation(resolveDataLocation(table, 
tableRoot));
+        Set<String> ownedRoots = new LinkedHashSet<>();
+        ownedRoots.add(tableRoot);
+        ownedRoots.add(dataRoot);
+
+        String requested = namedArguments.getString(LOCATION);
+        if (requested != null) {
+            String normalized = normalizeLocation(requested);
+            boolean owned = ownedRoots.stream().anyMatch(root -> 
isWithin(normalized, root));
+            if (!owned) {
+                throw new DorisConnectorException(
+                        "location must be within an Iceberg table-owned 
metadata or data location");
+            }
+            return Lists.newArrayList(normalized);
+        }
+
+        List<String> roots = new ArrayList<>();
+        for (String candidate : ownedRoots) {
+            // Avoid listing a nested default data directory twice when the 
table root already covers it.
+            if (ownedRoots.stream().noneMatch(other -> 
!other.equals(candidate) && isWithin(candidate, other))) {

Review Comment:
   [P2] Keep one root when the configured roots are canonically equal. For 
example, `table.location = s3a://bucket/table` and `write.data.path = 
s3://bucket/table` remain two raw strings, but `isWithin` considers each inside 
the other, so this predicate drops both and the default cleanup silently lists 
nothing. Please deduplicate by `FileIdentity` or remove only strictly nested 
roots, and test alias-equivalent table/data locations.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java:
##########
@@ -81,12 +89,17 @@ Optional<String> getBranchName() {
 
     /**
      * The statement's READ snapshot id (the MVCC pin the scan used, S_read), 
threaded from the write
-     * handle in {@code planWrite}; {@code -1} = no pin (the legacy 
fresh-current behavior). The
+     * handle in {@code planWrite}; {@code -1} means either no pin or an 
explicitly empty read, as
+     * distinguished by {@link #isReadSnapshotPinned()}. The
      * RowDelta path anchors {@code baseSnapshotId} at this snapshot so the 
commit-time removeDeletes
      * (option D) and the scan-time deletes BE unions into the new DV share 
one snapshot — see
      * {@link IcebergConnectorTransaction} [SHOULD-2] / Fix B.
      */
     long getReadSnapshotId() {
         return readSnapshotId;
     }
+
+    boolean isReadSnapshotPinned() {

Review Comment:
   [P1] Consume this explicit-empty distinction in the RowDelta begin guard 
too. DELETE/UPDATE/MERGE still test only `readSnapshotId >= 0`; for a pinned 
empty target they fall back to the writable table's current snapshot. If MERGE 
scans the empty target and S1 commits before `beginWrite`, S1 becomes 
`validateFromSnapshot` instead of a conflict, so `WHEN NOT MATCHED` rows 
planned against emptiness can commit stale or duplicate results. Please 
preserve an empty validation fence for RowDelta and add a 
first-snapshot-between-scan-and-begin test.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java:
##########
@@ -0,0 +1,317 @@
+// 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.
+
+package org.apache.doris.connector.iceberg.action;
+
+import org.apache.doris.connector.api.ConnectorColumn;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorType;
+import org.apache.doris.connector.api.DorisConnectorException;
+import org.apache.doris.connector.api.pushdown.ConnectorPredicate;
+import org.apache.doris.foundation.util.ArgumentParsers;
+
+import com.google.common.collect.Lists;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.ManifestContent;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
+import org.apache.iceberg.ReachableFileUtil;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.io.FileInfo;
+import org.apache.iceberg.io.SupportsPrefixOperations;
+import org.apache.iceberg.util.PropertyUtil;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/** Safely lists or deletes old files that are unreachable from every retained 
snapshot. */
+public class IcebergRemoveOrphanFilesAction extends BaseIcebergAction {
+    private static final long MIN_RETENTION_MS = 
Duration.ofHours(24).toMillis();
+    public static final String OLDER_THAN = "older_than";
+    public static final String LOCATION = "location";
+    public static final String DRY_RUN = "dry_run";
+
+    public IcebergRemoveOrphanFilesAction(Map<String, String> properties, 
List<String> partitionNames,
+            ConnectorPredicate whereCondition) {
+        super("remove_orphan_files", properties, partitionNames, 
whereCondition);
+    }
+
+    @Override
+    protected void registerIcebergArguments() {
+        namedArguments.registerRequiredArgument(OLDER_THAN, "Creation time 
cutoff in milliseconds",
+                ArgumentParsers.nonNegativeLong(OLDER_THAN));
+        namedArguments.registerOptionalArgument(LOCATION, "Prefix within the 
table location",
+                null, ArgumentParsers.nonEmptyString(LOCATION));
+        namedArguments.registerOptionalArgument(DRY_RUN, "Only count orphan 
files", true,
+                ArgumentParsers.booleanValue(DRY_RUN));
+    }
+
+    @Override
+    protected void validateIcebergAction() {
+        validateNoPartitions();
+        validateNoWhereCondition();
+        String location = namedArguments.getString(LOCATION);
+        if (location != null) {
+            try {
+                normalizeLocation(location);
+            } catch (IllegalArgumentException e) {
+                throw new DorisConnectorException("Invalid location URI: " + 
location, e);
+            }
+        }
+    }
+
+    @Override
+    protected List<String> executeAction(Table table, ConnectorSession 
session) {
+        if (!(table.io() instanceof SupportsPrefixOperations)) {
+            throw new DorisConnectorException("remove_orphan_files requires 
FileIO prefix listing support");
+        }
+        if (!PropertyUtil.propertyAsBoolean(table.properties(), 
TableProperties.GC_ENABLED,
+                TableProperties.GC_ENABLED_DEFAULT)) {
+            // A GC-disabled table may share files with another table, so no 
destructive scan is safe.
+            throw new DorisConnectorException("Cannot remove orphan files: 
Iceberg GC is disabled");
+        }
+        List<String> scanLocations = resolveScanLocations(table);
+
+        try {
+            ReachableIndex reachable = new 
ReachableIndex(collectReachableFiles(table));
+            long orphanCount = 0;
+            long deletedCount = 0;
+            long olderThan = namedArguments.getLong(OLDER_THAN);
+            // The SQL procedure needs a retention fence because concurrent 
uploads are not reachable until commit.
+            if (olderThan > System.currentTimeMillis() - MIN_RETENTION_MS) {
+                throw new DorisConnectorException(
+                        "older_than must retain at least 24 hours of files");
+            }
+            boolean dryRun = namedArguments.getBoolean(DRY_RUN);
+            Set<String> visitedFiles = new HashSet<>();
+            for (String scanLocation : scanLocations) {
+                // Object stores use raw prefix matching, so the separator 
excludes sibling prefixes.
+                String listingPrefix = scanLocation.endsWith("/") ? 
scanLocation : scanLocation + "/";
+                for (FileInfo file : ((SupportsPrefixOperations) 
table.io()).listPrefix(listingPrefix)) {
+                    if (visitedFiles.add(file.location()) && 
file.createdAtMillis() < olderThan
+                            && !isReachable(file.location(), reachable)) {
+                        orphanCount++;
+                        if (!dryRun) {
+                            table.io().deleteFile(file.location());
+                            deletedCount++;
+                        }
+                    }
+                }
+            }
+            return Lists.newArrayList(String.valueOf(orphanCount), 
String.valueOf(deletedCount));
+        } catch (Exception e) {
+            throw new DorisConnectorException("Failed to remove orphan files: 
" + e.getMessage(), e);
+        }
+    }
+
+    private List<String> resolveScanLocations(Table table) {
+        String tableRoot = normalizeLocation(table.location());
+        String dataRoot = normalizeLocation(resolveDataLocation(table, 
tableRoot));
+        Set<String> ownedRoots = new LinkedHashSet<>();

Review Comment:
   [P2] Include the configured Iceberg metadata root in `ownedRoots`. When 
`write.metadata.path` points outside `table.location`, table operations create 
metadata JSON, manifest-list, and manifest artifacts there; the default action 
never lists that root, and an explicit `location` is rejected as unowned. 
Failed or obsolete metadata files therefore cannot be cleaned. Please admit the 
exact configured metadata root, deduplicate it canonically with table/data 
roots, and test separate table, data, and metadata locations.



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