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


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java:
##########
@@ -80,10 +87,26 @@ public void run(ConnectContext ctx, StmtExecutor 
stmtExecutor) throws Exception
             Callable<Void> execute = () -> {
                 LogicalPlanAdapter logicalPlanAdapter = new 
LogicalPlanAdapter(plan, ctx.getStatementContext());
                 NereidsPlanner planner = new 
NereidsPlanner(ctx.getStatementContext());
+                if (op == RowLevelDmlOp.DELETE) {
+                    captureAnalyzedPlan(ctx);
+                }
                 planner.plan(logicalPlanAdapter, 
ctx.getSessionVariable().toThrift());
                 stmtExecutor.setPlanner(planner);
                 stmtExecutor.checkBlockRules();
 
+                // Whole-file metadata delete (Spark/StarRocks-style): when 
the connector can satisfy the DELETE
+                // by dropping whole data files, it commits that remote 
metadata operation here and the
+                // row-level scan/sink never runs. Skipped for a 
PARTITION(...) scope, whose row restriction is
+                // carried by the scan rather than by the statement predicate 
the connector receives.
+                if (op == RowLevelDmlOp.DELETE && 
args.getPartitions().isEmpty() && !args.isTempPart()) {
+                    OptionalLong metadataDeletedRows =
+                            transform.deleteUsingMetadata(ctx, args, 
planner.getAnalyzedPlan());

Review Comment:
   [P1] Preserve cancellation before committing a metadata delete
   
   If KILL QUERY or the timeout checker cancels a DELETE while planner.plan is 
still loading remote metadata, StmtExecutor retains the cancellation reason 
without interrupting the planning thread. The ordinary row-level path publishes 
its coordinator in AbstractInsertExecutor.executeSingleInsert, replays that 
cancellation, and checks terminal status again before commit. This branch 
bypasses those fences and directly invokes a committing connector method, so an 
already-cancelled whole-partition DELETE can still remove data and report OK. 
Cancellation during the whole-file preflight is also invisible to the 
connector. Check the retained status before dispatch and carry a cancellation 
check through to the remote commit, preserving the original cancellation result.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1938,6 +1970,324 @@ public void validateRowLevelDmlMode(ConnectorSession 
session, ConnectorTableHand
         }
     }
 
+    /**
+     * Whole-file metadata delete for {@code DELETE} (the engine's fast path): 
commit the delete as one atomic
+     * metadata operation that removes every matching data file, instead of 
scanning rows and writing position
+     * delete files / deletion vectors. This is what Spark, StarRocks and 
Trino do for whole-partition and
+     * whole-file predicates, and it is the only way to get rid of a data file 
whose rows are all gone: a
+     * row-level Doris DELETE keeps the file (plus a redundant deletion 
vector) in the snapshot, and every later
+     * scan still opens and reads it.
+     *
+     * <p>The predicate is lowered with {@link 
IcebergPredicateConverter.Mode#REWRITE} — the strictly
+     * all-or-nothing matrix, because a dropped conjunct would widen the 
delete — and then every candidate data
+     * file must be proven matched <i>in full</i> (strict partition projection 
or file metrics), exactly the
+     * check Spark's {@code SparkTable.canDeleteUsingMetadata} and StarRocks's
+     * {@code IcebergMetadata.canDeleteUsingMetadata} perform. Only then is 
the delete committed: without that
+     * proof iceberg's {@code deleteFromRowFilter} would fail the statement 
with "Cannot delete file where some,
+     * but not all, rows match filter".</p>
+     *
+     * <p>Anything that cannot be proven returns empty and the engine runs the 
ordinary row-level delete; a
+     * {@link ValidationException} raised at commit (the table moved between 
the check and the commit, so a
+     * candidate file is no longer fully matched) does the same, because 
nothing was committed.</p>
+     *
+     * <p>The returned row count is iceberg's {@code deleted-records} summary 
minus the rows that the delete
+     * files removed with those data files had already hidden (mirroring 
Trino's {@code executeDelete}), i.e. the
+     * rows this statement deleted. As in the engines above, the commit 
applies the predicate to the table's
+     * current state: rows concurrently appended into a deleted partition are 
deleted as well, whereas a
+     * row-level Doris DELETE would only delete the rows it scanned.</p>
+     */
+    @Override
+    public OptionalLong deleteUsingMetadata(ConnectorSession session, 
ConnectorTableHandle handle,
+            Optional<ConnectorExpression> predicate, String 
boundWriteMetadataIdentity) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            // withTable runs the configured cached-table cleanup in finally: 
the fresh load below owns its
+            // FileIO on Glue / S3Tables / vended-credential REST catalogs, so 
it must not outlive this call.
+            return executeAuthenticated(() -> catalogOps.withTable(
+                    iceHandle.getDbName(), iceHandle.getTableName(),
+                    table -> deleteUsingMetadata(table, session, predicate, 
boundWriteMetadataIdentity)));
+        } catch (ValidationException e) {
+            // A racing writer turned a candidate file into a partial match 
(or another writer already removed
+            // it): nothing was committed, so the row-level path still 
produces the right result.
+            LOG.info("Metadata delete of {} is no longer whole-file ({}); 
falling back to the row-level delete.",
+                    qualifiedName, e.getMessage());
+            return OptionalLong.empty();
+        } catch (Exception e) {
+            throw new DorisConnectorException("Failed to metadata-delete 
Iceberg table " + qualifiedName
+                    + ": " + e.getMessage(), e);
+        }
+    }
+
+    /** Body of the whole-file delete, run against a fresh table load inside 
{@code withTable}. */
+    private OptionalLong deleteUsingMetadata(Table table, ConnectorSession 
session,
+            Optional<ConnectorExpression> predicate, String 
boundWriteMetadataIdentity) {
+        validateBoundWriteGeneration(table, boundWriteMetadataIdentity);
+        Expression deleteExpr = predicate.isPresent()
+                ? wholeFileDeleteExpression(table, session, predicate.get())
+                : Expressions.alwaysTrue();
+        if (deleteExpr == null) {
+            return OptionalLong.empty();
+        }
+        Snapshot base = table.currentSnapshot();
+        if (base == null) {
+            // Nothing to delete; leave the empty-table path to the row-level 
plan.
+            return OptionalLong.empty();
+        }
+        long baseSnapshotId = base.snapshotId();
+        List<DeleteFile> liveDeletes = readLiveDeleteFiles(table, base);
+        List<DataFile> candidates = new ArrayList<>();
+        if (!matchesWholeFilesOnly(table, deleteExpr, !liveDeletes.isEmpty(), 
candidates)) {
+            return OptionalLong.empty();
+        }
+        if (!liveDeletes.isEmpty() && !onlyExactlyCountedDeletesApply(table, 
liveDeletes, candidates)) {
+            // The affected rows cannot be derived exactly from the snapshot 
summary; keeping the row-level path
+            // keeps the reported affected-row count exact.
+            LOG.info("Skipping metadata delete of {}: affected files carry 
delete files whose row count is not "
+                    + "exact (legacy position or equality deletes).", 
table.name());
+            return OptionalLong.empty();
+        }
+        // The load above is a metadata snapshot; re-read it immediately 
before committing so a drop/recreate or
+        // schema rebind between planning and commit aborts instead of hitting 
the new table.
+        table.refresh();
+        validateBoundWriteGeneration(table, boundWriteMetadataIdentity);
+
+        // Use a transaction rather than table.newDelete(): its ops carry the 
plugin Kerberos FileIO for Iceberg's
+        // worker-pool manifest work, and its table view keeps the metadata it 
committed, so currentSnapshot()
+        // below is this delete's own snapshot even if another writer 
committed meanwhile.
+        Transaction transaction = 
IcebergConnectorTransaction.openTransaction(table, context, null, false);
+        DeleteFiles deleteFiles = 
transaction.newDelete().deleteFromRowFilter(deleteExpr);
+        deleteFiles.commit();
+        transaction.commitTransaction();
+        Snapshot committed = transaction.table().currentSnapshot();
+        if (committed == null || committed.snapshotId() == baseSnapshotId) {
+            // The predicate matched nothing; DeleteFiles does not create an 
empty snapshot.
+            return OptionalLong.of(0L);
+        }
+        return OptionalLong.of(deletedRowCount(committed));
+    }
+
+    /** Aborts a stale statement when the table was dropped/recreated (or 
rebound) after it was planned. */
+    private static void validateBoundWriteGeneration(Table table, String 
boundWriteMetadataIdentity) {
+        if (boundWriteMetadataIdentity != null
+                && 
!boundWriteMetadataIdentity.equals(IcebergWritePlanProvider.writeMetadataIdentity(table)))
 {
+            throw new DorisConnectorException(
+                    "Iceberg write metadata changed after the write was bound; 
retry the statement");
+        }
+    }
+
+    /**
+     * Lowers the statement predicate to an iceberg expression with the 
strictly all-or-nothing
+     * {@link IcebergPredicateConverter.Mode#REWRITE} matrix, or {@code null} 
when it is not fully representable
+     * (the caller then falls back to the row-level delete). A bare {@code 
true} — an unfiltered
+     * {@code DELETE FROM t} — is {@link Expressions#alwaysTrue()}; the 
rewrite matrix rejects bare literals, so
+     * that case is handled here.
+     *
+     * <p>{@code forTable} also carries the schema/type history: a DATE field 
promoted to TIMESTAMP_NS makes old
+     * four-byte bounds unreadable, and such predicates must fall back instead 
of pruning on mis-decoded metrics.</p>
+     */
+    private Expression wholeFileDeleteExpression(Table table, ConnectorSession 
session,
+            ConnectorExpression predicate) {
+        if (!isExactDeletePredicate(predicate)) {
+            // Negation other than IS NOT NULL (or a negated IN) changes NULL 
semantics: iceberg's notEqual/notIn
+            // match NULL rows while SQL's NOT(UNKNOWN) does not, so such a 
predicate must not drive this path.
+            return null;
+        }
+        if (predicate instanceof ConnectorLiteral) {
+            return Boolean.TRUE.equals(((ConnectorLiteral) 
predicate).getValue())
+                    ? Expressions.alwaysTrue() : null;
+        }
+        ZoneId zone = IcebergTimeUtils.resolveSessionZone(session);
+        List<Expression> predicates = IcebergPredicateConverter.forTable(
+                table, table.schema(), zone, 
IcebergPredicateConverter.Mode.REWRITE).convert(predicate);
+        // Same guard as RewriteDataFilePlanner: that matrix drops an 
unconvertible top-level conjunct instead of
+        // failing, and a dropped conjunct would widen the delete here.
+        if (predicates.isEmpty() || predicates.size() < 
countTopLevelConjuncts(predicate)) {
+            return null;
+        }
+        Expression combined = null;
+        for (Expression converted : predicates) {
+            combined = combined == null ? converted : 
Expressions.and(combined, converted);
+        }
+        return combined;
+    }
+
+    /** Null-safe lowering guard for the destructive path; see {@link 
#wholeFileDeleteExpression}. */
+    private static boolean isExactDeletePredicate(ConnectorExpression 
expression) {
+        if (expression instanceof ConnectorNot) {
+            return ((ConnectorNot) expression).getOperand() instanceof 
ConnectorIsNull;
+        }
+        if (expression instanceof ConnectorIn && ((ConnectorIn) 
expression).isNegated()) {
+            return false;
+        }
+        if (expression instanceof ConnectorComparison
+                && ((ConnectorComparison) expression).getOperator() == 
ConnectorComparison.Operator.NE) {
+            return false;
+        }
+        for (ConnectorExpression child : expression.getChildren()) {
+            if (!isExactDeletePredicate(child)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Number of top-level conjuncts of a neutral predicate (a top-level 
AND's arity, else 1). */
+    private static int countTopLevelConjuncts(ConnectorExpression predicate) {
+        return predicate instanceof ConnectorAnd
+                ? ((ConnectorAnd) predicate).getConjuncts().size() : 1;
+    }
+
+    /**
+     * Whether every candidate data file of {@code deleteExpr} is matched in 
full, so that iceberg's
+     * {@link DeleteFiles#deleteFromRowFilter} drops files instead of 
rejecting a partially matched one. Whole
+     * partitions short-circuit ({@link ExpressionUtil#selectsPartitions} 
holds for every spec, so partition
+     * evolution is covered); otherwise each candidate file — as enumerated by 
the very scan the engine would
+     * run — must satisfy its own spec's strict partition projection or the 
strict metrics evaluator.
+     *
+     * @param collectCandidates whether the candidate data files must be 
materialized (needed by the affected-row
+     *                          check when the table already has delete files)
+     */
+    private static boolean matchesWholeFilesOnly(Table table, Expression 
deleteExpr, boolean collectCandidates,
+            List<DataFile> candidates) {
+        boolean caseSensitive = false;
+        boolean wholePartitions = ExpressionUtil.selectsPartitions(deleteExpr, 
table, caseSensitive);
+        if (wholePartitions && !collectCandidates) {
+            return true;
+        }
+        TableScan scan = table.newScan()
+                .filter(deleteExpr)
+                .caseSensitive(caseSensitive)
+                .includeColumnStats()
+                .ignoreResiduals();
+        try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {

Review Comment:
   [P2] Authenticate the candidate scan before planning manifests
   
   On secured HDFS with multiple manifests, a non-partition predicate (or any 
live delete file requiring candidate collection) reaches this scan on the raw 
table from withTable. executeAuthenticated covers the caller, but Iceberg 
DataTableScan/ManifestGroup opens manifest inputs on its shared workers; those 
workers need IcebergAuthenticatedFileIO, as the existing wrapTableForScan path 
documents. The wrapper installed later by openTransaction cannot cover this 
preflight, and its worker RuntimeException bypasses the IOException fallback 
and fails the DELETE. This is separate from the earlier commit-worker issue: 
that commit site is now wrapped, while this earlier scan is not. Prepare an 
authenticated table/FileIO view before candidate planning and reuse the 
existing scan-side pattern.



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