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 cf2eb3fff6 [core] Keep staging trees out of an overwrite that clears a 
prefix (#8905)
cf2eb3fff6 is described below

commit cf2eb3fff68f12bb9c54d6188865b11ca37c8799
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Jul 30 12:01:05 2026 +0800

    [core] Keep staging trees out of an overwrite that clears a prefix (#8905)
---
 .../table/format/FormatBatchWriteBuilder.java      |   1 +
 .../paimon/table/format/FormatTableCommit.java     |  39 ++++--
 .../paimon/table/format/FormatTableScan.java       |  68 ++++++++--
 .../paimon/table/format/FormatTableCommitTest.java | 145 +++++++++++++++++++++
 .../paimon/table/format/FormatTableScanTest.java   |  27 ----
 .../paimon/utils/PartitionPathUtilsTest.java       |  14 ++
 6 files changed, 242 insertions(+), 52 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
index 0b510c5940..b14ab00890 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
@@ -83,6 +83,7 @@ public class FormatBatchWriteBuilder implements 
BatchWriteBuilder {
                 table.partitionKeys(),
                 table.fileIO(),
                 formatTablePartitionOnlyValueInPath,
+                table.defaultPartName(),
                 overwrite,
                 Identifier.fromString(table.fullName()),
                 staticPartition,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
index c0f356fe22..278e4c4f36 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
@@ -56,6 +56,7 @@ public class FormatTableCommit implements BatchTableCommit {
 
     private String location;
     private final boolean formatTablePartitionOnlyValueInPath;
+    private final String defaultPartName;
     private FileIO fileIO;
     private List<String> partitionKeys;
     protected Map<String, String> staticPartitions;
@@ -69,6 +70,7 @@ public class FormatTableCommit implements BatchTableCommit {
             List<String> partitionKeys,
             FileIO fileIO,
             boolean formatTablePartitionOnlyValueInPath,
+            String defaultPartName,
             boolean overwrite,
             Identifier tableIdentifier,
             @Nullable Map<String, String> staticPartitions,
@@ -78,6 +80,7 @@ public class FormatTableCommit implements BatchTableCommit {
         this.location = location;
         this.fileIO = fileIO;
         this.formatTablePartitionOnlyValueInPath = 
formatTablePartitionOnlyValueInPath;
+        this.defaultPartName = defaultPartName;
         validateStaticPartition(staticPartitions, partitionKeys);
         this.staticPartitions = staticPartitions;
         this.overwrite = overwrite;
@@ -128,7 +131,10 @@ public class FormatTableCommit implements BatchTableCommit 
{
                     partitionSpecs.add(staticPartitions);
                 }
                 if (overwrite) {
-                    deletePreviousDataFile(partitionPath);
+                    // A static partition may name only the leading keys, in 
which case the path
+                    // is a prefix and the partition directories of the 
remaining keys sit below.
+                    deletePreviousDataFile(
+                            partitionPath, partitionKeys.size() - 
staticPartitions.size());
                 }
                 if (!fileIO.exists(partitionPath)) {
                     fileIO.mkdirs(partitionPath);
@@ -139,7 +145,10 @@ public class FormatTableCommit implements BatchTableCommit 
{
                     partitionPaths.add(c.targetPath().getParent());
                 }
                 for (Path p : partitionPaths) {
-                    deletePreviousDataFile(p);
+                    // The parent of a written file is a complete partition 
directory - the table
+                    // directory itself when the table is unpartitioned - so 
there is no partition
+                    // level below it to descend.
+                    deletePreviousDataFile(p, 0);
                 }
             }
 
@@ -267,17 +276,23 @@ public class FormatTableCommit implements 
BatchTableCommit {
     @Override
     public void close() throws Exception {}
 
-    private void deletePreviousDataFile(Path partitionPath) throws IOException 
{
+    private void deletePreviousDataFile(Path partitionPath, int 
partitionLevels)
+            throws IOException {
         if (fileIO.exists(partitionPath)) {
-            FileStatus[] files = fileIO.listFiles(partitionPath, true);
-            for (FileStatus file : files) {
-                if (FormatTableScan.isDataFileName(file.getPath().getName())) {
-                    try {
-                        fileIO.delete(file.getPath(), false);
-                    } catch (FileNotFoundException ignore) {
-                    } catch (IOException e) {
-                        throw new RuntimeException(e);
-                    }
+            // Committed data files only: what sits under a staging directory 
is another writer's
+            // uncommitted output, whatever its name looks like.
+            for (FileStatus file :
+                    FormatTableScan.listDataFiles(
+                            fileIO,
+                            partitionPath,
+                            partitionLevels,
+                            formatTablePartitionOnlyValueInPath,
+                            defaultPartName)) {
+                try {
+                    fileIO.delete(file.getPath(), false);
+                } catch (FileNotFoundException ignore) {
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
                 }
             }
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
index f86a3439c9..3f059a0e59 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
@@ -101,10 +101,6 @@ public class FormatTableScan implements InnerTableScan {
         throw new UnsupportedOperationException("Filter is not supported for 
FormatTable.");
     }
 
-    public static boolean isDataFileName(String fileName) {
-        return fileName != null && !PartitionPathUtils.isHiddenName(fileName);
-    }
-
     /**
      * Lists the data files under {@code listedRoot}, skipping committer 
staging trees ({@code
      * _temporary/}, {@code __magic_job-<id>/}, {@code .hive-staging_*}) 
without descending into
@@ -120,16 +116,54 @@ public class FormatTableScan implements InnerTableScan {
      *     disappears further down is skipped instead, leaving the rest of the 
listing complete
      */
     static List<FileStatus> listDataFiles(FileIO fileIO, Path listedRoot) 
throws IOException {
+        return listDataFiles(fileIO, listedRoot, 0, false, null);
+    }
+
+    /**
+     * As {@link #listDataFiles(FileIO, Path)}, for a root that still has 
partition directories
+     * below it: an {@code INSERT OVERWRITE} naming only the leading partition 
keys clears such a
+     * prefix.
+     *
+     * <p>Being at a partition level does not exempt a directory from the 
{@code '_'} / {@code '.'}
+     * rule. A job writing the same prefix with the trailing keys dynamic 
stages exactly there, so
+     * {@code year=2025/_temporary} holds that job's own month directories, 
not this table's. One
+     * hidden name is table content: the default partition name in the 
value-only layout, where a
+     * partition directory is the bare value. That is the exemption {@link
+     * PartitionPathUtils#searchPartSpecAndPaths} already makes on the scan 
side.
+     *
+     * @param partitionLevels how many directory levels below {@code 
listedRoot} hold partition
+     *     directories rather than table content
+     * @param onlyValueInPath whether a partition directory is named by its 
value alone ({@code
+     *     2025/}) instead of {@code key=value} ({@code year=2025/})
+     * @param defaultPartName the directory name standing for a null partition 
value
+     */
+    static List<FileStatus> listDataFiles(
+            FileIO fileIO,
+            Path listedRoot,
+            int partitionLevels,
+            boolean onlyValueInPath,
+            @Nullable String defaultPartName)
+            throws IOException {
+        String partitionDirExemptFromHiding = onlyValueInPath ? 
defaultPartName : null;
         List<FileStatus> dataFiles = new ArrayList<>();
         List<Path> level = new ArrayList<>();
         // A missing root is the caller's signal, e.g. a partition that the 
catalog knows but whose
         // directory is gone, so let it surface.
-        collectDataFiles(fileIO.listStatus(listedRoot), dataFiles, level);
-        while (!level.isEmpty()) {
+        collectDataFiles(
+                fileIO.listStatus(listedRoot),
+                partitionLevels >= 1 ? partitionDirExemptFromHiding : null,
+                dataFiles,
+                level);
+        for (int depth = 1; !level.isEmpty(); depth++) {
+            boolean childrenArePartitions = partitionLevels >= depth + 1;
             List<Path> next = new ArrayList<>();
             for (Path directory : level) {
                 try {
-                    collectDataFiles(fileIO.listStatus(directory), dataFiles, 
next);
+                    collectDataFiles(
+                            fileIO.listStatus(directory),
+                            childrenArePartitions ? 
partitionDirExemptFromHiding : null,
+                            dataFiles,
+                            next);
                 } catch (FileNotFoundException e) {
                     // The directory vanished after its parent listed it; the 
rest of the listing
                     // is still complete.
@@ -140,18 +174,26 @@ public class FormatTableScan implements InnerTableScan {
         return dataFiles;
     }
 
+    /**
+     * @param exemptFromHiding the one hidden directory name that holds table 
content here, or null
+     *     when every hidden directory is a staging tree
+     */
     private static void collectDataFiles(
-            @Nullable FileStatus[] children, List<FileStatus> dataFiles, 
List<Path> directories) {
+            @Nullable FileStatus[] children,
+            @Nullable String exemptFromHiding,
+            List<FileStatus> dataFiles,
+            List<Path> directories) {
         if (children == null) {
             return;
         }
         for (FileStatus child : children) {
-            if (PartitionPathUtils.isHiddenName(child.getPath().getName())) {
-                continue;
-            }
+            String name = child.getPath().getName();
+            boolean hidden = PartitionPathUtils.isHiddenName(name);
             if (child.isDir()) {
-                directories.add(child.getPath());
-            } else {
+                if (!hidden || name.equals(exemptFromHiding)) {
+                    directories.add(child.getPath());
+                }
+            } else if (!hidden) {
                 dataFiles.add(child);
             }
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
index c7f8c8dac1..c7a557b449 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
@@ -37,6 +37,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
+import static org.apache.paimon.CoreOptions.PARTITION_DEFAULT_NAME;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.entry;
@@ -72,6 +73,7 @@ class FormatTableCommitTest {
                         Arrays.asList("year", "month"),
                         fileIO,
                         false,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
                         false,
                         Identifier.create("catalog_partition_db", 
"catalog_partition_table"),
                         null,
@@ -103,6 +105,7 @@ class FormatTableCommitTest {
                         Arrays.asList("year", "month"),
                         fileIO,
                         false,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
                         false,
                         Identifier.create("catalog_partition_db", 
"catalog_partition_table"),
                         null,
@@ -164,6 +167,146 @@ class FormatTableCommitTest {
                 .containsExactly(entry("year", "2025"), entry("month", "a:b"));
     }
 
+    @Test
+    void testOverwriteKeepsFilesOfConcurrentWritersStagingTrees() throws 
Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        Path partitionPath = new Path(tablePath, "year=2025/month=10");
+        Path previousDataFile = new Path(partitionPath, "data-old.csv");
+        fileIO.writeFile(previousDataFile, "1", false);
+        // A concurrent job is mid-write in this partition, under a magic 
committer's tree. Its
+        // file carries an ordinary data file name; only the directories above 
it say otherwise.
+        // ('_temporary' is the other such tree, but this writer's own clean() 
still empties it -
+        //  covered once that is fixed separately.)
+        Path stagingFile =
+                new Path(
+                        partitionPath,
+                        
"__magic_job-6e7f/tasks/attempt_202607271200_0001_m_000010_15"
+                                + "/__base/part-00010.csv");
+        fileIO.writeFile(stagingFile, "2", false);
+
+        Path targetPath = new Path(partitionPath, "data-new.csv");
+        RenamingTwoPhaseOutputStream outputStream =
+                new RenamingTwoPhaseOutputStream(fileIO, targetPath, false);
+        outputStream.write(1);
+        TwoPhaseOutputStream.Committer committer = 
outputStream.closeForCommit();
+        Map<String, String> staticPartition = new LinkedHashMap<>();
+        staticPartition.put("year", "2025");
+        staticPartition.put("month", "10");
+        FormatTableCommit commit =
+                new FormatTableCommit(
+                        tablePath.toString(),
+                        Arrays.asList("year", "month"),
+                        fileIO,
+                        false,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
+                        true,
+                        Identifier.create("overwrite_db", "overwrite_table"),
+                        staticPartition,
+                        null,
+                        null,
+                        null);
+
+        commit.commit(Collections.singletonList(new 
TwoPhaseCommitMessage(committer)));
+
+        assertThat(fileIO.exists(previousDataFile)).isFalse();
+        assertThat(fileIO.exists(targetPath)).isTrue();
+        assertThat(fileIO.exists(stagingFile)).isTrue();
+    }
+
+    @Test
+    void testOverwritingAPrefixKeepsStagingTreesSittingAtAPartitionLevel() 
throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        Path staticPrefix = new Path(tablePath, "year=2025");
+        Path staleFile = new Path(staticPrefix, "month=10/data-old.csv");
+        fileIO.writeFile(staleFile, "1", false);
+        // A concurrent job overwriting the same prefix with a dynamic month 
stages below the
+        // prefix itself, so its staging root stands where the month 
directories do rather than
+        // inside one of them. Being at a partition level does not make it 
partition data: the
+        // month directories it holds are the job's own, to be moved into 
place at its commit.
+        List<Path> stagingFiles =
+                Arrays.asList(
+                        new Path(staticPrefix, "_temporary/attempt/part.csv"),
+                        new Path(
+                                staticPrefix,
+                                
"_temporary/0/attempt_202607271200_0001_m_000012_17"
+                                        + "/month=12/part-00012.csv"),
+                        new Path(
+                                staticPrefix,
+                                
"_temporary/0/_temporary/attempt_202607271200_0001_m_000011_16"
+                                        + "/month=11/part-00011.csv"));
+        for (Path stagingFile : stagingFiles) {
+            fileIO.writeFile(stagingFile, "2", false);
+        }
+
+        // INSERT OVERWRITE ... PARTITION (year = '2025'), month left dynamic.
+        FormatTableCommit commit =
+                new FormatTableCommit(
+                        tablePath.toString(),
+                        Arrays.asList("year", "month"),
+                        fileIO,
+                        false,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
+                        true,
+                        Identifier.create("overwrite_db", "overwrite_table"),
+                        Collections.singletonMap("year", "2025"),
+                        null,
+                        null,
+                        null);
+
+        commit.commit(Collections.emptyList());
+
+        assertThat(fileIO.exists(staleFile)).isFalse();
+        for (Path stagingFile : stagingFiles) {
+            assertThat(fileIO.exists(stagingFile)).isTrue();
+        }
+    }
+
+    @Test
+    void testOverwritingAPrefixClearsTheDefaultPartitionDirectory() throws 
Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        // Value-only layout: the directory of a null partition value is the 
default partition
+        // name, which starts with '_' without being a staging directory. The 
scan reads it, so an
+        // overwrite has to clear it too.
+        Path defaultPartition =
+                new Path(tablePath, "2025/" + 
PARTITION_DEFAULT_NAME.defaultValue());
+        Path staleFile = new Path(defaultPartition, "data-old.csv");
+        fileIO.writeFile(staleFile, "1", false);
+        Path staleSibling = new Path(tablePath, "2025/10/data-old.csv");
+        fileIO.writeFile(staleSibling, "1", false);
+        Path stagedFile = new Path(defaultPartition, 
"__magic_job-6e7f/__base/part-00010.csv");
+        fileIO.writeFile(stagedFile, "2", false);
+        // The exemption is that one directory name and no other: a staging 
tree standing next to
+        // the partition directories is still a staging tree.
+        Path stagedNextToThePartitions =
+                new Path(tablePath, "2025/_temporary/attempt/part-00011.csv");
+        fileIO.writeFile(stagedNextToThePartitions, "2", false);
+
+        FormatTableCommit commit =
+                new FormatTableCommit(
+                        tablePath.toString(),
+                        Arrays.asList("year", "month"),
+                        fileIO,
+                        true,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
+                        true,
+                        Identifier.create("overwrite_db", "overwrite_table"),
+                        Collections.singletonMap("year", "2025"),
+                        null,
+                        null,
+                        null);
+
+        commit.commit(Collections.emptyList());
+
+        assertThat(fileIO.exists(staleFile)).isFalse();
+        assertThat(fileIO.exists(staleSibling)).isFalse();
+        // Inside the partition, hidden still means staging.
+        assertThat(fileIO.exists(stagedFile)).isTrue();
+        assertThat(fileIO.exists(stagedNextToThePartitions)).isTrue();
+    }
+
     @Test
     void testPathNotMatchingThePartitionKeysFails() {
         Path tablePath = new Path(tempDir.toUri());
@@ -194,6 +337,7 @@ class FormatTableCommitTest {
                         Collections.singletonList("year"),
                         fileIO,
                         true,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
                         true,
                         Identifier.create("catalog_partition_db", 
"catalog_partition_table"),
                         staticPartition,
@@ -225,6 +369,7 @@ class FormatTableCommitTest {
                         Arrays.asList("year", "month"),
                         fileIO,
                         onlyValueInPath,
+                        PARTITION_DEFAULT_NAME.defaultValue(),
                         false,
                         Identifier.create("catalog_partition_db", 
"catalog_partition_table"),
                         null,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
index 7454f89847..53ba4cd067 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
@@ -67,7 +67,6 @@ import static 
org.apache.paimon.CoreOptions.SOURCE_SPLIT_TARGET_SIZE;
 import static 
org.apache.paimon.utils.PartitionPathUtils.searchPartSpecAndPaths;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /** Test for {@link FormatTableScan}. */
@@ -99,32 +98,6 @@ public class FormatTableScanTest {
                 new Object[] {false, "/year=2023/month=2"}, new Object[] 
{true, "/2023/2"});
     }
 
-    @TestTemplate
-    void testValidDataFileNames() {
-        // Test valid data file names
-        String[] fileNames = {"File.txt", "file.txt", "123file.txt", "data", 
"Test_file.log"};
-        for (String fileName : fileNames) {
-            assertTrue(
-                    FormatTableScan.isDataFileName(fileName),
-                    "Filename '" + fileName + "' should be valid");
-        }
-    }
-
-    @TestTemplate
-    void testInvalidDataFileNames() {
-        String[] fileNames = {".hidden", "_file.txt"};
-        for (String fileName : fileNames) {
-            assertFalse(
-                    FormatTableScan.isDataFileName(fileName),
-                    "Filename '" + fileName + "' should be invalid");
-        }
-    }
-
-    @TestTemplate
-    void testNullInput() {
-        assertFalse(FormatTableScan.isDataFileName(null), "Null input should 
return false");
-    }
-
     @TestTemplate
     void testComputeScanPathAndLevelNoPartitionKeys() {
         List<String> partitionKeys = Collections.emptyList();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
index 6b29527780..6026eadc04 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
@@ -151,4 +151,18 @@ class PartitionPathUtilsTest {
         assertThat(mightMatch(p, 0, 1, GenericRow.of(2025, 6))).isTrue();
         assertThat(mightMatch(p, 0, 1, GenericRow.of(2025, 5))).isFalse();
     }
+
+    @Test
+    void testIsHiddenName() {
+        for (String name :
+                new String[] {"File.txt", "file.txt", "123file.txt", "data", 
"a_b.log"}) {
+            
assertThat(PartitionPathUtils.isHiddenName(name)).as(name).isFalse();
+        }
+        for (String name : new String[] {".hidden", "_file.txt", "_temporary", 
"__magic_job-1"}) {
+            
assertThat(PartitionPathUtils.isHiddenName(name)).as(name).isTrue();
+        }
+        // A name that is absent or empty is not a hidden one.
+        assertThat(PartitionPathUtils.isHiddenName(null)).isFalse();
+        assertThat(PartitionPathUtils.isHiddenName("")).isFalse();
+    }
 }

Reply via email to