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 55f2be7386 [core] Parallelize snapshot expiration file IO (#8241)
55f2be7386 is described below

commit 55f2be73860624af7064c205f4bf47f236ad416d
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jun 16 13:08:11 2026 +0800

    [core] Parallelize snapshot expiration file IO (#8241)
    
    This PR parallelizes snapshot expiration planning and file IO work to
    reduce object-store latency impact when expiring many snapshots. It
    keeps final snapshot/changelog deletion ordering where required, while
    moving expensive snapshot reads, manifest reads, tag reads, and file
    deletion planning onto the existing file operation thread pool.
---
 .../apache/paimon/operation/ChangelogDeletion.java |  28 +-
 .../apache/paimon/operation/FileDeletionBase.java  | 343 ++++++----
 .../apache/paimon/operation/SnapshotDeletion.java  |  37 +-
 .../org/apache/paimon/operation/TagDeletion.java   |  14 +-
 .../apache/paimon/table/ExpireChangelogImpl.java   |   4 +-
 .../apache/paimon/table/ExpireSnapshotsImpl.java   | 235 +++++--
 .../paimon/utils/ManifestReadThreadPool.java       |   6 +
 .../paimon/operation/ExpireSnapshotsTest.java      | 730 ++++++++++++++++++++-
 .../paimon/utils/ManifestReadThreadPoolTest.java   |  11 +
 .../java/org/apache/paimon/utils/SlowFileIO.java   | 159 +++++
 .../services/org.apache.paimon.fs.FileIOLoader     |   1 +
 11 files changed, 1346 insertions(+), 222 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ChangelogDeletion.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/ChangelogDeletion.java
index 5a5f04fedc..ba64eeecab 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ChangelogDeletion.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ChangelogDeletion.java
@@ -31,7 +31,9 @@ import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.stats.StatsFileHandler;
 import org.apache.paimon.utils.FileStorePathFactory;
 
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.function.Predicate;
@@ -47,7 +49,7 @@ public class ChangelogDeletion extends 
FileDeletionBase<Changelog> {
             IndexFileHandler indexFileHandler,
             StatsFileHandler statsFileHandler,
             boolean cleanEmptyDirectories,
-            int deleteFileThreadNum) {
+            int fileOperationThreadNum) {
         super(
                 fileIO,
                 pathFactory,
@@ -56,17 +58,15 @@ public class ChangelogDeletion extends 
FileDeletionBase<Changelog> {
                 indexFileHandler,
                 statsFileHandler,
                 cleanEmptyDirectories,
-                deleteFileThreadNum);
+                fileOperationThreadNum);
     }
 
     @Override
-    public void cleanUnusedDataFiles(Changelog changelog, 
Predicate<ExpireFileEntry> skipper) {
+    public void cleanDeletedDataFiles(Changelog changelog, 
Predicate<ExpireFileEntry> skipper) {
         if (changelog.changelogManifestList() != null) {
-            deleteAddedDataFiles(changelog.changelogManifestList());
+            cleanDataFiles(planAddedInChangelogManifest(changelog));
         } else {
-            if (manifestList.exists(changelog.deltaManifestList())) {
-                cleanUnusedDataFiles(changelog.deltaManifestList(), skipper);
-            }
+            cleanDataFiles(planDeletedInDeltaManifest(changelog, skipper));
         }
     }
 
@@ -127,4 +127,18 @@ public class ChangelogDeletion extends 
FileDeletionBase<Changelog> {
 
         return skippingSet;
     }
+
+    public void cleanUnusedManifestList(String manifestName, Set<String> 
skippingSet) {
+        executeAll(planUnusedManifestList(manifestName, skippingSet));
+    }
+
+    public List<Runnable> planUnusedManifestList(String manifestName, 
Set<String> skippingSet) {
+        Set<String> manifests = new LinkedHashSet<>();
+        collectUnusedManifestList(manifestName, skippingSet, manifests);
+        List<Runnable> tasks = new ArrayList<>();
+        for (String manifest : manifests) {
+            tasks.add(() -> manifestFile.delete(manifest));
+        }
+        return tasks;
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
index 4256a503c1..2d21b5743b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
@@ -37,6 +37,7 @@ import org.apache.paimon.stats.StatsFileHandler;
 import org.apache.paimon.utils.DataFilePathFactories;
 import org.apache.paimon.utils.FileOperationThreadPool;
 import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.ManifestReadThreadPool;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.SnapshotManager;
 
@@ -50,10 +51,13 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
@@ -76,6 +80,7 @@ public abstract class FileDeletionBase<T extends Snapshot> {
     protected final Map<BinaryRow, Set<Integer>> deletionBuckets;
 
     private final Executor fileExecutor;
+    private final int fileOperationParallelism;
 
     protected boolean changelogDecoupled;
 
@@ -93,7 +98,7 @@ public abstract class FileDeletionBase<T extends Snapshot> {
             IndexFileHandler indexFileHandler,
             StatsFileHandler statsFileHandler,
             boolean cleanEmptyDirectories,
-            int deleteFileThreadNum) {
+            int fileOperationThreadNum) {
         this.fileIO = fileIO;
         this.pathFactory = pathFactory;
         this.manifestFile = manifestFile;
@@ -101,8 +106,12 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
         this.indexFileHandler = indexFileHandler;
         this.statsFileHandler = statsFileHandler;
         this.cleanEmptyDirectories = cleanEmptyDirectories;
-        this.deletionBuckets = new HashMap<>();
-        this.fileExecutor = 
FileOperationThreadPool.getExecutorService(deleteFileThreadNum);
+        this.deletionBuckets = new ConcurrentHashMap<>();
+        this.fileExecutor = 
FileOperationThreadPool.getExecutorService(fileOperationThreadNum);
+        this.fileOperationParallelism =
+                fileOperationThreadNum > 0
+                        ? fileOperationThreadNum
+                        : Runtime.getRuntime().availableProcessors();
     }
 
     public Executor fileExecutor() {
@@ -116,7 +125,7 @@ public abstract class FileDeletionBase<T extends Snapshot> {
      * @param skipper if the test result of a data file is true, it will be 
skipped when deleting;
      *     else it will be deleted
      */
-    public abstract void cleanUnusedDataFiles(T snapshot, 
Predicate<ExpireFileEntry> skipper);
+    public abstract void cleanDeletedDataFiles(T snapshot, 
Predicate<ExpireFileEntry> skipper);
 
     /**
      * Clean metadata files that will not be used anymore of a snapshot, 
including data manifests,
@@ -137,7 +146,6 @@ public abstract class FileDeletionBase<T extends Snapshot> {
             return;
         }
 
-        // All directory paths are deduplicated and sorted by hierarchy level
         Map<Integer, Set<Path>> deduplicate = new HashMap<>();
         for (Map.Entry<BinaryRow, Set<Integer>> entry : 
deletionBuckets.entrySet()) {
             List<Path> toDeleteEmptyDirectory = new ArrayList<>();
@@ -145,7 +153,7 @@ public abstract class FileDeletionBase<T extends Snapshot> {
             for (Integer bucket : entry.getValue()) {
                 
toDeleteEmptyDirectory.add(pathFactory.bucketPath(entry.getKey(), bucket));
             }
-            deleteFiles(toDeleteEmptyDirectory, this::tryDeleteEmptyDirectory);
+            executeAll(toDeleteEmptyDirectory, this::tryDeleteEmptyDirectory);
 
             List<Path> hierarchicalPaths = 
pathFactory.getHierarchicalPartitionPath(entry.getKey());
             int hierarchies = hierarchicalPaths.size();
@@ -172,36 +180,48 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
 
     protected void recordDeletionBuckets(ExpireFileEntry entry) {
         deletionBuckets
-                .computeIfAbsent(entry.partition(), p -> new HashSet<>())
+                .computeIfAbsent(entry.partition(), p -> 
ConcurrentHashMap.newKeySet())
                 .add(entry.bucket());
     }
 
-    public void cleanUnusedDataFiles(String manifestList, 
Predicate<ExpireFileEntry> skipper) {
-        // try read manifests
-        List<ManifestFileMeta> manifests = tryReadManifestList(manifestList);
-        List<ExpireFileEntry> manifestEntries;
+    /** Plan data files referenced by DELETE entries in the snapshot's delta 
manifest list. */
+    public List<Path> planDeletedInDeltaManifest(T snapshot, 
Predicate<ExpireFileEntry> skipper) {
+        String deltaManifestList = snapshot.deltaManifestList();
         // data file path -> (original manifest entry, extra file paths)
         Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete = new 
HashMap<>();
-        for (ManifestFileMeta manifest : manifests) {
-            try {
-                manifestEntries =
-                        manifestFile.readExpireFileEntries(
-                                manifest.fileName(), manifest.fileSize());
-            } catch (Exception e) {
-                // cancel deletion if any exception occurs
-                LOG.warn("Failed to read some manifest files. Cancel 
deletion.", e);
-                return;
+        try {
+            Iterable<ExpireFileEntry> dataFileEntries =
+                    
readExpireFileEntries(tryReadManifestList(deltaManifestList));
+            // we cannot delete a data file directly when we meet a DELETE 
entry, because that
+            // file might be upgraded
+            DataFilePathFactories factories = new 
DataFilePathFactories(pathFactory);
+            for (ExpireFileEntry entry : dataFileEntries) {
+                DataFilePathFactory dataFilePathFactory =
+                        factories.get(entry.partition(), entry.bucket());
+                Path dataFilePath = dataFilePathFactory.toPath(entry);
+                switch (entry.kind()) {
+                    case ADD:
+                        dataFileToDelete.remove(dataFilePath);
+                        break;
+                    case DELETE:
+                        List<Path> extraFiles = new 
ArrayList<>(entry.extraFiles().size());
+                        for (String file : entry.extraFiles()) {
+                            
extraFiles.add(dataFilePathFactory.toAlignedPath(file, entry));
+                        }
+                        dataFileToDelete.put(dataFilePath, Pair.of(entry, 
extraFiles));
+                        break;
+                    default:
+                        throw new UnsupportedOperationException(
+                                "Unknown value kind " + entry.kind().name());
+                }
             }
-
-            getDataFileToDelete(dataFileToDelete, manifestEntries);
+        } catch (Exception e) {
+            // cancel deletion if any exception occurs
+            LOG.warn("Failed to read some manifest files. Cancel deletion.", 
e);
+            return Collections.emptyList();
         }
 
-        doCleanUnusedDataFile(dataFileToDelete, skipper);
-    }
-
-    protected void doCleanUnusedDataFile(
-            Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete,
-            Predicate<ExpireFileEntry> skipper) {
+        // apply skipper
         List<Path> actualDataFileToDelete = new ArrayList<>();
         dataFileToDelete.forEach(
                 (path, pair) -> {
@@ -215,79 +235,69 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
                         recordDeletionBuckets(entry);
                     }
                 });
-        deleteFiles(actualDataFileToDelete, fileIO::deleteQuietly);
+        return actualDataFileToDelete;
     }
 
-    protected void getDataFileToDelete(
-            Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete,
-            List<ExpireFileEntry> dataFileEntries) {
-        // we cannot delete a data file directly when we meet a DELETE entry, 
because that
-        // file might be upgraded
+    /** Plan data files referenced by ADD entries in the snapshot's changelog 
manifest list. */
+    public List<Path> planAddedInChangelogManifest(T snapshot) {
+        List<ManifestFileMeta> manifests = 
tryReadManifestList(snapshot.changelogManifestList());
+        Iterable<ExpireFileEntry> entries =
+                ManifestReadThreadPool.sequentialBatchedExecute(
+                        manifest -> {
+                            try {
+                                return manifestFile.readExpireFileEntries(
+                                        manifest.fileName(), 
manifest.fileSize());
+                            } catch (Exception e) {
+                                // We want to delete the data file, so just 
ignore the unavailable
+                                // files
+                                LOG.info(
+                                        "Failed to read manifest {}. Ignore 
it.",
+                                        manifest.fileName(),
+                                        e);
+                                return Collections.emptyList();
+                            }
+                        },
+                        manifests,
+                        fileOperationParallelism);
+
+        List<Path> dataFiles = new ArrayList<>();
         DataFilePathFactories factories = new 
DataFilePathFactories(pathFactory);
-        for (ExpireFileEntry entry : dataFileEntries) {
+        for (ExpireFileEntry entry : entries) {
             DataFilePathFactory dataFilePathFactory =
                     factories.get(entry.partition(), entry.bucket());
-            Path dataFilePath = dataFilePathFactory.toPath(entry);
-            switch (entry.kind()) {
-                case ADD:
-                    dataFileToDelete.remove(dataFilePath);
-                    break;
-                case DELETE:
-                    List<Path> extraFiles = new 
ArrayList<>(entry.extraFiles().size());
-                    for (String file : entry.extraFiles()) {
-                        extraFiles.add(dataFilePathFactory.toAlignedPath(file, 
entry));
-                    }
-                    dataFileToDelete.put(dataFilePath, Pair.of(entry, 
extraFiles));
-                    break;
-                default:
-                    throw new UnsupportedOperationException(
-                            "Unknown value kind " + entry.kind().name());
+            if (entry.kind() == FileKind.ADD) {
+                dataFiles.add(dataFilePathFactory.toPath(entry));
+                recordDeletionBuckets(entry);
             }
         }
+        return dataFiles;
     }
 
-    /**
-     * Delete added file in the manifest list files. Added files marked as 
"ADD" in manifests.
-     *
-     * @param manifestListName name of manifest list
-     */
-    public void deleteAddedDataFiles(String manifestListName) {
-        List<ManifestFileMeta> manifests = 
tryReadManifestList(manifestListName);
-        for (ManifestFileMeta manifest : manifests) {
-            try {
-                List<ExpireFileEntry> manifestEntries =
+    private Iterable<ExpireFileEntry> 
readExpireFileEntries(List<ManifestFileMeta> manifests) {
+        return ManifestReadThreadPool.sequentialBatchedExecute(
+                manifest ->
                         manifestFile.readExpireFileEntries(
-                                manifest.fileName(), manifest.fileSize());
-                deleteAddedDataFiles(manifestEntries);
-            } catch (Exception e) {
-                // We want to delete the data file, so just ignore the 
unavailable files
-                LOG.info("Failed to read manifest " + manifest.fileName() + ". 
Ignore it.", e);
-            }
-        }
+                                manifest.fileName(), manifest.fileSize()),
+                manifests,
+                fileOperationParallelism);
     }
 
-    private void deleteAddedDataFiles(List<ExpireFileEntry> manifestEntries) {
-        List<Path> dataFileToDelete = new ArrayList<>();
-        DataFilePathFactories factories = new 
DataFilePathFactories(pathFactory);
-        for (ExpireFileEntry entry : manifestEntries) {
-            DataFilePathFactory dataFilePathFactory =
-                    factories.get(entry.partition(), entry.bucket());
-            if (entry.kind() == FileKind.ADD) {
-                dataFileToDelete.add(dataFilePathFactory.toPath(entry));
-                recordDeletionBuckets(entry);
-            }
-        }
-        deleteFiles(dataFileToDelete, fileIO::deleteQuietly);
+    public void cleanDataFiles(Collection<Path> dataFiles) {
+        executeAll(new LinkedHashSet<>(dataFiles), fileIO::deleteQuietly);
     }
 
-    public void cleanUnusedStatisticsManifests(Snapshot snapshot, Set<String> 
skippingSet) {
-        // clean statistics
-        if (snapshot.statistics() != null && 
!skippingSet.contains(snapshot.statistics())) {
-            statsFileHandler.deleteStats(snapshot.statistics());
+    private void collectUnusedStatisticsManifests(
+            Snapshot snapshot, Set<String> skippingSet, Set<String> 
statistics) {
+        if (snapshot.statistics() != null && 
skippingSet.add(snapshot.statistics())) {
+            statistics.add(snapshot.statistics());
         }
     }
 
-    public void cleanUnusedIndexManifests(Snapshot snapshot, Set<String> 
skippingSet) {
+    private void collectUnusedIndexManifests(
+            Snapshot snapshot,
+            Set<String> skippingSet,
+            Set<IndexManifestEntry> indexFiles,
+            Set<String> indexManifests) {
         // clean index manifests
         String indexManifest = snapshot.indexManifest();
         // check exists, it may have been deleted by other snapshots
@@ -300,39 +310,41 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
             } catch (IOException e) {
                 throw new RuntimeException(e);
             }
-            indexManifestEntries.removeIf(
-                    entry -> 
skippingSet.contains(entry.indexFile().fileName()));
-            deleteFiles(indexManifestEntries, 
indexFileHandler::deleteIndexFile);
+            for (IndexManifestEntry entry : indexManifestEntries) {
+                if (skippingSet.add(entry.indexFile().fileName())) {
+                    indexFiles.add(entry);
+                }
+            }
 
-            if (!skippingSet.contains(indexManifest)) {
-                indexFileHandler.deleteManifest(indexManifest);
+            if (skippingSet.add(indexManifest)) {
+                indexManifests.add(indexManifest);
             }
         }
     }
 
-    public void cleanUnusedManifestList(String manifestName, Set<String> 
skippingSet) {
-        List<String> toDeleteManifests = new ArrayList<>();
+    protected void collectUnusedManifestList(
+            String manifestName, Set<String> skippingSet, Set<String> 
manifests) {
         List<ManifestFileMeta> toExpireManifests = 
tryReadManifestList(manifestName);
         for (ManifestFileMeta manifest : toExpireManifests) {
             String fileName = manifest.fileName();
-            if (!skippingSet.contains(fileName)) {
-                toDeleteManifests.add(fileName);
-                // to avoid other snapshots trying to delete again
-                skippingSet.add(fileName);
+            if (skippingSet.add(fileName)) {
+                manifests.add(fileName);
             }
         }
-        if (!skippingSet.contains(manifestName)) {
-            toDeleteManifests.add(manifestName);
+        if (skippingSet.add(manifestName)) {
+            manifests.add(manifestName);
         }
-
-        deleteFiles(toDeleteManifests, manifestFile::delete);
     }
 
-    protected void cleanUnusedManifests(
+    protected List<Runnable> planManifestsCleaner(
             Snapshot snapshot,
             Set<String> skippingSet,
             boolean deleteDataManifestLists,
             boolean deleteChangelog) {
+        Set<String> manifests = new LinkedHashSet<>();
+        Set<IndexManifestEntry> indexFiles = new LinkedHashSet<>();
+        Set<String> indexManifests = new LinkedHashSet<>();
+        Set<String> statistics = new LinkedHashSet<>();
         if (deleteDataManifestLists) {
             // deleteDataManifestLists will be false
             // with changelog decouple + none changelog producer.
@@ -341,14 +353,29 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
             // For none changelog producer, changelog files are the level 0 
files.
             // Even if these files are not used by the earliest snapshot,
             // we have to keep them as changelog, and clean then in 
ChangelogDeletion.
-            cleanUnusedManifestList(snapshot.baseManifestList(), skippingSet);
-            cleanUnusedManifestList(snapshot.deltaManifestList(), skippingSet);
+            collectUnusedManifestList(snapshot.baseManifestList(), 
skippingSet, manifests);
+            collectUnusedManifestList(snapshot.deltaManifestList(), 
skippingSet, manifests);
         }
         if (deleteChangelog && snapshot.changelogManifestList() != null) {
-            cleanUnusedManifestList(snapshot.changelogManifestList(), 
skippingSet);
+            collectUnusedManifestList(snapshot.changelogManifestList(), 
skippingSet, manifests);
         }
-        cleanUnusedIndexManifests(snapshot, skippingSet);
-        cleanUnusedStatisticsManifests(snapshot, skippingSet);
+        collectUnusedIndexManifests(snapshot, skippingSet, indexFiles, 
indexManifests);
+        collectUnusedStatisticsManifests(snapshot, skippingSet, statistics);
+
+        List<Runnable> tasks = new ArrayList<>();
+        for (String manifest : manifests) {
+            tasks.add(() -> manifestFile.delete(manifest));
+        }
+        for (IndexManifestEntry indexFile : indexFiles) {
+            tasks.add(() -> indexFileHandler.deleteIndexFile(indexFile));
+        }
+        for (String indexManifest : indexManifests) {
+            tasks.add(() -> indexFileHandler.deleteManifest(indexManifest));
+        }
+        for (String statistic : statistics) {
+            tasks.add(() -> statsFileHandler.deleteStats(statistic));
+        }
+        return tasks;
     }
 
     public Predicate<ExpireFileEntry> createDataFileSkipperForTags(
@@ -369,6 +396,12 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
         return entry -> false;
     }
 
+    public Predicate<ExpireFileEntry> createDataFileSkipperForTag(Snapshot 
tag) throws Exception {
+        Map<BinaryRow, Map<Integer, Set<String>>> tagDataFiles = new 
HashMap<>();
+        addMergedDataFiles(tagDataFiles, tag);
+        return entry -> containsDataFile(tagDataFiles, entry);
+    }
+
     /**
      * It is possible that a job was killed during expiration and some 
manifest files have been
      * deleted, so if the clean methods need to get manifests of a snapshot to 
be cleaned, we should
@@ -403,12 +436,7 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
     protected Collection<ExpireFileEntry> 
readMergedDataFiles(List<ManifestFileMeta> manifests)
             throws IOException {
         Map<Identifier, ExpireFileEntry> map = new HashMap<>();
-        for (ManifestFileMeta manifest : manifests) {
-            List<ExpireFileEntry> entries =
-                    manifestFile.readExpireFileEntries(manifest.fileName(), 
manifest.fileSize());
-            FileEntry.mergeEntries(entries, map);
-        }
-
+        FileEntry.mergeEntries(readExpireFileEntries(manifests), map);
         return map.values();
     }
 
@@ -425,30 +453,59 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
     }
 
     public Set<String> manifestSkippingSet(List<Snapshot> skippingSnapshots) {
-        Set<String> skippingSet = new HashSet<>();
+        if (skippingSnapshots.size() <= 1) {
+            Set<String> skippingSet = new HashSet<>();
+            for (Snapshot skippingSnapshot : skippingSnapshots) {
+                skippingSet.addAll(manifestSkippingSet(skippingSnapshot));
+            }
+            return skippingSet;
+        }
 
+        List<CompletableFuture<Set<String>>> futures = new ArrayList<>();
         for (Snapshot skippingSnapshot : skippingSnapshots) {
-            // data manifests
-            skippingSet.add(skippingSnapshot.baseManifestList());
-            skippingSet.add(skippingSnapshot.deltaManifestList());
-            manifestList.readDataManifests(skippingSnapshot).stream()
-                    .map(ManifestFileMeta::fileName)
-                    .forEach(skippingSet::add);
+            futures.add(
+                    CompletableFuture.supplyAsync(
+                            () -> manifestSkippingSet(skippingSnapshot),
+                            
ManifestReadThreadPool.getExecutorService(fileOperationParallelism)));
+        }
 
-            // index manifests
-            String indexManifest = skippingSnapshot.indexManifest();
-            if (indexManifest != null) {
-                skippingSet.add(indexManifest);
-                indexFileHandler.readManifest(indexManifest).stream()
-                        .map(IndexManifestEntry::indexFile)
-                        .map(IndexFileMeta::fileName)
-                        .forEach(skippingSet::add);
+        Set<String> skippingSet = new HashSet<>();
+        for (CompletableFuture<Set<String>> future : futures) {
+            try {
+                skippingSet.addAll(future.get());
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            } catch (ExecutionException e) {
+                throw new RuntimeException(e.getCause());
             }
+        }
+        return skippingSet;
+    }
 
-            // statistics
-            if (skippingSnapshot.statistics() != null) {
-                skippingSet.add(skippingSnapshot.statistics());
-            }
+    private Set<String> manifestSkippingSet(Snapshot skippingSnapshot) {
+        Set<String> skippingSet = new HashSet<>();
+
+        // data manifests
+        skippingSet.add(skippingSnapshot.baseManifestList());
+        skippingSet.add(skippingSnapshot.deltaManifestList());
+        manifestList.readDataManifests(skippingSnapshot).stream()
+                .map(ManifestFileMeta::fileName)
+                .forEach(skippingSet::add);
+
+        // index manifests
+        String indexManifest = skippingSnapshot.indexManifest();
+        if (indexManifest != null) {
+            skippingSet.add(indexManifest);
+            indexFileHandler.readManifest(indexManifest).stream()
+                    .map(IndexManifestEntry::indexFile)
+                    .map(IndexFileMeta::fileName)
+                    .forEach(skippingSet::add);
+        }
+
+        // statistics
+        if (skippingSnapshot.statistics() != null) {
+            skippingSet.add(skippingSnapshot.statistics());
         }
 
         return skippingSet;
@@ -464,21 +521,31 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
         }
     }
 
-    protected <F> void deleteFiles(Collection<F> files, Consumer<F> deletion) {
-        if (files.isEmpty()) {
+    protected <F> void executeAll(Collection<F> files, Consumer<F> consumer) {
+        List<Runnable> tasks = new ArrayList<>(files.size());
+        for (F f : files) {
+            tasks.add(() -> consumer.accept(f));
+        }
+        executeAll(tasks);
+    }
+
+    public void executeAll(Collection<Runnable> tasks) {
+        if (tasks.isEmpty()) {
             return;
         }
 
-        List<CompletableFuture<Void>> deletionFutures = new 
ArrayList<>(files.size());
-        for (F file : files) {
-            deletionFutures.add(
-                    CompletableFuture.runAsync(() -> deletion.accept(file), 
fileExecutor));
+        List<CompletableFuture<Void>> futures = new ArrayList<>(tasks.size());
+        for (Runnable runnable : tasks) {
+            futures.add(CompletableFuture.runAsync(runnable, fileExecutor));
         }
 
         try {
-            CompletableFuture.allOf(deletionFutures.toArray(new 
CompletableFuture[0])).get();
-        } catch (Exception e) {
+            CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).get();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
             throw new RuntimeException(e);
+        } catch (ExecutionException e) {
+            throw new RuntimeException(e.getCause());
         }
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/SnapshotDeletion.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/SnapshotDeletion.java
index 7d55b64c8e..f97ec0474d 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/SnapshotDeletion.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/SnapshotDeletion.java
@@ -19,7 +19,6 @@
 package org.apache.paimon.operation;
 
 import org.apache.paimon.Snapshot;
-import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.index.IndexFileHandler;
@@ -29,11 +28,8 @@ import org.apache.paimon.manifest.ManifestFile;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.stats.StatsFileHandler;
 import org.apache.paimon.utils.FileStorePathFactory;
-import org.apache.paimon.utils.Pair;
 
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.function.Predicate;
 
@@ -51,7 +47,7 @@ public class SnapshotDeletion extends 
FileDeletionBase<Snapshot> {
             StatsFileHandler statsFileHandler,
             boolean produceChangelog,
             boolean cleanEmptyDirectories,
-            int deleteFileThreadNum) {
+            int fileOperationThreadNum) {
         super(
                 fileIO,
                 pathFactory,
@@ -60,41 +56,44 @@ public class SnapshotDeletion extends 
FileDeletionBase<Snapshot> {
                 indexFileHandler,
                 statsFileHandler,
                 cleanEmptyDirectories,
-                deleteFileThreadNum);
+                fileOperationThreadNum);
         this.produceChangelog = produceChangelog;
     }
 
     @Override
-    public void cleanUnusedDataFiles(Snapshot snapshot, 
Predicate<ExpireFileEntry> skipper) {
+    public void cleanDeletedDataFiles(Snapshot snapshot, 
Predicate<ExpireFileEntry> skipper) {
+        cleanDataFiles(planDeletedInDeltaManifest(snapshot, skipper));
+    }
+
+    @Override
+    public List<Path> planDeletedInDeltaManifest(
+            Snapshot snapshot, Predicate<ExpireFileEntry> skipper) {
+        Predicate<ExpireFileEntry> enriched = skipper;
         if (changelogDecoupled && !produceChangelog) {
             // Skip clean the 'APPEND' data files.If we do not have the file 
source information
             // eg: the old version table file, we just skip clean this here, 
let it done by
             // ExpireChangelogImpl
-            Predicate<ExpireFileEntry> enriched =
+            enriched =
                     manifestEntry ->
                             skipper.test(manifestEntry)
                                     || 
(manifestEntry.fileSource().orElse(FileSource.APPEND)
                                             == FileSource.APPEND);
-            cleanUnusedDataFiles(snapshot.deltaManifestList(), enriched);
-        } else {
-            cleanUnusedDataFiles(snapshot.deltaManifestList(), skipper);
         }
+        return super.planDeletedInDeltaManifest(snapshot, enriched);
     }
 
     @Override
     public void cleanUnusedManifests(Snapshot snapshot, Set<String> 
skippingSet) {
         // delay clean the base and delta manifest lists when changelog 
decoupled enabled
-        cleanUnusedManifests(
+        executeAll(planManifestsCleaner(snapshot, skippingSet));
+    }
+
+    public List<Runnable> planManifestsCleaner(Snapshot snapshot, Set<String> 
skippingSet) {
+        // delay clean the base and delta manifest lists when changelog 
decoupled enabled
+        return planManifestsCleaner(
                 snapshot,
                 skippingSet,
                 !changelogDecoupled || produceChangelog,
                 !changelogDecoupled);
     }
-
-    @VisibleForTesting
-    void cleanUnusedDataFile(List<ExpireFileEntry> dataFileLog) {
-        Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete = new 
HashMap<>();
-        getDataFileToDelete(dataFileToDelete, dataFileLog);
-        doCleanUnusedDataFile(dataFileToDelete, f -> false);
-    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/TagDeletion.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/TagDeletion.java
index 569a1464cc..ff3e05f9aa 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/TagDeletion.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/TagDeletion.java
@@ -57,7 +57,7 @@ public class TagDeletion extends FileDeletionBase<Snapshot> {
             IndexFileHandler indexFileHandler,
             StatsFileHandler statsFileHandler,
             boolean cleanEmptyDirectories,
-            int deleteFileThreadNum) {
+            int fileOperationThreadNum) {
         super(
                 fileIO,
                 pathFactory,
@@ -66,11 +66,11 @@ public class TagDeletion extends FileDeletionBase<Snapshot> 
{
                 indexFileHandler,
                 statsFileHandler,
                 cleanEmptyDirectories,
-                deleteFileThreadNum);
+                fileOperationThreadNum);
     }
 
     @Override
-    public void cleanUnusedDataFiles(Snapshot taggedSnapshot, 
Predicate<ExpireFileEntry> skipper) {
+    public void cleanDeletedDataFiles(Snapshot taggedSnapshot, 
Predicate<ExpireFileEntry> skipper) {
         Collection<ExpireFileEntry> manifestEntries;
         try {
             List<ManifestFileMeta> manifests =
@@ -96,13 +96,17 @@ public class TagDeletion extends FileDeletionBase<Snapshot> 
{
                 recordDeletionBuckets(entry);
             }
         }
-        deleteFiles(dataFileToDelete, fileIO::deleteQuietly);
+        executeAll(dataFileToDelete, fileIO::deleteQuietly);
+    }
+
+    public void cleanUnusedDataFiles(Snapshot taggedSnapshot, 
Predicate<ExpireFileEntry> skipper) {
+        cleanDeletedDataFiles(taggedSnapshot, skipper);
     }
 
     @Override
     public void cleanUnusedManifests(Snapshot taggedSnapshot, Set<String> 
skippingSet) {
         // doesn't clean changelog files because they are handled by 
SnapshotDeletion
-        cleanUnusedManifests(taggedSnapshot, skippingSet, true, false);
+        executeAll(planManifestsCleaner(taggedSnapshot, skippingSet, true, 
false));
     }
 
     public Predicate<ExpireFileEntry> dataFileSkipper(List<Snapshot> 
fromSnapshots)
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
index 73f9c88b5f..b5d0af3954 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
@@ -191,7 +191,7 @@ public class ExpireChangelogImpl implements ExpireSnapshots 
{
                 continue;
             }
 
-            changelogDeletion.cleanUnusedDataFiles(changelog, skipper);
+            changelogDeletion.cleanDeletedDataFiles(changelog, skipper);
             changelogDeletion.cleanUnusedManifests(changelog, 
manifestSkippSet);
             
changelogManager.fileIO().deleteQuietly(changelogManager.longLivedChangelogPath(id));
         }
@@ -262,7 +262,7 @@ public class ExpireChangelogImpl implements ExpireSnapshots 
{
                 continue;
             }
 
-            changelogDeletion.cleanUnusedDataFiles(changelog, skipper);
+            changelogDeletion.cleanDeletedDataFiles(changelog, skipper);
             changelogDeletion.cleanUnusedManifests(changelog, 
manifestSkippSet);
             
changelogManager.fileIO().deleteQuietly(changelogManager.longLivedChangelogPath(id));
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java
index ef3796f20d..32124bfc2a 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java
@@ -22,9 +22,11 @@ import org.apache.paimon.Changelog;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.consumer.ConsumerManager;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.manifest.ExpireFileEntry;
 import org.apache.paimon.operation.SnapshotDeletion;
 import org.apache.paimon.options.ExpireConfig;
+import org.apache.paimon.tag.Tag;
 import org.apache.paimon.utils.ChangelogManager;
 import org.apache.paimon.utils.Preconditions;
 import org.apache.paimon.utils.SnapshotManager;
@@ -37,11 +39,15 @@ import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.util.ArrayList;
-import java.util.HashSet;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
 import java.util.function.Predicate;
@@ -196,44 +202,17 @@ public class ExpireSnapshotsImpl implements 
ExpireSnapshots {
         long beginInclusiveId = snapshotsIncludingEnd.get(0).id();
 
         // tags to create data file skipper
-        List<Snapshot> taggedSnapshots = tagManager.taggedSnapshots();
+        List<Snapshot> taggedSnapshots = collectTaggedSnapshots();
 
         // delete merge tree files
         // deleted merge tree files in a snapshot are not used by the next 
snapshot, so the range of
         // id should be (beginInclusiveId, endExclusiveId]
-        for (Snapshot snapshot : snapshotsIncludingEnd) {
-            long id = snapshot.id();
-            if (id == beginInclusiveId) {
-                continue;
-            }
-            if (LOG.isDebugEnabled()) {
-                LOG.debug("Ready to delete merge tree files not used by 
snapshot #{}", id);
-            }
-            // expire merge tree files and collect changed buckets
-            Predicate<ExpireFileEntry> skipper;
-            try {
-                skipper = 
snapshotDeletion.createDataFileSkipperForTags(taggedSnapshots, id);
-            } catch (Exception e) {
-                LOG.info(
-                        "Skip cleaning data files of snapshot '{}' due to 
failed to build skipping set.",
-                        id,
-                        e);
-                continue;
-            }
-
-            snapshotDeletion.cleanUnusedDataFiles(snapshot, skipper);
-        }
+        snapshotDeletion.cleanDataFiles(
+                collectDataFilesToDelete(snapshotsIncludingEnd, 
taggedSnapshots, beginInclusiveId));
 
         // delete changelog files
         if (!expireConfig.isChangelogDecoupled()) {
-            for (Snapshot snapshot : snapshotsExcludingEnd) {
-                if (LOG.isDebugEnabled()) {
-                    LOG.debug("Ready to delete changelog files from snapshot 
#{}", snapshot.id());
-                }
-                if (snapshot.changelogManifestList() != null) {
-                    
snapshotDeletion.deleteAddedDataFiles(snapshot.changelogManifestList());
-                }
-            }
+            
snapshotDeletion.cleanDataFiles(collectChangelogFilesToDelete(snapshotsExcludingEnd));
         }
 
         // data files and changelog files in bucket directories has been 
deleted
@@ -254,17 +233,15 @@ public class ExpireSnapshotsImpl implements 
ExpireSnapshots {
 
         Set<String> skippingSet = null;
         try {
-            skippingSet = new 
HashSet<>(snapshotDeletion.manifestSkippingSet(skippingSnapshots));
+            Set<String> builtSkippingSet = ConcurrentHashMap.newKeySet();
+            
builtSkippingSet.addAll(snapshotDeletion.manifestSkippingSet(skippingSnapshots));
+            skippingSet = builtSkippingSet;
         } catch (Exception e) {
             LOG.info("Skip cleaning manifest files due to failed to build 
skipping set.", e);
         }
         if (skippingSet != null) {
-            for (Snapshot snapshot : snapshotsExcludingEnd) {
-                if (LOG.isDebugEnabled()) {
-                    LOG.debug("Ready to delete manifests in snapshot #{}", 
snapshot.id());
-                }
-                snapshotDeletion.cleanUnusedManifests(snapshot, skippingSet);
-            }
+            snapshotDeletion.executeAll(
+                    collectManifestDeletionTasks(snapshotsExcludingEnd, 
skippingSet));
         }
 
         // delete snapshot file finally
@@ -285,6 +262,144 @@ public class ExpireSnapshotsImpl implements 
ExpireSnapshots {
         return snapshotsExcludingEnd.size();
     }
 
+    private Collection<Path> collectDataFilesToDelete(
+            List<Snapshot> snapshotsIncludingEnd,
+            List<Snapshot> taggedSnapshots,
+            long beginInclusiveId)
+            throws ExecutionException, InterruptedException {
+        Map<Long, Long> tagIdBySnapshotId = new HashMap<>();
+        Map<Long, Snapshot> tags = new HashMap<>();
+        int tagIndex = -1;
+        for (Snapshot snapshot : snapshotsIncludingEnd) {
+            long id = snapshot.id();
+            if (id == beginInclusiveId) {
+                continue;
+            }
+
+            tagIndex = advancePreviousSnapshot(taggedSnapshots, tagIndex, id);
+            if (tagIndex >= 0) {
+                Snapshot tag = taggedSnapshots.get(tagIndex);
+                tagIdBySnapshotId.put(id, tag.id());
+                tags.put(tag.id(), tag);
+            }
+        }
+
+        Map<Long, Optional<Predicate<ExpireFileEntry>>> skippers =
+                collectTagSkippers(tags.values());
+        Predicate<ExpireFileEntry> deleteAll = entry -> false;
+        List<CompletableFuture<List<Path>>> futures = new ArrayList<>();
+        for (Snapshot snapshot : snapshotsIncludingEnd) {
+            long id = snapshot.id();
+            if (id == beginInclusiveId) {
+                continue;
+            }
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("Ready to delete merge tree files not used by 
snapshot #{}", id);
+            }
+
+            Long tagId = tagIdBySnapshotId.get(id);
+            Optional<Predicate<ExpireFileEntry>> skipper =
+                    tagId == null
+                            ? Optional.of(deleteAll)
+                            : skippers.getOrDefault(tagId, Optional.empty());
+            if (!skipper.isPresent()) {
+                LOG.info(
+                        "Skip cleaning data files of snapshot '{}' due to 
failed to build skipping set.",
+                        id);
+                continue;
+            }
+
+            futures.add(
+                    CompletableFuture.supplyAsync(
+                            () ->
+                                    
snapshotDeletion.planDeletedInDeltaManifest(
+                                            snapshot, skipper.get()),
+                            fileExecutor));
+        }
+        return flatten(getAll(futures));
+    }
+
+    private Map<Long, Optional<Predicate<ExpireFileEntry>>> collectTagSkippers(
+            Collection<Snapshot> tags) throws ExecutionException, 
InterruptedException {
+        Map<Long, CompletableFuture<Optional<Predicate<ExpireFileEntry>>>> 
futures =
+                new HashMap<>();
+        for (Snapshot tag : tags) {
+            futures.put(
+                    tag.id(),
+                    CompletableFuture.supplyAsync(
+                            () -> {
+                                try {
+                                    return Optional.of(
+                                            
snapshotDeletion.createDataFileSkipperForTag(tag));
+                                } catch (Exception e) {
+                                    LOG.info(
+                                            "Failed to build data file 
skipping set for tag snapshot '{}'.",
+                                            tag.id(),
+                                            e);
+                                    return Optional.empty();
+                                }
+                            },
+                            fileExecutor));
+        }
+
+        Map<Long, Optional<Predicate<ExpireFileEntry>>> skippers = new 
HashMap<>();
+        for (Map.Entry<Long, 
CompletableFuture<Optional<Predicate<ExpireFileEntry>>>> entry :
+                futures.entrySet()) {
+            skippers.put(entry.getKey(), entry.getValue().get());
+        }
+        return skippers;
+    }
+
+    private Collection<Path> collectChangelogFilesToDelete(List<Snapshot> 
snapshots)
+            throws ExecutionException, InterruptedException {
+        List<CompletableFuture<List<Path>>> futures = new ArrayList<>();
+        for (Snapshot snapshot : snapshots) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("Ready to delete changelog files from snapshot #{}", 
snapshot.id());
+            }
+            if (snapshot.changelogManifestList() != null) {
+                futures.add(
+                        CompletableFuture.supplyAsync(
+                                () -> 
snapshotDeletion.planAddedInChangelogManifest(snapshot),
+                                fileExecutor));
+            }
+        }
+        return flatten(getAll(futures));
+    }
+
+    private Collection<Runnable> collectManifestDeletionTasks(
+            List<Snapshot> snapshots, Set<String> skippingSet)
+            throws ExecutionException, InterruptedException {
+        List<CompletableFuture<List<Runnable>>> futures = new ArrayList<>();
+        for (Snapshot snapshot : snapshots) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("Ready to delete manifests in snapshot #{}", 
snapshot.id());
+            }
+            futures.add(
+                    CompletableFuture.supplyAsync(
+                            () -> 
snapshotDeletion.planManifestsCleaner(snapshot, skippingSet),
+                            fileExecutor));
+        }
+        return flatten(getAll(futures));
+    }
+
+    private <T> List<T> getAll(List<CompletableFuture<T>> futures)
+            throws ExecutionException, InterruptedException {
+        List<T> result = new ArrayList<>();
+        for (CompletableFuture<T> future : futures) {
+            result.add(future.get());
+        }
+        return result;
+    }
+
+    private <T> List<T> flatten(List<? extends Collection<T>> collections) {
+        List<T> result = new ArrayList<>();
+        for (Collection<T> collection : collections) {
+            result.addAll(collection);
+        }
+        return result;
+    }
+
     private List<Snapshot> collectSnapshots(long earliestId, long 
endExclusiveId)
             throws InterruptedException, ExecutionException {
         List<CompletableFuture<Optional<Snapshot>>> futures = new 
ArrayList<>();
@@ -309,6 +424,48 @@ public class ExpireSnapshotsImpl implements 
ExpireSnapshots {
         return snapshots;
     }
 
+    private List<Snapshot> collectTaggedSnapshots()
+            throws InterruptedException, ExecutionException {
+        List<Path> tagPaths;
+        try {
+            tagPaths = tagManager.tagPaths(path -> true);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        List<CompletableFuture<Optional<Snapshot>>> futures = new 
ArrayList<>();
+        for (Path path : tagPaths) {
+            futures.add(
+                    CompletableFuture.supplyAsync(
+                            () -> {
+                                try {
+                                    return Optional.of(
+                                            
Tag.tryFromPath(snapshotManager.fileIO(), path)
+                                                    .trimToSnapshot());
+                                } catch (FileNotFoundException ignored) {
+                                    return Optional.empty();
+                                }
+                            },
+                            fileExecutor));
+        }
+
+        List<Snapshot> snapshots = new ArrayList<>();
+        for (CompletableFuture<Optional<Snapshot>> future : futures) {
+            future.get().ifPresent(snapshots::add);
+        }
+        snapshots.sort(Comparator.comparingLong(Snapshot::id));
+        return snapshots;
+    }
+
+    private static int advancePreviousSnapshot(
+            List<Snapshot> sortedSnapshots, int currentIndex, long 
targetSnapshotId) {
+        while (currentIndex + 1 < sortedSnapshots.size()
+                && sortedSnapshots.get(currentIndex + 1).id() < 
targetSnapshotId) {
+            currentIndex++;
+        }
+        return currentIndex;
+    }
+
     private void commitChangelog(Changelog changelog) {
         try {
             changelogManager.commitChangelog(changelog, changelog.id());
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
index 16bd73ee80..7cf9778714 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
@@ -37,6 +37,7 @@ public class ManifestReadThreadPool {
             createCachedThreadPool(Runtime.getRuntime().availableProcessors(), 
THREAD_NAME);
 
     public static synchronized ExecutorService getExecutorService(@Nullable 
Integer threadNum) {
+        threadNum = normalizeThreadNum(threadNum);
         if (threadNum == null || threadNum == 
executorService.getMaximumPoolSize()) {
             return executorService;
         }
@@ -54,6 +55,7 @@ public class ManifestReadThreadPool {
     /** This method aims to parallel process tasks with memory control and 
sequentially. */
     public static <T, U> Iterable<T> sequentialBatchedExecute(
             Function<U, List<T>> processor, List<U> input, @Nullable Integer 
threadNum) {
+        threadNum = normalizeThreadNum(threadNum);
         ExecutorService executor = getExecutorService(threadNum);
         if (threadNum == null) {
             threadNum =
@@ -64,6 +66,10 @@ public class ManifestReadThreadPool {
         return ThreadPoolUtils.sequentialBatchedExecute(executor, processor, 
input, threadNum);
     }
 
+    private static @Nullable Integer normalizeThreadNum(@Nullable Integer 
threadNum) {
+        return threadNum == null || threadNum <= 0 ? null : threadNum;
+    }
+
     /** This method aims to parallel process tasks with randomly but return 
values sequentially. */
     public static <T, U> Iterator<T> randomlyExecuteSequentialReturn(
             Function<U, List<T>> processor, List<U> input, @Nullable Integer 
threadNum) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
index 0b2b0031c8..1da28a5bef 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
@@ -37,15 +37,18 @@ import org.apache.paimon.manifest.ExpireFileEntry;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.FileSource;
 import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.mergetree.compact.DeduplicateMergeFunction;
 import org.apache.paimon.options.ExpireConfig;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.stats.SimpleStats;
 import org.apache.paimon.table.ExpireSnapshots;
 import org.apache.paimon.table.ExpireSnapshotsImpl;
 import org.apache.paimon.utils.ChangelogManager;
 import org.apache.paimon.utils.JsonSerdeUtil;
 import org.apache.paimon.utils.RecordWriter;
+import org.apache.paimon.utils.SlowFileIO;
 import org.apache.paimon.utils.SnapshotManager;
 import org.apache.paimon.utils.TagManager;
 
@@ -63,13 +66,18 @@ import java.nio.file.Paths;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Random;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import static java.util.Objects.requireNonNull;
@@ -277,8 +285,8 @@ public class ExpireSnapshotsTest {
                         1,
                         EMPTY_ROW,
                         EMPTY_ROW,
-                        null,
-                        null,
+                        SimpleStats.EMPTY_STATS,
+                        SimpleStats.EMPTY_STATS,
                         0,
                         1,
                         0,
@@ -296,9 +304,7 @@ public class ExpireSnapshotsTest {
         ManifestEntry delete = ManifestEntry.create(FileKind.DELETE, 
partition, 0, 1, dataFile);
 
         // expire
-        expire.snapshotDeletion()
-                .cleanUnusedDataFile(
-                        Arrays.asList(ExpireFileEntry.from(add), 
ExpireFileEntry.from(delete)));
+        cleanDeletedDataFiles(expire.snapshotDeletion(), Arrays.asList(add, 
delete));
 
         // check
         assertThat(fileIO.exists(myDataFile)).isFalse();
@@ -340,8 +346,8 @@ public class ExpireSnapshotsTest {
                         1,
                         EMPTY_ROW,
                         EMPTY_ROW,
-                        null,
-                        null,
+                        SimpleStats.EMPTY_STATS,
+                        SimpleStats.EMPTY_STATS,
                         0,
                         1,
                         0,
@@ -359,9 +365,7 @@ public class ExpireSnapshotsTest {
         ManifestEntry delete = ManifestEntry.create(FileKind.DELETE, 
partition, 0, 1, dataFile);
 
         // expire
-        expire.snapshotDeletion()
-                .cleanUnusedDataFile(
-                        Arrays.asList(ExpireFileEntry.from(add), 
ExpireFileEntry.from(delete)));
+        cleanDeletedDataFiles(expire.snapshotDeletion(), Arrays.asList(add, 
delete));
 
         // check
         assertThat(fileIO.exists(myDataFile)).isFalse();
@@ -371,6 +375,54 @@ public class ExpireSnapshotsTest {
         store.assertCleaned();
     }
 
+    private void cleanDeletedDataFiles(
+            SnapshotDeletion snapshotDeletion, List<ManifestEntry> 
dataFileLog) {
+        List<ManifestFileMeta> manifests = 
store.manifestFileFactory().create().write(dataFileLog);
+        String manifestList = 
store.manifestListFactory().create().write(manifests).getLeft();
+        Snapshot snapshot = snapshotWithDeltaManifestList(manifestList);
+
+        snapshotDeletion.cleanDataFiles(
+                snapshotDeletion.planDeletedInDeltaManifest(snapshot, file -> 
false));
+
+        for (ManifestFileMeta manifest : manifests) {
+            
fileIO.deleteQuietly(store.pathFactory().toManifestFilePath(manifest.fileName()));
+        }
+        
fileIO.deleteQuietly(store.pathFactory().toManifestListPath(manifestList));
+    }
+
+    private Snapshot snapshotWithDeltaManifestList(String manifestList) {
+        return snapshotWithManifestLists(manifestList, null);
+    }
+
+    private Snapshot snapshotWithChangelogManifestList(String manifestList) {
+        return snapshotWithManifestLists(null, manifestList);
+    }
+
+    private Snapshot snapshotWithManifestLists(
+            String deltaManifestList, String changelogManifestList) {
+        return new Snapshot(
+                0,
+                0L,
+                null,
+                null,
+                deltaManifestList,
+                null,
+                changelogManifestList,
+                null,
+                null,
+                "test",
+                0L,
+                Snapshot.CommitKind.APPEND,
+                0L,
+                0L,
+                0L,
+                null,
+                null,
+                null,
+                null,
+                null);
+    }
+
     @Test
     public void testNoSnapshot() throws IOException {
         ExpireSnapshots expire = store.newExpire(1, 3, Long.MAX_VALUE);
@@ -560,6 +612,276 @@ public class ExpireSnapshotsTest {
         store.assertCleaned();
     }
 
+    @Test
+    public void testExpireCollectsSnapshotsConcurrently() throws Exception {
+        
store.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM, 4);
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(5, allData, snapshotPositions);
+        int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+        for (int i = 1; i <= 5; i++) {
+            rewriteSnapshotTime(i, 0);
+        }
+
+        BlockingSnapshotManager blockingSnapshotManager =
+                new BlockingSnapshotManager(snapshotManager, 1, 5);
+        ExpireSnapshotsImpl expire =
+                new ExpireSnapshotsImpl(
+                        blockingSnapshotManager,
+                        changelogManager,
+                        store.newSnapshotDeletion(),
+                        store.newTagManager());
+
+        expire.expireUntil(1, latestSnapshotId);
+
+        assertThat(blockingSnapshotManager.maxActiveReads()).isGreaterThan(1);
+        assertSnapshot(latestSnapshotId, allData, snapshotPositions);
+        store.assertCleaned();
+    }
+
+    @Test
+    public void testExpirePlansDataFilesConcurrently() throws Exception {
+        
store.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM, 4);
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(6, allData, snapshotPositions);
+        SnapshotManager snapshotManager = store.snapshotManager();
+        int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+        for (int i = 1; i <= latestSnapshotId; i++) {
+            rewriteSnapshotTime(i, 0);
+        }
+
+        BlockingSnapshotDeletion snapshotDeletion =
+                new BlockingSnapshotDeletion(store, 2, latestSnapshotId);
+        snapshotDeletion.blockDataFilePlans();
+        ExpireSnapshotsImpl expire =
+                newExpireWithSnapshotDeletion(store, snapshotManager, 
snapshotDeletion);
+        expire.config(expireAllButLatestConfig());
+        expire.setCurrentTimeMillis(() -> 1000L);
+
+        expire.expire();
+
+        assertThat(snapshotDeletion.maxActiveDataFilePlans()).isGreaterThan(1);
+        assertSnapshot(latestSnapshotId, allData, snapshotPositions);
+        store.assertCleaned();
+    }
+
+    @Test
+    public void testExpirePlansChangelogFilesConcurrently() throws Exception {
+        TestFileStore inputStore = 
createStore(CoreOptions.ChangelogProducer.INPUT);
+        
inputStore.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM,
 4);
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(inputStore, 6, allData, snapshotPositions);
+        SnapshotManager snapshotManager = inputStore.snapshotManager();
+        int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+        for (int i = 1; i <= latestSnapshotId; i++) {
+            rewriteSnapshotTime(inputStore.fileIO(), snapshotManager, i, 0);
+        }
+
+        Set<String> changelogManifestLists = new HashSet<>();
+        for (int i = 1; i < latestSnapshotId; i++) {
+            String changelogManifestList = 
snapshotManager.snapshot(i).changelogManifestList();
+            if (changelogManifestList != null) {
+                changelogManifestLists.add(changelogManifestList);
+            }
+        }
+        assertThat(changelogManifestLists.size()).isGreaterThan(1);
+
+        BlockingSnapshotDeletion snapshotDeletion =
+                new BlockingSnapshotDeletion(inputStore, 1, latestSnapshotId - 
1);
+        snapshotDeletion.blockChangelogPlans(changelogManifestLists);
+        ExpireSnapshotsImpl expire =
+                newExpireWithSnapshotDeletion(inputStore, snapshotManager, 
snapshotDeletion);
+        expire.config(expireAllButLatestConfig());
+        expire.setCurrentTimeMillis(() -> 1000L);
+
+        expire.expire();
+
+        
assertThat(snapshotDeletion.maxActiveChangelogPlans()).isGreaterThan(1);
+        assertSnapshot(inputStore, latestSnapshotId, allData, 
snapshotPositions);
+        inputStore.assertCleaned();
+    }
+
+    @Test
+    public void testExpirePlansManifestsConcurrentlyWithSkippingSet() throws 
Exception {
+        
store.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM, 4);
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(6, allData, snapshotPositions);
+        SnapshotManager snapshotManager = store.snapshotManager();
+        int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+        for (int i = 1; i <= latestSnapshotId; i++) {
+            rewriteSnapshotTime(i, 0);
+        }
+
+        BlockingSnapshotDeletion snapshotDeletion =
+                new BlockingSnapshotDeletion(store, 1, latestSnapshotId - 1);
+        snapshotDeletion.blockManifestPlans();
+        ExpireSnapshotsImpl expire =
+                newExpireWithSnapshotDeletion(store, snapshotManager, 
snapshotDeletion);
+        expire.config(expireAllButLatestConfig());
+        expire.setCurrentTimeMillis(() -> 1000L);
+
+        expire.expire();
+
+        assertThat(snapshotDeletion.maxActiveManifestPlans()).isGreaterThan(1);
+        assertSnapshot(latestSnapshotId, allData, snapshotPositions);
+        store.assertCleaned();
+    }
+
+    @Test
+    public void 
testExpireWithTagsAndConcurrentPlanningKeepsTaggedSnapshotsReadable()
+            throws Exception {
+        
store.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM, 4);
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(8, allData, snapshotPositions);
+        int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+        for (int i = 1; i <= latestSnapshotId; i++) {
+            rewriteSnapshotTime(i, 0);
+        }
+
+        TagManager tagManager = store.newTagManager();
+        tagManager.createTag(
+                snapshotManager.snapshot(3),
+                "tag3",
+                store.options().tagDefaultTimeRetained(),
+                Collections.emptyList(),
+                false);
+        tagManager.createTag(
+                snapshotManager.snapshot(6),
+                "tag6",
+                store.options().tagDefaultTimeRetained(),
+                Collections.emptyList(),
+                false);
+
+        ExpireSnapshotsImpl expire =
+                (ExpireSnapshotsImpl) 
store.newExpire(expireAllButLatestConfig());
+        expire.setCurrentTimeMillis(() -> 1000L);
+        expire.expire();
+
+        for (int i = 1; i < latestSnapshotId; i++) {
+            assertThat(snapshotManager.snapshotExists(i)).isFalse();
+        }
+        assertSnapshot(latestSnapshotId, allData, snapshotPositions);
+        assertSnapshot(tagManager.getOrThrow("tag3").trimToSnapshot(), 
allData, snapshotPositions);
+        assertSnapshot(tagManager.getOrThrow("tag6").trimToSnapshot(), 
allData, snapshotPositions);
+    }
+
+    @Test
+    public void testExpireReadsTagsConcurrentlyWithObjectStoreFileIO() throws 
Exception {
+        TestFileStore slowStore = createSlowStore();
+        
slowStore.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM,
 4);
+
+        try {
+            List<KeyValue> allData = new ArrayList<>();
+            List<Integer> snapshotPositions = new ArrayList<>();
+            commit(slowStore, 6, allData, snapshotPositions);
+
+            SnapshotManager snapshotManager = slowStore.snapshotManager();
+            int latestSnapshotId = 
requireNonNull(snapshotManager.latestSnapshotId()).intValue();
+            TagManager tagManager = slowStore.newTagManager();
+            for (int i = 1; i <= latestSnapshotId; i++) {
+                tagManager.createTag(
+                        snapshotManager.snapshot(i),
+                        "tag" + i,
+                        slowStore.options().tagDefaultTimeRetained(),
+                        Collections.emptyList(),
+                        false);
+                rewriteSnapshotTime(slowStore.fileIO(), snapshotManager, i, 0);
+            }
+
+            SlowFileIO.reset();
+            SlowFileIO.setDelayMillis(20);
+            ExpireSnapshotsImpl expire =
+                    (ExpireSnapshotsImpl) 
slowStore.newExpire(expireAllButLatestConfig());
+            expire.setCurrentTimeMillis(() -> 1000L);
+
+            expire.expire();
+
+            assertThat(SlowFileIO.delayedOperations()).isGreaterThan(0);
+            assertThat(SlowFileIO.maxActiveOperations()).isGreaterThan(1);
+            assertSnapshot(slowStore, latestSnapshotId, allData, 
snapshotPositions);
+        } finally {
+            SlowFileIO.reset();
+        }
+    }
+
+    @Test
+    public void 
testPlanUnusedDataFilesCancelsDeletionWhenManifestFileMissing() throws 
Exception {
+        ManifestFileMeta missingManifest =
+                new ManifestFileMeta(
+                        "missing-manifest",
+                        1,
+                        0,
+                        1,
+                        SimpleStats.EMPTY_STATS,
+                        0,
+                        null,
+                        null,
+                        null,
+                        null,
+                        null,
+                        null);
+        String manifestList =
+                store.manifestListFactory()
+                        .create()
+                        .write(Arrays.asList(missingManifest))
+                        .getLeft();
+
+        CapturingSnapshotDeletion snapshotDeletion = new 
CapturingSnapshotDeletion(store);
+        assertThat(
+                        snapshotDeletion.planDeletedInDeltaManifest(
+                                snapshotWithDeltaManifestList(manifestList), 
entry -> false))
+                .isEmpty();
+    }
+
+    @Test
+    public void testExpireWithSlowObjectStoreFileIO() throws Exception {
+        TestFileStore slowStore = createSlowStore();
+        
slowStore.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM,
 4);
+
+        try {
+            List<KeyValue> allData = new ArrayList<>();
+            List<Integer> snapshotPositions = new ArrayList<>();
+            for (int i = 0; i < 8; i++) {
+                commit(slowStore, 3, allData, snapshotPositions);
+            }
+
+            SnapshotManager slowSnapshotManager = slowStore.snapshotManager();
+            int latestSnapshotId =
+                    
requireNonNull(slowSnapshotManager.latestSnapshotId()).intValue();
+            for (int i = 1; i <= latestSnapshotId; i++) {
+                rewriteSnapshotTime(slowStore.fileIO(), slowSnapshotManager, 
i, 0);
+            }
+
+            SlowFileIO.reset();
+            SlowFileIO.setDelayMillis(20);
+            ExpireConfig config =
+                    ExpireConfig.builder()
+                            .snapshotRetainMin(1)
+                            .snapshotRetainMax(Integer.MAX_VALUE)
+                            .snapshotTimeRetain(Duration.ofMillis(1))
+                            .build();
+            ExpireSnapshotsImpl expire = (ExpireSnapshotsImpl) 
slowStore.newExpire(config);
+            expire.setCurrentTimeMillis(() -> 1000L);
+
+            expire.expire();
+
+            assertThat(SlowFileIO.delayedOperations()).isGreaterThan(0);
+            assertThat(SlowFileIO.maxActiveOperations()).isGreaterThan(1);
+            assertSnapshot(slowStore, latestSnapshotId, allData, 
snapshotPositions);
+        } finally {
+            SlowFileIO.reset();
+        }
+    }
+
     @Test
     public void testExpireWithTimeProtectsEachSnapshot() throws Exception {
         // Even with a small retainMin, each snapshot should be protected by
@@ -611,6 +933,45 @@ public class ExpireSnapshotsTest {
         store.assertCleaned();
     }
 
+    @Test
+    public void testExpireWithTimeDoesNotReadProtectedRange() throws Exception 
{
+        ExpireConfig config =
+                ExpireConfig.builder()
+                        .snapshotRetainMin(1)
+                        .snapshotRetainMax(Integer.MAX_VALUE)
+                        .snapshotTimeRetain(Duration.ofMillis(5000))
+                        .build();
+
+        List<KeyValue> allData = new ArrayList<>();
+        List<Integer> snapshotPositions = new ArrayList<>();
+        commit(6, allData, snapshotPositions);
+        for (int i = 1; i <= 6; i++) {
+            rewriteSnapshotTime(i, 0);
+        }
+        rewriteSnapshotTime(4, 2000);
+
+        FailingSnapshotManager failingSnapshotManager =
+                new FailingSnapshotManager(snapshotManager, 5);
+        ExpireSnapshotsImpl expire =
+                new ExpireSnapshotsImpl(
+                        failingSnapshotManager,
+                        changelogManager,
+                        store.newSnapshotDeletion(),
+                        store.newTagManager());
+        expire.config(config);
+        expire.setCurrentTimeMillis(() -> 6000L);
+
+        expire.expire();
+
+        assertThat(failingSnapshotManager.readFailedSnapshot()).isFalse();
+        assertThat(snapshotManager.snapshotExists(1)).isFalse();
+        assertThat(snapshotManager.snapshotExists(2)).isFalse();
+        for (int i = 3; i <= 6; i++) {
+            assertThat(snapshotManager.snapshotExists(i)).isTrue();
+            assertSnapshot(i, allData, snapshotPositions);
+        }
+    }
+
     @Test
     public void testExpireWithUpgradedFile() throws Exception {
         // write & commit data
@@ -654,6 +1015,26 @@ public class ExpireSnapshotsTest {
         store.assertCleaned();
     }
 
+    @Test
+    public void testExpireWithZeroFileOperationThreadNumCleansDataFiles() 
throws Exception {
+        
store.options().toConfiguration().set(CoreOptions.FILE_OPERATION_THREAD_NUM, 0);
+
+        List<KeyValue> data = FileStoreTestUtils.partitionedData(5, gen, 
"0401", 8);
+        BinaryRow partition = gen.getPartition(data.get(0));
+        RecordWriter<KeyValue> writer = FileStoreTestUtils.writeData(store, 
data, partition, 0);
+        Map<BinaryRow, Map<Integer, RecordWriter<KeyValue>>> writers =
+                Collections.singletonMap(partition, 
Collections.singletonMap(0, writer));
+        FileStoreTestUtils.commitData(store, 0, writers);
+
+        writer.compact(true);
+        writer.sync();
+        FileStoreTestUtils.commitData(store, 1, writers);
+
+        store.newExpire(1, 1, Long.MAX_VALUE).expire();
+
+        store.assertCleaned();
+    }
+
     @RepeatedTest(5)
     public void testChangelogOutLivedSnapshot() throws Exception {
         List<KeyValue> allData = new ArrayList<>();
@@ -779,6 +1160,14 @@ public class ExpireSnapshotsTest {
     }
 
     private TestFileStore createStore() {
+        return createStore(tempDir.toString());
+    }
+
+    private TestFileStore createSlowStore() {
+        return createStore(SlowFileIO.SCHEME + "://" + tempDir.toString());
+    }
+
+    private TestFileStore createStore(String root) {
         ThreadLocalRandom random = ThreadLocalRandom.current();
 
         CoreOptions.ChangelogProducer changelogProducer;
@@ -788,9 +1177,18 @@ public class ExpireSnapshotsTest {
             changelogProducer = CoreOptions.ChangelogProducer.NONE;
         }
 
+        return createStore(root, changelogProducer);
+    }
+
+    private TestFileStore createStore(CoreOptions.ChangelogProducer 
changelogProducer) {
+        return createStore(tempDir.toString(), changelogProducer);
+    }
+
+    private TestFileStore createStore(
+            String root, CoreOptions.ChangelogProducer changelogProducer) {
         return new TestFileStore.Builder(
                         "avro",
-                        tempDir.toString(),
+                        root,
                         1,
                         TestKeyValueGenerator.DEFAULT_PART_TYPE,
                         TestKeyValueGenerator.KEY_TYPE,
@@ -802,7 +1200,29 @@ public class ExpireSnapshotsTest {
                 .build();
     }
 
+    private ExpireConfig expireAllButLatestConfig() {
+        return ExpireConfig.builder()
+                .snapshotRetainMin(1)
+                .snapshotRetainMax(Integer.MAX_VALUE)
+                .snapshotTimeRetain(Duration.ofMillis(1))
+                .build();
+    }
+
+    private ExpireSnapshotsImpl newExpireWithSnapshotDeletion(
+            TestFileStore store,
+            SnapshotManager snapshotManager,
+            SnapshotDeletion snapshotDeletion) {
+        return new ExpireSnapshotsImpl(
+                snapshotManager, store.changelogManager(), snapshotDeletion, 
store.newTagManager());
+    }
+
     private void rewriteSnapshotTime(long snapshotId, long newTimeMillis) 
throws IOException {
+        rewriteSnapshotTime(fileIO, snapshotManager, snapshotId, 
newTimeMillis);
+    }
+
+    private void rewriteSnapshotTime(
+            FileIO fileIO, SnapshotManager snapshotManager, long snapshotId, 
long newTimeMillis)
+            throws IOException {
         String oldJson = 
fileIO.readFileUtf8(snapshotManager.snapshotPath(snapshotId));
         ObjectNode node = (ObjectNode) 
JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.readTree(oldJson);
         node.put("timeMillis", newTimeMillis);
@@ -811,8 +1231,275 @@ public class ExpireSnapshotsTest {
         snapshotManager.invalidateCache();
     }
 
+    private static class FailingSnapshotManager extends SnapshotManager {
+
+        private final long failedSnapshotId;
+        private boolean readFailedSnapshot;
+
+        private FailingSnapshotManager(SnapshotManager snapshotManager, long 
failedSnapshotId) {
+            super(
+                    snapshotManager.fileIO(),
+                    snapshotManager.tablePath(),
+                    snapshotManager.branch(),
+                    null,
+                    null);
+            this.failedSnapshotId = failedSnapshotId;
+        }
+
+        @Override
+        public Snapshot tryGetSnapshot(long snapshotId) throws 
java.io.FileNotFoundException {
+            if (snapshotId == failedSnapshotId) {
+                readFailedSnapshot = true;
+                throw new RuntimeException("Unexpected snapshot read.");
+            }
+            return super.tryGetSnapshot(snapshotId);
+        }
+
+        private boolean readFailedSnapshot() {
+            return readFailedSnapshot;
+        }
+    }
+
+    private static class BlockingSnapshotManager extends SnapshotManager {
+
+        private final long minBlockedSnapshotId;
+        private final long maxBlockedSnapshotId;
+        private final CountDownLatch releaseReads = new CountDownLatch(1);
+        private final AtomicInteger activeReads = new AtomicInteger();
+        private final AtomicInteger maxActiveReads = new AtomicInteger();
+
+        private BlockingSnapshotManager(
+                SnapshotManager snapshotManager,
+                long minBlockedSnapshotId,
+                long maxBlockedSnapshotId) {
+            super(
+                    snapshotManager.fileIO(),
+                    snapshotManager.tablePath(),
+                    snapshotManager.branch(),
+                    null,
+                    null);
+            this.minBlockedSnapshotId = minBlockedSnapshotId;
+            this.maxBlockedSnapshotId = maxBlockedSnapshotId;
+        }
+
+        @Override
+        public Snapshot tryGetSnapshot(long snapshotId) throws 
java.io.FileNotFoundException {
+            if (snapshotId < minBlockedSnapshotId
+                    || snapshotId > maxBlockedSnapshotId
+                    || releaseReads.getCount() == 0) {
+                return super.tryGetSnapshot(snapshotId);
+            }
+
+            int active = activeReads.incrementAndGet();
+            updateMaxActiveReads(active);
+            if (active > 1) {
+                releaseReads.countDown();
+            }
+            try {
+                if (!releaseReads.await(5, TimeUnit.SECONDS)) {
+                    throw new RuntimeException("Snapshots were not read 
concurrently.");
+                }
+                return super.tryGetSnapshot(snapshotId);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            } finally {
+                activeReads.decrementAndGet();
+            }
+        }
+
+        private void updateMaxActiveReads(int active) {
+            int current;
+            do {
+                current = maxActiveReads.get();
+                if (active <= current) {
+                    return;
+                }
+            } while (!maxActiveReads.compareAndSet(current, active));
+        }
+
+        private int maxActiveReads() {
+            return maxActiveReads.get();
+        }
+    }
+
+    private static class BlockingSnapshotDeletion extends SnapshotDeletion {
+
+        private final long minBlockedSnapshotId;
+        private final long maxBlockedSnapshotId;
+        private final ConcurrentCallTracker dataFilePlans =
+                new ConcurrentCallTracker("Data file plans were not created 
concurrently.");
+        private final ConcurrentCallTracker changelogPlans =
+                new ConcurrentCallTracker("Changelog plans were not created 
concurrently.");
+        private final ConcurrentCallTracker manifestPlans =
+                new ConcurrentCallTracker("Manifest plans were not created 
concurrently.");
+
+        private boolean blockDataFilePlans;
+        private boolean blockManifestPlans;
+        private Set<String> blockedChangelogManifestLists = 
Collections.emptySet();
+
+        private BlockingSnapshotDeletion(
+                TestFileStore store, long minBlockedSnapshotId, long 
maxBlockedSnapshotId) {
+            super(
+                    store.fileIO(),
+                    store.pathFactory(),
+                    store.manifestFileFactory().create(),
+                    store.manifestListFactory().create(),
+                    store.newIndexFileHandler(),
+                    store.newStatsFileHandler(),
+                    store.options().changelogProducer() != 
CoreOptions.ChangelogProducer.NONE,
+                    store.options().cleanEmptyDirectories(),
+                    store.options().fileOperationThreadNum());
+            this.minBlockedSnapshotId = minBlockedSnapshotId;
+            this.maxBlockedSnapshotId = maxBlockedSnapshotId;
+        }
+
+        private void blockDataFilePlans() {
+            blockDataFilePlans = true;
+        }
+
+        private void blockChangelogPlans(Set<String> changelogManifestLists) {
+            blockedChangelogManifestLists = changelogManifestLists;
+        }
+
+        private void blockManifestPlans() {
+            blockManifestPlans = true;
+        }
+
+        @Override
+        public List<Path> planDeletedInDeltaManifest(
+                Snapshot snapshot, Predicate<ExpireFileEntry> skipper) {
+            if (blockDataFilePlans && shouldBlock(snapshot.id())) {
+                dataFilePlans.awaitConcurrentCall();
+            }
+            return super.planDeletedInDeltaManifest(snapshot, skipper);
+        }
+
+        @Override
+        public List<Path> planAddedInChangelogManifest(Snapshot snapshot) {
+            if 
(blockedChangelogManifestLists.contains(snapshot.changelogManifestList())) {
+                changelogPlans.awaitConcurrentCall();
+            }
+            return super.planAddedInChangelogManifest(snapshot);
+        }
+
+        @Override
+        public List<Runnable> planManifestsCleaner(Snapshot snapshot, 
Set<String> skippingSet) {
+            if (blockManifestPlans && shouldBlock(snapshot.id())) {
+                manifestPlans.awaitConcurrentCall();
+            }
+            return super.planManifestsCleaner(snapshot, skippingSet);
+        }
+
+        private boolean shouldBlock(long snapshotId) {
+            return snapshotId >= minBlockedSnapshotId && snapshotId <= 
maxBlockedSnapshotId;
+        }
+
+        private int maxActiveDataFilePlans() {
+            return dataFilePlans.maxActiveCalls();
+        }
+
+        private int maxActiveChangelogPlans() {
+            return changelogPlans.maxActiveCalls();
+        }
+
+        private int maxActiveManifestPlans() {
+            return manifestPlans.maxActiveCalls();
+        }
+    }
+
+    private static class CapturingSnapshotDeletion extends SnapshotDeletion {
+
+        private final List<List<Object>> deleteBatches = new ArrayList<>();
+
+        private CapturingSnapshotDeletion(TestFileStore store) {
+            super(
+                    store.fileIO(),
+                    store.pathFactory(),
+                    store.manifestFileFactory().create(),
+                    store.manifestListFactory().create(),
+                    store.newIndexFileHandler(),
+                    store.newStatsFileHandler(),
+                    store.options().changelogProducer() != 
CoreOptions.ChangelogProducer.NONE,
+                    store.options().cleanEmptyDirectories(),
+                    store.options().fileOperationThreadNum());
+        }
+
+        @Override
+        protected <F> void executeAll(
+                Collection<F> files, java.util.function.Consumer<F> deletion) {
+            if (!files.isEmpty()) {
+                deleteBatches.add(new ArrayList<>(files));
+            }
+        }
+
+        private void assertDeleteBatchesDeduplicated() {
+            assertThat(deleteBatches).isNotEmpty();
+            for (List<Object> batch : deleteBatches) {
+                assertThat(new HashSet<>(batch)).hasSize(batch.size());
+            }
+        }
+
+        private void reset() {
+            deleteBatches.clear();
+        }
+    }
+
+    private static class ConcurrentCallTracker {
+
+        private final String timeoutMessage;
+        private final CountDownLatch releaseCalls = new CountDownLatch(1);
+        private final AtomicInteger activeCalls = new AtomicInteger();
+        private final AtomicInteger maxActiveCalls = new AtomicInteger();
+
+        private ConcurrentCallTracker(String timeoutMessage) {
+            this.timeoutMessage = timeoutMessage;
+        }
+
+        private void awaitConcurrentCall() {
+            int active = activeCalls.incrementAndGet();
+            updateMaxActiveCalls(active);
+            if (active > 1) {
+                releaseCalls.countDown();
+            }
+            try {
+                if (!releaseCalls.await(5, TimeUnit.SECONDS)) {
+                    throw new RuntimeException(timeoutMessage);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            } finally {
+                activeCalls.decrementAndGet();
+            }
+        }
+
+        private void updateMaxActiveCalls(int active) {
+            int current;
+            do {
+                current = maxActiveCalls.get();
+                if (active <= current) {
+                    return;
+                }
+            } while (!maxActiveCalls.compareAndSet(current, active));
+        }
+
+        private int maxActiveCalls() {
+            return maxActiveCalls.get();
+        }
+    }
+
     protected void commit(int numCommits, List<KeyValue> allData, 
List<Integer> snapshotPositions)
             throws Exception {
+        commit(store, numCommits, allData, snapshotPositions);
+    }
+
+    protected void commit(
+            TestFileStore store,
+            int numCommits,
+            List<KeyValue> allData,
+            List<Integer> snapshotPositions)
+            throws Exception {
         for (int i = 0; i < numCommits; i++) {
             int numRecords = ThreadLocalRandom.current().nextInt(100) + 1;
             List<KeyValue> data = new ArrayList<>();
@@ -830,12 +1517,31 @@ public class ExpireSnapshotsTest {
     protected void assertSnapshot(
             int snapshotId, List<KeyValue> allData, List<Integer> 
snapshotPositions)
             throws Exception {
-        assertSnapshot(snapshotManager.snapshot(snapshotId), allData, 
snapshotPositions);
+        assertSnapshot(store, snapshotManager.snapshot(snapshotId), allData, 
snapshotPositions);
+    }
+
+    protected void assertSnapshot(
+            TestFileStore store,
+            int snapshotId,
+            List<KeyValue> allData,
+            List<Integer> snapshotPositions)
+            throws Exception {
+        assertSnapshot(
+                store, store.snapshotManager().snapshot(snapshotId), allData, 
snapshotPositions);
     }
 
     protected void assertSnapshot(
             Snapshot snapshot, List<KeyValue> allData, List<Integer> 
snapshotPositions)
             throws Exception {
+        assertSnapshot(store, snapshot, allData, snapshotPositions);
+    }
+
+    protected void assertSnapshot(
+            TestFileStore store,
+            Snapshot snapshot,
+            List<KeyValue> allData,
+            List<Integer> snapshotPositions)
+            throws Exception {
         int snapshotId = (int) snapshot.id();
         Map<BinaryRow, BinaryRow> expected =
                 store.toKvMap(allData.subList(0, 
snapshotPositions.get(snapshotId - 1)));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/ManifestReadThreadPoolTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/utils/ManifestReadThreadPoolTest.java
index 8c131bdc52..7664660694 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/utils/ManifestReadThreadPoolTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/utils/ManifestReadThreadPoolTest.java
@@ -26,6 +26,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static java.util.Collections.emptyList;
@@ -128,6 +129,16 @@ public class ManifestReadThreadPoolTest {
         re.forEach(i -> Assertions.assertThat(i).isEqualTo(2));
     }
 
+    @Test
+    public void testNonPositiveThreadNumUsesDefaultExecutor() {
+        ExecutorService executor = 
ManifestReadThreadPool.getExecutorService(0);
+        
Assertions.assertThat(executor).isNotInstanceOf(SemaphoredDelegatingExecutor.class);
+
+        Iterable<Integer> re =
+                sequentialBatchedExecute(i -> singletonList(i + 1), 
singletonList(1), 0);
+        re.forEach(i -> Assertions.assertThat(i).isEqualTo(2));
+    }
+
     @Test
     public void testDifferentQueueSizeWithFilterElement() {
         for (int queueSize = 1; queueSize < 20; queueSize++) {
diff --git a/paimon-core/src/test/java/org/apache/paimon/utils/SlowFileIO.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/SlowFileIO.java
new file mode 100644
index 0000000000..a8f68cd24d
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/SlowFileIO.java
@@ -0,0 +1,159 @@
+/*
+ * 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.utils;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileIOLoader;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** A local {@link FileIO} which adds configurable latency to each file 
operation. */
+public class SlowFileIO extends LocalFileIO {
+
+    public static final String SCHEME = "slow-expire";
+
+    private static final AtomicLong DELAY_MILLIS = new AtomicLong();
+    private static final AtomicLong DELAYED_OPERATIONS = new AtomicLong();
+    private static final AtomicInteger ACTIVE_OPERATIONS = new AtomicInteger();
+    private static final AtomicInteger MAX_ACTIVE_OPERATIONS = new 
AtomicInteger();
+
+    public static void reset() {
+        DELAY_MILLIS.set(0);
+        DELAYED_OPERATIONS.set(0);
+        ACTIVE_OPERATIONS.set(0);
+        MAX_ACTIVE_OPERATIONS.set(0);
+    }
+
+    public static void setDelayMillis(long delayMillis) {
+        DELAY_MILLIS.set(delayMillis);
+    }
+
+    public static long delayedOperations() {
+        return DELAYED_OPERATIONS.get();
+    }
+
+    public static int maxActiveOperations() {
+        return MAX_ACTIVE_OPERATIONS.get();
+    }
+
+    @Override
+    public boolean isObjectStore() {
+        return true;
+    }
+
+    @Override
+    public SeekableInputStream newInputStream(Path path) throws IOException {
+        delay();
+        return super.newInputStream(path);
+    }
+
+    @Override
+    public PositionOutputStream newOutputStream(Path path, boolean overwrite) 
throws IOException {
+        delay();
+        return super.newOutputStream(path, overwrite);
+    }
+
+    @Override
+    public FileStatus getFileStatus(Path path) throws IOException {
+        delay();
+        return super.getFileStatus(path);
+    }
+
+    @Override
+    public FileStatus[] listStatus(Path path) throws IOException {
+        delay();
+        return super.listStatus(path);
+    }
+
+    @Override
+    public boolean exists(Path path) throws IOException {
+        delay();
+        return super.exists(path);
+    }
+
+    @Override
+    public boolean delete(Path path, boolean recursive) throws IOException {
+        delay();
+        return super.delete(path, recursive);
+    }
+
+    @Override
+    public boolean mkdirs(Path path) throws IOException {
+        delay();
+        return super.mkdirs(path);
+    }
+
+    @Override
+    public boolean rename(Path src, Path dst) throws IOException {
+        delay();
+        return super.rename(src, dst);
+    }
+
+    private void delay() throws IOException {
+        long delayMillis = DELAY_MILLIS.get();
+        if (delayMillis <= 0) {
+            return;
+        }
+
+        DELAYED_OPERATIONS.incrementAndGet();
+        int active = ACTIVE_OPERATIONS.incrementAndGet();
+        updateMaxActiveOperations(active);
+        try {
+            Thread.sleep(delayMillis);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IOException(e);
+        } finally {
+            ACTIVE_OPERATIONS.decrementAndGet();
+        }
+    }
+
+    private static void updateMaxActiveOperations(int active) {
+        int current;
+        do {
+            current = MAX_ACTIVE_OPERATIONS.get();
+            if (active <= current) {
+                return;
+            }
+        } while (!MAX_ACTIVE_OPERATIONS.compareAndSet(current, active));
+    }
+
+    /** Loader for {@link SlowFileIO}. */
+    public static class Loader implements FileIOLoader {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public String getScheme() {
+            return SCHEME;
+        }
+
+        @Override
+        public FileIO load(Path path) {
+            return new SlowFileIO();
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fs.FileIOLoader
 
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fs.FileIOLoader
index ae1021e74e..fd22396ea2 100644
--- 
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fs.FileIOLoader
+++ 
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fs.FileIOLoader
@@ -15,4 +15,5 @@
 
 org.apache.paimon.utils.FailingFileIO$Loader
 org.apache.paimon.utils.TraceableFileIO$Loader
+org.apache.paimon.utils.SlowFileIO$Loader
 org.apache.paimon.rest.RESTFileIOTestLoader


Reply via email to