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 e2b5eb9d8c [spark] Record the write operation type in snapshot 
properties (#8236)
e2b5eb9d8c is described below

commit e2b5eb9d8cffcd5e86763fba94c7d53c941fcedf
Author: Zouxxyy <[email protected]>
AuthorDate: Tue Jun 23 14:25:04 2026 +0800

    [spark] Record the write operation type in snapshot properties (#8236)
    
    Add a first-class `operation` field (`Snapshot.Operation` enum) to
    `Snapshot`, recording the logical operation type that produced it. This
    complements the physical `CommitKind` (APPEND/COMPACT/OVERWRITE/ANALYZE)
    and lets downstream tooling distinguish, e.g., an APPEND from INSERT vs.
    one from MERGE.
    
    **Design:**
    - `Snapshot.Operation` enum: `WRITE`, `OVERWRITE`, `DELETE`, `TRUNCATE`,
    `UPDATE`, `MERGE`, `CREATE_TABLE_AS_SELECT`, `REPLACE_TABLE_AS_SELECT`,
    `CREATE_OR_REPLACE_TABLE_AS_SELECT`
    - Nullable field with `@JsonInclude(NON_NULL)` — old snapshots
    deserialize as `null`, old readers ignore the unknown field via
    `@JsonIgnoreProperties(ignoreUnknown = true)`
    - `BatchTableCommit.withOperation(Operation)` — `default` method, no
    breaking change for existing implementations
    - `FileStoreCommit.withOperation(Operation)` — internal API, propagated
    to `Snapshot` construction in `FileStoreCommitImpl`
    - `TRUNCATE` is automatically set by
    `TableCommitImpl.truncateTable()`/`truncatePartitions()` in core, so
    callers don't need to handle it
    
    **Spark coverage (both v1 and v2 write paths):**
    
    | SQL | operation |
    | --- | --- |
    | INSERT INTO | `WRITE` |
    | INSERT OVERWRITE | `OVERWRITE` |
    | DELETE (row-level) | `DELETE` |
    | DELETE (full-table / partition) | `TRUNCATE` |
    | TRUNCATE TABLE | `TRUNCATE` |
    | UPDATE | `UPDATE` |
    | MERGE INTO | `MERGE` |
    | CREATE TABLE AS SELECT | `CREATE_TABLE_AS_SELECT` |
    | (CREATE OR) REPLACE TABLE AS SELECT | `REPLACE_TABLE_AS_SELECT` /
    `CREATE_OR_REPLACE_TABLE_AS_SELECT` |
---
 docs/docs/concepts/spec/snapshot.md                |   3 +
 .../src/main/java/org/apache/paimon/Snapshot.java  | 131 ++++++++++++++++++++-
 .../src/main/java/org/apache/paimon/Changelog.java |   9 +-
 .../apache/paimon/operation/FileStoreCommit.java   |   2 +
 .../paimon/operation/FileStoreCommitImpl.java      |  16 ++-
 .../apache/paimon/table/sink/BatchTableCommit.java |   6 +
 .../apache/paimon/table/sink/TableCommitImpl.java  |   9 ++
 .../apache/paimon/table/system/SnapshotsTable.java |   9 +-
 .../src/main/java/org/apache/paimon/tag/Tag.java   |   8 +-
 .../test/java/org/apache/paimon/SnapshotTest.java  |  46 ++++++++
 .../DataEvolutionRowIdReassignerTest.java          |   3 +-
 .../paimon/catalog/RenamingSnapshotCommitTest.java |   1 +
 .../paimon/operation/ExpireSnapshotsTest.java      |   1 +
 .../operation/commit/ConflictDetectionTest.java    |   1 +
 .../paimon/table/system/SnapshotsTableTest.java    |   5 +-
 .../org/apache/paimon/tag/TagAutoManagerTest.java  |   2 +
 .../test/java/org/apache/paimon/tag/TagTest.java   |   1 +
 .../apache/paimon/utils/SnapshotManagerTest.java   |   5 +
 .../shim/PaimonCreateTableAsSelectStrategy.scala   |   7 +-
 .../shim/PaimonCreateTableAsSelectStrategy.scala   |   7 +-
 .../shim/PaimonCreateTableAsSelectStrategy.scala   |   7 +-
 .../shim/PaimonReplaceTableAsSelectStrategy.scala  |   8 +-
 .../MergeIntoPaimonDataEvolutionTable.scala        |   3 +-
 .../spark/commands/MergeIntoPaimonTable.scala      |   3 +-
 .../paimon/spark/write/PaimonBatchWrite.scala      |  23 +++-
 .../apache/spark/sql/paimon/shims/Spark4Shim.scala |  12 +-
 .../commands/DeleteFromPaimonTableCommand.scala    |   3 +-
 .../MergeIntoPaimonDataEvolutionTable.scala        |   3 +-
 .../spark/commands/MergeIntoPaimonTable.scala      |   3 +-
 .../paimon/spark/commands/PaimonSparkWriter.scala  |   7 ++
 .../spark/commands/UpdatePaimonTableCommand.scala  |   3 +-
 .../spark/commands/WriteIntoPaimonTable.scala      |  13 +-
 .../rowops/PaimonSparkCopyOnWriteOperation.scala   |   2 +
 .../paimon/spark/write/PaimonBatchWriteBase.scala  |   7 +-
 .../apache/paimon/spark/write/PaimonV2Write.scala  |   7 +-
 .../paimon/spark/write/PaimonV2WriteBuilder.scala  |  17 ++-
 ...WriteBuilder.scala => PaimonWriteOptions.scala} |  25 +---
 .../shim/PaimonCreateTableAsSelectStrategy.scala   |   8 +-
 .../shim/PaimonReplaceTableAsSelectStrategy.scala  |  11 +-
 .../apache/spark/sql/paimon/shims/SparkShim.scala  |   4 +-
 .../paimon/spark/sql/SnapshotOperationTest.scala   | 110 +++++++++++++++++
 .../paimon/spark/write/PaimonBatchWrite.scala      |  23 +++-
 .../apache/spark/sql/paimon/shims/Spark3Shim.scala |  12 +-
 .../paimon/spark/write/PaimonBatchWrite.scala      |  23 +++-
 .../apache/spark/sql/paimon/shims/Spark4Shim.scala |  12 +-
 45 files changed, 546 insertions(+), 75 deletions(-)

diff --git a/docs/docs/concepts/spec/snapshot.md 
b/docs/docs/concepts/spec/snapshot.md
index f9762b0599..dd2bf28ed2 100644
--- a/docs/docs/concepts/spec/snapshot.md
+++ b/docs/docs/concepts/spec/snapshot.md
@@ -62,3 +62,6 @@ Snapshot File is JSON, it includes:
 15. changelogRecordCount: record count of all changelog produced in this 
snapshot. 
 16. watermark: watermark for input records, from Flink watermark mechanism, 
Long.MIN_VALUE if there is no watermark. 
 17. statistics: stats file name for statistics of this table.
+18. properties: additional key-value properties of this snapshot.
+19. nextRowId: next row id for row tracking.
+20. operation: logical operation type, e.g. WRITE, DELETE, UPDATE, MERGE. Null 
if not set.
diff --git a/paimon-api/src/main/java/org/apache/paimon/Snapshot.java 
b/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
index 93f525a7e3..4a200dfecc 100644
--- a/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
+++ b/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
@@ -69,6 +69,7 @@ public class Snapshot implements Serializable {
     protected static final String FIELD_STATISTICS = "statistics";
     protected static final String FIELD_PROPERTIES = "properties";
     protected static final String FIELD_NEXT_ROW_ID = "nextRowId";
+    protected static final String FIELD_OPERATION = "operation";
 
     // version of snapshot
     @JsonProperty(FIELD_VERSION)
@@ -181,6 +182,11 @@ public class Snapshot implements Serializable {
     @Nullable
     protected final Long nextRowId;
 
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    @JsonProperty(FIELD_OPERATION)
+    @Nullable
+    protected final Operation operation;
+
     public Snapshot(
             long id,
             long schemaId,
@@ -202,6 +208,52 @@ public class Snapshot implements Serializable {
             @Nullable String statistics,
             @Nullable Map<String, String> properties,
             @Nullable Long nextRowId) {
+        this(
+                id,
+                schemaId,
+                baseManifestList,
+                baseManifestListSize,
+                deltaManifestList,
+                deltaManifestListSize,
+                changelogManifestList,
+                changelogManifestListSize,
+                indexManifest,
+                commitUser,
+                commitIdentifier,
+                commitKind,
+                timeMillis,
+                totalRecordCount,
+                deltaRecordCount,
+                changelogRecordCount,
+                watermark,
+                statistics,
+                properties,
+                nextRowId,
+                null);
+    }
+
+    public Snapshot(
+            long id,
+            long schemaId,
+            String baseManifestList,
+            @Nullable Long baseManifestListSize,
+            String deltaManifestList,
+            @Nullable Long deltaManifestListSize,
+            @Nullable String changelogManifestList,
+            @Nullable Long changelogManifestListSize,
+            @Nullable String indexManifest,
+            String commitUser,
+            long commitIdentifier,
+            CommitKind commitKind,
+            long timeMillis,
+            long totalRecordCount,
+            long deltaRecordCount,
+            @Nullable Long changelogRecordCount,
+            @Nullable Long watermark,
+            @Nullable String statistics,
+            @Nullable Map<String, String> properties,
+            @Nullable Long nextRowId,
+            @Nullable Operation operation) {
         this(
                 CURRENT_VERSION,
                 id,
@@ -223,7 +275,55 @@ public class Snapshot implements Serializable {
                 watermark,
                 statistics,
                 properties,
-                nextRowId);
+                nextRowId,
+                operation);
+    }
+
+    public Snapshot(
+            int version,
+            long id,
+            long schemaId,
+            String baseManifestList,
+            @Nullable Long baseManifestListSize,
+            String deltaManifestList,
+            @Nullable Long deltaManifestListSize,
+            @Nullable String changelogManifestList,
+            @Nullable Long changelogManifestListSize,
+            @Nullable String indexManifest,
+            String commitUser,
+            long commitIdentifier,
+            CommitKind commitKind,
+            long timeMillis,
+            long totalRecordCount,
+            long deltaRecordCount,
+            @Nullable Long changelogRecordCount,
+            @Nullable Long watermark,
+            @Nullable String statistics,
+            @Nullable Map<String, String> properties,
+            @Nullable Long nextRowId) {
+        this(
+                version,
+                id,
+                schemaId,
+                baseManifestList,
+                baseManifestListSize,
+                deltaManifestList,
+                deltaManifestListSize,
+                changelogManifestList,
+                changelogManifestListSize,
+                indexManifest,
+                commitUser,
+                commitIdentifier,
+                commitKind,
+                timeMillis,
+                totalRecordCount,
+                deltaRecordCount,
+                changelogRecordCount,
+                watermark,
+                statistics,
+                properties,
+                nextRowId,
+                null);
     }
 
     @JsonCreator
@@ -249,7 +349,8 @@ public class Snapshot implements Serializable {
             @JsonProperty(FIELD_WATERMARK) @Nullable Long watermark,
             @JsonProperty(FIELD_STATISTICS) @Nullable String statistics,
             @JsonProperty(FIELD_PROPERTIES) @Nullable Map<String, String> 
properties,
-            @JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId) {
+            @JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId,
+            @JsonProperty(FIELD_OPERATION) @Nullable Operation operation) {
         this.version = version;
         this.id = id;
         this.schemaId = schemaId;
@@ -271,6 +372,7 @@ public class Snapshot implements Serializable {
         this.statistics = statistics;
         this.properties = properties;
         this.nextRowId = nextRowId;
+        this.operation = operation;
     }
 
     @JsonGetter(FIELD_VERSION)
@@ -388,6 +490,12 @@ public class Snapshot implements Serializable {
         return nextRowId;
     }
 
+    @JsonGetter(FIELD_OPERATION)
+    @Nullable
+    public Operation operation() {
+        return operation;
+    }
+
     public String toJson() {
         return JsonSerdeUtil.toJson(this);
     }
@@ -415,7 +523,8 @@ public class Snapshot implements Serializable {
                 watermark,
                 statistics,
                 properties,
-                nextRowId);
+                nextRowId,
+                operation);
     }
 
     @Override
@@ -447,7 +556,8 @@ public class Snapshot implements Serializable {
                 && Objects.equals(watermark, that.watermark)
                 && Objects.equals(statistics, that.statistics)
                 && Objects.equals(properties, that.properties)
-                && Objects.equals(nextRowId, that.nextRowId);
+                && Objects.equals(nextRowId, that.nextRowId)
+                && operation == that.operation;
     }
 
     /** Type of changes in this snapshot. */
@@ -469,6 +579,19 @@ public class Snapshot implements Serializable {
         ANALYZE
     }
 
+    /** Logical operation type that produced this snapshot. */
+    public enum Operation {
+        WRITE,
+        OVERWRITE,
+        DELETE,
+        TRUNCATE,
+        UPDATE,
+        MERGE,
+        CREATE_TABLE_AS_SELECT,
+        REPLACE_TABLE_AS_SELECT,
+        CREATE_OR_REPLACE_TABLE_AS_SELECT
+    }
+
     // =================== Utils for reading =========================
 
     public static Snapshot fromJson(String json) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/Changelog.java 
b/paimon-core/src/main/java/org/apache/paimon/Changelog.java
index 0ab3429dfc..05b76d7153 100644
--- a/paimon-core/src/main/java/org/apache/paimon/Changelog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/Changelog.java
@@ -63,7 +63,8 @@ public class Changelog extends Snapshot {
                 snapshot.watermark(),
                 snapshot.statistics(),
                 snapshot.properties,
-                snapshot.nextRowId);
+                snapshot.nextRowId,
+                snapshot.operation);
     }
 
     @JsonCreator
@@ -89,7 +90,8 @@ public class Changelog extends Snapshot {
             @JsonProperty(FIELD_WATERMARK) @Nullable Long watermark,
             @JsonProperty(FIELD_STATISTICS) @Nullable String statistics,
             @JsonProperty(FIELD_PROPERTIES) Map<String, String> properties,
-            @JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId) {
+            @JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId,
+            @JsonProperty(FIELD_OPERATION) @Nullable Operation operation) {
         super(
                 version,
                 id,
@@ -111,7 +113,8 @@ public class Changelog extends Snapshot {
                 watermark,
                 statistics,
                 properties,
-                nextRowId);
+                nextRowId,
+                operation);
     }
 
     public static Changelog fromJson(String json) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
index 31fb3c52ca..dbd316d8fa 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
@@ -42,6 +42,8 @@ public interface FileStoreCommit extends AutoCloseable {
 
     FileStoreCommit rowIdCheckConflict(@Nullable Long rowIdCheckFromSnapshot);
 
+    FileStoreCommit withOperation(Snapshot.Operation operation);
+
     /** Find out which committables need to be retried when recovering from 
the failure. */
     List<ManifestCommittable> filterCommitted(List<ManifestCommittable> 
committables);
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 47442f1b78..b97b12f74e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -163,6 +163,7 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
     private CommitMetrics commitMetrics;
     private boolean appendCommitCheckConflict = false;
     private long lastCommittedSnapshotId = -1L;
+    @Nullable private Snapshot.Operation operation;
 
     public FileStoreCommitImpl(
             SnapshotCommit snapshotCommit,
@@ -251,6 +252,12 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return this;
     }
 
+    @Override
+    public FileStoreCommit withOperation(Snapshot.Operation operation) {
+        this.operation = operation;
+        return this;
+    }
+
     @Override
     public List<ManifestCommittable> filterCommitted(List<ManifestCommittable> 
committables) {
         // nothing to filter, fast exit
@@ -1107,7 +1114,8 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                             statsFileName,
                             // if empty properties, just set to null
                             properties.isEmpty() ? null : properties,
-                            nextRowIdStart);
+                            nextRowIdStart,
+                            operation);
         } catch (Throwable e) {
             // fails when preparing for commit, we should clean up
             commitCleaner.cleanUpReuseTmpManifests(
@@ -1261,7 +1269,8 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         latest.statistics(),
                         // if empty properties, just set to null
                         latest.properties(),
-                        nextRowId);
+                        nextRowId,
+                        null);
 
         return commitSnapshotImpl(newSnapshot, emptyList());
     }
