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


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java:
##########
@@ -84,6 +90,19 @@ public void run(ConnectContext ctx, StmtExecutor 
stmtExecutor) throws Exception
                 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:
   [P2] Capture the analyzed DELETE plan before invoking the fast path
   
   A normal DELETE creates a fresh planner without an analyzed-plan hook. 
NereidsPlanner only fills analyzedPlan for ANALYZED_PLAN/ALL_PLAN EXPLAIN or 
the hook registered by the insert commands, so getAnalyzedPlan() is null here. 
extractCompletePredicate(null) returns empty and every potentially eligible 
DELETE falls back to the existing row-level path. Capture the pre-rewrite 
analyzed plan and test the real command/planner call path; the added tests pass 
constructed plans directly and miss this integration.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java:
##########
@@ -102,6 +108,100 @@ private static void collectTargetConjuncts(Plan plan, 
long targetTableId,
         }
     }
 
+    /**
+     * Strict, whole-plan variant of {@link #extract} used by the connector's 
whole-file "metadata delete" fast
+     * path (Spark/StarRocks-style {@code DELETE} that drops data files 
instead of writing delete files): it
+     * returns the statement's complete predicate, or empty when completeness 
cannot be proven.
+     *
+     * <p>The two extractors deliberately differ. {@link #extract} drops 
anything it cannot represent, which only
+     * ever <i>widens</i> a conflict-detection filter and is therefore safe 
there. Here a dropped conjunct would
+     * widen the <i>delete</i> and drop whole data files that still hold live 
rows, so this variant bails out
+     * instead of guessing whenever a conjunct is not provably part of the 
statement predicate — anything that
+     * references another table (join / subquery), any conjunct whose Nereids 
form is outside the neutral matrix,
+     * or any plan node that could restrict/reshape rows without appearing as 
a {@link LogicalFilter}.</p>
+     *
+     * <p>Conjuncts over the excluded synthetic columns (the connector's 
{@code $row_id} metadata column) are
+     * ignored rather than rejected: they are injected by the engine, not 
authored by the statement.</p>
+     *
+     * @param analyzedPlan  the analyzed DELETE plan
+     * @param targetTableId the id of the DELETE target table
+     * @param exclusion     a predicate marking synthetic (non-statement) 
slots, may be {@code null}
+     * @return the complete predicate — a {@code true} literal when the 
statement has no {@code WHERE} clause at
+     *         all — or empty when it cannot be extracted exactly
+     */
+    public static Optional<ConnectorExpression> extractCompletePredicate(Plan 
analyzedPlan, long targetTableId,
+            Predicate<SlotReference> exclusion) {
+        if (analyzedPlan == null) {
+            return Optional.empty();
+        }
+        List<Expression> targetConjuncts = new ArrayList<>();
+        if (!collectCompletePredicate(analyzedPlan, targetTableId, exclusion, 
targetConjuncts)) {
+            return Optional.empty();
+        }
+        if (targetConjuncts.isEmpty()) {
+            // No WHERE clause (or only engine-injected conjuncts): the 
statement deletes every row.
+            return Optional.of(ConnectorLiteral.ofBoolean(true));
+        }
+        List<ConnectorExpression> converted = new 
ArrayList<>(targetConjuncts.size());
+        for (Expression conjunct : targetConjuncts) {
+            ConnectorExpression neutral = 
NereidsToConnectorExpressionConverter.convertStrict(conjunct);
+            if (neutral == null) {
+                return Optional.empty();
+            }
+            converted.add(neutral);
+        }
+        return Optional.of(converted.size() == 1 ? converted.get(0) : new 
ConnectorAnd(converted));
+    }
+
+    /**
+     * Walks the analyzed plan along its single-input chain, collecting every 
statement conjunct, and returns
+     * {@code false} as soon as the plan proves to be more than "target table 
→ filter(s) → projection(s) → sink".
+     * Only nodes that cannot change which rows of the target are produced are 
allowed; any branching node (a
+     * join, set operation, ...) fails the walk, because its predicate would 
not be visible here.
+     */
+    private static boolean collectCompletePredicate(Plan plan, long 
targetTableId,
+            Predicate<SlotReference> exclusion, List<Expression> output) {
+        if (plan instanceof LogicalFilter) {
+            for (Expression conjunct : ((LogicalFilter<?>) 
plan).getConjuncts()) {
+                if (isTargetOnlyPredicate(conjunct, targetTableId, exclusion)) 
{
+                    output.add(conjunct);
+                } else if (!isSyntheticOnlyPredicate(conjunct, exclusion)) {

Review Comment:
   [P1] Keep predicates on physical columns with synthetic-looking names
   
   isSyntheticOnlyPredicate uses the name-only exclusion supplied by the 
transform. Iceberg excludes $partition_spec_id, but that is also a permitted 
physical user column name: schema parsing preserves it and only _file/_pos are 
reserved metadata names. For Filter(t.`$partition_spec_id` = 1) -> Scan(t) on 
such a table, the user conjunct is discarded here and an empty predicate list 
becomes alwaysTrue, deleting files containing other values too. Require proven 
engine-injection provenance before skipping a conjunct; otherwise reject it and 
fall back.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java:
##########
@@ -83,15 +83,31 @@ private NereidsToConnectorExpressionConverter() {
     /** Convert a Nereids predicate to a neutral {@link ConnectorExpression}, 
or {@code null} if not
      * representable in the legacy iceberg conflict matrix (a safe 
over-approximation). */
     public static ConnectorExpression convert(Expression expr) {
+        return convert(expr, false);
+    }
+
+    /**
+     * Strict variant of {@link #convert}: it refuses to <i>drop</i> anything. 
The loose variant is a safe
+     * over-approximation for its consumers (a dropped {@code AND} arm only 
widens a conflict-detection filter),
+     * but the whole-file "metadata delete" fast path consumes the result as 
the <i>exact</i> statement predicate:
+     * a dropped conjunct would widen the delete and drop data files that 
still hold live rows. Strict mode
+     * therefore collapses the whole expression to {@code null} — "not usable" 
— as soon as any node outside the
+     * matrix is met, so the caller falls back to the row-level path instead 
of guessing.
+     */
+    public static ConnectorExpression convertStrict(Expression expr) {
+        return convert(expr, true);

Review Comment:
   [P1] Commute inequalities when converting literal-left comparisons
   
   The new exact converter delegates to convertComparison, which swaps a 
literal-left comparison without reversing its operator: convertStrict(1 < id) 
produces id < 1. A valid analyzed tree is DeleteSink -> 
Project(operation,row_id) -> Filter(1 < id) -> Scan(t); analysis preserves 
operand order and normalization happens later. With files containing id=0 and 
id=2, this exact-delete predicate removes the wrong file. Commute LT/LE/GT/GE 
when swapping operands, or reject these forms. The old loose converter also has 
this behavior, but using it for the new destructive API introduces the 
wrong-file deletion consequence.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransform.java:
##########
@@ -209,10 +211,38 @@ public void finalizeSink(BaseExternalTableInsertExecutor 
executor, RowLevelDmlOp
 
     @Override
     public Optional<ConnectorPredicate> extractWriteConstraint(Plan 
analyzedPlan, TableIf table) {
+        return WriteConstraintExtractor.extract(analyzedPlan, table.getId(), 
syntheticColumnExclusion(table));
+    }
+
+    @Override
+    public OptionalLong deleteUsingMetadata(ConnectContext ctx, 
RowLevelDmlArgs args, Plan analyzedPlan) {
+        TableIf table = args.getTable();
+        // The statement's predicate must be extractable *exactly*: the 
connector turns it into a whole-file
+        // delete, where a dropped conjunct would drop data files that still 
hold live rows. extractCompletePredicate
+        // returns empty whenever completeness cannot be proven, and the 
row-level path then runs unchanged.
+        Optional<ConnectorExpression> predicate = 
WriteConstraintExtractor.extractCompletePredicate(
+                analyzedPlan, table.getId(), syntheticColumnExclusion(table));
+        if (!predicate.isPresent()) {
+            return OptionalLong.empty();
+        }
+        ExternalTable externalTable = (ExternalTable) table;
+        PluginDrivenExternalCatalog catalog =
+                (PluginDrivenExternalCatalog) ((PluginDrivenExternalTable) 
table).getCatalog();
+        ConnectorSession session = catalog.buildConnectorSession();
+        ConnectorMetadata metadata = PluginDrivenMetadata.get(session, 
catalog.getConnector());
+        ConnectorTableHandle handle = metadata.getTableHandle(
+                        session, externalTable.getRemoteDbName(), 
externalTable.getRemoteName())
+                .orElseThrow(() -> new AnalysisException("Table not found: "
+                        + externalTable.getRemoteDbName() + "." + 
externalTable.getRemoteName()
+                        + " in catalog " + catalog.getName()));
+        return metadata.deleteUsingMetadata(session, handle, predicate);

Review Comment:
   [P1] Carry the bound write generation into the metadata-delete request
   
   The synthesized delete sink pins a write metadata identity, but this call 
forwards only a newly resolved name-based handle and predicate. If another 
client drops/recreates the table after analysis, the connector reloads the 
replacement by name and commits the old statement against its new UUID. 
Dropping/readding a same-name field similarly rebinds the name-only predicate 
to a different field. The normal write path rejects these races through 
validateBoundWriteGeneration and the UUID/schema identity. Carry and validate 
that identity on the fresh load and final metadata commit; including new 
snapshots of the same table need not discard the generation fence.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java:
##########
@@ -102,6 +108,100 @@ private static void collectTargetConjuncts(Plan plan, 
long targetTableId,
         }
     }
 
+    /**
+     * Strict, whole-plan variant of {@link #extract} used by the connector's 
whole-file "metadata delete" fast
+     * path (Spark/StarRocks-style {@code DELETE} that drops data files 
instead of writing delete files): it
+     * returns the statement's complete predicate, or empty when completeness 
cannot be proven.
+     *
+     * <p>The two extractors deliberately differ. {@link #extract} drops 
anything it cannot represent, which only
+     * ever <i>widens</i> a conflict-detection filter and is therefore safe 
there. Here a dropped conjunct would
+     * widen the <i>delete</i> and drop whole data files that still hold live 
rows, so this variant bails out
+     * instead of guessing whenever a conjunct is not provably part of the 
statement predicate — anything that
+     * references another table (join / subquery), any conjunct whose Nereids 
form is outside the neutral matrix,
+     * or any plan node that could restrict/reshape rows without appearing as 
a {@link LogicalFilter}.</p>
+     *
+     * <p>Conjuncts over the excluded synthetic columns (the connector's 
{@code $row_id} metadata column) are
+     * ignored rather than rejected: they are injected by the engine, not 
authored by the statement.</p>
+     *
+     * @param analyzedPlan  the analyzed DELETE plan
+     * @param targetTableId the id of the DELETE target table
+     * @param exclusion     a predicate marking synthetic (non-statement) 
slots, may be {@code null}
+     * @return the complete predicate — a {@code true} literal when the 
statement has no {@code WHERE} clause at
+     *         all — or empty when it cannot be extracted exactly
+     */
+    public static Optional<ConnectorExpression> extractCompletePredicate(Plan 
analyzedPlan, long targetTableId,
+            Predicate<SlotReference> exclusion) {
+        if (analyzedPlan == null) {
+            return Optional.empty();
+        }
+        List<Expression> targetConjuncts = new ArrayList<>();
+        if (!collectCompletePredicate(analyzedPlan, targetTableId, exclusion, 
targetConjuncts)) {
+            return Optional.empty();
+        }
+        if (targetConjuncts.isEmpty()) {
+            // No WHERE clause (or only engine-injected conjuncts): the 
statement deletes every row.
+            return Optional.of(ConnectorLiteral.ofBoolean(true));
+        }
+        List<ConnectorExpression> converted = new 
ArrayList<>(targetConjuncts.size());
+        for (Expression conjunct : targetConjuncts) {
+            ConnectorExpression neutral = 
NereidsToConnectorExpressionConverter.convertStrict(conjunct);
+            if (neutral == null) {
+                return Optional.empty();
+            }
+            converted.add(neutral);
+        }
+        return Optional.of(converted.size() == 1 ? converted.get(0) : new 
ConnectorAnd(converted));
+    }
+
+    /**
+     * Walks the analyzed plan along its single-input chain, collecting every 
statement conjunct, and returns
+     * {@code false} as soon as the plan proves to be more than "target table 
→ filter(s) → projection(s) → sink".
+     * Only nodes that cannot change which rows of the target are produced are 
allowed; any branching node (a
+     * join, set operation, ...) fails the walk, because its predicate would 
not be visible here.
+     */
+    private static boolean collectCompletePredicate(Plan plan, long 
targetTableId,
+            Predicate<SlotReference> exclusion, List<Expression> output) {
+        if (plan instanceof LogicalFilter) {
+            for (Expression conjunct : ((LogicalFilter<?>) 
plan).getConjuncts()) {
+                if (isTargetOnlyPredicate(conjunct, targetTableId, exclusion)) 
{
+                    output.add(conjunct);
+                } else if (!isSyntheticOnlyPredicate(conjunct, exclusion)) {
+                    return false;
+                }
+            }
+        } else if (!(plan instanceof LogicalSink) && !(plan instanceof 
LogicalProject)

Review Comment:
   [P1] Resolve projected slots before treating filters as physical predicates
   
   Allowing every LogicalProject loses the meaning of aliases used above it. A 
valid analyzed mask plan is DeleteSink -> Project(operation,row_id) -> 
Filter(a#masked = 1) -> Project(b AS a#masked, b, row_id) -> Scan(t). 
Alias.toSlot preserves b's original table/column but uses the alias name a, so 
isTargetOnlyPredicate accepts it and columnRef emits physical a=1. With files 
(a=0,b=1) and (a=1,b=0), the API selects the wrong file. DELETE targets do pass 
through CheckPolicy, which can produce this bare-slot mask projection. Rewrite 
through projections using slot identity, or reject non-identity projections and 
fall back.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());
+                Expression deleteExpr = predicate.isPresent()
+                        ? wholeFileDeleteExpression(table, session, 
predicate.get())
+                        : Expressions.alwaysTrue();
+                if (deleteExpr == null || !matchesWholeFilesOnly(table, 
deleteExpr)) {
+                    return OptionalLong.empty();
+                }
+                DeleteFiles deleteFiles = 
table.newDelete().deleteFromRowFilter(deleteExpr);
+                deleteFiles.commit();
+                return 
OptionalLong.of(deletedRowCount(table.currentSnapshot()));
+            });
+        } catch (ValidationException e) {
+            // A racing writer turned a candidate file into a partial match 
(or another delete 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);
+        }
+    }
+
+    /**
+     * 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.
+     */
+    private Expression wholeFileDeleteExpression(Table table, ConnectorSession 
session,
+            ConnectorExpression predicate) {
+        if (predicate instanceof ConnectorLiteral) {
+            return Boolean.TRUE.equals(((ConnectorLiteral) 
predicate).getValue())
+                    ? Expressions.alwaysTrue() : null;
+        }
+        ZoneId zone = IcebergTimeUtils.resolveSessionZone(session);
+        List<Expression> predicates = new IcebergPredicateConverter(
+                table.schema(), zone, 
IcebergPredicateConverter.Mode.REWRITE).convert(predicate);

Review Comment:
   [P1] Preserve SQL null semantics when lowering negated predicates
   
   REWRITE conversion is not an exact SQL DELETE predicate for nullable 
negation. An accepted analyzed Filter(NOT(id = 1)) becomes Iceberg 
not(equal(id,1)); Iceberg rewrites this to notEqual, and its 
StrictEvalVisitor.notEq explicitly treats a NULL-only file as fully matching. 
The API therefore drops that file although SQL NOT(UNKNOWN) is UNKNOWN and 
deletes no rows. NOT IN has the same issue. Add SQL null guards with correct 
nested boolean semantics, or reject unsafe negative forms and fall back.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());
+                Expression deleteExpr = predicate.isPresent()
+                        ? wholeFileDeleteExpression(table, session, 
predicate.get())
+                        : Expressions.alwaysTrue();
+                if (deleteExpr == null || !matchesWholeFilesOnly(table, 
deleteExpr)) {
+                    return OptionalLong.empty();
+                }
+                DeleteFiles deleteFiles = 
table.newDelete().deleteFromRowFilter(deleteExpr);
+                deleteFiles.commit();
+                return 
OptionalLong.of(deletedRowCount(table.currentSnapshot()));
+            });
+        } catch (ValidationException e) {
+            // A racing writer turned a candidate file into a partial match 
(or another delete 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);
+        }
+    }
+
+    /**
+     * 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.
+     */
+    private Expression wholeFileDeleteExpression(Table table, ConnectorSession 
session,
+            ConnectorExpression predicate) {
+        if (predicate instanceof ConnectorLiteral) {
+            return Boolean.TRUE.equals(((ConnectorLiteral) 
predicate).getValue())
+                    ? Expressions.alwaysTrue() : null;
+        }
+        ZoneId zone = IcebergTimeUtils.resolveSessionZone(session);
+        List<Expression> predicates = new IcebergPredicateConverter(

Review Comment:
   [P1] Retain the timestamp history guard for metadata deletes
   
   The plain constructor leaves unsafeNanosFields empty, bypassing the 
DATE-history/incomplete-history checks in IcebergPredicateConverter.forTable. 
For a retained DATE file with ts=2024-01-02 whose field was externally promoted 
to TIMESTAMP_NS in format v3, Iceberg decodes its four-byte epoch-day bound as 
19724 nanoseconds. A predicate ts < TIMESTAMP_NS '2000-01-01 00:00:00' can then 
pass both candidate pruning and the strict whole-file proof and delete the 
nonmatching 2024 file. Use the history-aware forTable factory so these fields 
force fallback, as RewriteDataFilePlanner already does.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());
+                Expression deleteExpr = predicate.isPresent()
+                        ? wholeFileDeleteExpression(table, session, 
predicate.get())
+                        : Expressions.alwaysTrue();
+                if (deleteExpr == null || !matchesWholeFilesOnly(table, 
deleteExpr)) {
+                    return OptionalLong.empty();
+                }
+                DeleteFiles deleteFiles = 
table.newDelete().deleteFromRowFilter(deleteExpr);

Review Comment:
   [P2] Reuse authenticated FileIO for the metadata commit workers
   
   On secured HDFS, executeAuthenticated authenticates the caller only. 
DeleteFiles commits dispatch manifest filtering/writing to Iceberg's shared 
worker pool, where raw table operations resolve HadoopFileIO without that 
Kerberos UGI. With workers created outside this call, a whole-file delete fails 
authentication and is wrapped as a statement error. 
IcebergConnectorTransaction.openTransaction already wraps TableOperations.io in 
IcebergAuthenticatedFileIO for this exact worker-thread failure. Reuse that 
preparation for the new commit path; the InMemoryCatalog tests do not cover it.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());

Review Comment:
   [P2] Release the freshly loaded table on every exit
   
   This extra load is neither registered with a statement/cache cleanup scope 
nor passed through catalogOps.withTable. Glue/S3Tables tables own their FileIO, 
and REST tables can own vended FileIO; CatalogBackedIcebergCatalogOps.withTable 
runs the configured cachedTableCleanup in finally for exactly these resources. 
Success, unsupported-predicate fallback, partial-file fallback and exceptions 
all leave this fresh FileIO unclosed, accumulating clients and 
credential-provider resources across API calls. Use the bounded withTable 
lifecycle while retaining a fresh table load.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());
+                Expression deleteExpr = predicate.isPresent()
+                        ? wholeFileDeleteExpression(table, session, 
predicate.get())
+                        : Expressions.alwaysTrue();
+                if (deleteExpr == null || !matchesWholeFilesOnly(table, 
deleteExpr)) {
+                    return OptionalLong.empty();
+                }
+                DeleteFiles deleteFiles = 
table.newDelete().deleteFromRowFilter(deleteExpr);
+                deleteFiles.commit();
+                return 
OptionalLong.of(deletedRowCount(table.currentSnapshot()));

