This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 1f17ab0ede1 Reopen the mutable HNSW searcher on a background thread
instead of on the query path (#19467)
1f17ab0ede1 is described below
commit 1f17ab0ede12e2703e4a037837d4c641c4756d11
Author: Xiang Fu <[email protected]>
AuthorDate: Sat Sep 5 18:37:33 2026 -0700
Reopen the mutable HNSW searcher on a background thread instead of on the
query path (#19467)
* Reopen the mutable HNSW searcher on a background thread instead of on the
query path
A filtered vector search on a consuming segment must see every row the
query's visible-document set names,
including rows still in the writer's RAM buffer, so it reopened the
near-real-time searcher itself. That reopen
flushes the writer and builds an HNSW graph for the flushed rows, and under
continuous ingestion nearly every
filtered query found new rows -- so segment creation was driven by query
rate rather than by ingestion.
Queries now publish the writer generation they need and wait; one
background thread per index performs the
reopen. Concurrent queries needing the same generation share one flush
instead of forcing one each. Measured with
the workload added here at 2000 docs/s and 8 reader threads, against the
same harness on the previous behavior:
throughput 1104 -> 1645 QPS, p50 4.0 -> 3.2ms, p95 14.7 -> 9.5ms, p99 61.3
-> 34.4ms, with roughly a quarter of
the reopens.
Lucene's ControlledRealTimeReopenThread is deliberately not used. It
publishes its searching generation from a
RefreshListener that ReferenceManager invokes from a finally block, so a
reopen that threw still advertises the
generation it merely attempted -- a query would then search a stale
searcher and silently drop rows its filter
names. Its loop also reopens on a fixed cadence whether or not anyone is
waiting, which on a consuming segment
always flushes because rows are always buffered. The loop here publishes
only after a reopen returns normally,
and runs only while a query is waiting.
- Fail the query, rather than answer it from a searcher that may not hold
its rows, when a reopen fails or the
wait exceeds refreshWaitTimeoutMs.
- Space reopens by refreshMinIntervalMs, and back off with capped retries
after a failure so a persistently
failing writer cannot spin the loop or flood the log.
- Add refreshMinIntervalMs and refreshWaitTimeoutMs to
VectorIndexConfigValidator, parsing exactly as the
consumer does so a config it accepts cannot then halt ingestion on the
server.
- Cover the reopen-failure and timeout contracts, the generation handshake
under concurrency, and that close()
stops the thread and releases waiters.
* Hold the reopen spacing across wakeups
Every arriving query notifies the reopen thread, so the single timed wait
returned on that notification and
reopened early -- leaving refreshMinIntervalMs unenforced exactly under the
load it exists to bound. Measured
with the added test: 11 reopens inside a window that should hold 1.
Also read the requested generation after the wait rather than before, so a
reopen serves the newest request
rather than whichever one woke the loop, and cover the interrupted-wait
contract.
---
.../pinot/perf/BenchmarkVectorFilterWorkloads.java | 169 +++++++++
.../realtime/impl/vector/MutableVectorIndex.java | 307 ++++++++++++----
.../impl/vector/MutableVectorIndexTest.java | 395 ++++++++++++++++-----
.../index/creator/VectorIndexConfigValidator.java | 30 +-
.../creator/VectorIndexConfigValidatorTest.java | 46 +++
5 files changed, 788 insertions(+), 159 deletions(-)
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkVectorFilterWorkloads.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkVectorFilterWorkloads.java
index ab950a67099..a23e67a020f 100644
---
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkVectorFilterWorkloads.java
+++
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkVectorFilterWorkloads.java
@@ -22,11 +22,22 @@ import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Random;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.io.FileUtils;
+import
org.apache.pinot.segment.local.realtime.impl.invertedindex.RealtimeLuceneTextIndexSearcherPool;
+import org.apache.pinot.segment.local.realtime.impl.vector.MutableVectorIndex;
import
org.apache.pinot.segment.local.segment.index.readers.vector.IvfFlatVectorIndexReader;
import
org.apache.pinot.segment.local.segment.index.readers.vector.IvfOnDiskVectorIndexReader;
import org.apache.pinot.segment.local.segment.index.vector.IvfCombinedBuffers;
@@ -89,6 +100,164 @@ public final class BenchmarkVectorFilterWorkloads {
benchmarkExactBaselines(out);
benchmarkFilterAwareAnn(out);
benchmarkApproximateRadius(out);
+ benchmarkConcurrentMutableIngestAndQuery(out);
+ }
+
+ /// Filtered search against a consuming segment while that segment is still
ingesting. This is the case the
+ /// near-real-time reopen exists for, and the one whose cost is invisible to
every other scenario here: the
+ /// others index everything up front, so their searcher is never stale and
never reopens.
+ ///
+ /// Reported alongside throughput is the reopen count. Queries do not reopen
the searcher themselves -- they wait
+ /// for a background thread -- so the gap between query count and reopen
count is the sharing that keeps a high
+ /// query rate from flushing the writer once per query.
+ private static void benchmarkConcurrentMutableIngestAndQuery(PrintStream out)
+ throws Exception {
+ out.println();
+ out.println("=== Concurrent Mutable Ingest + Filtered Query ===");
+ out.printf("%-28s %14s %10s %10s %10s %10s%n", "Workload",
"ingest(docs/s)", "QPS", "p50(us)", "p95(us)",
+ "p99(us)");
+
+ int seedDocs = 4000;
+ int readerThreads = 8;
+ long ingestPerSec = Long.getLong("pinot.perf.vector.filters.ingestPerSec",
2000L);
+ long runMs = Long.getLong("pinot.perf.vector.filters.concurrentRunMs",
10_000L);
+ long warmupMs =
Long.getLong("pinot.perf.vector.filters.concurrentWarmupMs", 3_000L);
+
+ RealtimeLuceneTextIndexSearcherPool.init(readerThreads * 2);
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", String.valueOf(DIMENSION));
+ properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
+ properties.put("commitIntervalMs",
String.valueOf(TimeUnit.DAYS.toMillis(1)));
+ VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", DIMENSION,
1,
+ VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+ MutableVectorIndex index =
+ new MutableVectorIndex("benchmarkConcurrentVector_" +
System.nanoTime(), COLUMN_NAME, config);
+ try {
+ Random seeder = new Random(SEED);
+ for (int i = 0; i < seedDocs; i++) {
+ index.add(boxed(BenchmarkVectorIndex.generateGaussianVectors(1,
DIMENSION, seeder.nextLong())[0]), null, i);
+ }
+ // ~1% of the seeded rows, which is the selectivity range where a
filtered scan is worth doing at all.
+ MutableRoaringBitmap allowed = new MutableRoaringBitmap();
+ for (int i = 0; i < seedDocs; i += 100) {
+ allowed.add(i);
+ }
+
+ AtomicBoolean stop = new AtomicBoolean();
+ AtomicLong ingested = new AtomicLong();
+ AtomicLong queried = new AtomicLong();
+ List<List<Long>> perReaderLatencies = new ArrayList<>();
+ Thread writer = new Thread(() -> {
+ Random wr = new Random(SEED + 1);
+ int docId = seedDocs;
+ long intervalNs = ingestPerSec > 0 ? 1_000_000_000L / ingestPerSec :
0L;
+ long next = System.nanoTime();
+ while (!stop.get()) {
+ index.add(boxed(BenchmarkVectorIndex.generateGaussianVectors(1,
DIMENSION, wr.nextLong())[0]), null,
+ docId++);
+ ingested.incrementAndGet();
+ if (intervalNs > 0) {
+ next += intervalNs;
+ long sleepNs = next - System.nanoTime();
+ if (sleepNs > 0) {
+ try {
+ Thread.sleep(sleepNs / 1_000_000L, (int) (sleepNs %
1_000_000L));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+ }
+ }, "vector-bench-writer");
+
+ List<Thread> readers = new ArrayList<>(readerThreads);
+ CountDownLatch ready = new CountDownLatch(readerThreads);
+ for (int r = 0; r < readerThreads; r++) {
+ List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
+ perReaderLatencies.add(latencies);
+ int readerId = r;
+ readers.add(new Thread(() -> {
+ Random qr = new Random(SEED + 100 + readerId);
+ ready.countDown();
+ while (!stop.get()) {
+ float[] query = BenchmarkVectorIndex.generateGaussianVectors(1,
DIMENSION, qr.nextLong())[0];
+ long start = System.nanoTime();
+ index.getDocIds(query, TOP_K, allowed);
+ latencies.add(System.nanoTime() - start);
+ queried.incrementAndGet();
+ }
+ }, "vector-bench-reader-" + readerId));
+ }
+
+ writer.start();
+ readers.forEach(Thread::start);
+ ready.await();
+ // Discard the warmup window: the first queries pay class loading, JIT,
and the first graph build.
+ Thread.sleep(warmupMs);
+ long queriesAtWarmupEnd = queried.get();
+ long ingestedAtWarmupEnd = ingested.get();
+ // Counters are cumulative from construction, so snapshot here: only the
delta over the measured window is
+ // comparable with the query count reported below.
+ Map<String, Object> debugAtWarmupEnd = index.getIndexDebugInfo();
+ long reopensAtWarmupEnd = ((Number)
debugAtWarmupEnd.get("searcherRefreshCount")).longValue();
+ long waitsAtWarmupEnd = ((Number)
debugAtWarmupEnd.get("searcherRefreshWaitCount")).longValue();
+ List<List<Long>> measured = new ArrayList<>();
+ for (List<Long> latencies : perReaderLatencies) {
+ synchronized (latencies) {
+ latencies.clear();
+ }
+ measured.add(latencies);
+ }
+ long startNs = System.nanoTime();
+ Thread.sleep(runMs);
+ long elapsedMs = (System.nanoTime() - startNs) / 1_000_000L;
+ long queries = queried.get() - queriesAtWarmupEnd;
+ long docs = ingested.get() - ingestedAtWarmupEnd;
+ stop.set(true);
+ writer.join(TimeUnit.SECONDS.toMillis(30));
+ for (Thread reader : readers) {
+ reader.join(TimeUnit.SECONDS.toMillis(30));
+ }
+ // Read after the threads stop, so the counters cover exactly the
measured window.
+ Map<String, Object> debugAtEnd = index.getIndexDebugInfo();
+ long reopens = ((Number)
debugAtEnd.get("searcherRefreshCount")).longValue() - reopensAtWarmupEnd;
+ long waits = ((Number)
debugAtEnd.get("searcherRefreshWaitCount")).longValue() - waitsAtWarmupEnd;
+
+ List<Long> all = new ArrayList<>();
+ for (List<Long> latencies : measured) {
+ synchronized (latencies) {
+ all.addAll(latencies);
+ }
+ }
+ Collections.sort(all);
+ out.printf("%-28s %14.1f %10.1f %10.1f %10.1f %10.1f%n",
+ ingestPerSec + "/s_" + readerThreads + "_readers", docs * 1000.0 /
elapsedMs, queries * 1000.0 / elapsedMs,
+ percentileMicros(all, 0.50), percentileMicros(all, 0.95),
percentileMicros(all, 0.99));
+ // Deltas over the measured window, not the cumulative counters: only
these are comparable with the query
+ // count above. waits/reopens is the sharing -- how many queries one
reopen served.
+ out.printf(" reopens=%d waits=%d luceneSegments=%s (waits per reopen is
the sharing)%n", reopens, waits,
+ debugAtEnd.get("luceneSegments"));
+ } finally {
+ index.close();
+ }
+ }
+
+ private static double percentileMicros(List<Long> sortedNanos, double
percentile) {
+ if (sortedNanos.isEmpty()) {
+ return 0.0;
+ }
+ int idx = Math.min(sortedNanos.size() - 1, (int) (sortedNanos.size() *
percentile));
+ return sortedNanos.get(idx) / 1000.0;
+ }
+
+ private static Float[] boxed(float[] values) {
+ Float[] boxedValues = new Float[values.length];
+ for (int i = 0; i < values.length; i++) {
+ boxedValues[i] = values[i];
+ }
+ return boxedValues;
}
private static void benchmarkExactBaselines(PrintStream out) {
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
index d7ecb1b5e04..f85b263a383 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.Nullable;
import org.apache.commons.io.FileUtils;
import org.apache.lucene.document.Document;
@@ -71,7 +73,6 @@ import org.slf4j.LoggerFactory;
/// Every added document stores the supplied Pinot doc id as a
[NumericDocValuesField]. The same doc values
/// drive both filtered traversal and the translation of search hits, so no
assumption is made that
/// `ScoreDoc.doc == Pinot docId` (Lucene may renumber on merges).
-
///
/// Filtered search ([#getDocIds(float[], int, ImmutableRoaringBitmap)])
restricts HNSW candidate generation
/// to the given Pinot doc ids (used to enforce the upsert doc-ids snapshot).
It searches a near-real-time
@@ -80,16 +81,16 @@ import org.slf4j.LoggerFactory;
/// unfiltered path keeps searching the last committed generation (cheaper;
commit cadence is controlled by
/// `commitIntervalMs` / `commitDocs`).
///
-/// **Cost of that freshness.** A refresh flushes the writer's RAM buffer,
which for a vector field writes out
-/// the HNSW graph for the pending rows as a new Lucene segment. While a
segment is actively consuming, nearly
-/// every filtered query finds new rows and therefore triggers one, so segment
creation is driven by query rate
-/// rather than ingestion rate, and the resulting merges rebuild graphs.
Concurrent callers coalesce onto a
-/// single refresh rather than each forcing their own. Bounding this properly
means reopening on a background
-/// thread and having queries wait on a sequence number instead of driving the
flush themselves -- see
-/// `RealtimeLuceneIndexRefreshManager`, which does that for the text indexes,
and Lucene's
-/// `ControlledRealTimeReopenThread`. Until then, correctness is preserved at
the cost of that flush.
+/// **Cost of that freshness.** A reopen flushes the writer's RAM buffer,
which writes a new Lucene segment and
+/// builds an HNSW graph over the rows it contains. Query threads never pay
that cost directly: a filtered query
+/// publishes the writer generation it needs and waits, and a single
background thread performs the reopen, so
+/// concurrent queries needing the same generation share one flush rather than
forcing one each. Reopens happen
+/// only while a query is waiting -- never on a timer -- and are spaced at
least `refreshMinIntervalMs` apart, so
+/// segment creation stays bounded by ingestion rather than by query rate. A
query waits at most
+/// `refreshWaitTimeoutMs`, then fails rather than answering from a searcher
that may not hold its rows.
///
-/// This class is thread-safe for single writer multiple readers.
+/// This class is thread-safe for a single writer and multiple readers, plus
one background reopen thread that
+/// this instance owns and [#close()] stops.
public class MutableVectorIndex
implements FilterAwareVectorIndexReader, MutableIndex,
VectorIndexConfigProvider, EfSearchAware {
private static final Logger LOGGER =
LoggerFactory.getLogger(MutableVectorIndex.class);
@@ -99,6 +100,21 @@ public class MutableVectorIndex
public static final String VECTOR_INDEX_DOC_ID_COLUMN_NAME = "DocID";
public static final long DEFAULT_COMMIT_INTERVAL_MS = 10_000L;
public static final long DEFAULT_COMMIT_DOCS = 1000L;
+ public static final String REFRESH_MIN_INTERVAL_MS = "refreshMinIntervalMs";
+ public static final String REFRESH_WAIT_TIMEOUT_MS = "refreshWaitTimeoutMs";
+ /// Minimum spacing between reopens, and equally the freshness delay a
filtered query can pay. Raising it trades
+ /// query latency for fewer writer flushes; 0 disables the spacing.
+ ///
+ /// The default is deliberately small because most of the reduction comes
from sharing rather than from spacing:
+ /// one reopen serves every query waiting on it, so reopens fall roughly by
the number of concurrent readers
+ /// whatever this is set to. Measured at 2000 docs/s with 8 reader threads,
raising it from 1ms to 10ms cut
+ /// reopens a further 4x but cost 4x the throughput and 4.5x the p50,
landing well below the per-query-refresh
+ /// behaviour it replaces. At 1ms it measured better than that behaviour on
throughput, p50, p95 and p99 while
+ /// still performing about a quarter of its reopens.
+ public static final long DEFAULT_REFRESH_MIN_INTERVAL_MS = 1L;
+ /// How long a filtered query waits for a reopen before failing. Bounded on
purpose: an unbounded wait would let
+ /// a stalled reopen pin query threads of the shared searcher pool
indefinitely.
+ public static final long DEFAULT_REFRESH_WAIT_TIMEOUT_MS = 5_000L;
private final int _vectorDimension;
private final VectorIndexConfig _vectorIndexConfig;
private final VectorSimilarityFunction _vectorSimilarityFunction;
@@ -110,24 +126,47 @@ public class MutableVectorIndex
private final FSDirectory _indexDirectory;
private final IndexWriter _indexWriter;
// Near-real-time searcher over the writer, used by the filtered search path
(upsert doc-ids snapshot
- // enforcement) so uncommitted rows are visible; refreshed on demand, reused
across queries
+ // enforcement) so uncommitted rows are visible; reopened by _reopenThread,
reused across queries
private final SearcherManager _searcherManager;
- // Coordinates callers that need the same near-real-time generation. One
caller refreshes while the others wait
- // for its published watermark instead of serializing on
maybeRefreshBlocking or reopening the same generation.
- private final Object _searcherRefreshMonitor = new Object();
- // Guarded by _searcherRefreshMonitor.
- private boolean _searcherRefreshInProgress;
- private volatile long _searcherRefreshCount;
+ /// Performs every near-real-time reopen on one background thread. Query
threads never flush the writer
+ /// themselves: they publish the generation they need and block until this
thread has reopened past it, so N
+ /// concurrent queries share one reopen instead of forcing N.
+ ///
+ /// Deliberately hand-rolled rather than delegating to Lucene's
`ControlledRealTimeReopenThread`, for two reasons
+ /// that both bear on correctness here. That class publishes its searching
generation from a
+ /// `ReferenceManager.RefreshListener`, which
`ReferenceManager#doMaybeRefresh` invokes from a finally block --
+ /// so a reopen that *threw* still advertises the generation it merely
attempted, and a filtered query would then
+ /// search a stale searcher and silently drop rows its filter names. And its
reopen loop refreshes on a fixed
+ /// cadence whether or not anyone is waiting, which on a consuming segment
is never a no-op: rows are always
+ /// buffered, so every tick flushes and builds a graph even for a table that
runs no filtered query at all.
+ /// The loop below publishes only after a reopen returns normally, and runs
only when a query is waiting.
+ private final Thread _reopenThread;
+ private final long _refreshMinIntervalMs;
+ private final long _refreshWaitTimeoutMs;
+ /// Guards the reopen handshake: the requested and reached generations, the
failure record, and the closed flag.
+ private final Object _refreshMonitor = new Object();
+ /// Highest generation any waiting query has asked for. Guarded by
[#_refreshMonitor].
+ private long _requestedSequenceNumber = -1;
+ /// Highest generation the shared searcher is known to cover. Published only
after a reopen returns normally, so
+ /// a failed reopen can never make a query believe it is looking at rows the
searcher does not have.
+ private volatile long _refreshedThroughSequenceNumber = -1;
+ /// Last reopen failure and a count of them, so a query blocked across a
failure fails instead of waiting out its
+ /// timeout. Guarded by [#_refreshMonitor].
+ private Throwable _reopenFailure;
+ private long _reopenFailureCount;
+ private boolean _closed;
+ private final AtomicLong _searcherRefreshCount = new AtomicLong();
+ private final AtomicLong _searcherRefreshWaitCount = new AtomicLong();
// Number of documents added so far; used only for the commit cadence, never
as a doc id. Written by the indexing
// thread only; read cross-thread for debug output, where staleness is
acceptable.
private volatile int _numDocsAdded;
- /// Sequence number of the newest row handed to the writer, and the sequence
number the shared searcher was
- /// last refreshed through. Together they let a filtered search skip the
refresh when nothing has been added
- /// since. Doc ids cannot be used for this: [MutableIndex#add] allows rows
in arbitrary doc-id order, so a
- /// doc-id watermark would skip the refresh a lower-numbered but newer row
needs. Writer sequence numbers are
- /// monotonic by construction. Written by the single indexing thread, read
by query threads.
+ /// Sequence number of the newest row handed to the writer. A filtered
search waits for the reopen thread to
+ /// pass this value, and skips waiting entirely when the searcher is already
past it. Doc ids cannot be used
+ /// for this: [MutableIndex#add] allows rows in arbitrary doc-id order, so a
doc-id watermark would skip the
+ /// wait a lower-numbered but newer row needs. Writer sequence numbers are
monotonic by construction, and share
+ /// the space that `IndexWriter#getMaxCompletedSequenceNumber` reports,
which is what the reopen thread
+ /// publishes as its searching generation. Written by the single indexing
thread, read by query threads.
private volatile long _lastAddedSequenceNumber = -1;
- private volatile long _searcherRefreshedThroughSequenceNumber = -1;
private long _lastCommitTime;
private final ThreadLocal<Integer> _efSearchOverride = new ThreadLocal<>();
@@ -143,6 +182,14 @@ public class MutableVectorIndex
vectorIndexConfig.getProperties().getOrDefault("commitIntervalMs",
String.valueOf(DEFAULT_COMMIT_INTERVAL_MS)));
_commitDocs = Long.parseLong(
vectorIndexConfig.getProperties().getOrDefault("commitDocs",
String.valueOf(DEFAULT_COMMIT_DOCS)));
+ _refreshMinIntervalMs = Long.parseLong(vectorIndexConfig.getProperties()
+ .getOrDefault(REFRESH_MIN_INTERVAL_MS,
String.valueOf(DEFAULT_REFRESH_MIN_INTERVAL_MS)));
+ _refreshWaitTimeoutMs = Long.parseLong(vectorIndexConfig.getProperties()
+ .getOrDefault(REFRESH_WAIT_TIMEOUT_MS,
String.valueOf(DEFAULT_REFRESH_WAIT_TIMEOUT_MS)));
+ Preconditions.checkArgument(_refreshMinIntervalMs >= 0, "Require %s >= 0,
got %s for column: %s",
+ REFRESH_MIN_INTERVAL_MS, _refreshMinIntervalMs, vectorColumn);
+ Preconditions.checkArgument(_refreshWaitTimeoutMs > 0, "Require %s > 0,
got %s for column: %s",
+ REFRESH_WAIT_TIMEOUT_MS, _refreshWaitTimeoutMs, vectorColumn);
_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.
@@ -152,6 +199,7 @@ public class MutableVectorIndex
FSDirectory indexDirectory = null;
IndexWriter indexWriter = null;
SearcherManager searcherManager = null;
+ Thread reopenThread = null;
try {
// segment generation is always in V1 and later we convert (as part of
post creation processing)
// to V3 if segmentVersion is set to V3 in SegmentGeneratorConfig.
@@ -165,6 +213,8 @@ public class MutableVectorIndex
VectorIndexUtils.getIndexWriterConfig(vectorIndexConfig).setOpenMode(IndexWriterConfig.OpenMode.CREATE));
indexWriter.commit();
searcherManager = new SearcherManager(indexWriter, false, false, null);
+ reopenThread = new Thread(this::reopenLoop, "vector-nrt-reopen-" +
segmentName + "-" + vectorColumn);
+ reopenThread.setDaemon(true);
_lastCommitTime = System.currentTimeMillis();
} catch (Exception e) {
// IndexWriter does not close the Directory passed to it, so both need
to be closed.
@@ -180,6 +230,18 @@ public class MutableVectorIndex
_indexDirectory = indexDirectory;
_indexWriter = indexWriter;
_searcherManager = searcherManager;
+ _reopenThread = reopenThread;
+ // Started last: the loop reads final fields assigned above, so publishing
`this` to another thread any
+ // earlier would race construction. Guarded because this is where
OutOfMemoryError: unable to create native
+ // thread lands, and by now nothing else holds a reference that could
close the writer -- which would keep
+ // write.lock on a directory whose name is deterministic, so every retry
of this segment would then fail.
+ try {
+ _reopenThread.start();
+ } catch (Throwable t) {
+ IOUtils.closeWhileHandlingException(_searcherManager, _indexWriter,
_indexDirectory);
+ deleteIndexDir();
+ throw t;
+ }
}
@Override
@@ -314,6 +376,11 @@ public class MutableVectorIndex
info.put("effectiveHnswUseRelativeDistance",
getEffectiveUseRelativeDistance());
info.put("effectiveHnswUseBoundedQueue", getEffectiveUseBoundedQueue());
info.put("supportsPreFilter", supportsPreFilter());
+ info.put(REFRESH_MIN_INTERVAL_MS, _refreshMinIntervalMs);
+ info.put(REFRESH_WAIT_TIMEOUT_MS, _refreshWaitTimeoutMs);
+ // Reopens vs. the queries that had to wait for one: the gap between them
is the sharing this path relies on.
+ info.put("searcherRefreshCount", _searcherRefreshCount.get());
+ info.put("searcherRefreshWaitCount", _searcherRefreshWaitCount.get());
try (DirectoryReader directoryReader =
DirectoryReader.open(_indexDirectory)) {
info.put("numDocs", directoryReader.numDocs());
info.put("numDeletedDocs", directoryReader.numDeletedDocs());
@@ -333,14 +400,9 @@ public class MutableVectorIndex
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 flushes
the writer, so only refresh when this
- // query can actually see past the last refresh, and coalesce callers
waiting for the same generation. The
- // added-doc watermark is read BEFORE refreshing so rows arriving during
the refresh are not wrongly claimed
- // as visible.
- long lastAdded = _lastAddedSequenceNumber;
- if (lastAdded > _searcherRefreshedThroughSequenceNumber) {
- refreshSearcherThrough(lastAdded);
- }
+ // including rows still in the writer's RAM buffer. The watermark is
read BEFORE waiting, so rows arriving
+ // during the reopen are not wrongly claimed as visible.
+ awaitSearcherGeneration(_lastAddedSequenceNumber);
IndexSearcher indexSearcher = _searcherManager.acquire();
try {
return search(indexSearcher, vector, topK, efSearch,
useRelativeDistance, useBoundedQueue,
@@ -356,64 +418,166 @@ public class MutableVectorIndex
}
}
- /// Refreshes the shared near-real-time searcher through
`targetSequenceNumber`, coalescing concurrent callers.
+ /// Blocks until the shared searcher is known to cover
`targetSequenceNumber`.
///
- /// The winning caller publishes the exact generation it captured before
refreshing and wakes the waiters. A
- /// waiter whose target is newer then performs the next refresh. This is
necessary because rows added during a
- /// refresh are not guaranteed to be visible in the reopened reader.
- private void refreshSearcherThrough(long targetSequenceNumber)
+ /// The reopen itself runs on [#_reopenThread]; this only publishes the
generation needed and waits. That is the
+ /// point of the indirection -- every concurrent caller needing the same or
an older generation is satisfied by
+ /// one reopen, and no query thread ever flushes the writer.
+ ///
+ /// Fails rather than waits when the reopen cannot deliver: a reopen that
threw, a closed index, or a wait past
+ /// [#_refreshWaitTimeoutMs]. Returning normally without the generation
would mean searching a stale searcher and
+ /// silently dropping rows the query's filter names, which is the failure
this whole path exists to prevent.
+ @VisibleForTesting
+ void awaitSearcherGeneration(long targetSequenceNumber)
throws IOException {
- synchronized (_searcherRefreshMonitor) {
- while (_searcherRefreshInProgress
- && _searcherRefreshedThroughSequenceNumber < targetSequenceNumber) {
- onSearcherRefreshWait();
+ // Plain volatile read, so a query that is already covered adds no
synchronization at all.
+ if (targetSequenceNumber <= _refreshedThroughSequenceNumber) {
+ return;
+ }
+ _searcherRefreshWaitCount.incrementAndGet();
+ long deadlineMs = System.currentTimeMillis() + _refreshWaitTimeoutMs;
+ synchronized (_refreshMonitor) {
+ long failuresBefore = _reopenFailureCount;
+ if (targetSequenceNumber > _requestedSequenceNumber) {
+ _requestedSequenceNumber = targetSequenceNumber;
+ }
+ _refreshMonitor.notifyAll();
+ while (targetSequenceNumber > _refreshedThroughSequenceNumber) {
+ if (_closed) {
+ throw new IOException(describe("Vector index closed while waiting
for the searcher to reopen"));
+ }
+ if (_reopenFailureCount != failuresBefore) {
+ throw new IOException(describe("Vector searcher reopen failed"),
_reopenFailure);
+ }
+ long remainingMs = deadlineMs - System.currentTimeMillis();
+ if (remainingMs <= 0) {
+ throw new IOException(describe(
+ "Timed out after " + _refreshWaitTimeoutMs + "ms waiting for the
vector searcher to reopen through "
+ + "generation " + targetSequenceNumber));
+ }
try {
- _searcherRefreshMonitor.wait();
+ _refreshMonitor.wait(remainingMs);
} catch (InterruptedException e) {
- throw new IOException("Interrupted while waiting to refresh vector
searcher for segment " + _segmentName
- + " column " + _vectorColumn, e);
+ // Not re-arming the interrupt flag: the cause is preserved on the
IOException, and this runs on a pooled
+ // Lucene searcher thread the pool deliberately keeps un-interrupted
(see submitSearch).
+ throw new IOException(describe("Interrupted while waiting for the
vector searcher to reopen"), e);
}
}
- if (_searcherRefreshedThroughSequenceNumber >= targetSequenceNumber) {
- return;
- }
- _searcherRefreshInProgress = true;
}
+ }
- try {
- beforeSearcherRefresh();
- // A false return means another caller already owns SearcherManager's
refresh lock. This is benign: use the
- // blocking form so this call still returns a searcher covering the
requested writer generation.
- if (!_searcherManager.maybeRefresh()) {
- _searcherManager.maybeRefreshBlocking();
+ /// Reopens the shared searcher whenever a query is waiting for a generation
it does not yet cover.
+ ///
+ /// Runs only on demand. An idle cadence would be pure cost here: a
consuming segment always has buffered rows,
+ /// so a timed reopen is never the cheap no-op it is for a settled index --
it flushes the writer and builds an
+ /// HNSW graph for the flushed rows, even for a table that issues no
filtered query at all.
+ private void reopenLoop() {
+ long lastReopenMs = 0L;
+ long failedRequest = Long.MIN_VALUE;
+ int consecutiveFailures = 0;
+ while (true) {
+ long request;
+ synchronized (_refreshMonitor) {
+ // Park unless someone needs a generation we have not reached AND have
not already failed on. Without the
+ // failedRequest half, a request whose waiters have all given up would
drive an unbounded retry-and-log
+ // loop, because a failed reopen never advances
_refreshedThroughSequenceNumber.
+ while (!_closed && (_requestedSequenceNumber <=
_refreshedThroughSequenceNumber
+ || _requestedSequenceNumber <= failedRequest)) {
+ try {
+ _refreshMonitor.wait();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ if (_closed) {
+ return;
+ }
+ // Spacing waits on the monitor rather than sleeping, so close() can
wake it immediately. It has to loop:
+ // every arriving query calls notifyAll, and a single timed wait would
return on that notification and
+ // reopen early, leaving the interval unenforced exactly when load
makes it matter.
+ long reopenNotBeforeMs = lastReopenMs + Math.max(_refreshMinIntervalMs,
+ consecutiveFailures == 0 ? 0L : Math.min(1000L <<
Math.min(consecutiveFailures - 1, 5), 30_000L));
+ while (!_closed) {
+ long waitMs = reopenNotBeforeMs - System.currentTimeMillis();
+ if (waitMs <= 0) {
+ break;
+ }
+ try {
+ _refreshMonitor.wait(waitMs);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ if (_closed) {
+ return;
+ }
+ // Read after the wait, so this reopen serves the newest request
rather than the one that woke us.
+ request = _requestedSequenceNumber;
}
- synchronized (_searcherRefreshMonitor) {
- _searcherRefreshedThroughSequenceNumber =
- Math.max(_searcherRefreshedThroughSequenceNumber,
targetSequenceNumber);
- _searcherRefreshCount++;
+ // Read the writer's generation BEFORE reopening, so rows arriving
during the reopen are not claimed as
+ // visible by a reader that may not contain them.
+ long generation;
+ try {
+ generation = _indexWriter.getMaxCompletedSequenceNumber();
+ doReopen();
+ } catch (Throwable t) {
+ consecutiveFailures++;
+ // Log the first failure of a run in full; after that only
periodically, so a persistently failing writer
+ // cannot turn an already-degraded server into a log flood.
+ if (consecutiveFailures == 1 || consecutiveFailures % 100 == 0) {
+ LOGGER.error("Failed to reopen the vector searcher ({} consecutive)
for segment: {}, column: {}",
+ consecutiveFailures, _segmentName, _vectorColumn, t);
+ }
+ lastReopenMs = System.currentTimeMillis();
+ synchronized (_refreshMonitor) {
+ _reopenFailure = t;
+ _reopenFailureCount++;
+ // Remember what failed so an abandoned request cannot drive a retry
loop. A newer request still gets a
+ // fresh attempt, so a transient failure (disk pressure, a slow
flush) recovers on the next query.
+ failedRequest = request;
+ _refreshMonitor.notifyAll();
+ }
+ continue;
}
- } finally {
- synchronized (_searcherRefreshMonitor) {
- _searcherRefreshInProgress = false;
- _searcherRefreshMonitor.notifyAll();
+ lastReopenMs = System.currentTimeMillis();
+ consecutiveFailures = 0;
+ _searcherRefreshCount.incrementAndGet();
+ synchronized (_refreshMonitor) {
+ // Published only here, after a reopen that returned normally.
+ _refreshedThroughSequenceNumber =
Math.max(_refreshedThroughSequenceNumber, generation);
+ _refreshMonitor.notifyAll();
}
}
}
- /// Test hook invoked by the winning refresher after it publishes the
in-progress state.
+ /// Seam for tests that need a reopen to fail; the failure handling around
it is the part worth covering.
@VisibleForTesting
- void beforeSearcherRefresh()
+ void doReopen()
throws IOException {
+ _searcherManager.maybeRefreshBlocking();
+ }
+
+ private String describe(String message) {
+ return message + " for segment: " + _segmentName + ", column: " +
_vectorColumn;
}
- /// Test hook invoked when another caller is about to wait for the winning
refresher.
@VisibleForTesting
- void onSearcherRefreshWait() {
+ long getLastAddedSequenceNumber() {
+ return _lastAddedSequenceNumber;
}
@VisibleForTesting
long getSearcherRefreshCount() {
- return _searcherRefreshCount;
+ return _searcherRefreshCount.get();
+ }
+
+ /// Cumulative number of queries that had to wait for a reopen. Compared
against the reopen count, this is what
+ /// shows sharing: many waits against one reopen.
+ @VisibleForTesting
+ long getSearcherRefreshWaitCount() {
+ return _searcherRefreshWaitCount.get();
}
private MutableRoaringBitmap search(IndexSearcher indexSearcher, float[]
vector, int topK, int efSearch,
@@ -527,6 +691,17 @@ public class MutableVectorIndex
@Override
public void close() {
+ // Stop the reopen loop and release every waiting query before the
searcher manager goes away: the loop
+ // refreshes through it, and a query blocked on a generation that will now
never arrive must not hang.
+ synchronized (_refreshMonitor) {
+ _closed = true;
+ _refreshMonitor.notifyAll();
+ }
+ try {
+ _reopenThread.join(TimeUnit.SECONDS.toMillis(30));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
try {
_indexWriter.commit();
// IndexWriter does not close the Directory passed to it, so both need
to be closed.
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
index b2814b4398a..23a6c59cbfc 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
@@ -25,13 +25,14 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Phaser;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import
org.apache.pinot.segment.local.realtime.impl.invertedindex.RealtimeLuceneTextIndexSearcherPool;
import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
@@ -39,6 +40,7 @@ import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
import org.roaringbitmap.buffer.MutableRoaringBitmap;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -289,8 +291,8 @@ public class MutableVectorIndexTest {
() -> index.getDocIds(new float[]{1.0F, 0.0F}, 2,
ImmutableRoaringBitmap.bitmapOf()));
ImmutableRoaringBitmap filtered = emptySearch.get(10, TimeUnit.SECONDS);
Assert.assertEquals(filtered.getCardinality(), 0);
- Assert.assertEquals(index.getSearcherRefreshCount(), 0L,
- "An empty filter must return before search submission or NRT
refresh");
+ Assert.assertEquals(index.getSearcherRefreshWaitCount(), 0L,
+ "An empty filter must return before search submission or entering
the NRT wait path");
} finally {
releaseBlocker.countDown();
try {
@@ -312,45 +314,314 @@ public class MutableVectorIndexTest {
}
@Test(timeOut = 60_000)
- public void testConcurrentFilteredSearchesCoalesceRefresh()
+ public void testConcurrentFilteredSearchesShareOneReopen()
throws Exception {
- int numCallers = SEARCHER_POOL_SIZE;
+ // Exercises the generation handshake directly rather than through
getDocIds. The searcher pool is a shared,
+ // scaling singleton that serializes blocking searches in this JVM, so
routing through it would only ever put
+ // one caller in the wait path at a time and the assertion would measure
the pool, not the sharing.
+ //
+ // A long refreshMinIntervalMs makes the overlap deterministic: the first
waiter's reopen cannot start until
+ // the limiter elapses, so both callers are guaranteed to be parked before
it runs, and one reopen must serve
+ // them both.
+ int numCallers = 2;
ExecutorService callers = Executors.newFixedThreadPool(numCallers);
- CoordinatedRefreshMutableVectorIndex index =
createCoordinatedIndexWithoutCommits(1);
+ MutableVectorIndex index = createIndexWithRefreshTuning("1000");
try (index) {
float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(),
new int[]{0});
long initialRefreshCount = index.getSearcherRefreshCount();
+ long initialWaits = index.getSearcherRefreshWaitCount();
addVector(index, query, 10);
- index.coordinateNextRefresh();
+ long target = index.getLastAddedSequenceNumber();
CyclicBarrier startTogether = new CyclicBarrier(numCallers);
- List<Future<ImmutableRoaringBitmap>> searches = new
ArrayList<>(numCallers);
+ List<Future<?>> waits = new ArrayList<>(numCallers);
for (int i = 0; i < numCallers; i++) {
- searches.add(callers.submit(() -> {
+ waits.add(callers.submit(() -> {
startTogether.await(10, TimeUnit.SECONDS);
- return index.getDocIds(query, 1, bitmapOf(10));
+ index.awaitSearcherGeneration(target);
+ return null;
}));
}
- index.awaitWinningRefresher();
- index.awaitWaitingCallers();
- index.releaseWinningRefresher();
- for (Future<ImmutableRoaringBitmap> search : searches) {
- Assert.assertEquals(search.get(10, TimeUnit.SECONDS).toArray(), new
int[]{10});
+ for (Future<?> wait : waits) {
+ wait.get(30, TimeUnit.SECONDS);
}
+ Assert.assertEquals(index.getSearcherRefreshWaitCount() - initialWaits,
numCallers,
+ "Every caller must have blocked on the shared reopen rather than
being served without waiting");
Assert.assertEquals(index.getSearcherRefreshCount(), initialRefreshCount
+ 1,
- "Concurrent readers targeting one writer generation must share
exactly one refresh");
- // The refresh count alone does not prove coalescing: a caller arriving
after publication would skip refresh
- // and still leave the count at one. Require actual participation in the
production waiter path.
- Assert.assertTrue(index.getObservedRefreshWaiters() > 0,
- "Expected concurrent callers to coalesce onto the in-flight refresh,
but none entered the waiter path");
+ "Callers targeting one writer generation must cost exactly one
reopen between them");
+ // And the searcher really does cover the row, not merely claim the
generation.
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(10)).toArray(),
new int[]{10});
} finally {
- index.releaseWinningRefresher();
callers.shutdownNow();
Assert.assertTrue(callers.awaitTermination(10, TimeUnit.SECONDS),
"Concurrent callers did not terminate");
}
}
+ /// The reason this path does not delegate to Lucene's
ControlledRealTimeReopenThread: a reopen that threw must
+ /// not publish the generation it merely attempted. Publishing it would let
the next query search a searcher
+ /// that does not hold the rows its filter names, and return fewer results
with no error at all.
+ @Test(timeOut = 60_000)
+ public void testFailedReopenNeitherPublishesItsGenerationNorHangsTheQuery()
+ throws Exception {
+ AtomicBoolean failReopen = new AtomicBoolean(true);
+ Map<String, String> properties = new HashMap<>();
+ properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
+ properties.put("commitIntervalMs",
String.valueOf(TimeUnit.DAYS.toMillis(1)));
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "5");
+ properties.put(MutableVectorIndex.REFRESH_MIN_INTERVAL_MS, "0");
+ VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 5, 1,
+ VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+ try (MutableVectorIndex index =
+ new MutableVectorIndex("mutableVectorIndexReopenFailureTest_" +
System.nanoTime(), COLUMN_NAME, config) {
+ @Override
+ void doReopen()
+ throws IOException {
+ if (failReopen.get()) {
+ throw new IOException("injected reopen failure");
+ }
+ super.doReopen();
+ }
+ }) {
+ float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+ addVector(index, query, 10);
+ long generation = index.getLastAddedSequenceNumber();
+
+ IOException thrown = Assert.expectThrows(IOException.class, () ->
index.awaitSearcherGeneration(generation));
+ Assert.assertTrue(thrown.getMessage().contains("reopen failed"),
+ "A query must fail when the reopen it needs failed, got: " +
thrown.getMessage());
+ Assert.assertEquals(index.getSearcherRefreshCount(), 0L,
+ "A reopen that threw must not be counted as having produced a
searcher");
+ // The decisive assertion: the failed attempt must not have advanced the
published generation, or a later
+ // query would take the fast path and search a searcher that never
received the row.
+ IOException second = Assert.expectThrows(IOException.class, () ->
index.awaitSearcherGeneration(generation));
+ Assert.assertNotNull(second.getMessage());
+
+ // And a transient failure recovers on the next request rather than
disabling the segment.
+ failReopen.set(false);
+ addVector(index, query, 11);
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(11)).toArray(),
new int[]{11});
+ }
+ }
+
+ /// A reopen that never completes must surface as a failed query rather than
an unbounded wait holding a thread
+ /// of the shared searcher pool.
+ @Test(timeOut = 60_000)
+ public void testFilteredSearchTimesOutRatherThanWaitingForever()
+ throws Exception {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
+ properties.put("commitIntervalMs",
String.valueOf(TimeUnit.DAYS.toMillis(1)));
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "5");
+ // The spacing outlives the timeout, so the reopen this query needs cannot
start before it gives up.
+ properties.put(MutableVectorIndex.REFRESH_MIN_INTERVAL_MS,
String.valueOf(TimeUnit.SECONDS.toMillis(30)));
+ properties.put(MutableVectorIndex.REFRESH_WAIT_TIMEOUT_MS, "50");
+ VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 5, 1,
+ VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+ try (MutableVectorIndex index =
+ new MutableVectorIndex("mutableVectorIndexTimeoutTest_" +
System.nanoTime(), COLUMN_NAME, config)) {
+ addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 0);
+ // Spend the first reopen, which is never delayed: only from the second
on does the spacing apply, and it is
+ // the spacing that keeps the awaited reopen from starting before the
wait gives up.
+ Assert.assertEquals(index.getDocIds(new float[]{1.0F, 0.0F, 0.0F, 0.0F,
0.0F}, 1, bitmapOf(0)).toArray(),
+ new int[]{0});
+ addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 10);
+ IOException thrown = Assert.expectThrows(IOException.class,
+ () ->
index.awaitSearcherGeneration(index.getLastAddedSequenceNumber()));
+ Assert.assertTrue(thrown.getMessage().contains("Timed out after 50ms"),
+ "Expected the bounded-wait failure, got: " + thrown.getMessage());
+ }
+ }
+
+ /// The spacing must hold even while queries keep arriving. Each arriving
query notifies the reopen thread, so a
+ /// single timed wait would return on that notification and reopen early --
leaving the interval unenforced
+ /// exactly under the load it exists to bound.
+ @Test(timeOut = 60_000)
+ public void testReopenSpacingHoldsWhileQueriesKeepArriving()
+ throws Exception {
+ long spacingMs = 1000L;
+ try (MutableVectorIndex index =
createIndexWithRefreshTuning(String.valueOf(spacingMs))) {
+ float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(),
new int[]{0});
+ long reopensAfterFirst = index.getSearcherRefreshCount();
+
+ // Keep registering fresh requests for most of one spacing window; every
one of them notifies the loop.
+ long deadline = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(spacingMs / 2);
+ int docId = 100;
+ while (System.nanoTime() < deadline) {
+ addVector(index, query, docId++);
+ long generation = index.getLastAddedSequenceNumber();
+ Thread nudge = new Thread(() -> {
+ try {
+ index.awaitSearcherGeneration(generation);
+ } catch (IOException e) {
+ // The wait may outlive the window; the notification it sent is
what this test is about.
+ }
+ });
+ nudge.setDaemon(true);
+ nudge.start();
+ Thread.sleep(20);
+ }
+ Assert.assertEquals(index.getSearcherRefreshCount(), reopensAfterFirst,
+ "No reopen may run inside the spacing window, however many queries
notify the loop");
+ }
+ }
+
+ /// A query interrupted while waiting must surface that as a failure
carrying the cause, not return as though
+ /// the generation had arrived.
+ @Test(timeOut = 60_000)
+ public void testInterruptedWaitFailsRatherThanReturningEarly()
+ throws Exception {
+ try (MutableVectorIndex index =
createIndexWithRefreshTuning(String.valueOf(TimeUnit.SECONDS.toMillis(30)))) {
+ float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(),
new int[]{0});
+ addVector(index, query, 10);
+ long generation = index.getLastAddedSequenceNumber();
+
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ CountDownLatch waiting = new CountDownLatch(1);
+ Thread waiter = new Thread(() -> {
+ waiting.countDown();
+ try {
+ index.awaitSearcherGeneration(generation);
+ failure.set(new AssertionError("Interrupted wait returned
normally"));
+ } catch (Throwable t) {
+ failure.set(t);
+ }
+ });
+ waiter.setDaemon(true);
+ waiter.start();
+ Assert.assertTrue(waiting.await(10, TimeUnit.SECONDS), "Waiter did not
start");
+ awaitWaitCount(index, 2);
+ waiter.interrupt();
+ waiter.join(TimeUnit.SECONDS.toMillis(10));
+
+ Assert.assertTrue(failure.get() instanceof IOException,
+ "An interrupted wait must fail, got: " + failure.get());
+ Assert.assertTrue(failure.get().getMessage().contains("Interrupted while
waiting"),
+ "Expected the interrupt failure, got: " +
failure.get().getMessage());
+ }
+ }
+
+ /// One reopen thread exists per consuming segment per vector column, so a
close that failed to stop it would
+ /// leak a thread per partition -- invisible at runtime because the thread
is a daemon.
+ @Test(timeOut = 60_000)
+ public void testCloseStopsTheReopenThread()
+ throws Exception {
+ String segmentName = "mutableVectorIndexCloseTest_" + System.nanoTime();
+ String threadName = "vector-nrt-reopen-" + segmentName + "-" + COLUMN_NAME;
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "5");
+ MutableVectorIndex index = new MutableVectorIndex(segmentName, COLUMN_NAME,
+ new VectorIndexConfig(false, "HNSW", 5, 1,
VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties));
+ Assert.assertTrue(hasLiveThread(threadName), "The reopen thread must run
while the index is open");
+ index.close();
+ Assert.assertFalse(hasLiveThread(threadName),
+ "close() must stop the reopen thread, or every consuming segment leaks
one");
+ }
+
+ /// A query must never be told a generation is available when the reopen
that would have produced it failed:
+ /// answering from the stale searcher would silently drop rows the filter
names. Closing the index mid-wait is
+ /// the reachable version of that -- the awaited generation can no longer
arrive, so the query must fail.
+ @Test(timeOut = 60_000)
+ public void testFilteredSearchFailsWhenTheAwaitedGenerationCannotArrive()
+ throws Exception {
+ MutableVectorIndex index =
createIndexWithRefreshTuning(String.valueOf(TimeUnit.SECONDS.toMillis(5)));
+ float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(), new
int[]{0});
+ addVector(index, query, 10);
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+ try {
+ // The 30s limiter keeps the reopen pending, so this call is parked in
the wait path when close() lands.
+ Future<ImmutableRoaringBitmap> search = caller.submit(() ->
index.getDocIds(query, 1, bitmapOf(10)));
+ awaitWaitCount(index, 2);
+ index.close();
+ ExecutionException thrown = Assert.expectThrows(ExecutionException.class,
+ () -> search.get(30, TimeUnit.SECONDS));
+ Assert.assertTrue(hasCauseContaining(thrown, "closed while waiting for
the searcher to reopen"),
+ "A query whose generation can never arrive must fail for that
reason: " + thrown.getCause());
+ } finally {
+ caller.shutdownNow();
+ Assert.assertTrue(caller.awaitTermination(10, TimeUnit.SECONDS), "Caller
did not terminate");
+ }
+ }
+
+ @Test(dataProvider = "invalidRefreshTuning")
+ public void testRejectsInvalidRefreshTuning(String key, String value) {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "5");
+ properties.put(key, value);
+ VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 5, 1,
+ VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+ Assert.expectThrows(IllegalArgumentException.class,
+ () -> new MutableVectorIndex("mutableVectorIndexConfigTest_" +
System.nanoTime(), COLUMN_NAME, config));
+ }
+
+ @DataProvider(name = "invalidRefreshTuning")
+ public Object[][] invalidRefreshTuning() {
+ return new Object[][]{
+ {MutableVectorIndex.REFRESH_MIN_INTERVAL_MS, "-1"},
+ {MutableVectorIndex.REFRESH_MIN_INTERVAL_MS, "abc"},
+ {MutableVectorIndex.REFRESH_WAIT_TIMEOUT_MS, "0"},
+ {MutableVectorIndex.REFRESH_WAIT_TIMEOUT_MS, "-5"}
+ };
+ }
+
+ private static boolean hasCauseContaining(Throwable thrown, String fragment)
{
+ for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
+ if (cause.getMessage() != null && cause.getMessage().contains(fragment))
{
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean hasLiveThread(String threadName) {
+ return Thread.getAllStackTraces().keySet().stream()
+ .anyMatch(thread -> thread.isAlive() &&
threadName.equals(thread.getName()));
+ }
+
+ private static void awaitWaitCount(MutableVectorIndex index, long expected)
+ throws InterruptedException, TimeoutException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ while (index.getSearcherRefreshWaitCount() < expected) {
+ if (System.nanoTime() > deadline) {
+ throw new TimeoutException("Only " +
index.getSearcherRefreshWaitCount() + " of " + expected + " waits seen");
+ }
+ Thread.sleep(5);
+ }
+ }
+
+ /// Queries already covered by the searcher's current generation must
neither reopen nor enter the wait path.
+ /// This guards the regression where a filtered query drives a reopen
unconditionally, which is what makes the
+ /// per-query flush cost unbounded. It does not on its own distinguish
publishing the true generation reached
+ /// from the previous per-caller watermark: with no rows added between these
queries, both skip the refresh.
+ @Test
+ public void testFilteredSearchesAfterOneReopenDoNotRefreshAgain()
+ throws Exception {
+ try (MutableVectorIndex index = createIndexWithRefreshTuning()) {
+ float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(),
new int[]{0});
+ addVector(index, query, 10);
+
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(10)).toArray(),
new int[]{10});
+ long refreshesAfterFirstSearch = index.getSearcherRefreshCount();
+ long waitersAfterFirstSearch = index.getSearcherRefreshWaitCount();
+
+ for (int i = 0; i < 20; i++) {
+ Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(10)).toArray(),
new int[]{10});
+ }
+ Assert.assertEquals(index.getSearcherRefreshCount(),
refreshesAfterFirstSearch,
+ "Queries already covered by the published generation must not
trigger another reopen");
+ Assert.assertEquals(index.getSearcherRefreshWaitCount(),
waitersAfterFirstSearch,
+ "Queries already covered by the published generation must not even
enter the waiter path");
+ }
+ }
+
@Test
public void testUnfilteredSearchTranslatesSuppliedPinotDocIds() {
// commitDocs=4 commits on the 4th add, so the committed-view unfiltered
path sees all rows; with doc ids
@@ -440,74 +711,6 @@ public class MutableVectorIndexTest {
return phaser.awaitAdvanceInterruptibly(phase, 10, TimeUnit.SECONDS) >= 0;
}
- /// Holds the winning refresher after it publishes
`_searcherRefreshInProgress`, so at least one concurrent search
- /// is proven to enter the production waiter path before the refresh is
allowed to finish.
- private static class CoordinatedRefreshMutableVectorIndex extends
MutableVectorIndex {
- private final CountDownLatch _winningRefresherEntered = new
CountDownLatch(1);
- private final CountDownLatch _releaseWinningRefresher = new
CountDownLatch(1);
- private final AtomicInteger _observedRefreshWaiters = new AtomicInteger();
- private final CountDownLatch _waitingCallers;
- private volatile boolean _coordinateRefresh;
-
- CoordinatedRefreshMutableVectorIndex(String segmentName, VectorIndexConfig
config, int expectedWaitingCallers) {
- super(segmentName, COLUMN_NAME, config);
- _waitingCallers = new CountDownLatch(expectedWaitingCallers);
- }
-
- void coordinateNextRefresh() {
- _coordinateRefresh = true;
- }
-
- void awaitWinningRefresher()
- throws InterruptedException, TimeoutException {
- if (!_winningRefresherEntered.await(10, TimeUnit.SECONDS)) {
- throw new TimeoutException("No filtered-search caller became the
winning refresher");
- }
- }
-
- void awaitWaitingCallers()
- throws InterruptedException, TimeoutException {
- if (!_waitingCallers.await(10, TimeUnit.SECONDS)) {
- throw new TimeoutException("No concurrent caller entered the refresh
waiter path");
- }
- }
-
- void releaseWinningRefresher() {
- _releaseWinningRefresher.countDown();
- }
-
- @Override
- void beforeSearcherRefresh()
- throws IOException {
- if (!_coordinateRefresh) {
- return;
- }
- _winningRefresherEntered.countDown();
- try {
- if (!_releaseWinningRefresher.await(10, TimeUnit.SECONDS)) {
- throw new IOException("Timed out waiting to release the winning
refresher");
- }
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException("Interrupted while holding the winning
refresher", e);
- } finally {
- _coordinateRefresh = false;
- }
- }
-
- @Override
- void onSearcherRefreshWait() {
- _observedRefreshWaiters.incrementAndGet();
- if (_coordinateRefresh) {
- _waitingCallers.countDown();
- }
- }
-
- int getObservedRefreshWaiters() {
- return _observedRefreshWaiters.get();
- }
- }
-
/// Blocks the first filter membership check until the writer has added its
concurrent rows. This puts the
/// synchronization point inside Lucene's actual filtered search, rather
than merely racing two caller threads.
private static class ConcurrentWriteCoordinatingBitmap extends
MutableRoaringBitmap {
@@ -550,16 +753,24 @@ public class MutableVectorIndexTest {
return index;
}
- private static CoordinatedRefreshMutableVectorIndex
createCoordinatedIndexWithoutCommits(int expectedWaiters) {
+ /// An index whose reopen cadence is pinned for assertions: no commits, no
idle reopen inside the test
+ /// window, and a waiting query allowed to trigger its reopen immediately.
+ private static MutableVectorIndex createIndexWithRefreshTuning() {
+ return createIndexWithRefreshTuning("0");
+ }
+
+ private static MutableVectorIndex createIndexWithRefreshTuning(String
refreshMinIntervalMs) {
Map<String, String> properties = new HashMap<>();
properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
properties.put("commitIntervalMs",
String.valueOf(TimeUnit.DAYS.toMillis(1)));
properties.put("vectorIndexType", "HNSW");
properties.put("vectorDimension", "5");
+ // No rate limiting, so a waiting query's reopen starts immediately and
the test never sits on the limiter.
+ properties.put(MutableVectorIndex.REFRESH_MIN_INTERVAL_MS,
refreshMinIntervalMs);
VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 5, 1,
VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
- CoordinatedRefreshMutableVectorIndex index = new
CoordinatedRefreshMutableVectorIndex(
- "mutableVectorIndexCoalescingTest_" + System.nanoTime(), config,
expectedWaiters);
+ MutableVectorIndex index =
+ new MutableVectorIndex("mutableVectorIndexCoalescingTest_" +
System.nanoTime(), COLUMN_NAME, config);
addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 0);
addVector(index, new float[]{0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, 1);
return index;
diff --git
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidator.java
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidator.java
index b21eb6aa335..cdcb70a803f 100644
---
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidator.java
+++
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidator.java
@@ -42,7 +42,8 @@ public final class VectorIndexConfigValidator {
// HNSW-specific property keys
static final Set<String> HNSW_PROPERTIES = Collections.unmodifiableSet(new
HashSet<>(
Arrays.asList("maxCon", "beamWidth", "maxDimensions", "maxBufferSizeMB",
- "useCompoundFile", "mode", "commit", "commitIntervalMs",
"commitDocs")));
+ "useCompoundFile", "mode", "commit", "commitIntervalMs",
"commitDocs",
+ "refreshMinIntervalMs", "refreshWaitTimeoutMs")));
// IVF_FLAT-specific property keys
static final Set<String> IVF_FLAT_PROPERTIES =
Collections.unmodifiableSet(new HashSet<>(
@@ -260,6 +261,33 @@ public final class VectorIndexConfigValidator {
validatePositiveIntProperty(properties, "beamWidth", "HNSW beamWidth");
validatePositiveIntProperty(properties, "maxDimensions", "HNSW
maxDimensions");
validatePositiveDoubleProperty(properties, "maxBufferSizeMB", "HNSW
maxBufferSizeMB");
+ // Rejected here rather than only in MutableVectorIndex: the constructor
guard runs when a consuming segment is
+ // created on the server, which would stop ingestion for the partition
instead of failing the table config.
+ validateLongProperty(properties, "refreshMinIntervalMs", "HNSW
refreshMinIntervalMs", 0L);
+ validateLongProperty(properties, "refreshWaitTimeoutMs", "HNSW
refreshWaitTimeoutMs", 1L);
+ }
+
+ /// Absent means "use the default"; anything present must parse and satisfy
the bound.
+ ///
+ /// Deliberately parses exactly as the consumer does -- no trimming -- so a
value this accepts cannot then fail
+ /// on the server when a consuming segment is created, which would stop
ingestion for the partition rather than
+ /// rejecting the table config. A key present with a null value is rejected
for the same reason: `getOrDefault`
+ /// does not substitute the default for it.
+ private static void validateLongProperty(Map<String, String> properties,
String key, String displayName,
+ long minInclusive) {
+ if (!properties.containsKey(key)) {
+ return;
+ }
+ String value = properties.get(key);
+ long longValue;
+ try {
+ longValue = Long.parseLong(value);
+ } catch (NumberFormatException | NullPointerException e) {
+ throw new IllegalArgumentException(displayName + " must be a valid long,
got: '" + value + "'");
+ }
+ if (longValue < minInclusive) {
+ throw new IllegalArgumentException(displayName + " must be >= " +
minInclusive + ", got: " + longValue);
+ }
}
/// Validates IVF_FLAT-specific property values.
diff --git
a/pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidatorTest.java
b/pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidatorTest.java
index 89261761a12..95242e3d44b 100644
---
a/pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidatorTest.java
+++
b/pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/creator/VectorIndexConfigValidatorTest.java
@@ -393,6 +393,52 @@ public class VectorIndexConfigValidatorTest {
// Property value validation tests
// ============================================================
+ @Test
+ public void testAcceptRefreshTuning() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "768");
+ properties.put("refreshMinIntervalMs", "0");
+ properties.put("refreshWaitTimeoutMs", "5000");
+
+ VectorIndexConfigValidator.validate(new VectorIndexConfig(properties));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class,
+ expectedExceptionsMessageRegExp = ".*HNSW refreshMinIntervalMs must be
>= 0.*")
+ public void testRejectNegativeRefreshMinIntervalMs() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "768");
+ properties.put("refreshMinIntervalMs", "-1");
+
+ VectorIndexConfigValidator.validate(new VectorIndexConfig(properties));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class,
+ expectedExceptionsMessageRegExp = ".*HNSW refreshWaitTimeoutMs must be
>= 1.*")
+ public void testRejectZeroRefreshWaitTimeoutMs() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "768");
+ properties.put("refreshWaitTimeoutMs", "0");
+
+ VectorIndexConfigValidator.validate(new VectorIndexConfig(properties));
+ }
+
+ /// The validator must be exactly as strict as MutableVectorIndex's own
parse, including whitespace: a value it
+ /// accepts here but the server rejects would stop ingestion instead of
failing the table config.
+ @Test(expectedExceptions = IllegalArgumentException.class,
+ expectedExceptionsMessageRegExp = ".*HNSW refreshMinIntervalMs must be a
valid long.*")
+ public void testRejectNonNumericRefreshMinIntervalMs() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("vectorIndexType", "HNSW");
+ properties.put("vectorDimension", "768");
+ properties.put("refreshMinIntervalMs", " 100 ");
+
+ VectorIndexConfigValidator.validate(new VectorIndexConfig(properties));
+ }
+
@Test(expectedExceptions = IllegalArgumentException.class,
expectedExceptionsMessageRegExp = ".*HNSW maxCon must be a positive
integer.*")
public void testRejectNegativeMaxCon() {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]