@@ -1340,7 +1349,8 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         latestSnapshot.watermark(),
                         latestSnapshot.statistics(),
                         latestSnapshot.properties(),
-                        latestSnapshot.nextRowId());
+                        latestSnapshot.nextRowId(),
+                        null);
 
         return commitSnapshotImpl(newSnapshot, emptyList());
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
index 84215980d5..2cfc181ead 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.table.sink;
 
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.Snapshot.CommitKind;
 import org.apache.paimon.annotation.Public;
 import org.apache.paimon.stats.Statistics;
@@ -71,4 +72,9 @@ public interface BatchTableCommit extends TableCommit {
 
     /** Compact the manifest entries. Generates a snapshot with {@link 
CommitKind#COMPACT}. */
     void compactManifests();
+
+    /** Set the logical operation type (e.g. WRITE, DELETE, MERGE) recorded in 
the snapshot. */
+    default BatchTableCommit withOperation(Snapshot.Operation operation) {
+        return this;
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
index 0311c9bbe4..6a9e3046cc 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.table.sink;
 
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.consumer.ConsumerManager;
 import org.apache.paimon.fs.Path;
@@ -170,6 +171,12 @@ public class TableCommitImpl implements InnerTableCommit {
         return this;
     }
 
+    @Override
+    public TableCommitImpl withOperation(Snapshot.Operation operation) {
+        commit.withOperation(operation);
+        return this;
+    }
+
     @Override
     public InnerTableCommit withMetricRegistry(MetricRegistry registry) {
         commit.withMetrics(new CommitMetrics(registry, tableName));
@@ -185,11 +192,13 @@ public class TableCommitImpl implements InnerTableCommit {
     @Override
     public void truncateTable() {
         checkCommitted();
+        commit.withOperation(Snapshot.Operation.TRUNCATE);
         commit.truncateTable(COMMIT_IDENTIFIER);
     }
 
     @Override
     public void truncatePartitions(List<Map<String, String>> partitionSpecs) {
+        commit.withOperation(Snapshot.Operation.TRUNCATE);
         commit.dropPartitions(partitionSpecs, COMMIT_IDENTIFIER);
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
index 394dc4d58a..201192fde6 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
@@ -108,7 +108,9 @@ public class SnapshotsTable implements ReadonlyTable {
                             new DataField(10, "delta_record_count", new 
BigIntType(true)),
                             new DataField(11, "changelog_record_count", new 
BigIntType(true)),
                             new DataField(12, "watermark", new 
BigIntType(true)),
-                            new DataField(13, "next_row_id", new 
BigIntType(true))));
+                            new DataField(13, "next_row_id", new 
BigIntType(true)),
+                            new DataField(
+                                    14, "operation", 
SerializationUtils.newStringType(true))));
 
     private final FileIO fileIO;
     private final Path location;
@@ -339,7 +341,10 @@ public class SnapshotsTable implements ReadonlyTable {
                     snapshot.deltaRecordCount(),
                     snapshot.changelogRecordCount(),
                     snapshot.watermark(),
-                    snapshot.nextRowId());
+                    snapshot.nextRowId(),
+                    snapshot.operation() == null
+                            ? null
+                            : 
BinaryString.fromString(snapshot.operation().toString()));
         }
     }
 }
