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 a657beb510 [core][spark] Let DROP PARTITION repair overlapping Format
Table locations (#9712)
a657beb510 is described below
commit a657beb51014c5dbc51628459549d8f634e9e415
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Sep 10 13:07:39 2026 +0800
[core][spark] Let DROP PARTITION repair overlapping Format Table locations
(#9712)
---
.../FormatTablePartitionRegistryValidator.java | 46 ++++++-
.../FormatTablePartitionRegistryValidatorTest.java | 148 +++++++++++++++++++++
.../paimon/spark/format/PaimonFormatTable.scala | 15 ++-
.../FormatTablePartitionManagementTest.scala | 70 ++++++++++
4 files changed, 273 insertions(+), 6 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidator.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidator.java
index 44c4f787af..1758389641 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidator.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidator.java
@@ -33,6 +33,7 @@ public final class FormatTablePartitionRegistryValidator {
private FormatTablePartitionRegistryValidator() {}
+ /** Validates each partition and rejects a registry whose partitions claim
each other's data. */
public static void validatePartitionLocations(
List<Partition> partitions,
List<String> partitionKeys,
@@ -40,6 +41,47 @@ public final class FormatTablePartitionRegistryValidator {
String tableName,
boolean onlyValueInPath,
@Nullable CatalogContext catalogContext) {
+ validate(
+ partitions,
+ partitionKeys,
+ tablePath,
+ tableName,
+ onlyValueInPath,
+ catalogContext,
+ true);
+ }
+
+ /**
+ * Validates every partition on its own: a complete spec, and a location
that parses and stays
+ * out of the table directory. Partitions are not compared to each other,
which is what an
+ * operation needs when it only has to know where each partition lives
rather than whether the
+ * registry as a whole is consistent.
+ */
+ public static void validateEachPartitionLocation(
+ List<Partition> partitions,
+ List<String> partitionKeys,
+ Path tablePath,
+ String tableName,
+ boolean onlyValueInPath,
+ @Nullable CatalogContext catalogContext) {
+ validate(
+ partitions,
+ partitionKeys,
+ tablePath,
+ tableName,
+ onlyValueInPath,
+ catalogContext,
+ false);
+ }
+
+ private static void validate(
+ List<Partition> partitions,
+ List<String> partitionKeys,
+ Path tablePath,
+ String tableName,
+ boolean onlyValueInPath,
+ @Nullable CatalogContext catalogContext,
+ boolean rejectPartitionsClaimingEachOther) {
FormatTablePartitionPathResolver resolver =
new FormatTablePartitionPathResolver(
tablePath, tableName, onlyValueInPath, catalogContext);
@@ -61,7 +103,9 @@ public final class FormatTablePartitionRegistryValidator {
resolver.resolve(
orderedSpec,
FormatTablePartitionPathResolver.customLocation(partition));
- resolver.validateAndRecord(orderedSpec, resolved);
+ if (rejectPartitionsClaimingEachOther) {
+ resolver.validateAndRecord(orderedSpec, resolved);
+ }
}
}
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidatorTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidatorTest.java
new file mode 100644
index 0000000000..0286ba48bf
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionRegistryValidatorTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.table.format;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.CoreOptions.PATH;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link FormatTablePartitionRegistryValidator}. */
+class FormatTablePartitionRegistryValidatorTest {
+
+ private static final List<String> PARTITION_KEYS = Arrays.asList("year",
"month");
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ void
testPartitionsClaimingEachOtherAreRejectedOnlyWhenComparedToEachOther() {
+ Path tablePath = new Path(new Path(tempDir.toUri()), "table");
+ Path external = new Path(new Path(tempDir.toUri()), "external");
+ List<Partition> registry =
+ Arrays.asList(
+ partitionAt(partitionSpec("2026", "01"),
external.toString()),
+ partitionAt(
+ partitionSpec("2026", "02"),
+ new Path(external, "child").toString()));
+
+ assertThatThrownBy(() -> validateTogether(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("overlapping locations");
+
+ // Each location is well formed on its own, so an operation that only
needs to know where
+ // every partition lives can go ahead - dropping one of the two is
what repairs the pair.
+ assertThatCode(() -> validateEach(registry,
tablePath)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testLocationInsideTheTableDirectoryIsRejectedEitherWay() {
+ Path tablePath = new Path(new Path(tempDir.toUri()), "table");
+ List<Partition> registry =
+ Collections.singletonList(
+ partitionAt(
+ partitionSpec("2026", "01"),
+ new Path(tablePath,
"year=2026/month=02").toString()));
+
+ assertThatThrownBy(() -> validateTogether(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("invalid custom location");
+ assertThatThrownBy(() -> validateEach(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("invalid custom location");
+ }
+
+ @Test
+ void testIncompleteSpecIsRejectedEitherWay() {
+ Path tablePath = new Path(new Path(tempDir.toUri()), "table");
+ List<Partition> registry =
+ Collections.singletonList(
+ partitionAt(Collections.singletonMap("year", "2026"),
null));
+
+ assertThatThrownBy(() -> validateTogether(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("incomplete partition spec");
+ assertThatThrownBy(() -> validateEach(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("incomplete partition spec");
+ }
+
+ @Test
+ void testTwoRowsForOneSpecAreRejectedOnlyWhenComparedToEachOther() {
+ Path tablePath = new Path(new Path(tempDir.toUri()), "table");
+ Path external = new Path(new Path(tempDir.toUri()), "external");
+ List<Partition> registry =
+ Arrays.asList(
+ partitionAt(partitionSpec("2026", "01"),
external.toString()),
+ partitionAt(
+ partitionSpec("2026", "01"),
+ new Path(new Path(tempDir.toUri()),
"other").toString()));
+
+ assertThatThrownBy(() -> validateTogether(registry, tablePath))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("overlapping locations");
+ assertThatCode(() -> validateEach(registry,
tablePath)).doesNotThrowAnyException();
+ }
+
+ private static void validateTogether(List<Partition> registry, Path
tablePath) {
+ FormatTablePartitionRegistryValidator.validatePartitionLocations(
+ registry, PARTITION_KEYS, tablePath, "db.table", false, null);
+ }
+
+ private static void validateEach(List<Partition> registry, Path tablePath)
{
+ FormatTablePartitionRegistryValidator.validateEachPartitionLocation(
+ registry, PARTITION_KEYS, tablePath, "db.table", false, null);
+ }
+
+ private static Partition partitionAt(Map<String, String> spec, String
location) {
+ Map<String, String> options =
+ location == null ? null : Collections.singletonMap(PATH.key(),
location);
+ return new Partition(
+ spec,
+ 0,
+ 0,
+ 0,
+ 0,
+ PartitionStatistics.UNKNOWN_TOTAL_BUCKETS,
+ false,
+ null,
+ null,
+ null,
+ null,
+ options);
+ }
+
+ private static Map<String, String> partitionSpec(String year, String
month) {
+ LinkedHashMap<String, String> spec = new LinkedHashMap<>();
+ spec.put("year", year);
+ spec.put("month", month);
+ return spec;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
index 046a12015b..c610b9d031 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
@@ -362,10 +362,10 @@ case class PaimonFormatTable(table: FormatTable)
}
/**
- * Resolves DROP requests from one validated view of the catalog registry.
The boolean array is
- * aligned with the requests and tells callers which complete specs are
registered; partial-spec
- * entries are not used for existence reporting. The returned partitions are
the deduplicated
- * registered leaves covered by all requests.
+ * Resolves DROP requests from one view of the catalog registry, with every
partition validated on
+ * its own. The boolean array is aligned with the requests and tells callers
which complete specs
+ * are registered; partial-spec entries are not used for existence
reporting. The returned
+ * partitions are the deduplicated registered leaves covered by all requests.
*/
private[spark] def resolveFormatTablePartitionsForDrop(
partitionNames: Array[Array[String]],
@@ -385,7 +385,12 @@ case class PaimonFormatTable(table: FormatTable)
requested.foreach(spec =>
resolvePartitionPathWithinTable(orderedSpec(spec), onlyValueInPath))
val manager = requirePartitionManager()
val registry = manager.listPartitions(Collections.emptyMap[String,
String](), null)
- FormatTablePartitionRegistryValidator.validatePartitionLocations(
+ // A drop unregisters partitions and deletes default-location directories;
a custom location is
+ // never probed or deleted. Every partition still has to say where it
lives - a location inside
+ // the table directory could name a directory this drop deletes - but two
partitions that claim
+ // each other's data elsewhere do not change what this drop removes, and
refusing them here
+ // would leave that pair with no way out: dropping one of the two is how
it is repaired.
+ FormatTablePartitionRegistryValidator.validateEachPartitionLocation(
registry,
table.partitionKeys(),
new Path(table.location()),
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
index 06a152d2d6..5329e578ba 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
@@ -577,6 +577,76 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
}
}
+ test("catalog-managed DROP removes one of two partitions whose custom
locations overlap") {
+ val fileIO = LocalFileIO.create()
+ val tablePath =
+ new
Path(Files.createTempDirectory("catalog-partition-format-drop-overlapping-pair").toUri)
+ val outerDir =
+ new
Path(Files.createTempDirectory("catalog-partition-format-drop-overlapping-outer").toUri)
+ val nestedDir = new Path(outerDir, "child")
+ val outerFile = new Path(outerDir, "outer.csv")
+ val nestedFile = new Path(nestedDir, "nested.csv")
+ def customPartition(spec: Map[String, String], location: Path): Partition =
+ new Partition(
+ spec.asJava,
+ 0L,
+ 0L,
+ 0L,
+ 0L,
+ 0,
+ false,
+ null,
+ null,
+ null,
+ null,
+ Map(CoreOptions.PATH.key() -> location.toString).asJava)
+ val outerPartition = customPartition(partitionSpec(20260715, 10), outerDir)
+ val nestedPartition = customPartition(partitionSpec(20260716, 11),
nestedDir)
+ var dropped = Seq.empty[Map[String, String]]
+ val gateway = new FormatTablePartitionManager {
+ override def createPartitions(
+ partitions: JList[JMap[String, String]],
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean,
+ partitionOptions: JList[JMap[String, String]]): Unit = {}
+
+ override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit =
+ dropped = partitions.asScala.map(_.asScala.toMap).toSeq
+
+ override def listPartitionsByNames(
+ partitions: JList[JMap[String, String]]): JList[Partition] =
+ Collections.singletonList(nestedPartition)
+
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
+ Seq(outerPartition, nestedPartition).asJava
+ }
+
+ try {
+ fileIO.mkdirs(nestedDir)
+ fileIO.writeFile(outerFile, "outer", false)
+ fileIO.writeFile(nestedFile, "nested", false)
+ val sparkTable =
+ new PaimonFormatTable(
+ formatTableWithCatalogManagedPartitions(fileIO, tablePath.toString,
gateway))
+
+ // A registry where one custom location sits inside another cannot be
read, and dropping one
+ // of the two is the way out of it.
+ assert(
+ sparkTable
+ .dropFormatTablePartitions(Array(Array("dt", "hh")),
Array(partitionRow(20260716, 11))))
+
+ assert(dropped == Seq(Map("dt" -> "20260716", "hh" -> "11")))
+ assert(fileIO.exists(outerFile))
+ assert(fileIO.exists(nestedFile))
+ } finally {
+ fileIO.delete(tablePath, true)
+ fileIO.delete(outerDir, true)
+ }
+ }
+
test("catalog-managed DROP of a custom-location partition leaves all data in
place") {
val fileIO = LocalFileIO.create()
val tablePath =