Jackie-Jiang commented on code in PR #19303:
URL: https://github.com/apache/pinot/pull/19303#discussion_r3890217425


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:
##########
@@ -90,13 +127,11 @@ public MutableVectorIndex(String segmentName, String 
vectorColumn, VectorIndexCo
     _commitDocs = Long.parseLong(
         vectorIndexConfig.getProperties().getOrDefault("commitDocs", 
String.valueOf(DEFAULT_COMMIT_DOCS)));
     _vectorSimilarityFunction = 
VectorIndexUtils.toSimilarityFunction(vectorIndexConfig.getVectorDistanceFunction());
-    // Each column of a segment gets its own directory, so that cleaning up 
one column does not remove the index of
-    // another column of the same segment.
-    _indexDir = new File(new File(FileUtils.getTempDirectory(), segmentName),
-        _vectorColumn + 
V1Constants.Indexes.VECTOR_V912_HNSW_INDEX_FILE_EXTENSION);
+    _indexDir = createIndexDir(segmentName, _vectorColumn);

Review Comment:
   The per-instance directory isolation change fixes an independent 
write-lock/lifecycle problem and has its own dedicated test. This PR is already 
a large correctness-sensitive filtered-ANN change; please move directory 
isolation into a focused follow-up PR unless it is required for the mutable 
pre-filter capability.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:
##########
@@ -279,18 +450,35 @@ public void close() {
     try {
       _indexWriter.commit();
       // IndexWriter does not close the Directory passed to it, so both need 
to be closed.
-      IOUtils.close(_indexWriter, _indexDirectory);
+      IOUtils.close(_searcherManager, _indexWriter, _indexDirectory);
     } catch (IOException e) {
-      // Both close() implementations are idempotent, so this is a no-op for 
whatever was already closed above.
-      IOUtils.closeWhileHandlingException(_indexWriter, _indexDirectory);
+      // All close() implementations are idempotent, so this is a no-op for 
whatever was already closed above.
+      IOUtils.closeWhileHandlingException(_searcherManager, _indexWriter, 
_indexDirectory);
       throw new RuntimeException(e);
     } finally {
       deleteIndexDir();
     }
   }
 
-  /// Deletes the temporary index directory of this column, then the segment 
directory holding it if this was the last
-  /// column with an index under it.
+  /// Creates a private directory for this index instance, under a directory 
named for the segment.
+  ///
+  /// The directory is unique per instance rather than per segment and column, 
so two replicas of the same segment
+  /// hosted in one JVM cannot collide on the Lucene write lock. Segment and 
column stay in the path so a leaked
+  /// directory can still be attributed to its owner.
+  private static File createIndexDir(String segmentName, String column) {
+    File segmentDir = new File(FileUtils.getTempDirectory(), segmentName);
+    try {
+      Files.createDirectories(segmentDir.toPath());
+      return Files.createTempDirectory(segmentDir.toPath(),
+          column + V1Constants.Indexes.VECTOR_V912_HNSW_INDEX_FILE_EXTENSION + 
'_').toFile();
+    } catch (IOException e) {
+      throw new RuntimeException(
+          "Failed to create mutable vector index directory for column: " + 
column + ", segment: " + segmentName, e);
+    }
+  }
+
+  /// Deletes the temporary index directory of this instance, then the segment 
directory holding it if this was the

Review Comment:
   Deleting the shared segment parent races with construction of another index 
for the same segment. T2 can create/observe the parent, T1 can delete it after 
removing its private child, and then T2's `createTempDirectory(segmentDir, 
...)` fails with `NoSuchFileException`. Please delete only this instance's 
private `_indexDir`, or coordinate parent creation/deletion atomically.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/IvfPqVectorIndexReader.java:
##########
@@ -217,6 +217,13 @@ public MutableRoaringBitmap getDocIds(float[] searchQuery, 
int topK) {
     return result;
   }
 
+  @Override
+  public boolean supportsPreFilter() {

Review Comment:
   This explicit pre-filter opt-in is behaviorally significant now that the 
interface default is `false`, but IVF_PQ has no test covering the override or 
filtered path. Please add coverage asserting `supportsPreFilter()` and 
verifying that filtered search excludes nearer disallowed documents, or an 
integration query whose EXPLAIN reports `FILTER_THEN_ANN`.



##########
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java:
##########
@@ -80,12 +84,180 @@ public void testRuntimeControlDebugInfoReflectsOverrides() 
{
       Assert.assertEquals(debugInfo.get("effectiveEfSearch"), 6);
       Assert.assertEquals(debugInfo.get("effectiveHnswUseRelativeDistance"), 
Boolean.FALSE);
       Assert.assertEquals(debugInfo.get("effectiveHnswUseBoundedQueue"), 
Boolean.FALSE);
-      Assert.assertEquals(debugInfo.get("supportsPreFilter"), Boolean.FALSE);
+      Assert.assertEquals(debugInfo.get("supportsPreFilter"), Boolean.TRUE);
+      Assert.assertTrue(index.supportsPreFilter(),
+          "The reader must advertise filtered search: that is what makes the 
planner choose it over an exact scan");
     } finally {
       index.close();
     }
   }
 
+  // -----------------------------------------------------------------------
+  // Filtered search (upsert doc-ids snapshot enforcement)
+  // -----------------------------------------------------------------------
+
+  /// 2-D corpus with distinct distances from the query vector {1, 0}:
+  /// docs 0 and 1 are nearest (the "upsert-obsoleted" rows), docs 2 and 3 are 
the valid rows.
+  private static MutableVectorIndex create2DIndex(long commitDocs, int 
docIdOffset) {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", String.valueOf(commitDocs));
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "2");
+    VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 2, 1,
+        VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+    MutableVectorIndex index =
+        new MutableVectorIndex("mutableVectorIndexFilterTest_" + 
System.nanoTime(), COLUMN_NAME, config);
+    addVector(index, new float[]{1.0F, 0.0F}, docIdOffset);
+    addVector(index, new float[]{0.99F, 0.01F}, docIdOffset + 1);
+    addVector(index, new float[]{0.0F, 1.0F}, docIdOffset + 2);
+    addVector(index, new float[]{0.0F, -1.0F}, docIdOffset + 3);
+    return index;
+  }
+
+  @Test
+  public void testFilteredSearchExcludesNearestDisallowedDocs() {
+    // commitDocs=4 commits on the 4th add, so the unfiltered committed-view 
sanity check below sees all rows
+    MutableVectorIndex index = create2DIndex(4, 0);
+    try {
+      // Sanity: unfiltered top-2 returns the physically nearest ("obsolete") 
docs 0 and 1
+      ImmutableRoaringBitmap unfiltered = index.getDocIds(new float[]{1.0F, 
0.0F}, 2);
+      Assert.assertEquals(unfiltered, ImmutableRoaringBitmap.bitmapOf(0, 1));
+
+      // Filtered top-2 restricted to docs 2 and 3 must return exactly those 
docs. A post-intersection
+      // implementation would return empty here (the unfiltered top-2 has no 
overlap with the allowed set),
+      // so this assertion genuinely discriminates filtered candidate 
generation.
+      ImmutableRoaringBitmap filtered =
+          index.getDocIds(new float[]{1.0F, 0.0F}, 2, 
ImmutableRoaringBitmap.bitmapOf(2, 3));
+      Assert.assertEquals(filtered, ImmutableRoaringBitmap.bitmapOf(2, 3),
+          "Filtered search must return the allowed docs, not the nearest 
disallowed ones");
+    } finally {
+      index.close();
+    }
+  }
+
+  @Test
+  public void testConcurrentFilteredSearchWithLiveWriter()
+      throws Exception {
+    // Single writer, concurrent reader: filtered searches must stay correct 
(results always a subset of
+    // the filter bitmap) while rows are being added and commits fire mid-run
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", "7");
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "2");
+    VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 2, 1,
+        VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+    MutableVectorIndex index =
+        new MutableVectorIndex("mutableVectorIndexConcurrentTest_" + 
System.nanoTime(), COLUMN_NAME, config);
+    int numDocs = 200;
+    ImmutableRoaringBitmap allowed = ImmutableRoaringBitmap.bitmapOf(2, 3);
+    AtomicReference<Throwable> failure =
+        new AtomicReference<>();
+    try {
+      addVector(index, new float[]{0.0F, 1.0F}, 0);
+      addVector(index, new float[]{0.0F, -1.0F}, 1);
+      addVector(index, new float[]{1.0F, 0.0F}, 2);
+      addVector(index, new float[]{0.99F, 0.01F}, 3);
+
+      Thread writer = new Thread(() -> {
+        try {
+          for (int docId = 4; docId < numDocs; docId++) {
+            addVector(index, new float[]{-1.0F, 0.0F}, docId);
+          }
+        } catch (Throwable t) {
+          failure.compareAndSet(null, t);
+        }
+      });
+      writer.start();
+      while (writer.isAlive() && failure.get() == null) {

Review Comment:
   This test does not guarantee concurrent reader/writer execution: the writer 
can finish all additions before the main thread evaluates `writer.isAlive()`, 
leaving only the post-join assertion. Please use latches or a `Phaser` to force 
at least one interleaved add/search phase and assert that a filtered read 
occurred before allowing the writer to finish.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseDocIdBitmapFilterQuery.java:
##########
@@ -0,0 +1,136 @@
+/**
+ * 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.pinot.segment.local.segment.index.readers.vector;
+
+import java.io.IOException;
+import javax.annotation.Nullable;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.search.ConstantScoreWeight;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Scorer;
+import org.apache.lucene.search.Weight;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+
+
+/// Base class for Lucene [Query] implementations that accept only documents 
whose Pinot doc id is present
+/// in a [ImmutableRoaringBitmap]. Used to implement pre-filter ANN search by 
restricting HNSW graph
+/// traversal to the filtered document set.
+///
+/// Because Lucene uses its own internal doc ids (which differ from Pinot doc 
ids), subclasses supply the
+/// per-leaf iterator that maps Lucene doc ids to Pinot doc ids before testing 
membership in the bitmap
+/// (via a doc-id translator, doc values, etc.). This class owns the 
constant-score weight/scorer
+/// scaffolding, identity-based equality, and cache opt-out, so 
filter-correctness fixes apply to every
+/// implementation at once.
+///
+/// Instances are single-use per search and must never be cached by Lucene 
([Weight#isCacheable] returns
+/// false), since the accepted docs depend on the bitmap instance.
+public abstract class BaseDocIdBitmapFilterQuery extends Query {

Review Comment:
   Please document bitmap ownership and thread safety on this new public base 
class. `MutableRoaringBitmap` is an `ImmutableRoaringBitmap` subtype, so a 
caller can mutate the retained instance during search and change matching 
behavior. State that the bitmap must remain unchanged for the query lifetime 
and whether query instances may be shared across threads (or snapshot 
defensively if mutation is allowed).



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:
##########
@@ -230,33 +294,140 @@ public Map<String, Object> getIndexDebugInfo() {
     info.put("effectiveEfSearch", getEffectiveEfSearch());
     info.put("effectiveHnswUseRelativeDistance", 
getEffectiveUseRelativeDistance());
     info.put("effectiveHnswUseBoundedQueue", getEffectiveUseBoundedQueue());
-    info.put("supportsPreFilter", false);
+    info.put("supportsPreFilter", true);
     try (DirectoryReader directoryReader = 
DirectoryReader.open(_indexDirectory)) {
       info.put("numDocs", directoryReader.numDocs());
       info.put("numDeletedDocs", directoryReader.numDeletedDocs());
       info.put("luceneSegments", directoryReader.leaves().size());
     } catch (IOException e) {
       LOGGER.warn("Failed to load mutable HNSW debug stats for segment: {}, 
column: {}", _segmentName, _vectorColumn,
           e);
-      info.put("numDocs", _nextDocId);
+      info.put("numDocs", _numDocsAdded);
       info.put("numDeletedDocs", 0);
       info.put("luceneSegments", 0);
     }
     return info;
   }
 
   private MutableRoaringBitmap executeVectorSearch(float[] vector, int topK, 
int efSearch,
-      boolean useRelativeDistance, boolean useBoundedQueue) throws IOException 
{
+      boolean useRelativeDistance, boolean useBoundedQueue, @Nullable 
ImmutableRoaringBitmap preFilterBitmap)
+      throws IOException {
+    if (preFilterBitmap != null) {
+      // Filtered search enforces the query's visible-document set, so it must 
see every row that set names --
+      // including rows still in the writer's RAM buffer. Refreshing is 
expensive: maybeRefreshBlocking takes an
+      // exclusive lock (it does not coalesce; that is maybeRefresh), and on 
an actively consuming segment the
+      // reopen always flushes the writer, which stalls indexing. So only 
refresh when this query can actually
+      // see past the last refresh. The added-doc watermark is read BEFORE 
refreshing so rows arriving during
+      // the refresh are not wrongly claimed as visible.
+      if (!preFilterBitmap.isEmpty() && preFilterBitmap.last() > 
_searcherRefreshedThroughDocId) {

Review Comment:
   **Blocking correctness issue:** this freshness check assumes Pinot doc IDs 
arrive monotonically, but `MutableIndex` explicitly allows additions in 
arbitrary doc-ID order. For example, after refreshing through doc 10, adding 
uncommitted doc 5 and searching with bitmap `{5}` skips refresh because `5 <= 
_searcherRefreshedThroughDocId`; the stale searcher silently omits doc 5. 
Please track an insertion/writer generation rather than the maximum doc ID, and 
add an out-of-order regression test.
   
   This path can also call `maybeRefreshBlocking()` once per interleaved 
ingested row, forcing tiny Lucene flushes and serializing query callers. Please 
coalesce generation-aware refreshes (or merge a stable ANN generation with an 
exact-scanned unrefreshed tail) and validate concurrent ingest/query 
performance.



-- 
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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to