diff --git a/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java 
b/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
index 192df2d470..22d86b6fca 100644
--- a/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
+++ b/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
@@ -80,6 +80,7 @@ public class Tag extends Snapshot {
             @JsonProperty(FIELD_STATISTICS) @Nullable String statistics,
             @JsonProperty(FIELD_PROPERTIES) Map<String, String> properties,
             @JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId,
+            @JsonProperty(FIELD_OPERATION) @Nullable Operation operation,
             @JsonProperty(FIELD_TAG_CREATE_TIME) @Nullable LocalDateTime 
tagCreateTime,
             @JsonProperty(FIELD_TAG_TIME_RETAINED) @Nullable Duration 
tagTimeRetained) {
         super(
@@ -103,7 +104,8 @@ public class Tag extends Snapshot {
                 watermark,
                 statistics,
                 properties,
-                nextRowId);
+                nextRowId,
+                operation);
         this.tagCreateTime = tagCreateTime;
         this.tagTimeRetained = tagTimeRetained;
     }
@@ -142,6 +144,7 @@ public class Tag extends Snapshot {
                 snapshot.statistics(),
                 snapshot.properties(),
                 snapshot.nextRowId(),
+                snapshot.operation(),
                 tagCreateTime,
                 tagTimeRetained);
     }
