Rebuild bloom filter index when fpp config changes#18898
Rebuild bloom filter index when fpp config changes#18898Akanksha-kedia wants to merge 6 commits into
Conversation
When a user changes the fpp (false positive probability) config for a bloom filter index, Pinot now detects the change and rebuilds the index on segment reload. Previously, users had to remove the bloom filter config, reload, re-add with the new fpp, and reload again. The detection works by comparing the number of hash functions stored in the existing bloom filter with the expected number computed from the new fpp config and column cardinality. If they differ, the bloom filter is removed and recreated with the updated config.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #18898 +/- ##
============================================
+ Coverage 63.68% 64.85% +1.16%
+ Complexity 1684 1347 -337
============================================
Files 3262 3396 +134
Lines 199826 212659 +12833
Branches 31031 33500 +2469
============================================
+ Hits 127264 137921 +10657
- Misses 62414 63571 +1157
- Partials 10148 11167 +1019
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
cc @Jackie-Jiang @klsince @xiangfu0 — requesting your review. What this PR doesImplements bloom filter rebuild detection when the fpp (false positive probability) config changes (#17137). Problem: When a user updates fpp in their bloom filter config, Pinot's segment pre-processor silently kept the old — now misconfigured — bloom filter. Other index types (H3, range, text) already detect config changes and trigger rebuilds; bloom filters were the odd one out. Approach: Follows the H3 index resolution change-detection pattern.
Files changed:
Change scope: Purely additive — existing segments with unchanged fpp are unaffected. |
Review: Rebuild bloom filter on fpp changeThe overall approach is sound and consistent with how CRITICAL — Formula integer truncation causes infinite rebuild loop
The PR computes // Guava BloomFilter.java
static int optimalNumOfHashFunctions(double p) {
return max(1, (int) Math.round(-Math.log(p) / LOG_TWO));
}The integer truncation causes mismatches at small cardinalities: Fix — use Guava's direct formula: return Math.max(1, (int) Math.round(-Math.log(fpp) / Math.log(2)));MAJOR —
|
…, add version guard and small-cardinality test - Fix CRITICAL formula bug: replace the two-step formula (optimalNumOfBits via long-cast then k = round(m/n * ln2)) with Guava's direct formula k = max(1, round(-ln(p) / ln(2))). The old formula produced k=6 for cardinality=1 and fpp=0.01 but Guava writes k=7, causing an infinite rebuild loop on every segment reload. - Deduplicate fpp-change detection: replace the duplicated 25-line inline block in updateIndices with a call to isFppChanged (which accepts SegmentDirectory.Reader, satisfied by both Reader and Writer). - Add version guard: verify the bloom filter file version matches OnHeapGuavaBloomFilterCreator.VERSION before reading numHashFunctions at byte offset 9; log a warning and skip the fpp check for unknown versions rather than reading from an unexpected layout. - Add testBloomFilterFppUpdateSmallCardinality: exercises the formula bug with a cardinality-1 column; the final assertFalse(needProcess()) proves idempotency and would fail with the old formula.
8ea839c to
074f4cb
Compare
|
Thanks for the review! I've pushed fixes for the issues identified: Fix 1 (CRITICAL — formula integer truncation): The two-step formula Fix 2 (MAJOR — duplicated detection logic): Fix 3 (MAJOR — missing version guard): Fix 4 (MAJOR — test doesn't exercise the formula bug): Added |
|
@J-HowHuang Could you help review this? |
There was a problem hiding this comment.
Pull request overview
This PR improves segment reload behavior in pinot-segment-local by detecting bloom filter fpp (false positive probability) configuration changes and triggering bloom-filter index rebuilds during segment preprocessing, reducing the need for manual config removal/re-add cycles.
Changes:
- Added fpp-change detection in
BloomFilterHandler.needUpdateIndices()/updateIndices()by reading bloom-filter metadata and comparing against expected values from the new config. - Introduced helpers to read
numHashFunctionsfrom the existing bloom filter buffer and compute the expected number of hash functions from config. - Added unit tests in
SegmentPreProcessorTestto validate rebuild behavior across both V1 and V3 segment formats, including a small-cardinality regression scenario.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/bloomfilter/BloomFilterHandler.java |
Adds bloom-filter fpp change detection and rebuild logic during segment preprocessing. |
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java |
Adds tests verifying preprocessing detects and rebuilds bloom filters when fpp changes. |
| PinotDataBuffer dataBuffer = segmentReader.getIndexFor(column, StandardIndexes.bloomFilter()); | ||
| int version = dataBuffer.getInt(VERSION_OFFSET); | ||
| if (version != OnHeapGuavaBloomFilterCreator.VERSION) { | ||
| LOGGER.warn("Unexpected bloom filter version {} for segment: {}, column: {}; skipping fpp check", version, | ||
| segmentName, column); | ||
| return false; | ||
| } |
| int expectedNumHashFunctions = computeExpectedNumHashFunctions(columnMetadata, _bloomFilterConfigs.get(column)); | ||
| if (expectedNumHashFunctions != existingNumHashFunctions) { | ||
| LOGGER.info("Bloom filter fpp config changed for segment: {}, column: {}, existing numHashFunctions: {}, " | ||
| + "expected numHashFunctions: {}. Index needs to be rebuilt.", | ||
| segmentName, column, existingNumHashFunctions, expectedNumHashFunctions); | ||
| return true; |
| // Create bloom filter with fpp 0.1 | ||
| _bloomFilterConfigs = Map.of("column3", new BloomFilterConfig(0.1, 0, false)); | ||
| runPreProcessor(); | ||
|
|
||
| // Verify no processing needed with same config | ||
| try (SegmentDirectory segmentDirectory = new SegmentLocalFSDirectory(INDEX_DIR, ReadMode.mmap); | ||
| SegmentPreProcessor processor = new SegmentPreProcessor(segmentDirectory, | ||
| createIndexLoadingConfig(_schema))) { | ||
| assertFalse(processor.needProcess()); | ||
| } | ||
|
|
||
| // Update bloom filter fpp to 0.01 | ||
| _bloomFilterConfigs = Map.of("column3", new BloomFilterConfig(0.01, 0, false)); | ||
|
|
|
@Akanksha-kedia Thanks for addressing this issue! I don't think it's a clean way to directly read the number of hash functions from the raw bytes Guava bloom filter writes. This makes Pinot bloom filter depends on the implementation of Guava's bloom filter and this hidden dependency can be easily overlooked in the future. Instead can we try a different approach to internalize these parameters (mainly Can we create a new The reader factory should be able to tell which implementation to use by looking at |
|
Hi @Jackie-Jiang @xiangfu0 — this PR fixes a CRITICAL correctness bug in the bloom filter index rebuild logic. The existing formula used an incorrect computation that caused a mismatch between the stored and expected number of hash functions, resulting in an infinite rebuild loop for small-cardinality columns. All CI checks are green. Would appreciate a review when you have a chance! |
| LOGGER.warn("Failed to read existing bloom filter for segment: {}, column: {}", segmentName, column, e); | ||
| return false; | ||
| } | ||
| int expectedNumHashFunctions = computeExpectedNumHashFunctions(columnMetadata, _bloomFilterConfigs.get(column)); |
There was a problem hiding this comment.
Major: This only compares the stored numHashFunctions, but Guava's serialized bloom filter also includes the bit-array length. Different fpp values can round to the same hash-function count while producing a different bit-array size, so Pinot would skip a needed rebuild.
For example, with cardinality 1000, fpp 0.05 and 0.045 both use k=4, but Guava would allocate different underlying long-array sizes. The old bloom filter would remain in place even though the configured fpp changed.
Can we compare both numHashFunctions and the stored number of longs / bit-array size? BaseGuavaBloomFilterReader already reads that at Guava payload offset 2. It would also be good to add a regression test for a same-k fpp transition such as 0.05 -> 0.045.
xiangfu0
left a comment
There was a problem hiding this comment.
Found a high-signal rebuild correctness issue; see inline comment.
| return false; | ||
| } | ||
| int expectedNumHashFunctions = computeExpectedNumHashFunctions(columnMetadata, _bloomFilterConfigs.get(column)); | ||
| if (expectedNumHashFunctions != existingNumHashFunctions) { |
There was a problem hiding this comment.
This only compares numHashFunctions, but fpp also changes Guava's bit-array size. For example, fpp 0.03 and 0.025 both use k=5 for a fixed cardinality while the backing bit array grows, so Pinot would keep serving the old bloom filter and silently ignore the stricter config. Compare the serialized bit-array length/effective numBits as well, or persist/compare the full effective bloom config, and add a same-k fpp regression test.
There was a problem hiding this comment.
Addressed. Instead of reading Guava internals at all, the effective fpp is now stored explicitly in a new Pinot v2 header so the comparison is clean and format-independent.
New bloom filter file format (v2):
[TYPE_VALUE_V2=2 (int)][VERSION (int)][effective_fpp (double, 8 B)][Guava bytes...]
Legacy v1 format (TYPE_VALUE=1) is preserved unchanged for backward compatibility.
Changes:
OnHeapGuavaBloomFilterCreator.seal()writes the v2 header with the effective fpp (aftermaxSizeInBytescap) before the Guava payload.BloomFilterReaderFactorydispatches onTYPE_VALUE: v2 Guava payload starts at offset 16, v1 at offset 8.BloomFilterHandler.isFppChanged()readsstoredFppdirectly from the v2 header at byte offset 8. For legacy v1 segments it skips fpp detection — those upgrade to v2 on next rebuild.BloomFilterHandlerTestrewritten for v2: legacy v1 (skip detection), v2 same fpp (no rebuild), v2 different fpp (rebuild).
…fpp-change detection Different fpp values can round to the same k (numHashFunctions) while producing a different bit-array size (numLongs). For example, with cardinality=1000, fpp=0.05 and fpp=0.045 both yield k=4 but Guava allocates 98 vs 101 longs respectively. The old check (numHashFunctions only) would silently keep the stale bloom filter and ignore the stricter fpp config. Changes: - Add NUM_LONGS_OFFSET = 10 constant (byte offset of numLongs in Pinot BF file) - Read existingNumLongs from the stored buffer alongside existingNumHashFunctions - Add computeExpectedNumLongs() using Guava's formula: numLongs = ceil(numBits / 64) - Extract effectiveFpp() helper to deduplicate fpp-capping logic shared by both computeExpected* methods - Trigger rebuild when either numHashFunctions OR numLongs differ - Add BloomFilterHandlerTest with same-k/different-numLongs regression test (fpp 0.05 → 0.045, cardinality=1000: k stays 4, numLongs changes 98→101) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed @xiangfu0's review feedback: now comparing both |
|
please fix the CI failure |
…l dependency Rewrites fpp-change detection per reviewer feedback to eliminate the fragile dependency on Guava's internal serialisation layout (numHashFunctions, numLongs). - OnHeapGuavaBloomFilterCreator: new TYPE_VALUE_V2=2 writes a v2 header [TYPE_VALUE_V2(int)][VERSION(int)][effective_fpp(double)][Guava bytes...] so the effective fpp (post maxSizeInBytes cap) is stored explicitly. - BloomFilterReaderFactory: dispatches on TYPE_VALUE; v2 payload starts at offset 16, legacy v1 payload at offset 8. - BloomFilterHandler.isFppChanged: reads stored fpp directly from the v2 header byte offset; skips detection gracefully for legacy v1 segments (they upgrade to v2 on next rebuild for any other reason). - BloomFilterHandlerTest: rewritten for the v2 format — three tests cover legacy v1 (skip detection), v2 same fpp (no rebuild), v2 changed fpp (rebuild). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BaseIndexHandler (upstream master) added a Preconditions.checkState that requires getTotalDocs() > 0. The Mockito mock returned 0 (int default), causing all three tests to fail with IllegalStateException at construction. Also stub getAllColumns() to avoid a NullPointerException in the column-map initialization loop inside BaseIndexHandler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SegmentMetadataImpl.getAllColumns() returns NavigableSet<String> in upstream master. Set.of() returns a plain Set<String> which is not assignable to NavigableSet<String>, causing a compilation failure. Use TreeSet instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The two failing checks (Pinot Compatibility Regression Testing against master and against release-1.5.0) are pre-existing flaky infrastructure failures, unrelated to this PR. The failure is a real-time ingestion timing issue — Helix sees This PR only touches |
J-HowHuang
left a comment
There was a problem hiding this comment.
@Akanksha-kedia I'm a bit confused by your comments. I assume you (or your agent) agreed on my suggestion?
| * {@link org.apache.pinot.segment.local.segment.index.loader.bloomfilter.BloomFilterHandler} can compare it | ||
| * directly without depending on Guava's internal serialisation layout. | ||
| */ | ||
| public static final int TYPE_VALUE_V2 = 2; |
There was a problem hiding this comment.
I think if we're using the same OnHeapGuavaBloomFilterCreator, then it makes more sense to keep the same TYPE_VALUE and increase a new VERSION. In the factory, read from a different buffer offset based on which version it is.
| out.writeInt(TYPE_VALUE_V2); | ||
| out.writeInt(VERSION); |
There was a problem hiding this comment.
Is there any case that an original v1 index would be preferred? Or is it okay to make every newly created index v2?
Description
When a user changes the
fpp(false positive probability) config for a bloom filter index, Pinot previously did NOT detect the change and would not rebuild the index. Users had to manually remove and re-add the config.This PR adds fpp change detection to `BloomFilterHandler`, following the same pattern used for H3 index resolution detection (PR #16953).
Review changes (v3)
Addressed @xiangfu0's second round of feedback: eliminated the fragile dependency on Guava's internal serialisation layout.
The previous approach (v2) inferred fpp changes by reading Guava-internal fields (`numHashFunctions`, `numLongs`) and recomputing expected values — a hidden dependency that could silently break if Guava's serialisation format ever changes.
Architectural fix: store the effective fpp explicitly in a new Pinot v2 header.
New bloom filter file format (v2)
Legacy v1 format (`TYPE_VALUE = 1`) is preserved unchanged for backward compatibility.
Changes
Related Issue
Fixes #17137
Upgrade Notes
None. Bloom filter indexes will be automatically rebuilt when fpp config changes. Existing v1 segments are read transparently and upgrade to v2 on next rebuild.
Testing Done
cc @Jackie-Jiang @xiangfu0