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 052adc366f [core] Harden and slim FM index storage (#9450)
052adc366f is described below

commit 052adc366f63adc613dce01e2f1e7fec82f3c449
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Aug 28 23:35:18 2026 +0800

    [core] Harden and slim FM index storage (#9450)
---
 docs/docs/multimodal-table/global-index/fm.mdx     |   9 +-
 docs/generated/core_configuration.html             |   6 +
 .../paimon/globalindex/fmindex/FMBytePattern.java  |  61 -----
 .../globalindex/fmindex/FMGlobalIndexOptions.java  |   2 +-
 .../globalindex/fmindex/FMGlobalIndexReader.java   | 293 +++------------------
 .../globalindex/fmindex/FMGlobalIndexWriter.java   |  89 +------
 .../paimon/globalindex/fmindex/FMIndexFile.java    | 182 ++++---------
 .../globalindex/fmindex/FMIndexReadContext.java    |  13 +
 .../globalindex/fmindex/FMGlobalIndexTest.java     | 279 ++++++++------------
 .../src/test/resources/fmindex-v1-golden.base64    |   2 +-
 .../spark/sql/PrimaryKeySortedIndexTest.scala      |   3 +-
 11 files changed, 231 insertions(+), 708 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/fm.mdx 
b/docs/docs/multimodal-table/global-index/fm.mdx
index 6ab2bf4a99..87666483d7 100644
--- a/docs/docs/multimodal-table/global-index/fm.mdx
+++ b/docs/docs/multimodal-table/global-index/fm.mdx
@@ -30,9 +30,10 @@ values do not match; empty needles follow the normal Paimon 
predicate semantics.
 
 The writer divides source rows into independent partitions and appends them to 
one checksummed
 container file. Each partition stores a compressed wavelet matrix, sampled 
suffix-array values,
-row boundaries, null rows, and exact verification pages. Reads demand-load 
bounded blocks instead
-of downloading the complete container. If locating matches would cost more 
than exact
-verification, the reader scans the relevant verification pages and still 
returns an exact result.
+row boundaries, and null rows. It does not duplicate the source values. Reads 
demand-load bounded
+blocks instead of downloading the complete container. If locating matches 
would cost too much, the
+index declines the predicate so the normal data path can scan the source 
values and preserve exact
+results.
 
 ## Create a Global FM Index
 
@@ -94,7 +95,7 @@ JSON object.
 | `fm-index.compression-level` | `1` | Compression level for codecs which 
support levels. |
 | `fm-index.read-cache-size` | `64 mb` | Maximum decoded rank and sample block 
cache per indexer. |
 | `fm-index.demand-page-size` | `512 kb` | Target contiguous range size when 
demand-loading blocks. |
-| `fm-index.locate-cost-ratio` | `0.001` | Maximum estimated suffix-array 
locate work relative to exact stored-value scan bytes before verification 
fallback. |
+| `fm-index.locate-cost-ratio` | `0.001` | Maximum estimated suffix-array 
locate work relative to source text bytes before declining index evaluation. |
 
 `fm-index.partition-size` and `fm-index.partition-row-count` bound build 
memory and the unit of
 independent reads. Smaller partitions reduce peak construction memory but 
increase the number of
diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 4dca9eb1d6..b8a48d5e8d 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1296,6 +1296,12 @@ For an internal format table in a REST catalog, it also 
makes the catalog own th
             <td>Boolean</td>
             <td>Enables clustering by non-primary key fields. When set to 
true, the physical sort order of data files is determined by the configured 
'clustering.columns' instead of the primary key, optimizing query performance 
for non-PK columns.</td>
         </tr>
+        <tr>
+            <td><h5>pk-fm.index.columns</h5></td>
+            <td style="word-wrap: break-word;">(none)</td>
+            <td>String</td>
+            <td>Comma-separated character columns indexed by primary-key FM 
indexes.</td>
+        </tr>
         <tr>
             <td><h5>pk-full-text.index.columns</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMBytePattern.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMBytePattern.java
deleted file mode 100644
index cbb49f1c7d..0000000000
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMBytePattern.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * 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.globalindex.fmindex;
-
-/** Immutable linear-time byte pattern used by the exact dense-occurrence 
fallback. */
-final class FMBytePattern {
-
-    private final byte[] needle;
-    private final int[] failure;
-
-    FMBytePattern(byte[] needle) {
-        this.needle = needle;
-        this.failure = new int[needle.length];
-        int matched = 0;
-        for (int i = 1; i < needle.length; i++) {
-            while (matched > 0 && needle[i] != needle[matched]) {
-                matched = failure[matched - 1];
-            }
-            if (needle[i] == needle[matched]) {
-                matched++;
-            }
-            failure[i] = matched;
-        }
-    }
-
-    boolean contains(byte[] value, int offset, int length) {
-        if (needle.length == 0) {
-            return true;
-        }
-        int matched = 0;
-        int end = offset + length;
-        for (int i = offset; i < end; i++) {
-            while (matched > 0 && value[i] != needle[matched]) {
-                matched = failure[matched - 1];
-            }
-            if (value[i] == needle[matched]) {
-                matched++;
-                if (matched == needle.length) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-}
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexOptions.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexOptions.java
index 864d54dfe9..2cab94fce5 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexOptions.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexOptions.java
@@ -76,7 +76,7 @@ public final class FMGlobalIndexOptions {
                     .doubleType()
                     .defaultValue(0.001d)
                     .withDescription(
-                            "Maximum estimated SA-locate work divided by exact 
stored-value scan bytes before falling back to verification.");
+                            "Maximum estimated SA-locate work divided by 
source text bytes before declining index evaluation.");
 
     private FMGlobalIndexOptions() {}
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java
index 8189e60cee..2e2da0f3ec 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java
@@ -19,10 +19,7 @@
 package org.apache.paimon.globalindex.fmindex;
 
 import org.apache.paimon.data.BinaryString;
-import org.apache.paimon.fs.FileRange;
 import org.apache.paimon.fs.SeekableInputStream;
-import org.apache.paimon.fs.VectoredReadUtils;
-import org.apache.paimon.fs.VectoredReadable;
 import org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexResult;
@@ -47,11 +44,6 @@ import java.util.function.ToIntFunction;
 /** Exact FM-index reader using blocked wavelet ranks and value-sampled suffix 
locations. */
 final class FMGlobalIndexReader implements ContainsRefiningGlobalIndexReader {
 
-    private static final int MAX_VERIFICATION_RANGE_SIZE = 4 * 1024 * 1024;
-    private static final int MAX_VERIFICATION_UNCOMPRESSED_RANGE_SIZE = 4 * 
1024 * 1024;
-    private static final int MAX_VERIFICATION_RANGE_BATCH_SIZE = 8;
-    private static final int ESTIMATED_READ_REQUEST_BYTES = 64 * 1024;
-
     @Nullable private final GlobalIndexFileReader fileReader;
     @Nullable private final GlobalIndexIOMeta file;
     private final ExecutorService executor;
@@ -181,8 +173,18 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
                                     current.footer.firstRowId + 
current.footer.rowCount)) {
                 return exactEmptyResult();
             }
-            // Enumerate selective intervals and use the stored-value path 
when occurrence-level
-            // SA location would cost more than Milvus' count-first guard 
permits.
+            boolean hasNonEmptyNeedle = false;
+            for (byte[] needle : needles) {
+                if (needle.length > 0) {
+                    hasNonEmptyNeedle = true;
+                    break;
+                }
+            }
+            if (hasNonEmptyNeedle && !readContext.supportsLocate()) {
+                return Optional.empty();
+            }
+            // Like Milvus, decline index evaluation when occurrence-level SA 
location is too
+            // expensive. The caller can then scan the source values without 
duplicating them here.
             List<SearchInterval> intervals = new ArrayList<>(needles.size());
             for (byte[] needle : needles) {
                 if (needle.length > 0) {
@@ -196,13 +198,9 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
             intervals.sort((left, right) -> Integer.compare(left.size(), 
right.size()));
 
             RoaringNavigableMap64 result = null;
-            List<byte[]> denseNeedles = new ArrayList<>();
             for (SearchInterval interval : intervals) {
-                RoaringNavigableMap64 effectiveCandidates =
-                        result != null ? result : candidates == null ? null : 
candidates.results();
-                if (!shouldLocate(current, interval, effectiveCandidates)) {
-                    denseNeedles.add(interval.needle);
-                    continue;
+                if (!shouldLocate(current, interval)) {
+                    return Optional.empty();
                 }
                 RoaringNavigableMap64 matches = locateRows(input, current, 
interval);
                 if (result == null) {
@@ -217,11 +215,7 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
                     return exactEmptyResult();
                 }
             }
-            if (!denseNeedles.isEmpty()) {
-                RoaringNavigableMap64 verificationCandidates =
-                        result != null ? result : candidates == null ? null : 
candidates.results();
-                result = verifyRows(input, current, denseNeedles, 
verificationCandidates);
-            } else if (result == null) {
+            if (result == null) {
                 result = nullRows(input, current, false);
                 if (candidates != null) {
                     result.and(candidates.results());
@@ -259,218 +253,26 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
         for (int i = needle.length - 1; i >= 0 && lower < upper; i--) {
             int symbol = directory.byteToSymbol[needle[i] & 0xFF];
             if (symbol < 0) {
-                return new SearchInterval(0, 0, needle);
+                return new SearchInterval(0, 0);
             }
             int cumulative = directory.cumulativeCounts[symbol];
             RankPair ranks = rankPair(input, directory, symbol, lower, upper);
             lower = cumulative + ranks.lower;
             upper = cumulative + ranks.upper;
         }
-        return new SearchInterval(lower, upper, needle);
+        return new SearchInterval(lower, upper);
     }
 
-    private boolean shouldLocate(
-            Metadata metadata,
-            SearchInterval interval,
-            @Nullable RoaringNavigableMap64 candidates) {
+    private boolean shouldLocate(Metadata metadata, SearchInterval interval) {
+        if (!readContext.supportsLocate()) {
+            return false;
+        }
         FMIndexFile.Directory directory = metadata.directory;
         double locateCost = (double) interval.size() * directory.sampleRate;
-        if (candidates != null) {
-            long verificationBytes = 0;
-            for (FMIndexFile.VerificationPageMeta page : 
directory.verificationPages) {
-                if (pageSelected(metadata.footer.firstRowId, page, 
candidates)) {
-                    verificationBytes += page.block.uncompressedLength;
-                }
-            }
-            if (locateCost >= locateCostRatio * verificationBytes) {
-                return false;
-            }
-        }
         long textBytes = (long) directory.textLength - directory.rowCount - 1;
         return locateCost < locateCostRatio * textBytes;
     }
 
-    private RoaringNavigableMap64 verifyRows(
-            SeekableInputStream input,
-            Metadata metadata,
-            List<byte[]> needles,
-            @Nullable RoaringNavigableMap64 candidates)
-            throws IOException {
-        List<FMBytePattern> patterns = new ArrayList<>(needles.size());
-        for (byte[] needle : needles) {
-            patterns.add(new FMBytePattern(needle));
-        }
-        RoaringNavigableMap64 result = new RoaringNavigableMap64();
-        List<VerificationRange> ranges = verificationRanges(metadata, 
candidates);
-        for (int rangePosition = 0; rangePosition < ranges.size(); ) {
-            int rangeEnd = rangePosition;
-            long batchStoredBytes = 0;
-            while (rangeEnd < ranges.size()
-                    && rangeEnd - rangePosition < 
MAX_VERIFICATION_RANGE_BATCH_SIZE) {
-                VerificationRange range = ranges.get(rangeEnd);
-                if (rangeEnd > rangePosition
-                        && batchStoredBytes + range.storedLength > 
MAX_VERIFICATION_RANGE_SIZE) {
-                    break;
-                }
-                batchStoredBytes += range.storedLength;
-                rangeEnd++;
-            }
-            List<VerificationRange> batch = ranges.subList(rangePosition, 
rangeEnd);
-            byte[][] storedRanges = readVerificationRanges(input, batch);
-            for (int rangeIndex = 0; rangeIndex < batch.size(); rangeIndex++) {
-                VerificationRange range = batch.get(rangeIndex);
-                List<byte[]> pages =
-                        FMIndexFile.decodeVerificationBlockRange(
-                                storedRanges[rangeIndex], range.blocks);
-                for (int pageOffset = 0; pageOffset < pages.size(); 
pageOffset++) {
-                    verifyPage(
-                            metadata.footer.firstRowId,
-                            
metadata.directory.verificationPages.get(range.pageStart + pageOffset),
-                            pages.get(pageOffset),
-                            patterns,
-                            candidates,
-                            result);
-                }
-            }
-            rangePosition = rangeEnd;
-        }
-        return result;
-    }
-
-    private List<VerificationRange> verificationRanges(
-            Metadata metadata, @Nullable RoaringNavigableMap64 candidates) {
-        List<FMIndexFile.VerificationPageMeta> pages = 
metadata.directory.verificationPages;
-        List<VerificationRange> ranges = new ArrayList<>();
-        int pagePosition = 0;
-        while (pagePosition < pages.size()) {
-            if (!pageSelected(metadata.footer.firstRowId, 
pages.get(pagePosition), candidates)) {
-                pagePosition++;
-                continue;
-            }
-            int pageStart = pagePosition;
-            List<FMIndexFile.BlockInfo> blocks = new ArrayList<>();
-            long storedBytes = 0;
-            long uncompressedBytes = 0;
-            long physicalEnd = -1;
-            while (pagePosition < pages.size()
-                    && pageSelected(
-                            metadata.footer.firstRowId, 
pages.get(pagePosition), candidates)) {
-                FMIndexFile.BlockInfo block = pages.get(pagePosition).block;
-                if (!blocks.isEmpty()
-                        && (block.offset != physicalEnd
-                                || storedBytes + block.storedLength > 
MAX_VERIFICATION_RANGE_SIZE
-                                || uncompressedBytes + block.uncompressedLength
-                                        > 
MAX_VERIFICATION_UNCOMPRESSED_RANGE_SIZE)) {
-                    break;
-                }
-                FMIndexFile.validateVerificationBlock(block, fileSize());
-                blocks.add(block);
-                storedBytes += block.storedLength;
-                uncompressedBytes += block.uncompressedLength;
-                physicalEnd = block.offset + block.storedLength;
-                pagePosition++;
-            }
-            ranges.add(new VerificationRange(pageStart, blocks, storedBytes));
-        }
-        return ranges;
-    }
-
-    private byte[][] readVerificationRanges(
-            SeekableInputStream input, List<VerificationRange> ranges) throws 
IOException {
-        byte[][] result = new byte[ranges.size()][];
-        if (ranges.size() == 1 || !(input instanceof VectoredReadable)) {
-            for (int i = 0; i < ranges.size(); i++) {
-                result[i] =
-                        FMIndexFile.readVerificationBlockRange(
-                                input, ranges.get(i).blocks, fileSize());
-            }
-            return result;
-        }
-
-        VectoredReadable readable = (VectoredReadable) input;
-        List<FileRange> fileRanges = new ArrayList<>(ranges.size());
-        for (VerificationRange range : ranges) {
-            fileRanges.add(
-                    FileRange.createFileRange(
-                            range.blocks.get(0).offset, (int) 
range.storedLength));
-        }
-        VectoredReadUtils.ReadOptions options =
-                VectoredReadUtils.ReadOptions.from(readable)
-                        
.withMinSeekForVectorReads(ESTIMATED_READ_REQUEST_BYTES)
-                        .withSequentialReadFallback(false);
-        VectoredReadUtils.readVectored(readable, fileRanges, options);
-        for (int i = 0; i < fileRanges.size(); i++) {
-            try {
-                result[i] = fileRanges.get(i).getData().join();
-            } catch (CompletionException e) {
-                if (e.getCause() instanceof IOException) {
-                    throw (IOException) e.getCause();
-                }
-                throw e;
-            }
-        }
-        return result;
-    }
-
-    private static void verifyPage(
-            long firstRowId,
-            FMIndexFile.VerificationPageMeta page,
-            byte[] values,
-            List<FMBytePattern> patterns,
-            @Nullable RoaringNavigableMap64 candidates,
-            RoaringNavigableMap64 result) {
-        int offset = 0;
-        for (int row = 0; row < page.rowCount; row++) {
-            Preconditions.checkState(
-                    offset <= values.length - Integer.BYTES,
-                    "FM index verification page ended before its declared 
rows.");
-            int length = readInt(values, offset);
-            offset += Integer.BYTES;
-            Preconditions.checkState(
-                    length == -1 || (length >= 0 && length <= values.length - 
offset),
-                    "Invalid FM index verification value length.");
-            long rowId = firstRowId + page.firstRow + row;
-            if (length >= 0
-                    && (candidates == null || candidates.contains(rowId))
-                    && matchesAll(patterns, values, offset, length)) {
-                result.add(rowId);
-            }
-            if (length >= 0) {
-                offset += length;
-            }
-        }
-        Preconditions.checkState(
-                offset == values.length, "FM index verification page has 
trailing bytes.");
-    }
-
-    private static boolean pageSelected(
-            long firstRowId,
-            FMIndexFile.VerificationPageMeta page,
-            @Nullable RoaringNavigableMap64 candidates) {
-        if (candidates == null) {
-            return true;
-        }
-        long first = firstRowId + page.firstRow;
-        return candidates.intersects(first, first + page.rowCount);
-    }
-
-    private static boolean matchesAll(
-            List<FMBytePattern> patterns, byte[] value, int offset, int 
length) {
-        for (FMBytePattern pattern : patterns) {
-            if (!pattern.contains(value, offset, length)) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    private static int readInt(byte[] bytes, int offset) {
-        return ((bytes[offset] & 0xFF) << 24)
-                | ((bytes[offset + 1] & 0xFF) << 16)
-                | ((bytes[offset + 2] & 0xFF) << 8)
-                | (bytes[offset + 3] & 0xFF);
-    }
-
     private RoaringNavigableMap64 locateRows(
             SeekableInputStream input, Metadata metadata, SearchInterval 
interval)
             throws IOException {
@@ -788,9 +590,13 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
     private RoaringNavigableMap64 nullRows(
             SeekableInputStream input, Metadata metadata, boolean selectNulls) 
throws IOException {
         RoaringNavigableMap64 result = new RoaringNavigableMap64();
-        for (int row = 0; row < metadata.directory.rowCount; row++) {
-            if (bit(input, metadata.directory.nullRows, row) == selectNulls) {
-                result.add(metadata.footer.firstRowId + row);
+        FMIndexFile.BitVectorMeta nullRows = metadata.directory.nullRows;
+        for (FMIndexFile.BitBlockMeta meta : nullRows.blocks) {
+            FMIndexFile.BitBlock block = bitBlock(input, nullRows, meta);
+            for (int local = 0; local < meta.bitCount; local++) {
+                if (block.get(local) == selectNulls) {
+                    result.add(metadata.footer.firstRowId + meta.firstBit + 
local);
+                }
             }
         }
         return result;
@@ -808,9 +614,12 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
                 Preconditions.checkState(
                         containerLoader != null && partition != null,
                         "Missing FM index container metadata.");
-                containerLoader.validate(input);
+                FMIndexFile.ContainerFooter containerFooter = 
containerLoader.validate(input);
                 FMIndexFile.Footer footer =
                         FMIndexFile.readFooter(input, partition, 
file.fileSize());
+                Preconditions.checkState(
+                        footer.featureFlags == containerFooter.featureFlags,
+                        "FM index partition and container feature flags do not 
match.");
                 FMIndexFile.Directory directory =
                         FMIndexFile.readDirectory(input, footer, 
file.fileSize());
                 current = new Metadata(footer, directory);
@@ -825,11 +634,6 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
         return fileReader.getInputStream(file);
     }
 
-    private long fileSize() {
-        Preconditions.checkState(file != null, "Missing FM index file.");
-        return file.fileSize();
-    }
-
     @Nullable
     private static byte[] needle(@Nullable Object literal) {
         if (literal == null) {
@@ -923,28 +727,13 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
     @Override
     public void close() {}
 
-    private static final class VerificationRange {
-        private final int pageStart;
-        private final List<FMIndexFile.BlockInfo> blocks;
-        private final long storedLength;
-
-        private VerificationRange(
-                int pageStart, List<FMIndexFile.BlockInfo> blocks, long 
storedLength) {
-            this.pageStart = pageStart;
-            this.blocks = blocks;
-            this.storedLength = storedLength;
-        }
-    }
-
     private static final class SearchInterval {
         private final int lower;
         private final int upper;
-        private final byte[] needle;
 
-        private SearchInterval(int lower, int upper, byte[] needle) {
+        private SearchInterval(int lower, int upper) {
             this.lower = lower;
             this.upper = upper;
-            this.needle = needle;
         }
 
         private boolean isEmpty() {
@@ -979,25 +768,27 @@ final class FMGlobalIndexReader implements 
ContainsRefiningGlobalIndexReader {
     static final class ContainerMetadataLoader {
         private final GlobalIndexIOMeta file;
         private final FMIndexFile.IndexMeta expected;
-        private boolean validated;
+        @Nullable private FMIndexFile.ContainerFooter footer;
 
         ContainerMetadataLoader(GlobalIndexIOMeta file, FMIndexFile.IndexMeta 
expected) {
             this.file = file;
             this.expected = expected;
         }
 
-        synchronized void validate(SeekableInputStream input) throws 
IOException {
-            if (validated) {
-                return;
+        synchronized FMIndexFile.ContainerFooter validate(SeekableInputStream 
input)
+                throws IOException {
+            if (footer != null) {
+                return footer;
             }
-            FMIndexFile.ContainerFooter footer =
+            FMIndexFile.ContainerFooter current =
                     FMIndexFile.readContainerFooter(input, file.fileSize());
             FMIndexFile.IndexMeta actual =
-                    FMIndexFile.readContainerDirectory(input, footer, 
file.fileSize());
+                    FMIndexFile.readContainerDirectory(input, current, 
file.fileSize());
             Preconditions.checkState(
                     expected.sameLayout(actual),
                     "FM index manifest metadata does not match the container 
directory.");
-            validated = true;
+            footer = current;
+            return current;
         }
     }
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexWriter.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexWriter.java
index 01b5ca8449..c43c4c1979 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexWriter.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexWriter.java
@@ -28,7 +28,6 @@ import org.apache.paimon.utils.Preconditions;
 
 import javax.annotation.Nullable;
 
-import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.DataOutputStream;
 import java.io.IOException;
@@ -40,9 +39,6 @@ import java.util.List;
 /** Streaming, bounded-partition writer for an exact byte-oriented FM index. */
 public class FMGlobalIndexWriter implements GlobalIndexSingleColumnWriter, 
Closeable {
 
-    private static final int VERIFICATION_PAGE_ROW_COUNT = 128;
-    private static final int TARGET_VERIFICATION_PAGE_SIZE = 64 * 1024;
-
     private final GlobalIndexFileWriter fileWriter;
     private final int maxPartitionTextLength;
     private final int maxPartitionRowCount;
@@ -93,12 +89,6 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
                     "FM index expects BinaryString values, but found %s.",
                     key.getClass().getName());
             bytes = ((BinaryString) key).toBytes();
-            Preconditions.checkArgument(
-                    bytes.length
-                            <= 
FMIndexFile.MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH
-                                    - Integer.BYTES,
-                    "A value exceeds the FM index exact-fallback block limit 
(%s bytes).",
-                    FMIndexFile.MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH);
         }
         long encodedLength = (bytes == null ? 0L : bytes.length) + 1L;
         Preconditions.checkArgument(
@@ -257,8 +247,6 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
             FMIndexFile.BitVectorMeta rowBoundaries =
                     FMIndexFile.writeBitVector(
                             stream, output, boundaryWords, symbols.length, 
compressionFactory);
-            List<FMIndexFile.VerificationPageMeta> verificationPages =
-                    writeVerificationPages(stream, output, symbols, 
alphabet.symbolToByte);
             FMIndexFile.Directory directory =
                     new FMIndexFile.Directory(
                             partitionRowCount,
@@ -273,8 +261,7 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
                             sampled,
                             samples,
                             nullVector,
-                            rowBoundaries,
-                            verificationPages);
+                            rowBoundaries);
             FMIndexFile.BlockInfo directoryBlock =
                     FMIndexFile.writeDirectory(stream, output, directory, 
compressionFactory);
             FMIndexFile.writeFooter(
@@ -336,68 +323,6 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
         return counts;
     }
 
-    private List<FMIndexFile.VerificationPageMeta> writeVerificationPages(
-            PositionOutputStream stream,
-            DataOutputStream output,
-            char[] symbols,
-            int[] symbolToByte)
-            throws IOException {
-        List<FMIndexFile.VerificationPageMeta> pages = new ArrayList<>();
-        int row = 0;
-        int symbolPosition = 0;
-        while (row < partitionRowCount) {
-            int firstRow = row;
-            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-            DataOutputStream values = new DataOutputStream(bytes);
-            int pageRows = 0;
-            while (row < partitionRowCount) {
-                int separator = symbolPosition;
-                while (separator < symbols.length && symbols[separator] != 
FMIndexFile.SEPARATOR) {
-                    separator++;
-                }
-                Preconditions.checkState(
-                        separator < symbols.length,
-                        "FM index encoded text is missing a row separator.");
-                int valueLength = separator - symbolPosition;
-                long recordLength = Integer.BYTES + (nullRows[row] ? 0L : 
valueLength);
-                if (pageRows > 0
-                        && (pageRows >= VERIFICATION_PAGE_ROW_COUNT
-                                || bytes.size() + recordLength > 
TARGET_VERIFICATION_PAGE_SIZE)) {
-                    break;
-                }
-                Preconditions.checkState(
-                        bytes.size() + recordLength
-                                <= 
FMIndexFile.MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH,
-                        "FM index verification value exceeds the supported 
block size.");
-                if (nullRows[row]) {
-                    Preconditions.checkState(
-                            valueLength == 0, "FM index null row contains 
encoded bytes.");
-                    values.writeInt(-1);
-                } else {
-                    values.writeInt(valueLength);
-                    for (int i = symbolPosition; i < separator; i++) {
-                        values.writeByte(symbolToByte[symbols[i]]);
-                    }
-                }
-                symbolPosition = separator + 1;
-                row++;
-                pageRows++;
-            }
-            values.flush();
-            pages.add(
-                    new FMIndexFile.VerificationPageMeta(
-                            firstRow,
-                            pageRows,
-                            FMIndexFile.writeBlock(
-                                    stream, output, bytes.toByteArray(), 
compressionFactory)));
-        }
-        Preconditions.checkState(
-                symbolPosition == symbols.length - 1
-                        && symbols[symbolPosition] == FMIndexFile.TERMINATOR,
-                "FM index verification rows do not cover the encoded text.");
-        return pages;
-    }
-
     private static DenseAlphabet densify(char[] symbols) {
         boolean[] present = new boolean[256];
         for (char symbol : symbols) {
@@ -413,18 +338,12 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
                 byteToSymbol[value] = alphabetSize++;
             }
         }
-        int[] symbolToByte = new int[alphabetSize];
-        for (int value = 0; value < byteToSymbol.length; value++) {
-            if (byteToSymbol[value] >= 0) {
-                symbolToByte[byteToSymbol[value]] = value;
-            }
-        }
         for (int i = 0; i < symbols.length; i++) {
             if (symbols[i] >= FMIndexFile.FIRST_BYTE_SYMBOL) {
                 symbols[i] = (char) byteToSymbol[symbols[i] - 
FMIndexFile.FIRST_BYTE_SYMBOL];
             }
         }
-        return new DenseAlphabet(alphabetSize, byteToSymbol, symbolToByte);
+        return new DenseAlphabet(alphabetSize, byteToSymbol);
     }
 
     private void ensureNullCapacity(int capacity) {
@@ -442,12 +361,10 @@ public class FMGlobalIndexWriter implements 
GlobalIndexSingleColumnWriter, Close
     private static final class DenseAlphabet {
         private final int alphabetSize;
         private final int[] byteToSymbol;
-        private final int[] symbolToByte;
 
-        private DenseAlphabet(int alphabetSize, int[] byteToSymbol, int[] 
symbolToByte) {
+        private DenseAlphabet(int alphabetSize, int[] byteToSymbol) {
             this.alphabetSize = alphabetSize;
             this.byteToSymbol = byteToSymbol;
-            this.symbolToByte = symbolToByte;
         }
     }
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexFile.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexFile.java
index d02b68dc21..0ce01c600a 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexFile.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexFile.java
@@ -25,8 +25,6 @@ import org.apache.paimon.compression.BlockDecompressor;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.fs.VectoredReadable;
-import org.apache.paimon.memory.MemorySegment;
-import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.utils.Preconditions;
 
 import javax.annotation.Nullable;
@@ -40,19 +38,18 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-
-import static org.apache.paimon.sst.SstFileUtils.crc32c;
+import java.util.zip.CRC32;
 
 /**
  * Portable V1 container layout for partitioned, demand-paged FM indexes.
  *
  * <p>Each physical index file contains one or more canonical, contiguous 
partitions followed by a
  * checksummed container directory and fixed footer. A partition contains 
dense-alphabet blocked
- * quaternary wavelet levels, sampled-SA mask and values, null mask, 
row-boundary mask,
- * exact-verification value pages, its directory, and a fixed footer. Every 
independently readable
- * block records its offset, stored and uncompressed lengths, compression ID 
and CRC32C. The reader
- * validates all physical ranges before allocating decoded buffers and 
verifies the stored checksum
- * before decompression.
+ * quaternary wavelet levels, sampled-SA mask and values, null mask, 
row-boundary mask, its
+ * directory, and a fixed footer. Every independently readable block records 
its offset, stored and
+ * uncompressed lengths, compression ID and CRC32 over the stored bytes 
followed by the compression
+ * ID byte. The reader validates all physical ranges before allocating decoded 
buffers and verifies
+ * the stored checksum before decompression.
  */
 final class FMIndexFile {
 
@@ -72,12 +69,8 @@ final class FMIndexFile {
     private static final int FEATURE_VALUE_SAMPLED_SA = 1;
     private static final int FEATURE_DENSE_QUAD_WAVELET = 1 << 1;
     private static final int FEATURE_SEPARATOR_ROW_IDS = 1 << 2;
-    private static final int FEATURE_EXACT_DENSE_FALLBACK = 1 << 3;
     private static final int FEATURE_FLAGS =
-            FEATURE_VALUE_SAMPLED_SA
-                    | FEATURE_DENSE_QUAD_WAVELET
-                    | FEATURE_SEPARATOR_ROW_IDS
-                    | FEATURE_EXACT_DENSE_FALLBACK;
+            FEATURE_VALUE_SAMPLED_SA | FEATURE_DENSE_QUAD_WAVELET | 
FEATURE_SEPARATOR_ROW_IDS;
 
     static final int BLOCK_WORDS = 4096;
     static final int BLOCK_BITS = BLOCK_WORDS * Long.SIZE;
@@ -90,10 +83,14 @@ final class FMIndexFile {
     static final int FOOTER_CHECKSUM_OFFSET = 60;
     static final int MAX_DIRECTORY_UNCOMPRESSED_LENGTH = 16 * 1024 * 1024;
     static final int MAX_DATA_BLOCK_UNCOMPRESSED_LENGTH = 64 * 1024;
-    static final int MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH = 64 * 1024 * 
1024;
 
     private FMIndexFile() {}
 
+    private static void validateFeatureFlags(int flags, String scope) {
+        Preconditions.checkState(
+                flags == FEATURE_FLAGS, "Unsupported FM index %s feature 
flags: %s.", scope, flags);
+    }
+
     static byte[] writeIndexMeta(long firstRowId, long rowCount, 
List<PartitionMeta> partitions) {
         Preconditions.checkArgument(firstRowId >= 0 && rowCount > 0, "Invalid 
FM index row range.");
         Preconditions.checkArgument(
@@ -211,8 +208,7 @@ final class FMIndexFile {
         }
         long offset = stream.getPos();
         out.write(stored, 0, storedLength);
-        int checksum =
-                crc32c(new MemorySlice(MemorySegment.wrap(stored), 0, 
storedLength), compression);
+        int checksum = crc32(stored, 0, storedLength, compression);
         return new BlockInfo(
                 offset, storedLength, uncompressed.length, 
compression.persistentId(), checksum);
     }
@@ -342,12 +338,6 @@ final class FMIndexFile {
         writeIntVectorMeta(data, directory.sampleValues);
         writeBitVectorMeta(data, directory.nullRows);
         writeBitVectorMeta(data, directory.rowBoundaries);
-        data.writeInt(directory.verificationPages.size());
-        for (VerificationPageMeta page : directory.verificationPages) {
-            data.writeInt(page.firstRow);
-            data.writeInt(page.rowCount);
-            writeBlockInfo(data, page.block);
-        }
         data.flush();
         byte[] uncompressed = bytes.toByteArray();
         Preconditions.checkState(
@@ -429,10 +419,8 @@ final class FMIndexFile {
                 "FM index container footer checksum mismatch: expected=%s, 
actual=%s.",
                 expectedChecksum,
                 actualChecksum);
-        Preconditions.checkState(
-                readInt(bytes, 44) == FEATURE_FLAGS,
-                "Unsupported FM index container feature flags: %s.",
-                readInt(bytes, 44));
+        int featureFlags = readInt(bytes, 44);
+        validateFeatureFlags(featureFlags, "container");
         Preconditions.checkState(
                 readInt(bytes, 48) == 0, "Invalid FM index container reserved 
field.");
 
@@ -456,7 +444,7 @@ final class FMIndexFile {
         Preconditions.checkState(
                 directory.offset + directory.storedLength == fileSize - 
CONTAINER_FOOTER_LENGTH,
                 "FM index container directory is not immediately before the 
footer.");
-        return new ContainerFooter(directory, firstRowId, rowCount, 
partitionCount);
+        return new ContainerFooter(directory, firstRowId, rowCount, 
partitionCount, featureFlags);
     }
 
     static IndexMeta readContainerDirectory(
@@ -497,10 +485,8 @@ final class FMIndexFile {
                 "FM index partition footer checksum mismatch: expected=%s, 
actual=%s.",
                 expectedChecksum,
                 actualChecksum);
-        Preconditions.checkState(
-                readInt(bytes, 44) == FEATURE_FLAGS,
-                "Unsupported FM index partition feature flags: %s.",
-                readInt(bytes, 44));
+        int featureFlags = readInt(bytes, 44);
+        validateFeatureFlags(featureFlags, "partition");
         Preconditions.checkState(
                 readInt(bytes, 48) == 0, "Invalid FM index partition reserved 
field.");
 
@@ -532,7 +518,8 @@ final class FMIndexFile {
                 textLength,
                 sampleRate,
                 partition.startOffset,
-                partition.endOffset);
+                partition.endOffset,
+                featureFlags);
     }
 
     static Footer readFooter(SeekableInputStream input, long fileSize) throws 
IOException {
@@ -647,34 +634,6 @@ final class FMIndexFile {
         Preconditions.checkState(
                 rowBoundaries.totalOnes == rowCount,
                 "FM index row-boundary cardinality does not match its row 
count.");
-        int verificationPageCount = data.readInt();
-        Preconditions.checkState(
-                verificationPageCount > 0 && verificationPageCount <= rowCount,
-                "Invalid FM index verification page count.");
-        List<VerificationPageMeta> verificationPages = new 
ArrayList<>(verificationPageCount);
-        int nextRow = 0;
-        for (int i = 0; i < verificationPageCount; i++) {
-            int firstRow = data.readInt();
-            int pageRowCount = data.readInt();
-            BlockInfo block = readBlockInfo(data);
-            Preconditions.checkState(
-                    firstRow == nextRow
-                            && pageRowCount > 0
-                            && pageRowCount <= rowCount - firstRow
-                            && block.uncompressedLength >= pageRowCount * 
Integer.BYTES
-                            && block.uncompressedLength
-                                    <= 
MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH,
-                    "Invalid FM index verification page metadata.");
-            validateCanonicalBlock(
-                    block,
-                    expectedOffset,
-                    footer.directory.offset,
-                    MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH);
-            verificationPages.add(new VerificationPageMeta(firstRow, 
pageRowCount, block));
-            nextRow += pageRowCount;
-        }
-        Preconditions.checkState(
-                nextRow == rowCount, "FM index verification pages do not cover 
all rows.");
         Preconditions.checkState(
                 expectedOffset[0] == footer.directory.offset,
                 "FM index payload blocks are not canonical and contiguous.");
@@ -693,13 +652,12 @@ final class FMIndexFile {
                 sampledRows,
                 sampleValues,
                 nullRows,
-                rowBoundaries,
-                verificationPages);
+                rowBoundaries);
     }
 
     static byte[] readBlock(SeekableInputStream input, BlockInfo block, long 
fileSize)
             throws IOException {
-        validateBlock(block, fileSize, 
MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH, true);
+        validateBlock(block, fileSize, MAX_DIRECTORY_UNCOMPRESSED_LENGTH, 
true);
         byte[] stored = readAt(input, block.offset, block.storedLength);
         return decodeStoredBlock(stored, 0, block);
     }
@@ -731,52 +689,12 @@ final class FMIndexFile {
         return result;
     }
 
-    static byte[] readVerificationBlockRange(
-            SeekableInputStream input, List<BlockInfo> blocks, long fileSize) 
throws IOException {
-        Preconditions.checkArgument(
-                !blocks.isEmpty(), "FM verification range must contain 
blocks.");
-        long firstOffset = blocks.get(0).offset;
-        long nextOffset = firstOffset;
-        long totalStoredLength = 0;
-        for (BlockInfo block : blocks) {
-            validateVerificationBlock(block, fileSize);
-            Preconditions.checkState(
-                    block.offset == nextOffset,
-                    "FM verification blocks are not canonical and 
contiguous.");
-            totalStoredLength += block.storedLength;
-            Preconditions.checkState(
-                    totalStoredLength <= Integer.MAX_VALUE,
-                    "FM verification range exceeds the supported read size.");
-            nextOffset += block.storedLength;
-        }
-        return readAt(input, firstOffset, (int) totalStoredLength);
-    }
-
-    static List<byte[]> decodeVerificationBlockRange(byte[] stored, 
List<BlockInfo> blocks) {
-        List<byte[]> result = new ArrayList<>(blocks.size());
-        int offset = 0;
-        for (BlockInfo block : blocks) {
-            result.add(decodeStoredBlock(stored, offset, block));
-            offset += block.storedLength;
-        }
-        Preconditions.checkState(
-                offset == stored.length, "FM verification range contains 
trailing bytes.");
-        return result;
-    }
-
-    static void validateVerificationBlock(BlockInfo block, long fileSize) {
-        validateBlock(block, fileSize, 
MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH, false);
-    }
-
     private static byte[] decodeStoredBlock(byte[] stored, int offset, 
BlockInfo block) {
         Preconditions.checkState(
                 offset >= 0 && block.storedLength <= stored.length - offset,
                 "FM demand-page block exceeds its stored bytes.");
         BlockCompressionType compression = compression(block.compressionId);
-        int checksum =
-                crc32c(
-                        new MemorySlice(MemorySegment.wrap(stored), offset, 
block.storedLength),
-                        compression);
+        int checksum = crc32(stored, offset, block.storedLength, compression);
         Preconditions.checkState(
                 checksum == block.checksum,
                 "FM index block checksum mismatch: expected=%s, actual=%s.",
@@ -1209,20 +1127,28 @@ final class FMIndexFile {
     }
 
     private static int footerChecksum(byte[] footer) {
-        return crc32c(
-                new MemorySlice(MemorySegment.wrap(footer), 0, 
FOOTER_CHECKSUM_OFFSET),
-                BlockCompressionType.NONE);
+        return crc32(footer, 0, FOOTER_CHECKSUM_OFFSET, 
BlockCompressionType.NONE);
     }
 
     private static int indexMetaChecksum(byte[] metadata) {
-        return crc32c(
-                new MemorySlice(
-                        MemorySegment.wrap(metadata),
-                        0,
-                        metadata.length - INDEX_META_CHECKSUM_LENGTH),
+        return crc32(
+                metadata,
+                0,
+                metadata.length - INDEX_META_CHECKSUM_LENGTH,
                 BlockCompressionType.NONE);
     }
 
+    /** V1 checksum contract: IEEE CRC32 over stored bytes followed by the 
compression ID byte. */
+    static int crc32(byte[] stored, int offset, int length, 
BlockCompressionType compressionType) {
+        Preconditions.checkArgument(
+                offset >= 0 && length >= 0 && length <= stored.length - offset,
+                "Invalid FM checksum byte range.");
+        CRC32 crc = new CRC32();
+        crc.update(stored, offset, length);
+        crc.update(compressionType.persistentId() & 0xFF);
+        return (int) crc.getValue();
+    }
+
     private static byte[] readAt(SeekableInputStream input, long offset, int 
length)
             throws IOException {
         byte[] result = new byte[length];
@@ -1482,7 +1408,6 @@ final class FMIndexFile {
         final IntVectorMeta sampleValues;
         final BitVectorMeta nullRows;
         final BitVectorMeta rowBoundaries;
-        final List<VerificationPageMeta> verificationPages;
 
         Directory(
                 int rowCount,
@@ -1497,8 +1422,7 @@ final class FMIndexFile {
                 BitVectorMeta sampledRows,
                 IntVectorMeta sampleValues,
                 BitVectorMeta nullRows,
-                BitVectorMeta rowBoundaries,
-                List<VerificationPageMeta> verificationPages) {
+                BitVectorMeta rowBoundaries) {
             this.rowCount = rowCount;
             this.textLength = textLength;
             this.sampleRate = sampleRate;
@@ -1513,19 +1437,6 @@ final class FMIndexFile {
             this.sampleValues = sampleValues;
             this.nullRows = nullRows;
             this.rowBoundaries = rowBoundaries;
-            this.verificationPages = verificationPages;
-        }
-    }
-
-    static final class VerificationPageMeta {
-        final int firstRow;
-        final int rowCount;
-        final BlockInfo block;
-
-        VerificationPageMeta(int firstRow, int rowCount, BlockInfo block) {
-            this.firstRow = firstRow;
-            this.rowCount = rowCount;
-            this.block = block;
         }
     }
 
@@ -1537,6 +1448,7 @@ final class FMIndexFile {
         final int sampleRate;
         final long partitionStartOffset;
         final long partitionEndOffset;
+        final int featureFlags;
 
         Footer(
                 BlockInfo directory,
@@ -1545,7 +1457,8 @@ final class FMIndexFile {
                 int textLength,
                 int sampleRate,
                 long partitionStartOffset,
-                long partitionEndOffset) {
+                long partitionEndOffset,
+                int featureFlags) {
             this.directory = directory;
             this.firstRowId = firstRowId;
             this.rowCount = rowCount;
@@ -1553,6 +1466,7 @@ final class FMIndexFile {
             this.sampleRate = sampleRate;
             this.partitionStartOffset = partitionStartOffset;
             this.partitionEndOffset = partitionEndOffset;
+            this.featureFlags = featureFlags;
         }
     }
 
@@ -1561,13 +1475,19 @@ final class FMIndexFile {
         final long firstRowId;
         final long rowCount;
         final int partitionCount;
+        final int featureFlags;
 
         private ContainerFooter(
-                BlockInfo directory, long firstRowId, long rowCount, int 
partitionCount) {
+                BlockInfo directory,
+                long firstRowId,
+                long rowCount,
+                int partitionCount,
+                int featureFlags) {
             this.directory = directory;
             this.firstRowId = firstRowId;
             this.rowCount = rowCount;
             this.partitionCount = partitionCount;
+            this.featureFlags = featureFlags;
         }
     }
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexReadContext.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexReadContext.java
index 45f0df9865..13c8cd4192 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexReadContext.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexReadContext.java
@@ -32,6 +32,15 @@ import java.util.function.Supplier;
 final class FMIndexReadContext {
 
     private static final int DEFAULT_MAX_CONCURRENT_FILE_READS = 8;
+    // A full decoded quaternary rank block contains packed words plus four 
rank-prefix integers
+    // per 64 words and one terminal prefix. It is larger than a bit-rank or 
sampled-value block.
+    private static final long QUAD_BLOCK_WORD_BYTES = FMIndexFile.BLOCK_WORDS 
* Long.BYTES;
+    private static final long QUAD_BLOCK_PREFIX_BYTES =
+            ((FMIndexFile.BLOCK_WORDS + 63L) / 64L + 1L) * 4L * Integer.BYTES;
+    // A locate must at least retain its largest decoded block; otherwise 
repeated access performs
+    // another physical read every time.
+    private static final long MIN_LOCATE_CACHE_BYTES =
+            QUAD_BLOCK_WORD_BYTES + QUAD_BLOCK_PREFIX_BYTES;
 
     private final long cacheBudget;
     private final Semaphore fileReadPermits = new 
Semaphore(DEFAULT_MAX_CONCURRENT_FILE_READS);
@@ -46,6 +55,10 @@ final class FMIndexReadContext {
         return (int) Math.min(configuredPageSize, Math.min(cacheBudget, 
Integer.MAX_VALUE));
     }
 
+    boolean supportsLocate() {
+        return cacheBudget >= MIN_LOCATE_CACHE_BYTES;
+    }
+
     @Nullable
     synchronized <T> T get(GlobalIndexIOMeta file, FMIndexFile.BlockInfo 
block, Class<T> type) {
         CacheEntry entry = cache.get(new BlockKey(file, block, type));
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
index de8b576df9..edc1c9f2d8 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
@@ -103,8 +103,9 @@ public class FMGlobalIndexTest {
 
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 3);
-        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 4);
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1);
         options.set(FMGlobalIndexOptions.COMPRESSION, "lz4");
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
         indexer = new FMGlobalIndexer(dataField, options);
     }
 
@@ -160,7 +161,9 @@ public class FMGlobalIndexTest {
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 2);
         options.set(FMGlobalIndexOptions.PARTITION_SIZE, 
MemorySize.ofKibiBytes(1));
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1);
         options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
         indexer = new FMGlobalIndexer(dataField, options);
 
         List<GlobalIndexIOMeta> files =
@@ -206,7 +209,8 @@ public class FMGlobalIndexTest {
     public void testRandomizedExactnessAgainstByteScan() throws Exception {
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 17);
-        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 8);
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1);
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
         indexer = new FMGlobalIndexer(dataField, options);
         Random random = new Random(99173);
         List<BinaryString> values = new ArrayList<>();
@@ -306,6 +310,7 @@ public class FMGlobalIndexTest {
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 100);
         options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 4);
         options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
         indexer = new FMGlobalIndexer(dataField, options);
         List<GlobalIndexIOMeta> actualFiles =
                 writeData(
@@ -347,6 +352,13 @@ public class FMGlobalIndexTest {
         }
     }
 
+    @Test
+    public void testV1ChecksumUsesIeeeCrc32Contract() {
+        byte[] bytes = "123456789".getBytes(StandardCharsets.US_ASCII);
+        assertThat(FMIndexFile.crc32(bytes, 0, bytes.length, 
BlockCompressionType.NONE))
+                .isEqualTo(0x00C49E49);
+    }
+
     @Test
     public void testDefaultLz4IsPersistedPerCompressibleBlock() throws 
Exception {
         String repeated = String.join("", Collections.nCopies(20_000, 
"compressible-value-"));
@@ -417,7 +429,11 @@ public class FMGlobalIndexTest {
     }
 
     @Test
-    public void testDenseOccurrenceGuardFallsBackToExactStoredValues() throws 
Exception {
+    public void testDenseOccurrenceGuardDeclinesIndexEvaluation() throws 
Exception {
+        Options options = new Options();
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 4);
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
+        indexer = new FMGlobalIndexer(dataField, options);
         String repeated = String.join("", Collections.nCopies(10_000, "a"));
         List<GlobalIndexIOMeta> files =
                 writeData(Arrays.asList(str(repeated + "b"), str(repeated)), 
0);
@@ -437,9 +453,8 @@ public class FMGlobalIndexTest {
         corruptByte(file, sampleBlockOffset);
 
         try (GlobalIndexReader reader = createReader(files, 2)) {
-            // Locating every 'a' occurrence would read the corrupted SA 
samples. The count-first
-            // guard instead scans independently checksummed stored-value 
pages and remains exact.
-            assertRows(reader.visitContains(fieldRef, str("a")).join(), 0L, 
1L);
+            // Dense intervals are left to the source scan, so the corrupted 
SA sample is not read.
+            assertThat(reader.visitContains(fieldRef, 
str("a")).join()).isEmpty();
             assertThatThrownBy(() -> reader.visitContains(fieldRef, 
str("b")).join())
                     .isInstanceOf(CompletionException.class)
                     .hasMessageContaining("block checksum mismatch");
@@ -450,7 +465,7 @@ public class FMGlobalIndexTest {
         conservative.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 0.0001d);
         indexer = new FMGlobalIndexer(dataField, conservative);
         try (GlobalIndexReader reader = createReader(files, 2)) {
-            assertRows(reader.visitContains(fieldRef, str("b")).join(), 0L);
+            assertThat(reader.visitContains(fieldRef, 
str("b")).join()).isEmpty();
         }
     }
 
@@ -470,7 +485,80 @@ public class FMGlobalIndexTest {
     }
 
     @Test
-    public void testCandidateAwareFallbackAvoidsOccurrenceLocation() throws 
Exception {
+    public void testZeroReadCacheDeclinesLocate() throws Exception {
+        Options options = new Options();
+        options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 10);
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1024);
+        options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.READ_CACHE_SIZE, 
MemorySize.ofBytes(0));
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
+        indexer = new FMGlobalIndexer(dataField, options);
+        String value = String.join("", Collections.nCopies(3_000, "x")) + 
"unique-needle";
+        List<GlobalIndexIOMeta> files = 
writeData(Collections.singletonList(str(value)), 0);
+
+        try (GlobalIndexReader reader = createReader(files, 1)) {
+            assertThat(reader.visitContains(fieldRef, 
str("unique-needle")).join()).isEmpty();
+        }
+    }
+
+    @Test
+    public void testZeroReadCacheScansNullBitmapOncePerBlock() throws 
Exception {
+        Options options = new Options();
+        options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 1_000);
+        options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.READ_CACHE_SIZE, 
MemorySize.ofBytes(0));
+        indexer = new FMGlobalIndexer(dataField, options);
+        List<BinaryString> values = new ArrayList<>();
+        for (int row = 0; row < 1_000; row++) {
+            values.add((row & 1) == 0 ? null : str("value-" + row));
+        }
+        List<GlobalIndexIOMeta> files = writeData(values, 0);
+
+        AtomicInteger preadCalls = new AtomicInteger();
+        fileReader =
+                meta ->
+                        new CountingVectoredInput(
+                                fileIO.newInputStream(meta.filePath()), 
preadCalls);
+        try (GlobalIndexReader reader = createReader(files, values.size())) {
+            assertRows(
+                    reader.visitIsNull(fieldRef).join(),
+                    java.util.stream.LongStream.range(0, values.size())
+                            .filter(row -> (row & 1) == 0)
+                            .toArray());
+        }
+        assertThat(preadCalls.get()).isLessThan(10);
+    }
+
+    @Test
+    public void testDenseQueriesDeclineWithoutStoredValues() throws Exception {
+        Options options = new Options();
+        options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 10);
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 4);
+        options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
+        indexer = new FMGlobalIndexer(dataField, options);
+        List<GlobalIndexIOMeta> files = writeData(Arrays.asList(str("aaaa"), 
null, str("bbbb")), 0);
+
+        try (GlobalIndexReader reader = createReader(files, 3)) {
+            assertThat(reader.visitContains(fieldRef, 
str("a")).join()).isEmpty();
+            assertRows(reader.visitContains(fieldRef, str("missing")).join());
+            assertRows(reader.visitContains(fieldRef, str("")).join(), 0L, 2L);
+            org.apache.paimon.utils.RoaringNavigableMap64 candidates =
+                    new org.apache.paimon.utils.RoaringNavigableMap64();
+            candidates.add(2L);
+            assertRows(
+                    
((org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader) reader)
+                            .visitContainsConjunction(
+                                    fieldRef,
+                                    Collections.singletonList(str("bbbb")),
+                                    GlobalIndexResult.create(candidates))
+                            .join(),
+                    2L);
+        }
+    }
+
+    @Test
+    public void testDenseCandidateQueryDeclinesIndexEvaluation() throws 
Exception {
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 1_000);
         options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 4);
@@ -478,20 +566,6 @@ public class FMGlobalIndexTest {
         indexer = new FMGlobalIndexer(dataField, options);
         List<GlobalIndexIOMeta> files =
                 writeData(new ArrayList<>(Collections.nCopies(200, 
str("aaaaa"))), 0);
-        GlobalIndexIOMeta file = files.get(0);
-        long sampleBlockOffset;
-        try (org.apache.paimon.fs.SeekableInputStream input =
-                fileIO.newInputStream(file.filePath())) {
-            FMIndexFile.Footer footer = FMIndexFile.readFooter(input, 
file.fileSize());
-            sampleBlockOffset =
-                    FMIndexFile.readDirectory(input, footer, file.fileSize())
-                            .sampleValues
-                            .blocks
-                            .get(0)
-                            .block
-                            .offset;
-        }
-        corruptByte(file, sampleBlockOffset);
 
         org.apache.paimon.utils.RoaringNavigableMap64 candidates =
                 new org.apache.paimon.utils.RoaringNavigableMap64();
@@ -499,21 +573,18 @@ public class FMGlobalIndexTest {
         try (GlobalIndexReader reader = createReader(files, 200)) {
             org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader 
refining =
                     
(org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader) reader;
-            // The FM interval has only 1,000 occurrences, below the normal 
count-first guard. A
-            // one-row ANN/sibling candidate makes one sequential verification 
page cheaper than
-            // locating all occurrences, so the corrupted (and unused) samples 
must not be read.
-            assertRows(
-                    refining.visitContainsConjunction(
-                                    fieldRef,
-                                    Collections.singletonList(str("a")),
-                                    GlobalIndexResult.create(candidates))
-                            .join(),
-                    17L);
+            assertThat(
+                            refining.visitContainsConjunction(
+                                            fieldRef,
+                                            
Collections.singletonList(str("a")),
+                                            
GlobalIndexResult.create(candidates))
+                                    .join())
+                    .isEmpty();
         }
     }
 
     @Test
-    public void testMediumOccurrenceGuardUsesExactStoredValues() throws 
Exception {
+    public void testMediumOccurrenceGuardDeclinesIndexEvaluation() throws 
Exception {
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 1_000);
         options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 32);
@@ -521,27 +592,9 @@ public class FMGlobalIndexTest {
         indexer = new FMGlobalIndexer(dataField, options);
         List<GlobalIndexIOMeta> files =
                 writeData(new ArrayList<>(Collections.nCopies(200, 
str("aaaaa"))), 0);
-        GlobalIndexIOMeta file = files.get(0);
-        long sampleBlockOffset;
-        try (org.apache.paimon.fs.SeekableInputStream input =
-                fileIO.newInputStream(file.filePath())) {
-            FMIndexFile.Footer footer = FMIndexFile.readFooter(input, 
file.fileSize());
-            sampleBlockOffset =
-                    FMIndexFile.readDirectory(input, footer, file.fileSize())
-                            .sampleValues
-                            .blocks
-                            .get(0)
-                            .block
-                            .offset;
-        }
-        corruptByte(file, sampleBlockOffset);
 
         try (GlobalIndexReader reader = createReader(files, 200)) {
-            // 1,000 occurrences are below the old unconditional 4,096 locate 
threshold, but
-            // locating them is much more expensive than scanning the 
checksummed value pages.
-            assertRows(
-                    reader.visitContains(fieldRef, str("a")).join(),
-                    java.util.stream.LongStream.range(0, 200).toArray());
+            assertThat(reader.visitContains(fieldRef, 
str("a")).join()).isEmpty();
         }
     }
 
@@ -549,7 +602,9 @@ public class FMGlobalIndexTest {
     public void testCandidatePartitionPruningSkipsUnrelatedWaveletData() 
throws Exception {
         Options options = new Options();
         options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 2);
+        options.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1);
         options.set(FMGlobalIndexOptions.COMPRESSION, "none");
+        options.set(FMGlobalIndexOptions.LOCATE_COST_RATIO, 1d);
         indexer = new FMGlobalIndexer(dataField, options);
         List<GlobalIndexIOMeta> files =
                 writeData(
@@ -648,69 +703,6 @@ public class FMGlobalIndexTest {
                 .hasMessageContaining("metadata checksum mismatch");
     }
 
-    @Test
-    public void testDenseFallbackCoalescesVerificationPages() throws Exception 
{
-        Options options = new Options();
-        options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 1_000);
-        options.set(FMGlobalIndexOptions.COMPRESSION, "none");
-        indexer = new FMGlobalIndexer(dataField, options);
-        List<BinaryString> values =
-                new ArrayList<>(
-                        Collections.nCopies(
-                                512, str(String.join("", 
Collections.nCopies(2_048, "a")))));
-        List<GlobalIndexIOMeta> files = writeData(values, 0);
-
-        AtomicInteger preadCalls = new AtomicInteger();
-        fileReader =
-                meta ->
-                        new CountingVectoredInput(
-                                fileIO.newInputStream(meta.filePath()),
-                                preadCalls,
-                                new AtomicLong());
-        try (GlobalIndexReader reader = createReader(files, values.size())) {
-            assertRows(
-                    reader.visitContains(fieldRef, str("a")).join(),
-                    java.util.stream.LongStream.range(0, 
values.size()).toArray());
-        }
-        assertThat(preadCalls.get()).isLessThanOrEqualTo(10);
-    }
-
-    @Test
-    public void testSparseFallbackReadsVerificationRangesConcurrently() throws 
Exception {
-        Options options = new Options();
-        options.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 10);
-        options.set(FMGlobalIndexOptions.COMPRESSION, "none");
-        indexer = new FMGlobalIndexer(dataField, options);
-        BinaryString value = str(String.join("", Collections.nCopies(100_000, 
"a")));
-        List<GlobalIndexIOMeta> files =
-                writeData(new ArrayList<>(Collections.nCopies(6, value)), 0);
-
-        AtomicInteger concurrentPreads = new AtomicInteger();
-        fileReader =
-                meta ->
-                        new ConcurrentCountingVectoredInput(
-                                fileIO.newInputStream(meta.filePath()), 
concurrentPreads);
-        org.apache.paimon.utils.RoaringNavigableMap64 candidates =
-                new org.apache.paimon.utils.RoaringNavigableMap64();
-        candidates.add(0L);
-        candidates.add(2L);
-        candidates.add(4L);
-        try (GlobalIndexReader reader = createReader(files, 6)) {
-            org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader 
refining =
-                    
(org.apache.paimon.globalindex.ContainsRefiningGlobalIndexReader) reader;
-            assertRows(
-                    refining.visitContainsConjunction(
-                                    fieldRef,
-                                    Collections.singletonList(str("a")),
-                                    GlobalIndexResult.create(candidates))
-                            .join(),
-                    0L,
-                    2L,
-                    4L);
-        }
-        assertThat(concurrentPreads.get()).isGreaterThanOrEqualTo(2);
-    }
-
     @Test
     public void testDemandPagingDoesNotOutgrowReadCache() throws Exception {
         Options options = new Options();
@@ -739,31 +731,6 @@ public class FMGlobalIndexTest {
         
assertThat(maximumPread.get()).isLessThanOrEqualTo(MemorySize.ofKibiBytes(64).getBytes());
     }
 
-    @Test
-    public void testDenseExactFallbackVerificationCorruptionFailsClosed() 
throws Exception {
-        String repeated = String.join("", Collections.nCopies(10_000, "a"));
-        List<GlobalIndexIOMeta> files = 
writeData(Collections.singletonList(str(repeated)), 0);
-        GlobalIndexIOMeta file = files.get(0);
-        long verificationOffset;
-        try (org.apache.paimon.fs.SeekableInputStream input =
-                fileIO.newInputStream(file.filePath())) {
-            FMIndexFile.Footer footer = FMIndexFile.readFooter(input, 
file.fileSize());
-            verificationOffset =
-                    FMIndexFile.readDirectory(input, footer, file.fileSize())
-                            .verificationPages
-                            .get(0)
-                            .block
-                            .offset;
-        }
-        corruptByte(file, verificationOffset);
-
-        try (GlobalIndexReader reader = createReader(files, 1)) {
-            assertThatThrownBy(() -> reader.visitContains(fieldRef, 
str("a")).join())
-                    .isInstanceOf(CompletionException.class)
-                    .hasMessageContaining("block checksum mismatch");
-        }
-    }
-
     private List<GlobalIndexIOMeta> writeData(List<BinaryString> values, long 
firstRowId)
             throws Exception {
         FMGlobalIndexWriter writer = indexer.createWriter(fileWriter);
@@ -889,36 +856,4 @@ public class FMGlobalIndexTest {
             return vectored.pread(position, buffer, offset, length);
         }
     }
-
-    private static final class ConcurrentCountingVectoredInput extends 
SeekableInputStreamWrapper
-            implements VectoredReadable {
-
-        private final VectoredReadable vectored;
-        private final AtomicInteger maximumConcurrentPreads;
-        private final AtomicInteger currentPreads = new AtomicInteger();
-
-        private ConcurrentCountingVectoredInput(
-                SeekableInputStream input, AtomicInteger 
maximumConcurrentPreads) {
-            super(input);
-            this.vectored = (VectoredReadable) input;
-            this.maximumConcurrentPreads = maximumConcurrentPreads;
-        }
-
-        @Override
-        public int pread(long position, byte[] buffer, int offset, int length) 
throws IOException {
-            int concurrent = currentPreads.incrementAndGet();
-            maximumConcurrentPreads.accumulateAndGet(concurrent, Math::max);
-            try {
-                try {
-                    Thread.sleep(10L);
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
-                    throw new IOException("Interrupted while observing 
vectored reads.", e);
-                }
-                return vectored.pread(position, buffer, offset, length);
-            } finally {
-                currentPreads.decrementAndGet();
-            }
-        }
-    }
 }
diff --git a/paimon-common/src/test/resources/fmindex-v1-golden.base64 
b/paimon-common/src/test/resources/fmindex-v1-golden.base64
index f7efdf4dca..8b573075c0 100644
--- a/paimon-common/src/test/resources/fmindex-v1-golden.base64
+++ b/paimon-common/src/test/resources/fmindex-v1-golden.base64
@@ -1 +1 @@
-AAAAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAcAAAAAAAAAAAAAAAABVVAAAAAAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAgAAAACAAAABgAAACBVv8X1AAAAAQAAAAIAAAAAAAAABQAAAAAAAaAkAAAAEAAAAAgAAAAAAAAABAAAAAwAAAABAAAAAgAAAAAAAAABAAAAAAAAAAIAAAABAAAAAgAAAAAAAAAEAAAAAAADAMAAAAAGYmFuYW5h/////wAAAAgA/2JhbmFuYQAAAAAAAAAEAAAAEwAAAAQAAAACAAAABwAAEAAAAAAC////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
 [...]
+AAAAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAcAAAAAAAAAAAAAAAABVVAAAAAAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAgAAAACAAAABgAAACBVv8X1AAAAAQAAAAIAAAAAAAAABQAAAAAAAaAkAAAAEAAAAAgAAAAAAAAABAAAAAwAAAABAAAAAgAAAAAAAAABAAAAAAAAAAIAAAABAAAAAgAAAAAAAAAEAAAAAAADAMAAAAAEAAAAEwAAAAQAAAACAAAABwAAEAAAAAAC////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
 [...]
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeySortedIndexTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeySortedIndexTest.scala
index 228a925348..01beef921a 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeySortedIndexTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeySortedIndexTest.scala
@@ -33,6 +33,7 @@ class PrimaryKeySortedIndexTest extends PaimonSparkTestBase {
 
   test("primary-key FM index supports exact contains with partitioned 
container") {
     withTable("t") {
+      // Force this tiny fixture through FM locate; production defaults may 
prefer a source scan.
       spark.sql("""
                   |CREATE TABLE t (id INT, content STRING)
                   |TBLPROPERTIES (
@@ -41,7 +42,7 @@ class PrimaryKeySortedIndexTest extends PaimonSparkTestBase {
                   |  'deletion-vectors.enabled' = 'true',
                   |  'pk-fm.index.columns' = 'content',
                   |  'fields.content.pk-fm.index.options' =
-                  |    '{"partition-row-count":"2"}'
+                  |    
'{"partition-row-count":"2","sa-sample-rate":"1","locate-cost-ratio":"1"}'
                   |)
                   |""".stripMargin)
       spark.sql("""

Reply via email to