@@ -168,7 +171,8 @@ public class Tag extends Snapshot {
                 watermark,
                 statistics,
                 properties,
-                nextRowId);
+                nextRowId,
+                operation);
     }
 
     @Override
diff --git a/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java 
b/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
index 0cd75f3528..3a035c8a33 100644
--- a/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
@@ -78,6 +78,52 @@ public class SnapshotTest {
         assertThat(Snapshot.fromJson(snapshot.toJson())).isEqualTo(snapshot);
     }
 
+    @Test
+    public void testSnapshotWithOperation() {
+        // Old snapshot without operation field: operation should be null
+        String oldJson =
+                "{\n"
+                        + "  \"version\" : 3,\n"
+                        + "  \"id\" : 1,\n"
+                        + "  \"schemaId\" : 0,\n"
+                        + "  \"baseManifestList\" : \"m-0\",\n"
+                        + "  \"deltaManifestList\" : \"m-1\",\n"
+                        + "  \"commitUser\" : \"user\",\n"
+                        + "  \"commitIdentifier\" : 0,\n"
+                        + "  \"commitKind\" : \"APPEND\",\n"
+                        + "  \"timeMillis\" : 1000,\n"
+                        + "  \"totalRecordCount\" : 10,\n"
+                        + "  \"deltaRecordCount\" : 5\n"
+                        + "}";
+        Snapshot old = Snapshot.fromJson(oldJson);
+        assertThat(old.operation()).isNull();
+
+        // New snapshot with operation field
+        String newJson =
+                "{\n"
+                        + "  \"version\" : 3,\n"
+                        + "  \"id\" : 2,\n"
+                        + "  \"schemaId\" : 0,\n"
+                        + "  \"baseManifestList\" : \"m-0\",\n"
+                        + "  \"deltaManifestList\" : \"m-1\",\n"
+                        + "  \"commitUser\" : \"user\",\n"
+                        + "  \"commitIdentifier\" : 1,\n"
+                        + "  \"commitKind\" : \"APPEND\",\n"
+                        + "  \"timeMillis\" : 2000,\n"
+                        + "  \"totalRecordCount\" : 20,\n"
+                        + "  \"deltaRecordCount\" : 10,\n"
+                        + "  \"operation\" : \"MERGE\"\n"
+                        + "}";
+        Snapshot withOp = Snapshot.fromJson(newJson);
+        assertThat(withOp.operation()).isEqualTo(Snapshot.Operation.MERGE);
+
+        // Round-trip: toJson -> fromJson preserves operation
+        assertThat(Snapshot.fromJson(withOp.toJson())).isEqualTo(withOp);
+
+        // Null operation is omitted in JSON
+        assertThat(old.toJson()).doesNotContain("operation");
+    }
+
     public static SnapshotManager newSnapshotManager(FileIO fileIO, Path 
tablePath) {
         return newSnapshotManager(fileIO, tablePath, DEFAULT_MAIN_BRANCH);
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index b17c912aaf..ffdc12539a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -993,7 +993,8 @@ public class DataEvolutionRowIdReassignerTest extends 
TableTestBase {
                         latest.watermark(),
                         latest.statistics(),
                         latest.properties(),
-                        latest.nextRowId());
+                        latest.nextRowId(),
+                        latest.operation());
         SnapshotManager snapshotManager = table.snapshotManager();
         snapshotManager
                 .fileIO()
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
index 6e8ae6f36a..0924b94203 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
@@ -137,6 +137,7 @@ public class RenamingSnapshotCommitTest {
                 null,
                 null,
                 null,
+                null,
                 null);
     }
 
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
index 1da28a5bef..c4de0f44e1 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
@@ -420,6 +420,7 @@ public class ExpireSnapshotsTest {
                 null,
                 null,
                 null,
+                null,
                 null);
     }
 
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
index c3371e1c19..0695406bde 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
@@ -740,6 +740,7 @@ class ConflictDetectionTest {
                 null,
                 null,
                 null,
+                null,
                 null);
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
index 7a3bbf961c..c1a6b6f714 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
@@ -118,7 +118,10 @@ public class SnapshotsTableTest extends TableTestBase {
                             snapshot.deltaRecordCount(),
                             snapshot.changelogRecordCount(),
                             snapshot.watermark(),
-                            snapshot.nextRowId()));
+                            snapshot.nextRowId(),
+                            snapshot.operation() == null
+                                    ? null
+                                    : 
BinaryString.fromString(snapshot.operation().toString())));
         }
 
         return expectedRow;
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
index ef0e1627ee..d262110f03 100644
--- a/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
@@ -410,6 +410,7 @@ public class TagAutoManagerTest extends 
PrimaryKeyTableTestBase {
                         null,
                         null,
                         null,
+                        null,
                         null);
         tagManager.createTag(
                 snapshot1,
@@ -439,6 +440,7 @@ public class TagAutoManagerTest extends 
PrimaryKeyTableTestBase {
                         null,
                         null,
                         null,
+                        null,
                         null);
         tagManager.createTag(
                 snapshot2,
diff --git a/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java 
b/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
index d7dbeacb7f..436e86c211 100644
--- a/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
@@ -51,6 +51,7 @@ public class TagTest {
                     null,
                     null,
                     null,
+                    null,
                     null);
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
index 8e47b74aa3..02b21ba906 100644
--- a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
@@ -293,6 +293,7 @@ public class SnapshotManagerTest {
                 null,
                 null,
                 null,
+                null,
                 null);
     }
 
@@ -317,6 +318,7 @@ public class SnapshotManagerTest {
                 watermark,
                 null,
                 null,
+                null,
                 null);
     }
 
@@ -342,6 +344,7 @@ public class SnapshotManagerTest {
                         null,
                         null,
                         null,
+                        null,
                         null));
     }
 
@@ -373,6 +376,7 @@ public class SnapshotManagerTest {
                             null,
                             null,
                             null,
+                            null,
                             null);
             localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
         }
@@ -426,6 +430,7 @@ public class SnapshotManagerTest {
                             null,
                             null,
                             null,
+                            null,
                             null);
             localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
         }
diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
index 44095b7d2e..4aa8fb7840 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
@@ -18,8 +18,10 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkCatalog
 import org.apache.paimon.spark.catalog.FormatTableCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.{SparkSession, Strategy}
 import org.apache.spark.sql.catalyst.plans.logical.{CreateTableAsSelect, 
LogicalPlan}
@@ -66,7 +68,10 @@ case class PaimonCreateTableAsSelectStrategy(spark: 
SparkSession) extends Strate
         query,
         planLater(query),
         newProps,
-        new CaseInsensitiveStringMap(writeOptions.asJava),
+        new CaseInsensitiveStringMap(
+          (writeOptions +
+            (PaimonWriteOptions.OPERATION_OPTION -> 
Snapshot.Operation.CREATE_TABLE_AS_SELECT
+              .name())).asJava),
         ifNotExists
       ) :: Nil
     case _ => Nil
