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 a26481b8b8 [spark] Let MSCK see the null partition of a value-only 
format table (#9521)
a26481b8b8 is described below

commit a26481b8b84f4fb82421ccb5932c85a31de22a7e
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Wed Sep 2 14:40:46 2026 +0800

    [spark] Let MSCK see the null partition of a value-only format table (#9521)
---
 .../spark/format/FormatTablePartitionRepair.java   |  14 +-
 .../format/FormatTablePartitionRepairTest.java     | 171 +++++++++++++++++++++
 2 files changed, 183 insertions(+), 2 deletions(-)

diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
index d213f4d86b..40dcea70aa 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
@@ -94,7 +94,14 @@ public class FormatTablePartitionRepair {
     private static List<Map<String, String>> 
listFilesystemPartitionSpecs(FormatTable formatTable) {
         // Raw directory names rather than the table scan: the scan casts each 
value to its column
         // type and back (month=01 -> 1), producing specs that no longer name 
the real directory,
-        // while the write path registers the raw value.
+        // while the write path registers the raw value. That is also why no 
partition type is
+        // passed below: it would re-enable the cast this discovery 
deliberately avoids.
+        //
+        // The default partition name is still needed. In a value-only layout 
the null partition is
+        // a bare "__DEFAULT_PARTITION__" directory, which the generic 
hidden-directory rule ("_"
+        // prefix) skips unless the listing knows the name is meaningful. 
Without it a repair never
+        // registers the null partition, and a SYNC/DROP sees it as 
registered-but-deleted and
+        // unregisters a partition that still holds data.
         boolean onlyValueInPath =
                 new 
CoreOptions(formatTable.options()).formatTablePartitionOnlyValueInPath();
         List<Pair<LinkedHashMap<String, String>, Path>> found =
@@ -103,7 +110,10 @@ public class FormatTablePartitionRepair {
                         new Path(formatTable.location()),
                         formatTable.partitionKeys().size(),
                         formatTable.partitionKeys(),
-                        onlyValueInPath);
+                        onlyValueInPath,
+                        null,
+                        null,
+                        formatTable.defaultPartName());
         List<Map<String, String>> specs = new ArrayList<>(found.size());
         for (Pair<LinkedHashMap<String, String>, Path> pair : found) {
             PartitionPathUtils.validatePartitionSpecForPath(pair.getKey(), 
onlyValueInPath);
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
index 1ee208f2b9..811d0328aa 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
@@ -282,6 +282,111 @@ class FormatTablePartitionRepairTest {
         assertThat(catalog.createIgnoreFlags).containsExactly(true);
     }
 
+    @Test
+    void repairAddsTheNullPartitionDirectoryInValueOnlyLayout() throws 
Exception {
+        // A value-only layout writes the null partition as a bare 
__DEFAULT_PARTITION__ directory,
+        // which the generic hidden-directory rule ("_" prefix) would swallow.
+        writeDataFile(tempDir.resolve("20260701"));
+        writeDataFile(tempDir.resolve("__DEFAULT_PARTITION__"));
+
+        RecordingPartitionManager catalog = new RecordingPartitionManager();
+        PaimonFormatTable sparkTable =
+                new PaimonFormatTable(formatTable(tempDir.toUri().toString(), 
true, catalog));
+
+        int applied = FormatTablePartitionRepair.repair(sparkTable, true, 
false);
+
+        assertThat(applied).isEqualTo(2);
+        assertThat(catalog.createdPartitions)
+                .containsExactly(
+                        Arrays.asList(spec("dt", "20260701"), spec("dt", 
"__DEFAULT_PARTITION__")));
+        assertThat(catalog.droppedPartitions).isEmpty();
+    }
+
+    @Test
+    void repairKeepsTheRegisteredNullPartitionInValueOnlyLayout() throws 
Exception {
+        // Both directories exist, so a SYNC must be a no-op. Missing the null 
partition on the
+        // filesystem side makes it look "registered but deleted" and silently 
unregisters live
+        // data.
+        writeDataFile(tempDir.resolve("20260701"));
+        writeDataFile(tempDir.resolve("__DEFAULT_PARTITION__"));
+
+        RecordingPartitionManager catalog = new RecordingPartitionManager();
+        catalog.register(
+                Arrays.asList(spec("dt", "20260701"), spec("dt", 
"__DEFAULT_PARTITION__")));
+        PaimonFormatTable sparkTable =
+                new PaimonFormatTable(formatTable(tempDir.toUri().toString(), 
true, catalog));
+
+        int applied = FormatTablePartitionRepair.repair(sparkTable, true, 
true);
+
+        assertThat(applied).isZero();
+        assertThat(catalog.droppedPartitions).isEmpty();
+        assertThat(catalog.createdPartitions).isEmpty();
+    }
+
+    @Test
+    void repairReadsTheDefaultPartitionNameFromTableOptions() throws Exception 
{
+        // Pins that the rescued name comes from partition.default-name rather 
than a literal:
+        // hardcoding "__DEFAULT_PARTITION__" reproduces the bug for anyone 
who overrides it.
+        writeDataFile(tempDir.resolve("20260701"));
+        writeDataFile(tempDir.resolve("__MY_NULL__"));
+
+        RecordingPartitionManager catalog = new RecordingPartitionManager();
+        Map<String, String> extra = new LinkedHashMap<>();
+        extra.put(CoreOptions.PARTITION_DEFAULT_NAME.key(), "__MY_NULL__");
+        PaimonFormatTable sparkTable =
+                new PaimonFormatTable(
+                        formatTable(tempDir.toUri().toString(), true, catalog, 
extra));
+
+        int applied = FormatTablePartitionRepair.repair(sparkTable, true, 
false);
+
+        assertThat(applied).isEqualTo(2);
+        assertThat(catalog.createdPartitions)
+                .containsExactly(Arrays.asList(spec("dt", "20260701"), 
spec("dt", "__MY_NULL__")));
+    }
+
+    @Test
+    void repairDescendsIntoANullPartitionSubtreeInValueOnlyLayout() throws 
Exception {
+        // A null value on a non-leaf level hides the whole subtree, not just 
one directory:
+        // listStatusRecursively applies the same hidden-name rule while 
descending.
+        writeDataFile(tempDir.resolve("20260701").resolve("01"));
+        writeDataFile(tempDir.resolve("__DEFAULT_PARTITION__").resolve("01"));
+
+        RecordingPartitionManager catalog = new RecordingPartitionManager();
+        PaimonFormatTable sparkTable =
+                new 
PaimonFormatTable(twoLevelValueOnlyTable(tempDir.toUri().toString(), catalog));
+
+        int applied = FormatTablePartitionRepair.repair(sparkTable, true, 
false);
+
+        Map<String, String> real = new LinkedHashMap<>();
+        real.put("dt", "20260701");
+        real.put("month", "01");
+        Map<String, String> nullDt = new LinkedHashMap<>();
+        nullDt.put("dt", "__DEFAULT_PARTITION__");
+        nullDt.put("month", "01");
+        assertThat(applied).isEqualTo(2);
+        
assertThat(catalog.createdPartitions).containsExactly(Arrays.asList(real, 
nullDt));
+    }
+
+    @Test
+    void repairKeepsAnUnderscoreValueInTheKeyValueLayout() throws Exception {
+        // The hidden-name rule reads the directory name, and in a key=value 
layout that name is
+        // "dt=_abc" - the underscore sits on the value, not on the first 
character. Pins that the
+        // value-only defect does not extend to the default layout.
+        writeDataFile(tempDir.resolve("dt=20260701"));
+        writeDataFile(tempDir.resolve("dt=_abc"));
+
+        RecordingPartitionManager catalog = new RecordingPartitionManager();
+        catalog.register(Arrays.asList(spec("dt", "20260701"), spec("dt", 
"_abc")));
+        PaimonFormatTable sparkTable =
+                new PaimonFormatTable(formatTable(tempDir.toUri().toString(), 
catalog));
+
+        int applied = FormatTablePartitionRepair.repair(sparkTable, true, 
true);
+
+        assertThat(applied).isZero();
+        assertThat(catalog.droppedPartitions).isEmpty();
+        assertThat(catalog.createdPartitions).isEmpty();
+    }
+
     @Test
     void repairRejectsUnsafeValueOnlyDirectoryBeforeCatalogMutation() throws 
Exception {
         Files.createDirectories(tempDir.resolve("%2E%2E"));
@@ -501,6 +606,14 @@ class FormatTablePartitionRepairTest {
         assertThat(catalog.replaceFlags).containsExactly(false);
     }
 
+    private static void writeDataFile(java.nio.file.Path partitionDirectory) 
throws IOException {
+        Files.createDirectories(partitionDirectory);
+        Files.write(
+                partitionDirectory.resolve("data.csv"),
+                Collections.singletonList("1"),
+                StandardCharsets.UTF_8);
+    }
+
     private static Map<String, String> spec(String key, String value) {
         Map<String, String> spec = new LinkedHashMap<>();
         spec.put(key, value);
@@ -511,6 +624,45 @@ class FormatTablePartitionRepairTest {
         return formatTable(location, false, catalog);
     }
 
+    private static FormatTable formatTable(
+            String location,
+            boolean onlyValueInPath,
+            FormatTablePartitionManager catalog,
+            Map<String, String> extraOptions) {
+        RowType rowType =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field("dt", DataTypes.STRING())
+                        .build();
+        return build(
+                LocalFileIO.create(),
+                location,
+                rowType,
+                Collections.singletonList("dt"),
+                onlyValueInPath,
+                catalog,
+                extraOptions);
+    }
+
+    /** Two STRING partition keys in a value-only layout, so a null value can 
sit on a non-leaf. */
+    private static FormatTable twoLevelValueOnlyTable(
+            String location, FormatTablePartitionManager catalog) {
+        RowType rowType =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field("dt", DataTypes.STRING())
+                        .field("month", DataTypes.STRING())
+                        .build();
+        return build(
+                LocalFileIO.create(),
+                location,
+                rowType,
+                Arrays.asList("dt", "month"),
+                true,
+                catalog,
+                Collections.emptyMap());
+    }
+
     private static FormatTable formatTable(
             String location, boolean onlyValueInPath, 
FormatTablePartitionManager catalog) {
         return formatTable(LocalFileIO.create(), location, onlyValueInPath, 
catalog);
@@ -554,11 +706,30 @@ class FormatTablePartitionRepairTest {
             List<String> partitionKeys,
             boolean onlyValueInPath,
             FormatTablePartitionManager catalog) {
+        return build(
+                fileIO,
+                location,
+                rowType,
+                partitionKeys,
+                onlyValueInPath,
+                catalog,
+                Collections.emptyMap());
+    }
+
+    private static FormatTable build(
+            FileIO fileIO,
+            String location,
+            RowType rowType,
+            List<String> partitionKeys,
+            boolean onlyValueInPath,
+            FormatTablePartitionManager catalog,
+            Map<String, String> extraOptions) {
         Map<String, String> options = new LinkedHashMap<>();
         options.put(CoreOptions.METASTORE_PARTITIONED_TABLE.key(), "true");
         options.put(
                 CoreOptions.FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
                 Boolean.toString(onlyValueInPath));
+        options.putAll(extraOptions);
         return FormatTable.builder()
                 .fileIO(fileIO)
                 .identifier(Identifier.create("db", "t"))

Reply via email to