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 d7f3d04c5a [core][spark] Fix RTAS reads after incompatible nested type
replace (#8469)
d7f3d04c5a is described below
commit d7f3d04c5a149d57de2224431f9cb80c653c0a29
Author: Zouxxyy <[email protected]>
AuthorDate: Tue Jul 7 15:25:50 2026 +0800
[core][spark] Fix RTAS reads after incompatible nested type replace (#8469)
Fix `CREATE OR REPLACE TABLE ... AS SELECT` after an incompatible nested
type replace.
The issue happens in the two-snapshot RTAS flow:
1. a truncate snapshot clears the old visible files
2. the following RTAS append snapshot writes the new result
Without extra handling, the RTAS append snapshot can still inherit stale
snapshot state from the truncate snapshot. This is especially
problematic for incompatible schema replacement, where latest-table
reads may observe old metadata/state that should not survive the
replace.
This change:
- adds a Spark SQL regression in `DDLTestBase` that reproduces the
incompatible nested-type RTAS read failure
- resets inherited manifest state for the RTAS append-after-truncate
path
- also clears inherited index manifest and statistics metadata in that
path
- adds a focused core unit test to verify the index/statistics reset
behavior
---
.../paimon/operation/FileStoreCommitImpl.java | 50 ++++++++++++-------
.../paimon/operation/FileStoreCommitTest.java | 45 +++++++++++++++++
.../org/apache/paimon/spark/sql/DDLTestBase.scala | 57 ++++++++++++++++++++++
3 files changed, 135 insertions(+), 17 deletions(-)
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 d5807e33d8..cb2357eabe 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
@@ -849,6 +849,16 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
null);
}
+ private boolean isRtasAfterTruncate(@Nullable Snapshot latestSnapshot,
CommitKind commitKind) {
+ if (latestSnapshot == null || operation == null) {
+ return false;
+ }
+
+ return latestSnapshot.operation() == Snapshot.Operation.TRUNCATE
+ && (operation == Snapshot.Operation.REPLACE_TABLE_AS_SELECT
+ || operation ==
Snapshot.Operation.CREATE_OR_REPLACE_TABLE_AS_SELECT);
+ }
+
@VisibleForTesting
CommitResult tryCommitOnce(
@Nullable RetryCommitResult retryResult,
@@ -1016,23 +1026,29 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
oldIndexManifest = latestSnapshot.indexManifest();
}
- // try to merge old manifest files to create base manifest list
- ManifestMergeReuse manifestMergeReuse =
- tryReuseManifestMergeResult(retryResult,
mergeBeforeManifests);
- skipManifestMergeOnRetry = manifestMergeReuse == null &&
retryResult != null;
- if (manifestMergeReuse != null) {
- mergeBeforeManifests = manifestMergeReuse.preservedManifests;
- mergeAfterManifests = manifestMergeReuse.mergeAfterManifests;
- } else if (skipManifestMergeOnRetry) {
- mergeAfterManifests = mergeBeforeManifests;
+ boolean resetSnapshotStateForRtas =
isRtasAfterTruncate(latestSnapshot, commitKind);
+ if (resetSnapshotStateForRtas) {
+ mergeBeforeManifests = emptyList();
+ mergeAfterManifests = emptyList();
+ oldIndexManifest = null;
} else {
- mergeAfterManifests =
- ManifestFileMerger.merge(
- mergeBeforeManifests,
- manifestFile,
- partitionType,
- options,
- ioManager);
+ ManifestMergeReuse manifestMergeReuse =
+ tryReuseManifestMergeResult(retryResult,
mergeBeforeManifests);
+ skipManifestMergeOnRetry = manifestMergeReuse == null &&
retryResult != null;
+ if (manifestMergeReuse != null) {
+ mergeBeforeManifests =
manifestMergeReuse.preservedManifests;
+ mergeAfterManifests =
manifestMergeReuse.mergeAfterManifests;
+ } else if (skipManifestMergeOnRetry) {
+ mergeAfterManifests = mergeBeforeManifests;
+ } else {
+ mergeAfterManifests =
+ ManifestFileMerger.merge(
+ mergeBeforeManifests,
+ manifestFile,
+ partitionType,
+ options,
+ ioManager);
+ }
}
baseManifestList = manifestList.write(mergeAfterManifests);
@@ -1081,7 +1097,7 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
String statsFileName = null;
if (newStatsFileName != null) {
statsFileName = newStatsFileName;
- } else if (latestSnapshot != null) {
+ } else if (latestSnapshot != null && !resetSnapshotStateForRtas) {
Optional<Statistics> previousStatistic =
statsFileHandler.readStats(latestSnapshot);
if (previousStatistic.isPresent()) {
if (previousStatistic.get().schemaId() != latestSchemaId) {
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 10bce28e94..e3903b5077 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
@@ -1132,6 +1132,51 @@ public class FileStoreCommitTest {
.isEqualTo(0);
}
+ @Test
+ public void testRtasAppendAfterTruncateResetsInheritedIndexAndStats()
throws Exception {
+ TestFileStore store = createStore(false, 1,
CoreOptions.ChangelogProducer.NONE);
+ BinaryRow partition = gen.getPartition(gen.next());
+
+ store.commitData(generateDataList(1), s -> partition, kv -> 0);
+ try (FileStoreCommitImpl commit = store.newCommit()) {
+ commit.commit(indexCommittable(partition, "stale-index", 0, 0),
false);
+ }
+
+ Snapshot latestSnapshot =
checkNotNull(store.snapshotManager().latestSnapshot());
+ HashMap<String, ColStats<?>> fakeColStatsMap = new HashMap<>();
+ fakeColStatsMap.put("orderId", ColStats.newColStats(3, 1L, 1L, 1L, 0L,
8L, 8L));
+ Statistics fakeStats =
+ new Statistics(
+ latestSnapshot.id(), latestSnapshot.schemaId(), 1L,
100L, fakeColStatsMap);
+ try (FileStoreCommitImpl commit = store.newCommit()) {
+ commit.commitStatistics(fakeStats, Long.MAX_VALUE);
+ }
+
+ try (FileStoreCommitImpl truncateCommit = store.newCommit()) {
+ truncateCommit.withOperation(Snapshot.Operation.TRUNCATE);
+ truncateCommit.truncateTable(1L);
+ }
+
+ List<KeyValue> replacement = generateDataList(1);
+ store.commitDataImpl(
+ replacement,
+ s -> partition,
+ kv -> 0,
+ false,
+ null,
+ null,
+ Collections.emptyList(),
+ (commit, committable) -> {
+
commit.withOperation(Snapshot.Operation.REPLACE_TABLE_AS_SELECT);
+ commit.commit(committable, false);
+ });
+
+ Snapshot rtasSnapshot =
checkNotNull(store.snapshotManager().latestSnapshot());
+
assertThat(rtasSnapshot.operation()).isEqualTo(Snapshot.Operation.REPLACE_TABLE_AS_SELECT);
+ assertThat(rtasSnapshot.indexManifest()).isNull();
+ assertThat(rtasSnapshot.statistics()).isNull();
+ }
+
@Test
public void testDropStatsForOverwrite() throws Exception {
TestFileStore store = createStore(false);
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DDLTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DDLTestBase.scala
index 2c2405c366..9a63cab33d 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DDLTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DDLTestBase.scala
@@ -406,6 +406,63 @@ abstract class DDLTestBase extends PaimonSparkTestBase {
}
}
+ test(
+ "Paimon DDL: CREATE OR REPLACE TABLE AS SELECT reads latest rows after
incompatible nested type replace") {
+ assume(gteqSpark3_4)
+ withTable("src", "t") {
+ sql("""
+ |CREATE TABLE src (
+ | id BIGINT,
+ | payload DOUBLE,
+ | name_a STRING,
+ | name_b STRING
+ |)
+ |USING paimon
+ |TBLPROPERTIES ('bucket' = '-1')
+ |""".stripMargin)
+ sql("""
+ |INSERT INTO src VALUES
+ | (1, 1.1D, 'a', 'x'),
+ | (2, 2.2D, 'b', 'y')
+ |""".stripMargin)
+
+ sql("""
+ |CREATE TABLE t
+ |USING paimon
+ |TBLPROPERTIES ('bucket' = '-1')
+ |AS SELECT * FROM src
+ |""".stripMargin)
+
+ sql("""
+ |CREATE OR REPLACE TABLE t
+ |USING paimon
+ |TBLPROPERTIES ('bucket' = '-1')
+ |AS
+ |SELECT
+ | id,
+ | named_struct(
+ | 'items_before', array(name_a),
+ | 'items_after', array(name_b)
+ | ) AS payload,
+ | name_a,
+ | name_b
+ |FROM src
+ |""".stripMargin)
+
+ Assertions.assertEquals("struct",
spark.table("t").schema("payload").dataType.typeName)
+
+ checkAnswer(
+ sql("""
+ |SELECT id, payload.items_before, payload.items_after, name_a,
name_b
+ |FROM t
+ |WHERE name_a = 'a'
+ |LIMIT 1
+ |""".stripMargin),
+ Row(1L, Seq("a"), Seq("x"), "a", "x") :: Nil
+ )
+ }
+ }
+
test("Paimon DDL: REPLACE TABLE supports incompatible schema and preserves
old snapshots") {
assume(gteqSpark3_4)
withTable("t") {