Fix FULL-upsert vector candidate generation - #19287
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19287 +/- ##
============================================
+ Coverage 67.09% 67.20% +0.11%
Complexity 1424 1424
============================================
Files 3459 3459
Lines 219789 220166 +377
Branches 35007 35110 +103
============================================
+ Hits 147457 147962 +505
+ Misses 60543 60377 -166
- Partials 11789 11827 +38
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:
|
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Fixes FULL-upsert vector candidate generation so per-segment top-K selection cannot be “consumed” by obsolete physical versions before the query-scoped upsert snapshot is applied, with consistent behavior across ANN, exact-scan fallback, radius predicates, and mutable HNSW.
Changes:
- Make upsert snapshot (and mutable publication boundary) a mandatory candidate-generation scope for vector predicates, with clear fallback/skip behavior and added explain attributes.
- Add filter-aware behavior to mutable HNSW (Lucene) including explicit Pinot doc IDs and near-real-time reader usage.
- Expand unit/integration coverage for upsert-scoped vector queries, fallback paths, and boolean-plan interactions.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java | Adds a filtered-reader test to ensure top-K results respect an allowed-doc bitmap. |
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java | Adds tests for mutable HNSW Pinot-ID translation, filtered search semantics, NRT visibility, and async bitmap capture. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java | Implements filter-aware mutable HNSW with explicit Pinot doc IDs, NRT reader search, and per-instance temp dirs. |
| pinot-segment-local/pom.xml | Adds provided JetBrains annotations dependency. |
| pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java | New integration test validating FULL-upsert vector correctness across engine modes and segment states. |
| pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java | Adds extensive plan-level tests for mandatory scopes, vector subtree safety, and fallback behavior. |
| pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java | Updates strategy tests to allow pre-filter when the reader supports it (even for mutable segments). |
| pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorRadiusFilterOperatorTest.java | Adds tests for mandatory-scope exact allowed scans and skip behavior. |
| pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java | Adds tests for required upsert scope behavior, bitmap intersections, explain attributes, and exact fallback. |
| pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java | Adds tests ensuring exact scan respects allowed-doc scope and intersections. |
| pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BaseFilterOperatorTest.java | Adds a test ensuring materialized boolean children aren’t scanned twice and accounting is preserved. |
| pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java | Introduces mandatory vector candidate scope plumbing, safe metadata materialization, and filtered/fallback routing. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java | Adds mandatory-scope + metadata-scope intersection, exact-scan fallback, skip reasons, and explain attributes. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java | Adjusts decision logic: pre-filter eligibility is capability-based (not mutability-based). |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorRadiusFilterOperator.java | Enforces mandatory-scope exact scans for radius predicates; adds explain attributes + skip state. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java | Switches boolean execution to reuse materialized bitmaps via getTruesForExecution(). |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java | Switches false-set derivation to reuse cached materialization via getTruesForExecution(). |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java | Adds mandatory scope restriction, metadata-scope intersection, and shared exact-scan scoring utility. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java | Introduces getTruesForExecution() to avoid re-scanning children after materialization. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java | Switches boolean execution to reuse materialized bitmaps via getTruesForExecution(). |
Suppressed comments (1)
pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:1
- retainedChildFilters and childFilterOperators can become misaligned when a child operator is empty. In the isResultEmpty() branch, you add the operator but do not add the corresponding childFilter to retainedChildFilters before breaking, so wirePreFilterForVectorOperators(...) can throw IndexOutOfBoundsException when it assumes parallel lists. Fix by keeping these lists in lockstep (e.g., add childFilter to retainedChildFilters whenever you add an operator, including the empty case), or by passing a single zipped structure instead of two separate lists.
/**
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
2c163f0 to
6a12924
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (6)
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java:273
computeExactMatches()can NPE whentopK == 0(or behave incorrectly for non-positive K): the heap is created, butscoreDocument()will eventually callmaxHeap.peek()on an empty heap. Since this method is now used as a correctness fallback for mandatory candidate scopes, it should defensively handletopK <= 0by returning an empty bitmap whendistanceThreshold == null(top‑K semantics), and avoid constructing/using the heap in that case.
@SuppressWarnings({"rawtypes", "unchecked"})
static ImmutableRoaringBitmap computeExactMatches(ForwardIndexReader<?> forwardIndexReader,
float[] queryVector, int topK, int numDocs,
VectorIndexConfig.VectorDistanceFunction distanceFunction, @Nullable Float distanceThreshold,
@Nullable ImmutableRoaringBitmap allowedDocIds, String column) {
if (allowedDocIds != null && allowedDocIds.isEmpty()) {
return new MutableRoaringBitmap();
}
PriorityQueue<DocDistance> maxHeap = distanceThreshold == null
? new PriorityQueue<>(topK + 1, (a, b) -> Float.compare(b._distance, a._distance)) : null;
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:159
- This stores the Pinot doc id twice (DocValues + StoredField) and then loads a stored
Documentper hit to translate Lucene doc IDs back to Pinot doc IDs. For high QPS / largertopK, the stored-fields IO/object allocation can add noticeable overhead and the duplicated storage increases index size. Consider translating viaNumericDocValuesinstead (using leaf contexts +advanceExact) so only DocValues are needed, or at least loading only the single stored field via aStoredFieldVisitor/field selector to avoid materializing fullDocumentinstances.
// Store the supplied Pinot document ID in both forms. The numeric doc value drives filtered traversal, while the
// stored field translates Lucene search hits back to Pinot IDs from the same near-real-time reader generation.
docToIndex.add(new NumericDocValuesField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, docId));
docToIndex.add(new StoredField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, docId));
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:306
- This stores the Pinot doc id twice (DocValues + StoredField) and then loads a stored
Documentper hit to translate Lucene doc IDs back to Pinot doc IDs. For high QPS / largertopK, the stored-fields IO/object allocation can add noticeable overhead and the duplicated storage increases index size. Consider translating viaNumericDocValuesinstead (using leaf contexts +advanceExact) so only DocValues are needed, or at least loading only the single stored field via aStoredFieldVisitor/field selector to avoid materializing fullDocumentinstances.
private static MutableRoaringBitmap translateTopDocs(DirectoryReader directoryReader, TopDocs topDocs)
throws IOException {
MutableRoaringBitmap docIds = new MutableRoaringBitmap();
StoredFields storedFields = directoryReader.storedFields();
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
Document document = storedFields.document(scoreDoc.doc);
IndexableField docIdField = document.getField(VECTOR_INDEX_DOC_ID_COLUMN_NAME);
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java:211
- This test assumes the
RealtimeLuceneTextIndexSearcherPoolexecutor will serialize tasks such that submittingblockerwill prevent the subsequent vector search from running untilreleaseSearcheris counted down. If the pool has a parallelism > 1, the search may execute on a different thread immediately, making the “queued search uses captured state” guarantee non-deterministic and potentially flaky. A more robust approach is to either (a) configure the pool to a single thread for this test, or (b) saturate the pool with enough blockers to cover its parallelism, or (c) introduce a deterministic hook/latch in the index/search path under test to control when execution begins.
public void testFilteredSearchCopiesBitmapBeforeAsyncDispatch()
throws Exception {
MutableVectorIndex index = createEmptyIndex(createConfig(1, 3_600_000L));
ExecutorService callerExecutor = Executors.newSingleThreadExecutor();
CountDownLatch searcherBlocked = new CountDownLatch(1);
CountDownLatch releaseSearcher = new CountDownLatch(1);
Future<?> blocker = RealtimeLuceneTextIndexSearcherPool.getInstance().getExecutorService().submit(() -> {
searcherBlocked.countDown();
try {
releaseSearcher.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:522
wirePreFilterForVectorOperators()relies onchildFiltersandchildOperatorsbeing perfectly index-aligned. That coupling is subtle (especially because callers now pass a “retained” filter list after pruning match-all operators), and an accidental mismatch would surface as anIndexOutOfBoundsExceptionat runtime. Adding a defensive precondition (e.g., size equality) at method entry will fail fast with a clearer message and reduce future maintenance risk.
private void wirePreFilterForVectorOperators(List<FilterContext> childFilters,
List<BaseFilterOperator> childOperators, int numDocs) {
if (childOperators.size() < 2) {
return;
}
pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:534
wirePreFilterForVectorOperators()relies onchildFiltersandchildOperatorsbeing perfectly index-aligned. That coupling is subtle (especially because callers now pass a “retained” filter list after pruning match-all operators), and an accidental mismatch would surface as anIndexOutOfBoundsExceptionat runtime. Adding a defensive precondition (e.g., size equality) at method entry will fail fast with a clearer message and reduce future maintenance risk.
for (int i = 0; i < childOperators.size(); i++) {
FilterContext childFilter = childFilters.get(i);
BaseFilterOperator op = childOperators.get(i);
Summary
Fix FULL-upsert vector queries so obsolete physical versions cannot consume the per-segment top-K candidate budget before Pinot applies the query-scoped upsert snapshot.
Root cause
FilterPlanNodeconstructed and executedVECTOR_SIMILARITYbefore addingSegmentContext.getDocIdsSnapshot()as an outer AND. Obsolete versions could therefore occupy ANN top-K slots and be removed only afterward, producing fewer than K rows or omitting nearer current rows.Validation
All commands ran with JDK 25 and
GITHUB_ACTIONS=true.VectorUpsertTableTest: 2 query-engine invocations passed, covering both a single consuming segment and sealed-plus-consuming segments.clean test-compilewith-Xlint:allpassed.git diff --checkpassed.The integration test verifies six physical records/four current records, exact K and entity membership, scalar-distance equivalence, both query engines, two replicas, and a
skipUpsert=truecontrol proving obsolete rows are physically nearest.Performance and compatibility
ANN approximation semantics are unchanged. Readers that cannot honor the mandatory bitmap use a correctness-first exact scan whose cost is proportional to the allowed-document count times vector dimension. Mutable HNSW currently opens an NRT reader per search; reusable reader management can be optimized separately.