This is an automated email from the ASF dual-hosted git repository.
mkhludnev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 2f9c868c24a SOLR-18508: {!auxIndexJoin} prune join-index column via
`[to-min..to-max]` range (#5033)
2f9c868c24a is described below
commit 2f9c868c24aa3cd60e34df1c103e89d485fcf9f5
Author: Mikhail Khludnev <[email protected]>
AuthorDate: Wed Oct 7 18:04:44 2026 +0300
SOLR-18508: {!auxIndexJoin} prune join-index column via `[to-min..to-max]`
range (#5033)
---
...8508-auxIndexJoin-skip-out-of-range-columns.yml | 11 +++
.../join/auxindexjoin/JoinIndexScorerSupplier.java | 92 ++++++++++++++++------
2 files changed, 81 insertions(+), 22 deletions(-)
diff --git
a/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
b/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
new file mode 100644
index 00000000000..78e7acf62c2
--- /dev/null
+++ b/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
@@ -0,0 +1,11 @@
+title: >
+ Faster {!auxIndexJoin} confirmation: join columns whose to-doc range doesn't
cover the candidate doc are skipped or dropped without being read.
+
+ Diagnostic logging now reports empty and skipped columns.
+type: changed
+authors:
+ - name: Mikhail Khludnev
+ nick: mkhl
+links:
+ - name: SOLR-18508
+ url: https://issues.apache.org/jira/browse/SOLR-18508
diff --git
a/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
b/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
index 9fa09423676..d55bc3710d7 100644
---
a/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
+++
b/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
@@ -75,9 +75,11 @@ import org.slf4j.LoggerFactory;
* lazy one never fires, so a log with no {@code cause=lazy-to-segment}
line is the expected
* shape.
* <li>{@code evt=ctx} -- this context finished setting up: how many (from,
to) pairs contributed,
- * how many the a-priori from-edge check dropped before any column was
opened, and how loose
- * the resulting approximation is. Emitted from {@link #get}, with
{@code mode=eager} when
- * every column is drained up front instead of confirmed lazily.
+ * how many the a-priori from-edge check dropped before any column was
opened ({@code
+ * cellsEmpty} of them because the pair maps no from doc at all, the
rest because the query's
+ * from matches miss the pair's from-range), and how loose the resulting
approximation is.
+ * Emitted from {@link #get}, with {@code mode=eager} when every column
is drained up front
+ * instead of confirmed lazily.
* <li>{@code evt=drain} -- one join column was read through during
confirmation, and whether that
* read confirmed the doc under test (an early exit) or not.
* <li>{@code evt=done} -- confirmation reached a terminal state for this
context: every column
@@ -139,6 +141,12 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
/** pairs the a-priori from-edge check dropped, i.e. columns never opened at
all */
private int leafsDroppedApriori;
+ /**
+ * those of {@link #leafsDroppedApriori} whose pair maps no from doc to this
to segment at all: a
+ * property of the index layout, not of the query
+ */
+ private int leafsEmpty;
+
/** calls to {@link LazyConfirmationIterator#matches()} */
private int confirmCalls;
@@ -148,6 +156,11 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
/** columns read through during confirmation */
private int leafsDrained;
+ /**
+ * columns dropped unread during confirmation, because the doc under test
passed their to-range
+ */
+ private int leafsPassed;
+
/** from-docs walked while draining, i.e. the column reads laziness is
trying to avoid */
private long fromDocsWalked;
@@ -199,6 +212,26 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
} // otherwise we don't know if 0 is real false
}
+ // only cells whose to-range covers the doc can confirm it. One ending
below it can't map it
+ // or any later doc, since the approximation only moves forward, so it's
dropped unread. One
+ // starting above it stays for the docs to come.
+ final int doc = approximation.docID();
+ List<LeafJoin> covering = new ArrayList<>(leafJoins.size());
+ for (LeafJoin joinTask : new ArrayList<>(leafJoins)) {
+ if (doc > joinTask.toDocEdges()[1]) {
+ JoinIndexScorerSupplier.this.dropJoinLeaf(joinTask);
+ leafsPassed++;
+ } else if (doc >= joinTask.toDocEdges()[0]) {
+ covering.add(joinTask);
+ }
+ }
+ if (covering.isEmpty()) {
+ if (leafJoins.isEmpty()) {
+ converge(doc);
+ }
+ return false;
+ }
+
IndexSearcher freshSearcher =
JoinIndexScorerSupplier.this.joinIndex.acquire();
try {
// the result matters here too: a cell whose column the reaper dropped
is rebuilt by the
@@ -208,13 +241,12 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
entry.getKey().bind(entry.getValue());
}
assert
Objects.equals(JoinIndexScorerSupplier.this.lastSeenJoinSearcher,
freshSearcher);
- for (LeafJoin joinTask : new ArrayList<>(leafJoins)) {
- if (falseNegToDocsBits == null) {
- this.shift = approximation.docID();
- falseNegToDocsBits = new FixedBitSet(lastToDoc + 1 - shift);
- }
- DrainOutcome outcome =
- joinTask.dumpMatchesInto(falseNegToDocsBits, shift,
approximation.docID());
+ if (falseNegToDocsBits == null) {
+ this.shift = doc;
+ falseNegToDocsBits = new FixedBitSet(lastToDoc + 1 - shift);
+ }
+ for (LeafJoin joinTask : covering) {
+ DrainOutcome outcome = joinTask.dumpMatchesInto(falseNegToDocsBits,
shift, doc);
fromDocsWalked += joinTask.lastDrainWalked();
if (outcome == DrainOutcome.CONFIRMED) {
logDrain(joinTask, true, falseNegToDocsBits);
@@ -231,17 +263,30 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
} finally {
JoinIndexScorerSupplier.this.joinIndex.release(freshSearcher);
}
- // drop all to masks, got no hit - it means it's a true negative now.
-
- falsePositiveToDocsBits.clear(shift, lastToDoc + 1);
- FixedBitSet.orRange(
- falseNegToDocsBits, 0, falsePositiveToDocsBits, shift, lastToDoc -
shift + 1);
+ // every covering cell was drained without a hit, so the doc is a true
negative; cells
+ // starting above it may remain, and until they are gone the union is
still half-read
+ if (leafJoins.isEmpty()) {
+ converge(doc);
+ }
+ return false;
+ }
- boolean matched = falsePositiveToDocsBits.get(approximation.docID());
- // every column has now been drained: the half-read union has converged,
so from here on
- // every answer -- true or false -- is a free lookup and there is
nothing left to prune
- logConfirmationDone(matched ? "converged-on-match" :
"converged-on-refutation");
- return matched;
+ /**
+ * Narrows the approximation to the drained union once no cell is left, so
from here on every
+ * answer -- true or false -- is a free lookup and there is nothing left
to prune. Docs below
+ * {@code doc} are behind the iterator and left as they are.
+ */
+ private void converge(int doc) {
+ if (falseNegToDocsBits == null) {
+ // nothing was ever drained: every cell was passed unread, so nothing
ahead can match
+ falsePositiveToDocsBits.clear(doc, lastToDoc + 1);
+ } else {
+ falsePositiveToDocsBits.clear(shift, lastToDoc + 1);
+ FixedBitSet.orRange(
+ falseNegToDocsBits, 0, falsePositiveToDocsBits, shift, lastToDoc -
shift + 1);
+ }
+ assert !falsePositiveToDocsBits.get(doc);
+ logConfirmationDone("converged");
}
@Override
@@ -719,7 +764,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
JoinIndexUtils.logDiagnostic(
log,
"AUXIJOIN evt=ctx ctx={} toSeg={} mode={} toMaxDoc={} cellsCreated={}"
- + " cellsDroppedApriori={} cellsLive={} buildMs={} approxCard={}
approxSpanSum={} approxFrom={} approxTo={}"
+ + " cellsDroppedApriori={} cellsEmpty={} cellsLive={} buildMs={}
approxCard={} approxSpanSum={} approxFrom={} approxTo={}"
+ " colToCountSum={}",
ctxId,
JoinIndexUtils.segmentName(toContext),
@@ -727,6 +772,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
toContext.reader().maxDoc(),
joinLeafsCreated,
leafsDroppedApriori,
+ leafsEmpty,
leafJoins.size(),
joinIndexBuildNanos / 1_000_000L,
falsePositiveToDocsBits == null ? 0 :
falsePositiveToDocsBits.cardinality(),
@@ -749,7 +795,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
JoinIndexUtils.logDiagnostic(
log,
"AUXIJOIN evt=done ctx={} toSeg={} reason={} confirmCalls={}
freeHits={} cellsDrained={}"
- + " cellsLive={} fromDocsWalked={} rebindsAfterReap={}
fkLateLoads={} modelsReleased={}"
+ + " cellsPassed={} cellsLive={} fromDocsWalked={}
rebindsAfterReap={} fkLateLoads={} modelsReleased={}"
+ " buildMs={}",
ctxId,
JoinIndexUtils.segmentName(toContext),
@@ -757,6 +803,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
confirmCalls,
confirmFreeHits,
leafsDrained,
+ leafsPassed,
leafJoins.size(),
fromDocsWalked,
rebindsAfterReap,
@@ -988,6 +1035,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
if (minFromDoc < 0) {
// {-1, -1} sentinel: this pair maps no from doc to any to doc at all
leafsDroppedApriori++;
+ leafsEmpty++;
dropJoinLeaf(cell);
return false;
}