utafrali commented on code in PR #9450:
URL: https://github.com/apache/paimon/pull/9450#discussion_r3880631446
##########
paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexFile.java:
##########
@@ -1209,20 +1218,28 @@ private static int[] waveletStarts(int[] cumulative,
int levelCount) {
}
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,
Review Comment:
The switch from `crc32c` (Castagnoli) to standard IEEE CRC32 produces
completely different values for the same input — any file written by the old
code will fail checksum verification under the new code, and vice versa. The
`VERSION` constant is written to the footer but does not appear to have been
incremented here.
If in-flight or already-written files exist (even in CI/CD artifact caches),
readers will reject them with a spurious checksum mismatch that is nearly
impossible to diagnose. Either bump `VERSION` so the reader can detect and
reject old-format files with a clear error message, or add an explicit note in
the class Javadoc that this change is only safe because no stable release
shipped the CRC32C format.
Suggested minimum: `private static final int VERSION = 2;` paired with a
guard in `readFooter` and `readContainerFooter` that rejects old version
numbers with a migration hint.
##########
paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMIndexReadContext.java:
##########
@@ -32,6 +32,11 @@
final class FMIndexReadContext {
private static final int DEFAULT_MAX_CONCURRENT_FILE_READS = 8;
+ // A locate must at least be able to retain one decoded rank/sample block.
Otherwise every
Review Comment:
The `MIN_LOCATE_CACHE_BYTES` formula is correct but opaque. The two additive
terms correspond to different block types and it is not clear which is which.
Consider naming the components:
```java
// One decoded rank block: BLOCK_WORDS longs.
long rankBlockBytes = FMIndexFile.BLOCK_WORDS * Long.BYTES;
// One decoded sample block: one int per 64-bit word of the sampled-SA
bitmask, plus one overflow slot.
long sampleBlockBytes = ((FMIndexFile.BLOCK_WORDS + 63L) / 64L + 1L) * 4L *
Integer.BYTES;
private static final long MIN_LOCATE_CACHE_BYTES = rankBlockBytes +
sampleBlockBytes;
```
This makes the intent clear without changing behaviour.
##########
paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexWriter.java:
##########
@@ -93,12 +96,14 @@ public void write(@Nullable Object key, long relativeRowId)
{
"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);
+ if (storeVerificationValues) {
Review Comment:
The value-length guard (`bytes.length <=
MAX_VERIFICATION_BLOCK_UNCOMPRESSED_LENGTH - Integer.BYTES`) is now skipped
when `storeVerificationValues` is false. This is intentional — values that will
never be packed into verification blocks have no length limit — but a follow-up
`checkArgument` or comment on the outer block would make the intent clear to
the next reader:
```java
if (storeVerificationValues) {
// Values must fit in a single verification block together with their
4-byte length prefix.
Preconditions.checkArgument(
bytes.length <= ...,
"A value exceeds the FM index exact-fallback block limit (%s
bytes).",
...);
}
```
Without a comment someone may re-add the check unconditionally thinking it
was accidentally omitted.
##########
paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java:
##########
@@ -181,6 +181,22 @@ private Optional<GlobalIndexResult> queryUnchecked(
current.footer.firstRowId +
current.footer.rowCount)) {
return exactEmptyResult();
}
+ boolean hasNonEmptyNeedle = false;
Review Comment:
`hasNonEmptyNeedle |= needle.length > 0;` uses bitwise-OR assignment on a
`boolean`. This works in Java but does not short-circuit: once
`hasNonEmptyNeedle` is `true` the remaining iterations still execute. For
typical needle counts the cost is negligible, but the idiom is non-standard and
surprises readers.
Prefer the explicit short-circuit form:
```java
if (needle.length > 0) {
hasNonEmptyNeedle = true;
break;
}
```
##########
paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java:
##########
@@ -469,6 +476,102 @@ public void testReadCostOptionsAreValidated() {
.hasMessageContaining("demand page size");
}
+ @Test
+ public void testZeroReadCacheDeclinesLocateInsteadOfRereadingBlocks()
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);
+ GlobalIndexIOMeta file = files.get(0);
+ long sampleBlockOffset;
+ try (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, 1)) {
+ // With sufficient cache this selective interval uses SA locate
and observes the
+ // corrupted sample. A zero cache must take the exact-value path
without LF rereads.
+ assertRows(reader.visitContains(fieldRef,
str("unique-needle")).join(), 0L);
+ }
+ }
+
+ @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(20);
+ }
+
+ @Test
+ public void testVerificationValuesCanBeOmitted() throws Exception {
Review Comment:
`assertThat(preadCalls.get()).isLessThan(20)` is a very loose bound. With 1
000 rows and `BLOCK_BITS = 4096 * 64 = 262 144`, all 1 000 null-bitmap bits fit
in a single bit block, so the block-wise scan should produce a handful of
physical reads (one seek + read per block, plus directory/footer reads). A
bound of, say, 10 would still give the test room to breathe on all compression
configurations while actually catching a regression to the old row-by-row path
(which would produce ~1 000 reads). Consider tightening the assertion.
##########
paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexReader.java:
##########
@@ -273,9 +292,12 @@ private boolean shouldLocate(
Metadata metadata,
SearchInterval interval,
Review Comment:
The new early-return path when `!readContext.supportsLocate()` in
`shouldLocate()` is correct, but the surrounding cost calculation (`locateCost`
vs `verificationBytes` comparison) is now only reached when locate IS supported
AND verification pages are non-empty. A brief comment would clarify why the two
conditions were added and how they interact:
```java
// locate requires sufficient cache; skip cost comparison if we cannot
locate at all.
if (!readContext.supportsLocate()) {
return false;
}
// Cost comparison only applies when stored values are available for
fallback.
if (candidates != null && !directory.verificationPages.isEmpty()) {
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]