raghavyadav01 commented on code in PR #19760:
URL: https://github.com/apache/pinot/pull/19760#discussion_r4199239588
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java:
##########
@@ -218,60 +224,80 @@ public MutableRoaringBitmap getDocIds(String searchQuery)
{
@Override
public MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String
optionsString) {
- if (optionsString != null && !optionsString.trim().isEmpty()) {
- LuceneTextIndexUtils.LuceneTextIndexOptions options =
LuceneTextIndexUtils.createOptions(optionsString);
- if (!options.getOptions().isEmpty()) {
- return getDocIdsWithOptions(searchQuery, options);
- }
- }
- return getDocIdsWithoutOptions(searchQuery);
- }
-
- private MutableRoaringBitmap getDocIdsWithoutOptions(String searchQuery) {
MutableRoaringBitmap docIds = new MutableRoaringBitmap();
Collector docIDCollector = new LuceneDocIdCollector(docIds,
_docIdTranslator);
try {
- // Lucene query parsers are generally stateful and a new instance must
be created per query.
- QueryParserBase parser =
_queryParserClassConstructor.newInstance(_column, _analyzer);
- // Phrase search with prefix/suffix matching may have leading *. E.g.,
`*pache pinot` which can be stripped by
- // the query parser. To support the feature, we need to explicitly set
the config to be true.
- if (_enablePrefixSuffixMatchingInPhraseQueries) {
- parser.setAllowLeadingWildcard(true);
- }
- if (_useANDForMultiTermQueries) {
- parser.setDefaultOperator(QueryParser.Operator.AND);
- }
- Query query = parser.parse(searchQuery);
- if
(_queryParserClass.equals("org.apache.lucene.queryparser.classic.QueryParser")
- && _enablePrefixSuffixMatchingInPhraseQueries) {
- query = LuceneTextIndexUtils.convertToMultiTermSpanQuery(query);
- }
- _indexSearcher.search(query, docIDCollector);
+ _indexSearcher.search(buildQuery(searchQuery, optionsString),
docIDCollector);
return docIds;
} catch (Exception e) {
- String msg =
- "Caught exception while searching the text index for column:" +
_column + " search query:" + searchQuery;
- throw new RuntimeException(msg, e);
+ // Wording matches what the options path threw before the two paths were
merged; it is asserted on in
+ //
TextSearchQueriesTest#testTextFilterOptimizerWithWildcardsDifferentOptions.
+ throw new RuntimeException(
+ "Failed while searching the text index for column " + _column + "
with search query: " + searchQuery, e);
}
}
- // TODO: Consider creating a base class (e.g., BaseLuceneTextIndexReader) to
avoid code duplication
- // for getDocIdsWithOptions method across LuceneTextIndexReader,
MultiColumnLuceneTextIndexReader,
- // RealtimeLuceneTextIndex, and MultiColumnRealtimeLuceneTextIndex
- private MutableRoaringBitmap getDocIdsWithOptions(String actualQuery,
- LuceneTextIndexUtils.LuceneTextIndexOptions options) {
- MutableRoaringBitmap docIds = new MutableRoaringBitmap();
- Collector docIDCollector = new LuceneDocIdCollector(docIds,
_docIdTranslator);
+ /// Parses a search string into a Lucene query, applying the configured
parser and options.
+ ///
+ /// Shared by the doc-id and the count paths so that a `count(*)` and the
filter it counts can never
+ /// interpret the same query differently.
+ private Query buildQuery(String searchQuery)
+ throws Exception {
+ // Lucene query parsers are generally stateful and a new instance must be
created per query.
+ QueryParserBase parser = _queryParserClassConstructor.newInstance(_column,
_analyzer);
+ // Phrase search with prefix/suffix matching may have leading *. E.g.,
`*pache pinot` which can be stripped by
+ // the query parser. To support the feature, we need to explicitly set the
config to be true.
+ if (_enablePrefixSuffixMatchingInPhraseQueries) {
+ parser.setAllowLeadingWildcard(true);
+ }
+ if (_useANDForMultiTermQueries) {
+ parser.setDefaultOperator(QueryParser.Operator.AND);
+ }
+ Query query = parser.parse(searchQuery);
+ if
(_queryParserClass.equals("org.apache.lucene.queryparser.classic.QueryParser")
+ && _enablePrefixSuffixMatchingInPhraseQueries) {
+ query = LuceneTextIndexUtils.convertToMultiTermSpanQuery(query);
+ }
+ return query;
+ }
+
+ @Override
+ public int getNumMatchingDocs(String searchQuery, @Nullable String
optionsString) {
try {
- Query query =
LuceneTextIndexUtils.createQueryParserWithOptions(actualQuery, options,
_column, _analyzer);
- _indexSearcher.search(query, docIDCollector);
- return docIds;
+ // A Lucene index with more documents than the segment means the doc-id
mapping is broken -- a state
+ // getDocIds surfaces as an IndexOutOfBoundsException from the
translator. Counting does not consult the
+ // translator, so without this guard the corruption would become a
silently inflated count instead.
+ // Fall back so that it keeps failing loudly.
+ if (_indexReader.numDocs() > _numDocs) {
+ return getDocIds(searchQuery, optionsString).getCardinality();
+ }
+ return LuceneTextIndexUtils.countWithoutMaterializing(_indexSearcher,
+ buildQuery(searchQuery, optionsString));
} catch (Exception e) {
throw new RuntimeException(
- "Failed while searching the text index for column " + _column + "
with search query: " + actualQuery, e);
+ "Caught exception while counting matches in the text index for
column:" + _column + " search query:"
Review Comment:
`checkTerminationAndSampleUsagePeriodically` throws `TerminationException`,
which is a `QueryException` — wrapping it here turns a timeout or an accountant
kill into a generic `QUERY_EXECUTION` error, since
`BaseSingleBlockCombineOperator.onProcessSegmentsException` only preserves the
error code for `QueryException`. That's exactly what the
`CollectionTerminatedException` conversion in `LuceneDocIdCollector` exists to
avoid, so the count path loses the termination reason the doc-id path keeps.
Can you rethrow `QueryException` unwrapped before the generic wrap?
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/LuceneTextIndexUtils.java:
##########
@@ -361,4 +371,55 @@ public String getMinimumShouldMatch() {
public static LuceneTextIndexOptions createOptions(String optionsString) {
return new LuceneTextIndexOptions(optionsString);
}
+
+ /// Counts the documents matching `query` without materializing their ids.
+ ///
+ /// A `count(*)` over a text filter only needs the size of the match set.
Collecting every matching doc id
+ /// into a bitmap first costs time proportional to the number of matches,
which on a frequent term over a
+ /// large segment is most of the query.
+ ///
+ /// Follows the recipe in Lucene's
[org.apache.lucene.search.IndexSearcher#count(Query)]: one rewrite and one
+ /// [Weight], then per leaf take [Weight#count] when the leaf can answer
from index metadata and iterate that
+ /// leaf only when it cannot. Probing per leaf rather than bailing out
wholesale means a query that is
+ /// countable on some leaves still pays iteration on the rest only.
+ ///
+ /// Wrapping in [ConstantScoreQuery] before the rewrite is NOT redundant
with [ScoreMode#COMPLETE_NO_SCORES]:
+ /// it is what makes several rewritten shapes produce count-friendly
weights, and mirrors `IndexSearcher#count`.
+ /// Removing it silently degrades those shapes to full iteration.
+ ///
+ /// The iterating branch keeps
[QueryThreadContext#checkTerminationAndSampleUsagePeriodically] so a slow count
+ /// still honours the query timeout. The metadata probe itself is not
interruptible, matching the rewrite that
+ /// already happens inside `IndexSearcher#search`.
+ ///
+ /// Counts are invariant under Pinot's Lucene-to-Pinot doc id translation,
which is 1:1, so no translator is
+ /// needed here. Callers that cannot guarantee that mapping must not use
this method.
+ ///
+ /// @return the number of matching documents
+ public static int countWithoutMaterializing(IndexSearcher searcher, Query
query)
+ throws IOException {
+ Query rewritten = searcher.rewrite(new ConstantScoreQuery(query));
+ Weight weight = searcher.createWeight(rewritten,
ScoreMode.COMPLETE_NO_SCORES, 1f);
+ int total = 0;
+ for (LeafReaderContext leaf : searcher.getIndexReader().leaves()) {
+ int leafCount = weight.count(leaf);
+ if (leafCount != -1) {
+ total += leafCount;
+ continue;
+ }
+ Scorer scorer = weight.scorer(leaf);
Review Comment:
Lucene 9.12's `IndexSearcher#count` doesn't iterate a `Scorer` by hand on
the fallback — it does `search(new ConstantScoreQuery(q), new
TotalHitCountCollectorManager(...))`, which keeps the `BulkScorer` path.
`Weight#count` returns -1 for anything but simple terms, so every conjunction,
disjunction and phrase lands in this branch and gives up bulk scoring that the
old `search(query, LuceneDocIdCollector)` was getting; a wide OR could come out
slower than the bitmap it replaced.
Would a counting `Collector` modelled on `LuceneDocIdCollector` work here
instead — same `CollectionTerminatedException` handling, with the
`weight.count(leaf)` shortcut in `getLeafCollector` the way
`TotalHitCountCollector` does it? It would also keep the query-cache key
identical to the doc-id path; right now the `ConstantScoreQuery` wrapper means
the same predicate gets two entries in the shared `LRUQueryCache` when
`enableQueryCache` is on.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java:
##########
@@ -218,60 +224,80 @@ public MutableRoaringBitmap getDocIds(String searchQuery)
{
@Override
public MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String
optionsString) {
- if (optionsString != null && !optionsString.trim().isEmpty()) {
- LuceneTextIndexUtils.LuceneTextIndexOptions options =
LuceneTextIndexUtils.createOptions(optionsString);
- if (!options.getOptions().isEmpty()) {
- return getDocIdsWithOptions(searchQuery, options);
- }
- }
- return getDocIdsWithoutOptions(searchQuery);
- }
-
- private MutableRoaringBitmap getDocIdsWithoutOptions(String searchQuery) {
MutableRoaringBitmap docIds = new MutableRoaringBitmap();
Collector docIDCollector = new LuceneDocIdCollector(docIds,
_docIdTranslator);
try {
- // Lucene query parsers are generally stateful and a new instance must
be created per query.
- QueryParserBase parser =
_queryParserClassConstructor.newInstance(_column, _analyzer);
- // Phrase search with prefix/suffix matching may have leading *. E.g.,
`*pache pinot` which can be stripped by
- // the query parser. To support the feature, we need to explicitly set
the config to be true.
- if (_enablePrefixSuffixMatchingInPhraseQueries) {
- parser.setAllowLeadingWildcard(true);
- }
- if (_useANDForMultiTermQueries) {
- parser.setDefaultOperator(QueryParser.Operator.AND);
- }
- Query query = parser.parse(searchQuery);
- if
(_queryParserClass.equals("org.apache.lucene.queryparser.classic.QueryParser")
- && _enablePrefixSuffixMatchingInPhraseQueries) {
- query = LuceneTextIndexUtils.convertToMultiTermSpanQuery(query);
- }
- _indexSearcher.search(query, docIDCollector);
+ _indexSearcher.search(buildQuery(searchQuery, optionsString),
docIDCollector);
return docIds;
} catch (Exception e) {
- String msg =
- "Caught exception while searching the text index for column:" +
_column + " search query:" + searchQuery;
- throw new RuntimeException(msg, e);
+ // Wording matches what the options path threw before the two paths were
merged; it is asserted on in
+ //
TextSearchQueriesTest#testTextFilterOptimizerWithWildcardsDifferentOptions.
+ throw new RuntimeException(
+ "Failed while searching the text index for column " + _column + "
with search query: " + searchQuery, e);
}
}
- // TODO: Consider creating a base class (e.g., BaseLuceneTextIndexReader) to
avoid code duplication
- // for getDocIdsWithOptions method across LuceneTextIndexReader,
MultiColumnLuceneTextIndexReader,
- // RealtimeLuceneTextIndex, and MultiColumnRealtimeLuceneTextIndex
- private MutableRoaringBitmap getDocIdsWithOptions(String actualQuery,
- LuceneTextIndexUtils.LuceneTextIndexOptions options) {
- MutableRoaringBitmap docIds = new MutableRoaringBitmap();
- Collector docIDCollector = new LuceneDocIdCollector(docIds,
_docIdTranslator);
+ /// Parses a search string into a Lucene query, applying the configured
parser and options.
+ ///
+ /// Shared by the doc-id and the count paths so that a `count(*)` and the
filter it counts can never
+ /// interpret the same query differently.
+ private Query buildQuery(String searchQuery)
+ throws Exception {
+ // Lucene query parsers are generally stateful and a new instance must be
created per query.
+ QueryParserBase parser = _queryParserClassConstructor.newInstance(_column,
_analyzer);
+ // Phrase search with prefix/suffix matching may have leading *. E.g.,
`*pache pinot` which can be stripped by
+ // the query parser. To support the feature, we need to explicitly set the
config to be true.
+ if (_enablePrefixSuffixMatchingInPhraseQueries) {
+ parser.setAllowLeadingWildcard(true);
+ }
+ if (_useANDForMultiTermQueries) {
+ parser.setDefaultOperator(QueryParser.Operator.AND);
+ }
+ Query query = parser.parse(searchQuery);
+ if
(_queryParserClass.equals("org.apache.lucene.queryparser.classic.QueryParser")
+ && _enablePrefixSuffixMatchingInPhraseQueries) {
+ query = LuceneTextIndexUtils.convertToMultiTermSpanQuery(query);
+ }
+ return query;
+ }
+
+ @Override
+ public int getNumMatchingDocs(String searchQuery, @Nullable String
optionsString) {
try {
- Query query =
LuceneTextIndexUtils.createQueryParserWithOptions(actualQuery, options,
_column, _analyzer);
- _indexSearcher.search(query, docIDCollector);
- return docIds;
+ // A Lucene index with more documents than the segment means the doc-id
mapping is broken -- a state
+ // getDocIds surfaces as an IndexOutOfBoundsException from the
translator. Counting does not consult the
+ // translator, so without this guard the corruption would become a
silently inflated count instead.
+ // Fall back so that it keeps failing loudly.
+ if (_indexReader.numDocs() > _numDocs) {
Review Comment:
The translator is indexed by `context.docBase + doc` in
`LuceneDocIdCollector`, which is `maxDoc()` space, so `maxDoc()` is what
actually trips the `IndexOutOfBounds` this guard stands in for. Once a leaf has
deletions `numDocs() < maxDoc()`, and an index that would blow up in
`getDocIds` can still slip past here into the count path. Can we compare
against `maxDoc()` instead?
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java:
##########
@@ -218,60 +224,80 @@ public MutableRoaringBitmap getDocIds(String searchQuery)
{
@Override
public MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String
optionsString) {
- if (optionsString != null && !optionsString.trim().isEmpty()) {
- LuceneTextIndexUtils.LuceneTextIndexOptions options =
LuceneTextIndexUtils.createOptions(optionsString);
- if (!options.getOptions().isEmpty()) {
- return getDocIdsWithOptions(searchQuery, options);
- }
- }
- return getDocIdsWithoutOptions(searchQuery);
- }
-
- private MutableRoaringBitmap getDocIdsWithoutOptions(String searchQuery) {
MutableRoaringBitmap docIds = new MutableRoaringBitmap();
Collector docIDCollector = new LuceneDocIdCollector(docIds,
_docIdTranslator);
try {
- // Lucene query parsers are generally stateful and a new instance must
be created per query.
- QueryParserBase parser =
_queryParserClassConstructor.newInstance(_column, _analyzer);
- // Phrase search with prefix/suffix matching may have leading *. E.g.,
`*pache pinot` which can be stripped by
- // the query parser. To support the feature, we need to explicitly set
the config to be true.
- if (_enablePrefixSuffixMatchingInPhraseQueries) {
- parser.setAllowLeadingWildcard(true);
- }
- if (_useANDForMultiTermQueries) {
- parser.setDefaultOperator(QueryParser.Operator.AND);
- }
- Query query = parser.parse(searchQuery);
- if
(_queryParserClass.equals("org.apache.lucene.queryparser.classic.QueryParser")
- && _enablePrefixSuffixMatchingInPhraseQueries) {
- query = LuceneTextIndexUtils.convertToMultiTermSpanQuery(query);
- }
- _indexSearcher.search(query, docIDCollector);
+ _indexSearcher.search(buildQuery(searchQuery, optionsString),
docIDCollector);
return docIds;
} catch (Exception e) {
- String msg =
- "Caught exception while searching the text index for column:" +
_column + " search query:" + searchQuery;
- throw new RuntimeException(msg, e);
+ // Wording matches what the options path threw before the two paths were
merged; it is asserted on in
+ //
TextSearchQueriesTest#testTextFilterOptimizerWithWildcardsDifferentOptions.
+ throw new RuntimeException(
+ "Failed while searching the text index for column " + _column + "
with search query: " + searchQuery, e);
Review Comment:
Merging the two paths also changes the error text for the no-options path,
which has been `Caught exception while searching the text index for column:...`
up to now. Harmless for the test you cite, but this is the common path and it's
the kind of string people end up alerting on — intentional?
##########
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexCountTest.java:
##########
@@ -0,0 +1,199 @@
+/**
+ * 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.text;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.commons.io.FileUtils;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.ConstantScoreQuery;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.Weight;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import
org.apache.pinot.segment.local.segment.creator.impl.text.LuceneTextIndexCreator;
+import
org.apache.pinot.segment.local.segment.index.text.TextIndexConfigBuilder;
+import org.apache.pinot.segment.local.utils.LuceneTextIndexUtils;
+import org.apache.pinot.segment.spi.index.TextIndexConfig;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Covers the `count(*)` short-circuit:
[LuceneTextIndexReader#getNumMatchingDocs] and the
+/// [LuceneTextIndexUtils#countWithoutMaterializing] it delegates to.
+///
+/// Not thread-safe: the fixtures write to per-instance temp directories and
are shared across the test
+/// methods of one instance, which TestNG runs single-threaded by default.
+public class LuceneTextIndexCountTest {
+ // Unique per run: a fixed path under the shared temp dir races other
executors/surefire forks, whose
+ // @BeforeClass cleanup would delete a live mmapped index out from under
this one.
+ private static final File TEMP_DIR =
+ new File(FileUtils.getTempDirectory(),
LuceneTextIndexCountTest.class.getSimpleName() + "-" + UUID.randomUUID());
+ private static final File INDEX_DIR = new File(TEMP_DIR, "single-leaf");
+ private static final File MULTI_LEAF_DIR = new File(TEMP_DIR, "multi-leaf");
+ private static final String COLUMN = "body";
+
+ /// Every document carries `sentinelall` so there is a term matching all
documents. `to` cannot serve that
+ /// role: it is in the default English stop-word set and matches nothing.
+ private static final String[] DOCS = {
+ "failed to place order for user alice sentinelall",
+ "failed to charge card for user bob sentinelall",
+ "connection refused by payment service sentinelall",
+ "connection established to cache service sentinelall",
+ "order confirmation email sent to carol sentinelall",
+ "cache miss while loading order details sentinelall",
+ "payment accepted for order 4815 sentinelall",
+ "unrelated housekeeping log line sentinelall"
+ };
+
+ @BeforeClass
+ public void setUp()
+ throws IOException {
+ FileUtils.forceMkdir(INDEX_DIR);
+ TextIndexConfig config = new TextIndexConfigBuilder().build();
+ try (LuceneTextIndexCreator creator = new LuceneTextIndexCreator(COLUMN,
INDEX_DIR, true, false, null, null,
+ config)) {
+ for (String doc : DOCS) {
+ creator.add(doc);
+ }
+ creator.seal();
+ }
+
+ // LuceneTextIndexCreator.seal() force-merges to one segment, so the
fixture above can never exercise
+ // cross-leaf accumulation. Build a second index by hand, committing
between batches and never merging,
+ // so leaves() > 1.
+ FileUtils.forceMkdir(MULTI_LEAF_DIR);
+ try (Directory directory = FSDirectory.open(MULTI_LEAF_DIR.toPath());
+ IndexWriter writer = new IndexWriter(directory, new
IndexWriterConfig(new StandardAnalyzer()))) {
+ for (String doc : DOCS) {
+ Document document = new Document();
+ document.add(new TextField(COLUMN, doc, Field.Store.NO));
+ writer.addDocument(document);
+ writer.commit();
+ }
+ }
+ }
+
+ @AfterClass
+ public void tearDown()
+ throws IOException {
+ FileUtils.deleteDirectory(TEMP_DIR);
+ }
+
+ @DataProvider(name = "queries")
+ public Object[][] queries() {
+ return new Object[][]{
+ {"order", null},
+ {"connection", null},
+ {"housekeeping", null},
+ {"nonexistentterm", null},
+ {"sentinelall", null},
+ {"to", null},
+ {"failed AND order", null},
+ {"failed OR connection", null},
+ {"order AND NOT failed", null},
+ {"\"place order\"", null},
+ {"\"failed to place order\"", null},
+ {"conn*", null},
+ {"c?che", null},
+ {"connection~1", null},
+ {"/conn.*/", null},
+ {"failed order connection", "parser=MATCH,minimumShouldMatch=2"},
+ {"*tion", "allowLeadingWildcard=true"}
+ };
+ }
+
+ /// The count must equal what materializing the doc ids would produce, for
every query shape -- both the
+ /// shapes Lucene counts from metadata and the ones it has to iterate.
+ @Test(dataProvider = "queries")
+ public void testCountMatchesMaterializedDocIds(String query, String options)
+ throws IOException {
+ try (LuceneTextIndexReader reader = new LuceneTextIndexReader(COLUMN,
INDEX_DIR, DOCS.length, Map.of())) {
Review Comment:
This fixture always ends up with an identity Lucene→Pinot mapping
(`LuceneTextIndexCreator` stores `DocID == i`, so `TryOptimize` collapses to
`NoOpDocIdTranslator`), which means the invariant the whole change rests on —
that counts survive translation — is never tested against a real translator. A
realtime conversion with a sorted column gives a genuine permutation via
`mutableToImmutableDocIdMap`, and passing a `numDocs` smaller than the index
would cover the `numDocs() > _numDocs` fallback that nothing exercises today.
Worth adding both?
--
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]