This is an automated email from the ASF dual-hosted git repository.

Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new bf278a5b210 [feature](iceberg) Support whole-file metadata delete for 
DELETE (#69176)
bf278a5b210 is described below

commit bf278a5b2106b92ebe8f8d91d9d339e9628bfdb4
Author: daidai <[email protected]>
AuthorDate: Fri Oct 23 15:20:38 2026 +0800

    [feature](iceberg) Support whole-file metadata delete for DELETE (#69176)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Problem Summary:
    For an Iceberg table, a row-level DELETE always scans the matching rows
    and writes delete files (position deletes on v2, deletion vectors on
    v3), so a data file whose rows are all gone stays in the snapshot and
    every later scan still reads it.
    
    This PR adds the whole-file "metadata delete" fast path that Spark /
    Trino use for whole-partition and whole-file predicates: the DELETE is
    committed as one metadata operation that drops the matching data files,
    so the statement reads no rows, writes no delete files, and later scans
    no longer read those files.
    
    - The engine only tries the fast path when the statement predicate can
    be extracted exactly (single-table DELETE, leaf is the target table
    scan, every conjunct representable). Anything else keeps the current
    row-level path. `PARTITION(...)` scoped deletes, UPDATE and MERGE are
    unchanged.
    - The Iceberg connector lowers the predicate with the strictly
    all-or-nothing rewrite matrix and only commits when every candidate file
    is matched in full (whole-partition shortcut, strict partition
    projection or file metrics); otherwise it falls back to the row-level
    delete, so a partially matched file is never dropped.
    - Like the engines above, the committed snapshot applies the predicate
    to the current table state, and the reported row count is iceberg's
    `deleted-records` minus the rows the removed delete files had already
    hidden.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
        - [ ] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason
    
    - Behavior changed:
        - [ ] No.
        - [x] Yes.
    
    - Does this need documentation?
        - [x] No.
        - [ ] Yes.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../connector/hive/HiveConnectorMetadata.java      |  16 +
 ...HiveConnectorMetadataSiblingDelegationTest.java |  42 ++-
 .../iceberg/IcebergConnectorMetadata.java          | 333 ++++++++++++++++++
 .../iceberg/IcebergConnectorTransaction.java       |  57 +++-
 .../iceberg/IcebergMetadataDeleteTest.java         | 378 +++++++++++++++++++++
 .../doris/connector/spi/ConnectorWriteOps.java     |  43 +++
 .../connector/spi/ConnectorPluginSurfaceTest.java  |   6 +-
 .../test/resources/connector-metadata-methods.txt  |   1 +
 fe/fe-connector/pom.xml                            |   2 +-
 .../NereidsToConnectorExpressionConverter.java     | 100 ++++--
 .../converter/WriteConstraintExtractor.java        | 106 ++++++
 .../PositionDeleteRowLevelDmlTransform.java        |  42 ++-
 .../trees/plans/commands/RowLevelDmlCommand.java   |  87 +++++
 .../trees/plans/commands/RowLevelDmlTransform.java |  21 ++
 .../NereidsToConnectorExpressionConverterTest.java |  59 ++++
 .../converter/WriteConstraintExtractorTest.java    | 199 +++++++++++
 .../PositionDeleteRowLevelDmlTransformTest.java    |  95 ++++++
 .../plans/commands/RowLevelDmlCommandTest.java     |  50 +++
 .../test_iceberg_write_dml_modes_evolution.out     |   5 +-
 ...t_iceberg_v3_row_lineage_dml_mode_matrix.groovy |  26 +-
 ...t_iceberg_delete_unpartitioned_evolution.groovy |   7 +-
 21 files changed, 1629 insertions(+), 46 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
index 6971d3763f5..4c8f56c58b5 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
@@ -100,6 +100,7 @@ import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.function.BooleanSupplier;
 import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.function.ToLongBiFunction;
@@ -2149,6 +2150,21 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
         // permissive here -- a throw would newly reject legal plain-hive 
row-level DML.
     }
 
+    /**
+     * A foreign Iceberg handle must use the sibling's whole-file 
metadata-delete implementation. The Hive gateway
+     * itself has no data-file delete contract, so a real Hive handle keeps 
the SPI default of falling back.
+     */
+    @Override
+    public OptionalLong deleteUsingMetadata(ConnectorSession session, 
ConnectorTableHandle handle,
+            Optional<ConnectorExpression> predicate, String 
boundWriteMetadataIdentity,
+            BooleanSupplier isCancelled) {
+        if (!(handle instanceof HiveTableHandle)) {
+            return siblingMetadata(session, handle).deleteUsingMetadata(
+                    session, handle, predicate, boundWriteMetadataIdentity, 
isCancelled);
+        }
+        return OptionalLong.empty();
+    }
+
     /**
      * Rejects an illegal static-partition column on {@code INSERT [OVERWRITE] 
... PARTITION (col=val)}: the
      * column must be a partition key of this (partitioned) table. Port of 
upstream #65991's
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
index 928fd132811..d2f340f0f31 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
@@ -70,6 +70,7 @@ import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.function.BooleanSupplier;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -356,12 +357,12 @@ public class HiveConnectorMetadataSiblingDelegationTest {
     }
 
     @Test
-    public void 
everyAlterDdlAndValidateMethodForwardsAForeignHandleToTheSibling() {
+    public void everyForeignWriteOperationForwardsAForeignHandleToTheSibling() 
{
         HiveConnectorMetadata md = withSibling();
 
-        // The 14 ALTER-DDL mutators + 2 write validators: a foreign 
(iceberg-on-HMS) handle must divert, never
-        // run the hive branch and never be cast. Change objects are null — 
the guard fires on the handle type
-        // before any param is touched.
+        // The 14 ALTER-DDL mutators + 2 write validators + metadata delete: a 
foreign (iceberg-on-HMS) handle
+        // must divert, never run the hive branch and never be cast. Change 
objects are null — the guard fires on
+        // the handle type before any param is touched.
         md.renameTable(session, foreignHandle, "new");
         md.setTableProperties(session, foreignHandle, Collections.emptyMap());
         md.addColumn(session, foreignHandle, null, null);
@@ -387,9 +388,21 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         // Empty list on purpose: a foreign handle must forward REGARDLESS of 
emptiness (the empty-early-return is
         // hive-only) — this would fail if the empty check were placed before 
the foreign-handle divert.
         md.validateWritePartitionNames(session, foreignHandle, 
Collections.emptyList());
+        ConnectorExpression predicate = new ConnectorColumnRef("id", 
ConnectorType.of("INT"));
+        BooleanSupplier cancellation = () -> true;
+        OptionalLong deleted = md.deleteUsingMetadata(
+                session, foreignHandle, Optional.of(predicate), 
"bound-generation", cancellation);
 
         
Assertions.assertEquals(RecordingSiblingMetadata.EXPECTED_WRITE_METHODS, 
siblingMetadata.calls,
                 "every ALTER-DDL mutator + write validator must forward a 
foreign handle to the sibling");
+        
Assertions.assertEquals(OptionalLong.of(RecordingSiblingMetadata.SENTINEL_DELETED_ROWS),
 deleted,
+                "metadata delete must return the sibling's result");
+        Assertions.assertSame(predicate, siblingMetadata.lastDeletePredicate,
+                "metadata delete must forward the exact predicate");
+        Assertions.assertEquals("bound-generation", 
siblingMetadata.lastBoundWriteMetadataIdentity,
+                "metadata delete must forward the write-generation fence");
+        Assertions.assertSame(cancellation, siblingMetadata.lastCancellation,
+                "metadata delete must forward the cancellation callback");
     }
 
     @Test
@@ -451,6 +464,9 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         // plain-hive row-level DML / static-partition INSERTs.
         md.validateRowLevelDmlMode(session, hive, null);
         md.validateStaticPartitionColumns(session, hive, 
Collections.emptyList());
+        Assertions.assertEquals(OptionalLong.empty(),
+                md.deleteUsingMetadata(session, hive, Optional.empty(), null, 
() -> false),
+                "a real Hive handle must keep the metadata-delete fallback");
 
         Assertions.assertEquals(0, siblingConnector.getMetadataCount,
                 "a hive handle must never build/consult the iceberg sibling 
for ALTER-DDL / validate");
@@ -721,6 +737,7 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         static final ConnectorTransaction SIBLING_TXN = new 
NoOpConnectorTransaction(4243L, "ICEBERG");
         static final long SENTINEL_SIZE = 4242L;
         static final long SENTINEL_SNAPSHOT_ID = 99L;
+        static final long SENTINEL_DELETED_ROWS = 31L;
         static final List<Long> SENTINEL_FILE_SIZES = 
Collections.unmodifiableList(Arrays.asList(7L, 11L));
         static final List<ConnectorExpression> SIBLING_PREDICATES = 
Collections.singletonList(
                 new ConnectorColumnRef("sibling-pred", 
ConnectorType.of("STRING")));
@@ -747,9 +764,13 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 "modifyNestedColumn", "modifyColumnComment",
                 "createOrReplaceBranch", "createOrReplaceTag", "dropBranch", 
"dropTag",
                 "addPartitionField", "dropPartitionField", 
"replacePartitionField",
-                "validateRowLevelDmlMode", "validateStaticPartitionColumns", 
"validateWritePartitionNames"));
+                "validateRowLevelDmlMode", "validateStaticPartitionColumns", 
"validateWritePartitionNames",
+                "deleteUsingMetadata"));
 
         final List<String> calls = new ArrayList<>();
+        ConnectorExpression lastDeletePredicate;
+        String lastBoundWriteMetadataIdentity;
+        BooleanSupplier lastCancellation;
         final Optional<FilterApplicationResult<ConnectorTableHandle>> 
filterResult =
                 Optional.of(new FilterApplicationResult<>(SIBLING_HANDLE, 
null, false));
 
@@ -1069,5 +1090,16 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 List<String> partitionNames) {
             calls.add("validateWritePartitionNames");
         }
+
+        @Override
+        public OptionalLong deleteUsingMetadata(ConnectorSession session, 
ConnectorTableHandle handle,
+                Optional<ConnectorExpression> predicate, String 
boundWriteMetadataIdentity,
+                BooleanSupplier isCancelled) {
+            calls.add("deleteUsingMetadata");
+            lastDeletePredicate = predicate.orElse(null);
+            lastBoundWriteMetadataIdentity = boundWriteMetadataIdentity;
+            lastCancellation = isCancelled;
+            return OptionalLong.of(SENTINEL_DELETED_ROWS);
+        }
     }
 }
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index 3864846d2b3..084ff2fe8fb 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -46,7 +46,14 @@ import org.apache.doris.connector.spi.handle.WriteOperation;
 import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
 import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
 import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
+import org.apache.doris.connector.spi.pushdown.ConnectorAnd;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
 import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorIn;
+import org.apache.doris.connector.spi.pushdown.ConnectorIsNull;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
+import org.apache.doris.connector.spi.pushdown.ConnectorNot;
+import org.apache.doris.kerberos.HadoopAuthenticator;
 import org.apache.doris.thrift.THiveTable;
 import org.apache.doris.thrift.TIcebergTable;
 import org.apache.doris.thrift.TTableDescriptor;
@@ -54,6 +61,13 @@ import org.apache.doris.thrift.TTableType;
 
 import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.DeleteFiles;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
 import org.apache.iceberg.MetadataTableType;
 import org.apache.iceberg.MetadataTableUtils;
 import org.apache.iceberg.PartitionField;
@@ -62,12 +76,24 @@ import org.apache.iceberg.RowLevelOperationMode;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Snapshot;
 import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.SnapshotSummary;
 import org.apache.iceberg.SortOrder;
 import org.apache.iceberg.Table;
+import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.TableScan;
+import org.apache.iceberg.Transaction;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Evaluator;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.ExpressionUtil;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.expressions.Projections;
+import org.apache.iceberg.expressions.StrictMetricsEvaluator;
+import org.apache.iceberg.io.CloseableIterable;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.SnapshotUtil;
@@ -77,6 +103,9 @@ import org.apache.iceberg.view.ViewVersion;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -86,10 +115,12 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.OptionalLong;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.concurrent.Callable;
+import java.util.function.BooleanSupplier;
 import java.util.function.Predicate;
 
 /**
@@ -1968,6 +1999,308 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
         }
     }
 
+    /**
+     * 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,
+            BooleanSupplier isCancelled) {
+        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, isCancelled)));
+        } 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,
+            BooleanSupplier isCancelled) {
+        if (isCancelled.getAsBoolean()) {
+            return OptionalLong.empty();
+        }
+        if (!(table instanceof BaseTable)
+                || ((BaseTable) table).operations().current().formatVersion() 
< 2) {
+            // A v1 table has no row-level delete contract (the row-level path 
fails with
+            // "must have format version 2 or higher for position deletes"); 
keep that behavior.
+            return OptionalLong.empty();
+        }
+        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();
+        }
+        // Candidate planning and delete-manifest reads fan out onto Iceberg's 
shared worker pool, which needs the
+        // plugin Kerberos FileIO; the caller-thread executeAuthenticated 
scope does not cover those workers.
+        Table scanTable = wrapTableForScan(table);
+        if (hasLiveDeleteFiles(scanTable, base)) {
+            // The affected-row count is only exact when nothing was hidden 
before: legacy position deletes,
+            // equality deletes (a key is not a row) and stale-delete cleanup 
all break the summary arithmetic.
+            LOG.info("Skipping metadata delete of {}: the table already 
carries delete files.", table.name());
+            return OptionalLong.empty();
+        }
+        if (!matchesWholeFilesOnly(scanTable, deleteExpr) || 
isCancelled.getAsBoolean()) {
+            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, and so a
+        // concurrently added delete file cannot slip into the affected-row 
summary.
+        table.refresh();
+        validateBoundWriteGeneration(table, boundWriteMetadataIdentity);
+        Snapshot refreshed = table.currentSnapshot();
+        if (refreshed == null || hasLiveDeleteFiles(scanTable, refreshed)) {
+            return OptionalLong.empty();
+        }
+        long refreshedSnapshotId = refreshed.snapshotId();
+
+        // 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. The bound identity
+        // and required snapshot fence the final CAS and every retry replay: a 
concurrent commit aborts the
+        // metadata delete (the row-level fallback then reports an exact 
affected-row count).
+        Transaction transaction = IcebergConnectorTransaction.openTransaction(
+                table, context, null, false, boundWriteMetadataIdentity, 
refreshedSnapshotId);
+        DeleteFiles deleteFiles = 
transaction.newDelete().deleteFromRowFilter(deleteExpr);
+        if (isCancelled.getAsBoolean()) {
+            return OptionalLong.empty();
+        }
+        deleteFiles.commit();
+        if (isCancelled.getAsBoolean()) {
+            // The snapshot is staged on the transaction only, so dropping it 
leaves the table unchanged (the
+            // staged metadata files are unreferenced and are reclaimed by the 
existing orphan-file cleanup).
+            LOG.info("Metadata delete of {} was cancelled before the remote 
commit.", table.name());
+            return OptionalLong.empty();
+        }
+        transaction.commitTransaction();
+        Snapshot committed = transaction.table().currentSnapshot();
+        if (committed == null || committed.snapshotId() == 
refreshedSnapshotId) {
+            // The predicate matched nothing; DeleteFiles does not create an 
empty snapshot.
+            return OptionalLong.of(0L);
+        }
+        return OptionalLong.of(deletedRowCount(committed));
+    }
+
+    /**
+     * Authenticated view of {@code table} for candidate-scan manifest IO — 
the metadata-delete mirror of
+     * {@code IcebergScanPlanProvider.wrapTableForScan}. Non-Kerberos catalogs 
and offline fakes get the table
+     * unchanged; the wrapper delegates reads to the same underlying 
operations.
+     */
+    private Table wrapTableForScan(Table table) {
+        if (!(context instanceof TcclPinningConnectorContext) || !(table 
instanceof BaseTable)) {
+            return table;
+        }
+        HadoopAuthenticator auth = ((TcclPinningConnectorContext) 
context).getPluginAuthenticator();
+        if (auth == null) {
+            return table;
+        }
+        BaseTable baseTable = (BaseTable) table;
+        TableOperations rawOps = baseTable.operations();
+        return new BaseTable(new IcebergAuthenticatedTableOperations(
+                rawOps, new IcebergAuthenticatedFileIO(rawOps.io(), auth)),
+                table.name(), baseTable.reporter());
+    }
+
+    /** 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.
+     *
+     */
+    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;
+        }
+    }
+
+    /**
+     * Whether the snapshot has any live delete file. Both callers only need 
presence, so stop at the first live
+     * entry instead of materializing every delete file of every unrelated 
partition.
+     */
+    private static boolean hasLiveDeleteFiles(Table table, Snapshot snapshot) {
+        try {
+            for (ManifestFile manifest : snapshot.deleteManifests(table.io())) 
{
+                try (ManifestReader<DeleteFile> reader =
+                        ManifestFiles.readDeleteManifest(manifest, table.io(), 
table.specs())) {
+                    if (reader.iterator().hasNext()) {
+                        return true;
+                    }
+                }
+            }
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+        return false;
+    }
+
+    /**
+     * Rows deleted by a metadata delete, derived from this delete's own 
committed snapshot. The pre-commit gate
+     * requires that no delete file existed on the table (checked again after 
the refresh), so no hidden rows need
+     * subtracting; a deletion vector added by a concurrent row-level delete 
between that check and our commit is
+     * still exact because its record count is its cardinality. 
Equality-delete records are keys, never rows, so
+     * they are not subtracted (a concurrent equality delete only logs a 
warning).
+     */
+    private static long deletedRowCount(Snapshot committed) {
+        if (committed == null || committed.summary() == null) {
+            return 0L;
+        }
+        Map<String, String> summary = committed.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"));
+        if (removedEqDeletes > 0) {
+            // A concurrent writer removed an equality delete between the gate 
and the commit: its records are
+            // keys, so the exact live-row count is no longer derivable.
+            LOG.warn("Metadata delete {} removed equality deletes; 
affected-row count may be approximate: {}.",
+                    committed.snapshotId(), committed.summary());
+        }
+        return Math.max(0L, deletedRecords - removedPosDeletes);
+    }
+
     /**
      * Rejects an illegal static-partition column on {@code INSERT [OVERWRITE] 
... PARTITION (col=val)}: the name
      * must be the SOURCE column of an <em>identity</em> partition field of 
this (partitioned) table.
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
index ddfa9d9fae1..ad17c6d353e 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
@@ -60,6 +60,7 @@ import org.apache.iceberg.TableScan;
 import org.apache.iceberg.Transaction;
 import org.apache.iceberg.Transactions;
 import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.exceptions.ValidationException;
 import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.io.CloseableIterable;
@@ -356,6 +357,17 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
      * {@code io()} differs. Non-Kerberos catalogs ({@code 
getPluginAuthenticator() == null}) keep the legacy path.
      */
     private Transaction openTransaction(Table loaded, boolean 
requireCurrentPartitionSpec) {
+        return openTransaction(loaded, context, writeSchemaContext, 
requireCurrentPartitionSpec, null, null);
+    }
+
+    /**
+     * Transaction-opening seam shared by the row-level write path and the 
whole-file metadata delete: wraps the
+     * table's {@link TableOperations#io()} in the plugin Kerberos {@code 
doAs} so manifest work fanned onto
+     * Iceberg's shared worker pool still authenticates, and optionally 
installs the pinned write-schema validator.
+     */
+    static Transaction openTransaction(Table loaded, ConnectorContext context,
+            IcebergWriteSchemaContext writeSchemaContext, boolean 
requireCurrentPartitionSpec,
+            String boundWriteMetadataIdentity, Long requiredSnapshotId) {
         HadoopAuthenticator auth = context instanceof 
TcclPinningConnectorContext
                 ? ((TcclPinningConnectorContext) 
context).getPluginAuthenticator()
                 : null;
@@ -370,9 +382,10 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
             FileIO authIo = new IcebergAuthenticatedFileIO(realOps.io(), auth);
             writeOps = new IcebergAuthenticatedTableOperations(realOps, 
authIo);
         }
-        if (writeSchemaContext != null) {
+        if (writeSchemaContext != null || boundWriteMetadataIdentity != null 
|| requiredSnapshotId != null) {
             writeOps = new ValidatingTableOperations(
-                    writeOps, writeSchemaContext, requireCurrentPartitionSpec, 
loaded.name());
+                    writeOps, writeSchemaContext, requireCurrentPartitionSpec, 
loaded.name(),
+                    boundWriteMetadataIdentity, requiredSnapshotId);
         }
         if (writeOps == realOps) {
             return loaded.newTransaction();
@@ -399,14 +412,23 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
         private final IcebergWriteSchemaContext writeSchemaContext;
         private final boolean requireCurrentPartitionSpec;
         private final String tableName;
+        // Non-null for a whole-file metadata delete: fences the final CAS 
(including retry replays) against a
+        // same-name table replacement or a schema/partition rebind that 
happened after planning.
+        private final String boundWriteMetadataIdentity;
+        // Non-null for a whole-file metadata delete: requires the commit to 
apply to the exact snapshot the
+        // affected-row preflight inspected, so a concurrent commit aborts 
instead of changing the removed set.
+        private final Long requiredSnapshotId;
 
         private ValidatingTableOperations(TableOperations delegate,
                 IcebergWriteSchemaContext writeSchemaContext,
-                boolean requireCurrentPartitionSpec, String tableName) {
+                boolean requireCurrentPartitionSpec, String tableName,
+                String boundWriteMetadataIdentity, Long requiredSnapshotId) {
             this.delegate = delegate;
             this.writeSchemaContext = writeSchemaContext;
             this.requireCurrentPartitionSpec = requireCurrentPartitionSpec;
             this.tableName = tableName;
+            this.boundWriteMetadataIdentity = boundWriteMetadataIdentity;
+            this.requiredSnapshotId = requiredSnapshotId;
         }
 
         @Override
@@ -416,7 +438,9 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
 
         @Override
         public TableMetadata refresh() {
-            return delegate.refresh();
+            TableMetadata refreshed = delegate.refresh();
+            checkRequiredSnapshot(refreshed);
+            return refreshed;
         }
 
         @Override
@@ -434,11 +458,32 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
                     new StaticTableOperations(
                             currentMetadata, delegate.io(), 
delegate.locationProvider()),
                     tableName);
-            writeSchemaContext.validateCurrentSchema(
-                    currentTable, requireCurrentPartitionSpec);
+            if (writeSchemaContext != null) {
+                writeSchemaContext.validateCurrentSchema(
+                        currentTable, requireCurrentPartitionSpec);
+            }
+            if (boundWriteMetadataIdentity != null
+                    && !boundWriteMetadataIdentity.equals(
+                            
IcebergWritePlanProvider.writeMetadataIdentity(currentTable))) {
+                throw new DorisConnectorException(
+                        "Iceberg write metadata changed after the write was 
bound; retry the statement");
+            }
+            checkRequiredSnapshot(currentMetadata);
             delegate.commit(base, metadata);
         }
 
+        /** Aborts a retry replay that would apply the delete to a snapshot 
other than the preflighted one. */
+        private void checkRequiredSnapshot(TableMetadata metadata) {
+            if (requiredSnapshotId == null) {
+                return;
+            }
+            Snapshot current = metadata.currentSnapshot();
+            if (current == null || current.snapshotId() != requiredSnapshotId) 
{
+                throw new ValidationException(
+                        "Table %s changed since the metadata delete preflight; 
retry the statement", tableName);
+            }
+        }
+
         @Override
         public FileIO io() {
             return delegate.io();
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergMetadataDeleteTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergMetadataDeleteTest.java
new file mode 100644
index 00000000000..c6b08bdcdf0
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergMetadataDeleteTest.java
@@ -0,0 +1,378 @@
+// 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"))),
+                null, () -> false);
+
+        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(), null, () -> false);
+
+        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))),
+                null, () -> false);
+
+        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))),
+                null, () -> false);
+
+        Assertions.assertFalse(deleted.isPresent(), "an unliftable predicate 
must not be treated as always-true");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId());
+    }
+
+    @Test
+    public void existingDeleteFilesFallBackToTheRowLevelPath() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = 
PartitionSpec.builderFor(SCHEMA).identity("region").build();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "3"));
+        DataFile dataFile = partitionedDataFile(spec, 
"s3://b/db1/t1/region=us/a.parquet", 10L, "region=us");
+        table.newAppend().appendFile(dataFile).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", dataFile, 3L)).commit();
+        Assertions.assertEquals(1, attachedDeleteFileCount(table, 
dataFile.path().toString()),
+                "the deletion vector must be attached to its data file");
+        long snapshotBefore = table.currentSnapshot().snapshotId();
+
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(SESSION, 
HANDLE, Optional.of(
+                new ConnectorComparison(ConnectorComparison.Operator.EQ,
+                        new ConnectorColumnRef("region", 
ConnectorType.of("STRING")), ConnectorLiteral.ofString("us"))),
+                null, () -> false);
+
+        Assertions.assertFalse(deleted.isPresent(),
+                "the affected-row count cannot be derived exactly once the 
file is hidden by a delete file");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId(),
+                "the fallback must not commit");
+        
Assertions.assertEquals(Collections.singletonList(dataFile.path().toString()),
+                dataFilePaths(reload(catalog)));
+        Assertions.assertEquals(1, attachedDeleteFileCount(reload(catalog), 
dataFile.path().toString()));
+    }
+
+    @Test
+    public void cancelledDeleteDoesNotCommit() {
+        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();
+        long snapshotBefore = table.currentSnapshot().snapshotId();
+
+        OptionalLong deleted = metadata(catalog).deleteUsingMetadata(
+                SESSION, HANDLE, Optional.empty(), null, () -> true);
+
+        Assertions.assertFalse(deleted.isPresent(), "a cancelled delete must 
not commit");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId());
+        Assertions.assertEquals(2, dataFilePaths(reload(catalog)).size());
+    }
+
+    @Test
+    public void v1TableKeepsTheFormatVersionContract() {
+        InMemoryCatalog catalog = freshCatalog();
+        PartitionSpec spec = PartitionSpec.unpartitioned();
+        Table table = catalog.createTable(TABLE_ID, SCHEMA, spec, 
props("format-version", "1"));
+        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.empty(), null, () -> false);
+
+        Assertions.assertFalse(deleted.isPresent(),
+                "v1 tables must fall back so the row-level path keeps 
rejecting row-level deletes");
+        Assertions.assertEquals(snapshotBefore, 
reload(catalog).currentSnapshot().snapshotId());
+    }
+
+    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, DataFile referencedDataFile,
+            long cardinality) {
+        return FileMetadata.deleteFileBuilder(spec)
+                .ofPositionDeletes()
+                .withPath(puffin)
+                .withFormat(FileFormat.PUFFIN)
+                .withFileSizeInBytes(128L)
+                .withRecordCount(cardinality)
+                // The deletion vector must carry the data file's partition 
tuple; otherwise iceberg treats the
+                // pair as corrupt metadata and a scan cannot attach the 
vector to its data file.
+                .withPartition(referencedDataFile.partition())
+                .withReferencedDataFile(referencedDataFile.path().toString())
+                .withContentOffset(0L)
+                .withContentSizeInBytes(64L)
+                .build();
+    }
+
+    private static int attachedDeleteFileCount(Table table, String 
dataFilePath) {
+        try (CloseableIterable<FileScanTask> tasks = 
table.newScan().planFiles()) {
+            for (FileScanTask task : tasks) {
+                if (task.file().path().toString().equals(dataFilePath)) {
+                    return task.deletes().size();
+                }
+            }
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+        return -1;
+    }
+
+    /** Minimal {@link ConnectorSession} exposing a time zone 
(partition-timestamp parsing); no Mockito. */
+    private static final class FakeSession implements ConnectorSession {
+        private final String timeZone;
+
+        FakeSession(String timeZone) {
+            this.timeZone = timeZone;
+        }
+
+        @Override
+        public String getQueryId() {
+            return "q";
+        }
+
+        @Override
+        public String getUser() {
+            return "u";
+        }
+
+        @Override
+        public String getTimeZone() {
+            return timeZone;
+        }
+
+        @Override
+        public String getLocale() {
+            return "en_US";
+        }
+
+        @Override
+        public <T> T getProperty(String name, Class<T> type) {
+            return null;
+        }
+
+        @Override
+        public long getCatalogId() {
+            return 0;
+        }
+
+        @Override
+        public String getCatalogName() {
+            return "test";
+        }
+
+        @Override
+        public Map<String, String> getCatalogProperties() {
+            return Collections.emptyMap();
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorWriteOps.java
 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorWriteOps.java
index ef47bd92875..92b81a4a09b 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorWriteOps.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorWriteOps.java
@@ -20,8 +20,12 @@ package org.apache.doris.connector.spi;
 import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
 import org.apache.doris.connector.spi.handle.ConnectorTransaction;
 import org.apache.doris.connector.spi.handle.WriteOperation;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
 
 import java.util.List;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.function.BooleanSupplier;
 
 /**
  * Write (DML) operations that a connector may support.
@@ -55,6 +59,45 @@ public interface ConnectorWriteOps {
         // 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. The current engine represents an unfiltered DELETE as a 
present true literal; implementations
+     * may also accept {@code Optional.empty()} as always true. 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; the engine sends a
+     *                  true literal for an unfiltered delete
+     * @param boundWriteMetadataIdentity the write-metadata generation the 
statement was planned against, or
+     *                  {@code null} when the engine has none; a connector 
whose writes are fenced by a table
+     *                  generation MUST reject a mismatch before committing
+     * @param isCancelled checked before the remote commit; returning {@code 
true} must abort without committing
+     *                  (the engine then reports the retained cancellation 
reason)
+     * @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,
+            Optional<ConnectorExpression> predicate, String 
boundWriteMetadataIdentity,
+            BooleanSupplier isCancelled) {
+        return OptionalLong.empty();
+    }
+
     /**
      * Validates that every column named in a static-partition spec ({@code 
INSERT [OVERWRITE] ... PARTITION
      * (col=val)}) is a legal static-partition target on {@code handle}, 
throwing a {@link DorisConnectorException}
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
index cce4173053f..83944f58185 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
@@ -88,9 +88,9 @@ public class ConnectorPluginSurfaceTest {
             Assertions.assertNotNull(in, "missing connector plugin API version 
resource");
             version.load(in);
         }
-        // Major 19 adds 
ConnectorColumn#isSourceNullable()/withSourceNullable(boolean) - the 
nullability the
-        // source declares, kept separate from the nullability the engine 
reads the column with.
-        Assertions.assertEquals("19.0", version.getProperty("api.version"));
+        // Major 19 adds 
ConnectorColumn#isSourceNullable()/withSourceNullable(boolean); major 20 adds
+        // deleteUsingMetadata to the connector write surface.
+        Assertions.assertEquals("20.0", version.getProperty("api.version"));
     }
 
     /** Root entry points plus provider/handle types returned to connector 
plugins. */
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
index 80057cd9be8..8c48399c75f 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
@@ -17,6 +17,7 @@ 
createOrReplaceBranch(org.apache.doris.connector.spi.ConnectorSession,org.apache
 
createOrReplaceTag(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,org.apache.doris.connector.spi.ddl.TagChange)
 
createTable(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.ddl.ConnectorCreateTableRequest)
 
databaseExists(org.apache.doris.connector.spi.ConnectorSession,java.lang.String)
+deleteUsingMetadata(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.Optional,java.lang.String,java.util.function.BooleanSupplier)
 
dropBranch(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,org.apache.doris.connector.spi.ddl.DropRefChange)
 
dropColumn(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.lang.String)
 
dropDatabase(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,boolean,boolean)
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index 50adfde9967..d1d8f2fb4c4 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -55,7 +55,7 @@ under the License.
           of the latter two means bumping this property as well (and 
fe-extension-spi means bumping
           all five families).
         -->
-        <connector.plugin.api.version>19.0</connector.plugin.api.version>
+        <connector.plugin.api.version>20.0</connector.plugin.api.version>
     </properties>
 
     <modules>
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java
index ad3db2b4869..7e5dc31130e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java
@@ -57,8 +57,9 @@ import java.util.List;
  * IcebergNereidsUtils.convertNereidsToIcebergExpression}): {@code And}/{@code 
Or}/{@code Not}, the five
  * comparisons ({@code EqualTo}/{@code GreaterThan}/{@code 
GreaterThanEqual}/{@code LessThan}/{@code
  * LessThanEqual}), {@code InPredicate}, {@code IsNull}, {@code Between}. 
Comparisons require a bare
- * {@link SlotReference} on one side and a {@link Literal} on the other 
(operands are normalised to
- * column-on-left, the operator unchanged — mirroring legacy {@code 
convertNereidsBinaryPredicate}).</p>
+ * {@link SlotReference} on one side and a {@link Literal} on the other. Loose 
conversion preserves the
+ * legacy operator behavior; strict conversion mirrors the operator when it 
normalises operands to
+ * column-on-left.</p>
  *
  * <p><b>Anything else yields {@code null}</b> — {@code NullSafeEqual}, {@code 
Cast}-wrapped columns,
  * column-to-column comparisons, bare literals, etc. The legacy conflict path 
drops exactly these. Dropping a
@@ -83,26 +84,49 @@ public final class 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);
+    }
+
+    private static ConnectorExpression convert(Expression expr, boolean 
strict) {
         if (expr == null) {
             return null;
         }
         if (expr instanceof And) {
-            return convertAnd((And) expr);
+            return convertAnd((And) expr, strict);
         } else if (expr instanceof Or) {
-            return convertOr((Or) expr);
+            return convertOr((Or) expr, strict);
         } else if (expr instanceof Not) {
-            ConnectorExpression child = convert(((Not) expr).child());
+            // SQL negation is not exact for a destructive whole-file 
predicate: Iceberg's notEqual/notIn
+            // matching treats NULL rows as matching, while SQL NOT(UNKNOWN) 
is UNKNOWN (deletes nothing), so
+            // lowering NOT(col = x) would drop files whose rows are all NULL. 
Only IS NOT NULL keeps SQL
+            // semantics under negation; every other negated form falls back 
to the row-level path in strict mode.
+            if (strict && !(((Not) expr).child() instanceof IsNull)) {
+                return null;
+            }
+            ConnectorExpression child = convert(((Not) expr).child(), strict);
             return child == null ? null : new ConnectorNot(child);
         } else if (expr instanceof EqualTo) {
-            return convertComparison(expr, ConnectorComparison.Operator.EQ);
+            return convertComparison(expr, ConnectorComparison.Operator.EQ, 
strict);
         } else if (expr instanceof GreaterThan) {
-            return convertComparison(expr, ConnectorComparison.Operator.GT);
+            return convertComparison(expr, ConnectorComparison.Operator.GT, 
strict);
         } else if (expr instanceof GreaterThanEqual) {
-            return convertComparison(expr, ConnectorComparison.Operator.GE);
+            return convertComparison(expr, ConnectorComparison.Operator.GE, 
strict);
         } else if (expr instanceof LessThan) {
-            return convertComparison(expr, ConnectorComparison.Operator.LT);
+            return convertComparison(expr, ConnectorComparison.Operator.LT, 
strict);
         } else if (expr instanceof LessThanEqual) {
-            return convertComparison(expr, ConnectorComparison.Operator.LE);
+            return convertComparison(expr, ConnectorComparison.Operator.LE, 
strict);
         } else if (expr instanceof InPredicate) {
             return convertIn((InPredicate) expr);
         } else if (expr instanceof IsNull) {
@@ -114,9 +138,12 @@ public final class NereidsToConnectorExpressionConverter {
         return null;
     }
 
-    private static ConnectorExpression convertAnd(And and) {
+    private static ConnectorExpression convertAnd(And and, boolean strict) {
         List<ConnectorExpression> conjuncts = new ArrayList<>();
-        flattenAnd(and, conjuncts);
+        boolean complete = flattenAnd(and, conjuncts, strict);
+        if (!complete) {
+            return null;
+        }
         conjuncts.removeIf(c -> c == null);
         if (conjuncts.isEmpty()) {
             return null;
@@ -124,34 +151,42 @@ public final class NereidsToConnectorExpressionConverter {
         return conjuncts.size() == 1 ? conjuncts.get(0) : new 
ConnectorAnd(conjuncts);
     }
 
-    private static void flattenAnd(Expression expr, List<ConnectorExpression> 
out) {
+    private static boolean flattenAnd(Expression expr, 
List<ConnectorExpression> out, boolean strict) {
         if (expr instanceof And) {
             for (Expression child : expr.children()) {
-                flattenAnd(child, out);
+                if (!flattenAnd(child, out, strict)) {
+                    return false;
+                }
             }
+            return true;
         } else {
-            out.add(convert(expr));
+            ConnectorExpression converted = convert(expr, strict);
+            if (converted == null && strict) {
+                return false;
+            }
+            out.add(converted);
+            return true;
         }
     }
 
-    private static ConnectorExpression convertOr(Or or) {
+    private static ConnectorExpression convertOr(Or or, boolean strict) {
         List<ConnectorExpression> disjuncts = new ArrayList<>();
-        if (!flattenOr(or, disjuncts) || disjuncts.isEmpty()) {
+        if (!flattenOr(or, disjuncts, strict) || disjuncts.isEmpty()) {
             return null;
         }
         return disjuncts.size() == 1 ? disjuncts.get(0) : new 
ConnectorOr(disjuncts);
     }
 
-    private static boolean flattenOr(Expression expr, 
List<ConnectorExpression> out) {
+    private static boolean flattenOr(Expression expr, 
List<ConnectorExpression> out, boolean strict) {
         if (expr instanceof Or) {
             for (Expression child : expr.children()) {
-                if (!flattenOr(child, out)) {
+                if (!flattenOr(child, out, strict)) {
                     return false;
                 }
             }
             return true;
         }
-        ConnectorExpression c = convert(expr);
+        ConnectorExpression c = convert(expr, strict);
         if (c == null) {
             return false;
         }
@@ -159,7 +194,8 @@ public final class NereidsToConnectorExpressionConverter {
         return true;
     }
 
-    private static ConnectorExpression convertComparison(Expression cmp, 
ConnectorComparison.Operator op) {
+    private static ConnectorExpression convertComparison(
+            Expression cmp, ConnectorComparison.Operator op, boolean strict) {
         Expression left = cmp.child(0);
         Expression right = cmp.child(1);
         SlotReference slot;
@@ -170,6 +206,11 @@ public final class NereidsToConnectorExpressionConverter {
         } else if (left instanceof Literal && right instanceof SlotReference) {
             slot = (SlotReference) right;
             literal = (Literal) left;
+            if (strict) {
+                // Exact destructive predicates must commute the operator with 
the operands: `1 < id` is
+                // `id > 1`. Loose conflict-filter behavior is deliberately 
unchanged by this feature.
+                op = commute(op);
+            }
         } else {
             return null;
         }
@@ -180,6 +221,23 @@ public final class NereidsToConnectorExpressionConverter {
         return new ConnectorComparison(op, columnRef(slot), litExpr);
     }
 
+    /** Mirrors {@code op} across the operands: {@code 1 < id} is {@code id > 
1}. */
+    private static ConnectorComparison.Operator 
commute(ConnectorComparison.Operator op) {
+        switch (op) {
+            case LT:
+                return ConnectorComparison.Operator.GT;
+            case LE:
+                return ConnectorComparison.Operator.GE;
+            case GT:
+                return ConnectorComparison.Operator.LT;
+            case GE:
+                return ConnectorComparison.Operator.LE;
+            default:
+                // EQ / NE / EQ_FOR_NULL are symmetric.
+                return op;
+        }
+    }
+
     private static ConnectorExpression convertIn(InPredicate in) {
         if (!(in.child(0) instanceof SlotReference)) {
             return null;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java
index 2897ad8c7b4..251380db7af 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java
@@ -17,15 +17,22 @@
 
 package org.apache.doris.datasource.connector.converter;
 
+import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.TableIf;
 import org.apache.doris.connector.spi.pushdown.ConnectorAnd;
 import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
 import org.apache.doris.connector.spi.pushdown.ConnectorPredicate;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCheckPolicy;
 import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSink;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -102,6 +109,105 @@ public final class WriteConstraintExtractor {
         }
     }
 
+    /**
+     * 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>Every conjunct must be a physical target-table predicate. A conjunct 
is rejected — not silently
+     * dropped — when it references a synthetic column: the exclusion list is 
name-based, and a name such as the
+     * connector's {@code $partition_spec_id} can also be a legitimate user 
column, so provenance cannot be
+     * proven here. A conjunct whose slot name differs from the physical 
column it came from is rejected too:
+     * an alias/mask projection renames a slot while keeping its original 
column, and emitting the alias name
+     * would target a different physical column.</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 at all: 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 (!isExactPhysicalTargetPredicate(conjunct, targetTableId, 
exclusion)) {
+                    return false;
+                }
+                output.add(conjunct);
+            }
+        } else if (!(plan instanceof LogicalSink) && !(plan instanceof 
LogicalProject)
+                && !(plan instanceof LogicalCheckPolicy) && !(plan instanceof 
LogicalSubQueryAlias)
+                && !(plan instanceof LogicalCatalogRelation)) {
+            return false;
+        }
+
+        List<Plan> children = plan.children();
+        if (plan instanceof LogicalCatalogRelation) {
+            return children.isEmpty() && ((LogicalCatalogRelation) 
plan).getTable().getId() == targetTableId;
+        }
+        if (children.size() != 1) {
+            return false;
+        }
+        return collectCompletePredicate(children.get(0), targetTableId, 
exclusion, output);
+    }
+
+    /**
+     * A conjunct that can be lowered to a physical predicate with no loss: 
target-only, and every slot still
+     * carries the name of the column it was created from (no alias/mask 
rename).
+     */
+    private static boolean isExactPhysicalTargetPredicate(Expression 
predicate, long targetTableId,
+            Predicate<SlotReference> exclusion) {
+        if (!isTargetOnlyPredicate(predicate, targetTableId, exclusion)) {
+            return false;
+        }
+        for (Slot slot : predicate.getInputSlots()) {
+            SlotReference reference = (SlotReference) slot;
+            Optional<Column> originalColumn = reference.getOriginalColumn();
+            if (!originalColumn.isPresent() || 
!reference.getName().equals(originalColumn.get().getName())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private static boolean isTargetOnlyPredicate(Expression predicate, long 
targetTableId,
             Predicate<SlotReference> exclusion) {
         if (predicate == null) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransform.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransform.java
index 9996e3e8915..d97a7c00801 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransform.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransform.java
@@ -23,6 +23,7 @@ import org.apache.doris.connector.spi.ConnectorSession;
 import org.apache.doris.connector.spi.DorisConnectorException;
 import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
 import org.apache.doris.connector.spi.handle.WriteOperation;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
 import org.apache.doris.connector.spi.pushdown.ConnectorPredicate;
 import org.apache.doris.connector.spi.write.ConnectorRowChangeStyle;
 import org.apache.doris.datasource.ExternalTable;
@@ -45,8 +46,10 @@ import org.apache.doris.planner.PlanFragment;
 import org.apache.doris.qe.ConnectContext;
 
 import java.util.Optional;
+import java.util.OptionalLong;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.function.BooleanSupplier;
 import java.util.function.Predicate;
 
 /**
@@ -209,10 +212,45 @@ public class PositionDeleteRowLevelDmlTransform 
implements RowLevelDmlTransform
 
     @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,
+            BooleanSupplier isCancelled) {
+        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()));
+        // Fence the fresh by-name load against a drop/recreate (or schema 
rebind) that happened after analysis,
+        // exactly like the row-level write path does with its pinned 
write-metadata identity.
+        PluginDrivenExternalTable.WriteSchemaSnapshot writeSchema =
+                ((PluginDrivenExternalTable) table).getWriteSchemaSnapshot();
+        String boundWriteMetadataIdentity =
+                writeSchema == null ? null : 
writeSchema.getWriteMetadataIdentity();
+        return metadata.deleteUsingMetadata(session, handle, predicate, 
boundWriteMetadataIdentity, isCancelled);
+    }
+
+    /** The connector-declared synthetic (non-statement) columns, excluded 
from a statement predicate. */
+    private static Predicate<SlotReference> syntheticColumnExclusion(TableIf 
table) {
         Set<String> excludedColumns = new 
TreeSet<>(String.CASE_INSENSITIVE_ORDER);
         excludedColumns.addAll(((PluginDrivenExternalTable) table)
                 .getConnectorRowLevelWriteConstraintExcludedColumns());
-        Predicate<SlotReference> exclusion = slot -> 
excludedColumns.contains(slot.getName());
-        return WriteConstraintExtractor.extract(analyzedPlan, table.getId(), 
exclusion);
+        return slot -> excludedColumns.contains(slot.getName());
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
index 9d3911f5422..cb1884eb572 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
@@ -17,10 +17,14 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.Status;
+import org.apache.doris.common.UserException;
 import org.apache.doris.connector.spi.handle.ConnectorTransaction;
 import org.apache.doris.datasource.ExternalTable;
 import org.apache.doris.nereids.NereidsPlanner;
+import org.apache.doris.nereids.PlannerHook;
 import org.apache.doris.nereids.glue.LogicalPlanAdapter;
 import org.apache.doris.nereids.trees.plans.Plan;
 import 
org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor;
@@ -34,8 +38,12 @@ import org.apache.doris.qe.StmtExecutor;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 
+import java.util.OptionalLong;
 import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Generic shell for row-level DML ({@code DELETE}/{@code UPDATE}/{@code MERGE 
INTO}) against external tables.
@@ -53,6 +61,8 @@ import java.util.concurrent.Callable;
  */
 public class RowLevelDmlCommand {
 
+    private static final Logger LOG = 
LogManager.getLogger(RowLevelDmlCommand.class);
+
     private final RowLevelDmlTransform transform;
     private final RowLevelDmlArgs args;
     private final RowLevelDmlOp op;
@@ -80,10 +90,37 @@ public class RowLevelDmlCommand {
             Callable<Void> execute = () -> {
                 LogicalPlanAdapter logicalPlanAdapter = new 
LogicalPlanAdapter(plan, ctx.getStatementContext());
                 NereidsPlanner planner = new 
NereidsPlanner(ctx.getStatementContext());
+                boolean tryMetadataDelete =
+                        op == RowLevelDmlOp.DELETE && 
args.getPartitions().isEmpty() && !args.isTempPart();
+                AtomicReference<Plan> metadataDeletePlan = new 
AtomicReference<>();
+                if (tryMetadataDelete) {
+                    captureAnalyzedPlan(ctx, metadataDeletePlan);
+                }
                 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 (tryMetadataDelete) {
+                    // The ordinary row-level path replays this retained 
cancellation when the executor publishes
+                    // its coordinator; this branch has no coordinator, so do 
it explicitly before touching the
+                    // remote table.
+                    throwIfCancelled(stmtExecutor);
+                    OptionalLong metadataDeletedRows =
+                            transform.deleteUsingMetadata(ctx, args, 
metadataDeletePlan.get(),
+                                    () -> 
stmtExecutor.getPendingCancelReason() != null);
+                    if (metadataDeletedRows.isPresent()) {
+                        reportMetadataDelete(ctx, table, 
metadataDeletedRows.getAsLong());
+                        return null;
+                    }
+                    // The connector aborts before committing when the query 
was cancelled; surface the original
+                    // cancellation instead of falling back to a row-level 
write.
+                    throwIfCancelled(stmtExecutor);
+                }
+
                 PhysicalSink<?> physicalSink = 
transform.requirePhysicalSink(planner, op);
                 PlanFragment fragment = planner.getFragments().get(0);
                 DataSink dataSink = fragment.getSink();
@@ -180,4 +217,54 @@ public class RowLevelDmlCommand {
         return sink.children() != null && sink.children().size() == 1
                 && sink.child(0) instanceof PhysicalEmptyRelation;
     }
+
+    /** Replays a cancellation retained by the executor before the statement 
may mutate the remote table. */
+    @VisibleForTesting
+    static void throwIfCancelled(StmtExecutor stmtExecutor) throws 
UserException {
+        Status cancelReason = stmtExecutor.getPendingCancelReason();
+        if (cancelReason != null) {
+            throw new UserException(cancelReason.getErrorMsg());
+        }
+    }
+
+    /**
+     * Registers the analyzed-plan capture the whole-file DELETE fast path 
consumes.
+     *
+     * <p>{@link NereidsPlanner} only retains {@code analyzedPlan} for {@code 
EXPLAIN ANALYZED}/{@code ALL} or when
+     * a {@link PlannerHook} records it; a plain DELETE leaves {@code 
getAnalyzedPlan()} null, which would make
+     * {@code extractCompletePredicate} return empty and silently disable the 
optimization. Keep the capture local
+     * to the metadata-delete attempt: publishing it through {@link 
NereidsPlanner#setAnalyzedPlan} would also
+     * activate the ordinary row-level conflict-filter consumer when this fast 
path falls back.</p>
+     */
+    @VisibleForTesting
+    static void captureAnalyzedPlan(ConnectContext ctx, AtomicReference<Plan> 
analyzedPlan) {
+        ctx.getStatementContext().addPlannerHook(new PlannerHook() {
+            @Override
+            public void afterAnalyze(NereidsPlanner planner) {
+                if (planner.getCascadesContext() != null
+                        && planner.getCascadesContext().getRewritePlan() != 
null) {
+                    
analyzedPlan.set(planner.getCascadesContext().getRewritePlan());
+                }
+            }
+        });
+    }
+
+    /**
+     * Publishes the result of a whole-file metadata delete. The connector has 
already committed the remote
+     * delete as one atomic metadata operation, so no coordinator or connector 
transaction is involved here:
+     * mirror the surfaces the row-level path's {@code afterExec} feeds — 
{@link org.apache.doris.qe.QueryState}
+     * for the client's "Query OK, N rows affected" and the fe.audit.log 
return-rows counter — plus its
+     * post-commit refresh, which a remote commit needs so later queries do 
not read a stale cached snapshot.
+     */
+    private static void reportMetadataDelete(ConnectContext ctx, TableIf 
table, long deletedRows) {
+        ctx.getState().setOk(deletedRows, 0, "{'status':'COMMITTED'}");
+        ctx.updateReturnRows((int) deletedRows);
+        try {
+            
Env.getCurrentEnv().getRefreshManager().refreshTableAfterExternalMutation((ExternalTable)
 table);
+        } catch (Exception e) {
+            // The remote delete is already durable: a refresh failure must 
not make the client retry it.
+            LOG.warn("Post-commit refresh failed for table {}. Data was 
committed successfully.",
+                    table.getName(), e);
+        }
+    }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java
index 841350fd8be..13ecbef0f17 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java
@@ -29,6 +29,8 @@ import org.apache.doris.planner.PlanFragment;
 import org.apache.doris.qe.ConnectContext;
 
 import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.function.BooleanSupplier;
 
 /**
  * Per-table strategy that turns a row-level DML ({@code DELETE}/{@code 
UPDATE}/{@code MERGE INTO}) against an
@@ -77,4 +79,23 @@ public interface RowLevelDmlTransform {
      * when no target-only conjunct survives.
      */
     Optional<ConnectorPredicate> extractWriteConstraint(Plan analyzedPlan, 
TableIf table);
+
+    /**
+     * Whole-file "metadata delete" fast path for {@code DELETE}, attempted 
once the analyzed plan is available
+     * and before the row-level (position-delete) plan is executed.
+     *
+     * <p>The implementation asks the connector whether the statement's 
predicate can be honoured by dropping
+     * whole data files ({@code ConnectorWriteOps.deleteUsingMetadata}). A 
returned count means the connector
+     * already committed the remote delete, so {@link RowLevelDmlCommand} 
reports the result and skips the scan,
+     * the sink and the connector transaction entirely. Returning empty — the 
default for transforms whose
+     * connector has no such path — runs the ordinary row-level delete.</p>
+     *
+     * <p>Callers must only invoke this for a DELETE whose statement scope is 
exactly its predicate (no
+     * {@code PARTITION(...)} clause), because a predicate that silently 
ignores another row restriction would
+     * delete too much.</p>
+     */
+    default OptionalLong deleteUsingMetadata(ConnectContext ctx, 
RowLevelDmlArgs args, Plan analyzedPlan,
+            BooleanSupplier isCancelled) {
+        return OptionalLong.empty();
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java
index 3d2e9e96eea..f016579131e 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java
@@ -295,4 +295,63 @@ public class NereidsToConnectorExpressionConverterTest {
     public void nullInputReturnsNull() {
         
Assertions.assertNull(NereidsToConnectorExpressionConverter.convert(null));
     }
+
+    // ---- strict variant: never drops an arm (the whole-file metadata-delete 
predicate) ----
+
+    @Test
+    public void strictConversionRefusesToDropAnAndArm() {
+        // The loose variant drops the column-to-column arm, which is safe 
only for a consumer that treats the
+        // result as an over-approximation (a conflict-detection filter). The 
whole-file delete fast path consumes
+        // the result as the exact statement predicate instead, so the strict 
variant must refuse the whole AND.
+        SlotReference a = slot("a", ScalarType.INT);
+        SlotReference b = slot("b", ScalarType.INT);
+        SlotReference c = slot("c", ScalarType.INT);
+        And and = new And(new EqualTo(a, new IntegerLiteral(1)), new 
EqualTo(b, c));
+
+        ConnectorExpression loose = 
NereidsToConnectorExpressionConverter.convert(and);
+        Assertions.assertInstanceOf(ConnectorComparison.class, loose, "the 
loose variant keeps the arm it can");
+
+        
Assertions.assertNull(NereidsToConnectorExpressionConverter.convertStrict(and),
+                "the strict variant must not widen an exact predicate");
+    }
+
+    @Test
+    public void strictConversionKeepsFullyRepresentablePredicates() {
+        SlotReference a = slot("a", ScalarType.INT);
+        SlotReference b = slot("b", ScalarType.INT);
+
+        ConnectorExpression strict = 
NereidsToConnectorExpressionConverter.convertStrict(
+                new And(new EqualTo(a, new IntegerLiteral(1)), new 
GreaterThan(b, new IntegerLiteral(2))));
+
+        Assertions.assertInstanceOf(ConnectorAnd.class, strict);
+        Assertions.assertEquals(2, ((ConnectorAnd) 
strict).getConjuncts().size());
+        Assertions.assertEquals(NereidsToConnectorExpressionConverter.convert(
+                        new And(new EqualTo(a, new IntegerLiteral(1)), new 
GreaterThan(b, new IntegerLiteral(2)))),
+                strict, "with nothing to drop, strict and loose agree");
+    }
+
+    @Test
+    public void strictConversionCommutesLiteralLeftComparisons() {
+        // Analysis keeps the operand order as written, so `1 < id` reaches 
the converter literal-first. The
+        // exact delete predicate must become `id > 1`; keeping `<` would 
select the wrong files.
+        SlotReference id = slot("id", ScalarType.INT);
+
+        ConnectorComparison strict = (ConnectorComparison) 
NereidsToConnectorExpressionConverter.convertStrict(
+                new LessThan(new IntegerLiteral(1), id));
+
+        Assertions.assertEquals(ConnectorComparison.Operator.GT, 
strict.getOperator());
+        Assertions.assertEquals("id", ((ConnectorColumnRef) 
strict.getLeft()).getColumnName());
+    }
+
+    @Test
+    public void strictConversionRejectsSqlUnsafeNegations() {
+        // NOT(col = 1) becomes iceberg notEqual, which treats NULL-only files 
as matching, while SQL
+        // NOT(UNKNOWN) deletes nothing. IS NOT NULL is the only negation that 
keeps SQL semantics.
+        SlotReference id = slot("id", ScalarType.INT);
+
+        
Assertions.assertNull(NereidsToConnectorExpressionConverter.convertStrict(
+                new Not(new EqualTo(id, new IntegerLiteral(1)))));
+        Assertions.assertInstanceOf(ConnectorNot.class,
+                NereidsToConnectorExpressionConverter.convertStrict(new 
Not(new IsNull(id))));
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java
index 911131f9f13..108d1811721 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java
@@ -24,19 +24,27 @@ import org.apache.doris.connector.spi.pushdown.ConnectorAnd;
 import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
 import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
 import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
 import org.apache.doris.connector.spi.pushdown.ConnectorPredicate;
+import org.apache.doris.datasource.ExternalTable;
+import org.apache.doris.nereids.memo.GroupExpression;
+import org.apache.doris.nereids.properties.LogicalProperties;
 import org.apache.doris.nereids.trees.expressions.EqualTo;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.GreaterThan;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
 import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.PlanType;
 import org.apache.doris.nereids.trees.plans.RelationId;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
 import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation;
 import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -45,6 +53,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
@@ -227,4 +236,194 @@ public class WriteConstraintExtractorTest {
     public void nullPlanReturnsEmpty() {
         Assertions.assertFalse(WriteConstraintExtractor.extract(null, 
TARGET_ID, NO_EXCLUSION).isPresent());
     }
+
+    // ───────── complete-predicate extraction (the whole-file "metadata 
delete" fast path) ─────────
+
+    /**
+     * The complete variant only accepts a plan that is provably "target table 
→ filter(s) → sink": the leaf must
+     * be the target {@link 
org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation}, so a
+     * {@link LogicalEmptyRelation} (which cannot even arise from a DELETE, 
but would mean "no rows") is rejected
+     * rather than read as an unfiltered delete of everything.
+     */
+    private Plan completePlanOver(Set<Expression> conjuncts, SlotReference 
output) {
+        ExternalTable externalTarget = targetTableWithId();
+        return new LogicalFilter<>(conjuncts, new TargetScan(externalTarget, 
ImmutableList.of(output)));
+    }
+
+    private SlotReference externalSlot(ExternalTable table, String name) {
+        return SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(), 
table,
+                new Column(name, ScalarType.INT), ImmutableList.of());
+    }
+
+    private ExternalTable targetTableWithId() {
+        ExternalTable externalTarget = Mockito.mock(ExternalTable.class);
+        Mockito.when(externalTarget.getId()).thenReturn(TARGET_ID);
+        return externalTarget;
+    }
+
+    /** A minimal concrete target-table scan standing in for the DELETE's 
{@code LogicalFileScan} leaf. */
+    private static final class TargetScan extends LogicalCatalogRelation {
+        private final List<Slot> output;
+
+        TargetScan(TableIf table, List<Slot> output) {
+            super(new RelationId(0), PlanType.LOGICAL_FILE_SCAN, table, 
ImmutableList.of("db1"));
+            this.output = ImmutableList.copyOf(output);
+        }
+
+        @Override
+        public List<Slot> computeOutput() {
+            return output;
+        }
+
+        @Override
+        public LogicalCatalogRelation withRelationId(RelationId relationId) {
+            return this;
+        }
+
+        @Override
+        public LogicalCatalogRelation withTableAlias(String tableAlias) {
+            return this;
+        }
+
+        @Override
+        public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression> 
groupExpression,
+                Optional<LogicalProperties> logicalProperties, List<Plan> 
children) {
+            return this;
+        }
+
+        @Override
+        public Plan withGroupExpression(Optional<GroupExpression> 
groupExpression) {
+            return this;
+        }
+    }
+
+    @Test
+    public void completePredicateIsExtractedOverTheTargetScan() {
+        ExternalTable externalTarget = targetTableWithId();
+        SlotReference id = externalSlot(externalTarget, "id");
+
+        Optional<ConnectorExpression> result = 
WriteConstraintExtractor.extractCompletePredicate(
+                completePlanOver(ImmutableSet.of(new EqualTo(id, new 
IntegerLiteral(1))), id),
+                TARGET_ID, NO_EXCLUSION);
+
+        Assertions.assertTrue(result.isPresent());
+        Assertions.assertTrue(result.get() instanceof ConnectorComparison);
+        Assertions.assertEquals("id",
+                ((ConnectorColumnRef) ((ConnectorComparison) 
result.get()).getLeft()).getColumnName());
+    }
+
+    @Test
+    public void completePredicateWithoutWhereClauseIsAlwaysTrue() {
+        ExternalTable externalTarget = targetTableWithId();
+        TargetScan scan = new TargetScan(externalTarget, ImmutableList.of());
+
+        Optional<ConnectorExpression> result =
+                WriteConstraintExtractor.extractCompletePredicate(scan, 
TARGET_ID, NO_EXCLUSION);
+
+        Assertions.assertTrue(result.isPresent(), "DELETE without WHERE is an 
always-true predicate");
+        Assertions.assertTrue(result.get() instanceof ConnectorLiteral);
+        Assertions.assertEquals(Boolean.TRUE, ((ConnectorLiteral) 
result.get()).getValue());
+    }
+
+    @Test
+    public void completePredicateBailsOutWhenAnyConjunctIsUnrepresentable() {
+        // The loose variant drops the column-to-column arm (safe there: it 
only widens a conflict filter), but a
+        // whole-file delete must not: a dropped conjunct would drop files 
that still hold live rows.
+        ExternalTable externalTarget = targetTableWithId();
+        SlotReference id = externalSlot(externalTarget, "id");
+        SlotReference v = externalSlot(externalTarget, "v");
+        SlotReference w = externalSlot(externalTarget, "w");
+        Plan plan = completePlanOver(ImmutableSet.of(
+                new EqualTo(id, new IntegerLiteral(1)),
+                new EqualTo(v, w)), id);
+
+        Assertions.assertTrue(WriteConstraintExtractor.extract(plan, 
TARGET_ID, NO_EXCLUSION).isPresent(),
+                "the loose extractor keeps the representable arm");
+        Assertions.assertFalse(
+                WriteConstraintExtractor.extractCompletePredicate(plan, 
TARGET_ID, NO_EXCLUSION).isPresent(),
+                "the complete extractor must refuse the whole predicate 
instead of guessing");
+    }
+
+    @Test
+    public void completePredicateBailsOutOnCrossTableConjunct() {
+        ExternalTable externalTarget = targetTableWithId();
+        TableIf other = Mockito.mock(TableIf.class);
+        Mockito.when(other.getId()).thenReturn(2L);
+        SlotReference id = externalSlot(externalTarget, "id");
+        SlotReference otherId = slot(other, "id", ScalarType.INT);
+
+        Plan plan = completePlanOver(ImmutableSet.of(
+                new EqualTo(id, new IntegerLiteral(1)),
+                new EqualTo(otherId, new IntegerLiteral(2))), id);
+
+        
Assertions.assertFalse(WriteConstraintExtractor.extractCompletePredicate(plan, 
TARGET_ID, NO_EXCLUSION)
+                .isPresent(), "a conjunct over another table cannot be 
honoured by a whole-file delete");
+    }
+
+    @Test
+    public void completePredicateBailsOutOnSyntheticConjuncts() {
+        // Provenance between an engine-injected $row_id conjunct and a user 
column that merely shares a
+        // synthetic-looking name cannot be established here, so the exact 
extractor must fall back instead of
+        // dropping the conjunct (dropping it would widen the delete).
+        ExternalTable externalTarget = targetTableWithId();
+        SlotReference id = externalSlot(externalTarget, "id");
+        SlotReference rowId = externalSlot(externalTarget, 
"__DORIS_ICEBERG_ROWID_COL__");
+        Plan plan = completePlanOver(ImmutableSet.of(
+                new EqualTo(id, new IntegerLiteral(1)),
+                new EqualTo(rowId, new IntegerLiteral(0))), id);
+        Predicate<SlotReference> excludeRowId = s -> 
"__DORIS_ICEBERG_ROWID_COL__".equalsIgnoreCase(s.getName());
+
+        Optional<ConnectorExpression> result =
+                WriteConstraintExtractor.extractCompletePredicate(plan, 
TARGET_ID, excludeRowId);
+
+        Assertions.assertFalse(result.isPresent(), "a synthetic conjunct must 
disable the fast path");
+    }
+
+    @Test
+    public void completePredicateBailsOutOnNonLinearPlan() {
+        // Anything that can restrict rows without showing up as a 
LogicalFilter (a join, set operation,
+        // aggregate, limit, ...) must disable the fast path: its predicate is 
not part of what we can see.
+        Plan join = Mockito.mock(LogicalJoin.class);
+
+        
Assertions.assertFalse(WriteConstraintExtractor.extractCompletePredicate(join, 
TARGET_ID, NO_EXCLUSION)
+                .isPresent());
+    }
+
+    @Test
+    public void completePredicateBailsOutOnEmptyRelationLeaf() {
+        SlotReference id = externalSlot(targetTableWithId(), "id");
+        Plan plan = filterOver(ImmutableSet.of(new EqualTo(id, new 
IntegerLiteral(1))), id);
+
+        
Assertions.assertFalse(WriteConstraintExtractor.extractCompletePredicate(plan, 
TARGET_ID, NO_EXCLUSION)
+                .isPresent(), "a non-table leaf cannot prove the delete 
touches whole table files");
+    }
+
+    @Test
+    public void completePredicateBailsOutOnAliasRenamedSlots() {
+        // Alias.toSlot keeps the original table/column but renames the slot. 
Emitting that name would target a
+        // different physical column, so the exact extractor must fall back 
instead of trusting it.
+        ExternalTable externalTarget = targetTableWithId();
+        SlotReference physical = externalSlot(externalTarget, "b");
+        SlotReference renamed = physical.withName("a");
+        Plan plan = completePlanOver(ImmutableSet.of(new EqualTo(renamed, new 
IntegerLiteral(1))), renamed);
+
+        
Assertions.assertFalse(WriteConstraintExtractor.extractCompletePredicate(plan, 
TARGET_ID, NO_EXCLUSION)
+                .isPresent(), "a renamed slot no longer denotes its physical 
column");
+    }
+
+    @Test
+    public void completePredicateBailsOutOnSyntheticNameCollision() {
+        // $partition_spec_id is both an Iceberg-injected name and a legal 
physical column name. The exclusion
+        // list is name-based, so a matching conjunct must be rejected rather 
than silently dropped as
+        // "synthetic" (which would turn the predicate into always-true and 
delete every file).
+        ExternalTable externalTarget = targetTableWithId();
+        SlotReference physical = externalSlot(externalTarget, 
"$partition_spec_id");
+        Plan plan = completePlanOver(ImmutableSet.of(new EqualTo(physical, new 
IntegerLiteral(1))), physical);
+        Predicate<SlotReference> excludeSyntheticNames =
+                slot -> "$partition_spec_id".equalsIgnoreCase(slot.getName());
+
+        
Assertions.assertFalse(WriteConstraintExtractor.extractCompletePredicate(
+                        plan, TARGET_ID, excludeSyntheticNames).isPresent(),
+                "a name collision must fall back; dropping the conjunct would 
widen the delete to everything");
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransformTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransformTest.java
index b838ad91ce6..aec0e088afc 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransformTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/PositionDeleteRowLevelDmlTransformTest.java
@@ -35,6 +35,8 @@ import 
org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog;
 import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.memo.GroupExpression;
+import org.apache.doris.nereids.properties.LogicalProperties;
 import org.apache.doris.nereids.trees.expressions.EqualTo;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -42,9 +44,11 @@ import 
org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
 import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.PlanType;
 import org.apache.doris.nereids.trees.plans.RelationId;
 import 
org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor;
 import 
org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertExecutor;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
 import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation;
 import 
org.apache.doris.nereids.trees.plans.logical.LogicalExternalRowLevelDeleteSink;
 import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
@@ -58,10 +62,13 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
 import java.util.EnumSet;
+import java.util.List;
 import java.util.Optional;
+import java.util.OptionalLong;
 import java.util.Set;
 
 /**
@@ -360,4 +367,92 @@ public class PositionDeleteRowLevelDmlTransformTest {
 
         Mockito.verify(t, 
Mockito.never()).extractWriteConstraint(Mockito.any(), Mockito.any());
     }
+
+    // ─────────────── whole-file metadata-delete fast path ───────────────
+
+    /** A minimal concrete target-table scan: the leaf an analyzed 
single-table DELETE filters over. */
+    private static final class TargetScan extends LogicalCatalogRelation {
+        private final List<SlotReference> output;
+
+        TargetScan(TableIf table, List<SlotReference> output) {
+            super(new RelationId(0), PlanType.LOGICAL_FILE_SCAN, table, 
ImmutableList.of("db1"));
+            this.output = ImmutableList.copyOf(output);
+        }
+
+        @Override
+        public List<org.apache.doris.nereids.trees.expressions.Slot> 
computeOutput() {
+            return ImmutableList.copyOf(output);
+        }
+
+        @Override
+        public LogicalCatalogRelation withRelationId(RelationId relationId) {
+            return this;
+        }
+
+        @Override
+        public LogicalCatalogRelation withTableAlias(String tableAlias) {
+            return this;
+        }
+
+        @Override
+        public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression> 
groupExpression,
+                Optional<LogicalProperties> logicalProperties, List<Plan> 
children) {
+            return this;
+        }
+
+        @Override
+        public Plan withGroupExpression(Optional<GroupExpression> 
groupExpression) {
+            return this;
+        }
+    }
+
+    private RowLevelDmlArgs deleteArgs(TableIf table) {
+        return RowLevelDmlArgs.forDelete(table, ImmutableList.of("db1", "t1"), 
null, false,
+                ImmutableList.of(), null);
+    }
+
+    @Test
+    public void 
deleteUsingMetadataForwardsTheCompletePredicateToTheConnector() {
+        ConnectorSession session = Mockito.mock(ConnectorSession.class);
+        ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+        ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+        Mockito.when(metadata.getTableHandle(session, "db1", 
"t1")).thenReturn(Optional.of(handle));
+        Mockito.when(metadata.deleteUsingMetadata(
+                        Mockito.eq(session), Mockito.eq(handle), 
Mockito.any(), Mockito.any(), Mockito.any()))
+                .thenReturn(OptionalLong.of(3L));
+        PluginDrivenExternalTable table = pluginTableWithMetadata(metadata, 
session);
+        SlotReference slot = slot(table, "id");
+        Plan plan = new LogicalFilter<>(ImmutableSet.of(new EqualTo(slot, new 
IntegerLiteral(1))),
+                new TargetScan(table, ImmutableList.of(slot)));
+
+        OptionalLong deleted = transform.deleteUsingMetadata(
+                Mockito.mock(ConnectContext.class), deleteArgs(table), plan, 
() -> false);
+
+        Assertions.assertEquals(OptionalLong.of(3L), deleted);
+        @SuppressWarnings("unchecked")
+        
ArgumentCaptor<Optional<org.apache.doris.connector.spi.pushdown.ConnectorExpression>>
 captor =
+                ArgumentCaptor.forClass(Optional.class);
+        Mockito.verify(metadata).deleteUsingMetadata(
+                Mockito.eq(session), Mockito.eq(handle), captor.capture(), 
Mockito.any(), Mockito.any());
+        Assertions.assertTrue(captor.getValue().isPresent(), "the statement 
predicate must reach the connector");
+        
Assertions.assertInstanceOf(org.apache.doris.connector.spi.pushdown.ConnectorComparison.class,
+                captor.getValue().get());
+    }
+
+    @Test
+    public void 
deleteUsingMetadataStaysOutWhenThePredicateIsNotProvablyComplete() {
+        ConnectorSession session = Mockito.mock(ConnectorSession.class);
+        ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+        ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+        Mockito.when(metadata.getTableHandle(session, "db1", 
"t1")).thenReturn(Optional.of(handle));
+        PluginDrivenExternalTable table = pluginTableWithMetadata(metadata, 
session);
+
+        // The leaf is not the target catalog relation, so completeness cannot 
be proven.
+        OptionalLong deleted = transform.deleteUsingMetadata(
+                Mockito.mock(ConnectContext.class), deleteArgs(table), 
filterOver(table, "id"), () -> false);
+
+        Assertions.assertEquals(OptionalLong.empty(), deleted);
+        Mockito.verify(metadata, Mockito.never())
+                .deleteUsingMetadata(Mockito.any(), Mockito.any(), 
Mockito.any(), Mockito.any(), Mockito.any());
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
index 81ef3a894d0..3d168db1ce2 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
@@ -18,19 +18,30 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.Status;
+import org.apache.doris.common.UserException;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.NereidsPlanner;
+import org.apache.doris.nereids.PlannerHook;
+import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.trees.plans.Plan;
 import 
org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalSink;
 import org.apache.doris.planner.DataSink;
 import org.apache.doris.planner.PlanFragment;
+import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.Coordinator;
 import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.thrift.TStatusCode;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
 import org.mockito.Mockito;
 
+import java.util.concurrent.atomic.AtomicReference;
+
 /**
  * Pins the begin→finalize guarded window of {@link RowLevelDmlCommand}: 
{@code beginTransaction} registers
  * the transaction with the transaction manager and the global 
external-transaction registry, and the
@@ -100,4 +111,43 @@ public class RowLevelDmlCommandTest {
         Mockito.verify(coordinator).setTxnId(42L);
         Mockito.verify(executor, Mockito.never()).onFail(Mockito.any());
     }
+
+    @Test
+    public void deleteCapturesThePreRewriteAnalyzedPlanLocally() {
+        // NereidsPlanner leaves analyzedPlan null for a plain DELETE. Capture 
the plan for the metadata-delete
+        // attempt only; publishing it through setAnalyzedPlan would also 
change row-level fallback semantics.
+        ConnectContext ctx = Mockito.mock(ConnectContext.class);
+        StatementContext statementContext = 
Mockito.mock(StatementContext.class);
+        Mockito.when(ctx.getStatementContext()).thenReturn(statementContext);
+        AtomicReference<Plan> captured = new AtomicReference<>();
+
+        RowLevelDmlCommand.captureAnalyzedPlan(ctx, captured);
+
+        ArgumentCaptor<PlannerHook> captor = 
ArgumentCaptor.forClass(PlannerHook.class);
+        Mockito.verify(statementContext).addPlannerHook(captor.capture());
+        NereidsPlanner planner = Mockito.mock(NereidsPlanner.class);
+        CascadesContext cascadesContext = Mockito.mock(CascadesContext.class);
+        Plan analyzed = Mockito.mock(Plan.class);
+        Mockito.when(planner.getCascadesContext()).thenReturn(cascadesContext);
+        Mockito.when(cascadesContext.getRewritePlan()).thenReturn(analyzed);
+
+        captor.getValue().afterAnalyze(planner);
+
+        Assertions.assertSame(analyzed, captured.get());
+        Mockito.verify(planner, 
Mockito.never()).setAnalyzedPlan(Mockito.any());
+    }
+
+    @Test
+    public void retainedCancellationIsReplayedBeforeTheMetadataDelete() {
+        // The row-level path replays the retained cancellation when the 
coordinator is published. The metadata
+        // branch has no coordinator, so it must replay it explicitly before 
any remote mutation.
+        StmtExecutor stmtExecutor = Mockito.mock(StmtExecutor.class);
+        Mockito.when(stmtExecutor.getPendingCancelReason())
+                .thenReturn(new Status(TStatusCode.CANCELLED, "query was 
killed"));
+
+        UserException thrown = Assertions.assertThrows(UserException.class,
+                () -> RowLevelDmlCommand.throwIfCancelled(stmtExecutor));
+
+        Assertions.assertTrue(thrown.getMessage().contains("query was 
killed"), thrown.getMessage());
+    }
 }
diff --git 
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_dml_modes_evolution.out
 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_dml_modes_evolution.out
index 98c60ec0eb8..eca58838d6b 100644
--- 
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_dml_modes_evolution.out
+++ 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_dml_modes_evolution.out
@@ -22,11 +22,10 @@
 7      C       golf    2026-03-01T07:00        70      \N
 
 -- !mor_delete_files --
-0      4       4
-2      2       2
+0      3       3
+2      1       1
 
 -- !cow_after_rejections --
 1      A       alpha   2026-01-01T01:00        10      base
 2      \N      null-key        \N      20      null-partition
 3      B       beta    2026-02-01T03:00        30      new-spec
-
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_dml_mode_matrix.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_dml_mode_matrix.groovy
index fede01f4fb4..9a9c7c0eec4 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_dml_mode_matrix.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_dml_mode_matrix.groovy
@@ -101,7 +101,7 @@ suite("test_iceberg_v3_row_lineage_dml_mode_matrix", 
"p0,external,iceberg,extern
         }
     }
 
-    def assertDeleteFilesForVersion = { tableName, formatVersion, format, 
scenario ->
+    def assertDeleteFilesForVersion = { tableName, formatVersion, format, 
partitioned, scenario ->
         def deleteFiles = sql("""
             select file_path, lower(file_format), record_count
             from ${tableName}\$delete_files
@@ -113,8 +113,26 @@ suite("test_iceberg_v3_row_lineage_dml_mode_matrix", 
"p0,external,iceberg,extern
                 "COW scenario ${scenario.name} should not create delete files 
for ${tableName}, actual=${deleteFiles}")
         }
         if (scenario.mode == "merge-on-read" && formatVersion == 3) {
-            assertTrue(deleteFiles.size() > 0,
-                    "MOR scenario ${scenario.name} should create delete files 
for ${tableName}")
+            if (scenario.operation == "delete") {
+                // Partitioned fixtures write one row per data file, so 
deleting id=2 drops a whole file and
+                // writes no DV. The unpartitioned fixture has one three-row 
data file, so it must fall back.
+                long dataFileCount =
+                        sql("""select count(*) from 
${tableName}\$data_files""")[0][0].toString().toLong()
+                if (partitioned) {
+                    assertEquals(0, deleteFiles.size(),
+                            "partitioned MOR delete should not create delete 
files for ${tableName}")
+                    assertEquals(2L, dataFileCount,
+                            "partitioned MOR delete should drop one whole data 
file for ${tableName}")
+                } else {
+                    assertEquals(1, deleteFiles.size(),
+                            "unpartitioned partial-file delete should create 
one DV for ${tableName}")
+                    assertEquals(1L, dataFileCount,
+                            "unpartitioned partial-file delete should retain 
its data file for ${tableName}")
+                }
+            } else {
+                assertTrue(deleteFiles.size() > 0,
+                        "MOR scenario ${scenario.name} should create delete 
files for ${tableName}")
+            }
         }
         deleteFiles.each { row ->
             String filePath = row[0].toString().toLowerCase()
@@ -264,7 +282,7 @@ suite("test_iceberg_v3_row_lineage_dml_mode_matrix", 
"p0,external,iceberg,extern
                                 }
                             }
 
-                            assertDeleteFilesForVersion(tableName, 
formatVersion, format, scenario)
+                            assertDeleteFilesForVersion(tableName, 
formatVersion, format, partitioned, scenario)
                         } finally {
                             sql """drop table if exists ${tableName}"""
                         }
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
index 7ae07744445..03a0003f353 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_delete_unpartitioned_evolution.groovy
@@ -109,8 +109,13 @@ suite("test_iceberg_delete_unpartitioned_evolution",
     """
     sql """delete from mixed_partition_specs where record_key in (1, 3)"""
     checkRows("mixed_partition_specs", expectedAfterMixedDelete)
+    // `record_key in (1, 3)` covers whole data files in this fixture, so 
Doris commits it as a metadata
+    // delete: no delete files are written and the surviving rows keep both 
historical spec ids.
+    assertEquals(0, sql("""
+        select count(*) from mixed_partition_specs\$delete_files
+    """)[0][0].toString().toLong())
     assertEquals(mixedSpecs, sql("""
-        select distinct spec_id from mixed_partition_specs\$delete_files order 
by spec_id
+        select distinct spec_id from mixed_partition_specs\$data_files order 
by spec_id
     """))
     def expectedAfterUpdate = spark_iceberg """
         select record_key, metric + 100 from 
demo.${dbName}.mixed_partition_specs order by record_key


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to