diff --git 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
index ca10cb259f..eb3e044459 100644
--- 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
@@ -18,8 +18,10 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkCatalog
 import org.apache.paimon.spark.catalog.FormatTableCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.{SparkSession, Strategy}
 import org.apache.spark.sql.catalyst.analysis.ResolvedDBObjectName
@@ -69,7 +71,10 @@ case class PaimonCreateTableAsSelectStrategy(spark: 
SparkSession)
         query,
         planLater(query),
         qualifiedSpec,
-        new CaseInsensitiveStringMap(writeOptions.asJava),
+        new CaseInsensitiveStringMap(
+          (writeOptions +
+            (PaimonWriteOptions.OPERATION_OPTION -> 
Snapshot.Operation.CREATE_TABLE_AS_SELECT
+              .name())).asJava),
         ifNotExists
       ) :: Nil
     case _ => Nil
diff --git 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
index be377c17d1..0e0f3037d2 100644
--- 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
@@ -18,8 +18,10 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkCatalog
 import org.apache.paimon.spark.catalog.FormatTableCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.{SparkSession, Strategy}
 import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
@@ -71,7 +73,10 @@ case class PaimonCreateTableAsSelectStrategy(spark: 
SparkSession)
         analyzedQuery.get,
         planLater(query),
         qualifiedSpec,
-        new CaseInsensitiveStringMap(writeOptions.asJava),
+        new CaseInsensitiveStringMap(
+          (writeOptions +
+            (PaimonWriteOptions.OPERATION_OPTION -> 
Snapshot.Operation.CREATE_TABLE_AS_SELECT
+              .name())).asJava),
         ifNotExists
       ) :: Nil
     case _ => Nil
diff --git 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
index ec4c0498e1..ee9064dcc3 100644
--- 
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
@@ -18,7 +18,9 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.catalog.SparkBaseCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.{SparkSession, Strategy}
 import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
@@ -62,7 +64,11 @@ case class PaimonReplaceTableAsSelectStrategy(spark: 
SparkSession)
       val (tableOptions, writeOptions) =
         splitTableAndWriteOptions(options)
       val qualifiedSpec = qualifyTableSpec(tableSpec, tableOptions)
-      val writeOpts = new CaseInsensitiveStringMap(writeOptions.asJava)
+      val operation =
+        if (orCreate) Snapshot.Operation.CREATE_OR_REPLACE_TABLE_AS_SELECT
+        else Snapshot.Operation.REPLACE_TABLE_AS_SELECT
+      val writeOpts = new CaseInsensitiveStringMap(
+        (writeOptions + (PaimonWriteOptions.OPERATION_OPTION -> 
operation.name())).asJava)
       val pinnedQuery =
         pinSnapshotInQuery(catalog, ident, analyzedQuery.get)
       if (canAtomicReplace(catalog, ident, qualifiedSpec, parts)) {
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index 4e5080c0d5..c14ee04648 100644
--- 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.commands
 
 import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
@@ -236,7 +237,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       if (plan.snapshotId() != null) {
         writer.rowIdCheckConflict(plan.snapshotId())
       }
-      writer.commit(updateCommit ++ insertCommit)
+      writer.commit(updateCommit ++ insertCommit, Snapshot.Operation.MERGE)
     } finally {
       if (persistSourceDss.isDefined) {
         persistSourceDss.get.unpersist(blocking = false)
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
index 89689e108c..c24d4d6675 100644
--- 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.commands
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkTable
 import org.apache.paimon.spark.catalyst.analysis.{PaimonRelation, 
PaimonUpdateAction}
 import org.apache.paimon.spark.schema.{PaimonMetadataColumn, 
SparkSystemColumns}
@@ -78,7 +79,7 @@ case class MergeIntoPaimonTable(
     } else {
       performMergeForNonPkTable(sparkSession)
     }
-    writer.commit(commitMessages)
+    writer.commit(commitMessages, Snapshot.Operation.MERGE)
     Seq.empty[Row]
   }
 
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
index c78f783924..6a5a141f08 100644
--- 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.write
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
 import org.apache.paimon.table.FileStoreTable
 
@@ -36,8 +37,15 @@ class PaimonBatchWrite(
     writeSchema: StructType,
     dataSchema: StructType,
     overwritePartitions: Option[Map[String, String]],
-    copyOnWriteScan: Option[PaimonCopyOnWriteScan])
-  extends PaimonBatchWriteBase(table, writeSchema, dataSchema, 
overwritePartitions, copyOnWriteScan)
+    copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+    operationType: Option[Snapshot.Operation] = None)
+  extends PaimonBatchWriteBase(
+    table,
+    writeSchema,
+    dataSchema,
+    overwritePartitions,
+    copyOnWriteScan,
+    operationType)
   with BatchWrite
   with Serializable {
 
@@ -57,6 +65,13 @@ object PaimonBatchWrite {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): PaimonBatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation] = None): PaimonBatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 }
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 9e97adc96e..b92411d8bd 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
@@ -18,6 +18,7 @@
 
 package org.apache.spark.sql.paimon.shims
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.variant.{GenericVariant, Variant}
 import org.apache.paimon.spark.catalyst.analysis.Spark4ResolutionRules
 import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark4SqlExtensionsParser
@@ -209,8 +210,15 @@ class Spark4Shim extends SparkShim {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): BatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation]): BatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 
   override def createFormatTableBatchWrite(
       table: FormatTable,
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DeleteFromPaimonTableCommand.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DeleteFromPaimonTableCommand.scala
index d052d35471..cd29efa152 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DeleteFromPaimonTableCommand.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DeleteFromPaimonTableCommand.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.commands
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
 import org.apache.paimon.spark.schema.SparkSystemColumns.ROW_KIND_COL
 import org.apache.paimon.table.FileStoreTable
@@ -46,7 +47,7 @@ case class DeleteFromPaimonTableCommand(
     } else {
       performNonPrimaryKeyDelete(sparkSession)
     }
-    writer.commit(commitMessages)
+    writer.commit(commitMessages, Snapshot.Operation.DELETE)
     Seq.empty[Row]
   }
 
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index 321eb6f147..7464176c87 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.commands
 
 import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
@@ -235,7 +236,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       if (plan.snapshotId() != null) {
         writer.rowIdCheckConflict(plan.snapshotId())
       }
