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 3d10846a9c [core] Add file index system table (#9138)
3d10846a9c is described below

commit 3d10846a9c0b1aae48778d9d36d676d15951d2b5
Author: sanshi <[email protected]>
AuthorDate: Mon Aug 10 13:41:31 2026 +0800

    [core] Add file index system table (#9138)
---
 docs/docs/concepts/system-tables.mdx               |  29 ++
 .../apache/paimon/fileindex/FileIndexFormat.java   |  58 +++
 .../fileindex/FileIndexFormatFormatTest.java       |  38 ++
 .../paimon/table/system/FileIndexesTable.java      | 444 +++++++++++++++++++++
 .../paimon/table/system/SystemTableLoader.java     |   2 +
 .../paimon/table/system/FileIndexesTableTest.java  | 273 +++++++++++++
 6 files changed, 844 insertions(+)

diff --git a/docs/docs/concepts/system-tables.mdx 
b/docs/docs/concepts/system-tables.mdx
index d70bc6c39b..67fb73a541 100644
--- a/docs/docs/concepts/system-tables.mdx
+++ b/docs/docs/concepts/system-tables.mdx
@@ -285,6 +285,35 @@ SELECT * FROM my_table$files /*+ 
OPTIONS('scan.snapshot-id'='1') */;
 */
 ```
 
+### File Indexes Table
+
+You can query the file indexes of every data file in a specific snapshot 
through the
+`file_indexes` table. Each row represents one index type for one column in one 
data file.
+Multiple rows can therefore refer to the same index container.
+
+```sql
+SELECT * FROM my_table$file_indexes;
+
+/*
++-----------+--------+--------------------------------+--------------------+--------------+-----------+-------------+--------------+--------------+--------------------------------+--------------------+----------------------------+----------+
+| partition | bucket |                      file_path | file_size_in_bytes | 
record_count | schema_id | column_name |  index_type  | storage_type |          
     index_file_path | index_size_in_bytes | index_container_size_in_bytes | 
is_empty |
++-----------+--------+--------------------------------+--------------------+--------------+-----------+-------------+--------------+--------------+--------------------------------+--------------------+----------------------------+----------+
+|       {1} |      0 | data-8f64af95-29cc-4342-adc... |                593 |   
         2 |         0 |          id |      bitmap |     EMBEDDED |             
            <NULL> |                 12 |                           48 |    
false |
+|       {1} |      0 | data-8f64af95-29cc-4342-adc... |                593 |   
         2 |         0 |          id | bloom-filter |     EMBEDDED |            
             <NULL> |                 16 |                           48 |    
false |
+|       {2} |      0 | data-8b369068-0d37-4011-aa5... |                593 |   
         2 |         0 |          id |      bitmap |         FILE | 
data-8b369068-0d37-4011-aa5... |                 12 |                           
48 |    false |
+|       {2} |      0 | data-8b369068-0d37-4011-aa5... |                593 |   
         2 |         0 |          id | bloom-filter |         FILE | 
data-8b369068-0d37-4011-aa5... |                 16 |                           
48 |    false |
++-----------+--------+--------------------------------+--------------------+--------------+-----------+-------------+--------------+--------------+--------------------------------+--------------------+----------------------------+----------+
+4 rows in set
+*/
+```
+
+The system table reads only file index headers and does not load index 
payloads. It lists indexes
+that physically exist in the selected snapshot; data files without file 
indexes do not produce
+rows.
+
+This table is different from `table_indexes`, which lists independently 
managed index files from
+the snapshot's index manifest, such as deletion vectors and global indexes.
+
 ### File Key Ranges Table
 
 You can query the key ranges and file location of each data file through the 
file key ranges table.
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java 
b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
index 7a132673dd..e10a95c50c 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
@@ -36,9 +36,11 @@ import java.io.DataOutputStream;
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -123,6 +125,43 @@ public final class FileIndexFormat {
         return new Reader(inputStream, fileRowType);
     }
 
+    /** Creates a reader for accessing header metadata without reading index 
payloads. */
+    public static Reader createMetadataReader(SeekableInputStream inputStream) 
{
+        return new Reader(inputStream, RowType.builder().build());
+    }
+
+    /** Metadata of one column index stored in a file index container. */
+    public static class FileIndexMeta {
+
+        private final String columnName;
+        private final String indexType;
+        private final int sizeInBytes;
+        private final boolean empty;
+
+        private FileIndexMeta(String columnName, String indexType, int 
sizeInBytes, boolean empty) {
+            this.columnName = columnName;
+            this.indexType = indexType;
+            this.sizeInBytes = sizeInBytes;
+            this.empty = empty;
+        }
+
+        public String columnName() {
+            return columnName;
+        }
+
+        public String indexType() {
+            return indexType;
+        }
+
+        public int sizeInBytes() {
+            return sizeInBytes;
+        }
+
+        public boolean empty() {
+            return empty;
+        }
+    }
+
     /** Writer for file index file. */
     public static class Writer implements Closeable {
 
@@ -301,6 +340,25 @@ public final class FileIndexFormat {
                     .orElse(Collections.emptySet());
         }
 
+        /** Returns the index metadata parsed from the header without reading 
index payloads. */
+        public List<FileIndexMeta> indexMetas() {
+            List<FileIndexMeta> metas = new ArrayList<>();
+            for (Map.Entry<String, Map<String, Pair<Integer, Integer>>> 
columnEntry :
+                    header.entrySet()) {
+                for (Map.Entry<String, Pair<Integer, Integer>> indexEntry :
+                        columnEntry.getValue().entrySet()) {
+                    Pair<Integer, Integer> startAndLength = 
indexEntry.getValue();
+                    metas.add(
+                            new FileIndexMeta(
+                                    columnEntry.getKey(),
+                                    indexEntry.getKey(),
+                                    startAndLength.getRight(),
+                                    startAndLength.getLeft() == 
EMPTY_INDEX_FLAG));
+                }
+            }
+            return Collections.unmodifiableList(metas);
+        }
+
         private FileIndexReader getFileIndexReader(
                 String columnName, String indexType, Pair<Integer, Integer> 
startAndLength) {
             if (startAndLength.getLeft() == EMPTY_INDEX_FLAG) {
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
index d9828e75a1..0f5f6e299e 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
@@ -31,11 +31,14 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Random;
 
 import static org.apache.paimon.utils.RandomUtil.randomBytes;
 import static org.apache.paimon.utils.RandomUtil.randomString;
+import static org.assertj.core.api.Assertions.tuple;
 
 /** Test for {@link FileIndexFormat}. */
 public class FileIndexFormatFormatTest {
@@ -105,4 +108,39 @@ public class FileIndexFormatFormatTest {
         Assertions.assertThat(new ArrayList<>(fileIndexFormatList).get(0))
                 .isEqualTo(EmptyFileIndexReader.INSTANCE);
     }
+
+    @Test
+    public void testIndexMetas() throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        Map<String, Map<String, byte[]>> indexes = new LinkedHashMap<>();
+        indexes.computeIfAbsent("user_id", key -> new LinkedHashMap<>())
+                .put("bitmap", new byte[] {1, 2, 3});
+        indexes.computeIfAbsent("user_id", key -> new LinkedHashMap<>())
+                .put("bloom-filter", new byte[] {4, 5});
+        indexes.computeIfAbsent("region", key -> new 
LinkedHashMap<>()).put("bitmap", null);
+
+        try (FileIndexFormat.Writer writer = 
FileIndexFormat.createWriter(baos)) {
+            writer.writeColumnIndexes(indexes);
+        }
+
+        List<FileIndexFormat.FileIndexMeta> metas;
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createMetadataReader(
+                        new ByteArraySeekableStream(baos.toByteArray()))) {
+            metas = reader.indexMetas();
+        }
+
+        Assertions.assertThat(metas)
+                .extracting(
+                        FileIndexFormat.FileIndexMeta::columnName,
+                        FileIndexFormat.FileIndexMeta::indexType,
+                        FileIndexFormat.FileIndexMeta::sizeInBytes,
+                        FileIndexFormat.FileIndexMeta::empty)
+                .containsExactlyInAnyOrder(
+                        tuple("user_id", "bitmap", 3, false),
+                        tuple("user_id", "bloom-filter", 2, false),
+                        tuple("region", "bitmap", 0, true));
+        Assertions.assertThatThrownBy(() -> metas.clear())
+                .isInstanceOf(UnsupportedOperationException.class);
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
new file mode 100644
index 0000000000..f79419c449
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
@@ -0,0 +1,444 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.system;
+
+import org.apache.paimon.casting.CastExecutor;
+import org.apache.paimon.casting.CastExecutors;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.fileindex.FileIndexFormat;
+import org.apache.paimon.fileindex.FileIndexFormat.FileIndexMeta;
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.predicate.CompoundPredicate;
+import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.LeafPredicateExtractor;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.ReadonlyTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.InnerTableRead;
+import org.apache.paimon.table.source.InnerTableScan;
+import org.apache.paimon.table.source.ReadOnceTableScan;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableRead;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.BigIntType;
+import org.apache.paimon.types.BooleanType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.DataFilePathFactories;
+import org.apache.paimon.utils.IteratorRecordReader;
+import org.apache.paimon.utils.PartitionPredicateHelper;
+import org.apache.paimon.utils.ProjectedRow;
+import org.apache.paimon.utils.SerializationUtils;
+
+import org.apache.paimon.shade.guava30.com.google.common.collect.Iterators;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.catalog.Identifier.SYSTEM_TABLE_SPLITTER;
+
+/** A {@link Table} for showing file indexes of data files in a snapshot. */
+public class FileIndexesTable implements ReadonlyTable {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String FILE_INDEXES = "file_indexes";
+
+    private static final String EMBEDDED = "EMBEDDED";
+    private static final String FILE = "FILE";
+
+    public static final RowType TABLE_TYPE =
+            new RowType(
+                    Arrays.asList(
+                            new DataField(0, "partition", 
SerializationUtils.newStringType(true)),
+                            new DataField(1, "bucket", new IntType(false)),
+                            new DataField(2, "file_path", 
SerializationUtils.newStringType(false)),
+                            new DataField(3, "file_size_in_bytes", new 
BigIntType(false)),
+                            new DataField(4, "record_count", new 
BigIntType(false)),
+                            new DataField(5, "schema_id", new 
BigIntType(false)),
+                            new DataField(
+                                    6, "column_name", 
SerializationUtils.newStringType(false)),
+                            new DataField(7, "index_type", 
SerializationUtils.newStringType(false)),
+                            new DataField(
+                                    8, "storage_type", 
SerializationUtils.newStringType(false)),
+                            new DataField(
+                                    9, "index_file_path", 
SerializationUtils.newStringType(true)),
+                            new DataField(10, "index_size_in_bytes", new 
BigIntType(false)),
+                            new DataField(
+                                    11, "index_container_size_in_bytes", new 
BigIntType(false)),
+                            new DataField(12, "is_empty", new 
BooleanType(false))));
+
+    private final FileStoreTable storeTable;
+
+    public FileIndexesTable(FileStoreTable storeTable) {
+        this.storeTable = storeTable;
+    }
+
+    @Override
+    public String name() {
+        return storeTable.name() + SYSTEM_TABLE_SPLITTER + FILE_INDEXES;
+    }
+
+    @Override
+    public RowType rowType() {
+        return TABLE_TYPE;
+    }
+
+    @Override
+    public List<String> primaryKeys() {
+        return Arrays.asList("file_path", "column_name", "index_type");
+    }
+
+    @Override
+    public FileIO fileIO() {
+        return storeTable.fileIO();
+    }
+
+    @Override
+    public InnerTableScan newScan() {
+        return new FileIndexesScan(storeTable);
+    }
+
+    @Override
+    public InnerTableRead newRead() {
+        return new FileIndexesRead(storeTable);
+    }
+
+    @Override
+    public Table copy(Map<String, String> dynamicOptions) {
+        return new FileIndexesTable(storeTable.copy(dynamicOptions));
+    }
+
+    private static class FileIndexesScan extends ReadOnceTableScan {
+
+        @Nullable private LeafPredicate partitionPredicate;
+        @Nullable private LeafPredicate bucketPredicate;
+
+        private final FileStoreTable storeTable;
+
+        private FileIndexesScan(FileStoreTable storeTable) {
+            this.storeTable = storeTable;
+        }
+
+        @Override
+        public InnerTableScan withFilter(Predicate pushdown) {
+            if (pushdown == null) {
+                return this;
+            }
+
+            Map<String, LeafPredicate> leafPredicates =
+                    pushdown.visit(LeafPredicateExtractor.INSTANCE);
+            partitionPredicate = leafPredicates.get("partition");
+            bucketPredicate = leafPredicates.get("bucket");
+            return this;
+        }
+
+        @Override
+        public Plan innerPlan() {
+            SnapshotReader snapshotReader = storeTable.newSnapshotReader();
+            boolean hasResults =
+                    PartitionPredicateHelper.applyPartitionFilter(
+                            snapshotReader,
+                            partitionPredicate,
+                            storeTable.partitionKeys(),
+                            storeTable.schema().logicalPartitionType());
+            if (!hasResults) {
+                return Collections::emptyList;
+            }
+
+            return () ->
+                    snapshotReader.partitions().stream()
+                            .map(
+                                    partition ->
+                                            // Keep file inventory planning 
aligned with FilesTable.
+                                            new FilesTable.FilesSplit(
+                                                    partition, 
bucketPredicate, null))
+                            .collect(Collectors.toList());
+        }
+    }
+
+    private static class FileIndexesRead implements InnerTableRead {
+
+        private static final Set<String> SCAN_PUSHDOWN_FIELDS =
+                Collections.unmodifiableSet(new 
HashSet<>(Arrays.asList("partition", "bucket")));
+        private static final Set<String> FILE_PUSHDOWN_FIELDS = 
Collections.singleton("file_path");
+
+        private final FileStoreTable storeTable;
+        private final DataFilePathFactories pathFactories;
+
+        @Nullable private Predicate filePredicate;
+        @Nullable private Predicate predicate;
+        @Nullable private RowType readType;
+
+        private FileIndexesRead(FileStoreTable storeTable) {
+            this.storeTable = storeTable;
+            this.pathFactories = new 
DataFilePathFactories(storeTable.store().pathFactory());
+        }
+
+        @Override
+        public InnerTableRead withFilter(Predicate predicate) {
+            List<Predicate> remaining =
+                    PredicateBuilder.splitAnd(predicate).stream()
+                            .filter(
+                                    p ->
+                                            !(p instanceof LeafPredicate)
+                                                    || !onlyContainsFields(p, 
SCAN_PUSHDOWN_FIELDS))
+                            .collect(Collectors.toList());
+            List<Predicate> filePredicates =
+                    remaining.stream()
+                            .filter(p -> onlyContainsFields(p, 
FILE_PUSHDOWN_FIELDS))
+                            .collect(Collectors.toList());
+            remaining.removeAll(filePredicates);
+
+            this.filePredicate =
+                    filePredicates.isEmpty() ? null : 
PredicateBuilder.and(filePredicates);
+            this.predicate = remaining.isEmpty() ? null : 
PredicateBuilder.and(remaining);
+            return this;
+        }
+
+        private static boolean onlyContainsFields(Predicate predicate, 
Set<String> fields) {
+            if (predicate instanceof CompoundPredicate) {
+                return ((CompoundPredicate) predicate)
+                        .children().stream().allMatch(p -> 
onlyContainsFields(p, fields));
+            }
+            return fields.containsAll(((LeafPredicate) 
predicate).fieldNames());
+        }
+
+        @Override
+        public InnerTableRead withReadType(RowType readType) {
+            this.readType = readType;
+            return this;
+        }
+
+        @Override
+        public TableRead withIOManager(IOManager ioManager) {
+            return this;
+        }
+
+        @Override
+        public RecordReader<InternalRow> createReader(Split split) {
+            if (!(split instanceof FilesTable.FilesSplit)) {
+                throw new IllegalArgumentException("Unsupported split: " + 
split.getClass());
+            }
+
+            List<Split> dataSplits = ((FilesTable.FilesSplit) 
split).splits(storeTable);
+            if (dataSplits.isEmpty()) {
+                return new IteratorRecordReader<>(Collections.emptyIterator());
+            }
+
+            @SuppressWarnings("unchecked")
+            CastExecutor<InternalRow, BinaryString> partitionCastExecutor =
+                    (CastExecutor<InternalRow, BinaryString>)
+                            CastExecutors.resolveToString(
+                                    
storeTable.schema().logicalPartitionType());
+
+            Iterator<InternalRow> iterator = splitRows(dataSplits, 
partitionCastExecutor);
+            if (predicate != null) {
+                iterator = Iterators.filter(iterator, predicate::test);
+            }
+            if (readType != null) {
+                iterator =
+                        Iterators.transform(
+                                iterator,
+                                row ->
+                                        ProjectedRow.from(readType, 
FileIndexesTable.TABLE_TYPE)
+                                                .replaceRow(row));
+            }
+            return new IteratorRecordReader<>(iterator);
+        }
+
+        private Iterator<InternalRow> splitRows(
+                List<Split> dataSplits,
+                CastExecutor<InternalRow, BinaryString> partitionCastExecutor) 
{
+            Iterator<Iterator<InternalRow>> splitRows =
+                    Iterators.transform(
+                            dataSplits.iterator(),
+                            split -> fileRows((DataSplit) split, 
partitionCastExecutor));
+            return Iterators.concat(splitRows);
+        }
+
+        private Iterator<InternalRow> fileRows(
+                DataSplit dataSplit,
+                CastExecutor<InternalRow, BinaryString> partitionCastExecutor) 
{
+            DataFilePathFactory dataFilePathFactory =
+                    pathFactories.get(dataSplit.partition(), 
dataSplit.bucket());
+            Iterator<Iterator<InternalRow>> fileRows =
+                    Iterators.transform(
+                            dataSplit.dataFiles().iterator(),
+                            file -> {
+                                BinaryString filePath = filePath(dataSplit, 
file);
+                                if (filePredicate != null && 
!testFilePath(filePath)) {
+                                    return Collections.emptyIterator();
+                                }
+                                return indexRows(
+                                                dataSplit,
+                                                file,
+                                                filePath,
+                                                dataFilePathFactory,
+                                                partitionCastExecutor)
+                                        .iterator();
+                            });
+            return Iterators.concat(fileRows);
+        }
+
+        private boolean testFilePath(BinaryString filePath) {
+            GenericRow row = new GenericRow(TABLE_TYPE.getFieldCount());
+            row.setField(2, filePath);
+            return filePredicate.test(row);
+        }
+
+        private List<InternalRow> indexRows(
+                DataSplit dataSplit,
+                DataFileMeta file,
+                BinaryString filePath,
+                DataFilePathFactory dataFilePathFactory,
+                CastExecutor<InternalRow, BinaryString> partitionCastExecutor) 
{
+            byte[] embeddedIndex = file.embeddedIndex();
+            if (embeddedIndex != null) {
+                try (FileIndexFormat.Reader reader =
+                        FileIndexFormat.createMetadataReader(
+                                new ByteArraySeekableStream(embeddedIndex))) {
+                    return toRows(
+                            dataSplit,
+                            file,
+                            filePath,
+                            partitionCastExecutor,
+                            reader.indexMetas(),
+                            EMBEDDED,
+                            null,
+                            embeddedIndex.length);
+                } catch (IOException e) {
+                    throw new UncheckedIOException(
+                            "Failed to read file index metadata from " + 
filePath + ".", e);
+                } catch (RuntimeException e) {
+                    throw fileIndexReadException(filePath.toString(), e);
+                }
+            }
+
+            List<String> indexFiles =
+                    file.extraFiles().stream()
+                            .filter(name -> 
name.endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX))
+                            .collect(Collectors.toList());
+            if (indexFiles.isEmpty()) {
+                return Collections.emptyList();
+            }
+            if (indexFiles.size() > 1) {
+                throw new IllegalStateException(
+                        "Found more than one file index for data file "
+                                + file.fileName()
+                                + ": "
+                                + String.join(", ", indexFiles));
+            }
+
+            Path indexPath = 
dataFilePathFactory.toAlignedPath(indexFiles.get(0), file);
+            try {
+                long containerSize = 
storeTable.fileIO().getFileStatus(indexPath).getLen();
+                try (FileIndexFormat.Reader reader =
+                        FileIndexFormat.createMetadataReader(
+                                
storeTable.fileIO().newInputStream(indexPath))) {
+                    return toRows(
+                            dataSplit,
+                            file,
+                            filePath,
+                            partitionCastExecutor,
+                            reader.indexMetas(),
+                            FILE,
+                            BinaryString.fromString(indexPath.toString()),
+                            containerSize);
+                }
+            } catch (IOException e) {
+                throw new UncheckedIOException(
+                        "Failed to read file index metadata from " + indexPath 
+ ".", e);
+            } catch (RuntimeException e) {
+                throw fileIndexReadException(indexPath.toString(), e);
+            }
+        }
+
+        private static RuntimeException fileIndexReadException(
+                String indexLocation, RuntimeException exception) {
+            String message = "Failed to read file index metadata from " + 
indexLocation + ".";
+            if (exception.getCause() instanceof IOException) {
+                return new UncheckedIOException(message, (IOException) 
exception.getCause());
+            }
+            return new RuntimeException(message, exception);
+        }
+
+        private static List<InternalRow> toRows(
+                DataSplit dataSplit,
+                DataFileMeta file,
+                BinaryString filePath,
+                CastExecutor<InternalRow, BinaryString> partitionCastExecutor,
+                List<FileIndexMeta> indexMetas,
+                String storageType,
+                @Nullable BinaryString indexFilePath,
+                long containerSize) {
+            BinaryString partition =
+                    dataSplit.partition() == null
+                            ? null
+                            : 
partitionCastExecutor.cast(dataSplit.partition());
+            List<InternalRow> rows = new ArrayList<>(indexMetas.size());
+            for (FileIndexMeta indexMeta : indexMetas) {
+                rows.add(
+                        GenericRow.of(
+                                partition,
+                                dataSplit.bucket(),
+                                filePath,
+                                file.fileSize(),
+                                file.rowCount(),
+                                file.schemaId(),
+                                
BinaryString.fromString(indexMeta.columnName()),
+                                BinaryString.fromString(indexMeta.indexType()),
+                                BinaryString.fromString(storageType),
+                                indexFilePath,
+                                (long) indexMeta.sizeInBytes(),
+                                containerSize,
+                                indexMeta.empty()));
+            }
+            return rows;
+        }
+
+        private static BinaryString filePath(DataSplit dataSplit, DataFileMeta 
file) {
+            return BinaryString.fromString(
+                    file.externalPath().orElse(dataSplit.bucketPath() + "/" + 
file.fileName()));
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java
index 1f0727c4f5..3bc083f3e2 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java
@@ -49,6 +49,7 @@ import static 
org.apache.paimon.table.system.BranchesTable.BRANCHES;
 import static org.apache.paimon.table.system.BucketsTable.BUCKETS;
 import static 
org.apache.paimon.table.system.CatalogOptionsTable.CATALOG_OPTIONS;
 import static org.apache.paimon.table.system.ConsumersTable.CONSUMERS;
+import static org.apache.paimon.table.system.FileIndexesTable.FILE_INDEXES;
 import static 
org.apache.paimon.table.system.FileKeyRangesTable.FILE_KEY_RANGES;
 import static org.apache.paimon.table.system.FilesTable.FILES;
 import static org.apache.paimon.table.system.ManifestsTable.MANIFESTS;
@@ -75,6 +76,7 @@ public class SystemTableLoader {
                     .put(BUCKETS, BucketsTable::new)
                     .put(AUDIT_LOG, AuditLogTable::new)
                     .put(FILES, FilesTable::new)
+                    .put(FILE_INDEXES, FileIndexesTable::new)
                     .put(FILE_KEY_RANGES, FileKeyRangesTable::new)
                     .put(TAGS, TagsTable::new)
                     .put(BRANCHES, BranchesTable::new)
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
new file mode 100644
index 0000000000..a3a90d1b45
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
@@ -0,0 +1,273 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.system;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.TableTestBase;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.catalog.Identifier.SYSTEM_TABLE_SPLITTER;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link FileIndexesTable}. */
+public class FileIndexesTableTest extends TableTestBase {
+
+    @Test
+    public void testEmbeddedFileIndexes() throws Exception {
+        FileIndexesTable fileIndexesTable = createTable("EmbeddedIndexes", "1 
MB");
+
+        List<InternalRow> rows = read(fileIndexesTable);
+        assertIndexRows(rows, "EMBEDDED");
+        assertThat(rows).allMatch(row -> row.isNullAt(9));
+        assertThat(rows)
+                .allMatch(
+                        row ->
+                                row.getLong(11) > row.getLong(10)
+                                        && row.getLong(11) > 0
+                                        && !row.getBoolean(12));
+    }
+
+    @Test
+    public void testExternalFileIndexes() throws Exception {
+        FileIndexesTable fileIndexesTable = createTable("ExternalIndexes", "1 
B");
+
+        List<InternalRow> rows = read(fileIndexesTable);
+        assertIndexRows(rows, "FILE");
+        assertThat(rows)
+                .allMatch(
+                        row ->
+                                row.getString(9).toString().endsWith(".index")
+                                        && row.getLong(11) > row.getLong(10)
+                                        && row.getLong(11) > 0
+                                        && !row.getBoolean(12));
+        assertThat(
+                        rows.stream()
+                                .map(row -> row.getString(9).toString())
+                                .distinct()
+                                .collect(Collectors.toList()))
+                .hasSize(1);
+    }
+
+    @Test
+    public void testLazyReadExternalFileIndexes() throws Exception {
+        String tableName = "LazyExternalIndexes";
+        FileIndexesTable fileIndexesTable = createTable(tableName, "1 B", 
true);
+        FileStoreTable dataTable = (FileStoreTable) 
catalog.getTable(identifier(tableName));
+
+        ReadBuilder readBuilder = fileIndexesTable.newReadBuilder();
+        List<Split> systemSplits = readBuilder.newScan().plan().splits();
+        assertThat(systemSplits).hasSize(1);
+
+        List<DataSplit> dataSplits =
+                ((FilesTable.FilesSplit) systemSplits.get(0))
+                        .splits(dataTable).stream()
+                                .map(DataSplit.class::cast)
+                                .collect(Collectors.toList());
+        List<DataFileMeta> dataFiles =
+                dataSplits.stream()
+                        .flatMap(split -> split.dataFiles().stream())
+                        .collect(Collectors.toList());
+        assertThat(dataFiles).hasSize(2);
+
+        DataSplit firstSplit = dataSplits.get(0);
+        DataFileMeta secondFile = dataFiles.get(1);
+        String secondIndexFile =
+                secondFile.extraFiles().stream()
+                        .filter(name -> 
name.endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX))
+                        .findFirst()
+                        .orElseThrow(AssertionError::new);
+        Path secondIndexPath =
+                dataTable
+                        .store()
+                        .pathFactory()
+                        .createDataFilePathFactory(firstSplit.partition(), 
firstSplit.bucket())
+                        .toAlignedPath(secondIndexFile, secondFile);
+        try (PositionOutputStream output =
+                dataTable.fileIO().newOutputStream(secondIndexPath, true)) {
+            output.write(0);
+        }
+
+        try (RecordReader<InternalRow> reader =
+                readBuilder.newRead().createReader(systemSplits.get(0))) {
+            RecordReader.RecordIterator<InternalRow> batch = 
reader.readBatch();
+            InternalRow first = batch.next();
+            InternalRow second = batch.next();
+            assertThat(first).isNotNull();
+            assertThat(second).isNotNull();
+            assertThat(first.getString(2)).isEqualTo(second.getString(2));
+            assertThatThrownBy(batch::next)
+                    .isInstanceOf(UncheckedIOException.class)
+                    .hasMessageContaining(secondIndexPath.toString());
+            batch.releaseBatch();
+        }
+    }
+
+    @Test
+    public void testFilters() throws Exception {
+        FileIndexesTable fileIndexesTable = createTable("FilteredIndexes", "1 
MB");
+        PredicateBuilder builder = new 
PredicateBuilder(FileIndexesTable.TABLE_TYPE);
+
+        List<InternalRow> bitmapRows =
+                readWithFilter(
+                        fileIndexesTable, builder.equal(7, 
BinaryString.fromString("bitmap")));
+        assertThat(bitmapRows).hasSize(1);
+        
assertThat(bitmapRows.get(0).getString(7).toString()).isEqualTo("bitmap");
+
+        List<InternalRow> partitionRows =
+                readWithFilter(fileIndexesTable, builder.equal(0, 
BinaryString.fromString("{1}")));
+        assertThat(partitionRows).hasSize(2);
+
+        String filePath = partitionRows.get(0).getString(2).toString();
+        List<InternalRow> fileRows =
+                readWithFilter(
+                        fileIndexesTable, builder.equal(2, 
BinaryString.fromString(filePath)));
+        assertThat(fileRows).hasSize(2);
+
+        assertThat(
+                        readWithFilter(
+                                fileIndexesTable,
+                                builder.equal(2, 
BinaryString.fromString(filePath + ".missing"))))
+                .isEmpty();
+
+        Predicate mixedOr =
+                PredicateBuilder.or(
+                        builder.equal(0, BinaryString.fromString("{2}")),
+                        builder.equal(7, BinaryString.fromString("bitmap")));
+        List<InternalRow> mixedOrRows = readWithFilter(fileIndexesTable, 
mixedOr);
+        assertThat(mixedOrRows).hasSize(1);
+        
assertThat(mixedOrRows.get(0).getString(7).toString()).isEqualTo("bitmap");
+    }
+
+    @Test
+    public void testTableWithoutFileIndexes() throws Exception {
+        Identifier identifier = identifier("NoIndexes");
+        catalog.createTable(
+                identifier,
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .option(CoreOptions.BUCKET_KEY.key(), "id")
+                        .build(),
+                false);
+        write((FileStoreTable) catalog.getTable(identifier), GenericRow.of(1));
+
+        FileIndexesTable fileIndexesTable =
+                (FileIndexesTable)
+                        catalog.getTable(
+                                identifier(
+                                        "NoIndexes"
+                                                + SYSTEM_TABLE_SPLITTER
+                                                + 
FileIndexesTable.FILE_INDEXES));
+        assertThat(read(fileIndexesTable)).isEmpty();
+    }
+
+    private FileIndexesTable createTable(String tableName, String 
inManifestThreshold)
+            throws Exception {
+        return createTable(tableName, inManifestThreshold, false);
+    }
+
+    private FileIndexesTable createTable(
+            String tableName, String inManifestThreshold, boolean 
writeSeparateFiles)
+            throws Exception {
+        Identifier identifier = identifier(tableName);
+        catalog.createTable(
+                identifier,
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("pt", DataTypes.INT())
+                        .partitionKeys("pt")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .option(CoreOptions.BUCKET_KEY.key(), "id")
+                        .option("file-index.bitmap.columns", "id")
+                        .option("file-index.bloom-filter.columns", "id")
+                        .option(
+                                
CoreOptions.FILE_INDEX_IN_MANIFEST_THRESHOLD.key(),
+                                inManifestThreshold)
+                        .build(),
+                false);
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+        if (writeSeparateFiles) {
+            write(table, GenericRow.of(1, 1));
+            write(table, GenericRow.of(2, 1));
+        } else {
+            write(table, GenericRow.of(1, 1), GenericRow.of(2, 1));
+        }
+
+        return (FileIndexesTable)
+                catalog.getTable(
+                        identifier(
+                                tableName + SYSTEM_TABLE_SPLITTER + 
FileIndexesTable.FILE_INDEXES));
+    }
+
+    private static void assertIndexRows(List<InternalRow> rows, String 
storageType) {
+        assertThat(rows).hasSize(2);
+        assertThat(rows).extracting(row -> 
row.getString(6).toString()).containsOnly("id");
+        assertThat(rows)
+                .extracting(row -> row.getString(7).toString())
+                .containsExactlyInAnyOrder("bitmap", "bloom-filter");
+        assertThat(rows).extracting(row -> 
row.getString(8).toString()).containsOnly(storageType);
+        assertThat(rows).allMatch(row -> 
row.getString(0).toString().equals("{1}"));
+        assertThat(rows).allMatch(row -> row.getInt(1) == 0);
+        assertThat(rows).allMatch(row -> row.getLong(3) > 0);
+        assertThat(rows).allMatch(row -> row.getLong(4) == 2);
+        assertThat(rows).allMatch(row -> row.getLong(5) == 0);
+        assertThat(rows).allMatch(row -> row.getLong(10) > 0);
+        assertThat(
+                        rows.stream()
+                                .map(row -> row.getString(2).toString())
+                                .distinct()
+                                .collect(Collectors.toList()))
+                .hasSize(1);
+    }
+
+    private static List<InternalRow> readWithFilter(FileIndexesTable table, 
Predicate predicate)
+            throws Exception {
+        ReadBuilder readBuilder = table.newReadBuilder().withFilter(predicate);
+        List<InternalRow> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(rows::add);
+        }
+        return rows;
+    }
+}

Reply via email to