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

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


The following commit(s) were added to refs/heads/master by this push:
     new 809be25122 [flink] Add data evolution delete action (#8716)
809be25122 is described below

commit 809be251226efa0ae0c36cace451035695aa551a
Author: wangwj <[email protected]>
AuthorDate: Thu Jul 23 21:07:38 2026 +0800

    [flink] Add data evolution delete action (#8716)
---
 docs/docs/flink/action-jars.md                     | 118 +++++
 docs/docs/multimodal-table/data-evolution.mdx      |   7 +-
 .../append/BaseAppendDeleteFileMaintainer.java     |  18 +
 .../paimon/flink/action/DataEvolutionDelete.java   | 441 ++++++++++++++++
 .../apache/paimon/flink/action/DeleteAction.java   |  82 ++-
 .../paimon/flink/action/DeleteActionFactory.java   |  35 +-
 .../dataevolution/DataEvolutionDeleteOperator.java | 568 +++++++++++++++++++++
 .../action/DeleteActionDataEvolutionITCase.java    | 494 ++++++++++++++++++
 .../paimon/flink/action/DeleteActionITCase.java    |  28 +
 .../paimon/flink/procedure/ProcedureTest.java      |   3 +-
 10 files changed, 1784 insertions(+), 10 deletions(-)

diff --git a/docs/docs/flink/action-jars.md b/docs/docs/flink/action-jars.md
index 419c342008..b44546518e 100644
--- a/docs/docs/flink/action-jars.md
+++ b/docs/docs/flink/action-jars.md
@@ -259,6 +259,124 @@ For more information of 'delete', see
     delete --help
 ```
 
+## Deleting from a Data Evolution table
+
+For a non-primary-key append table in
+[Data Evolution](../multimodal-table/data-evolution) mode, Paimon supports
+logically deleting matching rows through the same `delete` action used by
+primary-key tables. The action dispatches to the appropriate implementation
+based on the target table type. Regular append-only tables are not supported.
+
+The action evaluates the filter against a fixed snapshot of the
+`$row_tracking` system table and records matched rows in deletion vectors. It
+does not rewrite existing data files or dedicated BLOB files.
+
+:::info
+
+The target table must:
+
+1. have no primary key;
+2. use bucket-unaware mode (`bucket = -1`);
+3. enable `row-tracking.enabled`;
+4. enable `data-evolution.enabled`;
+5. enable `deletion-vectors.enabled`.
+
+:::
+
+Run the following command to submit a `delete` job:
+
+```bash
+<FLINK_HOME>/bin/flink run \
+    /path/to/paimon-flink-action-@@VERSION@@.jar \
+    delete \
+    --warehouse <warehouse-path> \
+    --database <database-name> \
+    --table <table-name> \
+    [--source_sql <sql> [--source_sql <sql> ...]] \
+    --where "<filter_spec>" \
+    [--sink_parallelism <sink-parallelism>] \
+    [--catalog_conf <paimon-catalog-conf> [--catalog_conf 
<paimon-catalog-conf> ...]]
+```
+
+`filter_spec` uses Flink SQL expression syntax and is equivalent to the
+predicate in a SQL `WHERE` clause. For example:
+
+```text
+last_access_time < TIMESTAMP '2026-07-01 00:00:00'
+status = 'expired'
+id >= 100 AND id < 200
+```
+
+`source_sql` is repeatable. Each statement is executed in order before the
+target query, so any bounded table supported by Flink SQL and optional views
+can be registered and referenced from a subquery in `filter_spec`. This is not
+limited to a particular external system: JDBC databases, data warehouses, and
+other bounded connectors can all be used as long as the corresponding
+connector is available in the action job's classpath.
+
+The subquery is also where source-side filtering belongs. In the example below,
+the action reads an access-state table, selects only cold URLs, and deletes the
+matching rows from the target Paimon table:
+
+```bash
+<FLINK_HOME>/bin/flink run \
+    /path/to/paimon-flink-action-@@VERSION@@.jar \
+    delete \
+    --warehouse <warehouse-path> \
+    --database <database-name> \
+    --table <table-name> \
+    --source_sql "CREATE TEMPORARY TABLE access_state (
+        url STRING,
+        last_ingest_time TIMESTAMP(3),
+        last_request_time TIMESTAMP(3)
+    ) WITH (
+        'connector' = 'jdbc',
+        'url' = '<jdbc-url>',
+        'table-name' = '<access-state-table>'
+    )" \
+    --where "url IN (
+        SELECT url
+        FROM access_state
+        WHERE last_ingest_time < TIMESTAMP '2026-07-01 00:00:00'
+          AND (last_request_time IS NULL
+               OR last_request_time < TIMESTAMP '2026-07-01 00:00:00')
+    )" \
+    --sink_parallelism 8
+```
+
+Using a subquery avoids copying a large candidate set into a temporary Paimon
+table. The external source must be bounded so the action can finish and commit
+one delete snapshot.
+
+:::warning
+
+- This action performs a logical delete. Physical data and BLOB file
+  reclamation still depends on subsequent Data Evolution compaction with
+  `data-evolution.compaction.rewrite-row-ids=true` and snapshot expiration.
+- Do not run multiple delete actions, or concurrent `APPEND`, `COMPACT`, or
+  `OVERWRITE` operations, against the same table. A conflicting
+  commit causes the action to fail instead of silently overwriting deletion
+  vectors.
+- Row positions are aggregated in parallel per anchor file. Deletion vectors
+  are then written in parallel across independent rewrite groups. One existing
+  deletion-vector index file is an atomic rewrite group and always has a single
+  writer owner, because it can contain deletion vectors for several anchor
+  files.
+- Split large deletes into bounded batches to limit row-tracking planning,
+  deletion-vector memory usage, and external-source scan size.
+- Snapshot retention must preserve the action's fixed base snapshot until the
+  job finishes.
+
+:::
+
+For more information, run:
+
+```bash
+<FLINK_HOME>/bin/flink run \
+    /path/to/paimon-flink-action-@@VERSION@@.jar \
+    delete --help
+```
+
 ## Drop Partition
 
 Run the following command to submit a 'drop_partition' job for the table.
diff --git a/docs/docs/multimodal-table/data-evolution.mdx 
b/docs/docs/multimodal-table/data-evolution.mdx
index e11e9f079b..ba5220a1f6 100644
--- a/docs/docs/multimodal-table/data-evolution.mdx
+++ b/docs/docs/multimodal-table/data-evolution.mdx
@@ -273,8 +273,11 @@ WHEN MATCHED AND s.op = 'update' THEN UPDATE SET t.b = t.b 
+ 10
 WHEN NOT MATCHED BY SOURCE AND t.id > 10 THEN DELETE;
 ```
 
-The `WHEN NOT MATCHED BY SOURCE` clause requires Spark 3.4 or later. Currently,
-only Spark supports writing deletes for Data Evolution tables.
+The `WHEN NOT MATCHED BY SOURCE` clause requires Spark 3.4 or later.
+
+Flink SQL does not currently support `DELETE FROM` for Data Evolution tables,
+but Flink users can submit the
+[`delete` action](../flink/action-jars#deleting-from-a-data-evolution-table).
 
 ## Self Updates
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/BaseAppendDeleteFileMaintainer.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/BaseAppendDeleteFileMaintainer.java
index b61985a177..c23556e2ee 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/BaseAppendDeleteFileMaintainer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/BaseAppendDeleteFileMaintainer.java
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
 import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
 /**
@@ -80,8 +81,25 @@ public interface BaseAppendDeleteFileMaintainer {
                 indexFileHandler.scan(snapshot, 
DELETION_VECTORS_INDEX).stream()
                         .filter(e -> e.partition().equals(partition))
                         .collect(Collectors.toList());
+        return forUnawareAppend(indexFileHandler, partition, manifestEntries);
+    }
+
+    /**
+     * Creates an unaware-bucket maintainer which owns only the provided index 
files.
+     *
+     * <p>This overload is used by parallel rewrite-group writers. Each old 
deletion-vector index
+     * file must be owned by exactly one writer; a writer for new anchors 
receives an empty list.
+     */
+    static AppendDeleteFileMaintainer forUnawareAppend(
+            IndexFileHandler indexFileHandler,
+            BinaryRow partition,
+            List<IndexManifestEntry> manifestEntries) {
         Map<String, DeletionFile> deletionFiles = new HashMap<>();
         for (IndexManifestEntry file : manifestEntries) {
+            checkArgument(
+                    file.partition().equals(partition),
+                    "Index file %s belongs to a different partition.",
+                    file.indexFile().fileName());
             LinkedHashMap<String, DeletionVectorMeta> dvMetas = 
file.indexFile().dvRanges();
             checkNotNull(dvMetas);
             for (DeletionVectorMeta dvMeta : dvMetas.values()) {
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionDelete.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionDelete.java
new file mode 100644
index 0000000000..03ea04d144
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionDelete.java
@@ -0,0 +1,441 @@
+/*
+ * 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.paimon.flink.action;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.flink.dataevolution.DataEvolutionDeleteOperator;
+import 
org.apache.paimon.flink.dataevolution.DataEvolutionDeleteOperator.DeletionTarget;
+import 
org.apache.paimon.flink.dataevolution.DataEvolutionDeleteOperator.DeletionVectorAggregator;
+import 
org.apache.paimon.flink.dataevolution.DataEvolutionDeleteOperator.DeletionVectorUpdate;
+import org.apache.paimon.flink.sink.Committable;
+import org.apache.paimon.flink.sink.CommittableTypeInfo;
+import org.apache.paimon.flink.sink.CommitterOperatorFactory;
+import org.apache.paimon.flink.sink.NoopCommittableStateManager;
+import org.apache.paimon.flink.sink.StoreCommitter;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.operation.DataEvolutionSplitRead;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.DeletionFile;
+import org.apache.paimon.utils.DataEvolutionUtils;
+import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.SerializationUtils;
+
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.functions.Partitioner;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.dag.Transformation;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.functions.sink.v2.DiscardingSink;
+import org.apache.flink.table.api.Table;
+import org.apache.flink.table.api.TableResult;
+import org.apache.flink.types.Row;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Internal implementation which logically deletes rows from a data-evolution 
append table.
+ *
+ * <p>The action evaluates a Flink SQL filter against a fixed row-tracking 
snapshot, maps every
+ * matched {@code _ROW_ID} to its data-evolution anchor file, and commits 
deletion-vector index
+ * files. Optional source SQL statements can register bounded external tables 
used by subqueries in
+ * the filter. Existing data and BLOB files are not rewritten by this action.
+ *
+ * <p>Only one instance of this action should run against the same table at a 
time. The fixed base
+ * snapshot and strict commit mode detect conflicting commits, including 
append, compaction, and
+ * overwrite, instead of silently applying deletion vectors to a stale row-id 
mapping.
+ *
+ * <p>The current implementation plans anchor ranges on the coordinator. Row 
positions are first
+ * aggregated per anchor and then shuffled by rewrite group. Anchors backed by 
the same existing
+ * deletion-vector index file always have one writer owner; anchors without 
existing deletion
+ * vectors are split into stable shards. Large deletes should still be split 
into bounded batches to
+ * limit coordinator and deletion-vector memory usage.
+ */
+class DataEvolutionDelete implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(DataEvolutionDelete.class);
+
+    private final DeleteAction action;
+    private final String filter;
+    private final long baseSnapshotId;
+
+    private int sinkParallelism = 1;
+
+    DataEvolutionDelete(DeleteAction action, String filter) {
+        this.action = action;
+        Preconditions.checkArgument(
+                filter != null && !filter.trim().isEmpty(),
+                "Deletion filter must not be null or blank.");
+        this.filter = filter;
+
+        if (!(action.table instanceof FileStoreTable)) {
+            throw new UnsupportedOperationException(
+                    String.format(
+                            "Only FileStoreTable supports Data Evolution 
delete. The table type is '%s'.",
+                            action.table.getClass().getName()));
+        }
+
+        FileStoreTable storeTable = (FileStoreTable) action.table;
+        Long latestSnapshotId = 
storeTable.snapshotManager().latestSnapshotId();
+        if (latestSnapshotId == null) {
+            throw new UnsupportedOperationException(
+                    "Data-evolution delete action doesn't support deleting 
from an empty table.");
+        }
+        this.baseSnapshotId = latestSnapshotId;
+
+        CoreOptions coreOptions = storeTable.coreOptions();
+        if (!storeTable.schema().primaryKeys().isEmpty()) {
+            throw new UnsupportedOperationException(
+                    "Data-evolution delete action only supports append tables 
without primary keys.");
+        }
+        if (!coreOptions.rowTrackingEnabled()) {
+            throw new UnsupportedOperationException(
+                    "Data-evolution delete action requires 
row-tracking.enabled to be true.");
+        }
+        if (!coreOptions.dataEvolutionEnabled()) {
+            throw new UnsupportedOperationException(
+                    "Data-evolution delete action requires 
data-evolution.enabled to be true.");
+        }
+        if (!coreOptions.deletionVectorsEnabled()) {
+            throw new UnsupportedOperationException(
+                    "Data-evolution delete action requires 
deletion-vectors.enabled to be true.");
+        }
+        if (storeTable.bucketMode() != BucketMode.BUCKET_UNAWARE) {
+            throw new UnsupportedOperationException(
+                    String.format(
+                            "Data-evolution delete action only supports 
unaware bucket mode, but table bucket mode is %s.",
+                            storeTable.bucketMode()));
+        }
+
+        action.table =
+                action.table.copy(
+                        Collections.singletonMap(
+                                
CoreOptions.COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(),
+                                latestSnapshotId.toString()));
+    }
+
+    DataEvolutionDelete withSinkParallelism(int sinkParallelism) {
+        Preconditions.checkArgument(
+                sinkParallelism > 0,
+                "Sink parallelism must be a positive integer, but is %s.",
+                sinkParallelism);
+        this.sinkParallelism = sinkParallelism;
+        return this;
+    }
+
+    /** Builds and executes the Flink batch topology. */
+    TableResult runInternal() {
+        FileStoreTable storeTable = (FileStoreTable) action.table;
+        List<AnchorRange> anchorRanges = planAnchorRanges(storeTable);
+        String commitUser =
+                
CoreOptions.createCommitUser(storeTable.coreOptions().toConfiguration());
+
+        String query =
+                String.format(
+                        "SELECT `_ROW_ID` FROM `%s`.`%s`.`%s$row_tracking` "
+                                + "/*+ OPTIONS('scan.snapshot-id'='%d') */ 
WHERE %s",
+                        action.catalogName,
+                        action.identifier.getDatabaseName(),
+                        action.identifier.getObjectName(),
+                        baseSnapshotId,
+                        filter);
+        LOG.info("Data-evolution delete source query: {}", query);
+
+        Table matchedRows = action.batchTEnv.sqlQuery(query);
+        DataStream<Long> rowIds =
+                action.batchTEnv
+                        .toDataStream(matchedRows)
+                        .map(
+                                (MapFunction<Row, Long>) row -> (Long) 
row.getField(0),
+                                TypeInformation.of(Long.class));
+
+        DataStream<DeletionTarget> targets =
+                rowIds.rebalance()
+                        .map(
+                                new RowIdToDeletionTarget(anchorRanges),
+                                TypeInformation.of(DeletionTarget.class))
+                        // Anchor ranges are part of the mapper closure. Bound 
the number of copies
+                        // by the configured sink parallelism instead of the 
source scan
+                        // parallelism.
+                        .setParallelism(sinkParallelism)
+                        .partitionCustom(new StringHashPartitioner(), new 
AnchorKeySelector());
+
+        DataStream<DeletionVectorUpdate> deletionVectorUpdates =
+                targets.transform(
+                                "AGGREGATE DELETION VECTORS",
+                                TypeInformation.of(DeletionVectorUpdate.class),
+                                new DeletionVectorAggregator(
+                                        
storeTable.coreOptions().deletionVectorBitmap64()))
+                        .setParallelism(sinkParallelism)
+                        .partitionCustom(
+                                new StringHashPartitioner(), new 
RewriteGroupKeySelector());
+
+        DataStream<Committable> written =
+                deletionVectorUpdates
+                        .transform(
+                                "WRITE DELETION VECTORS",
+                                new CommittableTypeInfo(),
+                                new DataEvolutionDeleteOperator(storeTable, 
baseSnapshotId))
+                        .setParallelism(sinkParallelism);
+
+        CommitterOperatorFactory<Committable, ManifestCommittable> 
committerOperator =
+                new CommitterOperatorFactory<>(
+                        false,
+                        true,
+                        commitUser,
+                        context ->
+                                new StoreCommitter(
+                                        storeTable,
+                                        storeTable
+                                                
.newCommit(context.commitUser())
+                                                
.withOperation(Snapshot.Operation.DELETE)
+                                                
.rowIdCheckConflict(baseSnapshotId),
+                                        context),
+                        new NoopCommittableStateManager());
+
+        DataStream<Committable> committed =
+                written.transform("COMMIT OPERATOR", new 
CommittableTypeInfo(), committerOperator)
+                        .setParallelism(1)
+                        .setMaxParallelism(1);
+
+        Transformation<?> end =
+                committed
+                        .sinkTo(new DiscardingSink<>())
+                        .name("END")
+                        .setParallelism(1)
+                        .getTransformation();
+
+        return action.executeInternal(
+                Collections.singletonList(end),
+                Collections.singletonList(action.identifier.getFullName()));
+    }
+
+    private List<AnchorRange> planAnchorRanges(FileStoreTable storeTable) {
+        List<AnchorRange> anchorRanges = new ArrayList<>();
+        for (DataSplit split :
+                
storeTable.newSnapshotReader().withSnapshot(baseSnapshotId).read().dataSplits())
 {
+            Map<String, String> oldIndexFileByDataFile = new HashMap<>();
+            if (split.deletionFiles().isPresent()) {
+                List<DeletionFile> deletionFiles = split.deletionFiles().get();
+                Preconditions.checkState(
+                        deletionFiles.size() == split.dataFiles().size(),
+                        "Deletion files and data files have different sizes in 
bucket path %s.",
+                        split.bucketPath());
+                for (int i = 0; i < deletionFiles.size(); i++) {
+                    DeletionFile deletionFile = deletionFiles.get(i);
+                    if (deletionFile != null) {
+                        oldIndexFileByDataFile.put(
+                                split.dataFiles().get(i).fileName(),
+                                new Path(deletionFile.path()).getName());
+                    }
+                }
+            }
+
+            for (List<DataFileMeta> group :
+                    
DataEvolutionSplitRead.mergeRangesAndSort(split.dataFiles())) {
+                DataFileMeta anchor = 
DataEvolutionUtils.retrieveAnchorFile(group, file -> file);
+                Range range = anchor.nonNullRowIdRange();
+                String anchorFilePath =
+                        anchor.externalPath().isPresent()
+                                ? anchor.externalPath().get()
+                                : split.bucketPath() + "/" + anchor.fileName();
+                String rewriteGroup =
+                        rewriteGroup(
+                                split.bucketPath(),
+                                oldIndexFileByDataFile.get(anchor.fileName()),
+                                anchorFilePath,
+                                sinkParallelism);
+                String oldIndexFileName = 
oldIndexFileByDataFile.get(anchor.fileName());
+                anchorRanges.add(
+                        new AnchorRange(
+                                range.from,
+                                range.to,
+                                rewriteGroup,
+                                split.bucketPath(),
+                                oldIndexFileName,
+                                
SerializationUtils.serializeBinaryRow(split.partition()),
+                                anchorFilePath));
+            }
+        }
+
+        anchorRanges.sort(Comparator.comparingLong(range -> range.from));
+        Preconditions.checkState(
+                !anchorRanges.isEmpty(),
+                "Cannot find data-evolution anchor files in snapshot %s.",
+                baseSnapshotId);
+        for (int i = 1; i < anchorRanges.size(); i++) {
+            AnchorRange previous = anchorRanges.get(i - 1);
+            AnchorRange current = anchorRanges.get(i);
+            Preconditions.checkState(
+                    previous.to < current.from,
+                    "Data-evolution anchor ranges overlap: [%s, %s] and [%s, 
%s].",
+                    previous.from,
+                    previous.to,
+                    current.from,
+                    current.to);
+        }
+        return anchorRanges;
+    }
+
+    /**
+     * Returns the ownership key for rewriting a deletion-vector index file.
+     *
+     * <p>An existing index file is the atomic rewrite unit because it may 
contain deletion vectors
+     * for multiple anchors. New anchors have no shared old file and can 
therefore be distributed
+     * over stable shards.
+     */
+    @VisibleForTesting
+    static String rewriteGroup(
+            String bucketPath,
+            @Nullable String oldIndexFile,
+            String anchorFilePath,
+            int parallelism) {
+        if (oldIndexFile != null) {
+            return bucketPath + "\u0000old\u0000" + oldIndexFile;
+        }
+        int shard = Math.floorMod(anchorFilePath.hashCode(), parallelism);
+        return bucketPath + "\u0000new\u0000" + shard;
+    }
+
+    /** A data-evolution anchor file and its covered global row-id range. */
+    private static class AnchorRange implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final long from;
+        private final long to;
+        private final String rewriteGroup;
+        private final String bucketPath;
+        @Nullable private final String oldIndexFileName;
+        private final byte[] serializedPartition;
+        private final String dataFilePath;
+
+        private AnchorRange(
+                long from,
+                long to,
+                String rewriteGroup,
+                String bucketPath,
+                @Nullable String oldIndexFileName,
+                byte[] serializedPartition,
+                String dataFilePath) {
+            this.from = from;
+            this.to = to;
+            this.rewriteGroup = rewriteGroup;
+            this.bucketPath = bucketPath;
+            this.oldIndexFileName = oldIndexFileName;
+            this.serializedPartition = serializedPartition;
+            this.dataFilePath = dataFilePath;
+        }
+    }
+
+    /** Maps a global row id to its anchor data file and local deletion-vector 
position. */
+    private static class RowIdToDeletionTarget implements MapFunction<Long, 
DeletionTarget> {
+
+        private static final long serialVersionUID = 1L;
+
+        private final List<AnchorRange> anchorRanges;
+
+        private RowIdToDeletionTarget(List<AnchorRange> anchorRanges) {
+            this.anchorRanges = anchorRanges;
+        }
+
+        @Override
+        public DeletionTarget map(Long rowId) {
+            int low = 0;
+            int high = anchorRanges.size() - 1;
+            int candidate = -1;
+
+            while (low <= high) {
+                int mid = (low + high) >>> 1;
+                if (anchorRanges.get(mid).from <= rowId) {
+                    candidate = mid;
+                    low = mid + 1;
+                } else {
+                    high = mid - 1;
+                }
+            }
+
+            if (candidate < 0 || rowId > anchorRanges.get(candidate).to) {
+                throw new IllegalStateException(
+                        String.format(
+                                "Cannot find data-evolution deletion-vector 
anchor range for row id %s.",
+                                rowId));
+            }
+
+            AnchorRange anchor = anchorRanges.get(candidate);
+            return new DeletionTarget(
+                    anchor.rewriteGroup,
+                    anchor.bucketPath,
+                    anchor.oldIndexFileName,
+                    anchor.serializedPartition,
+                    anchor.dataFilePath,
+                    rowId - anchor.from);
+        }
+    }
+
+    private static class AnchorKeySelector implements 
KeySelector<DeletionTarget, String> {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public String getKey(DeletionTarget value) {
+            return value.getBucketPath() + "\u0000" + value.getDataFilePath();
+        }
+    }
+
+    private static class RewriteGroupKeySelector
+            implements KeySelector<DeletionVectorUpdate, String> {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public String getKey(DeletionVectorUpdate value) {
+            return value.getRewriteGroup();
+        }
+    }
+
+    private static class StringHashPartitioner implements Partitioner<String> {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public int partition(String key, int numPartitions) {
+            return Math.floorMod(key.hashCode(), numPartitions);
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteAction.java
index ed9423cf52..41e3e49ae6 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteAction.java
@@ -19,6 +19,8 @@
 package org.apache.paimon.flink.action;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.Preconditions;
 
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.table.api.Table;
@@ -30,18 +32,22 @@ import org.apache.flink.types.RowKind;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
+
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
 import static org.apache.paimon.CoreOptions.MergeEngine.DEDUPLICATE;
 
-/** Delete from table action for Flink. */
+/** Delete from table action for Flink, dispatching by table type. */
 public class DeleteAction extends TableActionBase {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(DeleteAction.class);
 
     private final String filter;
+    @Nullable private final DataEvolutionDelete dataEvolutionDelete;
+    @Nullable private String[] sourceSqls;
 
     public DeleteAction(
             String databaseName,
@@ -49,11 +55,54 @@ public class DeleteAction extends TableActionBase {
             String filter,
             Map<String, String> catalogConfig) {
         super(databaseName, tableName, catalogConfig);
+        Preconditions.checkArgument(
+                filter != null && !filter.trim().isEmpty(),
+                "Deletion filter must not be null or blank.");
         this.filter = filter;
+
+        if (table instanceof FileStoreTable
+                && ((FileStoreTable) 
table).coreOptions().dataEvolutionEnabled()) {
+            dataEvolutionDelete = new DataEvolutionDelete(this, filter);
+        } else {
+            dataEvolutionDelete = null;
+        }
+    }
+
+    /** SQL statements used to configure the batch environment and register 
external sources. */
+    public DeleteAction withSourceSqls(String... sourceSqls) {
+        this.sourceSqls = sourceSqls;
+        return this;
+    }
+
+    /** Configures deletion-vector aggregation and writing parallelism for 
Data Evolution tables. */
+    public DeleteAction withSinkParallelism(int sinkParallelism) {
+        if (dataEvolutionDelete == null) {
+            throw new UnsupportedOperationException(
+                    "Sink parallelism is only supported when deleting from a 
Data Evolution table.");
+        }
+        dataEvolutionDelete.withSinkParallelism(sinkParallelism);
+        return this;
     }
 
     @Override
     public void run() throws Exception {
+        handleSqls();
+        if (dataEvolutionDelete != null) {
+            dataEvolutionDelete.runInternal().await();
+            return;
+        }
+
+        runPrimaryKeyDelete();
+    }
+
+    private void runPrimaryKeyDelete() throws Exception {
+        if (!(table instanceof FileStoreTable)
+                || ((FileStoreTable) table).schema().primaryKeys().isEmpty()) {
+            throw new UnsupportedOperationException(
+                    "Delete does not support regular append-only tables. "
+                            + "Only primary-key tables and Data Evolution 
append tables are supported.");
+        }
+
         CoreOptions.MergeEngine mergeEngine = 
CoreOptions.fromMap(table.options()).mergeEngine();
         if (mergeEngine != DEDUPLICATE) {
             throw new UnsupportedOperationException(
@@ -67,8 +116,11 @@ public class DeleteAction extends TableActionBase {
         Table queriedTable =
                 batchTEnv.sqlQuery(
                         String.format(
-                                "SELECT * FROM %s WHERE %s",
-                                identifier.getEscapedFullName(), filter));
+                                "SELECT * FROM `%s`.`%s`.`%s` WHERE %s",
+                                catalogName,
+                                identifier.getDatabaseName(),
+                                identifier.getObjectName(),
+                                filter));
 
         List<DataStructureConverter<Object, Object>> converters =
                 queriedTable.getResolvedSchema().getColumnDataTypes().stream()
@@ -95,4 +147,28 @@ public class DeleteAction extends TableActionBase {
 
         batchSink(dataStream).await();
     }
+
+    private void handleSqls() {
+        // NOTE: a source SQL statement may change the current catalog and 
database. Both target
+        // query paths therefore use fully-qualified target identifiers.
+        if (sourceSqls != null) {
+            for (int i = 0; i < sourceSqls.length; i++) {
+                try {
+                    batchTEnv.executeSql(sourceSqls[i]).await();
+                } catch (Exception e) {
+                    // Source DDL may contain JDBC credentials. Do not include 
the SQL or the
+                    // original exception (whose message may echo the SQL) in 
logs or the wrapper.
+                    if (e instanceof InterruptedException) {
+                        Thread.currentThread().interrupt();
+                    }
+                    String message =
+                            String.format(
+                                    "Failed to execute source SQL statement %s 
(cause type: %s).",
+                                    i + 1, e.getClass().getName());
+                    LOG.error(message);
+                    throw new RuntimeException(message);
+                }
+            }
+        }
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteActionFactory.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteActionFactory.java
index 774094168a..9508bfbffa 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteActionFactory.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DeleteActionFactory.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.flink.action;
 
+import java.util.Collection;
 import java.util.Optional;
 
 /** Factory to create {@link DeleteAction}. */
@@ -26,6 +27,8 @@ public class DeleteActionFactory implements ActionFactory {
     public static final String IDENTIFIER = "delete";
 
     private static final String WHERE = "where";
+    private static final String SOURCE_SQL = "source_sql";
+    private static final String SINK_PARALLELISM = "sink_parallelism";
 
     @Override
     public String identifier() {
@@ -35,7 +38,7 @@ public class DeleteActionFactory implements ActionFactory {
     @Override
     public Optional<Action> create(MultipleParameterToolAdapter params) {
         String filter = params.get(WHERE);
-        if (filter == null) {
+        if (filter == null || filter.trim().isEmpty()) {
             throw new IllegalArgumentException(
                     "Please specify deletion filter. If you want to delete all 
records, please use overwrite (see doc).");
         }
@@ -47,11 +50,29 @@ public class DeleteActionFactory implements ActionFactory {
                         filter,
                         catalogConfigMap(params));
 
+        if (params.has(SOURCE_SQL)) {
+            Collection<String> sourceSqls = 
params.getMultiParameter(SOURCE_SQL);
+            action.withSourceSqls(sourceSqls.toArray(new String[0]));
+        }
+
+        if (params.has(SINK_PARALLELISM)) {
+            int sinkParallelism;
+            try {
+                sinkParallelism = 
Integer.parseInt(params.getRequired(SINK_PARALLELISM));
+            } catch (NumberFormatException e) {
+                throw new IllegalArgumentException(
+                        "Invalid sink parallelism, must be an integer", e);
+            }
+            action.withSinkParallelism(sinkParallelism);
+        }
+
         return Optional.of(action);
     }
 
+    @Override
     public void printHelp() {
-        System.out.println("Action \"delete\" deletes data from a table.");
+        System.out.println(
+                "Action \"delete\" deletes data from a primary-key or Data 
Evolution table.");
         System.out.println();
 
         System.out.println("Syntax:");
@@ -60,18 +81,24 @@ public class DeleteActionFactory implements ActionFactory {
                         + " --warehouse <warehouse_path> \\\n"
                         + "--database <database_name> \\\n"
                         + "--table <table_name> \\\n"
-                        + "--where <filter_spec>");
+                        + "[--source_sql <sql> ...] \\\n"
+                        + "--where <filter_spec> \\\n"
+                        + "[--sink_parallelism <sink_parallelism>]");
         System.out.println("  delete --path <table_path> --where 
<filter_spec>");
         System.out.println();
 
         System.out.println(
                 "The '--where <filter_spec>' part is equal to the 'WHERE' 
clause in SQL DELETE statement. If you want to delete all records, please use 
overwrite (see doc).");
+        System.out.println(
+                "For Data Evolution tables, use repeated --source_sql 
arguments to register bounded external tables referenced by subqueries in 
--where.");
+        System.out.println(
+                "The --sink_parallelism option applies only to Data Evolution 
deletion-vector writing.");
         System.out.println();
 
         System.out.println("Examples:");
         System.out.println(
                 "  delete --path 
hdfs:///path/to/warehouse/test_db.db/test_table --where 'id > (SELECT count(*) 
FROM employee)'");
         System.out.println(
-                "  It's equal to 'DELETE FROM test_table WHERE id > (SELECT 
count(*) FROM employee)");
+                "  delete --warehouse hdfs:///path/to/warehouse --database 
test_db --table test_table --source_sql \"CREATE TEMPORARY TABLE candidates 
(url STRING) WITH (...)\" --where \"url IN (SELECT url FROM candidates)\" 
--sink_parallelism 4");
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionDeleteOperator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionDeleteOperator.java
new file mode 100644
index 0000000000..5cf1c94dff
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/dataevolution/DataEvolutionDeleteOperator.java
@@ -0,0 +1,568 @@
+/*
+ * 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.paimon.flink.dataevolution;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.Bitmap64DeletionVector;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.flink.sink.Committable;
+import org.apache.paimon.flink.utils.BoundedOneInputOperator;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.utils.SerializationUtils;
+
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * A bounded Flink operator which persists deletion vectors for a 
data-evolution table.
+ *
+ * <p>All updates of the same rewrite group must be shuffled to the same 
operator subtask before
+ * this operator. A rewrite group contains all anchors backed by the same 
existing deletion-vector
+ * index file. Anchors without an existing deletion vector are assigned to 
stable shards. This
+ * ownership rule prevents two subtasks from concurrently replacing the same 
old index file while
+ * still allowing an unaware-bucket partition to be processed in parallel.
+ */
+public class DataEvolutionDeleteOperator
+        extends BoundedOneInputOperator<
+                DataEvolutionDeleteOperator.DeletionVectorUpdate, Committable> 
{
+
+    private static final long serialVersionUID = 1L;
+
+    private final FileStoreTable table;
+    private final long baseSnapshotId;
+
+    private transient Map<String, RewriteGroupDeletionVectors> 
deletionVectorsByRewriteGroup;
+
+    public DataEvolutionDeleteOperator(FileStoreTable table, long 
baseSnapshotId) {
+        this.table = table;
+        this.baseSnapshotId = baseSnapshotId;
+    }
+
+    @Override
+    public void open() throws Exception {
+        super.open();
+
+        checkArgument(
+                table.bucketMode() == BucketMode.BUCKET_UNAWARE,
+                "Data-evolution delete only supports unaware bucket mode, but 
table bucket mode is %s.",
+                table.bucketMode());
+        checkArgument(
+                table.coreOptions().dataEvolutionEnabled(),
+                "Data-evolution delete requires data-evolution.enabled to be 
true.");
+        checkArgument(
+                table.coreOptions().deletionVectorsEnabled(),
+                "Data-evolution delete requires deletion-vectors.enabled to be 
true.");
+
+        // Fail early if the snapshot used to map row ids has already expired.
+        table.snapshot(baseSnapshotId);
+
+        deletionVectorsByRewriteGroup = new LinkedHashMap<>();
+    }
+
+    @Override
+    public void processElement(StreamRecord<DeletionVectorUpdate> element) {
+        DeletionVectorUpdate update =
+                checkNotNull(element.getValue(), "Deletion-vector update is 
null.");
+        String rewriteGroup = checkNotNull(update.getRewriteGroup(), "Rewrite 
group is null.");
+        String bucketPath = checkNotNull(update.getBucketPath(), "Bucket path 
is null.");
+        byte[] serializedPartition =
+                checkNotNull(update.getSerializedPartition(), "Serialized 
partition is null.");
+        String dataFilePath =
+                checkNotNull(update.getDataFilePath(), "Anchor data file path 
is null.");
+        byte[] serializedDeletionVector =
+                checkNotNull(
+                        update.getSerializedDeletionVector(),
+                        "Serialized deletion vector is null.");
+
+        RewriteGroupDeletionVectors rewriteGroupDeletionVectors =
+                deletionVectorsByRewriteGroup.get(rewriteGroup);
+        if (rewriteGroupDeletionVectors == null) {
+            // BinaryRow created by deserializeBinaryRow points to the input 
byte array. Copy both
+            // representations because Flink may reuse the input object after 
processElement
+            // returns. Deserialization is needed only for the first update of 
a rewrite group.
+            byte[] copiedPartition = Arrays.copyOf(serializedPartition, 
serializedPartition.length);
+            BinaryRow partition = 
SerializationUtils.deserializeBinaryRow(copiedPartition).copy();
+            rewriteGroupDeletionVectors =
+                    new RewriteGroupDeletionVectors(
+                            bucketPath, update.getOldIndexFileName(), 
partition, copiedPartition);
+            deletionVectorsByRewriteGroup.put(rewriteGroup, 
rewriteGroupDeletionVectors);
+        } else {
+            checkArgument(
+                    rewriteGroupDeletionVectors.bucketPath.equals(bucketPath),
+                    "Rewrite group %s is associated with different bucket 
paths.",
+                    rewriteGroup);
+            checkArgument(
+                    Objects.equals(
+                            rewriteGroupDeletionVectors.oldIndexFileName,
+                            update.getOldIndexFileName()),
+                    "Rewrite group %s is associated with different old index 
files.",
+                    rewriteGroup);
+            checkArgument(
+                    Arrays.equals(
+                            rewriteGroupDeletionVectors.serializedPartition, 
serializedPartition),
+                    "Rewrite group %s is associated with different 
partitions.",
+                    rewriteGroup);
+        }
+
+        String dataFileName = new Path(dataFilePath).getName();
+        DeletionVector deletionVector =
+                DeletionVector.deserializeFromBytes(serializedDeletionVector);
+        DeletionVector previous =
+                
rewriteGroupDeletionVectors.deletionVectorsByDataFile.putIfAbsent(
+                        dataFileName, deletionVector);
+        if (previous != null) {
+            // This is not expected after anchor-level shuffling, but merging 
keeps the writer
+            // correct if upstream parallelism or partitioning is changed in 
the future.
+            previous.merge(deletionVector);
+        }
+    }
+
+    @Override
+    public void endInput() {
+        if (deletionVectorsByRewriteGroup.isEmpty()) {
+            return;
+        }
+
+        Snapshot baseSnapshot = table.snapshot(baseSnapshotId);
+        IndexFileHandler indexFileHandler = 
table.store().newIndexFileHandler();
+        Map<BinaryRow, Set<String>> ownedIndexFileNamesByPartition = new 
HashMap<>();
+        for (RewriteGroupDeletionVectors rewriteGroupDeletionVectors :
+                deletionVectorsByRewriteGroup.values()) {
+            if (rewriteGroupDeletionVectors.oldIndexFileName != null) {
+                ownedIndexFileNamesByPartition
+                        .computeIfAbsent(
+                                rewriteGroupDeletionVectors.partition.copy(),
+                                ignored -> new HashSet<>())
+                        .add(rewriteGroupDeletionVectors.oldIndexFileName);
+            }
+        }
+
+        Map<BinaryRow, Map<String, IndexManifestEntry>> indexFilesByPartition 
= new HashMap<>();
+        if (!ownedIndexFileNamesByPartition.isEmpty()) {
+            for (IndexManifestEntry entry :
+                    indexFileHandler.scan(
+                            baseSnapshot,
+                            candidate -> {
+                                if (!candidate
+                                        .indexFile()
+                                        .indexType()
+                                        .equals(DELETION_VECTORS_INDEX)) {
+                                    return false;
+                                }
+                                Set<String> ownedIndexFileNames =
+                                        
ownedIndexFileNamesByPartition.get(candidate.partition());
+                                return ownedIndexFileNames != null
+                                        && ownedIndexFileNames.contains(
+                                                
candidate.indexFile().fileName());
+                            })) {
+                indexFilesByPartition
+                        .computeIfAbsent(entry.partition().copy(), ignored -> 
new HashMap<>())
+                        .put(entry.indexFile().fileName(), entry);
+            }
+        }
+
+        for (RewriteGroupDeletionVectors rewriteGroupDeletionVectors :
+                deletionVectorsByRewriteGroup.values()) {
+            List<IndexManifestEntry> ownedIndexFiles;
+            if (rewriteGroupDeletionVectors.oldIndexFileName == null) {
+                ownedIndexFiles = Collections.emptyList();
+            } else {
+                Map<String, IndexManifestEntry> partitionIndexFiles =
+                        
indexFilesByPartition.get(rewriteGroupDeletionVectors.partition);
+                IndexManifestEntry ownedIndexFile =
+                        partitionIndexFiles == null
+                                ? null
+                                : partitionIndexFiles.get(
+                                        
rewriteGroupDeletionVectors.oldIndexFileName);
+                ownedIndexFiles =
+                        Collections.singletonList(
+                                checkNotNull(
+                                        ownedIndexFile,
+                                        "Cannot find owned deletion-vector 
index file %s in the base snapshot.",
+                                        
rewriteGroupDeletionVectors.oldIndexFileName));
+            }
+            BaseAppendDeleteFileMaintainer maintainer =
+                    BaseAppendDeleteFileMaintainer.forUnawareAppend(
+                            indexFileHandler,
+                            rewriteGroupDeletionVectors.partition,
+                            ownedIndexFiles);
+
+            for (Map.Entry<String, DeletionVector> entry :
+                    
rewriteGroupDeletionVectors.deletionVectorsByDataFile.entrySet()) {
+                // The maintainer merges the new bitmap with an existing 
deletion vector, if any.
+                maintainer.notifyNewDeletionVector(entry.getKey(), 
entry.getValue());
+            }
+
+            List<IndexFileMeta> addedIndexFiles = new ArrayList<>();
+            List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
+            for (IndexManifestEntry entry : maintainer.persist()) {
+                if (entry.kind() == FileKind.ADD) {
+                    addedIndexFiles.add(entry.indexFile());
+                } else if (entry.kind() == FileKind.DELETE) {
+                    deletedIndexFiles.add(entry.indexFile());
+                } else {
+                    throw new IllegalStateException(
+                            "Unsupported index manifest entry kind: " + 
entry.kind());
+                }
+            }
+
+            CommitMessage commitMessage =
+                    new CommitMessageImpl(
+                            maintainer.getPartition(),
+                            UNAWARE_BUCKET,
+                            null,
+                            new DataIncrement(
+                                    Collections.emptyList(),
+                                    Collections.emptyList(),
+                                    Collections.emptyList(),
+                                    addedIndexFiles,
+                                    deletedIndexFiles),
+                            CompactIncrement.emptyIncrement());
+            output.collect(
+                    new StreamRecord<>(
+                            new 
Committable(BatchWriteBuilder.COMMIT_IDENTIFIER, commitMessage)));
+        }
+
+        deletionVectorsByRewriteGroup.clear();
+    }
+
+    private static class RewriteGroupDeletionVectors {
+
+        private final String bucketPath;
+        @Nullable private final String oldIndexFileName;
+        private final BinaryRow partition;
+        private final byte[] serializedPartition;
+        private final Map<String, DeletionVector> deletionVectorsByDataFile;
+
+        private RewriteGroupDeletionVectors(
+                String bucketPath,
+                @Nullable String oldIndexFileName,
+                BinaryRow partition,
+                byte[] serializedPartition) {
+            this.bucketPath = bucketPath;
+            this.oldIndexFileName = oldIndexFileName;
+            this.partition = partition;
+            this.serializedPartition = serializedPartition;
+            this.deletionVectorsByDataFile = new LinkedHashMap<>();
+        }
+    }
+
+    /**
+     * First-stage bounded operator which aggregates row positions into one 
compressed deletion
+     * vector per anchor file.
+     *
+     * <p>Targets must be shuffled by anchor file before this operator. The 
resulting compressed
+     * updates are shuffled again by rewrite group before they reach {@link
+     * DataEvolutionDeleteOperator}. This keeps high-cardinality row-id 
traffic distributed even
+     * when several anchors share one old deletion-vector index file.
+     */
+    public static class DeletionVectorAggregator
+            extends BoundedOneInputOperator<DeletionTarget, 
DeletionVectorUpdate> {
+
+        private static final long serialVersionUID = 1L;
+
+        private final boolean useBitmap64;
+
+        private transient Map<String, AggregatedDeletionVector> 
deletionVectorsByAnchor;
+
+        public DeletionVectorAggregator(boolean useBitmap64) {
+            this.useBitmap64 = useBitmap64;
+        }
+
+        @Override
+        public void open() throws Exception {
+            super.open();
+            deletionVectorsByAnchor = new LinkedHashMap<>();
+        }
+
+        @Override
+        public void processElement(StreamRecord<DeletionTarget> element) {
+            DeletionTarget target = checkNotNull(element.getValue(), "Deletion 
target is null.");
+            String dataFilePath =
+                    checkNotNull(target.getDataFilePath(), "Anchor data file 
path is null.");
+            checkNotNull(target.getRewriteGroup(), "Rewrite group is null.");
+            checkNotNull(target.getBucketPath(), "Bucket path is null.");
+            checkNotNull(target.getSerializedPartition(), "Serialized 
partition is null.");
+            checkArgument(
+                    target.getRowIndex() >= 0,
+                    "Deletion-vector row index must be non-negative, but is 
%s.",
+                    target.getRowIndex());
+
+            String anchorKey = target.getBucketPath() + "\u0000" + 
dataFilePath;
+            AggregatedDeletionVector aggregated = 
deletionVectorsByAnchor.get(anchorKey);
+            if (aggregated == null) {
+                aggregated = new AggregatedDeletionVector(target, 
newDeletionVector());
+                deletionVectorsByAnchor.put(anchorKey, aggregated);
+            } else {
+                aggregated.validate(target);
+            }
+            aggregated.deletionVector.checkedDelete(target.getRowIndex());
+        }
+
+        @Override
+        public void endInput() {
+            for (AggregatedDeletionVector aggregated : 
deletionVectorsByAnchor.values()) {
+                output.collect(
+                        new StreamRecord<>(
+                                new DeletionVectorUpdate(
+                                        aggregated.rewriteGroup,
+                                        aggregated.bucketPath,
+                                        aggregated.oldIndexFileName,
+                                        aggregated.serializedPartition,
+                                        aggregated.dataFilePath,
+                                        DeletionVector.serializeToBytes(
+                                                aggregated.deletionVector))));
+            }
+            deletionVectorsByAnchor.clear();
+        }
+
+        private DeletionVector newDeletionVector() {
+            return useBitmap64 ? new Bitmap64DeletionVector() : new 
BitmapDeletionVector();
+        }
+
+        private static class AggregatedDeletionVector {
+
+            private final String rewriteGroup;
+            private final String bucketPath;
+            @Nullable private final String oldIndexFileName;
+            private final byte[] serializedPartition;
+            private final String dataFilePath;
+            private final DeletionVector deletionVector;
+
+            private AggregatedDeletionVector(DeletionTarget target, 
DeletionVector deletionVector) {
+                this.rewriteGroup = target.getRewriteGroup();
+                this.bucketPath = target.getBucketPath();
+                this.oldIndexFileName = target.getOldIndexFileName();
+                this.serializedPartition =
+                        Arrays.copyOf(
+                                target.getSerializedPartition(),
+                                target.getSerializedPartition().length);
+                this.dataFilePath = target.getDataFilePath();
+                this.deletionVector = deletionVector;
+            }
+
+            private void validate(DeletionTarget target) {
+                checkArgument(
+                        rewriteGroup.equals(target.getRewriteGroup())
+                                && bucketPath.equals(target.getBucketPath())
+                                && Objects.equals(oldIndexFileName, 
target.getOldIndexFileName())
+                                && Arrays.equals(
+                                        serializedPartition, 
target.getSerializedPartition()),
+                        "Anchor file %s is associated with inconsistent 
deletion metadata.",
+                        dataFilePath);
+            }
+        }
+    }
+
+    /**
+     * A network-serializable deletion target.
+     *
+     * <p>{@code dataFilePath} is the anchor data-file path and {@code 
rowIndex} is the row's local
+     * position relative to the anchor file's row-id range. The partition is 
serialized because a
+     * {@link BinaryRow} can point to reused memory.
+     */
+    public static class DeletionTarget implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private String rewriteGroup;
+        private String bucketPath;
+        @Nullable private String oldIndexFileName;
+        private byte[] serializedPartition;
+        private String dataFilePath;
+        private long rowIndex;
+
+        /** Public no-argument constructor for Flink POJO serialization. */
+        public DeletionTarget() {}
+
+        public DeletionTarget(
+                String rewriteGroup,
+                String bucketPath,
+                @Nullable String oldIndexFileName,
+                byte[] serializedPartition,
+                String dataFilePath,
+                long rowIndex) {
+            this.rewriteGroup = rewriteGroup;
+            this.bucketPath = bucketPath;
+            this.oldIndexFileName = oldIndexFileName;
+            this.serializedPartition = serializedPartition;
+            this.dataFilePath = dataFilePath;
+            this.rowIndex = rowIndex;
+        }
+
+        public String getRewriteGroup() {
+            return rewriteGroup;
+        }
+
+        public void setRewriteGroup(String rewriteGroup) {
+            this.rewriteGroup = rewriteGroup;
+        }
+
+        public String getBucketPath() {
+            return bucketPath;
+        }
+
+        public void setBucketPath(String bucketPath) {
+            this.bucketPath = bucketPath;
+        }
+
+        public @Nullable String getOldIndexFileName() {
+            return oldIndexFileName;
+        }
+
+        public void setOldIndexFileName(@Nullable String oldIndexFileName) {
+            this.oldIndexFileName = oldIndexFileName;
+        }
+
+        public byte[] getSerializedPartition() {
+            return serializedPartition;
+        }
+
+        public void setSerializedPartition(byte[] serializedPartition) {
+            this.serializedPartition = serializedPartition;
+        }
+
+        public String getDataFilePath() {
+            return dataFilePath;
+        }
+
+        public void setDataFilePath(String dataFilePath) {
+            this.dataFilePath = dataFilePath;
+        }
+
+        public long getRowIndex() {
+            return rowIndex;
+        }
+
+        public void setRowIndex(long rowIndex) {
+            this.rowIndex = rowIndex;
+        }
+    }
+
+    /** A compressed anchor deletion vector sent across the rewrite-group 
shuffle. */
+    public static class DeletionVectorUpdate implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private String rewriteGroup;
+        private String bucketPath;
+        @Nullable private String oldIndexFileName;
+        private byte[] serializedPartition;
+        private String dataFilePath;
+        private byte[] serializedDeletionVector;
+
+        /** Public no-argument constructor for Flink POJO serialization. */
+        public DeletionVectorUpdate() {}
+
+        public DeletionVectorUpdate(
+                String rewriteGroup,
+                String bucketPath,
+                @Nullable String oldIndexFileName,
+                byte[] serializedPartition,
+                String dataFilePath,
+                byte[] serializedDeletionVector) {
+            this.rewriteGroup = rewriteGroup;
+            this.bucketPath = bucketPath;
+            this.oldIndexFileName = oldIndexFileName;
+            this.serializedPartition = serializedPartition;
+            this.dataFilePath = dataFilePath;
+            this.serializedDeletionVector = serializedDeletionVector;
+        }
+
+        public String getRewriteGroup() {
+            return rewriteGroup;
+        }
+
+        public void setRewriteGroup(String rewriteGroup) {
+            this.rewriteGroup = rewriteGroup;
+        }
+
+        public String getBucketPath() {
+            return bucketPath;
+        }
+
+        public void setBucketPath(String bucketPath) {
+            this.bucketPath = bucketPath;
+        }
+
+        public @Nullable String getOldIndexFileName() {
+            return oldIndexFileName;
+        }
+
+        public void setOldIndexFileName(@Nullable String oldIndexFileName) {
+            this.oldIndexFileName = oldIndexFileName;
+        }
+
+        public byte[] getSerializedPartition() {
+            return serializedPartition;
+        }
+
+        public void setSerializedPartition(byte[] serializedPartition) {
+            this.serializedPartition = serializedPartition;
+        }
+
+        public String getDataFilePath() {
+            return dataFilePath;
+        }
+
+        public void setDataFilePath(String dataFilePath) {
+            this.dataFilePath = dataFilePath;
+        }
+
+        public byte[] getSerializedDeletionVector() {
+            return serializedDeletionVector;
+        }
+
+        public void setSerializedDeletionVector(byte[] 
serializedDeletionVector) {
+            this.serializedDeletionVector = serializedDeletionVector;
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionDataEvolutionITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionDataEvolutionITCase.java
new file mode 100644
index 0000000000..08d3e58b11
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionDataEvolutionITCase.java
@@ -0,0 +1,494 @@
+/*
+ * 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.paimon.flink.action;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.index.DeletionVectorMeta;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.table.FileStoreTable;
+
+import org.apache.flink.table.planner.factories.TestValuesTableFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static 
org.apache.flink.table.planner.factories.TestValuesTableFactory.changelogRow;
+import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+import static org.apache.paimon.flink.util.ReadWriteTableTestUtil.buildDdl;
+import static org.apache.paimon.flink.util.ReadWriteTableTestUtil.init;
+import static org.apache.paimon.flink.util.ReadWriteTableTestUtil.insertInto;
+import static org.apache.paimon.flink.util.ReadWriteTableTestUtil.sEnv;
+import static 
org.apache.paimon.flink.util.ReadWriteTableTestUtil.testBatchRead;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Data Evolution IT cases for the unified {@link DeleteAction}. */
+public class DeleteActionDataEvolutionITCase extends ActionITCaseBase {
+
+    private static final String TABLE = "T";
+
+    @BeforeEach
+    public void setUp() {
+        init(warehouse);
+    }
+
+    @Test
+    public void testDeleteAcrossPartitionsAndMergeExistingDeletionVectors() 
throws Exception {
+        createDataEvolutionTable(TABLE, false, true);
+        insertInto(TABLE, "(1, 'one', 'A')", "(2, 'two', 'A')", "(3, 'three', 
'A')");
+        insertInto(TABLE, "(4, 'four', 'B')", "(5, 'five', 'B')", "(6, 'six', 
'B')");
+
+        FileStoreTable table = getFileStoreTable(TABLE);
+        List<String> originalFiles = plannedFiles(table);
+
+        action(TABLE, "id IN (2, 4)", 2).run();
+
+        testBatchRead(
+                "SELECT id, name, dt FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 1, "one", "A"),
+                        changelogRow("+I", 3, "three", "A"),
+                        changelogRow("+I", 5, "five", "B"),
+                        changelogRow("+I", 6, "six", "B")));
+        assertDeleteSnapshotAndFiles(table, originalFiles, 2L);
+
+        // A second action must merge with the existing DV instead of losing 
the first deletion.
+        action(TABLE, "id IN (2, 3)", 2).run();
+
+        testBatchRead(
+                "SELECT id, name, dt FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 1, "one", "A"),
+                        changelogRow("+I", 5, "five", "B"),
+                        changelogRow("+I", 6, "six", "B")));
+        assertDeleteSnapshotAndFiles(table, originalFiles, 3L);
+    }
+
+    @Test
+    public void testDeleteBlobRowWithoutRewritingDataOrBlobFiles() throws 
Exception {
+        createDataEvolutionTable("BLOB_T", true, true);
+        insertInto("BLOB_T", "(1, 'one', 'A', X'48656C6C6F')", "(2, 'two', 
'A', X'5041494D4F4E')");
+
+        FileStoreTable table = getFileStoreTable("BLOB_T");
+        List<String> originalFiles = plannedFiles(table);
+
+        action("BLOB_T", "id = 1", 1).run();
+
+        testBatchRead(
+                "SELECT id, name, picture FROM BLOB_T",
+                Collections.singletonList(
+                        changelogRow("+I", 2, "two", new byte[] {80, 65, 73, 
77, 79, 78})));
+        assertDeleteSnapshotAndFiles(table, originalFiles, 1L);
+    }
+
+    @Test
+    public void testNoMatchedRowsDoesNotCreateSnapshot() throws Exception {
+        createDataEvolutionTable(TABLE, false, true);
+        insertInto(TABLE, "(1, 'one', 'A')");
+
+        FileStoreTable table = getFileStoreTable(TABLE);
+        long snapshotId = table.latestSnapshot().get().id();
+
+        action(TABLE, "id = 999", 1).run();
+
+        
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshotId);
+        assertThat(deletionVectorCardinality(table)).isZero();
+    }
+
+    @Test
+    public void testDeleteWithBoundedExternalCandidates() throws Exception {
+        createDataEvolutionTable(TABLE, false, true);
+        insertInto(TABLE, "(1, 'one', 'A')", "(2, 'two', 'A')", "(3, 'three', 
'A')");
+
+        String dataId =
+                TestValuesTableFactory.registerData(
+                        Arrays.asList(
+                                changelogRow("+I", 2),
+                                changelogRow("+I", 2),
+                                changelogRow("+I", 999)));
+        String createCandidates =
+                String.format(
+                        "CREATE TEMPORARY TABLE deletion_candidate_source (id 
INT) "
+                                + "WITH ('connector' = 'values', 'bounded' = 
'true', "
+                                + "'data-id' = '%s')",
+                        dataId);
+        String createCandidateView =
+                "CREATE TEMPORARY VIEW deletion_candidates AS "
+                        + "SELECT id FROM deletion_candidate_source";
+
+        actionWithSourceSqls(
+                        TABLE,
+                        "id IN (SELECT id FROM deletion_candidates)",
+                        2,
+                        createCandidates,
+                        createCandidateView)
+                .run();
+
+        testBatchRead(
+                "SELECT id, name, dt FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 1, "one", "A"), changelogRow("+I", 
3, "three", "A")));
+        
assertThat(deletionVectorCardinality(getFileStoreTable(TABLE))).isEqualTo(1L);
+    }
+
+    @Test
+    public void testDeleteWithFilteredBoundedExternalTable() throws Exception {
+        createUnpartitionedUrlTable("URL_T");
+        insertInto(
+                "URL_T",
+                "('cold-url', 'cold')",
+                "('hot-url', 'hot')",
+                "('old-never-requested-url', 'old-never-requested')",
+                "('fresh-never-requested-url', 'fresh-never-requested')",
+                "('url-without-access-state', 'no-access-state')");
+
+        String dataId =
+                TestValuesTableFactory.registerData(
+                        Arrays.asList(
+                                changelogRow(
+                                        "+I",
+                                        "cold-url",
+                                        
LocalDateTime.parse("2026-06-01T00:00:00"),
+                                        
LocalDateTime.parse("2026-06-15T00:00:00")),
+                                changelogRow(
+                                        "+I",
+                                        "hot-url",
+                                        
LocalDateTime.parse("2026-06-01T00:00:00"),
+                                        
LocalDateTime.parse("2026-07-15T00:00:00")),
+                                changelogRow(
+                                        "+I",
+                                        "old-never-requested-url",
+                                        
LocalDateTime.parse("2026-06-01T00:00:00"),
+                                        null),
+                                changelogRow(
+                                        "+I",
+                                        "fresh-never-requested-url",
+                                        
LocalDateTime.parse("2026-07-15T00:00:00"),
+                                        null),
+                                changelogRow(
+                                        "+I",
+                                        "missing-target-url",
+                                        
LocalDateTime.parse("2026-06-01T00:00:00"),
+                                        null)));
+        String createAccessState =
+                String.format(
+                        "CREATE TEMPORARY TABLE external_access_state ("
+                                + "url STRING, last_ingest_time TIMESTAMP(3), "
+                                + "last_request_time TIMESTAMP(3)) "
+                                + "WITH ('connector' = 'values', 'bounded' = 
'true', "
+                                + "'data-id' = '%s')",
+                        dataId);
+
+        action(
+                        "URL_T",
+                        "url IN (SELECT url FROM external_access_state "
+                                + "WHERE last_ingest_time < TIMESTAMP 
'2026-07-01 00:00:00' "
+                                + "AND (last_request_time IS NULL "
+                                + "OR last_request_time < TIMESTAMP 
'2026-07-01 00:00:00'))",
+                        2)
+                .withSourceSqls(createAccessState)
+                .run();
+
+        testBatchRead(
+                "SELECT url, payload FROM URL_T ORDER BY url",
+                Arrays.asList(
+                        changelogRow("+I", "fresh-never-requested-url", 
"fresh-never-requested"),
+                        changelogRow("+I", "hot-url", "hot"),
+                        changelogRow("+I", "url-without-access-state", 
"no-access-state")));
+        
assertThat(deletionVectorCardinality(getFileStoreTable("URL_T"))).isEqualTo(2L);
+    }
+
+    @Test
+    public void testSourceSqlFailureDoesNotLeakSqlOrCreateSnapshot() throws 
Exception {
+        createDataEvolutionTable(TABLE, false, true);
+        insertInto(TABLE, "(1, 'one', 'A')");
+
+        FileStoreTable table = getFileStoreTable(TABLE);
+        long snapshotId = table.latestSnapshot().get().id();
+        String sensitiveMarker = "password_should_not_be_logged";
+
+        assertThatThrownBy(
+                        () ->
+                                action(TABLE, "id = 1", 1)
+                                        .withSourceSqls("INVALID SQL " + 
sensitiveMarker)
+                                        .run())
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("source SQL statement 1")
+                .hasMessageNotContaining(sensitiveMarker);
+        
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshotId);
+        assertThat(deletionVectorCardinality(table)).isZero();
+    }
+
+    @Test
+    public void testUnpartitionedTableWithParallelRewriteGroups() throws 
Exception {
+        createUnpartitionedDataEvolutionTable(TABLE);
+        for (int id = 1; id <= 12; id++) {
+            insertInto(TABLE, String.format("(%d, 'value_%d')", id, id));
+        }
+
+        FileStoreTable table = getFileStoreTable(TABLE);
+        List<String> originalFiles = plannedFiles(table);
+
+        action(TABLE, "MOD(id, 2) = 1", 4).run();
+
+        testBatchRead(
+                "SELECT id, name FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 2, "value_2"),
+                        changelogRow("+I", 4, "value_4"),
+                        changelogRow("+I", 6, "value_6"),
+                        changelogRow("+I", 8, "value_8"),
+                        changelogRow("+I", 10, "value_10"),
+                        changelogRow("+I", 12, "value_12")));
+        assertDeleteSnapshotAndFiles(table, originalFiles, 6L);
+
+        // The second action must safely merge updates which may be backed by 
several old DV
+        // index files created by the first parallel action.
+        action(TABLE, "id IN (2, 6, 10)", 4).run();
+
+        testBatchRead(
+                "SELECT id, name FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 4, "value_4"),
+                        changelogRow("+I", 8, "value_8"),
+                        changelogRow("+I", 12, "value_12")));
+        assertDeleteSnapshotAndFiles(table, originalFiles, 9L);
+    }
+
+    @Test
+    public void testRewriteGroupOwnership() {
+        String bucketPath = "dt=20260719/bucket-0";
+        assertThat(DataEvolutionDelete.rewriteGroup(bucketPath, 
"old-dv-index", "anchor-a", 4))
+                .isEqualTo(
+                        DataEvolutionDelete.rewriteGroup(
+                                bucketPath, "old-dv-index", "anchor-b", 4));
+        assertThat(DataEvolutionDelete.rewriteGroup(bucketPath, 
"old-dv-index-a", "anchor-a", 4))
+                .isNotEqualTo(
+                        DataEvolutionDelete.rewriteGroup(
+                                bucketPath, "old-dv-index-b", "anchor-a", 4));
+        assertThat(DataEvolutionDelete.rewriteGroup(bucketPath, null, 
"anchor-a", 4))
+                .startsWith(bucketPath + "\u0000new\u0000");
+    }
+
+    @Test
+    public void testActionsFromSameSnapshotConflictInsteadOfBeingFiltered() 
throws Exception {
+        createDataEvolutionTable(TABLE, false, true);
+        insertInto(TABLE, "(1, 'one', 'A')", "(2, 'two', 'A')", "(3, 'three', 
'A')");
+
+        // Both actions intentionally capture the same base snapshot. The 
second action must use a
+        // different commit user so that strict mode detects the first DELETE 
snapshot instead of
+        // treating the second action as an already committed retry.
+        DeleteAction first = action(TABLE, "id = 1", 1);
+        DeleteAction stale = action(TABLE, "id = 2", 1);
+
+        first.run();
+
+        assertThatThrownBy(stale::run)
+                
.hasStackTraceContaining(CoreOptions.COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key());
+        testBatchRead(
+                "SELECT id, name, dt FROM T ORDER BY id",
+                Arrays.asList(
+                        changelogRow("+I", 2, "two", "A"), changelogRow("+I", 
3, "three", "A")));
+    }
+
+    @Test
+    public void testRejectTableWithoutDeletionVectors() throws Exception {
+        createDataEvolutionTable(TABLE, false, false);
+        insertInto(TABLE, "(1, 'one', 'A')");
+
+        assertThatThrownBy(() -> action(TABLE, "id = 1", 1))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("deletion-vectors.enabled");
+    }
+
+    @Test
+    public void testFactoryRequiresWhere() {
+        assertThatThrownBy(
+                        () ->
+                                createAction(
+                                        DeleteAction.class,
+                                        "delete",
+                                        "--warehouse",
+                                        warehouse,
+                                        "--database",
+                                        database,
+                                        "--table",
+                                        TABLE))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("deletion filter");
+
+        assertThatThrownBy(
+                        () ->
+                                createAction(
+                                        DeleteAction.class,
+                                        "delete",
+                                        "--warehouse",
+                                        warehouse,
+                                        "--database",
+                                        database,
+                                        "--table",
+                                        TABLE,
+                                        "--where",
+                                        "   "))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("deletion filter");
+    }
+
+    private void createDataEvolutionTable(
+            String tableName, boolean withBlob, boolean deletionVectors) {
+        List<String> fields =
+                withBlob
+                        ? Arrays.asList("id INT", "name STRING", "dt STRING", 
"picture BYTES")
+                        : Arrays.asList("id INT", "name STRING", "dt STRING");
+
+        Map<String, String> options =
+                new java.util.HashMap<String, String>() {
+                    {
+                        put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+                        put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+                        put(
+                                CoreOptions.DELETION_VECTORS_ENABLED.key(),
+                                String.valueOf(deletionVectors));
+                        put(CoreOptions.BUCKET.key(), "-1");
+                        if (withBlob) {
+                            put("blob-field", "picture");
+                        }
+                    }
+                };
+
+        sEnv.executeSql(
+                buildDdl(
+                        tableName,
+                        fields,
+                        Collections.emptyList(),
+                        Collections.singletonList("dt"),
+                        options));
+    }
+
+    private void createUnpartitionedDataEvolutionTable(String tableName) {
+        Map<String, String> options =
+                new java.util.HashMap<String, String>() {
+                    {
+                        put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+                        put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+                        put(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true");
+                        put(CoreOptions.BUCKET.key(), "-1");
+                    }
+                };
+        sEnv.executeSql(
+                buildDdl(
+                        tableName,
+                        Arrays.asList("id INT", "name STRING"),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options));
+    }
+
+    private void createUnpartitionedUrlTable(String tableName) {
+        Map<String, String> options =
+                new java.util.HashMap<String, String>() {
+                    {
+                        put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+                        put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+                        put(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true");
+                        put(CoreOptions.BUCKET.key(), "-1");
+                    }
+                };
+        sEnv.executeSql(
+                buildDdl(
+                        tableName,
+                        Arrays.asList("url STRING", "payload STRING"),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options));
+    }
+
+    private DeleteAction action(String tableName, String filter, int 
sinkParallelism) {
+        return createAction(
+                DeleteAction.class,
+                "delete",
+                "--warehouse",
+                warehouse,
+                "--database",
+                database,
+                "--table",
+                tableName,
+                "--where",
+                filter,
+                "--sink_parallelism",
+                String.valueOf(sinkParallelism));
+    }
+
+    private DeleteAction actionWithSourceSqls(
+            String tableName,
+            String filter,
+            int sinkParallelism,
+            String firstSourceSql,
+            String secondSourceSql) {
+        return createAction(
+                DeleteAction.class,
+                "delete",
+                "--warehouse",
+                warehouse,
+                "--database",
+                database,
+                "--table",
+                tableName,
+                "--source_sql",
+                firstSourceSql,
+                "--source_sql",
+                secondSourceSql,
+                "--where",
+                filter,
+                "--sink_parallelism",
+                String.valueOf(sinkParallelism));
+    }
+
+    private static List<String> plannedFiles(FileStoreTable table) {
+        return table.store().newScan().plan().files().stream()
+                .map(entry -> entry.file().fileName())
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
+    private static long deletionVectorCardinality(FileStoreTable table) {
+        Snapshot snapshot = table.latestSnapshot().get();
+        return table.store().newIndexFileHandler().scan(snapshot, 
DELETION_VECTORS_INDEX).stream()
+                .map(IndexManifestEntry::indexFile)
+                .filter(index -> index.dvRanges() != null)
+                .flatMap(index -> index.dvRanges().values().stream())
+                .mapToLong(DeletionVectorMeta::cardinality)
+                .sum();
+    }
+
+    private static void assertDeleteSnapshotAndFiles(
+            FileStoreTable table, List<String> originalFiles, long 
expectedDeletedRows) {
+        
assertThat(table.latestSnapshot().get().operation()).isEqualTo(Snapshot.Operation.DELETE);
+        // This list contains both normal data files and dedicated BLOB files.
+        
assertThat(plannedFiles(table)).containsExactlyElementsOf(originalFiles);
+        
assertThat(deletionVectorCardinality(table)).isEqualTo(expectedDeletedRows);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionITCase.java
index 02ab4fd286..6d302fa4f0 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/DeleteActionITCase.java
@@ -44,6 +44,7 @@ import static 
org.apache.paimon.flink.util.ReadWriteTableTestUtil.init;
 import static 
org.apache.paimon.flink.util.ReadWriteTableTestUtil.testStreamingRead;
 import static 
org.apache.paimon.flink.util.ReadWriteTableTestUtil.validateStreamingReadResult;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** IT cases for {@link DeleteAction}. */
 public class DeleteActionITCase extends ActionITCaseBase {
@@ -95,6 +96,33 @@ public class DeleteActionITCase extends ActionITCaseBase {
         iterator.close();
     }
 
+    @Test
+    public void testRejectRegularAppendOnlyTable() throws Exception {
+        createFileStoreTable(
+                ROW_TYPE,
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.singletonMap("bucket", "-1"));
+
+        DeleteAction action =
+                createAction(
+                        DeleteAction.class,
+                        "delete",
+                        "--warehouse",
+                        warehouse,
+                        "--database",
+                        database,
+                        "--table",
+                        tableName,
+                        "--where",
+                        "k=1");
+
+        assertThatThrownBy(action::run)
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("regular append-only tables");
+    }
+
     private void prepareTable() throws Exception {
         Map<String, String> options = new HashMap<>();
         FileStoreTable table =
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ProcedureTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ProcedureTest.java
index 74e3aeeac5..79ba2d71fd 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ProcedureTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ProcedureTest.java
@@ -38,7 +38,8 @@ public class ProcedureTest {
     @Test
     public void testProcedureCoverAllActions() {
         Set<String> expectedExclusions = new HashSet<>();
-        // Can be covered by `DELETE FROM` syntax. No procedure needed.
+        // Delete is exposed through both Flink SQL and the unified delete 
action. No procedure is
+        // needed.
         expectedExclusions.add("delete");
         List<String> actionIdentifiers =
                 FactoryUtil.discoverIdentifiers(


Reply via email to