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 d113d293fd [core][python] Add snapshot UUID validation to catalog
commits (#8920)
d113d293fd is described below
commit d113d293fd39894b6521f51b4ed21865fd7dd1c1
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 30 15:54:21 2026 +0800
[core][python] Add snapshot UUID validation to catalog commits (#8920)
---
docs/docs/concepts/spec/snapshot.md | 39 ++++----
docs/static/rest-catalog-open-api.yaml | 6 ++
.../src/main/java/org/apache/paimon/Snapshot.java | 111 ++++-----------------
.../main/java/org/apache/paimon/rest/RESTApi.java | 5 +-
.../paimon/rest/requests/CommitTableRequest.java | 15 +++
.../src/main/java/org/apache/paimon/Changelog.java | 3 +
.../org/apache/paimon/catalog/AbstractCatalog.java | 1 +
.../java/org/apache/paimon/catalog/Catalog.java | 4 +-
.../paimon/catalog/CatalogSnapshotCommit.java | 8 +-
.../org/apache/paimon/catalog/DelegateCatalog.java | 4 +-
.../paimon/catalog/RenamingSnapshotCommit.java | 8 +-
.../org/apache/paimon/catalog/SnapshotCommit.java | 8 +-
.../paimon/operation/FileStoreCommitImpl.java | 23 +++--
.../java/org/apache/paimon/rest/RESTCatalog.java | 4 +-
.../src/main/java/org/apache/paimon/tag/Tag.java | 4 +
.../test/java/org/apache/paimon/SnapshotTest.java | 51 +++++++---
.../DataEvolutionRowIdReassignerTest.java | 2 +
.../paimon/catalog/CatalogSnapshotCommitTest.java | 62 ++++++++++++
.../paimon/catalog/RenamingSnapshotCommitTest.java | 4 +-
.../paimon/operation/FileStoreCommitTest.java | 19 +++-
.../org/apache/paimon/rest/RESTCatalogServer.java | 13 ++-
.../org/apache/paimon/rest/RESTCatalogTest.java | 36 +++++++
.../test/java/org/apache/paimon/tag/TagTest.java | 4 +
paimon-python/pypaimon/api/api_request.py | 4 +
paimon-python/pypaimon/api/rest_api.py | 9 +-
paimon-python/pypaimon/catalog/catalog.py | 2 +
.../pypaimon/catalog/filesystem_catalog.py | 1 +
paimon-python/pypaimon/catalog/jdbc_catalog.py | 1 +
.../pypaimon/catalog/rest/rest_catalog.py | 10 +-
paimon-python/pypaimon/changelog/changelog.py | 1 +
paimon-python/pypaimon/common/json_util.py | 13 ++-
.../pypaimon/snapshot/catalog_snapshot_commit.py | 18 +++-
.../pypaimon/snapshot/renaming_snapshot_commit.py | 10 +-
paimon-python/pypaimon/snapshot/snapshot.py | 9 ++
paimon-python/pypaimon/snapshot/snapshot_commit.py | 10 +-
paimon-python/pypaimon/tag/tag.py | 2 +
.../pypaimon/tests/file_store_commit_test.py | 10 +-
.../pypaimon/tests/overwrite_changes_cache_test.py | 12 +--
.../tests/overwrite_commit_conflict_test.py | 16 +--
paimon-python/pypaimon/tests/rest/api_test.py | 6 +-
.../rest/rest_catalog_commit_snapshot_test.py | 77 +++++++++++++-
.../pypaimon/tests/rest/rest_permission_test.py | 1 +
paimon-python/pypaimon/tests/rest/rest_server.py | 10 ++
.../pypaimon/tests/write/dynamic_bucket_test.py | 3 +-
paimon-python/pypaimon/write/file_store_commit.py | 6 +-
45 files changed, 483 insertions(+), 182 deletions(-)
diff --git a/docs/docs/concepts/spec/snapshot.md
b/docs/docs/concepts/spec/snapshot.md
index dd2bf28ed2..d09db56a51 100644
--- a/docs/docs/concepts/spec/snapshot.md
+++ b/docs/docs/concepts/spec/snapshot.md
@@ -46,22 +46,23 @@ be visible.
Snapshot File is JSON, it includes:
1. version: Snapshot file version, current is 3.
-2. id: snapshot id, same to file name.
-3. schemaId: the corresponding schema version for this commit.
-4. baseManifestList: a manifest list recording all changes from the previous
snapshots.
-5. deltaManifestList: a manifest list recording all new changes occurred in
this snapshot.
-6. changelogManifestList: a manifest list recording all changelog produced in
this snapshot, null if no changelog is produced.
-7. indexManifest: a manifest recording all index files of this table, null if
no table index file.
-8. commitUser: usually generated by UUID, it is used for recovery of streaming
writes, one stream write job with one user.
-9. commitIdentifier: transaction id corresponding to streaming write, each
transaction may result in multiple commits for different commitKinds.
-10. commitKind: type of changes in this snapshot, including append, compact,
overwrite and analyze.
-11. timeMillis: commit time millis.
-12. logOffsets: commit log offsets.
-13. totalRecordCount: record count of all changes occurred in this snapshot.
-14. deltaRecordCount: record count of all new changes occurred in this
snapshot.
-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.
+2. uuid: UUID identifying the complete immutable snapshot, null for snapshots
created by older versions.
+3. id: snapshot id, same to file name.
+4. schemaId: the corresponding schema version for this commit.
+5. baseManifestList: a manifest list recording all changes from the previous
snapshots.
+6. deltaManifestList: a manifest list recording all new changes occurred in
this snapshot.
+7. changelogManifestList: a manifest list recording all changelog produced in
this snapshot, null if no changelog is produced.
+8. indexManifest: a manifest recording all index files of this table, null if
no table index file.
+9. commitUser: usually generated by UUID, it is used for recovery of streaming
writes, one stream write job with one user.
+10. commitIdentifier: transaction id corresponding to streaming write, each
transaction may result in multiple commits for different commitKinds.
+11. commitKind: type of changes in this snapshot, including append, compact,
overwrite and analyze.
+12. timeMillis: commit time millis.
+13. logOffsets: commit log offsets.
+14. totalRecordCount: record count of all changes occurred in this snapshot.
+15. deltaRecordCount: record count of all new changes occurred in this
snapshot.
+16. changelogRecordCount: record count of all changelog produced in this
snapshot.
+17. watermark: watermark for input records, from Flink watermark mechanism,
Long.MIN_VALUE if there is no watermark.
+18. statistics: stats file name for statistics of this table.
+19. properties: additional key-value properties of this snapshot.
+20. nextRowId: next row id for row tracking.
+21. operation: logical operation type, e.g. WRITE, DELETE, UPDATE, MERGE. Null
if not set.
diff --git a/docs/static/rest-catalog-open-api.yaml
b/docs/static/rest-catalog-open-api.yaml
index 51def16c43..47f5acef5e 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -3203,6 +3203,9 @@ components:
properties:
tableId:
type: string
+ baseSnapshotUuid:
+ type: string
+ nullable: true
snapshot:
$ref: '#/components/schemas/Snapshot'
statistics:
@@ -3268,6 +3271,9 @@ components:
type: integer
format: int32
nullable: true
+ uuid:
+ type: string
+ nullable: true
id:
type: integer
format: int64
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 4a200dfecc..d34b061b78 100644
--- a/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
+++ b/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
@@ -32,6 +32,7 @@ import javax.annotation.Nullable;
import java.io.Serializable;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
/**
* This file is the entrance to all data committed at some specific time point.
@@ -49,6 +50,7 @@ public class Snapshot implements Serializable {
protected static final int CURRENT_VERSION = 3;
protected static final String FIELD_VERSION = "version";
+ protected static final String FIELD_UUID = "uuid";
protected static final String FIELD_ID = "id";
protected static final String FIELD_SCHEMA_ID = "schemaId";
protected static final String FIELD_BASE_MANIFEST_LIST =
"baseManifestList";
@@ -75,6 +77,12 @@ public class Snapshot implements Serializable {
@JsonProperty(FIELD_VERSION)
protected final int version;
+ // null for snapshots created before UUID was introduced
+ @JsonProperty(FIELD_UUID)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Nullable
+ protected final String uuid;
+
@JsonProperty(FIELD_ID)
protected final long id;
@@ -187,51 +195,6 @@ public class Snapshot implements Serializable {
@Nullable
protected final Operation operation;
- 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) {
- 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,
@@ -256,6 +219,7 @@ public class Snapshot implements Serializable {
@Nullable Operation operation) {
this(
CURRENT_VERSION,
+ UUID.randomUUID().toString(),
id,
schemaId,
baseManifestList,
@@ -279,56 +243,10 @@ public class Snapshot implements Serializable {
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
public Snapshot(
@JsonProperty(FIELD_VERSION) int version,
+ @JsonProperty(FIELD_UUID) @Nullable String uuid,
@JsonProperty(FIELD_ID) long id,
@JsonProperty(FIELD_SCHEMA_ID) long schemaId,
@JsonProperty(FIELD_BASE_MANIFEST_LIST) String baseManifestList,
@@ -352,6 +270,7 @@ public class Snapshot implements Serializable {
@JsonProperty(FIELD_NEXT_ROW_ID) @Nullable Long nextRowId,
@JsonProperty(FIELD_OPERATION) @Nullable Operation operation) {
this.version = version;
+ this.uuid = uuid;
this.id = id;
this.schemaId = schemaId;
this.baseManifestList = baseManifestList;
@@ -380,6 +299,12 @@ public class Snapshot implements Serializable {
return version;
}
+ @JsonGetter(FIELD_UUID)
+ @Nullable
+ public String uuid() {
+ return uuid;
+ }
+
@JsonGetter(FIELD_ID)
public long id() {
return id;
@@ -504,6 +429,7 @@ public class Snapshot implements Serializable {
public int hashCode() {
return Objects.hash(
version,
+ uuid,
id,
schemaId,
baseManifestList,
@@ -537,6 +463,7 @@ public class Snapshot implements Serializable {
}
Snapshot that = (Snapshot) o;
return Objects.equals(version, that.version)
+ && Objects.equals(uuid, that.uuid)
&& id == that.id
&& schemaId == that.schemaId
&& Objects.equals(baseManifestList, that.baseManifestList)
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index 8da06988c3..401921c8d1 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -669,6 +669,7 @@ public class RESTApi {
*
* @param identifier database name and table name.
* @param tableUuid Uuid of the table to avoid wrong commit
+ * @param baseSnapshotUuid Uuid of the snapshot on which the commit is
based
* @param snapshot snapshot for committing
* @param statistics statistics for this snapshot incremental
* @return true if commit success
@@ -679,9 +680,11 @@ public class RESTApi {
public boolean commitSnapshot(
Identifier identifier,
@Nullable String tableUuid,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics) {
- CommitTableRequest request = new CommitTableRequest(tableUuid,
snapshot, statistics);
+ CommitTableRequest request =
+ new CommitTableRequest(tableUuid, baseSnapshotUuid, snapshot,
statistics);
CommitTableResponse response =
client.post(
resourcePaths.commitTable(
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CommitTableRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CommitTableRequest.java
index 18bb1bf999..8048dd669a 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CommitTableRequest.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CommitTableRequest.java
@@ -27,6 +27,8 @@ import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGet
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+import javax.annotation.Nullable;
+
import java.util.List;
/** Request for committing snapshot to table. */
@@ -34,12 +36,17 @@ import java.util.List;
public class CommitTableRequest implements RESTRequest {
private static final String FIELD_TABLE_ID = "tableId";
+ private static final String FIELD_BASE_SNAPSHOT_UUID = "baseSnapshotUuid";
private static final String FIELD_SNAPSHOT = "snapshot";
private static final String FIELD_STATISTICS = "statistics";
@JsonProperty(FIELD_TABLE_ID)
private final String tableId;
+ @JsonProperty(FIELD_BASE_SNAPSHOT_UUID)
+ @Nullable
+ private final String baseSnapshotUuid;
+
@JsonProperty(FIELD_SNAPSHOT)
private final Snapshot snapshot;
@@ -49,9 +56,11 @@ public class CommitTableRequest implements RESTRequest {
@JsonCreator
public CommitTableRequest(
@JsonProperty(FIELD_TABLE_ID) String tableId,
+ @JsonProperty(FIELD_BASE_SNAPSHOT_UUID) @Nullable String
baseSnapshotUuid,
@JsonProperty(FIELD_SNAPSHOT) Snapshot snapshot,
@JsonProperty(FIELD_STATISTICS) List<PartitionStatistics>
statistics) {
this.tableId = tableId;
+ this.baseSnapshotUuid = baseSnapshotUuid;
this.snapshot = snapshot;
this.statistics = statistics;
}
@@ -61,6 +70,12 @@ public class CommitTableRequest implements RESTRequest {
return tableId;
}
+ @JsonGetter(FIELD_BASE_SNAPSHOT_UUID)
+ @Nullable
+ public String getBaseSnapshotUuid() {
+ return baseSnapshotUuid;
+ }
+
@JsonGetter(FIELD_SNAPSHOT)
public Snapshot getSnapshot() {
return snapshot;
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 05b76d7153..aaa1f7f4d5 100644
--- a/paimon-core/src/main/java/org/apache/paimon/Changelog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/Changelog.java
@@ -44,6 +44,7 @@ public class Changelog extends Snapshot {
public Changelog(Snapshot snapshot) {
this(
snapshot.version(),
+ snapshot.uuid(),
snapshot.id(),
snapshot.schemaId(),
snapshot.baseManifestList(),
@@ -70,6 +71,7 @@ public class Changelog extends Snapshot {
@JsonCreator
public Changelog(
@JsonProperty(FIELD_VERSION) int version,
+ @JsonProperty(FIELD_UUID) @Nullable String uuid,
@JsonProperty(FIELD_ID) long id,
@JsonProperty(FIELD_SCHEMA_ID) long schemaId,
@JsonProperty(FIELD_BASE_MANIFEST_LIST) String baseManifestList,
@@ -94,6 +96,7 @@ public class Changelog extends Snapshot {
@JsonProperty(FIELD_OPERATION) @Nullable Operation operation) {
super(
version,
+ uuid,
id,
schemaId,
baseManifestList,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java
index 67992a755a..5784d56b0e 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java
@@ -681,6 +681,7 @@ public abstract class AbstractCatalog implements Catalog {
public boolean commitSnapshot(
Identifier identifier,
@Nullable String tableUuid,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics) {
throw new UnsupportedOperationException();
diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
index eb650dc95e..5b437efbab 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
@@ -709,7 +709,7 @@ public interface Catalog extends AutoCloseable {
* will throw an {@link UnsupportedOperationException}, affect the
following methods:
*
* <ul>
- * <li>{@link #commitSnapshot(Identifier, String, Snapshot, List)}.
+ * <li>{@link #commitSnapshot(Identifier, String, String, Snapshot,
List)}.
* <li>{@link #loadSnapshot(Identifier)}.
* <li>{@link #rollbackTo(Identifier, Instant)}.
* <li>{@link #createBranch(Identifier, String, String)}.
@@ -729,6 +729,7 @@ public interface Catalog extends AutoCloseable {
*
* @param identifier Path of the table
* @param tableUuid Uuid of the table to avoid wrong commit
+ * @param baseSnapshotUuid Uuid of the snapshot on which the commit is
based
* @param snapshot Snapshot to be committed
* @param statistics statistics information of this change
* @return Success or not
@@ -739,6 +740,7 @@ public interface Catalog extends AutoCloseable {
boolean commitSnapshot(
Identifier identifier,
@Nullable String tableUuid,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics)
throws Catalog.TableNotExistException;
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogSnapshotCommit.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogSnapshotCommit.java
index b49f1a12ef..fc50e33b24 100644
---
a/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogSnapshotCommit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogSnapshotCommit.java
@@ -39,11 +39,15 @@ public class CatalogSnapshotCommit implements
SnapshotCommit {
}
@Override
- public boolean commit(Snapshot snapshot, String branch,
List<PartitionStatistics> statistics)
+ public boolean commit(
+ @Nullable String baseSnapshotUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
throws Exception {
Identifier newIdentifier =
new Identifier(identifier.getDatabaseName(),
identifier.getTableName(), branch);
- return catalog.commitSnapshot(newIdentifier, uuid, snapshot,
statistics);
+ return catalog.commitSnapshot(newIdentifier, uuid, baseSnapshotUuid,
snapshot, statistics);
}
@Override
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
index 10ae54ea45..13faef105c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
@@ -306,10 +306,12 @@ public abstract class DelegateCatalog implements Catalog {
public boolean commitSnapshot(
Identifier identifier,
@Nullable String tableUuid,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics)
throws TableNotExistException {
- return wrapped.commitSnapshot(identifier, tableUuid, snapshot,
statistics);
+ return wrapped.commitSnapshot(
+ identifier, tableUuid, baseSnapshotUuid, snapshot, statistics);
}
@Override
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/RenamingSnapshotCommit.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/RenamingSnapshotCommit.java
index a79aafb298..8a655f6076 100644
---
a/paimon-core/src/main/java/org/apache/paimon/catalog/RenamingSnapshotCommit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/catalog/RenamingSnapshotCommit.java
@@ -25,6 +25,8 @@ import org.apache.paimon.operation.Lock;
import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.utils.SnapshotManager;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.util.List;
import java.util.concurrent.Callable;
@@ -48,7 +50,11 @@ public class RenamingSnapshotCommit implements
SnapshotCommit {
}
@Override
- public boolean commit(Snapshot snapshot, String branch,
List<PartitionStatistics> statistics)
+ public boolean commit(
+ @Nullable String baseSnapshotUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
throws Exception {
Path newSnapshotPath =
snapshotManager.branch().equals(branch)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/SnapshotCommit.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/SnapshotCommit.java
index 43abacd7b6..ff912e2139 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/SnapshotCommit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/SnapshotCommit.java
@@ -21,11 +21,17 @@ package org.apache.paimon.catalog;
import org.apache.paimon.Snapshot;
import org.apache.paimon.partition.PartitionStatistics;
+import javax.annotation.Nullable;
+
import java.util.List;
/** Interface to commit snapshot atomically. */
public interface SnapshotCommit extends AutoCloseable {
- boolean commit(Snapshot snapshot, String branch, List<PartitionStatistics>
statistics)
+ boolean commit(
+ @Nullable String baseSnapshotUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
throws Exception;
}
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 1eacfa71f7..5725dc89a7 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
@@ -1201,7 +1201,7 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
callback ->
callback.call(finalBaseFiles, finalDeltaFiles,
indexFiles, newSnapshot));
try {
- success = commitSnapshotImpl(newSnapshot, deltaStatistics);
+ success = commitSnapshotImpl(latestSnapshot, newSnapshot,
deltaStatistics);
} catch (Exception e) {
// commit exception, not sure about the situation and should not
clean up the files
LOG.warn("Retry commit for exception.", e);
@@ -1336,7 +1336,7 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
nextRowId,
null);
- return commitSnapshotImpl(newSnapshot, emptyList());
+ return commitSnapshotImpl(latest, newSnapshot, emptyList());
}
@Override
@@ -1418,7 +1418,8 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
targetSnapshot.watermark(),
targetSnapshot.statistics(),
targetSnapshot.properties(),
- nextRowId);
+ nextRowId,
+ null);
// The rollback is an overwrite from the previous latest to the
target, so the base files,
// delta files and index changes describe the transition the callbacks
need. These are
@@ -1435,7 +1436,8 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
callback -> callback.call(baseFiles, deltaFiles, indexChanges,
newSnapshot));
boolean success =
- commitSnapshotImpl(newSnapshot, new
ArrayList<>(PartitionEntry.merge(deltaFiles)));
+ commitSnapshotImpl(
+ latest, newSnapshot, new
ArrayList<>(PartitionEntry.merge(deltaFiles)));
if (success) {
// Notify the post-commit callbacks so external views stay in sync
with the rolled-back
// state (e.g. Iceberg compatibility metadata and chain-table
overwrite handling).
@@ -1571,16 +1573,23 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
latestSnapshot.nextRowId(),
null);
- return commitSnapshotImpl(newSnapshot, emptyList());
+ return commitSnapshotImpl(latestSnapshot, newSnapshot, emptyList());
}
- private boolean commitSnapshotImpl(Snapshot newSnapshot,
List<PartitionEntry> deltaStatistics) {
+ private boolean commitSnapshotImpl(
+ @Nullable Snapshot baseSnapshot,
+ Snapshot newSnapshot,
+ List<PartitionEntry> deltaStatistics) {
try {
List<PartitionStatistics> statistics = new
ArrayList<>(deltaStatistics.size());
for (PartitionEntry entry : deltaStatistics) {
statistics.add(entry.toPartitionStatistics(partitionComputer));
}
- return snapshotCommit.commit(newSnapshot, options.branch(),
statistics);
+ return snapshotCommit.commit(
+ baseSnapshot == null ? null : baseSnapshot.uuid(),
+ newSnapshot,
+ options.branch(),
+ statistics);
} catch (Throwable e) {
// exception when performing the atomic rename,
// we cannot clean up because we can't determine the success
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index 3db1052c4c..f1b5d94cc1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -466,11 +466,13 @@ public class RESTCatalog implements Catalog {
public boolean commitSnapshot(
Identifier identifier,
@Nullable String tableUuid,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics)
throws TableNotExistException {
try {
- return api.commitSnapshot(identifier, tableUuid, snapshot,
statistics);
+ return api.commitSnapshot(
+ identifier, tableUuid, baseSnapshotUuid, snapshot,
statistics);
} catch (NoSuchResourceException e) {
throw new TableNotExistException(identifier, e);
} catch (ForbiddenException e) {
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 22d86b6fca..1acd425cd4 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
@@ -59,6 +59,7 @@ public class Tag extends Snapshot {
@JsonCreator
public Tag(
@JsonProperty(FIELD_VERSION) int version,
+ @JsonProperty(FIELD_UUID) @Nullable String uuid,
@JsonProperty(FIELD_ID) long id,
@JsonProperty(FIELD_SCHEMA_ID) long schemaId,
@JsonProperty(FIELD_BASE_MANIFEST_LIST) String baseManifestList,
@@ -85,6 +86,7 @@ public class Tag extends Snapshot {
@JsonProperty(FIELD_TAG_TIME_RETAINED) @Nullable Duration
tagTimeRetained) {
super(
version,
+ uuid,
id,
schemaId,
baseManifestList,
@@ -124,6 +126,7 @@ public class Tag extends Snapshot {
Snapshot snapshot, Duration tagTimeRetained, LocalDateTime
tagCreateTime) {
return new Tag(
snapshot.version(),
+ snapshot.uuid(),
snapshot.id(),
snapshot.schemaId(),
snapshot.baseManifestList(),
@@ -152,6 +155,7 @@ public class Tag extends Snapshot {
public Snapshot trimToSnapshot() {
return new Snapshot(
version,
+ uuid,
id,
schemaId,
baseManifestList,
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 3a035c8a33..d67e9feb03 100644
--- a/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
@@ -25,30 +25,49 @@ import org.apache.paimon.utils.SnapshotManager;
import org.junit.jupiter.api.Test;
+import java.util.UUID;
+
import static org.apache.paimon.catalog.Identifier.DEFAULT_MAIN_BRANCH;
import static org.assertj.core.api.Assertions.assertThat;
/** Test for snapshots. */
public class SnapshotTest {
+ @Test
+ public void testSnapshotUuid() {
+ Snapshot snapshot =
+
org.apache.paimon.utils.SnapshotManagerTest.createSnapshotWithMillis(1L, 1000L);
+
+ assertThat(snapshot.uuid()).isNotNull();
+
assertThat(UUID.fromString(snapshot.uuid()).toString()).isEqualTo(snapshot.uuid());
+
assertThat(Snapshot.fromJson(snapshot.toJson()).uuid()).isEqualTo(snapshot.uuid());
+ assertThat(
+
org.apache.paimon.utils.SnapshotManagerTest.createSnapshotWithMillis(
+ 2L, 2000L)
+ .uuid())
+ .isNotEqualTo(snapshot.uuid());
+ }
+
@Test
public void testJsonIgnoreProperties() {
- Snapshot.fromJson(
- "{\n"
- + " \"version\" : 3,\n"
- + " \"id\" : 5,\n"
- + " \"schemaId\" : 0,\n"
- + " \"baseManifestList\" : null,\n"
- + " \"deltaManifestList\" : null,\n"
- + " \"changelogManifestList\" : null,\n"
- + " \"commitUser\" : null,\n"
- + " \"commitIdentifier\" : 0,\n"
- + " \"commitKind\" : \"APPEND\",\n"
- + " \"timeMillis\" : 1234,\n"
- + " \"totalRecordCount\" : null,\n"
- + " \"deltaRecordCount\" : null,\n"
- + " \"unknownKey\" : 22222\n"
- + "}");
+ Snapshot snapshot =
+ Snapshot.fromJson(
+ "{\n"
+ + " \"version\" : 3,\n"
+ + " \"id\" : 5,\n"
+ + " \"schemaId\" : 0,\n"
+ + " \"baseManifestList\" : null,\n"
+ + " \"deltaManifestList\" : null,\n"
+ + " \"changelogManifestList\" : null,\n"
+ + " \"commitUser\" : null,\n"
+ + " \"commitIdentifier\" : 0,\n"
+ + " \"commitKind\" : \"APPEND\",\n"
+ + " \"timeMillis\" : 1234,\n"
+ + " \"totalRecordCount\" : null,\n"
+ + " \"deltaRecordCount\" : null,\n"
+ + " \"unknownKey\" : 22222\n"
+ + "}");
+ assertThat(snapshot.uuid()).isNull();
}
@Test
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 5e5b2ad923..96b2a8f230 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
@@ -82,6 +82,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -2872,6 +2873,7 @@ public class DataEvolutionRowIdReassignerTest extends
TableTestBase {
Snapshot staleSnapshot =
new Snapshot(
latest.version(),
+ UUID.randomUUID().toString(),
latest.id(),
latest.schemaId(),
latest.baseManifestList(),
diff --git
a/paimon-core/src/test/java/org/apache/paimon/catalog/CatalogSnapshotCommitTest.java
b/paimon-core/src/test/java/org/apache/paimon/catalog/CatalogSnapshotCommitTest.java
new file mode 100644
index 0000000000..1b5cfeb1cc
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/catalog/CatalogSnapshotCommitTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.catalog;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.utils.SnapshotManagerTest;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link CatalogSnapshotCommit}. */
+public class CatalogSnapshotCommitTest {
+
+ @Test
+ public void testCommitForwardsBaseSnapshotUuid() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ Identifier identifier = Identifier.create("database", "table");
+ Identifier branchIdentifier = new Identifier("database", "table",
"branch");
+ Snapshot snapshot = SnapshotManagerTest.createSnapshotWithMillis(2L,
1000L);
+ when(catalog.commitSnapshot(
+ branchIdentifier,
+ "table-uuid",
+ "base-snapshot-uuid",
+ snapshot,
+ Collections.emptyList()))
+ .thenReturn(true);
+
+ CatalogSnapshotCommit commit = new CatalogSnapshotCommit(catalog,
identifier, "table-uuid");
+
+ assertThat(commit.commit("base-snapshot-uuid", snapshot, "branch",
Collections.emptyList()))
+ .isTrue();
+ verify(catalog)
+ .commitSnapshot(
+ branchIdentifier,
+ "table-uuid",
+ "base-snapshot-uuid",
+ snapshot,
+ Collections.emptyList());
+ }
+}
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 0924b94203..1eef5c53cf 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
@@ -58,7 +58,7 @@ public class RenamingSnapshotCommitTest {
Snapshot snapshot = createSnapshot(1L);
- boolean committed = commit.commit(snapshot, "main",
Collections.emptyList());
+ boolean committed = commit.commit(null, snapshot, "main",
Collections.emptyList());
assertThat(committed).isTrue();
@@ -91,7 +91,7 @@ public class RenamingSnapshotCommitTest {
IOException ex =
assertThrows(
IOException.class,
- () -> commit.commit(snapshot, "main",
Collections.emptyList()));
+ () -> commit.commit(null, snapshot, "main",
Collections.emptyList()));
assertThat(ex)
.hasMessageContaining("Commit snapshot " + snapshot.id() + "
failed")
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index 97f4a5b804..98606bfb10 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -84,6 +84,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Nullable;
+
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
@@ -1962,12 +1964,13 @@ public class FileStoreCommitTest {
@Override
public boolean commit(
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
String branch,
List<org.apache.paimon.partition.PartitionStatistics>
statistics)
throws Exception {
if (commitAttempt >= conflictDeltaFilesByAttempt.size()) {
- return delegate.commit(snapshot, branch, statistics);
+ return delegate.commit(baseSnapshotUuid, snapshot, branch,
statistics);
}
List<ManifestEntry> conflictDeltaFiles =
conflictDeltaFilesByAttempt.get(commitAttempt);
@@ -2042,8 +2045,15 @@ public class FileStoreCommitTest {
previousSnapshot == null ? null :
previousSnapshot.watermark(),
previousSnapshot == null ? null :
previousSnapshot.statistics(),
previousSnapshot == null ? null :
previousSnapshot.properties(),
- previousSnapshot == null ? null :
previousSnapshot.nextRowId());
- assertThat(delegate.commit(conflictSnapshot, branch,
Collections.emptyList())).isTrue();
+ previousSnapshot == null ? null :
previousSnapshot.nextRowId(),
+ null);
+ assertThat(
+ delegate.commit(
+ baseSnapshotUuid,
+ conflictSnapshot,
+ branch,
+ Collections.emptyList()))
+ .isTrue();
return false;
}
@@ -2100,11 +2110,12 @@ public class FileStoreCommitTest {
@Override
public boolean commit(
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
String branch,
List<org.apache.paimon.partition.PartitionStatistics>
statistics)
throws Exception {
- boolean committed = delegate.commit(snapshot, branch, statistics);
+ boolean committed = delegate.commit(baseSnapshotUuid, snapshot,
branch, statistics);
if (firstCommit) {
firstCommit = false;
assertThat(committed).isTrue();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
index b6ae35561f..c571b19c38 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
@@ -1050,6 +1050,7 @@ public class RESTCatalogServer {
return commitSnapshot(
identifier,
requestBody.getTableId(),
+ requestBody.getBaseSnapshotUuid(),
requestBody.getSnapshot(),
requestBody.getStatistics());
}
@@ -2756,9 +2757,10 @@ public class RESTCatalogServer {
public static volatile boolean commitSuccessThrowException = false;
- private MockResponse commitSnapshot(
+ private synchronized MockResponse commitSnapshot(
Identifier identifier,
String tableId,
+ @Nullable String baseSnapshotUuid,
Snapshot snapshot,
List<PartitionStatistics> statistics)
throws Catalog.TableNotExistException {
@@ -2777,6 +2779,12 @@ public class RESTCatalogServer {
if (!tableId.equals(table.catalogEnvironment().uuid())) {
throw new Catalog.TableNotExistException(identifier);
}
+ TableSnapshot currentSnapshot =
tableLatestSnapshotStore.get(identifier.getFullName());
+ String currentSnapshotUuid =
+ currentSnapshot == null ? null :
currentSnapshot.snapshot().uuid();
+ if (!Objects.equals(currentSnapshotUuid, baseSnapshotUuid)) {
+ return mockResponse(new CommitTableResponse(false), 200);
+ }
RenamingSnapshotCommit commit =
new RenamingSnapshotCommit(table.snapshotManager(),
Lock.empty());
String branchName = identifier.getBranchName();
@@ -2785,7 +2793,8 @@ public class RESTCatalogServer {
}
TableSnapshot tableSnapshot;
try {
- boolean success = commit.commit(snapshot, branchName,
Collections.emptyList());
+ boolean success =
+ commit.commit(baseSnapshotUuid, snapshot, branchName,
Collections.emptyList());
if (!success) {
return mockResponse(new CommitTableResponse(success), 200);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
index 1438b49f17..8fc5e38a25 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
@@ -406,6 +406,7 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
restCatalog.commitSnapshot(
identifier,
"",
+ null,
createSnapshotWithMillis(1L,
System.currentTimeMillis()),
new ArrayList<PartitionStatistics>()));
}
@@ -2237,6 +2238,7 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
restCatalog.commitSnapshot(
hasSnapshotTableIdentifier,
"",
+ null,
createSnapshotWithMillis(1L,
System.currentTimeMillis()),
new ArrayList<>()));
@@ -2248,6 +2250,7 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
restCatalog.commitSnapshot(
hasSnapshotTableIdentifier,
"unknown_id",
+ null,
createSnapshotWithMillis(1L,
System.currentTimeMillis()),
new ArrayList<>()));
@@ -2283,6 +2286,39 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
assertThat(snapshot).isEmpty();
}
+ @Test
+ void testCommitSnapshotChecksBaseUuid() throws Exception {
+ RESTCatalogServer.commitSuccessThrowException = false;
+ Identifier identifier = Identifier.create("test_db_a",
"snapshot_uuid_commit");
+ createTable(identifier, Maps.newHashMap(), Lists.newArrayList("col1"));
+ Table table = catalog.getTable(identifier);
+
+ Snapshot first = createSnapshotWithMillis(1L,
System.currentTimeMillis());
+ assertThat(
+ restCatalog.commitSnapshot(
+ identifier, table.uuid(), null, first,
Collections.emptyList()))
+ .isTrue();
+
+ Snapshot second = createSnapshotWithMillis(2L,
System.currentTimeMillis());
+ assertThat(
+ restCatalog.commitSnapshot(
+ identifier,
+ table.uuid(),
+ "wrong-base-snapshot-uuid",
+ second,
+ Collections.emptyList()))
+ .isFalse();
+ assertThat(
+ restCatalog.commitSnapshot(
+ identifier,
+ table.uuid(),
+ first.uuid(),
+ second,
+ Collections.emptyList()))
+ .isTrue();
+
assertThat(restCatalog.loadSnapshot(identifier).get().snapshot()).isEqualTo(second);
+ }
+
@Test
public void testTableRollback() throws Exception {
Identifier identifier = Identifier.create("test_rollback",
"table_for_rollback");
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 436e86c211..459e006f17 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
@@ -32,6 +32,8 @@ public class TagTest {
private final Snapshot snapshot =
new Snapshot(
+ 3,
+ "snapshot-uuid",
0,
0L,
null,
@@ -60,6 +62,7 @@ public class TagTest {
assertEquals(
"{\n"
+ " \"version\" : 3,\n"
+ + " \"uuid\" : \"snapshot-uuid\",\n"
+ " \"id\" : 0,\n"
+ " \"schemaId\" : 0,\n"
+ " \"baseManifestList\" : null,\n"
@@ -85,6 +88,7 @@ public class TagTest {
assertEquals(
"{\n"
+ " \"version\" : 3,\n"
+ + " \"uuid\" : \"snapshot-uuid\",\n"
+ " \"id\" : 0,\n"
+ " \"schemaId\" : 0,\n"
+ " \"baseManifestList\" : null,\n"
diff --git a/paimon-python/pypaimon/api/api_request.py
b/paimon-python/pypaimon/api/api_request.py
index 39a01da93e..e983618387 100644
--- a/paimon-python/pypaimon/api/api_request.py
+++ b/paimon-python/pypaimon/api/api_request.py
@@ -74,12 +74,16 @@ class CreateTableRequest(RESTRequest):
@dataclass
class CommitTableRequest(RESTRequest):
FIELD_TABLE_ID = "tableId"
+ FIELD_BASE_SNAPSHOT_UUID = "baseSnapshotUuid"
FIELD_SNAPSHOT = "snapshot"
FIELD_STATISTICS = "statistics"
table_id: Optional[str] = json_field(FIELD_TABLE_ID)
snapshot: Snapshot = json_field(FIELD_SNAPSHOT)
statistics: List[PartitionStatistics] = json_field(FIELD_STATISTICS)
+ base_snapshot_uuid: Optional[str] = json_field(
+ FIELD_BASE_SNAPSHOT_UUID, default=None
+ )
@dataclass
diff --git a/paimon-python/pypaimon/api/rest_api.py
b/paimon-python/pypaimon/api/rest_api.py
index e1ee43a616..96a0321794 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -346,6 +346,7 @@ class RESTApi:
self,
identifier: Identifier,
table_uuid: Optional[str],
+ base_snapshot_uuid: Optional[str],
snapshot: Snapshot,
statistics: List[PartitionStatistics]
) -> bool:
@@ -355,6 +356,7 @@ class RESTApi:
Args:
identifier: Database name and table name
table_uuid: UUID of the table to avoid wrong commit
+ base_snapshot_uuid: UUID of the snapshot on which the commit is
based
snapshot: Snapshot for committing
statistics: Statistics for this snapshot incremental
@@ -371,7 +373,12 @@ class RESTApi:
if statistics is None:
raise ValueError("Statistics cannot be None")
- request = CommitTableRequest(table_uuid, snapshot, statistics)
+ request = CommitTableRequest(
+ table_id=table_uuid,
+ snapshot=snapshot,
+ statistics=statistics,
+ base_snapshot_uuid=base_snapshot_uuid,
+ )
response = self.client.post_with_response_type(
self.resource_paths.commit_table(
database_name, table_name),
diff --git a/paimon-python/pypaimon/catalog/catalog.py
b/paimon-python/pypaimon/catalog/catalog.py
index c52b65b4ae..d3c9c6e17f 100644
--- a/paimon-python/pypaimon/catalog/catalog.py
+++ b/paimon-python/pypaimon/catalog/catalog.py
@@ -157,6 +157,7 @@ class Catalog(ABC):
self,
identifier: Identifier,
table_uuid: Optional[str],
+ base_snapshot_uuid: Optional[str],
snapshot: Snapshot,
statistics: List[PartitionStatistics]
) -> bool:
@@ -166,6 +167,7 @@ class Catalog(ABC):
Args:
identifier: Path of the table
table_uuid: UUID of the table to avoid wrong commit
+ base_snapshot_uuid: UUID of the snapshot on which the commit is
based
snapshot: Snapshot to be committed
statistics: Statistics information of this change
diff --git a/paimon-python/pypaimon/catalog/filesystem_catalog.py
b/paimon-python/pypaimon/catalog/filesystem_catalog.py
index 76ba5e7798..be5f7e972f 100644
--- a/paimon-python/pypaimon/catalog/filesystem_catalog.py
+++ b/paimon-python/pypaimon/catalog/filesystem_catalog.py
@@ -279,6 +279,7 @@ class FileSystemCatalog(Catalog):
self,
identifier: Identifier,
table_uuid: Optional[str],
+ base_snapshot_uuid: Optional[str],
snapshot: Snapshot,
statistics: List[PartitionStatistics]
) -> bool:
diff --git a/paimon-python/pypaimon/catalog/jdbc_catalog.py
b/paimon-python/pypaimon/catalog/jdbc_catalog.py
index ccf5dbfa1a..5ab4a41702 100644
--- a/paimon-python/pypaimon/catalog/jdbc_catalog.py
+++ b/paimon-python/pypaimon/catalog/jdbc_catalog.py
@@ -545,6 +545,7 @@ class JdbcCatalog(Catalog):
self,
identifier: Identifier,
table_uuid: Optional[str],
+ base_snapshot_uuid: Optional[str],
snapshot: Snapshot,
statistics: List[PartitionStatistics]
) -> bool:
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 612db94aa3..35db60061d 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -100,6 +100,7 @@ class RESTCatalog(Catalog):
self,
identifier: Identifier,
table_uuid: Optional[str],
+ base_snapshot_uuid: Optional[str],
snapshot: Snapshot,
statistics: List[PartitionStatistics]
) -> bool:
@@ -109,6 +110,7 @@ class RESTCatalog(Catalog):
Args:
identifier: Path of the table
table_uuid: UUID of the table to avoid wrong commit
+ base_snapshot_uuid: UUID of the snapshot on which the commit is
based
snapshot: Snapshot to be committed
statistics: Statistics information of this change
@@ -120,7 +122,13 @@ class RESTCatalog(Catalog):
TableNoPermissionException: If no permission to access this table
"""
try:
- return self.rest_api.commit_snapshot(identifier, table_uuid,
snapshot, statistics)
+ return self.rest_api.commit_snapshot(
+ identifier,
+ table_uuid,
+ base_snapshot_uuid,
+ snapshot,
+ statistics,
+ )
except NoSuchResourceException as e:
raise TableNotExistException(identifier) from e
except ForbiddenException as e:
diff --git a/paimon-python/pypaimon/changelog/changelog.py
b/paimon-python/pypaimon/changelog/changelog.py
index 3c32c832c0..f562395390 100644
--- a/paimon-python/pypaimon/changelog/changelog.py
+++ b/paimon-python/pypaimon/changelog/changelog.py
@@ -36,6 +36,7 @@ class Changelog(Snapshot):
"""Create a Changelog from a Snapshot instance."""
return Changelog(
version=snapshot.version,
+ uuid=snapshot.uuid,
id=snapshot.id,
schema_id=snapshot.schema_id,
base_manifest_list=snapshot.base_manifest_list,
diff --git a/paimon-python/pypaimon/common/json_util.py
b/paimon-python/pypaimon/common/json_util.py
index de1421d7ad..6effec7d1e 100644
--- a/paimon-python/pypaimon/common/json_util.py
+++ b/paimon-python/pypaimon/common/json_util.py
@@ -24,7 +24,8 @@ T = TypeVar("T")
def json_field(json_name: str, **kwargs):
"""Create a field with custom JSON name"""
- return field(metadata={"json_name": json_name}, **kwargs)
+ metadata = kwargs.pop("metadata", {})
+ return field(metadata={"json_name": json_name, **metadata}, **kwargs)
def optional_json_field(json_name: str, json_include: str):
@@ -164,4 +165,14 @@ class JSON:
else:
kwargs[field_name] = value
+ for field_info in fields(target_class):
+ json_name = field_info.metadata.get("json_name", field_info.name)
+ if (
+ json_name not in data
+ and "json_missing_default" in field_info.metadata
+ ):
+ kwargs[field_info.name] = field_info.metadata[
+ "json_missing_default"
+ ]
+
return target_class(**kwargs)
diff --git a/paimon-python/pypaimon/snapshot/catalog_snapshot_commit.py
b/paimon-python/pypaimon/snapshot/catalog_snapshot_commit.py
index 9850707d34..f534d61ada 100755
--- a/paimon-python/pypaimon/snapshot/catalog_snapshot_commit.py
+++ b/paimon-python/pypaimon/snapshot/catalog_snapshot_commit.py
@@ -16,7 +16,7 @@
# under the License.
import logging
-from typing import List
+from typing import List, Optional
from pypaimon.catalog.catalog import Catalog
from pypaimon.common.identifier import Identifier
@@ -43,11 +43,17 @@ class CatalogSnapshotCommit(SnapshotCommit):
self.identifier = identifier
self.uuid = uuid
- def commit(self, snapshot: Snapshot, statistics:
List[PartitionStatistics]) -> bool:
+ def commit(
+ self,
+ base_snapshot_uuid: Optional[str],
+ snapshot: Snapshot,
+ statistics: List[PartitionStatistics],
+ ) -> bool:
"""
Commit the snapshot using the catalog.
Args:
+ base_snapshot_uuid: UUID of the snapshot on which the commit is
based
snapshot: The snapshot to commit
statistics: List of partition statistics
@@ -59,7 +65,13 @@ class CatalogSnapshotCommit(SnapshotCommit):
"""
# Call catalog's commit_snapshot method
if hasattr(self.catalog, 'commit_snapshot'):
- success = self.catalog.commit_snapshot(self.identifier, self.uuid,
snapshot, statistics)
+ success = self.catalog.commit_snapshot(
+ self.identifier,
+ self.uuid,
+ base_snapshot_uuid,
+ snapshot,
+ statistics,
+ )
if success:
logger.info("Catalog snapshot commit succeeded for %s,
snapshot id %d", self.identifier, snapshot.id)
return success
diff --git a/paimon-python/pypaimon/snapshot/renaming_snapshot_commit.py
b/paimon-python/pypaimon/snapshot/renaming_snapshot_commit.py
index 93c5121cc7..8b45e75ea7 100644
--- a/paimon-python/pypaimon/snapshot/renaming_snapshot_commit.py
+++ b/paimon-python/pypaimon/snapshot/renaming_snapshot_commit.py
@@ -16,7 +16,7 @@
# under the License.
import logging
-from typing import List
+from typing import List, Optional
from pypaimon.common.file_io import FileIO
@@ -47,11 +47,17 @@ class RenamingSnapshotCommit(SnapshotCommit):
self.snapshot_manager = snapshot_manager
self.file_io: FileIO = snapshot_manager.file_io
- def commit(self, snapshot: Snapshot, statistics:
List[PartitionStatistics]) -> bool:
+ def commit(
+ self,
+ base_snapshot_uuid: Optional[str],
+ snapshot: Snapshot,
+ statistics: List[PartitionStatistics],
+ ) -> bool:
"""
Commit the snapshot using file renaming.
Args:
+ base_snapshot_uuid: UUID of the base snapshot (unused for renaming
commits)
snapshot: The snapshot to commit
statistics: List of partition statistics (currently unused but
kept for interface compatibility)
diff --git a/paimon-python/pypaimon/snapshot/snapshot.py
b/paimon-python/pypaimon/snapshot/snapshot.py
index 85dcc4e928..407f27f0d7 100644
--- a/paimon-python/pypaimon/snapshot/snapshot.py
+++ b/paimon-python/pypaimon/snapshot/snapshot.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import uuid as uuid_module
from dataclasses import dataclass
from typing import Optional
@@ -48,3 +49,11 @@ class Snapshot:
statistics: Optional[str] = optional_json_field("statistics", "non_null")
next_row_id: Optional[int] = optional_json_field("nextRowId", "non_null")
properties: Optional[dict] = optional_json_field("properties", "non_null")
+ uuid: Optional[str] = json_field(
+ "uuid",
+ default_factory=lambda: str(uuid_module.uuid4()),
+ metadata={
+ "json_include": "non_null",
+ "json_missing_default": None,
+ },
+ )
diff --git a/paimon-python/pypaimon/snapshot/snapshot_commit.py
b/paimon-python/pypaimon/snapshot/snapshot_commit.py
index 5b4c996b83..dd9c90aead 100644
--- a/paimon-python/pypaimon/snapshot/snapshot_commit.py
+++ b/paimon-python/pypaimon/snapshot/snapshot_commit.py
@@ -18,7 +18,7 @@
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
-from typing import Dict, List
+from typing import Dict, List, Optional
from pypaimon.common.json_util import json_field
from pypaimon.snapshot.snapshot import Snapshot
@@ -72,11 +72,17 @@ class SnapshotCommit(ABC):
"""Interface to commit snapshot atomically."""
@abstractmethod
- def commit(self, snapshot: Snapshot, statistics:
List[PartitionStatistics]) -> bool:
+ def commit(
+ self,
+ base_snapshot_uuid: Optional[str],
+ snapshot: Snapshot,
+ statistics: List[PartitionStatistics],
+ ) -> bool:
"""
Commit the given snapshot.
Args:
+ base_snapshot_uuid: UUID of the snapshot on which the commit is
based
snapshot: The snapshot to commit
statistics: List of partition statistics
diff --git a/paimon-python/pypaimon/tag/tag.py
b/paimon-python/pypaimon/tag/tag.py
index d793839374..80b4d5c3f1 100644
--- a/paimon-python/pypaimon/tag/tag.py
+++ b/paimon-python/pypaimon/tag/tag.py
@@ -62,6 +62,7 @@ class Tag(Snapshot):
"""
return Tag(
version=snapshot.version,
+ uuid=snapshot.uuid,
id=snapshot.id,
schema_id=snapshot.schema_id,
base_manifest_list=snapshot.base_manifest_list,
@@ -90,6 +91,7 @@ class Tag(Snapshot):
"""Convert this Tag to a Snapshot"""
return Snapshot(
version=self.version,
+ uuid=self.uuid,
id=self.id,
schema_id=self.schema_id,
base_manifest_list=self.base_manifest_list,
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 98c8867fe6..c6451f0a9b 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -16,6 +16,7 @@
# under the License.
import unittest
+import uuid
from datetime import datetime
from unittest.mock import MagicMock, Mock, patch
@@ -425,6 +426,7 @@ class TestFileStoreCommit(unittest.TestCase):
latest_snapshot = Mock()
latest_snapshot.id = 3
+ latest_snapshot.uuid = "base-snapshot-uuid"
latest_snapshot.total_record_count = 10
latest_snapshot.index_manifest = "index-manifest-existing"
@@ -443,10 +445,16 @@ class TestFileStoreCommit(unittest.TestCase):
)
self.assertTrue(result.is_success())
+ self.assertEqual(
+ "base-snapshot-uuid",
+ snapshot_commit.commit.call_args[0][0],
+ )
+ committed_snapshot = snapshot_commit.commit.call_args[0][1]
self.assertEqual(
"index-manifest-existing",
- snapshot_commit.commit.call_args[0][0].index_manifest
+ committed_snapshot.index_manifest
)
+ self.assertEqual(str(uuid.UUID(committed_snapshot.uuid)),
committed_snapshot.uuid)
def test_null_partition_value(
self, mock_manifest_list_manager, mock_manifest_file_manager):
diff --git a/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
b/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
index 356eaaa883..861ae19eac 100644
--- a/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
+++ b/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
@@ -103,12 +103,12 @@ class OverwriteChangesCacheTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
self._append(pd.DataFrame({'f0': [99], 'f1':
[f'x{cas["fails"]}']}))
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
OverwriteChangesProvider._full_scan_manifest_entries = spy_full
@@ -174,12 +174,12 @@ class OverwriteChangesCacheTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
self._append(pd.DataFrame({'f0': [1], 'f1':
[f'y{cas["fails"]}']}))
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
OverwriteChangesProvider._full_scan_manifest_entries = spy_full
@@ -234,12 +234,12 @@ class OverwriteChangesCacheTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
concurrent_fn(cas['fails'])
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
diff --git a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
index 738d455b85..e18439ea84 100644
--- a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
+++ b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
@@ -114,14 +114,14 @@ class OverwriteCommitConflictTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
# Each conflict appends to an unrelated partition (f0=99),
advancing
# latest, then fails our CAS.
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
self._append(pd.DataFrame({'f0': [99], 'f1':
[f'x{cas["fails"]}']}))
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
@@ -178,12 +178,12 @@ class OverwriteCommitConflictTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
self._append(pd.DataFrame({'f0': [1], 'f1':
[f'y{cas["fails"]}']}))
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
@@ -255,12 +255,12 @@ class OverwriteCommitConflictTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
self._append(pd.DataFrame({'f0': [99], 'f1': ['x']}))
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
@@ -302,12 +302,12 @@ class OverwriteCommitConflictTest(unittest.TestCase):
orig_cas = fsc.snapshot_commit.commit
cas = {'fails': 0}
- def patched_cas(snapshot, statistics):
+ def patched_cas(base_snapshot_uuid, snapshot, statistics):
if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
cas['fails'] += 1
concurrent_fn(f'z{cas["fails"]}')
return False
- return orig_cas(snapshot, statistics)
+ return orig_cas(base_snapshot_uuid, snapshot, statistics)
fsc.snapshot_commit.commit = patched_cas
diff --git a/paimon-python/pypaimon/tests/rest/api_test.py
b/paimon-python/pypaimon/tests/rest/api_test.py
index a951461ec7..da0220e7e0 100644
--- a/paimon-python/pypaimon/tests/rest/api_test.py
+++ b/paimon-python/pypaimon/tests/rest/api_test.py
@@ -330,17 +330,17 @@ class ApiTest(unittest.TestCase):
# Test commit_snapshot with None identifier
with self.assertRaises(ValueError) as context:
- rest_api.commit_snapshot(None, "uuid", Mock(), [])
+ rest_api.commit_snapshot(None, "uuid", None, Mock(), [])
self.assertIn("Identifier cannot be None", str(context.exception))
# Test commit_snapshot with None snapshot
with self.assertRaises(ValueError) as context:
- rest_api.commit_snapshot(Mock(), "uuid", None, [])
+ rest_api.commit_snapshot(Mock(), "uuid", None, None, [])
self.assertIn("Snapshot cannot be None", str(context.exception))
# Test commit_snapshot with None statistics
with self.assertRaises(ValueError) as context:
- rest_api.commit_snapshot(Mock(), "uuid", Mock(), None)
+ rest_api.commit_snapshot(Mock(), "uuid", None, Mock(), None)
self.assertIn("Statistics cannot be None", str(context.exception))
def test_list_tables_paged_with_table_type_param(self):
diff --git
a/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
b/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
index 975c6f643b..4b7ac5d8b0 100644
--- a/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
@@ -16,8 +16,11 @@
# specific language governing permissions and limitations
# under the License.
+import json
import time
import unittest
+import uuid
+from dataclasses import replace
from unittest.mock import Mock, patch
import pyarrow as pa
@@ -30,6 +33,7 @@ from pypaimon.catalog.catalog_context import CatalogContext
from pypaimon.catalog.catalog_exception import TableNotExistException
from pypaimon.catalog.rest.rest_catalog import RESTCatalog
from pypaimon.common.identifier import Identifier
+from pypaimon.common.json_util import JSON
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.snapshot.snapshot_commit import PartitionStatistics
from pypaimon.tests.rest.rest_base_test import RESTBaseTest
@@ -102,6 +106,7 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
result = catalog.commit_snapshot(
self.identifier,
"test-uuid",
+ "base-snapshot-uuid",
self.test_snapshot,
self.test_statistics
)
@@ -113,6 +118,7 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
mock_api_instance.commit_snapshot.assert_called_once_with(
self.identifier,
"test-uuid",
+ "base-snapshot-uuid",
self.test_snapshot,
self.test_statistics
)
@@ -134,6 +140,7 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
catalog.commit_snapshot(
self.identifier,
"test-uuid",
+ "base-snapshot-uuid",
self.test_snapshot,
self.test_statistics
)
@@ -155,6 +162,7 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
catalog.commit_snapshot(
self.identifier,
"test-uuid",
+ "base-snapshot-uuid",
self.test_snapshot,
self.test_statistics
)
@@ -168,15 +176,39 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
request = CommitTableRequest(
table_id="test-uuid",
+ base_snapshot_uuid="base-snapshot-uuid",
snapshot=self.test_snapshot,
statistics=self.test_statistics
)
# Verify request fields
self.assertEqual(request.table_id, "test-uuid")
+ self.assertEqual(request.base_snapshot_uuid, "base-snapshot-uuid")
self.assertEqual(request.snapshot, self.test_snapshot)
self.assertEqual(request.statistics, self.test_statistics)
+ legacy_request = json.loads(JSON.to_json(request))
+ legacy_request.pop("baseSnapshotUuid")
+ self.assertIsNone(
+ JSON.from_json(
+ json.dumps(legacy_request), CommitTableRequest
+ ).base_snapshot_uuid
+ )
+
+ def test_snapshot_uuid_round_trip_and_legacy_compatibility(self):
+ snapshot_uuid = self.test_snapshot.uuid
+ self.assertEqual(str(uuid.UUID(snapshot_uuid)), snapshot_uuid)
+ self.assertEqual(
+ snapshot_uuid,
+ JSON.from_json(JSON.to_json(self.test_snapshot), Snapshot).uuid,
+ )
+
+ legacy_snapshot = json.loads(JSON.to_json(self.test_snapshot))
+ legacy_snapshot.pop("uuid")
+ self.assertIsNone(
+ JSON.from_json(json.dumps(legacy_snapshot), Snapshot).uuid
+ )
+
def test_commit_table_response_creation(self):
"""Test CommitTableResponse creation."""
from pypaimon.api.api_response import CommitTableResponse
@@ -209,6 +241,7 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
result = api.commit_snapshot(
self.identifier,
"test-uuid",
+ "base-snapshot-uuid",
self.test_snapshot,
self.test_statistics
)
@@ -218,6 +251,10 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
# Verify client was called correctly
mock_client.post_with_response_type.assert_called_once()
+ request = mock_client.post_with_response_type.call_args[0][1]
+ self.assertEqual(
+ request.base_snapshot_uuid, "base-snapshot-uuid"
+ )
def test_rest_catalog_commit_snapshot_with_lance_format(self):
"""Test snapshot commit with Lance format table."""
@@ -296,6 +333,42 @@ class TestRESTCatalogCommitSnapshot(unittest.TestCase):
class TestRESTCommit(RESTBaseTest):
+ def test_commit_snapshot_checks_base_uuid(self):
+ latest = self.table.snapshot_manager().get_latest_snapshot()
+ next_snapshot = replace(
+ latest,
+ id=latest.id + 1,
+ uuid=str(uuid.uuid4()),
+ )
+ table_uuid = self.table.catalog_environment.uuid
+
+ self.assertFalse(
+ self.rest_catalog.commit_snapshot(
+ self.table.identifier,
+ table_uuid,
+ "wrong-base-snapshot-uuid",
+ next_snapshot,
+ [],
+ )
+ )
+ self.assertEqual(
+ latest.id,
+ self.table.snapshot_manager().get_latest_snapshot().id,
+ )
+ self.assertTrue(
+ self.rest_catalog.commit_snapshot(
+ self.table.identifier,
+ table_uuid,
+ latest.uuid,
+ next_snapshot,
+ [],
+ )
+ )
+ self.assertEqual(
+ next_snapshot,
+ self.table.snapshot_manager().get_latest_snapshot(),
+ )
+
def test_multiple_row_tracking_commits_preserve_all_rows(self):
pa_schema = pa.schema([('id', pa.int32())])
schema = Schema.from_pyarrow_schema(
@@ -347,8 +420,8 @@ class TestRESTCommit(RESTBaseTest):
real_commit = tc.file_store_commit.snapshot_commit.commit
- def commit_then_raise(sn, st):
- real_commit(sn, st)
+ def commit_then_raise(base_snapshot_uuid, sn, st):
+ real_commit(base_snapshot_uuid, sn, st)
raise RuntimeError("simulated")
with patch.object(tc.file_store_commit.snapshot_commit, 'commit',
side_effect=commit_then_raise):
diff --git a/paimon-python/pypaimon/tests/rest/rest_permission_test.py
b/paimon-python/pypaimon/tests/rest/rest_permission_test.py
index def1f4060d..9f4f7d5864 100644
--- a/paimon-python/pypaimon/tests/rest/rest_permission_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_permission_test.py
@@ -156,6 +156,7 @@ class RESTPermissionTest(RESTBaseTest):
self.rest_catalog.commit_snapshot(
identifier,
"test-uuid",
+ None,
test_snapshot,
test_statistics
)
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py
b/paimon-python/pypaimon/tests/rest/rest_server.py
index d1fbd528b1..69d4361bb5 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -1082,6 +1082,16 @@ class RESTCatalogServer:
ErrorResponse("SNAPSHOT", None, "Snapshot is required for
commit operation", 400), 400
)
+ table = self._get_file_table(identifier)
+ current_snapshot = table.snapshot_manager().get_latest_snapshot()
+ current_snapshot_uuid = (
+ current_snapshot.uuid if current_snapshot else None
+ )
+ if current_snapshot_uuid != commit_request.base_snapshot_uuid:
+ return self._mock_response(
+ CommitTableResponse(success=False), 200
+ )
+
# Write snapshot to file system
self._write_snapshot_files(identifier, commit_request.snapshot,
commit_request.statistics)
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index 30d2dcaa85..61fece4934 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -621,7 +621,8 @@ class DynamicBucketTest(unittest.TestCase):
original_snapshot_commit = commit.file_store_commit.snapshot_commit
calls = 0
- def lose_first_compare_and_set(snapshot, statistics):
+ def lose_first_compare_and_set(
+ base_snapshot_uuid, snapshot, statistics):
nonlocal calls
calls += 1
concurrent_writer, concurrent_commit, concurrent_messages = (
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index 801771bf14..8c7484071a 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -615,7 +615,11 @@ class FileStoreCommit:
# Use SnapshotCommit for atomic commit
try:
with self.snapshot_commit:
- success = self.snapshot_commit.commit(snapshot_data,
statistics)
+ success = self.snapshot_commit.commit(
+ latest_snapshot.uuid if latest_snapshot else None,
+ snapshot_data,
+ statistics,
+ )
if not success:
commit_time_s = (int(time.time() * 1000) - start_millis) /
1000
logger.warning(