Review Comment:
   [P2] Read the summary of this delete's committed snapshot
   
   DeleteFiles.commit refreshes its TableOperations after committing; Iceberg 
explicitly looks up its own saved snapshot by ID because another writer may 
have committed before that refresh. Thus table.currentSnapshot() here can be 
another writer's snapshot. If this delete removes 100 rows and an append wins 
that interval, deletedRowCount sees the append summary and reports zero; a 
concurrent delete can supply its count instead. Retain the actual committed 
snapshot identity and read that summary across commit retries.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java:
##########
@@ -1889,6 +1907,153 @@ 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) {
+        IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        String qualifiedName = iceHandle.getDbName() + "." + 
iceHandle.getTableName();
+        try {
+            return executeAuthenticated(() -> {
+                // Load fresh rather than through the read-side caches: the 
delete commits against the current
+                // metadata, never a possibly stale cached snapshot.
+                Table table = catalogOps.loadTable(iceHandle.getDbName(), 
iceHandle.getTableName());
+                Expression deleteExpr = predicate.isPresent()
+                        ? wholeFileDeleteExpression(table, session, 
predicate.get())
+                        : Expressions.alwaysTrue();
+                if (deleteExpr == null || !matchesWholeFilesOnly(table, 
deleteExpr)) {
+                    return OptionalLong.empty();
+                }
+                DeleteFiles deleteFiles = 
table.newDelete().deleteFromRowFilter(deleteExpr);
+                deleteFiles.commit();
+                return 
OptionalLong.of(deletedRowCount(table.currentSnapshot()));
+            });
+        } catch (ValidationException e) {
+            // A racing writer turned a candidate file into a partial match 
(or another delete 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);
+        }
+    }
+
+    /**
+     * 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.
+     */
+    private Expression wholeFileDeleteExpression(Table table, ConnectorSession 
session,
+            ConnectorExpression predicate) {
+        if (predicate instanceof ConnectorLiteral) {
+            return Boolean.TRUE.equals(((ConnectorLiteral) 
predicate).getValue())
+                    ? Expressions.alwaysTrue() : null;
+        }
+        ZoneId zone = IcebergTimeUtils.resolveSessionZone(session);
+        List<Expression> predicates = new IcebergPredicateConverter(
+                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;
+    }
+
+    /** 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.
+     */
+    private static boolean matchesWholeFilesOnly(Table table, Expression 
deleteExpr) {
+        boolean caseSensitive = false;
+        if (ExpressionUtil.selectsPartitions(deleteExpr, table, 
caseSensitive)) {
+            return true;
+        }
+        TableScan scan = table.newScan()
+                .filter(deleteExpr)
+                .caseSensitive(caseSensitive)
+                .includeColumnStats()
+                .ignoreResiduals();
+        try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {
+            Map<Integer, Evaluator> evaluators = new HashMap<>();
+            StrictMetricsEvaluator metricsEvaluator =
+                    new StrictMetricsEvaluator(table.schema(), deleteExpr, 
caseSensitive);
+            for (FileScanTask task : tasks) {
+                DataFile file = task.file();
+                PartitionSpec spec = task.spec();
+                Evaluator evaluator = 
evaluators.computeIfAbsent(spec.specId(), specId -> new Evaluator(
+                        spec.partitionType(), Projections.strict(spec, 
caseSensitive).project(deleteExpr)));
+                if (!evaluator.eval(file.partition()) && 
!metricsEvaluator.eval(file)) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (IOException e) {
+            LOG.warn("Failed to evaluate whole-file delete candidates for {}: 
{}",
+                    table.name(), e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * Rows deleted by a metadata delete: the removed data files' records 
minus the records of the delete files
+     * removed with them (rows that were already deleted). Mirrors Trino's 
{@code executeDelete} accounting.
+     */
+    private static long deletedRowCount(Snapshot snapshot) {
+        if (snapshot == null || snapshot.summary() == null) {
+            return 0L;
+        }
+        Map<String, String> summary = snapshot.summary();
+        long deletedRecords = 
Long.parseLong(summary.getOrDefault(SnapshotSummary.DELETED_RECORDS_PROP, "0"));
+        long removedPosDeletes =
+                
Long.parseLong(summary.getOrDefault(SnapshotSummary.REMOVED_POS_DELETES_PROP, 
"0"));
+        long removedEqDeletes =
+                
Long.parseLong(summary.getOrDefault(SnapshotSummary.REMOVED_EQ_DELETES_PROP, 
"0"));
+        return Math.max(0L, deletedRecords - removedPosDeletes - 
removedEqDeletes);

Review Comment:
   [P2] Do not equate delete-file records with hidden data rows
   
   An equality-delete record is a key, not a count of matching data rows. For a 
ten-row v2 data file with eight rows matching id=7 and a newer one-record 
equality-delete file for that key, only two rows remain visible. An unfiltered 
metadata delete removes both files, but this calculation reports 10-1=9 
affected rows. Overlapping position deletes can also hide one row more than 
once. Restrict exact metadata counting to cases whose cardinality is 
guaranteed, or fall back when the exact number of live rows cannot be derived.



##########
fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorWriteOps.java:
##########
@@ -55,6 +58,39 @@ default void validateRowLevelDmlMode(ConnectorSession 
session, ConnectorTableHan
         // default: no per-table mode constraint
     }
 
+    /**
+     * Attempts to satisfy a {@code DELETE} entirely by dropping whole data 
files, without reading any rows
+     * ("metadata delete"): a remote atomic commit that removes the files 
whose rows must all disappear. Spark,
+     * StarRocks and Trino all take this path for whole-partition and 
whole-file predicates, and it is what makes
+     * {@code DELETE FROM t} / {@code DELETE FROM t WHERE <partition>} cheap.
+     *
+     * <p>The engine calls this before executing the row-level 
(position-delete) plan of a single-table
+     * {@code DELETE}; a returned value means the connector already committed 
the delete, so the engine skips the
+     * scan and the sink entirely. Returning empty is always legal and means 
"not applicable": the engine then
+     * falls back to the ordinary row-level delete.</p>
+     *
+     * <p><b>Contract on {@code predicate}.</b> The engine only calls this 
method when it could extract the
+     * statement's predicate <i>exactly</i> (every conjunct, target table 
only, no partition-clause scope), so an
+     * implementation may treat it as complete. That matters because a wider 
predicate would drop files that still
+     * hold live rows. {@code Optional.empty()} means the statement has no 
{@code WHERE} clause at all, i.e. every
+     * row of the table is deleted. An implementation MUST itself verify that 
the predicate only ever removes whole
+     * files (e.g. by checking file statistics / partition projections) and 
fall back — return empty, never a
+     * partial delete — when any candidate file is only partially matched.</p>
+     *
+     * <p>The default returns empty, so connectors without a whole-file delete 
path are unaffected.</p>
+     *
+     * @param session   the current session
+     * @param handle    the target table handle
+     * @param predicate the statement's complete delete predicate over the 
target's own columns, or empty for an
+     *                  unfiltered delete (always true)
+     * @return the number of deleted rows when the delete was fully executed 
as a metadata delete; empty to fall
+     *         back to row-level DML
+     */
+    default OptionalLong deleteUsingMetadata(ConnectorSession session, 
ConnectorTableHandle handle,

Review Comment:
   [P2] Advance the connector API major for the new SPI method
   
   This adds a public ConnectorMetadata method through ConnectorWriteOps, but 
connector.plugin.api.version and its pinned test remain 17.0. The loader 
requires equal majors and resolves SPI types from the FE, so the same declared 
version now describes two different APIs and cannot reject a plugin built 
against the new method on an older FE. Increment the major and update the 
version pin as required by the explicit connector API contract. The metadata 
baseline is updated; the second baseline does not enumerate this interface and 
has no corresponding signature drift.



##########
fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergMetadataDeleteTest.java:
##########
@@ -0,0 +1,320 @@
+// 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;
+
+import org.apache.doris.connector.spi.ConnectorSession;
+import org.apache.doris.connector.spi.ConnectorType;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
+
+import com.google.common.collect.Iterables;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * Pins {@link IcebergConnectorMetadata#deleteUsingMetadata}: the whole-file 
{@code DELETE} fast path that
+ * Spark / StarRocks / Trino use for whole-partition and whole-file predicates.
+ *
+ * <p>Mirrors the no-Mockito, real-{@link InMemoryCatalog} style of {@code 
IcebergConnectorTransactionTest}: the
+ * delete is committed against a real iceberg table, but — unlike a row-level 
delete — it never reads a data
+ * file, so the fixtures only need data-file metadata (path + record count), 
not real parquet files.</p>
+ */
+public class IcebergMetadataDeleteTest {
+
+    private static final TableIdentifier TABLE_ID = TableIdentifier.of("db1", 
"t1");
+    private static final ConnectorTableHandle HANDLE = new 
IcebergTableHandle("db1", "t1");
+    private static final ConnectorSession SESSION = new FakeSession("UTC");
+
+    @Test
+    public void wholePartitionDeleteDropsMatchingFilesOnly() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = 
PartitionSpec.builderFor(SCHEMA).identity("region").build();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "2"));
+        table.newAppend()
+                .appendFile(partitionedDataFile(spec, 
"s3://b/db1/t1/region=us/a.parquet", 10L, "region=us"))
+                .appendFile(partitionedDataFile(spec, 
"s3://b/db1/t1/region=us/b.parquet", 10L, "region=us"))
+                .appendFile(partitionedDataFile(spec, 
"s3://b/db1/t1/region=eu/c.parquet", 20L, "region=eu"))
+                .commit();
+
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.of(
+                new ConnectorComparison(ConnectorComparison.Operator.EQ,
+                        new ConnectorColumnRef("region", 
ConnectorType.of("STRING")), ConnectorLiteral.ofString("us"))));
+
+        Assertions.assertEquals(OptionalLong.of(20L), deleted,
+                "the two region=us files (10 records each) are the deleted 
rows");
+        
Assertions.assertEquals(Collections.singletonList("s3://b/db1/t1/region=eu/c.parquet"),
+                dataFilePaths(reload(catalog)),
+                "a whole-partition delete removes only the matching data 
files");
+        Assertions.assertEquals(0, liveDeleteFileCount(reload(catalog)),
+                "a metadata delete writes no delete files (no position 
deletes, no deletion vectors)");
+    }
+
+    @Test
+    public void unfilteredDeleteRemovesEveryFile() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = PartitionSpec.unpartitioned();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "2"));
+        table.newAppend()
+                .appendFile(dataFile(spec, "s3://b/db1/t1/a.parquet", 10L))
+                .appendFile(dataFile(spec, "s3://b/db1/t1/b.parquet", 5L))
+                .commit();
+
+        // The engine passes no predicate for "DELETE FROM t": every row of 
the table is deleted.
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.empty());
+
+        Assertions.assertEquals(OptionalLong.of(15L), deleted);
+        Assertions.assertTrue(dataFilePaths(reload(catalog)).isEmpty(),
+                "an unfiltered metadata delete drops every file");
+    }
+
+    @Test
+    public void partialMatchFallsBackWithoutCommitting() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = PartitionSpec.unpartitioned();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "2"));
+        // No column metrics on the file: nothing proves that ALL of its rows 
satisfy `id > 5`.
+        table.newAppend().appendFile(dataFile(spec, "s3://b/db1/t1/a.parquet", 
10L)).commit();
+        long snapshotBefore = table.currentSnapshot().snapshotId();
+
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.of(
+                new ConnectorComparison(ConnectorComparison.Operator.GT,
+                        new ConnectorColumnRef("id", ConnectorType.of("INT")), 
ConnectorLiteral.ofLong(5L))));
+
+        Assertions.assertFalse(deleted.isPresent(),
+                "a candidate file that is only partially matched must fall 
back to the row-level delete");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId(),
+                "the fallback must not commit anything");
+    }
+
+    @Test
+    public void unpushablePredicateFallsBack() {
+        InMemoryCatalog catalog = freshCatalog();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, 
PartitionSpec.unpartitioned(),
+                props("format-version", "2"));
+        table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), 
"s3://b/db1/t1/a.parquet", 10L))
+                .commit();
+        long snapshotBefore = table.currentSnapshot().snapshotId();
+
+        // A column the table does not have: the strict matrix cannot lower 
it, and dropping it would delete
+        // every row of every file.
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.of(
+                new ConnectorComparison(ConnectorComparison.Operator.EQ,
+                        new ConnectorColumnRef("no_such_column", 
ConnectorType.of("BIGINT")), ConnectorLiteral.ofLong(1L))));
+
+        Assertions.assertFalse(deleted.isPresent(), "an unliftable predicate 
must not be treated as always-true");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId());
+    }
+
+    @Test
+    public void deletedRowCountExcludesRowsHiddenByExistingDeleteFiles() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = 
PartitionSpec.builderFor(SCHEMA).identity("region").build();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "3"));
+        table.newAppend()
+                .appendFile(partitionedDataFile(spec, 
"s3://b/db1/t1/region=us/a.parquet", 10L, "region=us"))
+                .commit();
+        // Three of the ten rows are already deleted by a deletion vector for 
that data file.
+        table.newRowDelta().addDeletes(deletionVector(spec, 
"s3://b/db1/t1/dv.puffin",
+                "s3://b/db1/t1/region=us/a.parquet", 3L)).commit();
+
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.of(
+                new ConnectorComparison(ConnectorComparison.Operator.EQ,
+                        new ConnectorColumnRef("region", 
ConnectorType.of("STRING")), ConnectorLiteral.ofString("us"))));
+
+        Assertions.assertEquals(OptionalLong.of(7L), deleted,
+                "deleted rows = the dropped file's records minus the rows its 
deletion vector already hid");
+        Assertions.assertTrue(dataFilePaths(reload(catalog)).isEmpty());
+        Assertions.assertEquals(0, liveDeleteFileCount(reload(catalog)),
+                "the deletion vector of the dropped data file is removed as 
well");
+    }
+
+    private static IcebergConnectorMetadata metadata(InMemoryCatalog catalog) {
+        RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+        ops.table = reload(catalog);
+        return new IcebergConnectorMetadata(ops, 
IcebergCatalogProperties.of(Collections.emptyMap()),
+                new RecordingConnectorContext());
+    }
+
+    /** Re-reads the table so the assertions observe the snapshot the metadata 
delete committed. */
+    private static Table reload(InMemoryCatalog catalog) {
+        return catalog.loadTable(TABLE_ID);
+    }
+
+    private static List<String> dataFilePaths(Table table) {
+        List<String> paths = new ArrayList<>();
+        try (CloseableIterable<FileScanTask> tasks = 
table.newScan().planFiles()) {
+            for (FileScanTask task : tasks) {
+                paths.add(task.file().path().toString());
+            }
+        } catch (Exception e) {
+            throw new AssertionError(e);
+        }
+        Collections.sort(paths);
+        return paths;
+    }
+
+    /** Live delete-file entries in the current snapshot (an emptied manifest 
may legitimately remain). */
+    private static int liveDeleteFileCount(Table table) {
+        Snapshot snapshot = table.currentSnapshot();
+        if (snapshot == null) {
+            return 0;
+        }
+        int live = 0;
+        for (ManifestFile manifest : snapshot.deleteManifests(table.io())) {
+            try (ManifestReader<DeleteFile> reader =
+                    ManifestFiles.readDeleteManifest(manifest, table.io(), 
table.specs())) {
+                live += Iterables.size(reader);
+            } catch (IOException e) {
+                throw new AssertionError(e);
+            }
+        }
+        return live;
+    }
+
+    private static final Schema SCHEMA = new Schema(
+            Types.NestedField.required(1, "id", Types.IntegerType.get()),
+            Types.NestedField.required(2, "region", Types.StringType.get()));
+
+    private static InMemoryCatalog freshCatalog() {
+        InMemoryCatalog catalog = new InMemoryCatalog();
+        catalog.initialize("test", Collections.emptyMap());
+        catalog.createNamespace(Namespace.of("db1"));
+        return catalog;
+    }
+
+    private static Map<String, String> props(String... kv) {
+        Map<String, String> properties = new HashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            properties.put(kv[i], kv[i + 1]);
+        }
+        return properties;
+    }
+
+    private static DataFile dataFile(PartitionSpec spec, String path, long 
records) {
+        return DataFiles.builder(spec)
+                .withPath(path)
+                .withFileSizeInBytes(1024)
+                .withRecordCount(records)
+                .withFormat(FileFormat.PARQUET)
+                .build();
+    }
+
+    private static DataFile partitionedDataFile(PartitionSpec spec, String 
path, long records,
+            String partitionPath) {
+        return DataFiles.builder(spec)
+                .withPath(path)
+                .withFileSizeInBytes(1024)
+                .withRecordCount(records)
+                .withFormat(FileFormat.PARQUET)
+                .withPartitionPath(partitionPath)
+                .build();
+    }
+
+    /** A v3 deletion vector for {@code referencedDataFile} that already hides 
{@code cardinality} rows. */
+    private static DeleteFile deletionVector(PartitionSpec spec, String 
puffin, String referencedDataFile,
+            long cardinality) {
+        return FileMetadata.deleteFileBuilder(spec)
+                .ofPositionDeletes()
+                .withPath(puffin)
+                .withFormat(FileFormat.PUFFIN)
+                .withFileSizeInBytes(128L)
+                .withRecordCount(cardinality)
+                .withReferencedDataFile(referencedDataFile)

Review Comment:
   [P2] Give the deletion vector its referenced file's partition
   
   The data file in this test has region=us, but this helper never sets the DV 
partition, so FileMetadata builds it with region=null. Iceberg requires the DV 
and data file to have the same spec and partition tuple; normal DeleteFileIndex 
lookup rejects this pair as corrupted metadata. The test avoids planning the 
original table and can still pass after the whole-partition fast path removes 
both entries, so it does not prove the advertised seven-live-row case. Copy/set 
the data file partition on the DV and check that a pre-delete scan attaches it 
to the data file.



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