-      writer.commit(updateCommit ++ insertCommit)
+      writer.commit(updateCommit ++ insertCommit, Snapshot.Operation.MERGE)
     } finally {
       if (persistSourceDss.isDefined) {
         persistSourceDss.get.unpersist(blocking = false)
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
index 89689e108c..c24d4d6675 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonTable.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.commands
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkTable
 import org.apache.paimon.spark.catalyst.analysis.{PaimonRelation, 
PaimonUpdateAction}
 import org.apache.paimon.spark.schema.{PaimonMetadataColumn, 
SparkSystemColumns}
@@ -78,7 +79,7 @@ case class MergeIntoPaimonTable(
     } else {
       performMergeForNonPkTable(sparkSession)
     }
-    writer.commit(commitMessages)
+    writer.commit(commitMessages, Snapshot.Operation.MERGE)
     Seq.empty[Row]
   }
 
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
index 8e310dc9e0..92df5a7f06 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
@@ -414,6 +414,10 @@ case class PaimonSparkWriter(
   }
 
   def commit(commitMessages: Seq[CommitMessage]): Unit = {
+    commit(commitMessages, null)
+  }
+
+  def commit(commitMessages: Seq[CommitMessage], operation: 
Snapshot.Operation): Unit = {
     val finalWriteBuilder = if (postponeBatchWriteFixedBucket) {
       writeBuilder
         .asInstanceOf[BatchWriteBuilderImpl]
@@ -424,6 +428,9 @@ case class PaimonSparkWriter(
       writeBuilder
     }
     val tableCommit = finalWriteBuilder.newCommit()
+    if (operation != null) {
+      tableCommit.withOperation(operation)
+    }
     try {
       tableCommit.commit(commitMessages.toList.asJava)
     } catch {
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonTableCommand.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonTableCommand.scala
index 1feb0f1f76..1f1622fe2b 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonTableCommand.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonTableCommand.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.commands
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.schema.PaimonMetadataColumn.{ROW_ID_COLUMN, 
SEQUENCE_NUMBER_COLUMN}
 import org.apache.paimon.spark.schema.SparkSystemColumns.ROW_KIND_COL
 import org.apache.paimon.table.FileStoreTable
@@ -52,7 +53,7 @@ case class UpdatePaimonTableCommand(
     } else {
       performUpdateForNonPkTable(sparkSession)
     }
-    writer.commit(commitMessages)
+    writer.commit(commitMessages, Snapshot.Operation.UPDATE)
 
     Seq.empty[Row]
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
index fcf061ec67..f400409fda 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
@@ -19,10 +19,12 @@
 package org.apache.paimon.spark.commands
 
 import org.apache.paimon.CoreOptions.DYNAMIC_PARTITION_OVERWRITE
+import org.apache.paimon.Snapshot
 import org.apache.paimon.options.Options
 import org.apache.paimon.spark._
 import org.apache.paimon.spark.catalyst.analysis.ReplacePaimonFunctions
 import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
+import org.apache.paimon.spark.write.PaimonWriteOptions
 import org.apache.paimon.table.FileStoreTable
 
 import org.apache.spark.internal.Logging
@@ -60,8 +62,17 @@ case class WriteIntoPaimonTable(
     if (overwritePartition != null) {
       writer.writeBuilder.withOverwrite(overwritePartition.asJava)
     }
+    val operation = Option(options.get(PaimonWriteOptions.OPERATION_OPTION))
+      .map(Snapshot.Operation.valueOf)
+      .getOrElse {
+        if (overwritePartition != null || dynamicPartitionOverwriteMode) {
+          Snapshot.Operation.OVERWRITE
+        } else {
+          Snapshot.Operation.WRITE
+        }
+      }
     val commitMessages = writer.write(replacedData)
-    writer.commit(commitMessages)
+    writer.commit(commitMessages, operation)
 
     Seq.empty
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkCopyOnWriteOperation.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkCopyOnWriteOperation.scala
index 24c3c19761..5db630bec7 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkCopyOnWriteOperation.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/rowops/PaimonSparkCopyOnWriteOperation.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.rowops
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.options.Options
 import org.apache.paimon.spark.PaimonBaseScanBuilder
 import org.apache.paimon.spark.schema.PaimonMetadataColumn.{FILE_PATH_COLUMN, 
ROW_ID_COLUMN, SEQUENCE_NUMBER_COLUMN}
@@ -52,6 +53,7 @@ class PaimonSparkCopyOnWriteOperation(table: FileStoreTable, 
info: RowLevelOpera
   override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
     val options = Options.fromMap(info.options)
     val builder = new PaimonV2WriteBuilder(table, info.schema(), options)
+    builder.withOperationType(Snapshot.Operation.valueOf(command().toString))
     assert(copyOnWriteScan.isDefined)
     builder.overwriteFiles(copyOnWriteScan.get)
   }
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 42d2ebcd85..e925cad76b 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
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.write
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
 import org.apache.paimon.spark.SparkTypeUtils
 import org.apache.paimon.spark.catalyst.Compatibility
@@ -55,7 +56,8 @@ abstract class PaimonBatchWriteBase(
     val writeSchema: StructType,
     val dataSchema: StructType,
     val overwritePartitions: Option[Map[String, String]],
-    val copyOnWriteScan: Option[PaimonCopyOnWriteScan])
+    val copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+    operationType: Option[Snapshot.Operation] = None)
   extends WriteHelper
   with Serializable {
 
@@ -114,6 +116,9 @@ abstract class PaimonBatchWriteBase(
     logInfo(s"Committing to table ${table.name()}")
     val batchTableCommit = batchWriteBuilder.newCommit()
     batchTableCommit.withMetricRegistry(metricRegistry)
+    val operation = operationType.getOrElse(
+      if (overwritePartitions.isDefined) Snapshot.Operation.OVERWRITE else 
Snapshot.Operation.WRITE)
+    batchTableCommit.withOperation(operation)
     val addCommitMessage = WriteTaskResult.merge(messages)
     val deletedCommitMessage = copyOnWriteScan match {
       case Some(scan) => buildDeletedCommitMessage(scan.scannedFiles)
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2Write.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2Write.scala
index 2ae1dd53a3..6dd02172a6 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2Write.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2Write.scala
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.write
 
 import org.apache.paimon.CoreOptions.ChangelogProducer
+import org.apache.paimon.Snapshot
 import org.apache.paimon.options.Options
 import org.apache.paimon.spark._
 import org.apache.paimon.spark.commands.SchemaEvolutionHelper
@@ -41,7 +42,8 @@ class PaimonV2Write(
     overwritePartitions: Option[Map[String, String]],
     copyOnWriteScan: Option[PaimonCopyOnWriteScan],
     dataSchema: StructType,
-    options: Options
+    options: Options,
+    operationType: Option[Snapshot.Operation] = None
 ) extends Write
   with RequiresDistributionAndOrdering
   with SchemaEvolutionHelper
@@ -69,7 +71,8 @@ class PaimonV2Write(
       writeSchema,
       dataSchema,
       overwritePartitions,
-      copyOnWriteScan)
+      copyOnWriteScan,
+      operationType)
   }
 
   override def supportedCustomMetrics(): Array[CustomMetric] = {
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
index 91f4f861ce..f57c4e99aa 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.write
 
 import org.apache.paimon.CoreOptions
+import org.apache.paimon.Snapshot
 import org.apache.paimon.options.Options
 import org.apache.paimon.table.FileStoreTable
 import org.apache.paimon.types.RowType
@@ -30,13 +31,27 @@ import scala.collection.JavaConverters._
 class PaimonV2WriteBuilder(table: FileStoreTable, dataSchema: StructType, 
options: Options)
   extends BaseV2WriteBuilder(table) {
 
+  private var _operationType: Option[Snapshot.Operation] =
+    
Option(options.get(PaimonWriteOptions.OPERATION_OPTION)).map(Snapshot.Operation.valueOf)
+
+  def withOperationType(operationType: Snapshot.Operation): 
PaimonV2WriteBuilder = {
+    _operationType = Option(operationType)
+    this
+  }
+
   override def build: PaimonV2Write = {
     val finalTable = overwriteDynamic match {
       case Some(o) =>
         table.copy(Map(CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key -> 
o.toString).asJava)
       case _ => table
     }
-    new PaimonV2Write(finalTable, overwritePartitions, copyOnWriteScan, 
dataSchema, options)
+    new PaimonV2Write(
+      finalTable,
+      overwritePartitions,
+      copyOnWriteScan,
+      dataSchema,
+      options,
+      _operationType)
   }
 
   override def partitionRowType(): RowType = 
table.schema().logicalPartitionType()
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonWriteOptions.scala
similarity index 51%
copy from 
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
copy to 
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonWriteOptions.scala
index 91f4f861ce..16633cad37 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonV2WriteBuilder.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/write/PaimonWriteOptions.scala
@@ -18,26 +18,9 @@
 
 package org.apache.paimon.spark.write
 
-import org.apache.paimon.CoreOptions
-import org.apache.paimon.options.Options
-import org.apache.paimon.table.FileStoreTable
-import org.apache.paimon.types.RowType
+/** Internal Spark write-option keys. */
+object PaimonWriteOptions {
 
-import org.apache.spark.sql.types.StructType
-
-import scala.collection.JavaConverters._
-
-class PaimonV2WriteBuilder(table: FileStoreTable, dataSchema: StructType, 
options: Options)
-  extends BaseV2WriteBuilder(table) {
-
-  override def build: PaimonV2Write = {
-    val finalTable = overwriteDynamic match {
-      case Some(o) =>
-        table.copy(Map(CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key -> 
o.toString).asJava)
-      case _ => table
-    }
-    new PaimonV2Write(finalTable, overwritePartitions, copyOnWriteScan, 
dataSchema, options)
-  }
-
-  override def partitionRowType(): RowType = 
table.schema().logicalPartitionType()
+  /** Carries the snapshot operation type through Spark's CTAS/RTAS 
write-option map. */
+  val OPERATION_OPTION: String = "__paimon.operation"
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
index fbfb6e4eca..1a8e3ffe4b 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonCreateTableAsSelectStrategy.scala
@@ -18,8 +18,10 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.SparkCatalog
 import org.apache.paimon.spark.catalog.FormatTableCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
@@ -66,8 +68,10 @@ case class PaimonCreateTableAsSelectStrategy(spark: 
SparkSession)
         parts,
         query,
         qualifiedSpec,
-        writeOptions,
-        ifNotExists) :: Nil
+        writeOptions +
+          (PaimonWriteOptions.OPERATION_OPTION -> 
Snapshot.Operation.CREATE_TABLE_AS_SELECT.name()),
+        ifNotExists
+      ) :: Nil
     case _ => Nil
   }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
index 4c21a448e9..4bbcc0af25 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
@@ -18,7 +18,9 @@
 
 package org.apache.spark.sql.execution.shim
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.catalog.SparkBaseCatalog
+import org.apache.paimon.spark.write.PaimonWriteOptions
 
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
@@ -53,6 +55,11 @@ case class PaimonReplaceTableAsSelectStrategy(spark: 
SparkSession)
 
       val (tableOptions, writeOptions) = splitTableAndWriteOptions(options)
       val qualifiedSpec = qualifyTableSpec(tableSpec, tableOptions)
+      val operation =
+        if (orCreate) Snapshot.Operation.CREATE_OR_REPLACE_TABLE_AS_SELECT
+        else Snapshot.Operation.REPLACE_TABLE_AS_SELECT
+      val finalWriteOptions =
+        writeOptions + (PaimonWriteOptions.OPERATION_OPTION -> 
operation.name())
       // Pin snapshot in query to prevent self-referencing RTAS from reading 
truncated data
       val pinnedQuery = pinSnapshotInQuery(catalog, ident, query)
       if (canAtomicReplace(catalog, ident, qualifiedSpec, parts)) {
@@ -62,7 +69,7 @@ case class PaimonReplaceTableAsSelectStrategy(spark: 
SparkSession)
           parts,
           pinnedQuery,
           qualifiedSpec,
-          writeOptions,
+          finalWriteOptions,
           orCreate = orCreate) :: Nil
       } else {
         SparkShimLoader.shim.createReplaceTableAsSelectExec(
@@ -71,7 +78,7 @@ case class PaimonReplaceTableAsSelectStrategy(spark: 
SparkSession)
           parts,
           pinnedQuery,
           qualifiedSpec,
-          writeOptions,
+          finalWriteOptions,
           orCreate = orCreate) :: Nil
       }
     case _ => Nil
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 5c8094682f..b925036463 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
@@ -18,6 +18,7 @@
 
 package org.apache.spark.sql.paimon.shims
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.variant.Variant
 import org.apache.paimon.function.{Function => PaimonFunction}
 import org.apache.paimon.spark.data.{SparkArrayData, SparkInternalRow}
@@ -129,7 +130,8 @@ trait SparkShim {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): BatchWrite
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation]): BatchWrite
 
   /** Same `BatchWrite` mixin problem as [[createPaimonBatchWrite]], but for 
`FormatTable` writes. */
   def createFormatTableBatchWrite(
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/SnapshotOperationTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/SnapshotOperationTest.scala
new file mode 100644
index 0000000000..fcc4d06650
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/SnapshotOperationTest.scala
@@ -0,0 +1,110 @@
+/*
+ * 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.spark.PaimonSparkTestBase
+
+/** Verifies the logical operation type recorded in the committed snapshot. */
+class SnapshotOperationTest extends PaimonSparkTestBase {
+
+  private def latestOperation(tableName: String): Snapshot.Operation = {
+    val snapshot = loadTable(tableName).snapshotManager().latestSnapshot()
+    assert(snapshot != null, s"table $tableName has no snapshot")
+    snapshot.operation()
+  }
+
+  test("Snapshot operation: INSERT / OVERWRITE / UPDATE / DELETE / MERGE") {
+    for (useV2Write <- Seq("true", "false")) {
+      withSparkSQLConf("spark.paimon.write.use-v2-write" -> useV2Write) {
+        withTable("t", "s") {
+          sql("CREATE TABLE t (id INT, name STRING)")
+          sql("CREATE TABLE s (id INT, name STRING)")
+          sql("INSERT INTO s VALUES (1, 'merged'), (9, 'new')")
+          sql("INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+          assert(latestOperation("t") == Snapshot.Operation.WRITE)
+
+          sql("INSERT OVERWRITE t VALUES (1, 'a2'), (2, 'b2'), (3, 'c2')")
+          assert(latestOperation("t") == Snapshot.Operation.OVERWRITE)
+
+          sql("UPDATE t SET name = 'updated' WHERE id = 1")
+          assert(latestOperation("t") == Snapshot.Operation.UPDATE)
+
+          sql("DELETE FROM t WHERE id = 2")
+          assert(latestOperation("t") == Snapshot.Operation.DELETE)
+
+          sql("""
+                |MERGE INTO t USING s ON t.id = s.id
+                |WHEN MATCHED THEN UPDATE SET t.name = s.name
+                |WHEN NOT MATCHED THEN INSERT *
+                |""".stripMargin)
+          assert(latestOperation("t") == Snapshot.Operation.MERGE)
+        }
+      }
+    }
+  }
+
+  test("Snapshot operation: CTAS / RTAS") {
+    for (useV2Write <- Seq("true", "false")) {
+      withSparkSQLConf("spark.paimon.write.use-v2-write" -> useV2Write) {
+        withTable("src", "ctas", "rtas") {
+          sql("CREATE TABLE src (id INT, name STRING)")
+          sql("INSERT INTO src VALUES (1, 'a'), (2, 'b')")
+
+          sql("CREATE TABLE ctas AS SELECT * FROM src")
+          assert(latestOperation("ctas") == 
Snapshot.Operation.CREATE_TABLE_AS_SELECT)
+
+          sql("CREATE OR REPLACE TABLE rtas AS SELECT * FROM src")
+          assert(latestOperation("rtas") == 
Snapshot.Operation.CREATE_OR_REPLACE_TABLE_AS_SELECT)
+
+          // rtas twice
+          sql("CREATE OR REPLACE TABLE rtas AS SELECT * FROM src")
+          assert(latestOperation("rtas") == 
Snapshot.Operation.CREATE_OR_REPLACE_TABLE_AS_SELECT)
+
+          // pure REPLACE TABLE (no CREATE)
+          sql("REPLACE TABLE rtas AS SELECT * FROM src")
+          assert(latestOperation("rtas") == 
Snapshot.Operation.REPLACE_TABLE_AS_SELECT)
+        }
+      }
+    }
+  }
+
+  test("Snapshot operation: TRUNCATE") {
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, name STRING, dt STRING) PARTITIONED BY 
(dt)")
+      sql("INSERT INTO t VALUES (1, 'a', '2024-01-01'), (2, 'b', 
'2024-01-02')")
+
+      // Full-table DELETE (no WHERE) is optimized to truncateTable
+      sql("DELETE FROM t")
+      assert(latestOperation("t") == Snapshot.Operation.TRUNCATE)
+
+      sql("INSERT INTO t VALUES (3, 'c', '2024-01-01'), (4, 'd', 
'2024-01-02')")
+
+      // Partition DELETE is optimized to truncatePartitions
+      sql("DELETE FROM t WHERE dt = '2024-01-01'")
+      assert(latestOperation("t") == Snapshot.Operation.TRUNCATE)
+
+      sql("INSERT INTO t VALUES (5, 'e', '2024-01-01')")
+
+      // TRUNCATE TABLE
+      sql("TRUNCATE TABLE t")
+      assert(latestOperation("t") == Snapshot.Operation.TRUNCATE)
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
index 89eb15054c..57b89270d8 100644
--- 
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
+++ 
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.write
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
 import org.apache.paimon.table.FileStoreTable
 
@@ -33,8 +34,15 @@ class PaimonBatchWrite(
     writeSchema: StructType,
     dataSchema: StructType,
     overwritePartitions: Option[Map[String, String]],
-    copyOnWriteScan: Option[PaimonCopyOnWriteScan])
-  extends PaimonBatchWriteBase(table, writeSchema, dataSchema, 
overwritePartitions, copyOnWriteScan)
+    copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+    operationType: Option[Snapshot.Operation] = None)
+  extends PaimonBatchWriteBase(
+    table,
+    writeSchema,
+    dataSchema,
+    overwritePartitions,
+    copyOnWriteScan,
+    operationType)
   with BatchWrite
   with Serializable {
 
@@ -54,6 +62,13 @@ object PaimonBatchWrite {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): PaimonBatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation] = None): PaimonBatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 }
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 362e2bebf0..d4993fdce4 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
@@ -18,6 +18,7 @@
 
 package org.apache.spark.sql.paimon.shims
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.variant.Variant
 import org.apache.paimon.spark.catalyst.analysis.Spark3ResolutionRules
 import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark3SqlExtensionsParser
@@ -195,8 +196,15 @@ class Spark3Shim extends SparkShim {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): BatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation]): BatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 
   override def createFormatTableBatchWrite(
       table: FormatTable,
diff --git 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
index 38683e79c0..374062c6b4 100644
--- 
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
+++ 
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/write/PaimonBatchWrite.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark.write
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
 import org.apache.paimon.table.FileStoreTable
 
@@ -35,8 +36,15 @@ class PaimonBatchWrite(
     writeSchema: StructType,
     dataSchema: StructType,
     overwritePartitions: Option[Map[String, String]],
-    copyOnWriteScan: Option[PaimonCopyOnWriteScan])
-  extends PaimonBatchWriteBase(table, writeSchema, dataSchema, 
overwritePartitions, copyOnWriteScan)
+    copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+    operationType: Option[Snapshot.Operation] = None)
+  extends PaimonBatchWriteBase(
+    table,
+    writeSchema,
+    dataSchema,
+    overwritePartitions,
+    copyOnWriteScan,
+    operationType)
   with BatchWrite
   with Serializable {
 
@@ -56,6 +64,13 @@ object PaimonBatchWrite {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): PaimonBatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation] = None): PaimonBatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 }
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 3a49c223b6..00d6024362 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
@@ -18,6 +18,7 @@
 
 package org.apache.spark.sql.paimon.shims
 
+import org.apache.paimon.Snapshot
 import org.apache.paimon.data.variant.{GenericVariant, Variant}
 import org.apache.paimon.spark.catalyst.analysis.Spark4ResolutionRules
 import 
org.apache.paimon.spark.catalyst.parser.extensions.PaimonSpark4SqlExtensionsParser
@@ -193,8 +194,15 @@ class Spark4Shim extends SparkShim {
       writeSchema: StructType,
       dataSchema: StructType,
       overwritePartitions: Option[Map[String, String]],
-      copyOnWriteScan: Option[PaimonCopyOnWriteScan]): BatchWrite =
-    new PaimonBatchWrite(table, writeSchema, dataSchema, overwritePartitions, 
copyOnWriteScan)
+      copyOnWriteScan: Option[PaimonCopyOnWriteScan],
+      operationType: Option[Snapshot.Operation]): BatchWrite =
+    new PaimonBatchWrite(
+      table,
+      writeSchema,
+      dataSchema,
+      overwritePartitions,
+      copyOnWriteScan,
+      operationType)
 
   override def createFormatTableBatchWrite(
       table: FormatTable,

Reply via email to