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 63e77417d9 [core] Reduce index manifest memory usage during snapshot 
expiration (#10710)
63e77417d9 is described below

commit 63e77417d9387e1a3c5e8a16a5e8cd76d2d0de70
Author: zhoulii <[email protected]>
AuthorDate: Wed Oct 21 13:58:52 2026 +0800

    [core] Reduce index manifest memory usage during snapshot expiration 
(#10710)
---
 .../org/apache/paimon/index/IndexFileHandler.java  |   7 +
 .../index/IndexInDataFileDirPathFactory.java       |   8 +-
 .../org/apache/paimon/index/IndexPathFactory.java  |   8 +-
 .../paimon/manifest/BinaryIndexManifestEntry.java  |  41 +++
 .../apache/paimon/operation/ChangelogDeletion.java |   7 +-
 .../apache/paimon/operation/FileDeletionBase.java  |  57 ++--
 .../apache/paimon/utils/FileStorePathFactory.java  |   7 +-
 .../manifest/BinaryIndexManifestEntryTest.java     |  84 ++++++
 .../operation/IndexManifestDeletionTest.java       | 336 +++++++++++++++++++++
 9 files changed, 522 insertions(+), 33 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
index 6621cb56cd..666384f56c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
@@ -54,6 +54,7 @@ import java.util.Set;
 import java.util.function.Function;
 
 import static org.apache.paimon.index.HashIndexFile.HASH_INDEX;
+import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
 
 /** Handle index files. */
 public class IndexFileHandler {
@@ -290,6 +291,12 @@ public class IndexFileHandler {
         return pathFactories.get(entry.partition(), 
entry.bucket()).toPath(entry.indexFile());
     }
 
+    public Path filePath(BinaryIndexManifestEntry entry) {
+        return pathFactories
+                .get(deserializeBinaryRow(entry.partitionBytes()), 
entry.bucket())
+                .toPath(entry.fileName(), entry.externalPath());
+    }
+
     public boolean existsManifest(String indexManifest) {
         return indexManifestFile.exists(indexManifest);
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexInDataFileDirPathFactory.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexInDataFileDirPathFactory.java
index 840d218c64..f664802164 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexInDataFileDirPathFactory.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexInDataFileDirPathFactory.java
@@ -21,6 +21,8 @@ package org.apache.paimon.index;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFilePathFactory;
 
+import javax.annotation.Nullable;
+
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -52,10 +54,10 @@ public class IndexInDataFileDirPathFactory implements 
IndexPathFactory {
     }
 
     @Override
-    public Path toPath(IndexFileMeta file) {
-        return Optional.ofNullable(file.externalPath())
+    public Path toPath(String fileName, @Nullable String externalPath) {
+        return Optional.ofNullable(externalPath)
                 .map(Path::new)
-                .orElse(new Path(dataFilePathFactory.parent(), 
file.fileName()));
+                .orElse(new Path(dataFilePathFactory.parent(), fileName));
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexPathFactory.java 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexPathFactory.java
index c1ffc75733..3180bb2c44 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/IndexPathFactory.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexPathFactory.java
@@ -20,6 +20,8 @@ package org.apache.paimon.index;
 
 import org.apache.paimon.fs.Path;
 
+import javax.annotation.Nullable;
+
 /** Path factory to create an index path. */
 public interface IndexPathFactory {
 
@@ -27,8 +29,12 @@ public interface IndexPathFactory {
 
     Path newPath();
 
+    default Path toPath(String fileName, @Nullable String externalPath) {
+        return toPath(fileName);
+    }
+
     default Path toPath(IndexFileMeta file) {
-        return toPath(file.fileName());
+        return toPath(file.fileName(), file.externalPath());
     }
 
     boolean isExternalPath();
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryIndexManifestEntry.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryIndexManifestEntry.java
index e951316f09..fdd3ef8de3 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryIndexManifestEntry.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryIndexManifestEntry.java
@@ -36,6 +36,18 @@ public final class BinaryIndexManifestEntry {
 
     public static final Projection GLOBAL_INDEX_PROJECTION = 
createGlobalIndexProjection();
 
+    public static final Projection FILE_NAME_PROJECTION =
+            Projection.create(
+                    
IndexManifestEntry.MANIFEST_ROW_TYPE.project(IndexManifestEntry.FILE_NAME));
+
+    public static final Projection FILE_PATH_PROJECTION =
+            Projection.create(
+                    IndexManifestEntry.MANIFEST_ROW_TYPE.project(
+                            IndexManifestEntry.PARTITION,
+                            IndexManifestEntry.BUCKET,
+                            IndexManifestEntry.FILE_NAME,
+                            IndexManifestEntry.EXTERNAL_PATH));
+
     private final Projection projection;
     private @Nullable InternalRow row;
 
@@ -115,6 +127,27 @@ public final class BinaryIndexManifestEntry {
         return indexType;
     }
 
+    public String fileName() {
+        BinaryString fileName =
+                current()
+                        .getString(
+                                requiredPosition(
+                                        projection.fileNamePosition, 
IndexManifestEntry.FILE_NAME));
+        checkState(fileName != null, "Index file name cannot be null.");
+        return fileName.toString();
+    }
+
+    @Nullable
+    public String externalPath() {
+        BinaryString externalPath =
+                current()
+                        .getString(
+                                requiredPosition(
+                                        projection.externalPathPosition,
+                                        IndexManifestEntry.EXTERNAL_PATH));
+        return externalPath == null ? null : externalPath.toString();
+    }
+
     public boolean hasGlobalIndexMeta() {
         return !current()
                 .isNullAt(
@@ -185,6 +218,8 @@ public final class BinaryIndexManifestEntry {
         private final int partitionPosition;
         private final int bucketPosition;
         private final int indexTypePosition;
+        private final int fileNamePosition;
+        private final int externalPathPosition;
         private final int globalIndexPosition;
         private final int projectedGlobalIndexFieldCount;
         private final int rowRangeStartPosition;
@@ -198,6 +233,8 @@ public final class BinaryIndexManifestEntry {
                 int partitionPosition,
                 int bucketPosition,
                 int indexTypePosition,
+                int fileNamePosition,
+                int externalPathPosition,
                 int globalIndexPosition,
                 int projectedGlobalIndexFieldCount,
                 int rowRangeStartPosition,
@@ -209,6 +246,8 @@ public final class BinaryIndexManifestEntry {
             this.partitionPosition = partitionPosition;
             this.bucketPosition = bucketPosition;
             this.indexTypePosition = indexTypePosition;
+            this.fileNamePosition = fileNamePosition;
+            this.externalPathPosition = externalPathPosition;
             this.globalIndexPosition = globalIndexPosition;
             this.projectedGlobalIndexFieldCount = 
projectedGlobalIndexFieldCount;
             this.rowRangeStartPosition = rowRangeStartPosition;
@@ -246,6 +285,8 @@ public final class BinaryIndexManifestEntry {
                     projectedType.getFieldIndex(IndexManifestEntry.PARTITION),
                     projectedType.getFieldIndex(IndexManifestEntry.BUCKET),
                     projectedType.getFieldIndex(IndexManifestEntry.INDEX_TYPE),
+                    projectedType.getFieldIndex(IndexManifestEntry.FILE_NAME),
+                    
projectedType.getFieldIndex(IndexManifestEntry.EXTERNAL_PATH),
                     globalIndexPosition,
                     projectedGlobalIndexFieldCount,
                     rowRangeStartPosition,
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 eec6090a36..24f12bed5f 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
@@ -22,9 +22,7 @@ import org.apache.paimon.Changelog;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.index.IndexFileHandler;
-import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.manifest.ExpireFileEntry;
-import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.ManifestFile;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.stats.StatsFileHandler;
@@ -116,10 +114,7 @@ public class ChangelogDeletion extends 
FileDeletionBase<Changelog> {
             String indexManifest = skippingSnapshot.indexManifest();
             if (indexManifest != null && 
indexFileHandler.existsManifest(indexManifest)) {
                 skippingSet.add(indexManifest);
-                indexFileHandler.readManifest(indexManifest).stream()
-                        .map(IndexManifestEntry::indexFile)
-                        .map(IndexFileMeta::fileName)
-                        .forEach(skippingSet::add);
+                addIndexFilesToSkippingSet(skippingSnapshot, skippingSet);
             }
 
             // statistics
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 4f74342c0a..b449e7bdf0 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
@@ -23,20 +23,20 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.index.IndexFileHandler;
-import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.manifest.BinaryIndexManifestEntry;
 import org.apache.paimon.manifest.BucketFilter;
 import org.apache.paimon.manifest.ExpireFileEntry;
 import org.apache.paimon.manifest.FileEntry;
 import org.apache.paimon.manifest.FileEntry.Identifier;
 import org.apache.paimon.manifest.FileKind;
-import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.ManifestBucketFilter;
 import org.apache.paimon.manifest.ManifestFile;
 import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.manifest.ProjectedManifestEntry;
 import org.apache.paimon.stats.StatsFileHandler;
+import org.apache.paimon.utils.CloseableIterator;
 import org.apache.paimon.utils.DataFilePathFactories;
 import org.apache.paimon.utils.FileOperationThreadPool;
 import org.apache.paimon.utils.FileStorePathFactory;
@@ -51,6 +51,7 @@ import javax.annotation.Nullable;
 
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -310,24 +311,33 @@ public abstract class FileDeletionBase<T extends 
Snapshot> {
     private void collectUnusedIndexManifests(
             Snapshot snapshot,
             Set<String> skippingSet,
-            Set<IndexManifestEntry> indexFiles,
+            Set<Path> indexFiles,
             Set<String> indexManifests) {
         // clean index manifests
         String indexManifest = snapshot.indexManifest();
         // check exists, it may have been deleted by other snapshots
         if (indexManifest != null) {
-            List<IndexManifestEntry> indexManifestEntries;
+            CloseableIterator<BinaryIndexManifestEntry> entries;
             try {
-                indexManifestEntries = 
indexFileHandler.readManifestWithIOException(indexManifest);
-            } catch (FileNotFoundException e) {
-                return;
-            } catch (IOException e) {
-                throw new RuntimeException(e);
+                entries =
+                        indexFileHandler.scan(
+                                snapshot, 
BinaryIndexManifestEntry.FILE_PATH_PROJECTION);
+            } catch (UncheckedIOException e) {
+                if (e.getCause() instanceof FileNotFoundException) {
+                    return;
+                }
+                throw e;
             }
-            for (IndexManifestEntry entry : indexManifestEntries) {
-                if (skippingSet.add(entry.indexFile().fileName())) {
-                    indexFiles.add(entry);
+            try (CloseableIterator<BinaryIndexManifestEntry> iterator = 
entries) {
+                while (iterator.hasNext()) {
+                    BinaryIndexManifestEntry entry = iterator.next();
+                    if (skippingSet.add(entry.fileName())) {
+                        indexFiles.add(indexFileHandler.filePath(entry));
+                    }
                 }
+            } catch (Exception e) {
+                // A missing manifest is only safe to ignore before scanning 
starts.
+                throw new RuntimeException("Failed to scan index manifest " + 
indexManifest, e);
             }
 
             if (skippingSet.add(indexManifest)) {
@@ -363,7 +373,7 @@ public abstract class FileDeletionBase<T extends Snapshot> {
             boolean deleteDataManifestLists,
             boolean deleteChangelog) {
         Set<String> manifests = new LinkedHashSet<>();
-        Set<IndexManifestEntry> indexFiles = new LinkedHashSet<>();
+        Set<Path> indexFiles = new LinkedHashSet<>();
         Set<String> indexManifests = new LinkedHashSet<>();
         Set<String> statistics = new LinkedHashSet<>();
         if (deleteDataManifestLists) {
@@ -392,8 +402,8 @@ public abstract class FileDeletionBase<T extends Snapshot> {
         for (String manifest : manifests) {
             tasks.add(() -> manifestFile.delete(manifest));
         }
-        for (IndexManifestEntry indexFile : indexFiles) {
-            tasks.add(() -> indexFileHandler.deleteIndexFile(indexFile));
+        for (Path indexFile : indexFiles) {
+            tasks.add(() -> fileIO.deleteQuietly(indexFile));
         }
         for (String indexManifest : indexManifests) {
             tasks.add(() -> indexFileHandler.deleteManifest(indexManifest));
@@ -618,10 +628,7 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
         String indexManifest = skippingSnapshot.indexManifest();
         if (indexManifest != null) {
             skippingSet.add(indexManifest);
-            indexFileHandler.readManifest(indexManifest).stream()
-                    .map(IndexManifestEntry::indexFile)
-                    .map(IndexFileMeta::fileName)
-                    .forEach(skippingSet::add);
+            addIndexFilesToSkippingSet(skippingSnapshot, skippingSet);
         }
 
         String reassignPlan = planFile(skippingSnapshot);
@@ -637,6 +644,18 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
         return skippingSet;
     }
 
+    protected void addIndexFilesToSkippingSet(Snapshot snapshot, Set<String> 
skippingSet) {
+        try (CloseableIterator<BinaryIndexManifestEntry> entries =
+                indexFileHandler.scan(snapshot, 
BinaryIndexManifestEntry.FILE_NAME_PROJECTION)) {
+            while (entries.hasNext()) {
+                skippingSet.add(entries.next().fileName());
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(
+                    "Failed to scan index manifest " + 
snapshot.indexManifest(), e);
+        }
+    }
+
     protected static void addManifestToSkippingSet(
             Set<String> skippingSet, ManifestFileMeta manifest) {
         skippingSet.add(manifest.fileName());
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/FileStorePathFactory.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/FileStorePathFactory.java
index e2c99b52c5..981b3a24da 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/utils/FileStorePathFactory.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/utils/FileStorePathFactory.java
@@ -24,7 +24,6 @@ import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.fs.ExternalPathProvider;
 import org.apache.paimon.fs.Path;
-import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexInDataFileDirPathFactory;
 import org.apache.paimon.index.IndexPathFactory;
 import org.apache.paimon.io.ChainReadContext;
@@ -342,12 +341,12 @@ public class FileStorePathFactory {
             }
 
             @Override
-            public Path toPath(IndexFileMeta file) {
-                return Optional.ofNullable(file.externalPath())
+            public Path toPath(String fileName, @Nullable String externalPath) 
{
+                return Optional.ofNullable(externalPath)
                         .map(Path::new)
                         // If external path is null, use the index path (not 
global index root dir,
                         // because the root dir may change by alter table)
-                        .orElse(new Path(indexPath(), file.fileName()));
+                        .orElse(new Path(indexPath(), fileName));
             }
 
             @Override
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/BinaryIndexManifestEntryTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/BinaryIndexManifestEntryTest.java
index 02d952ed5e..52be4fd921 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/BinaryIndexManifestEntryTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/BinaryIndexManifestEntryTest.java
@@ -22,20 +22,29 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryRowWriter;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.TableTestBase;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.SegmentsCache;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.Arrays;
 
 import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 /** Tests for {@link BinaryIndexManifestEntry}. */
 class BinaryIndexManifestEntryTest extends TableTestBase {
@@ -127,6 +136,81 @@ class BinaryIndexManifestEntryTest extends TableTestBase {
         assertThatThrownBy(entry::hasGlobalIndexMeta)
                 .isInstanceOf(UnsupportedOperationException.class)
                 .hasMessageContaining(IndexManifestEntry.GLOBAL_INDEX);
+        assertThatThrownBy(entry::fileName)
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining(IndexManifestEntry.FILE_NAME);
+        assertThatThrownBy(entry::externalPath)
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining(IndexManifestEntry.EXTERNAL_PATH);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testFileProjectionsBypassCache(boolean warmCache) throws Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        SegmentsCache<Path> cache =
+                spy(new SegmentsCache<>(4096, MemorySize.ofMebiBytes(8), 
Long.MAX_VALUE));
+        IndexManifestFile manifest =
+                new IndexManifestFile.Factory(
+                                table.fileIO(),
+                                FileFormat.manifestFormat(table.coreOptions()),
+                                "zstd",
+                                table.store().pathFactory(),
+                                cache)
+                        .create();
+        IndexManifestEntry local = entry(FileKind.ADD, partition(1), 3, 
"hash", null);
+        String externalPath = "file:///external/index-file";
+        IndexManifestEntry external =
+                new IndexManifestEntry(
+                        FileKind.ADD,
+                        partition(2),
+                        4,
+                        new IndexFileMeta(
+                                "btree",
+                                "external-index-file",
+                                100,
+                                10,
+                                new GlobalIndexMeta(0, 9, 1, null, new 
byte[1024]),
+                                externalPath));
+        String fileName = manifest.writeWithoutRolling(Arrays.asList(local, 
external));
+        if (warmCache) {
+            assertThat(manifest.read(fileName)).containsExactly(local, 
external);
+            
assertThat(cache.getIfPresents(manifest.indexManifestFilePath(fileName))).isNotNull();
+        }
+        clearInvocations(cache);
+
+        try (CloseableIterator<BinaryIndexManifestEntry> entries =
+                manifest.scan(fileName, 
BinaryIndexManifestEntry.FILE_PATH_PROJECTION)) {
+            BinaryIndexManifestEntry first = entries.next();
+            
assertThat(first.fileName()).isEqualTo(local.indexFile().fileName());
+            assertThat(first.externalPath()).isNull();
+            
assertThat(deserializeBinaryRow(first.partitionBytes())).isEqualTo(local.partition());
+            assertThat(first.bucket()).isEqualTo(local.bucket());
+            assertThatThrownBy(first::hasGlobalIndexMeta)
+                    .isInstanceOf(UnsupportedOperationException.class);
+
+            BinaryIndexManifestEntry second = entries.next();
+            assertThat(second).isSameAs(first);
+            
assertThat(second.fileName()).isEqualTo(external.indexFile().fileName());
+            assertThat(second.externalPath()).isEqualTo(externalPath);
+            assertThat(deserializeBinaryRow(second.partitionBytes()))
+                    .isEqualTo(external.partition());
+            assertThat(second.bucket()).isEqualTo(external.bucket());
+            assertThat(entries.hasNext()).isFalse();
+        }
+        try (CloseableIterator<BinaryIndexManifestEntry> entries =
+                manifest.scan(fileName, 
BinaryIndexManifestEntry.FILE_NAME_PROJECTION)) {
+            
assertThat(entries.next().fileName()).isEqualTo(local.indexFile().fileName());
+            BinaryIndexManifestEntry second = entries.next();
+            
assertThat(second.fileName()).isEqualTo(external.indexFile().fileName());
+            assertThatThrownBy(second::partitionBytes)
+                    .isInstanceOf(UnsupportedOperationException.class);
+            assertThatThrownBy(second::externalPath)
+                    .isInstanceOf(UnsupportedOperationException.class);
+            assertThat(entries.hasNext()).isFalse();
+        }
+        verifyNoInteractions(cache);
     }
 
     private static IndexManifestEntry entry(
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/IndexManifestDeletionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/IndexManifestDeletionTest.java
new file mode 100644
index 0000000000..4da89a1f04
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/IndexManifestDeletionTest.java
@@ -0,0 +1,336 @@
+/*
+ * 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.operation;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.TestAppendFileStore;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryRowWriter;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.manifest.BinaryIndexManifestEntry;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.IndexManifestFile;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.IndexFilePathFactories;
+import org.apache.paimon.utils.TraceableFileIO;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.paimon.data.BinaryString.fromString;
+import static org.apache.paimon.index.HashIndexFile.HASH_INDEX;
+import static 
org.apache.paimon.index.IndexFileMetaSerializerTest.randomDeletionVectorIndexFile;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+/** Tests for planning index deletion with projected, reusable manifest 
entries. */
+class IndexManifestDeletionTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    private TestAppendFileStore store;
+    private String emptyManifestList;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        store = TestAppendFileStore.createAppendStore(tempDir, new 
HashMap<>());
+        emptyManifestList =
+                
store.manifestListFactory().create().write(Collections.emptyList()).getLeft();
+    }
+
+    @ParameterizedTest
+    @CsvSource({"false, false", "false, true", "true, false", "true, true"})
+    void testPlansPathsAndKeepsProtectedIndexes(boolean inDataDirectory, 
boolean externalRoot)
+            throws Exception {
+        store.options()
+                .toConfiguration()
+                .set(CoreOptions.INDEX_FILE_IN_DATA_FILE_DIR, inDataDirectory);
+        if (externalRoot) {
+            store.options()
+                    .toConfiguration()
+                    .set(
+                            CoreOptions.GLOBAL_INDEX_EXTERNAL_PATH,
+                            new Path(new Path(tempDir.toUri()), 
"new-index-root").toString());
+        }
+        IndexManifestFile manifest = store.indexManifestFileFactory().create();
+        IndexFileHandler handler = store.newIndexFileHandler();
+        SnapshotDeletion deletion = store.newSnapshotDeletion();
+        List<IndexManifestEntry> entries =
+                new ArrayList<>(
+                        Arrays.asList(
+                                hashEntry(0),
+                                hashEntry(1),
+                                hashEntry(2),
+                                entry(3, randomDeletionVectorIndexFile()),
+                                entry(
+                                        4,
+                                        new IndexFileMeta(
+                                                "btree",
+                                                "external-index",
+                                                100,
+                                                10,
+                                                new GlobalIndexMeta(0, 9, 1, 
null, new byte[1024]),
+                                                new Path(
+                                                                new 
Path(tempDir.toUri()),
+                                                                
"old-index-root/external-index")
+                                                        .toString()))));
+        String protectedManifest =
+                
manifest.writeWithoutRolling(Collections.singletonList(entries.get(1)));
+        String expiredManifest = manifest.writeWithoutRolling(entries);
+        String duplicateManifest = manifest.writeWithoutRolling(entries);
+        for (IndexManifestEntry entry : entries) {
+            store.fileIO().tryToWriteAtomic(handler.filePath(entry), "index");
+        }
+        Path localPath = handler.filePath(entries.get(0));
+        if (externalRoot && !inDataDirectory) {
+            
assertThat(localPath.getParent()).isEqualTo(store.pathFactory().indexPath());
+        }
+
+        Snapshot protectedSnapshot = snapshot(protectedManifest);
+        Set<String> skippingSet =
+                
deletion.manifestSkippingSet(Collections.singletonList(protectedSnapshot));
+        assertThat(skippingSet).contains(protectedManifest, 
entries.get(1).indexFile().fileName());
+        assertThat(
+                        store.newChangelogDeletion()
+                                
.manifestSkippingSet(Collections.singletonList(protectedSnapshot)))
+                .contains(protectedManifest, 
entries.get(1).indexFile().fileName());
+        List<Runnable> tasks =
+                deletion.planManifestsCleaner(snapshot(expiredManifest), 
skippingSet, false, false);
+        assertThat(tasks).hasSize(entries.size());
+        tasks.addAll(
+                deletion.planManifestsCleaner(
+                        snapshot(duplicateManifest), skippingSet, false, 
false));
+        assertThat(tasks).hasSize(entries.size() + 1);
+        assertThat(
+                        deletion.planManifestsCleaner(
+                                snapshot(expiredManifest), skippingSet, false, 
false))
+                .isEmpty();
+        for (IndexManifestEntry entry : entries) {
+            
assertThat(store.fileIO().exists(handler.filePath(entry))).isTrue();
+        }
+        assertThat(
+                        TraceableFileIO.openInputStreams(
+                                path -> 
path.toString().contains(tempDir.toString())))
+                .isEmpty();
+
+        deletion.executeAll(tasks);
+
+        for (int i = 0; i < entries.size(); i++) {
+            assertThat(store.fileIO().exists(handler.filePath(entries.get(i))))
+                    .as("index %s", i)
+                    .isEqualTo(i == 1);
+        }
+        assertThat(handler.existsManifest(protectedManifest)).isTrue();
+        assertThat(handler.existsManifest(expiredManifest)).isFalse();
+        assertThat(handler.existsManifest(duplicateManifest)).isFalse();
+    }
+
+    @Test
+    void testMissingManifestIsIgnoredOnlyForDeletion() {
+        SnapshotDeletion deletion = store.newSnapshotDeletion();
+        assertThat(
+                        deletion.planManifestsCleaner(
+                                snapshot("missing"), new HashSet<>(), false, 
false))
+                .isEmpty();
+        assertThatThrownBy(
+                        () ->
+                                deletion.manifestSkippingSet(
+                                        
Collections.singletonList(snapshot("missing"))))
+                .hasRootCauseInstanceOf(FileNotFoundException.class);
+    }
+
+    @ParameterizedTest
+    @CsvSource({"false, false", "false, true", "true, false", "true, true"})
+    void testScanFailureDoesNotExecutePartialPlan(boolean failOnClose, boolean 
buildingSkippingSet)
+            throws Exception {
+        IndexManifestFile manifest = 
spy(store.indexManifestFileFactory().create());
+        IndexFileHandler handler = spy(newHandler(manifest));
+        SnapshotDeletion deletion = newDeletion(handler);
+        IndexManifestEntry entry = hashEntry(0);
+        String fileName = 
manifest.writeWithoutRolling(Collections.singletonList(entry));
+        Path indexPath = handler.filePath(entry);
+        Path manifestPath = manifest.indexManifestFilePath(fileName);
+        store.fileIO().tryToWriteAtomic(indexPath, "index");
+        BinaryIndexManifestEntry.Projection projection =
+                buildingSkippingSet
+                        ? BinaryIndexManifestEntry.FILE_NAME_PROJECTION
+                        : BinaryIndexManifestEntry.FILE_PATH_PROJECTION;
+        CloseableIterator<BinaryIndexManifestEntry> rows = 
manifest.scan(fileName, projection);
+        FileNotFoundException failure = new FileNotFoundException("Failure 
during scanning");
+        boolean[] pathPlanned = {false};
+        Set<String> skippingSet = new HashSet<>();
+        doAnswer(
+                        invocation -> {
+                            pathPlanned[0] = true;
+                            return invocation.callRealMethod();
+                        })
+                .when(handler)
+                .filePath(any(BinaryIndexManifestEntry.class));
+        CloseableIterator<BinaryIndexManifestEntry> failing =
+                new CloseableIterator<BinaryIndexManifestEntry>() {
+                    private boolean consumed;
+
+                    @Override
+                    public boolean hasNext() {
+                        if (consumed) {
+                            assertThat(
+                                            buildingSkippingSet
+                                                    ? skippingSet.contains(
+                                                            
entry.indexFile().fileName())
+                                                    : pathPlanned[0])
+                                    .isTrue();
+                            if (!failOnClose) {
+                                throw new UncheckedIOException(failure);
+                            }
+                        }
+                        return rows.hasNext();
+                    }
+
+                    @Override
+                    public BinaryIndexManifestEntry next() {
+                        consumed = true;
+                        return rows.next();
+                    }
+
+                    @Override
+                    public void close() throws Exception {
+                        rows.close();
+                        if (failOnClose) {
+                            throw failure;
+                        }
+                    }
+                };
+        doReturn(failing).when(manifest).scan(fileName, projection);
+
+        assertThatThrownBy(
+                        () -> {
+                            if (buildingSkippingSet) {
+                                deletion.addIndexFilesToSkippingSet(
+                                        snapshot(fileName), skippingSet);
+                            } else {
+                                
deletion.cleanUnusedManifests(snapshot(fileName), skippingSet);
+                            }
+                        })
+                .hasRootCause(failure);
+        assertThat(store.fileIO().exists(indexPath)).isTrue();
+        assertThat(store.fileIO().exists(manifestPath)).isTrue();
+        
assertThat(TraceableFileIO.openInputStreams(manifestPath::equals)).isEmpty();
+
+        doCallRealMethod().when(manifest).scan(fileName, projection);
+        deletion.cleanUnusedManifests(snapshot(fileName), new HashSet<>());
+        assertThat(store.fileIO().exists(indexPath)).isFalse();
+        assertThat(store.fileIO().exists(manifestPath)).isFalse();
+    }
+
+    @Test
+    void testOpeningFailureIsNotIgnored() {
+        IndexManifestFile manifest = mock(IndexManifestFile.class);
+        IOException failure = new IOException("Unable to open manifest");
+        doThrow(new UncheckedIOException(failure))
+                .when(manifest)
+                .scan("unavailable", 
BinaryIndexManifestEntry.FILE_PATH_PROJECTION);
+
+        assertThatThrownBy(
+                        () ->
+                                newDeletion(newHandler(manifest))
+                                        .planManifestsCleaner(
+                                                snapshot("unavailable"),
+                                                new HashSet<>(),
+                                                false,
+                                                false))
+                .hasCause(failure);
+    }
+
+    private IndexFileHandler newHandler(IndexManifestFile manifest) {
+        return new IndexFileHandler(
+                store.fileIO(),
+                store.snapshotManager(),
+                manifest,
+                new IndexFilePathFactories(store.pathFactory()),
+                MemorySize.ofMebiBytes(2),
+                false);
+    }
+
+    private SnapshotDeletion newDeletion(IndexFileHandler handler) {
+        return new SnapshotDeletion(
+                store.fileIO(),
+                store.pathFactory(),
+                store.manifestFileFactory().create(),
+                store.manifestListFactory().create(),
+                handler,
+                store.newStatsFileHandler(),
+                false,
+                false,
+                2,
+                null);
+    }
+
+    private Snapshot snapshot(String indexManifest) {
+        Snapshot snapshot = mock(Snapshot.class);
+        when(snapshot.baseManifestList()).thenReturn(emptyManifestList);
+        when(snapshot.baseManifestListSize()).thenReturn(null);
+        when(snapshot.deltaManifestList()).thenReturn(emptyManifestList);
+        when(snapshot.deltaManifestListSize()).thenReturn(null);
+        when(snapshot.indexManifest()).thenReturn(indexManifest);
+        return snapshot;
+    }
+
+    private IndexManifestEntry hashEntry(int partition) {
+        return entry(
+                partition,
+                new IndexFileMeta(HASH_INDEX, "hash-index-" + partition, 16, 
4, null, null, null));
+    }
+
+    private IndexManifestEntry entry(int partition, IndexFileMeta indexFile) {
+        BinaryRow row = new BinaryRow(2);
+        BinaryRowWriter writer = new BinaryRowWriter(row);
+        writer.writeString(0, fromString("partition-" + partition));
+        writer.writeString(1, fromString("0"));
+        writer.complete();
+        return new IndexManifestEntry(FileKind.ADD, row, partition % 2, 
indexFile);
+    }
+}

Reply via email to