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 fa62f98a7f [spark] Support V2 delta-based row-level operations for 
deletion-vector append tables (#8539)
fa62f98a7f is described below

commit fa62f98a7ffaa54098fc75b69e00da49e1d6620f
Author: Kerwin Zhang <[email protected]>
AuthorDate: Mon Jul 13 10:37:17 2026 +0800

    [spark] Support V2 delta-based row-level operations for deletion-vector 
append tables (#8539)
---
 .../scala/org/apache/paimon/spark/SparkTable.scala |   2 +
 .../scala/org/apache/paimon/spark/SparkTable.scala |   2 +
 .../scala/org/apache/paimon/spark/SparkTable.scala |   2 +
 .../paimon/spark/write/PaimonDeltaBatchWrite.scala |  50 ++++
 .../apache/spark/sql/paimon/shims/Spark4Shim.scala |  10 +-
 .../org/apache/paimon/spark/PaimonMetrics.scala    |  10 +
 .../apache/paimon/spark/PaimonSparkTableBase.scala |  19 +-
 .../scala/org/apache/paimon/spark/SparkTable.scala |  49 +++-
 .../spark/rowops/PaimonSparkDeltaOperation.scala   |  84 ++++++
 .../paimon/spark/schema/PaimonMetadataColumn.scala |   9 +-
 .../paimon/spark/write/PaimonBatchWriteBase.scala  |  26 +-
 .../paimon/spark/write/PaimonDeltaWrite.scala      |  77 ++++++
 .../paimon/spark/write/PaimonDeltaWriteBase.scala  | 225 +++++++++++++++
 .../spark/write/PaimonDeltaWriteBuilder.scala      |  43 +++
 .../paimon/spark/write/PaimonDeltaWriter.scala     | 150 ++++++++++
 .../apache/paimon/spark/write/WriteHelper.scala    |  21 ++
 .../apache/spark/sql/paimon/shims/SparkShim.scala  |  13 +
 .../paimon/spark/sql/DeltaRowLevelOpsTest.scala    | 302 +++++++++++++++++++++
 .../apache/paimon/spark/sql/PaimonMetricTest.scala |  31 +++
 .../paimon/spark/write/PaimonDeltaBatchWrite.scala |  49 ++++
 .../apache/spark/sql/paimon/shims/Spark3Shim.scala |  10 +-
 .../paimon/spark/write/PaimonDeltaBatchWrite.scala |  51 ++++
 .../catalyst/analysis/PureAppendOnlyScope.scala    |  22 +-
 .../analysis/Spark41DeleteMetadataRestore.scala    |  34 ++-
 .../analysis/Spark41MergeIntoRewrite.scala         | 191 +++++++++++--
 .../analysis/Spark41UpdateTableRewrite.scala       |  77 +++++-
 .../apache/spark/sql/paimon/shims/Spark4Shim.scala |  10 +-
 27 files changed, 1499 insertions(+), 70 deletions(-)

diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/SparkTable.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/SparkTable.scala
index b36f8075f0..7f4295bd65 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/SparkTable.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/SparkTable.scala
@@ -35,4 +35,6 @@ object SparkTable {
   def of(table: Table): SparkTable = SparkTable(table)
 
   private[spark] def supportsV2RowLevelOps(sparkTable: SparkTable): Boolean = 
false
+
+  def supportsV2DeltaOps(sparkTable: SparkTable): Boolean = false
 }
diff --git 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/SparkTable.scala
 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/SparkTable.scala
index 57951ce9b2..c8751348e1 100644
--- 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/SparkTable.scala
+++ 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/SparkTable.scala
@@ -36,4 +36,6 @@ object SparkTable {
   def of(table: Table): SparkTable = SparkTable(table)
 
   private[spark] def supportsV2RowLevelOps(sparkTable: SparkTable): Boolean = 
false
+
+  def supportsV2DeltaOps(sparkTable: SparkTable): Boolean = false
 }
diff --git 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/SparkTable.scala
 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/SparkTable.scala
index 3d50edfff1..c7d4e9156f 100644
--- 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/SparkTable.scala
+++ 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/SparkTable.scala
@@ -35,4 +35,6 @@ object SparkTable {
   def of(table: Table): SparkTable = SparkTable(table)
 
   private[spark] def supportsV2RowLevelOps(sparkTable: SparkTable): Boolean = 
false
+
+  def supportsV2DeltaOps(sparkTable: SparkTable): Boolean = false
 }
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
new file mode 100644
index 0000000000..cb3d3c6ba0
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
@@ -0,0 +1,50 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.table.FileStoreTable
+
+import org.apache.spark.sql.connector.write.{DeltaBatchWrite, 
DeltaWriterFactory, PhysicalWriteInfo, WriterCommitMessage}
+import org.apache.spark.sql.types.StructType
+
+/**
+ * Spark 4.0 shadow of the `paimon-spark4-common` wrapper: identical source 
compiled against Spark
+ * 4.0.2 so the mixed-in `BatchWrite` methods carry no Spark 4.1-only 
`WriteSummary` signature. See
+ * [[PaimonDeltaWriteBase]].
+ */
+class PaimonDeltaBatchWrite(
+    table: FileStoreTable,
+    rowSchema: StructType,
+    rowIdSchema: StructType,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends PaimonDeltaWriteBase(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+  with DeltaBatchWrite
+  with Serializable {
+
+  override def createBatchWriterFactory(info: PhysicalWriteInfo): 
DeltaWriterFactory =
+    createPaimonDeltaWriterFactory(info)
+
+  override def useCommitCoordinator(): Boolean = false
+
+  override def commit(messages: Array[WriterCommitMessage]): Unit = 
commitMessages(messages)
+
+  override def abort(messages: Array[WriterCommitMessage]): Unit = 
abortMessages(messages)
+}
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
index b92411d8bd..e3046f564f 100644
--- 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
@@ -25,7 +25,7 @@ import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark4SqlExtensi
 import org.apache.paimon.spark.data.{Spark4ArrayData, Spark4InternalRow, 
Spark4InternalRowWithBlob, SparkArrayData, SparkInternalRow}
 import org.apache.paimon.spark.format.FormatTableBatchWrite
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
-import org.apache.paimon.spark.write.PaimonBatchWrite
+import org.apache.paimon.spark.write.{PaimonBatchWrite, PaimonDeltaBatchWrite}
 import org.apache.paimon.table.{FileStoreTable, FormatTable}
 import org.apache.paimon.types.{DataType, RowType}
 
@@ -220,6 +220,14 @@ class Spark4Shim extends SparkShim {
       copyOnWriteScan,
       operationType)
 
+  override def createPaimonDeltaBatchWrite(
+      table: FileStoreTable,
+      rowSchema: StructType,
+      rowIdSchema: StructType,
+      operationType: Snapshot.Operation,
+      readSnapshotId: Option[Long]): BatchWrite =
+    new PaimonDeltaBatchWrite(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+
   override def createFormatTableBatchWrite(
       table: FormatTable,
       overwriteDynamic: Option[Boolean],
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonMetrics.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonMetrics.scala
index d3d2a39364..85e639a842 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonMetrics.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonMetrics.scala
@@ -40,6 +40,7 @@ object PaimonMetrics {
   val ADDED_TABLE_FILES = "addedTableFiles"
   val DELETED_TABLE_FILES = "deletedTableFiles"
   val INSERTED_RECORDS = "insertedRecords"
+  val UPDATED_RECORDS = "updatedRecords"
   val DELETED_RECORDS = "deletedRecords"
   val APPENDED_CHANGELOG_FILES = "appendedChangelogFiles"
   val PARTITIONS_WRITTEN = "partitionsWritten"
@@ -212,6 +213,15 @@ case class PaimonInsertedRecordsTaskMetric(value: Long) 
extends PaimonTaskMetric
   override def name(): String = PaimonMetrics.INSERTED_RECORDS
 }
 
+case class PaimonUpdatedRecordsMetric() extends PaimonSumMetric {
+  override def name(): String = PaimonMetrics.UPDATED_RECORDS
+  override def description(): String = "number of updated records"
+}
+
+case class PaimonUpdatedRecordsTaskMetric(value: Long) extends 
PaimonTaskMetric {
+  override def name(): String = PaimonMetrics.UPDATED_RECORDS
+}
+
 case class PaimonDeletedRecordsMetric() extends PaimonSumMetric {
   override def name(): String = PaimonMetrics.DELETED_RECORDS
   override def description(): String = "number of deleted records"
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
index 6be314cee8..d42d129925 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
@@ -133,10 +133,25 @@ abstract class PaimonSparkTableBase(val table: Table)
       _metadataColumns.append(PaimonMetadataColumn.SEARCH_SCORE)
     }
 
+    // For tables on the delta-based row-level path these two columns form the 
delta row ID, and
+    // Spark rejects nullable row ID columns. Keep them nullable everywhere 
else: V1 commands
+    // project them through outer joins where the target side can be null 
(e.g. the not-matched
+    // rows of MERGE INTO).
+    val (filePathColumn, rowIndexColumn) =
+      if (
+        this.isInstanceOf[SparkTable] &&
+        SparkTable.supportsV2DeltaOps(this.asInstanceOf[SparkTable])
+      ) {
+        (
+          PaimonMetadataColumn.FILE_PATH.copy(nullable = false),
+          PaimonMetadataColumn.ROW_INDEX.copy(nullable = false))
+      } else {
+        (PaimonMetadataColumn.FILE_PATH, PaimonMetadataColumn.ROW_INDEX)
+      }
     _metadataColumns.appendAll(
       Seq(
-        PaimonMetadataColumn.FILE_PATH,
-        PaimonMetadataColumn.ROW_INDEX,
+        filePathColumn,
+        rowIndexColumn,
         PaimonMetadataColumn.PARTITION(partitionType),
         PaimonMetadataColumn.BUCKET
       ))
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTable.scala
index 9ea20de190..2dd70920cd 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTable.scala
@@ -19,9 +19,9 @@
 package org.apache.paimon.spark
 
 import org.apache.paimon.spark.read.ObjectTableScanBuilder
-import org.apache.paimon.spark.rowops.PaimonSparkCopyOnWriteOperation
+import org.apache.paimon.spark.rowops.{PaimonSparkCopyOnWriteOperation, 
PaimonSparkDeltaOperation}
 import org.apache.paimon.table.`object`.ObjectTable
-import org.apache.paimon.table.{FileStoreTable, Table}
+import org.apache.paimon.table.{BucketMode, FileStoreTable, Table}
 
 import org.apache.spark.sql.connector.catalog.{SupportsRead, 
SupportsRowLevelOperations, TableCapability}
 import org.apache.spark.sql.connector.read.ScanBuilder
@@ -40,15 +40,17 @@ import java.util.{EnumSet => JEnumSet, Set => JSet}
  * If this base class implemented `SupportsRowLevelOperations`, Spark 4.1 
would immediately call
  * `newRowLevelOperationBuilder` on tables whose V2 write is disabled (e.g. 
dynamic bucket or
  * primary-key tables that fall back to V1 write) and fail before Paimon has a 
chance to rewrite the
- * plan to a V1 command. Likewise, deletion-vector, data-evolution, and 
fixed-length CHAR tables
- * need to stay on Paimon's V1 postHoc path even when `useV2Write=true`, so 
they must also not
- * expose `SupportsRowLevelOperations`.
+ * plan to a V1 command. Likewise, data-evolution and fixed-length CHAR tables 
need to stay on
+ * Paimon's V1 postHoc path even when `useV2Write=true`, so they must also not 
expose
+ * `SupportsRowLevelOperations`.
  *
  * Tables that DO support V2 row-level operations use the 
[[SparkTableWithRowLevelOps]] subclass
  * instead; the [[SparkTable.of]] factory picks the right variant via
  * [[SparkTable.supportsV2RowLevelOps]]. Append-only tables, including 
row-tracking-only tables,
  * expose `SupportsRowLevelOperations` so DELETE, UPDATE, and MERGE INTO can 
go through the V2
- * copy-on-write path when the table has no PK, deletion vectors, data 
evolution, or CHAR columns.
+ * copy-on-write path when the table has no PK, deletion vectors, data 
evolution, or CHAR columns;
+ * unaware-bucket deletion-vector append tables expose it so DELETE can go 
through the delta path
+ * (see [[SparkTable.supportsV2DeltaOps]]).
  */
 case class SparkTable(override val table: Table) extends 
PaimonSparkTableBase(table)
 
@@ -64,7 +66,8 @@ class SparkTableWithRowLevelOps(tableArg: Table)
       info: RowLevelOperationInfo): RowLevelOperationBuilder = {
     table match {
       case t: FileStoreTable =>
-        () => new PaimonSparkCopyOnWriteOperation(t, info)
+        if (SparkTable.supportsV2DeltaOps(this)) { () => new 
PaimonSparkDeltaOperation(t, info) }
+        else { () => new PaimonSparkCopyOnWriteOperation(t, info) }
       case _ =>
         throw new UnsupportedOperationException(
           "Row-level write operation is only supported for FileStoreTable. " +
@@ -109,6 +112,10 @@ object SparkTable {
    * `NoSuchMethodError` at the first DML statement.
    */
   private[spark] def supportsV2RowLevelOps(sparkTable: SparkTable): Boolean = {
+    supportsV2CopyOnWriteOps(sparkTable) || supportsV2DeltaOps(sparkTable)
+  }
+
+  private def supportsV2CopyOnWriteOps(sparkTable: SparkTable): Boolean = {
     if (org.apache.spark.SPARK_VERSION < "3.5") return false
     if (!sparkTable.useV2Write) return false
     sparkTable.getTable match {
@@ -123,6 +130,34 @@ object SparkTable {
       case _ => false
     }
   }
+
+  /**
+   * Whether the table takes the delta-based row-level path 
([[PaimonSparkDeltaOperation]]): an
+   * unaware-bucket append-only table with deletion vectors enabled. Bucketed 
append tables are
+   * excluded because commit conflict detection does not cover concurrent 
deletion-vector index
+   * changes of the same bucket (`IndexManifestFileHandler` overwrites the 
whole bucket's index);
+   * row-tracking deletion-vector tables are excluded until the delta path 
defines the row lineage
+   * semantics for rewritten rows.
+   *
+   * The Spark 3.2/3.3/3.4 shim `SparkTable` companions must expose a method 
with this exact
+   * signature (hard-coded `false`), same as [[supportsV2RowLevelOps]]. Public 
because the Spark 4.1
+   * rewrite scope (`PureAppendOnlyScope.targetsV2DeltaTable`) delegates to it 
as the single source
+   * of truth for the delta gating conditions.
+   */
+  def supportsV2DeltaOps(sparkTable: SparkTable): Boolean = {
+    if (org.apache.spark.SPARK_VERSION < "3.5") return false
+    if (!sparkTable.useV2Write) return false
+    sparkTable.getTable match {
+      case fs: FileStoreTable =>
+        fs.primaryKeys().isEmpty &&
+        fs.bucketMode() == BucketMode.BUCKET_UNAWARE &&
+        sparkTable.coreOptions.deletionVectorsEnabled() &&
+        !sparkTable.coreOptions.rowTrackingEnabled() &&
+        !sparkTable.coreOptions.dataEvolutionEnabled() &&
+        !SparkTypeUtils.containsCharType(fs.rowType())
+      case _ => false
+    }
+  }
 }
 
 case class SparkIcebergTable(table: Table) extends BaseTable
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkDeltaOperation.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkDeltaOperation.scala
new file mode 100644
index 0000000000..f91d85a74d
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkDeltaOperation.scala
@@ -0,0 +1,84 @@
+/*
+ * 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.spark.rowops
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.Snapshot
+import org.apache.paimon.spark.PaimonScanBuilder
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.{FILE_PATH_COLUMN, 
ROW_INDEX_COLUMN}
+import org.apache.paimon.spark.write.PaimonDeltaWriteBuilder
+import org.apache.paimon.table.{FileStoreTable, InnerTable}
+
+import org.apache.spark.sql.connector.expressions.{Expressions, NamedReference}
+import org.apache.spark.sql.connector.read.ScanBuilder
+import org.apache.spark.sql.connector.write.{DeltaWriteBuilder, 
LogicalWriteInfo, RowLevelOperation, RowLevelOperationInfo, SupportsDelta}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+import java.util.{HashMap => JHashMap}
+
+/**
+ * A delta-based [[RowLevelOperation]] for deletion-vector enabled append 
tables: instead of
+ * rewriting whole files like [[PaimonSparkCopyOnWriteOperation]], deleted 
rows are marked in
+ * deletion vectors keyed by the `(__paimon_file_path, __paimon_row_index)` 
row ID.
+ *
+ * The scan side is a regular pushdown scan: Spark's runtime group filtering 
only applies to
+ * group-based (`ReplaceData`) plans, delta plans converge on matching rows 
through the rewrite
+ * condition and ordinary predicate pushdown.
+ */
+class PaimonSparkDeltaOperation(table: FileStoreTable, info: 
RowLevelOperationInfo)
+  extends RowLevelOperation
+  with SupportsDelta {
+
+  // Captured before the scan is planned. The deletion-vector maintainer must 
merge with the
+  // deletion vectors of the snapshot the job read, never a newer one: 
initializing it from the
+  // commit-time latest snapshot would silently absorb concurrent 
deletion-vector changes that
+  // commit conflict detection is supposed to reject (and an UPDATE/MERGE 
could resurrect a
+  // concurrently deleted row as its new version). Same contract as the V1 
commands' readSnapshot.
+  private val readSnapshotId: Option[Long] =
+    Option(table.snapshotManager().latestSnapshot()).map(_.id())
+
+  override def command(): RowLevelOperation.Command = info.command()
+
+  override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder 
= {
+    // Pin the scan to the captured snapshot so the read, the deletion-vector 
maintainer and the
+    // commit-time file mapping all observe the same version; a commit that 
lands in between (e.g.
+    // a concurrent compaction) then surfaces as a commit conflict instead of 
a spurious mismatch
+    // between the scanned files and the read snapshot.
+    val conf = new JHashMap[String, String](options.asCaseSensitiveMap())
+    readSnapshotId.foreach(id => conf.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), 
id.toString))
+    new PaimonScanBuilder(table.copy(conf).asInstanceOf[InnerTable])
+  }
+
+  override def newWriteBuilder(info: LogicalWriteInfo): DeltaWriteBuilder =
+    new PaimonDeltaWriteBuilder(
+      table,
+      info,
+      Snapshot.Operation.valueOf(command().toString),
+      readSnapshotId)
+
+  override def rowId(): Array[NamedReference] =
+    Array(Expressions.column(FILE_PATH_COLUMN), 
Expressions.column(ROW_INDEX_COLUMN))
+
+  // Paimon cannot update rows in place: an update is a deletion-vector mark 
on the old row plus
+  // an appended new row. Still answer false so that Spark plans a single 
UPDATE operation per row
+  // (no Expand doubling); the delta writer decomposes it internally.
+  override def representUpdateAsDeleteAndInsert(): Boolean = false
+
+  override def requiredMetadataAttributes(): Array[NamedReference] = 
Array.empty
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/schema/PaimonMetadataColumn.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/schema/PaimonMetadataColumn.scala
index 41c6cdaaaf..8dc90a7c4f 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/schema/PaimonMetadataColumn.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/schema/PaimonMetadataColumn.scala
@@ -32,9 +32,16 @@ case class PaimonMetadataColumn(
     override val dataType: DataType,
     preserveOnDelete: Boolean = true,
     preserveOnUpdate: Boolean = true,
-    preserveOnReinsert: Boolean = false)
+    preserveOnReinsert: Boolean = false,
+    nullable: Boolean = true)
   extends PaimonMetadataColumnBase {
 
+  // Only affects the Spark `MetadataColumn` capability (thus the relation's 
metadata output and
+  // the delta row ID nullability check). `toStructField` / `toAttribute` stay 
nullable: V1
+  // commands project these columns through outer joins where the target side 
can be null, e.g.
+  // the not-matched rows of MERGE INTO.
+  override def isNullable: Boolean = nullable
+
   def toPaimonDataField: DataField = {
     new DataField(id, name, SparkTypeUtils.toPaimonType(dataType));
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWriteBase.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWriteBase.scala
index b13cea4b3b..5ac1688223 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWriteBase.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWriteBase.scala
@@ -21,7 +21,6 @@ package org.apache.paimon.spark.write
 import org.apache.paimon.Snapshot
 import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
 import org.apache.paimon.spark.{PaimonDeletedRecordsTaskMetric, SparkTypeUtils}
-import org.apache.paimon.spark.catalyst.Compatibility
 import org.apache.paimon.spark.commands.SparkDataFileMeta
 import org.apache.paimon.spark.metric.SparkMetricRegistry
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
@@ -29,11 +28,8 @@ import 
org.apache.paimon.spark.schema.PaimonMetadataColumn.{FILE_PATH, ROW_ID, S
 import org.apache.paimon.table.{FileStoreTable, SpecialFields}
 import org.apache.paimon.table.sink.{BatchWriteBuilder, CommitMessage, 
CommitMessageImpl}
 
-import org.apache.spark.sql.PaimonSparkSession
 import org.apache.spark.sql.connector.metric.CustomTaskMetric
 import org.apache.spark.sql.connector.write.{DataWriterFactory, 
PhysicalWriteInfo, WriterCommitMessage}
-import org.apache.spark.sql.execution.SQLExecution
-import org.apache.spark.sql.execution.metric.SQLMetrics
 import org.apache.spark.sql.types.StructType
 
 import java.util.Collections
@@ -133,7 +129,9 @@ abstract class PaimonBatchWriteBase(
     } finally {
       batchTableCommit.close()
     }
-    postDriverMetrics(deletedRecordsTaskMetric(operation, addCommitMessage, 
deletedCommitMessage))
+    postDriverMetrics(
+      metricRegistry.buildSparkCommitMetrics() ++
+        deletedRecordsTaskMetric(operation, addCommitMessage, 
deletedCommitMessage))
     postCommit(commitMessages)
   }
 
@@ -178,24 +176,6 @@ abstract class PaimonBatchWriteBase(
     }
   }
 
-  // Spark support v2 write driver metrics since 4.0, see 
https://github.com/apache/spark/pull/48573
-  // To ensure compatibility with 3.x, manually post driver metrics here 
instead of using Spark's API.
-  protected def postDriverMetrics(extraMetrics: Array[CustomTaskMetric] = 
Array.empty): Unit = {
-    val spark = PaimonSparkSession.active
-    // todo: find a more suitable way to get metrics.
-    val commitMetrics = metricRegistry.buildSparkCommitMetrics() ++ 
extraMetrics
-    val executionId = 
spark.sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY)
-    val executionMetrics = Compatibility.getExecutionMetrics(spark, 
executionId.toLong).distinct
-    val metricUpdates = executionMetrics.flatMap {
-      m =>
-        commitMetrics.find(x => 
m.metricType.toLowerCase.contains(x.name.toLowerCase)) match {
-          case Some(customTaskMetric) => Some((m.accumulatorId, 
customTaskMetric.value()))
-          case None => None
-        }
-    }
-    SQLMetrics.postDriverMetricsUpdatedByValue(spark.sparkContext, 
executionId, metricUpdates)
-  }
-
   private def buildDeletedCommitMessage(
       deletedFiles: Seq[SparkDataFileMeta]): Seq[CommitMessage] = {
     logInfo(s"[V2 Write] Building deleted commit message for 
${deletedFiles.size} files")
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWrite.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWrite.scala
new file mode 100644
index 0000000000..2117c50fde
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWrite.scala
@@ -0,0 +1,77 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.spark.{PaimonAddedTableFilesMetric, 
PaimonCommitDurationMetric, PaimonDeletedRecordsMetric, 
PaimonInsertedRecordsMetric, PaimonNumWritersMetric, PaimonUpdatedRecordsMetric}
+import org.apache.paimon.table.FileStoreTable
+
+import org.apache.spark.sql.connector.metric.CustomMetric
+import org.apache.spark.sql.connector.write.{DeltaBatchWrite, DeltaWrite}
+import org.apache.spark.sql.paimon.shims.SparkShimLoader
+import org.apache.spark.sql.types.StructType
+
+import scala.collection.mutable
+
+/**
+ * A [[DeltaWrite]] for deletion-vector enabled append tables. `toBatch` goes 
through the shim so
+ * the `DeltaBatchWrite` mixin is compiled against the right Spark minor 
version, see
+ * [[PaimonDeltaWriteBase]].
+ */
+class PaimonDeltaWrite(
+    table: FileStoreTable,
+    rowSchema: StructType,
+    rowIdSchema: StructType,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends DeltaWrite {
+
+  override def toBatch: DeltaBatchWrite = {
+    // The shim factory is declared to return `BatchWrite` so that loading the 
shim class on
+    // Spark < 3.4 runtimes never links against the delta write API; this 
class itself is only
+    // loaded behind the `supportsV2DeltaOps` gate (Spark 3.5+).
+    SparkShimLoader.shim
+      .createPaimonDeltaBatchWrite(table, rowSchema, rowIdSchema, 
operationType, readSnapshotId)
+      .asInstanceOf[DeltaBatchWrite]
+  }
+
+  override def supportedCustomMetrics(): Array[CustomMetric] = {
+    val buffer = 
mutable.ArrayBuffer[CustomMetric](PaimonCommitDurationMetric())
+    operationType match {
+      case Snapshot.Operation.DELETE =>
+        buffer += PaimonDeletedRecordsMetric()
+      case Snapshot.Operation.UPDATE =>
+        buffer += PaimonAddedTableFilesMetric()
+        buffer += PaimonNumWritersMetric()
+        buffer += PaimonUpdatedRecordsMetric()
+      case Snapshot.Operation.MERGE =>
+        buffer += PaimonAddedTableFilesMetric()
+        buffer += PaimonNumWritersMetric()
+        buffer += PaimonDeletedRecordsMetric()
+        buffer += PaimonUpdatedRecordsMetric()
+        buffer += PaimonInsertedRecordsMetric()
+      case _ =>
+    }
+    buffer.toArray
+  }
+
+  override def toString: String = 
s"PaimonDeltaWrite(table=${table.fullName()})"
+
+  override def description(): String = toString
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBase.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBase.scala
new file mode 100644
index 0000000000..3f1c6001fa
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBase.scala
@@ -0,0 +1,225 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.deletionvectors.DeletionVector
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer
+import org.apache.paimon.fs.Path
+import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
+import org.apache.paimon.manifest.FileKind
+import org.apache.paimon.spark.PaimonMetrics
+import org.apache.paimon.spark.metric.SparkMetricRegistry
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.{FILE_PATH_COLUMN, 
ROW_INDEX_COLUMN}
+import org.apache.paimon.table.FileStoreTable
+import org.apache.paimon.table.sink.{BatchWriteBuilder, CommitMessage, 
CommitMessageImpl}
+import org.apache.paimon.table.source.DataSplit
+
+import org.apache.spark.sql.connector.write.{DeltaWriterFactory, 
PhysicalWriteInfo, WriterCommitMessage}
+import org.apache.spark.sql.types.StructType
+
+import java.util.Collections
+
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
+/**
+ * Business logic for Paimon delta batch writes on deletion-vector enabled 
append tables,
+ * deliberately *not* extending 
`org.apache.spark.sql.connector.write.DeltaBatchWrite` for the same
+ * `BatchWrite.commit(.., WriteSummary)` cross-version reason documented on
+ * [[PaimonBatchWriteBase]]: per-version `paimon-spark{3,4}-common` modules 
supply a thin
+ * `PaimonDeltaBatchWrite` wrapper that mixes in `DeltaBatchWrite`, and 
`paimon-spark-4.0/src/main`
+ * shadows that wrapper at the 4.0.2 compile target.
+ *
+ * Deleted rows arrive as per-task per-file bitmaps and appended rows as 
regular data commit
+ * messages (see [[PaimonDeltaWriter]]); this class merges the bitmaps across 
tasks at commit time,
+ * merges the result with the files' existing deletion vectors through
+ * [[BaseAppendDeleteFileMaintainer]], and commits the data messages together 
with index-only
+ * deletion-vector [[CommitMessage]]s.
+ */
+abstract class PaimonDeltaWriteBase(
+    val table: FileStoreTable,
+    val rowSchema: StructType,
+    val rowIdSchema: StructType,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends WriteHelper
+  with Serializable {
+
+  protected val metricRegistry: SparkMetricRegistry = SparkMetricRegistry()
+
+  @volatile protected var commitStarted: Boolean = false
+
+  protected val batchWriteBuilder: BatchWriteBuilder = 
table.newBatchWriteBuilder()
+
+  protected def createPaimonDeltaWriterFactory(info: PhysicalWriteInfo): 
DeltaWriterFactory = {
+    PaimonDeltaWriterFactory(
+      batchWriteBuilder,
+      rowSchema,
+      coreOptions,
+      catalogContextForBlobDescriptor,
+      rowIdSchema.fieldIndex(FILE_PATH_COLUMN),
+      rowIdSchema.fieldIndex(ROW_INDEX_COLUMN)
+    )
+  }
+
+  protected def commitMessages(messages: Array[WriterCommitMessage]): Unit = {
+    commitStarted = true
+    logInfo(s"Committing delta write to table ${table.name()}")
+    val taskMessages = messages.filter(_ != null)
+    val dataMessages = WriteTaskResult.merge(taskMessages)
+    val mergedDvs = mergeTaskDeletionVectors(taskMessages)
+    val dvMessages = if (mergedDvs.isEmpty) Seq.empty else 
buildDvCommitMessages(mergedDvs)
+    val commitMessages = dataMessages ++ dvMessages
+
+    val batchTableCommit = batchWriteBuilder.newCommit()
+    batchTableCommit.withMetricRegistry(metricRegistry)
+    batchTableCommit.withOperation(operationType)
+    try {
+      val start = System.currentTimeMillis()
+      batchTableCommit.commit(commitMessages.asJava)
+      logInfo(s"Committed in ${System.currentTimeMillis() - start} ms")
+    } finally {
+      batchTableCommit.close()
+    }
+    // Drop the commit-level insertedRecords gauge: it is derived from
+    // `CommitStats.deltaRecordsAppended` which mixes ADD and DELETE row 
counts and would
+    // overwrite the exact per-row record metrics reported by the delta writer 
tasks.
+    postDriverMetrics(
+      metricRegistry
+        .buildSparkCommitMetrics()
+        .filterNot(_.name() == PaimonMetrics.INSERTED_RECORDS))
+    postCommit(commitMessages)
+  }
+
+  protected def abortMessages(messages: Array[WriterCommitMessage]): Unit = {
+    if (commitStarted) {
+      logWarning(s"Skip abort cleanup for table ${table.name()} because commit 
has already started")
+      return
+    }
+    // Deletion-vector index files are only written inside `commitMessages`, 
so an abort before
+    // commit only needs to clean up the appended data files. If 
`commitMessages` fails after the
+    // maintainer persisted the index files, they are left behind like any 
data file of a failed
+    // commit and reclaimed by orphan-file cleanup.
+    logInfo(s"Aborting delta write to table ${table.name()}")
+    val batchTableCommit = batchWriteBuilder.newCommit()
+    try {
+      val dataMessages = WriteTaskResult.merge(messages.filter(_ != null))
+      batchTableCommit.abort(dataMessages.asJava)
+    } finally {
+      batchTableCommit.close()
+    }
+  }
+
+  /** Merge the per-task per-file bitmaps into one deletion vector per data 
file. */
+  private def mergeTaskDeletionVectors(
+      messages: Array[WriterCommitMessage]): mutable.HashMap[String, 
DeletionVector] = {
+    val merged = mutable.HashMap.empty[String, DeletionVector]
+    messages.foreach {
+      case result: PaimonDeltaWriteTaskResult =>
+        result.deletionVectors.foreach {
+          serialized =>
+            val dv = 
DeletionVector.deserializeFromBytes(serialized.deletionVector)
+            merged.get(serialized.dataFilePath) match {
+              case Some(existing) => existing.merge(dv)
+              case None => merged.put(serialized.dataFilePath, dv)
+            }
+        }
+      case other =>
+        throw new IllegalStateException(s"Unexpected delta writer commit 
message: $other")
+    }
+    merged
+  }
+
+  /**
+   * Persist the merged deletion vectors as index files and build index-only 
commit messages. The
+   * maintainer merges the new deletions with the files' deletion vectors of 
the snapshot captured
+   * BEFORE the scan (same contract as the V1 commands' readSnapshot): a 
concurrent deletion-vector
+   * change of the same file then surfaces as a commit conflict instead of 
being silently merged,
+   * which could otherwise resurrect a concurrently deleted row as the new 
version of an UPDATE.
+   */
+  private def buildDvCommitMessages(
+      mergedDvs: mutable.HashMap[String, DeletionVector]): Seq[CommitMessage] 
= {
+    val snapshot = readSnapshotId
+      .map {
+        id =>
+          try {
+            table.snapshotManager().snapshot(id)
+          } catch {
+            case e: Exception =>
+              throw new IllegalStateException(
+                s"Cannot load the read snapshot $id of table ${table.name()}, 
it may have " +
+                  "expired during the write, please retry.",
+                e)
+          }
+      }
+      .getOrElse(throw new IllegalStateException(
+        s"Table ${table.name()} had no snapshot when the scan was planned but 
rows were deleted."))
+    val fileNameToDv = mergedDvs.map { case (path, dv) => new 
Path(path).getName -> dv }
+
+    val fileNameToPartition = mutable.HashMap.empty[String, BinaryRow]
+    val snapshotReader = table.newSnapshotReader().withSnapshot(snapshot)
+    if (coreOptions.manifestDeleteFileDropStats()) {
+      snapshotReader.dropStats()
+    }
+    snapshotReader.withDataFileNameFilter(fileName => 
fileNameToDv.contains(fileName))
+    snapshotReader.read().splits().asScala.foreach {
+      case split: DataSplit =>
+        split
+          .dataFiles()
+          .asScala
+          .foreach(f => fileNameToPartition.put(f.fileName(), 
split.partition()))
+      case _ =>
+    }
+
+    val missingFiles = fileNameToDv.keySet.diff(fileNameToPartition.keySet)
+    if (missingFiles.nonEmpty) {
+      throw new IllegalStateException(
+        s"Data files $missingFiles of table ${table.name()} are missing in the 
read snapshot " +
+          s"${snapshot.id()}, its manifests may have expired during the write, 
please retry.")
+    }
+
+    fileNameToDv
+      .groupBy { case (fileName, _) => fileNameToPartition(fileName) }
+      .map {
+        case (partition, dvs) =>
+          val indexHandler = table.store().newIndexFileHandler()
+          val maintainer =
+            BaseAppendDeleteFileMaintainer.forUnawareAppend(indexHandler, 
snapshot, partition)
+          dvs.foreach { case (fileName, dv) => 
maintainer.notifyNewDeletionVector(fileName, dv) }
+          val indexEntries = maintainer.persist()
+          val (added, deleted) = indexEntries.asScala.partition(_.kind() == 
FileKind.ADD)
+          new CommitMessageImpl(
+            maintainer.getPartition,
+            maintainer.getBucket,
+            null,
+            new DataIncrement(
+              Collections.emptyList[DataFileMeta],
+              Collections.emptyList[DataFileMeta],
+              Collections.emptyList[DataFileMeta],
+              added.map(_.indexFile).asJava,
+              deleted.map(_.indexFile).asJava
+            ),
+            CompactIncrement.emptyIncrement()
+          ): CommitMessage
+      }
+      .toSeq
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBuilder.scala
new file mode 100644
index 0000000000..6a459ccbf9
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriteBuilder.scala
@@ -0,0 +1,43 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.table.FileStoreTable
+
+import org.apache.spark.sql.connector.write.{DeltaWrite, DeltaWriteBuilder, 
LogicalWriteInfo}
+
+/** A [[DeltaWriteBuilder]] for deletion-vector enabled append tables. */
+class PaimonDeltaWriteBuilder(
+    table: FileStoreTable,
+    info: LogicalWriteInfo,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends DeltaWriteBuilder {
+
+  assert(info.rowIdSchema().isPresent, "Delta write requires a row ID schema.")
+
+  override def build(): DeltaWrite =
+    new PaimonDeltaWrite(
+      table,
+      info.schema(),
+      info.rowIdSchema().get(),
+      operationType,
+      readSnapshotId)
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriter.scala
new file mode 100644
index 0000000000..72bd73b846
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaWriter.scala
@@ -0,0 +1,150 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.catalog.CatalogContext
+import org.apache.paimon.deletionvectors.{Bitmap64DeletionVector, 
BitmapDeletionVector, DeletionVector}
+import org.apache.paimon.spark.{PaimonDeletedRecordsTaskMetric, 
PaimonInsertedRecordsTaskMetric, PaimonUpdatedRecordsTaskMetric}
+import org.apache.paimon.table.sink.{BatchWriteBuilder, CommitMessage}
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.metric.CustomTaskMetric
+import org.apache.spark.sql.connector.write.{DeltaWriter, DeltaWriterFactory, 
WriterCommitMessage}
+import org.apache.spark.sql.types.StructType
+
+import scala.collection.mutable
+
+/** A deletion vector of one data file produced by a [[PaimonDeltaWriter]] 
task. */
+case class SerializedDeletionVector(dataFilePath: String, deletionVector: 
Array[Byte])
+
+/**
+ * The [[WriterCommitMessage]] of a [[PaimonDeltaWriter]] task: the commit 
messages of the appended
+ * data files (inserted rows and the new versions of updated rows) plus the 
per-file deletion
+ * bitmaps of the deleted rows.
+ */
+case class PaimonDeltaWriteTaskResult(
+    dataWriteResult: Option[InnerTableWriteTaskResult],
+    deletionVectors: Array[SerializedDeletionVector])
+  extends WriteTaskResult {
+
+  override def commitMessages(): Seq[CommitMessage] =
+    dataWriteResult.map(_.commitMessages()).getOrElse(Seq.empty)
+}
+
+case class PaimonDeltaWriterFactory(
+    writeBuilder: BatchWriteBuilder,
+    rowSchema: StructType,
+    coreOptions: CoreOptions,
+    catalogContext: CatalogContext,
+    filePathOrdinal: Int,
+    rowIndexOrdinal: Int)
+  extends DeltaWriterFactory {
+
+  override def createWriter(partitionId: Int, taskId: Long): 
DeltaWriter[InternalRow] =
+    PaimonDeltaWriter(
+      writeBuilder,
+      rowSchema,
+      coreOptions,
+      catalogContext,
+      filePathOrdinal,
+      rowIndexOrdinal)
+}
+
+/**
+ * A [[DeltaWriter]] for deletion-vector enabled append tables. Deleted rows 
are accumulated as
+ * per-file bitmaps keyed by the `__paimon_file_path` and `__paimon_row_index` 
row ID; the driver
+ * merges the bitmaps across tasks and persists them as deletion-vector index 
files at commit time,
+ * because Spark does not cluster the delta rows by file and several tasks may 
delete rows of the
+ * same data file. Inserted rows and the new versions of updated rows go 
through the regular append
+ * writer.
+ */
+case class PaimonDeltaWriter(
+    writeBuilder: BatchWriteBuilder,
+    rowSchema: StructType,
+    coreOptions: CoreOptions,
+    catalogContext: CatalogContext,
+    filePathOrdinal: Int,
+    rowIndexOrdinal: Int)
+  extends DeltaWriter[InternalRow] {
+
+  private val deletionVectors = mutable.HashMap.empty[String, DeletionVector]
+
+  private var deletedRecords = 0L
+  private var updatedRecords = 0L
+  private var insertedRecords = 0L
+
+  // Only UPDATE and MERGE INTO write rows; DELETE never initializes the 
append writer.
+  private var appendWriter: Option[PaimonV2DataWriter] = None
+
+  private def getOrCreateAppendWriter: PaimonV2DataWriter = {
+    appendWriter.getOrElse {
+      val writer =
+        PaimonV2DataWriter(writeBuilder, rowSchema, rowSchema, coreOptions, 
catalogContext)
+      appendWriter = Some(writer)
+      writer
+    }
+  }
+
+  private def markDeleted(id: InternalRow): Unit = {
+    val filePath = id.getUTF8String(filePathOrdinal).toString
+    val dv = deletionVectors.getOrElseUpdate(
+      filePath,
+      if (coreOptions.deletionVectorBitmap64()) new Bitmap64DeletionVector()
+      else new BitmapDeletionVector())
+    dv.delete(id.getLong(rowIndexOrdinal))
+  }
+
+  override def delete(metadata: InternalRow, id: InternalRow): Unit = {
+    markDeleted(id)
+    deletedRecords += 1
+  }
+
+  override def update(metadata: InternalRow, id: InternalRow, row: 
InternalRow): Unit = {
+    markDeleted(id)
+    getOrCreateAppendWriter.write(row)
+    updatedRecords += 1
+  }
+
+  override def insert(row: InternalRow): Unit = {
+    getOrCreateAppendWriter.write(row)
+    insertedRecords += 1
+  }
+
+  override def commit(): WriterCommitMessage = {
+    val dataWriteResult = appendWriter.map(_.commit)
+    val serialized = deletionVectors.map {
+      case (filePath, dv) =>
+        SerializedDeletionVector(filePath, DeletionVector.serializeToBytes(dv))
+    }.toArray
+    PaimonDeltaWriteTaskResult(dataWriteResult, serialized)
+  }
+
+  override def abort(): Unit = appendWriter.foreach(_.abort())
+
+  override def close(): Unit = appendWriter.foreach(_.close())
+
+  override def currentMetricsValues(): Array[CustomTaskMetric] = {
+    Array(
+      PaimonDeletedRecordsTaskMetric(deletedRecords),
+      PaimonUpdatedRecordsTaskMetric(updatedRecords),
+      PaimonInsertedRecordsTaskMetric(insertedRecords)
+    ) ++ 
appendWriter.map(_.currentMetricsValues()).getOrElse(Array.empty[CustomTaskMetric])
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/WriteHelper.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/WriteHelper.scala
index df73bd35d0..c039bf80ed 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/WriteHelper.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/WriteHelper.scala
@@ -22,12 +22,17 @@ import org.apache.paimon.CoreOptions
 import org.apache.paimon.CoreOptions.TagCreationMode
 import org.apache.paimon.catalog.CatalogContext
 import org.apache.paimon.partition.actions.PartitionMarkDoneAction
+import org.apache.paimon.spark.catalyst.Compatibility
 import org.apache.paimon.table.FileStoreTable
 import org.apache.paimon.table.sink.CommitMessage
 import org.apache.paimon.tag.TagBatchCreation
 import org.apache.paimon.utils.{BlobDescriptorUtils, 
InternalRowPartitionComputer, PartitionPathUtils, PartitionStatisticsReporter, 
TypeUtils}
 
 import org.apache.spark.internal.Logging
+import org.apache.spark.sql.PaimonSparkSession
+import org.apache.spark.sql.connector.metric.CustomTaskMetric
+import org.apache.spark.sql.execution.SQLExecution
+import org.apache.spark.sql.execution.metric.SQLMetrics
 
 import scala.collection.JavaConverters._
 
@@ -42,6 +47,22 @@ trait WriteHelper extends Logging {
       table.catalogEnvironment().catalogContext(),
       coreOptions.toConfiguration)
 
+  // Spark support v2 write driver metrics since 4.0, see 
https://github.com/apache/spark/pull/48573
+  // To ensure compatibility with 3.x, manually post driver metrics here 
instead of using Spark's API.
+  def postDriverMetrics(commitMetrics: Array[CustomTaskMetric]): Unit = {
+    val spark = PaimonSparkSession.active
+    val executionId = 
spark.sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY)
+    val executionMetrics = Compatibility.getExecutionMetrics(spark, 
executionId.toLong).distinct
+    val metricUpdates = executionMetrics.flatMap {
+      m =>
+        commitMetrics.find(x => 
m.metricType.toLowerCase.contains(x.name.toLowerCase)) match {
+          case Some(customTaskMetric) => Some((m.accumulatorId, 
customTaskMetric.value()))
+          case None => None
+        }
+    }
+    SQLMetrics.postDriverMetricsUpdatedByValue(spark.sparkContext, 
executionId, metricUpdates)
+  }
+
   def postCommit(messages: Seq[CommitMessage]): Unit = {
     if (messages.isEmpty) {
       return
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
index b925036463..85c923325d 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
@@ -133,6 +133,19 @@ trait SparkShim {
       copyOnWriteScan: Option[PaimonCopyOnWriteScan],
       operationType: Option[Snapshot.Operation]): BatchWrite
 
+  /**
+   * Same `BatchWrite` mixin problem as [[createPaimonBatchWrite]], but for 
the delta write of
+   * deletion-vector append tables. Declared to return `BatchWrite` instead of 
`DeltaBatchWrite` so
+   * that loading the shim class on Spark runtimes without the delta write API 
never links against
+   * it; the caller (only loaded behind the Spark 3.5+ delta gate) casts to 
`DeltaBatchWrite`.
+   */
+  def createPaimonDeltaBatchWrite(
+      table: FileStoreTable,
+      rowSchema: StructType,
+      rowIdSchema: StructType,
+      operationType: Snapshot.Operation,
+      readSnapshotId: Option[Long]): BatchWrite
+
   /** Same `BatchWrite` mixin problem as [[createPaimonBatchWrite]], but for 
`FormatTable` writes. */
   def createFormatTableBatchWrite(
       table: FormatTable,
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeltaRowLevelOpsTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeltaRowLevelOpsTest.scala
new file mode 100644
index 0000000000..b7190502b9
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeltaRowLevelOpsTest.scala
@@ -0,0 +1,302 @@
+/*
+ * 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.spark.sql
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.deletionvectors.{BitmapDeletionVector, DeletionVector}
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.{FILE_PATH_COLUMN, 
ROW_INDEX_COLUMN}
+import org.apache.paimon.spark.write.{PaimonDeltaBatchWrite, 
PaimonDeltaWriteTaskResult, SerializedDeletionVector}
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.{LongType, StringType, StructField, 
StructType}
+
+import scala.util.Random
+
+/**
+ * Tests for the delta-based (`SupportsDelta` / `WriteDelta`) DELETE, UPDATE 
and MERGE INTO of
+ * deletion-vector enabled unaware-bucket append tables.
+ */
+class DeltaRowLevelOpsTest extends PaimonSparkTestBase {
+
+  private val v2WriteConf = "spark.paimon.write.use-v2-write" -> "true"
+
+  private def explainContains(statement: String): String = {
+    sql(s"EXPLAIN EXTENDED $statement").head().getString(0)
+  }
+
+  private def dataFilePaths(): Set[String] = {
+    sql("SELECT file_path FROM `T$files`").collect().map(_.getString(0)).toSet
+  }
+
+  private def createDvAppendTable(extraProps: String = ""): Unit = {
+    sql(s"""
+           |CREATE TABLE T (id INT, v INT)
+           |TBLPROPERTIES (
+           | 'deletion-vectors.enabled' = 'true',
+           | 'deletion-vectors.bitmap64' = '${Random.nextBoolean()}'
+           | $extraProps
+           |)
+           |""".stripMargin)
+  }
+
+  test("Paimon delta ops: basic delete goes through WriteDelta") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      // file 1
+      sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ id, id FROM range(1, 
1001)")
+      // file 2
+      sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ id, id FROM range(1001, 
2001)")
+
+      assert(explainContains("DELETE FROM T WHERE id <= 
10").contains("WriteDelta"))
+
+      val filesBefore = dataFilePaths()
+      sql("DELETE FROM T WHERE id <= 10")
+      checkAnswer(sql("SELECT COUNT(*), MIN(id) FROM T"), Row(1990, 11))
+      // Delta delete marks rows in deletion vectors and must not rewrite data 
files.
+      assert(dataFilePaths() === filesBefore)
+
+      // Delete rows of both files, merging with the existing deletion vector 
of file 1.
+      sql("DELETE FROM T WHERE id IN (11, 1001)")
+      checkAnswer(sql("SELECT COUNT(*), MIN(id) FROM T"), Row(1988, 12))
+      checkAnswer(sql("SELECT COUNT(*) FROM T WHERE id = 1001"), Row(0))
+      assert(dataFilePaths() === filesBefore)
+
+      // Delete the remaining rows.
+      sql("DELETE FROM T WHERE id > 0")
+      checkAnswer(sql("SELECT COUNT(*) FROM T"), Row(0))
+    }
+  }
+
+  test("Paimon delta ops: delete with zero matched rows") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T VALUES (1, 1), (2, 2)")
+      sql("DELETE FROM T WHERE id > 100")
+      checkAnswer(sql("SELECT * FROM T ORDER BY id"), Row(1, 1) :: Row(2, 2) 
:: Nil)
+    }
+  }
+
+  test("Paimon delta ops: delete on partitioned table") {
+    withSparkSQLConf(v2WriteConf) {
+      sql(s"""
+             |CREATE TABLE T (id INT, v INT, pt STRING)
+             |PARTITIONED BY (pt)
+             |TBLPROPERTIES ('deletion-vectors.enabled' = 'true')
+             |""".stripMargin)
+      sql("INSERT INTO T VALUES (1, 1, 'a'), (2, 2, 'a'), (3, 3, 'b'), (4, 4, 
'b')")
+
+      // A row-level condition across partitions goes through WriteDelta.
+      assert(explainContains("DELETE FROM T WHERE id % 2 = 
0").contains("WriteDelta"))
+      sql("DELETE FROM T WHERE id % 2 = 0")
+      checkAnswer(sql("SELECT id, pt FROM T ORDER BY id"), Row(1, "a") :: 
Row(3, "b") :: Nil)
+
+      // A partition-only (metadata only) condition falls back to the V1 
command to drop whole
+      // files instead of writing deletion vectors.
+      assert(explainContains("DELETE FROM T WHERE pt = 
'a'").contains("DeleteFromPaimonTable"))
+      sql("DELETE FROM T WHERE pt = 'a'")
+      checkAnswer(sql("SELECT id, pt FROM T"), Row(3, "b") :: Nil)
+    }
+  }
+
+  test("Paimon delta ops: basic update goes through WriteDelta") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ id, id FROM range(1, 
1001)")
+
+      assert(explainContains("UPDATE T SET v = v + 1 WHERE id <= 
10").contains("WriteDelta"))
+
+      val filesBefore = dataFilePaths()
+      sql("UPDATE T SET v = v + 1 WHERE id <= 10")
+      checkAnswer(sql("SELECT COUNT(*) FROM T"), Row(1000))
+      checkAnswer(sql("SELECT SUM(v) FROM T"), Row(500510))
+      checkAnswer(sql("SELECT v FROM T WHERE id = 1"), Row(2))
+      // The old rows are marked in deletion vectors, the new versions are 
appended: the original
+      // data files must remain untouched.
+      assert(filesBefore.subsetOf(dataFilePaths()))
+
+      // Update rows whose current version lives in the appended file, merging 
deletion vectors
+      // with the previous update.
+      sql("UPDATE T SET v = v + 1 WHERE id <= 5")
+      checkAnswer(sql("SELECT SUM(v) FROM T"), Row(500515))
+      checkAnswer(sql("SELECT v FROM T WHERE id = 1"), Row(3))
+
+      // Zero matched rows.
+      sql("UPDATE T SET v = 0 WHERE id > 10000")
+      checkAnswer(sql("SELECT SUM(v) FROM T"), Row(500515))
+
+      // NULL assignment.
+      sql("UPDATE T SET v = NULL WHERE id = 1000")
+      checkAnswer(sql("SELECT v FROM T WHERE id = 1000"), Row(null))
+    }
+  }
+
+  test("Paimon delta ops: update moving rows across partitions") {
+    withSparkSQLConf(v2WriteConf) {
+      sql(s"""
+             |CREATE TABLE T (id INT, pt STRING)
+             |PARTITIONED BY (pt)
+             |TBLPROPERTIES ('deletion-vectors.enabled' = 'true')
+             |""".stripMargin)
+      sql("INSERT INTO T VALUES (1, 'a'), (2, 'a'), (3, 'b')")
+
+      sql("UPDATE T SET pt = 'b' WHERE id = 1")
+      checkAnswer(
+        sql("SELECT id, pt FROM T ORDER BY id"),
+        Row(1, "b") :: Row(2, "a") :: Row(3, "b") :: Nil)
+      checkAnswer(sql("SELECT COUNT(*) FROM T WHERE pt = 'a'"), Row(1))
+    }
+  }
+
+  test("Paimon delta ops: merge into goes through WriteDelta") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T VALUES (1, 1), (2, 2), (3, 3), (4, 4)")
+
+      val merge =
+        """
+          |MERGE INTO T
+          |USING (SELECT * FROM VALUES (1, 10), (2, 20), (5, 50) AS s(id, v)) s
+          |ON T.id = s.id
+          |WHEN MATCHED AND s.id = 1 THEN UPDATE SET T.v = s.v
+          |WHEN MATCHED AND s.id = 2 THEN DELETE
+          |WHEN NOT MATCHED THEN INSERT (id, v) VALUES (s.id, s.v)
+          |""".stripMargin
+
+      assert(explainContains(merge).contains("WriteDelta"))
+
+      val filesBefore = dataFilePaths()
+      sql(merge)
+      checkAnswer(
+        sql("SELECT id, v FROM T ORDER BY id"),
+        Row(1, 10) :: Row(3, 3) :: Row(4, 4) :: Row(5, 50) :: Nil)
+      assert(filesBefore.subsetOf(dataFilePaths()))
+    }
+  }
+
+  test("Paimon delta ops: merge into with not matched by source") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T VALUES (1, 1), (2, 2), (3, 3)")
+
+      sql("""
+            |MERGE INTO T
+            |USING (SELECT * FROM VALUES (1, 10) AS s(id, v)) s
+            |ON T.id = s.id
+            |WHEN MATCHED THEN UPDATE SET T.v = s.v
+            |WHEN NOT MATCHED BY SOURCE AND T.id = 3 THEN DELETE
+            |WHEN NOT MATCHED BY SOURCE THEN UPDATE SET T.v = -1
+            |""".stripMargin)
+      checkAnswer(sql("SELECT id, v FROM T ORDER BY id"), Row(1, 10) :: Row(2, 
-1) :: Nil)
+    }
+  }
+
+  test("Paimon delta ops: merge into cardinality violation") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T VALUES (1, 1), (2, 2)")
+
+      val e = intercept[Throwable] {
+        sql("""
+              |MERGE INTO T
+              |USING (SELECT * FROM VALUES (1, 10), (1, 11) AS s(id, v)) s
+              |ON T.id = s.id
+              |WHEN MATCHED THEN UPDATE SET T.v = s.v
+              |""".stripMargin)
+      }
+      def messages(t: Throwable): Seq[String] =
+        Iterator.iterate(t)(_.getCause).takeWhile(_ != 
null).map(_.toString).toSeq
+      assert(messages(e).exists(_.contains("MERGE_CARDINALITY_VIOLATION")))
+    }
+  }
+
+  test("Paimon delta ops: concurrent deletion vector commits conflict") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable()
+      sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ id, id FROM range(0, 
100)")
+
+      // A delta write planned against the current snapshot, deleting row 0 of 
the file.
+      val table = loadTable("T")
+      val readSnapshotId = table.snapshotManager().latestSnapshot().id()
+      val filePath = sql("SELECT __paimon_file_path FROM T LIMIT 
1").head().getString(0)
+      val dv = new BitmapDeletionVector()
+      dv.delete(0L)
+      val taskResult = PaimonDeltaWriteTaskResult(
+        None,
+        Array(SerializedDeletionVector(filePath, 
DeletionVector.serializeToBytes(dv))))
+
+      // A concurrent DELETE commits a deletion vector for the same file in 
between.
+      sql("DELETE FROM T WHERE id = 50")
+
+      // Committing against the stale read snapshot must fail with a conflict 
instead of silently
+      // merging with (or dropping) the concurrent deletion vector.
+      val rowIdSchema = StructType(
+        Seq(StructField(FILE_PATH_COLUMN, StringType), 
StructField(ROW_INDEX_COLUMN, LongType)))
+      val batchWrite = new PaimonDeltaBatchWrite(
+        table,
+        new StructType(),
+        rowIdSchema,
+        Snapshot.Operation.DELETE,
+        Some(readSnapshotId))
+      val e = intercept[Throwable](batchWrite.commit(Array(taskResult)))
+      def messages(t: Throwable): Seq[String] =
+        Iterator.iterate(t)(_.getCause).takeWhile(_ != 
null).map(_.toString).toSeq
+      assert(messages(e).exists(_.toLowerCase.contains("conflict")))
+
+      // Only the concurrent DELETE applied; the failed commit is invisible.
+      checkAnswer(sql("SELECT COUNT(*), MIN(id) FROM T"), Row(99, 0))
+    }
+  }
+
+  test("Paimon delta ops: bucketed deletion-vector append table stays on V1") {
+    withSparkSQLConf(v2WriteConf) {
+      createDvAppendTable(", 'bucket-key' = 'id', 'bucket' = '2'")
+      sql("INSERT INTO T VALUES (1, 1), (2, 2), (3, 3)")
+      assert(explainContains("DELETE FROM T WHERE id = 
1").contains("DeleteFromPaimonTable"))
+      sql("DELETE FROM T WHERE id = 1")
+      checkAnswer(sql("SELECT COUNT(*) FROM T"), Row(2))
+
+      val updatePlan = explainContains("UPDATE T SET v = v + 1 WHERE id = 2")
+      assert(!updatePlan.contains("WriteDelta") && 
!updatePlan.contains("ReplaceData"))
+      sql("UPDATE T SET v = v + 1 WHERE id = 2")
+      checkAnswer(sql("SELECT v FROM T WHERE id = 2"), Row(3))
+    }
+  }
+
+  test("Paimon delta ops: append table without deletion vectors keeps 
copy-on-write") {
+    withSparkSQLConf(v2WriteConf) {
+      sql("CREATE TABLE T (id INT, v INT)")
+      sql("INSERT INTO T VALUES (1, 1), (2, 2), (3, 3)")
+      val plan = explainContains("DELETE FROM T WHERE id = 1")
+      assert(plan.contains("ReplaceData") && !plan.contains("WriteDelta"))
+      sql("DELETE FROM T WHERE id = 1")
+      checkAnswer(sql("SELECT COUNT(*) FROM T"), Row(2))
+    }
+  }
+
+  test("Paimon delta ops: falls back to V1 when v2 write is disabled") {
+    withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+      createDvAppendTable()
+      sql("INSERT INTO T VALUES (1, 1), (2, 2)")
+      assert(explainContains("DELETE FROM T WHERE id = 
1").contains("DeleteFromPaimonTable"))
+      sql("DELETE FROM T WHERE id = 1")
+      checkAnswer(sql("SELECT id FROM T"), Row(2))
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonMetricTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonMetricTest.scala
index d2e23d097d..3b67841892 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonMetricTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonMetricTest.scala
@@ -196,6 +196,37 @@ class PaimonMetricTest extends PaimonSparkTestBase with 
ScanPlanHelper {
     }
   }
 
+  test(s"Paimon Metric: delta row-level operation record metrics") {
+    withSparkSQLConf("spark.paimon.write.use-v2-write" -> "true") {
+      sql("CREATE TABLE T (id INT, v INT) TBLPROPERTIES 
('deletion-vectors.enabled' = 'true')")
+      sql(s"INSERT INTO T SELECT /*+ REPARTITION(1) */ id, id as v FROM 
range(0, 10)")
+
+      val deleteDf = sql("DELETE FROM T WHERE id < 3")
+      val deleteMetrics = commandMetrics(deleteDf)
+      val deleteExecutionMetrics = commandExecutionMetrics(deleteMetrics)
+      assert(deleteExecutionMetrics(deleteMetrics("deletedRecords").id) == "3")
+
+      val updateDf = sql("UPDATE T SET v = v + 1 WHERE id >= 8")
+      val updateMetrics = commandMetrics(updateDf)
+      val updateExecutionMetrics = commandExecutionMetrics(updateMetrics)
+      assert(updateExecutionMetrics(updateMetrics("updatedRecords").id) == "2")
+
+      val mergeDf = sql("""
+                          |MERGE INTO T
+                          |USING (SELECT * FROM VALUES (3, 30), (4, 40), (100, 
1) AS s(id, v)) s
+                          |ON T.id = s.id
+                          |WHEN MATCHED AND s.id = 3 THEN UPDATE SET T.v = s.v
+                          |WHEN MATCHED AND s.id = 4 THEN DELETE
+                          |WHEN NOT MATCHED THEN INSERT (id, v) VALUES (s.id, 
s.v)
+                          |""".stripMargin)
+      val mergeMetrics = commandMetrics(mergeDf)
+      val mergeExecutionMetrics = commandExecutionMetrics(mergeMetrics)
+      assert(mergeExecutionMetrics(mergeMetrics("updatedRecords").id) == "1")
+      assert(mergeExecutionMetrics(mergeMetrics("deletedRecords").id) == "1")
+      assert(mergeExecutionMetrics(mergeMetrics("insertedRecords").id) == "1")
+    }
+  }
+
   def metric(metrics: Array[CustomTaskMetric], name: String): Long = {
     metrics.find(_.name() == name).get.value()
   }
diff --git 
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
new file mode 100644
index 0000000000..e2c9250e83
--- /dev/null
+++ 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
@@ -0,0 +1,49 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.table.FileStoreTable
+
+import org.apache.spark.sql.connector.write.{DeltaBatchWrite, 
DeltaWriterFactory, PhysicalWriteInfo, WriterCommitMessage}
+import org.apache.spark.sql.types.StructType
+
+/**
+ * Spark-3.x thin wrapper that mixes `DeltaBatchWrite` into 
[[PaimonDeltaWriteBase]]. See the base
+ * class scaladoc for why the inheritance lives here rather than in 
`paimon-spark-common`.
+ */
+class PaimonDeltaBatchWrite(
+    table: FileStoreTable,
+    rowSchema: StructType,
+    rowIdSchema: StructType,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends PaimonDeltaWriteBase(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+  with DeltaBatchWrite
+  with Serializable {
+
+  override def createBatchWriterFactory(info: PhysicalWriteInfo): 
DeltaWriterFactory =
+    createPaimonDeltaWriterFactory(info)
+
+  override def useCommitCoordinator(): Boolean = false
+
+  override def commit(messages: Array[WriterCommitMessage]): Unit = 
commitMessages(messages)
+
+  override def abort(messages: Array[WriterCommitMessage]): Unit = 
abortMessages(messages)
+}
diff --git 
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
index d4993fdce4..9bde530b28 100644
--- 
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
+++ 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
@@ -25,7 +25,7 @@ import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark3SqlExtensi
 import org.apache.paimon.spark.data.{Spark3ArrayData, Spark3InternalRow, 
Spark3InternalRowWithBlob, SparkArrayData, SparkInternalRow}
 import org.apache.paimon.spark.format.FormatTableBatchWrite
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
-import org.apache.paimon.spark.write.PaimonBatchWrite
+import org.apache.paimon.spark.write.{PaimonBatchWrite, PaimonDeltaBatchWrite}
 import org.apache.paimon.table.{FileStoreTable, FormatTable}
 import org.apache.paimon.types.{DataType, RowType}
 
@@ -206,6 +206,14 @@ class Spark3Shim extends SparkShim {
       copyOnWriteScan,
       operationType)
 
+  override def createPaimonDeltaBatchWrite(
+      table: FileStoreTable,
+      rowSchema: StructType,
+      rowIdSchema: StructType,
+      operationType: Snapshot.Operation,
+      readSnapshotId: Option[Long]): BatchWrite =
+    new PaimonDeltaBatchWrite(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+
   override def createFormatTableBatchWrite(
       table: FormatTable,
       overwriteDynamic: Option[Boolean],
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
new file mode 100644
index 0000000000..4584cfcc30
--- /dev/null
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonDeltaBatchWrite.scala
@@ -0,0 +1,51 @@
+/*
+ * 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.spark.write
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.table.FileStoreTable
+
+import org.apache.spark.sql.connector.write.{DeltaBatchWrite, 
DeltaWriterFactory, PhysicalWriteInfo, WriterCommitMessage}
+import org.apache.spark.sql.types.StructType
+
+/**
+ * Spark-4.x thin wrapper that mixes `DeltaBatchWrite` into 
[[PaimonDeltaWriteBase]]. See the base
+ * class scaladoc for why the inheritance lives here rather than in 
`paimon-spark-common`; this
+ * class is compiled against Spark 4.1 and shadowed in 
`paimon-spark-4.0/src/main` for the 4.0.2
+ * compile target.
+ */
+class PaimonDeltaBatchWrite(
+    table: FileStoreTable,
+    rowSchema: StructType,
+    rowIdSchema: StructType,
+    operationType: Snapshot.Operation,
+    readSnapshotId: Option[Long])
+  extends PaimonDeltaWriteBase(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+  with DeltaBatchWrite
+  with Serializable {
+
+  override def createBatchWriterFactory(info: PhysicalWriteInfo): 
DeltaWriterFactory =
+    createPaimonDeltaWriterFactory(info)
+
+  override def useCommitCoordinator(): Boolean = false
+
+  override def commit(messages: Array[WriterCommitMessage]): Unit = 
commitMessages(messages)
+
+  override def abort(messages: Array[WriterCommitMessage]): Unit = 
abortMessages(messages)
+}
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/PureAppendOnlyScope.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/PureAppendOnlyScope.scala
index 4fdf2bafc0..2f2c3755d4 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/PureAppendOnlyScope.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/PureAppendOnlyScope.scala
@@ -29,10 +29,11 @@ import 
org.apache.spark.sql.execution.datasources.v2.ExtractV2Table
  * ([[Spark41UpdateTableRewrite]] for UPDATE + metadata-only DELETE 
reverse-optimization,
  * [[Spark41MergeIntoRewrite]] for MERGE).
  *
- * These rules only intercept operations against Paimon tables that are valid 
for Spark's V2
- * copy-on-write rewrite: no primary key, data evolution, deletion vectors, or 
fixed-length
- * `CHAR(n)` columns. Row-tracking-only tables are included; tables that 
violate any of these
- * constraints go through Paimon's postHoc V1 commands or Spark's built-in 
analysis path.
+ * These rules intercept operations against Paimon tables that are valid for 
Spark's V2
+ * copy-on-write rewrite (no primary key, data evolution, deletion vectors, or 
fixed-length
+ * `CHAR(n)` columns; row-tracking-only tables are included) or for the 
delta-based rewrite
+ * (unaware-bucket deletion-vector append tables, see 
[[targetsV2DeltaTable]]). Tables that violate
+ * these constraints go through Paimon's postHoc V1 commands or Spark's 
built-in analysis path.
  *
  * Kept as a mix-in trait so the two rewrite objects stay 
single-responsibility (one rule per Spark
  * row-level command, mirroring Spark's own `RewriteUpdateTable` / 
`RewriteMergeIntoTable` layout)
@@ -50,6 +51,19 @@ trait PureAppendOnlyScope {
     }
   }
 
+  /**
+   * Whether the target is a Paimon table on the delta-based row-level path. 
Delegates to
+   * `SparkTable.supportsV2DeltaOps` so the delta gating conditions have a 
single source of truth
+   * (unlike the copy-on-write scope above, the full capability predicate is 
also the correct plan
+   * scope: its extra `useV2Write` / Spark version checks are trivially true 
once the table has
+   * exposed `SupportsRowLevelOperations` on 4.1).
+   */
+  protected def targetsV2DeltaTable(aliasedTable: LogicalPlan): Boolean = {
+    targetsPaimonFileStoreTable(aliasedTable) {
+      case (sparkTable, _) => SparkTable.supportsV2DeltaOps(sparkTable)
+    }
+  }
+
   private def targetsPaimonFileStoreTable(aliasedTable: LogicalPlan)(
       predicate: (SparkTable, FileStoreTable) => Boolean): Boolean = {
     EliminateSubqueryAliases(aliasedTable) match {
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41DeleteMetadataRestore.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41DeleteMetadataRestore.scala
index d21bc8098e..7267b14fc2 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41DeleteMetadataRestore.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41DeleteMetadataRestore.scala
@@ -23,7 +23,7 @@ import 
org.apache.paimon.spark.catalyst.optimizer.OptimizeMetadataOnlyDeleteFrom
 import org.apache.paimon.spark.commands.DeleteFromPaimonTableCommand
 import org.apache.paimon.table.FileStoreTable
 
-import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
LogicalPlan, ReplaceData}
+import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
LogicalPlan, ReplaceData, WriteDelta}
 import org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE
 import org.apache.spark.sql.connector.write.RowLevelOperationTable
 import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
@@ -59,10 +59,42 @@ object Spark41DeleteMetadataRestore extends 
RewriteRowLevelCommand with PureAppe
           val origRelation = 
rd.originalTable.asInstanceOf[DataSourceV2Relation]
           val fs = 
origRelation.table.asInstanceOf[SparkTable].getTable.asInstanceOf[FileStoreTable]
           DeleteFromPaimonTableCommand(origRelation, fs, rd.condition)
+        // The delta-based DELETE form of unaware-bucket deletion-vector 
append tables: restore
+        // metadata-only DELETE to the V1 command for the same truncate fast 
path, instead of
+        // marking every row of the dropped partitions in deletion vectors.
+        case wd: WriteDelta if isMetadataOnlyDeleteOnDvPaimon(wd) =>
+          val origRelation = 
wd.originalTable.asInstanceOf[DataSourceV2Relation]
+          val fs = 
origRelation.table.asInstanceOf[SparkTable].getTable.asInstanceOf[FileStoreTable]
+          DeleteFromPaimonTableCommand(origRelation, fs, wd.condition)
       }
     }
   }
 
+  /** The [[WriteDelta]] counterpart of 
[[isMetadataOnlyDeleteOnAppendOnlyPaimon]]. */
+  private def isMetadataOnlyDeleteOnDvPaimon(wd: WriteDelta): Boolean = {
+    val writeIsDelete = wd.table match {
+      case r: DataSourceV2Relation =>
+        r.table match {
+          case op: RowLevelOperationTable => op.operation.command() == DELETE
+          case _ => false
+        }
+      case _ => false
+    }
+    writeIsDelete && (wd.originalTable match {
+      case r: DataSourceV2Relation if targetsV2DeltaTable(r) =>
+        r.table match {
+          case spk: SparkTable =>
+            spk.getTable match {
+              case fs: FileStoreTable =>
+                
OptimizeMetadataOnlyDeleteFromPaimonTable.isMetadataOnlyDelete(fs, wd.condition)
+              case _ => false
+            }
+          case _ => false
+        }
+      case _ => false
+    })
+  }
+
   /**
    * Whether a `ReplaceData` node (Spark 4.1's post-rewrite DELETE form) 
targets a Paimon table
    * eligible for V2 copy-on-write with a metadata-only predicate, such that 
converting back to
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41MergeIntoRewrite.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41MergeIntoRewrite.scala
index f3dd21f15f..0d012e18f2 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41MergeIntoRewrite.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41MergeIntoRewrite.scala
@@ -25,13 +25,13 @@ import org.apache.spark.sql.{AnalysisException, 
SparkSession}
 import org.apache.spark.sql.catalyst.expressions.{Alias, And, Attribute, 
AttributeReference, Exists, Expression, IsNotNull, Literal, MetadataAttribute, 
MonotonicallyIncreasingID, OuterReference, PredicateHelper, SubqueryExpression}
 import org.apache.spark.sql.catalyst.expressions.Literal.{FalseLiteral, 
TrueLiteral}
 import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
-import org.apache.spark.sql.catalyst.plans.{FullOuter, JoinType, LeftAnti, 
LeftOuter}
-import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
AppendData, DeleteAction, Filter, HintInfo, InsertAction, Join, JoinHint, 
LogicalPlan, MergeAction, MergeIntoTable, MergeRows, 
NO_BROADCAST_AND_REPLICATION, Project, ReplaceData, UpdateAction}
-import org.apache.spark.sql.catalyst.plans.logical.MergeRows.{Copy, Discard, 
Insert, Instruction, Keep, ROW_ID, Update}
+import org.apache.spark.sql.catalyst.plans.{FullOuter, Inner, JoinType, 
LeftAnti, LeftOuter, RightOuter}
+import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
AppendData, DeleteAction, Filter, HintInfo, InsertAction, Join, JoinHint, 
LogicalPlan, MergeAction, MergeIntoTable, MergeRows, 
NO_BROADCAST_AND_REPLICATION, Project, ReplaceData, UpdateAction, WriteDelta}
+import org.apache.spark.sql.catalyst.plans.logical.MergeRows.{Copy, Delete, 
Discard, Insert, Instruction, Keep, ROW_ID, Update}
 import org.apache.spark.sql.catalyst.util.RowDeltaUtils.{OPERATION_COLUMN, 
WRITE_OPERATION, WRITE_WITH_METADATA_OPERATION}
 import org.apache.spark.sql.connector.catalog.SupportsRowLevelOperations
+import org.apache.spark.sql.connector.write.{RowLevelOperationTable, 
SupportsDelta}
 import org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE
-import org.apache.spark.sql.connector.write.RowLevelOperationTable
 import org.apache.spark.sql.errors.QueryCompilationErrors
 import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation, 
ExtractV2Table}
 import org.apache.spark.sql.types.IntegerType
@@ -47,14 +47,16 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
  * already marked analyzed and silently skipped, so the planner rejects it with
  * `UNSUPPORTED_FEATURE.TABLE_OPERATION`. We intercept via `transformDown` 
under
  * `allowInvokingTransformsInAnalyzer` and inline the three 
`ReplaceData`/`AppendData` branches.
- * `SupportsDelta` is omitted — Paimon is copy-on-write only.
+ * Copy-on-write tables mirror the non-delta rewrite; deletion-vector append 
tables mirror the
+ * `SupportsDelta` (`WriteDelta`) rewrite.
  *
  * We fire before `ResolveAssignments`, so `m.aligned` is `false`. The rule 
pre-aligns each action
  * list via `PaimonAssignmentUtils.alignActions` (shared with the postHoc 
`PaimonMergeInto` rule).
  *
- * Row-tracking-only tables use the same V2 copy-on-write rewrite. CHAR 
columns are excluded —
- * `readSidePadding` races with the rewrite and trips CheckAnalysis; those 
plans fall back to the
- * postHoc `PaimonMergeInto` V1 path, which also owns PK / DE / DV tables via
+ * Row-tracking-only tables use the same V2 copy-on-write rewrite; 
unaware-bucket deletion-vector
+ * append tables take the transcribed `WriteDelta` branch (delta-based 
row-level operations). CHAR
+ * columns are excluded — `readSidePadding` races with the rewrite and trips 
CheckAnalysis; those
+ * plans fall back to the postHoc `PaimonMergeInto` V1 path, which also owns 
PK / DE tables via
  * `RowLevelHelper.shouldFallbackToV1MergeInto`.
  */
 object Spark41MergeIntoRewrite
@@ -72,7 +74,7 @@ object Spark41MergeIntoRewrite
       plan.transformDown {
         case m: MergeIntoTable
             if m.resolved && m.rewritable && !m.needSchemaEvolution &&
-              targetsV2CopyOnWriteTable(m.targetTable) =>
+              (targetsV2CopyOnWriteTable(m.targetTable) || 
targetsV2DeltaTable(m.targetTable)) =>
           // Pure append-only tables skip postHoc `PaimonMergeInto`, so evolve 
schema here.
           val evolved = evolveSchemaIfPaimon(m)
           rewrite(alignAllMergeActions(evolved, evolved.targetTable.output))
@@ -112,14 +114,26 @@ object Spark41MergeIntoRewrite
           buildNotMatchedOnlyAppendPlan(relation, source, cond, 
notMatchedActions)
         } else {
           val operationTable = buildOperationTable(tbl, MERGE, 
CaseInsensitiveStringMap.empty())
-          buildReplaceDataPlan(
-            relation,
-            operationTable,
-            source,
-            cond,
-            matchedActions,
-            notMatchedActions,
-            notMatchedBySourceActions)
+          operationTable.operation match {
+            case _: SupportsDelta =>
+              buildWriteDeltaPlan(
+                relation,
+                operationTable,
+                source,
+                cond,
+                matchedActions,
+                notMatchedActions,
+                notMatchedBySourceActions)
+            case _ =>
+              buildReplaceDataPlan(
+                relation,
+                operationTable,
+                source,
+                cond,
+                matchedActions,
+                notMatchedActions,
+                notMatchedBySourceActions)
+          }
         }
       case _ =>
         m
@@ -174,6 +188,149 @@ object Spark41MergeIntoRewrite
     AppendData.byPosition(r, mergeRows)
   }
 
+  // Delta path producing a `WriteDelta` plan for deletion-vector append 
tables. Mirrors Spark
+  // 4.1's `RewriteMergeIntoTable.{buildWriteDeltaPlan, 
buildWriteDeltaMergeRowsPlan,
+  // chooseWriteDeltaJoinType, pushDownTargetPredicates}`; only the non-split 
UPDATE branch is
+  // transcribed because 
`PaimonSparkDeltaOperation.representUpdateAsDeleteAndInsert` is false.
+  private def buildWriteDeltaPlan(
+      relation: DataSourceV2Relation,
+      operationTable: RowLevelOperationTable,
+      source: LogicalPlan,
+      cond: Expression,
+      matchedActions: Seq[MergeAction],
+      notMatchedActions: Seq[MergeAction],
+      notMatchedBySourceActions: Seq[MergeAction]): WriteDelta = {
+
+    val operation = operationTable.operation.asInstanceOf[SupportsDelta]
+    assert(
+      !operation.representUpdateAsDeleteAndInsert,
+      "Paimon delta operations represent UPDATE as a single operation")
+
+    val rowAttrs = relation.output
+    val rowIdAttrs = resolveRowIdAttrs(relation, operation)
+    val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
+
+    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs, rowIdAttrs)
+
+    // if there is no NOT MATCHED BY SOURCE clause, predicates of the ON 
condition that
+    // reference only the target table can be pushed down
+    val (filteredReadRelation, joinCond) = if 
(notMatchedBySourceActions.isEmpty) {
+      pushDownTargetPredicates(readRelation, cond)
+    } else {
+      (readRelation, cond)
+    }
+
+    val checkCardinality = shouldCheckCardinality(matchedActions)
+
+    val joinType = chooseWriteDeltaJoinType(notMatchedActions, 
notMatchedBySourceActions)
+    val joinPlan = join(filteredReadRelation, source, joinType, joinCond, 
checkCardinality)
+
+    val mergeRowsPlan = buildWriteDeltaMergeRowsPlan(
+      readRelation,
+      joinPlan,
+      matchedActions,
+      notMatchedActions,
+      notMatchedBySourceActions,
+      rowIdAttrs,
+      checkCardinality)
+
+    val writeRelation = relation.copy(table = operationTable)
+    val projections = buildWriteDeltaProjections(mergeRowsPlan, rowAttrs, 
rowIdAttrs, metadataAttrs)
+    WriteDelta(writeRelation, cond, mergeRowsPlan, relation, projections)
+  }
+
+  private def chooseWriteDeltaJoinType(
+      notMatchedActions: Seq[MergeAction],
+      notMatchedBySourceActions: Seq[MergeAction]): JoinType = {
+    val unmatchedTargetRowsRequired = notMatchedBySourceActions.nonEmpty
+    val unmatchedSourceRowsRequired = notMatchedActions.nonEmpty
+    if (unmatchedTargetRowsRequired && unmatchedSourceRowsRequired) {
+      FullOuter
+    } else if (unmatchedTargetRowsRequired) {
+      LeftOuter
+    } else if (unmatchedSourceRowsRequired) {
+      RightOuter
+    } else {
+      Inner
+    }
+  }
+
+  private def buildWriteDeltaMergeRowsPlan(
+      targetTable: DataSourceV2Relation,
+      joinPlan: LogicalPlan,
+      matchedActions: Seq[MergeAction],
+      notMatchedActions: Seq[MergeAction],
+      notMatchedBySourceActions: Seq[MergeAction],
+      rowIdAttrs: Seq[Attribute],
+      checkCardinality: Boolean): MergeRows = {
+
+    val (metadataAttrs, rowAttrs) =
+      targetTable.output.partition(attr => 
MetadataAttribute.isValid(attr.metadata))
+
+    // original row ID values must be preserved and passed back to the table 
to encode updates
+    // if there are any assignments to row ID attributes, add extra columns 
for original values
+    val updateAssignments = (matchedActions ++ 
notMatchedBySourceActions).flatMap {
+      case UpdateAction(_, assignments, _) => assignments
+      case _ => Nil
+    }
+    val originalRowIdValues = buildOriginalRowIdValues(rowIdAttrs, 
updateAssignments)
+
+    def toDeltaInstruction(action: MergeAction): Instruction = {
+      action match {
+        case UpdateAction(cond, assignments, _) =>
+          val output = deltaUpdateOutput(assignments, metadataAttrs, 
originalRowIdValues)
+          Keep(Update, cond.getOrElse(TrueLiteral), output)
+        case DeleteAction(cond) =>
+          val output = deltaDeleteOutput(rowAttrs, rowIdAttrs, metadataAttrs, 
originalRowIdValues)
+          Keep(Delete, cond.getOrElse(TrueLiteral), output)
+        case InsertAction(cond, assignments) =>
+          val output = deltaInsertOutput(assignments, metadataAttrs, 
originalRowIdValues)
+          Keep(Insert, cond.getOrElse(TrueLiteral), output)
+        case other =>
+          throw new AnalysisException(
+            errorClass = "_LEGACY_ERROR_TEMP_3052",
+            messageParameters = Map("other" -> other.toString))
+      }
+    }
+
+    val matchedInstructions = matchedActions.map(toDeltaInstruction)
+    val notMatchedInstructions = notMatchedActions.map(toDeltaInstruction)
+    val notMatchedBySourceInstructions = 
notMatchedBySourceActions.map(toDeltaInstruction)
+
+    val rowFromSourceAttr = resolveAttrRef(ROW_FROM_SOURCE, joinPlan)
+    val rowFromTargetAttr = resolveAttrRef(ROW_FROM_TARGET, joinPlan)
+
+    val outputs = matchedInstructions.flatMap(_.outputs) ++
+      notMatchedInstructions.flatMap(_.outputs) ++
+      notMatchedBySourceInstructions.flatMap(_.outputs)
+
+    val operationTypeAttr = AttributeReference(OPERATION_COLUMN, IntegerType, 
nullable = false)()
+    val originalRowIdAttrs = originalRowIdValues.map(_.toAttribute)
+    val attrs = Seq(operationTypeAttr) ++ targetTable.output ++ 
originalRowIdAttrs
+
+    MergeRows(
+      isSourceRowPresent = IsNotNull(rowFromSourceAttr),
+      isTargetRowPresent = IsNotNull(rowFromTargetAttr),
+      matchedInstructions = matchedInstructions,
+      notMatchedInstructions = notMatchedInstructions,
+      notMatchedBySourceInstructions = notMatchedBySourceInstructions,
+      checkCardinality = checkCardinality,
+      output = generateExpandOutput(attrs, outputs),
+      joinPlan
+    )
+  }
+
+  private def pushDownTargetPredicates(
+      targetTable: LogicalPlan,
+      cond: Expression): (LogicalPlan, Expression) = {
+    val predicates = splitConjunctivePredicates(cond)
+    val (targetPredicates, joinPredicates) =
+      predicates.partition(predicate => 
predicate.references.subsetOf(targetTable.outputSet))
+    val targetCond = targetPredicates.reduceOption(And).getOrElse(TrueLiteral)
+    val joinCond = joinPredicates.reduceOption(And).getOrElse(TrueLiteral)
+    (Filter(targetCond, targetTable), joinCond)
+  }
+
   // General path producing a `ReplaceData` plan. Mirrors Spark 4.1.1's
   // `RewriteMergeIntoTable.buildReplaceDataPlan` + 
`buildReplaceDataMergeRowsPlan`.
   private def buildReplaceDataPlan(
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41UpdateTableRewrite.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41UpdateTableRewrite.scala
index 97edbdc780..e669f3a1df 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41UpdateTableRewrite.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/analysis/Spark41UpdateTableRewrite.scala
@@ -20,13 +20,13 @@ package org.apache.spark.sql.catalyst.analysis
 
 import org.apache.paimon.spark.catalyst.analysis.PaimonAssignmentUtils
 
-import org.apache.spark.sql.catalyst.expressions.{Alias, EqualNullSafe, 
Expression, If, Literal, MetadataAttribute, Not, SubqueryExpression}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
EqualNullSafe, Expression, If, Literal, MetadataAttribute, Not, 
SubqueryExpression}
 import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
-import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
Assignment, Filter, LogicalPlan, Project, ReplaceData, Union, UpdateTable}
-import 
org.apache.spark.sql.catalyst.util.RowDeltaUtils.WRITE_WITH_METADATA_OPERATION
+import org.apache.spark.sql.catalyst.plans.logical.{AnalysisHelper, 
Assignment, Filter, LogicalPlan, Project, ReplaceData, Union, UpdateTable, 
WriteDelta}
+import org.apache.spark.sql.catalyst.util.RowDeltaUtils.{OPERATION_COLUMN, 
UPDATE_OPERATION, WRITE_WITH_METADATA_OPERATION}
 import org.apache.spark.sql.connector.catalog.SupportsRowLevelOperations
+import org.apache.spark.sql.connector.write.{RowLevelOperationTable, 
SupportsDelta}
 import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
-import org.apache.spark.sql.connector.write.RowLevelOperationTable
 import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation, 
ExtractV2Table}
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 
@@ -47,9 +47,10 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
  * We fire before `ResolveAssignments`, so `u.aligned` is `false`; the rule 
pre-aligns via
  * `PaimonAssignmentUtils.alignUpdateAssignments` before building the plan.
  *
- * Row-tracking-only tables use the same V2 copy-on-write rewrite. PK / DE / 
DV tables go through
- * the postHoc V1 rule because they do not expose 
`SupportsRowLevelOperations`. DELETE is handled by
- * [[Spark41DeleteMetadataRestore]]; MERGE by [[Spark41MergeIntoRewrite]].
+ * Row-tracking-only tables use the same V2 copy-on-write rewrite; 
unaware-bucket deletion-vector
+ * append tables take the transcribed `WriteDelta` branch (delta-based 
row-level operations). PK /
+ * DE tables go through the postHoc V1 rule because they do not expose 
`SupportsRowLevelOperations`.
+ * DELETE is handled by [[Spark41DeleteMetadataRestore]]; MERGE by 
[[Spark41MergeIntoRewrite]].
  */
 object Spark41UpdateTableRewrite extends RewriteRowLevelCommand with 
PureAppendOnlyScope {
 
@@ -58,7 +59,8 @@ object Spark41UpdateTableRewrite extends 
RewriteRowLevelCommand with PureAppendO
     AnalysisHelper.allowInvokingTransformsInAnalyzer {
       plan.transformDown {
         case u @ UpdateTable(aliasedTable, assignments, cond)
-            if u.resolved && u.rewritable && 
targetsV2CopyOnWriteTable(aliasedTable) =>
+            if u.resolved && u.rewritable &&
+              (targetsV2CopyOnWriteTable(aliasedTable) || 
targetsV2DeltaTable(aliasedTable)) =>
           EliminateSubqueryAliases(aliasedTable) match {
             case r @ ExtractV2Table(tbl: SupportsRowLevelOperations) =>
               val table = buildOperationTable(tbl, UPDATE, 
CaseInsensitiveStringMap.empty())
@@ -70,10 +72,13 @@ object Spark41UpdateTableRewrite extends 
RewriteRowLevelCommand with PureAppendO
                 assignments,
                 fromStar = false,
                 mergeSchemaEnabled = false)
-              if (SubqueryExpression.hasSubquery(updateCond)) {
-                buildReplaceDataWithUnionPlan(r, table, alignedAssignments, 
updateCond)
-              } else {
-                buildReplaceDataPlan(r, table, alignedAssignments, updateCond)
+              table.operation match {
+                case _: SupportsDelta =>
+                  buildWriteDeltaPlan(r, table, alignedAssignments, updateCond)
+                case _ if SubqueryExpression.hasSubquery(updateCond) =>
+                  buildReplaceDataWithUnionPlan(r, table, alignedAssignments, 
updateCond)
+                case _ =>
+                  buildReplaceDataPlan(r, table, alignedAssignments, 
updateCond)
               }
             case _ =>
               u
@@ -82,6 +87,54 @@ object Spark41UpdateTableRewrite extends 
RewriteRowLevelCommand with PureAppendO
     }
   }
 
+  // Mirrors Spark 4.1 `RewriteUpdateTable.buildWriteDeltaPlan` for delta 
tables (Paimon's
+  // `PaimonSparkDeltaOperation` answers `representUpdateAsDeleteAndInsert = 
false`, so only the
+  // single-row UPDATE projection branch is transcribed).
+  private def buildWriteDeltaPlan(
+      relation: DataSourceV2Relation,
+      operationTable: RowLevelOperationTable,
+      assignments: Seq[Assignment],
+      cond: Expression): WriteDelta = {
+    val operation = operationTable.operation.asInstanceOf[SupportsDelta]
+    val rowAttrs = relation.output
+    val rowIdAttrs = resolveRowIdAttrs(relation, operation)
+    val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
+    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs, rowIdAttrs)
+    val matchedRowsPlan = Filter(cond, readRelation)
+    assert(
+      !operation.representUpdateAsDeleteAndInsert,
+      "Paimon delta operations represent UPDATE as a single operation")
+    val rowDeltaPlan = buildWriteDeltaUpdateProjection(matchedRowsPlan, 
assignments, rowIdAttrs)
+    val writeRelation = relation.copy(table = operationTable)
+    val projections = buildWriteDeltaProjections(rowDeltaPlan, rowAttrs, 
rowIdAttrs, metadataAttrs)
+    WriteDelta(writeRelation, cond, rowDeltaPlan, relation, projections)
+  }
+
+  // Mirrors Spark 4.1 `RewriteUpdateTable.buildWriteDeltaUpdateProjection`.
+  private def buildWriteDeltaUpdateProjection(
+      plan: LogicalPlan,
+      assignments: Seq[Assignment],
+      rowIdAttrs: Seq[Attribute]): LogicalPlan = {
+    val assignedValues = assignments.map(_.value)
+    val updatedValues = plan.output.zipWithIndex.map {
+      case (attr, index) =>
+        if (index < assignments.size) {
+          val assignedExpr = assignedValues(index)
+          Alias(assignedExpr, attr.name)()
+        } else {
+          assert(MetadataAttribute.isValid(attr.metadata))
+          if (MetadataAttribute.isPreservedOnUpdate(attr)) {
+            attr
+          } else {
+            Alias(Literal(null, attr.dataType), attr.name)(explicitMetadata = 
Some(attr.metadata))
+          }
+        }
+    }
+    val originalRowIdValues = buildOriginalRowIdValues(rowIdAttrs, assignments)
+    val operationType = Alias(Literal(UPDATE_OPERATION), OPERATION_COLUMN)()
+    Project(Seq(operationType) ++ updatedValues ++ originalRowIdValues, plan)
+  }
+
   // Mirrors Spark 4.1.1 `RewriteUpdateTable.{buildReplaceDataPlan, 
buildReplaceDataWithUnionPlan,
   // buildReplaceDataUpdateProjection}`.
   private def buildReplaceDataPlan(
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
index 00d6024362..7a7cdc70f5 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
@@ -25,7 +25,7 @@ import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark4SqlExtensi
 import org.apache.paimon.spark.data.{Spark4ArrayData, Spark4InternalRow, 
Spark4InternalRowWithBlob, SparkArrayData, SparkInternalRow}
 import org.apache.paimon.spark.format.FormatTableBatchWrite
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
-import org.apache.paimon.spark.write.PaimonBatchWrite
+import org.apache.paimon.spark.write.{PaimonBatchWrite, PaimonDeltaBatchWrite}
 import org.apache.paimon.table.{FileStoreTable, FormatTable}
 import org.apache.paimon.types.{DataType, RowType}
 
@@ -204,6 +204,14 @@ class Spark4Shim extends SparkShim {
       copyOnWriteScan,
       operationType)
 
+  override def createPaimonDeltaBatchWrite(
+      table: FileStoreTable,
+      rowSchema: StructType,
+      rowIdSchema: StructType,
+      operationType: Snapshot.Operation,
+      readSnapshotId: Option[Long]): BatchWrite =
+    new PaimonDeltaBatchWrite(table, rowSchema, rowIdSchema, operationType, 
readSnapshotId)
+
   override def createFormatTableBatchWrite(
       table: FormatTable,
       overwriteDynamic: Option[Boolean],

Reply via email to