A query-time join first collects the join values of the from-documents that match fromQuery, then returns the to-documents that hold one of those values. A from-document without a value should add nothing. The single-valued collectors add a default instead: 0 for a numeric join, the empty term for a term join.
- You index two orders and two customers: order o1 has customer number 7, order o2 has none; the customers are numbered 7 and 0. You join "the customers of the orders":
JoinUtil.createJoinQuery("customerNum", false, "num", Integer.class, type:order, searcher, scoreMode). - You expect
[c7]: o1 points at customer 7 and o2 points at nobody. WithmultipleValuesPerDocument=trueon the same data, that is what you get. - Instead you get
[c0, c7], in every ScoreMode: for o2 the join reads the missing value as 0. The term join does the same with the empty string: customers"c7"and""give["", c7]. - Scores are wrong too. If o1 (score 1) points at customer 0 and o2 (score 5) has no customer, customer 0 scores Max 5.0, Avg 3.0 and Total 6.0 instead of 1.0. There is no error or warning.
Direct JoinUtil users with multipleValuesPerDocument=false, the documented setting for a single-valued key, whose fromQuery matches documents without the key (an optional foreign key, or the package example's same-index content query) while some to-document is keyed 0 or the empty string. It is deterministic and silent; numeric key 0 is the realistic case.
Mechanism, terms and numbers698 words
JoinUtil.createJoinQuery runs a query-time join in two passes: it collects the join values of the "from" documents that match fromQuery, then returns the "to" documents whose to field holds one of those values. A from-document that matches fromQuery but has no value in the from field has nothing to join on, so it should add nothing.
With multipleValuesPerDocument=false, the setting the package docs prescribe for a single-valued from field, it does add something: the term join collects it as the empty term and the numeric join as the number 0. So every to-document keyed "" or 0 comes back as an extra hit in every ScoreMode, and when such a document is a real match its join score folds in the key-less documents' scores. The same call with multipleValuesPerDocument=true on the same single-valued field, the global-ordinals join and TestJoinUtil's own reference collector all skip the key-less document. The cause is four collect sites (three else branches and one long value = 0 initializer) that substitute a default when advanceExact(doc) is false; returning early instead (+10/-18, three files) fixes it. The term join has behaved this way since 4.0.0 (on 3.6.x, the first release with JoinUtil, the same input throws a NullPointerException instead) and the numeric join since its first release, 5.5.0; it is not a regression.
- Query-time join (
JoinUtil.createJoinQuery,@lucene.experimental): "The first pass collects all the terms from a fromField that match the fromQuery. The second pass returns all documents that have matching terms in a toField to the terms collected in the first pass" (package-info.java:65-67on maina9779ef3c193). The term overload reads the from field fromSortedDocValues(orSortedSetDocValues); the numeric overload fromNumericDocValues(orSortedNumericDocValues) and matches the to field with points. multipleValuesPerDocument: "Whether the from field has multiple terms per document" (JoinUtil.java:82); the package example says "Set only to true in the case when your fromField has multiple values per document" (package-info.java:94).advanceExact(doc): "return whether target has a value" (DocValuesIterator.java:25). False means the document has no value in that field.
- Routing. With
multipleValuesPerDocument=falsethe term overload buildsGenericTermsCollector.createCollectorSV(JoinUtil.java:106-108):TermsCollector.SVforScoreMode.None,TermsWithScoreCollector.SVorSV.Avgfor the scoring modes (GenericTermsCollector.java:116-128). The numeric overload uses its own single-valued collector (JoinUtil.java:265-293). - The default. When
advanceExact(doc)is false these collectors substitute a value instead of skipping the document.TermsCollector.SV.collectaddsnew BytesRef(BytesRef.EMPTY_BYTES)(TermsCollector.java:85-93);TermsWithScoreCollector.SVandSV.Avgdo the same (:113-119,:171-177) and then fold the document's score into the empty term's slot. The numeric collector starts fromlong value = 0;, overwrites it only ifadvanceExactsucceeds, and then adds it to the join set and, when scoring, to the score aggregator (JoinUtil.java:283-292). - Second pass. Every to-document whose to field holds a collected value matches, so the one keyed
""or0matches, and its join score aggregates the real and the key-less from-documents. - Everything else skips. The multi-valued term collectors (
TermsCollector.java:64-74,TermsWithScoreCollector.java:218-219,:264-265), the numeric multi-valued collector (JoinUtil.java:236-246), the global-ordinals collectors (GlobalOrdinalsCollectorManager.java:69,GlobalOrdinalsWithScoreCollector.java:116,:145) andTestJoinUtil's reference collector (TestJoinUtil.java:2167-2174) do nothing for a document without a value. WithmultipleValuesPerDocument=truea single-valued field is read throughDocValues.getSortedSet/getSortedNumeric, which wrap it as one value per document (DocValues.java:301-313,:325-337), sotruegives the right answer on the same index.
One index: order o1 (customer 7), order o2 (no customer), customers c7 (7) and c0 (0); fromQuery is type:order. Numeric join, Integer or Long: true gives [c7], false gives [c0, c7] in all five ScoreModes. Term join with customers "c7" and "": true gives [c7], false gives ["", c7]. Scores: o1 points at customer 0 with score 1, o2 has no customer and scores 5; customer 0 gets Max 5.0, Avg 3.0, Total 6.0 instead of 1.0 (Min stayed 1.0 only because the extra score was higher; with an extra score of 0.5, Min is 0.5, Avg 0.75 and Total 1.5, and Max stays 1.0).
Exposure and likelihood481 words
- Trigger. The term overload, or the numeric overload with
IntegerorLong(executed;FloatandDouble+0.0only in the hunt's bounded-exhaustive harness), withmultipleValuesPerDocument=false; afromQuerythat matches at least one document without the from field; and a to-document keyed0(numeric) or""(term). - Two natural shapes. An optional foreign key (an order with no customer, a comment with no author). Or a same-index join with a content query, which is the package example:
multipleValuesPerDocument = false,fromQuery = new TermQuery(new Term("content", searchTerm)), and "toSearcher can be the same as the fromSearcher" (package-info.java:92-100); every matching document that is not a from-document, to-documents included, then contributes the default key. - Effect. Every to-document keyed
0or""is an extra hit, in every ScoreMode (the repro has one; join keys need not be unique, and the second pass returns "all documents that have matching terms",package-info.java:65-67). When such a document is a real match, its score folds in the key-less documents' scores, so Min, Max, Avg or Total can be wrong: Max 5.0, Avg 3.0, Total 6.0 instead of 1.0 in the repro; Min 0.5, Avg 0.75, Total 1.5 with an extra score of 0.5 (executed). No error. Same index or two indexes, with and without assertions, with or without a segment that lacks the field. It is a per-document code path, so it should not depend on index size; only small indexes were run. - Realistic half. Numeric key 0 (0-based ids or codes) is the main impact; the empty-string half needs an empty key, which is legal but rare.
- Not hit (read from source, not executed):
multipleValuesPerDocument=true; the global-ordinals overloads; Solr's term score join, which always passestrue; the Elasticsearch and OpenSearch parent-child joins, which use global ordinals. Solr's numeric score join since SOLR-18440 (Solr main and branch_10x, unreleased) passes the from field'smultiValued()flag, so a single-valued numeric from field reaches the affected collector. The Lucene.NET 4.8 port has the term half of the same code. - Side effect, not a separate bug (hunt probe only).
TermsQuery.equalscompares the fields,fromQueryand the reader id, not the collected terms (TermsQuery.java:107-124, by design since LUCENE-7810). So thefalseandtruejoins over the same field compare equal while matching different documents; with a query cache that caches the join,count()of thetruejoin returned thefalsejoin's cached 2 instead of 1. Without a cache, or with the fix, both return 1. Main'sIndexSearcherhas no default query cache (IndexSearcher.java:82-84, GITHUB#14187): the probe ran under the test framework, which installs a cache that caches every segment. A plain run on the same data with no cache, or withLRUQueryCache(1000, 50MB)and the default caching policy, returns 1 (executed by the fact check). - Reach.
JoinUtilis@lucene.experimental; no public report was found in the searched sources. Likelihood: plausible.
The test shows that a single-valued numeric JoinUtil.createJoinQuery(..., multipleValuesPerDocument=false, ..., Integer.class, ...) treats an order with no customer key as if its key were 0, so it also returns customer 0.
/*
* 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.lucene.search.join;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestNumericJoinFromDocWithoutKey extends LuceneTestCase {
public void testFromDocWithoutKeyDoesNotJoinKeyZero() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(
List.of(
new StringField("type", "order", Field.Store.NO),
new NumericDocValuesField("customer", 7)));
w.addDocument(List.of(new StringField("type", "order", Field.Store.NO))); // no customer key
w.addDocument(List.of(new IntPoint("id", 7), new StoredField("id", 7)));
w.addDocument(List.of(new IntPoint("id", 0), new StoredField("id", 0)));
try (DirectoryReader r = DirectoryReader.open(w)) {
IndexSearcher s = new IndexSearcher(r);
Query fromQuery = new TermQuery(new Term("type", "order"));
Query join =
JoinUtil.createJoinQuery(
"customer", false, "id", Integer.class, fromQuery, s, ScoreMode.None);
List<Integer> joined = new ArrayList<>();
for (ScoreDoc hit : s.search(join, 10).scoreDocs) {
joined.add(s.storedFields().document(hit.doc).getField("id").numericValue().intValue());
}
assertEquals("customers joined to the orders", List.of(7), joined);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-766/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/join/build/libs/lucene-join-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.join.TestNumericJoinFromDocWithoutKey
- Expected vs actual
- Expected: only customer 7 (the one order that has a key points to it). Actual: customers
[7, 0].
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: customers joined to the orders expected:<[7]> but was:<[7, 0]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.apache.lucene.search.join.TestNumericJoinFromDocWithoutKey.testFromDocWithoutKeyDoesNotJoinKeyZero(TestNumericJoinFromDocWithoutKey.java:60)- The javadoc joins on "the same terms in the from field"; a document without the field has no term, not
""or 0. multipleValuesPerDocument=truegives the right answer on the same data, and Lucene's random test expects the flag not to change results.- The global-ordinals join and the test's reference collector skip value-less documents; no doc states a missing-as-default rule.
The full argument370 words
- The contract.
JoinUtil.java:64-66: the query retrieves "all documents that have the same terms in the to field that match with documents matching the specified fromQuery and have the same terms in the from field"; the package doc: "The first pass collects all the terms from a fromField that match the fromQuery" (package-info.java:65-67). The numeric overload inherits "All considerations" of the term overload (JoinUtil.java:122-125). - The flag is a shape hint. "Whether the from field has multiple terms per document" (
JoinUtil.java:82); "Set only to true in the case when your fromField has multiple values per document" (package-info.java:94). The upstream random test pickstrueat random for single-valued data and expects the same results: "single val can be handled by multiple-vals" (TestJoinUtil.java:1535-1536). - Lucene's own oracle skips missing values.
TestJoinUtil's reference collector returns whenadvanceExactis false (TestJoinUtil.java:2167-2174). It was written to skip them from LUCENE-4043 (2012:if (joinValue == null) return;), but that check could not fire, becauseDocTerms.getTermreturned an emptyBytesRef, never null. The skip took effect in 4.2.0 (BinaryDocValues.MISSING), thendocsWithField(LUCENE-5183, 4.5.0). The random test never exercises the difference: every document gets a join value, and "empty strings aren't allowed" (:1701). - The sibling join skips them. The global-ordinals join (LUCENE-6352, 2015, by the same original author) skipped value-less documents from its first commit.
- No rule states the default. No javadoc, CHANGES or MIGRATE entry describes missing-as-
0/empty or requires the term or numeric overloads'fromQueryto match only documents with the from field. That precondition ("the fromQuery can only match \"from\" documents") is written for the global-ordinals overloads only (JoinUtil.java:467-468,:505-506). - Origin is mechanical. The 2012 single-valued collector called
FieldCache.DocTerms.getTerm(doc), which returned "an empty (length=0) BytesRef if the doc did not have this field"; the same API offeredexists(docID), unused. Missing numeric doc values read as0. The iterator migrations LUCENE-7407 and LUCENE-8152 wrote the old defaults out as explicitelsebranches; their threads do not discuss join semantics. - Precedent. LUCENE-7871 (#8922), "false positive match BlockJoinSelector[SortedDV] when child value is absent", in the same module, was fixed as a bug in 7.0.
The devil's-advocate check266 words
The strongest case for "intended": JoinUtil is @lucene.experimental; the else branches are explicit, so the default looks deliberate; no public report was found in 14 years of releases; the random test says "empty strings aren't allowed"; a caller can add FieldExistsQuery(fromField) to fromQuery; and multipleValuesPerDocument=false could be read as "every from-document has exactly one value". The numeric half has the stronger case: before 7.0 a missing numeric doc value read as 0, and callers may rely on a missing key joining as 0.
Why it fails: the explicit branches are a mechanical carry-over of the old doc-values defaults, not a decision (no doc, no CHANGES entry, no discussion). The test comment is about how the random test generates data, and it does not touch the numeric-0 half. No documented precondition asks callers to filter fromQuery, the package example does not, and the package doc calls fromQuery "usually the user specified query" (package-info.java:73-74). The "exactly one value" reading is contradicted by the package example and by TestJoinUtil.java:1535. For the numeric half, 0 is also a legal key, no join doc names it a sentinel, and since 7.0 advanceExact reports absence, which the multi-valued numeric collector honours on the same data (JoinUtil.java:236-246); the compatibility risk is real, which is why the fix needs a CHANGES entry. Every reviewer concluded the case fails; the reading-only reviews by another model family rated its strength moderate, weak and moderate, and the reading-only cross-model fact check of this doc rated it weak for the term half and moderate for the numeric half.
Recommended Skip from-documents without a value
Return early when advanceExact(doc) is false at the four single-valued collect sites (+10/-18 in three files), as the multi-valued and global-ordinals collectors already do. The repro passes, the whole join module passes (107 tests, 0 failures) and lint passes. Add a CHANGES entry: some caller may rely on missing == 0.
// TermsCollector.SV
if (docValues.advanceExact(doc)) {
collectorTerms.add(docValues.lookupOrd(docValues.ordValue()));
}
// TermsWithScoreCollector.SV and SV.Avg (then the score aggregation as before)
if (docValues.advanceExact(doc) == false) {
return;
}
BytesRef value = docValues.lookupOrd(docValues.ordValue());
// JoinUtil, numeric single-valued collector
if (numericDocValues.advanceExact(doc) == false) {
return;
}
long value = numericDocValues.longValue();All options and trade-offs3 options · 585 words
The recommended fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); it was also validated earlier with the fixed classes shadowed into the stock jars. The documentation-only option was not run; the user workaround was checked on stock main.
In TermsCollector.SV.collect, TermsWithScoreCollector.SV.collect, TermsWithScoreCollector.SV.Avg.collect and the numeric single-valued collector in JoinUtil, return (or collect nothing) when advanceExact(doc) is false, instead of substituting new BytesRef(BytesRef.EMPTY_BYTES) or 0. This is what multipleValuesPerDocument=true, the global-ordinals join and TestJoinUtil's reference collector already do. It skips the hash insert and score work for documents without a value; performance was not measured.
// TermsCollector.SV
if (docValues.advanceExact(doc)) {
collectorTerms.add(docValues.lookupOrd(docValues.ordValue()));
}
// TermsWithScoreCollector.SV and SV.Avg (then the score aggregation as before)
if (docValues.advanceExact(doc) == false) {
return;
}
BytesRef value = docValues.lookupOrd(docValues.ordValue());
// JoinUtil, numeric single-valued collector
if (numericDocValues.advanceExact(doc) == false) {
return;
}
long value = numericDocValues.longValue();Trade-offs
- Behaviour change. Callers that relied on a missing value joining as
0or""(undocumented) lose those hits; say so in a CHANGES entry (Bug Fixes). - Coordination. Open PR #16805 ("Collector manager join", active) reworks
GenericTermsCollector,TermsWithScoreCollectorand the numeric collector forCollectorManagerand keepslong value = 0(it does not touchTermsCollector), and #13747 moves all of them and keeps both defaults; the fix should land in or rebase onto #16805. The hunt's sibling finding about the 0.0 score sentinel in the sameSV.collectmethods is a different defect and fix line. - Add a regression test with a value-less from-document and to-documents keyed
0and""; the repro is one.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: the repro passes 3 of 3 andTestJoinUtil16 of 16, with assertions on and off; the wholelucene/joinmodule with the repro added: 17 classes, 107 tests, 3 skipped, 0 failures;checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass. Without the fix the same worktree fails the repro 3 of 3 with assertions on and off. - With the fixed classes shadowed into the stock
a9779ef3c193jars: the repro and the independent reproducer's test pass 3 of 3; upstreamTestJoinUtilpasses 16 of 16 on seeds DEADBEEF, 1234ABCD, 0BADF00D and 7E57C0DE. - The hunt's bounded-exhaustive harness, in the two of its 21 scopes that were re-run with the fix (one term, one numeric): the missing-value mismatch class of the single-valued joins disappears and no new class appears. The other 19 scopes were not re-run with the fix.
State in the javadoc that with multipleValuesPerDocument=false a matching from-document without a value joins as "" / 0, and tell callers to restrict fromQuery to documents with the from field.
Trade-offs
Keeps the flag changing results on single-valued data, contradicts the "same terms in the from field" contract and the global-ordinals join, and leaves the package example (a content fromQuery) wrong. Not recommended.
Pass multipleValuesPerDocument=true (correct on a single-valued field: the repro's true checks pass), or add new FieldExistsQuery(fromField) as a FILTER clause to fromQuery: on stock main that returns [c7] for the term and numeric joins in all five ScoreModes (2 of 2 tests pass; they check hit ids only, for the term join and the Integer numeric join; scores, Long, Float and Double were not checked).
Trade-offs
Each caller must know about it; the cost of either variant was not measured.
Not a Lucene security issue under its SECURITY.md, but downstream, a single-valued join used as an authorization or visibility filter would also show the to-documents keyed 0 or the empty string; from reading their source, only Solr's unreleased numeric score join reaches this code, not Solr's term score join or the Elasticsearch and OpenSearch parent-child joins.
Code reading first: the empty-term fallback was noticed while working on a sibling join bug, and an independent reviewer from another model family (OpenAI GPT-5.6) flagged the numeric value = 0. A bounded-exhaustive differential check of every JoinUtil overload against a brute-force join then showed it is the only hit or score mismatch of the single-valued joins within its bounds.
The method in full236 words
Method: code reading, then bounded-exhaustive translation validation on the stock jar (the registry labels it non-formal because the first signal was code reading; the finder tiered it semi-formal). No model checker, solver or prover was involved.
- First signals: the term half was noted, unassessed, in the notes of the hunt's sibling join finding ("mapped to the empty term", 2026-10-26); the numeric half was flagged on 2026-10-27 by the hunt's planning critic, an independent reviewer from another model family (OpenAI GPT-5.6), from a source check of
long value = 0. - A query-time-join hunt then built a harness that runs every
JoinUtiloverload on the real jar against a brute-force join: 1-3 from by 1-3 to documents, join values{"", a, U+FF21, U+10400},{0, 1, -1, 2}and{+0.0, 1.0, -0.0, 2.0}, every from-query subset, every ScoreMode, one or per-document segments, same and two indexes, deletions. Within those bounds (from-scores 1, 2, 4, 8 and 16), missing-as-default was the only hit-set or score mismatch class of the single-valued term and numeric joins; in the two scopes re-run with the fix (one term, one numeric) it disappears and no new class appears. - The finder wrote a public-API JUnit (2026-10-27). It was then checked by an independent reproducer, reading-only reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a history sweep, a prior-report search, a security screen and a credibility audit.
No prior report found (2026-10-27) in over 200 recorded queries over GitHub, JIRA, the mailing lists, Solr, Lucene.NET, Elasticsearch, OpenSearch, Stack Overflow and the web, plus the changed files of all 408 open PRs. Open PRs #16805 and #13747 rework or move these collectors and keep the defaults.
Searches4 searches
Verdict new: 39 hits triaged, none the same bug. Keyword search cannot prove absence; GitHub search handles "0" and "empty" poorly (compensated by reading every issue and PR that names JoinUtil, TermsCollector or TermsWithScoreCollector), and private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Solr, Lucene.NET, Elasticsearch, OpenSearch; dev@, java-user@, issues@, commits@; JIRA archive; the web | 2026-10-27 | the hunt's prior-report tool: four runs of 51 queries each (API names and symptoms, downstream projects, history issues and doc-values missing-value terms, web) plus the finder's run | no report; context only: #4676 (LUCENE-3602), #6930 (LUCENE-5868), #6247 (LUCENE-5183), #9200 (LUCENE-8152) |
| GitHub (apache/lucene, apache/solr, apache/lucenenet, OpenSearch) | 2026-10-27 | 16 manual searches; bodies and comments of 31 issues and PRs read or grepped; changed files of all 408 open apache/lucene PRs | open PRs #16805 (reworks the collectors except TermsCollector) and #13747 (moves them all) keep the defaults |
| LUCENE and SOLR JIRA | 2026-10-27 | 29 issues with all comments; 5 JQL searches | SOLR-15482 (Solr's own cross-collection join changed to ignore documents without the from field); LUCENE-7871 (absent-value false positive in BlockJoinSelector, fixed in 7.0); LUCENE-7810 (why TermsQuery.equals ignores the collected terms) |
| mailing lists; web; Stack Overflow | 2026-10-27 | 4 list threads read in full; 10 web queries; Stack Overflow through its API | nothing about this bug; two Stack Overflow questions report too few join hits (the opposite symptom) |
The term join since 4.0.0 (LUCENE-3602, 2012; on 3.6.x, the first release with JoinUtil, the same input throws instead) and the numeric join since its first release, 5.5.0 (LUCENE-5868, 2015). Every executed version from 4.0.0 to 10.5.2, and main, fails. Not a regression, and not fixed upstream.
Versions tested7 rows
Term join: introduced by the trunk commit of LUCENE-3602 "Add join query to Lucene" (#4676), 7cdb8028c10 (martijnvg, 2012-01-16, trunk svn commit, no PR), first released in 4.0.0; the scoring collector (LUCENE-4043, 36acada7629 / 3b51274435e, 2012-05-29) copied the default. Numeric join: LUCENE-5868 "JoinUtil support for NUMERIC docValues fields" (#6930), branch_5x 3c7f3f4c435 / trunk bc51c86b4bbf (2015-12-07; CHANGES credits Alexey Zelin via Mikhail Khludnev), first released in 5.5.0.
Kept, not caused, by LUCENE-7095 (5733f05c70d, 6.0.0, point-based numeric join), LUCENE-7407 (f7aa200d406, 7.0.0), LUCENE-8152 (aa157f18335 / 6ae6ce2f42b, 7.3.0) and LUCENE-9796 (f3a284ad830, 9.0.0). On 3.6.x (the 3.x port) the same input throws NullPointerException instead: same root mistake, different symptom, long end-of-life. Upstream main f7bcc0abd816, branch_10x, branch_10_6 and branch_10_5 keep the lines (read).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | JUnit 3 of 3 fail through gradle and the runner, assertions on and off; the reproducer's test 3 of 3; term and numeric ports fail |
| 9.5.0, 9.12.3, 10.5.2 | affected | JUnit 3 of 3 fail (10.5.2 also with assertions off); ports fail on 9.12.3 and 10.5.2 |
| 5.5.0 to 9.0.0 | affected | ports: numeric join [c0, c7] with inflated scores on 5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.2.1, 7.3.0, 8.11.4 and 9.0.0; term join [c7, c_empty] on the same releases except 5.5.0 and 6.0.0 (same code, read only) |
| 4.0.0 to 5.0.0 | term join only | term port fails on 4.0.0, 4.2.0, 4.5.0, 4.10.4 and 5.0.0; no numeric overload before 5.5.0 (5.4.1 does not compile) |
| 3.6.0, 3.6.2 | different symptom | createJoinQuery throws NullPointerException on the same input |
| 3.5.0 | no JoinUtil | the API does not exist |
| main + fix | not affected | the JUnit, both ports and TestJoinUtil pass |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own runs; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication, an audit and this doc's gradle runs on main confirmed it and validated the fix.
Checks and verdicts410 words
Every executing check wrote or re-ran a JUnit on stock main; every multipleValuesPerDocument=true control passed. The reviews from the other model family could not run code, so all execution evidence comes from the hunt's own runs.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Harness: missing-as-default is the only hit-set or score mismatch class of the single-valued joins, and it disappears in the two scopes re-run with the fix; the JUnit fails 3 of 3 on main (on and off), 10.5.2, 9.12.3 and 9.5.0; upstream TestJoinUtil passes 16 of 16 with the fix. |
| independent reproducer | real | Own test with two indexes, Integer and Long: fails 3 of 3 on main (on and off), 10.5.2 and 9.12.3; every true variant correct; passes with the fix. |
| cross-model reviews (intended, refute, root cause) | real (medium); not real (low, procedural); real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) could only read code. The refute lens's "not real" rests only on the missing own execution; all agreed on the root cause and found the inputs legal; separate re-runs failed 3 of 3. |
| intended-behaviour devil's advocate | real | Built the strongest "intended" case and showed why it fails; re-ran the JUnit on main (on and off) and 10.5.2: 3 of 3 fail. |
| adjudication | confirmed (high) | Re-ran the reproducer's test with a new seed, assertions on and off: 3 of 3 fail; duplicate of none. |
| security screen | not security-sensitive | A silent wrong result on legal data from correct API use; breaks no guarantee Lucene's SECURITY.md documents for untrusted input. |
| credibility audit and re-runs | keep | Re-ran the finder's and the reproducer's tests with fresh seeds (fail 3 of 3) and the fix (pass 3 of 3); checked the score against the rubric. |
| this doc | reproduced; fix validated | Gradle in a scratch worktree at a9779ef3c193: stock 3 of 3 fail (on and off); with the fix 3 of 3 pass, TestJoinUtil 16 of 16, the join module 107 tests with 0 failures, lint passes; the FieldExistsQuery workaround passes on stock. |
| fact checks of this doc | bug confirmed; wording corrected | An independent fact check re-ran the gradle repro, the fix, the reproducer's test and the workaround and probed scores and the query cache; a reading-only fact check by an independent reviewer from another model family (OpenAI GPT-5.6) and a diagram check followed. They found wording errors only; all were fixed or answered. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 556 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,666 words
- Registry title
- JoinUtil's single-valued term and numeric joins treat a 'from' document without a join value as if it had the empty term or the number 0, so they also return the 'to' document whose key is empty or 0 and add the value-less documents' scores to it (since 4.0.0)
- Area
- join / JoinUtil query-time join (single-valued term and numeric collectors)
- Priority
- Medium, rank 245: A deterministic silent extra hit plus inflated scores in JoinUtil's documented single-valued joins since 4.0, needing only a from-document without the field and a to-key of 0 or the empty string, so it heads the JoinUtil silent rows just above the score-only sibling LFV-227.
- Impact class
- silent wrong results
- Impact note
- Hits callers of JoinUtil.createJoinQuery (term overload, or numeric overload with Integer or Long [exec]; Float and Double +0.0 only in the finder's bounded-exhaustive harness) with multipleValuesPerDocument=false, the setting the package docs prescribe for a single-valued from field, whose fromQuery matches at least one document that lacks the from field (an optional foreign key, or a same-index join whose fromQuery, like the package example's content query, also matches documents that are not from-documents) while some to-document has the key 0 (numeric) or the empty string (term, legal but rare): every to-document with that key is returned as an extra hit in every ScoreMode, and when it is a real match its score folds in the value-less documents' scores (in the repro Max 5.0, Avg 3.0, Total 6.0 where 1.0 is correct; a lower extra score lowers Min instead); no error, default codec, writer and searcher, -ea and -da, same index or two indexes (a per-document code path; only small indexes were run); multipleValuesPerDocument=true on the same field and the global-ordinals join give the correct answer; released since 4.0.0 (term) and 5.5.0 (numeric) through 10.6.0 (10.6.0 by reading).
- Summary
- You join orders to customers with JoinUtil.createJoinQuery("customer", false, "id", fromQuery, searcher, scoreMode), or the numeric overload with Integer.class, where fromQuery matches two orders and only one of them has a customer key, and expect only that order's customer back, because the javadoc returns the to-documents that 'have the same terms' as the matching from-documents. Instead the order without a key is collected as if its key were the empty term (term join) or 0 (numeric join), so the customer whose id is "" or 0 is returned too, in every ScoreMode, and a real customer 0 also gets the key-less order's score (Max 5.0 instead of 1.0). The same call with multipleValuesPerDocument=true on the same single-valued field, and the global-ordinals join, return the correct answer.
- Introduced
- Term join: 7cdb8028c1067b24b0141fdbb8600053ac6e84c9 (LUCENE-3602 'Added query time joining', by martijnvg, trunk svn r1232223; issue apache/lucene#4676, no PR; first released in 4.0.0): its single-valued collector added FieldCache.DocTerms.getTerm(doc), which by the contract of the time returned an empty BytesRef for a document without the field, while the multi-valued collector of the same commit skipped such documents; the scoring collector TermsWithScoreCollector.SV (LUCENE-4043, 36acada7629 / 3b51274435e, 2012-05-29, 4.0.0) copied the default. Numeric join: LUCENE-5868 (issue #6930; branch_5x 3c7f3f4c435, trunk bc51c86b4bbf; committed by mkhludnev, CHANGES credits Alexey Zelin; first released in 5.5.0), where a missing numeric doc value read as 0; kept by the point-based rewrite LUCENE-7095 (5733f05c70d, 6.0.0). The iterator migrations LUCENE-7407 (f7aa200d406, 7.0.0), LUCENE-8152 (aa157f18335 / 6ae6ce2f42b, 7.3.0) and LUCENE-9796 (f3a284ad830, 9.0.0) wrote the defaults out as the explicit else-branches seen on main; they kept the behaviour, they did not introduce it [read: git blame, git log -S on EMPTY_BYTES and 'long value = 0', collect() bodies at 121 release tags; exec: release sweep]. Not a regression: the term join has it since 4.0.0 (on 3.6.x, the first release with JoinUtil, the same input throws NullPointerException instead) and the numeric join since its first release, 5.5.0. (2012-01-16 (term join, LUCENE-3602); 2015-12-07 (numeric join, LUCENE-5868))
- First affected version
- 4.0.0 [exec] for the term join (the 4.0.0-alpha and -beta pre-releases contain the introducing commit by git ancestry [read, not executed]); 5.5.0 [exec] for the numeric join (5.4.1 has no numeric overload [exec]). Before that: 3.5.0 has no JoinUtil [exec]; on 3.6.0-3.6.2 (the 3.x port) the same input makes createJoinQuery throw NullPointerException instead [exec], the same root mistake with a different symptom, not part of this row
- Affected versions
- [exec] term join: 4.0.0, 4.2.0, 4.5.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.0.0, 7.2.1, 7.3.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main a9779ef3c193 (-ea and -da) with version-portable main() ports, each also running multipleValuesPerDocument=true as a control, which was correct on every version; numeric join: 5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.2.1, 7.3.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da); the candidate JUnit fails 3 of 3 on 9.5.0, 9.12.3, 10.5.2 (-ea and -da) and main (-ea and -da), the independent reproduction's JUnit 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3. [read] every other release from 4.0.0 (term) and 5.5.0 (numeric) to 10.5.2 has the same collect() bodies; so do branch_10_6, released as 10.6.0 on 2026-10-29, and the unreleased branch_10x (and 'long value = 0' on branch_10_5). Not fixed upstream: main f7bcc0abd816 (2026-10-27) has the same lines [read].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-27, the prior-report record: three scripted prior-report searches of 51 queries each plus a web re-ingest and the finder's run, over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web; 16 manual GitHub searches; bodies and comments of 31 issues and PRs read or grepped; all 408 open apache/lucene PRs listed with their changed files; 29 LUCENE and SOLR JIRA issues read with all comments plus 5 JQL searches; 4 list threads; 10 web queries and Stack Overflow through its API; 39 hits triaged, none of them this bug; the independent reproduction's and the intended-behaviour check's own searches found nothing either); not fixed upstream (main f7bcc0abd816, branch_10x and branch_10_5 keep the lines: TermsCollector.java:90, TermsWithScoreCollector.java:118 and :176, JoinUtil.java:285); open PR #16805 ('Collector manager join', active) reworks these collectors except TermsCollector and keeps 'long value = 0', and #13747 moves them all and keeps the defaults, so a fix should be coordinated with #16805; nearest items, not reports of this bug: #4676 (LUCENE-3602) and #6930 (LUCENE-5868), the introducing issues, with no discussion of absent values; #6247 (LUCENE-5183), whose commit made the test reference skip absent values; #8922 (LUCENE-7871), an analogous absent-value false positive in BlockJoinSelector fixed as a bug in 7.0; #8861 (LUCENE-7810), why TermsQuery.equals ignores the collected terms; SOLR-15482, Solr's own cross-collection join (not JoinUtil) changed to ignore documents without the from field; downstream reach [read, not executed]: Solr's term score join passes multipleValuesPerDocument=true (unaffected), Solr's numeric score join since SOLR-18440 (Solr main and branch_10x, unreleased) passes the from field's multiValued flag and so reaches the affected collector for a single-valued numeric from field, Elasticsearch and OpenSearch parent-child joins use the global-ordinals overload (unaffected)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 9.5.0, 9.12.3 and 10.5.2 release jars through the runner; version-portable ports of the same scenario on 4.0.0 to 10.5.2. The fix was validated in the scratch worktree: the repro,TestJoinUtil, the wholelucene/joinmodule, and the format, ECJ and forbidden-API checks. Upstream mainf7bcc0abd816(2026-10-27) has the same lines (read, not executed).
- Finding record
a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/TermsCollector.java:83-93 (SV.collect: else term = new BytesRef(BytesRef.EMPTY_BYTES), then add); lucene/join/src/java/org/apache/lucene/search/join/TermsWithScoreCollector.java:111-120 and :169-178 (SV and SV.Avg collect, same default, then the score aggregation); lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:280-293 (numeric single-valued collector: 'long value = 0;' added to joinValues and to scoreAggregator whether or not advanceExact found a value)a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:52-55 (@lucene.experimental), :61-67 (contract: to-documents that 'have the same terms in the to field that match with documents matching the specified fromQuery and have the same terms in the from field'), :119-126 (numeric overload: 'All considerations from createJoinQuery(...) are applicable here too'), :463-468 ('the fromQuery can only match "from" documents', stated for the global-ordinals overloads only); lucene/join/src/java/org/apache/lucene/search/join/package-info.java:63-75 (two-pass contract; fromQuery 'is usually the user specified query') and :90-100 (example: multipleValuesPerDocument = false 'Set only to true in the case when your fromField has multiple values per document', a content TermQuery as fromQuery, 'toSearcher can be the same as the fromSearcher')a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1535 ('single val can be handled by multiple-vals'), :1698-1701 ('empty strings aren't allowed' in the random test), :2160-2176 (reference collector SortedDocValuesJoinScoreCollector returns when advanceExact is false)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/package-info.java:65-67, 94; JoinUtil.java:82, 106-108, 236-246, 265-293; GenericTermsCollector.java:116-128a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/TermsCollector.java:64-74, 85-93; TermsWithScoreCollector.java:113-196, 218-219, 264-265; GlobalOrdinalsCollectorManager.java:69; GlobalOrdinalsWithScoreCollector.java:116, 145a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:25; DocValues.java:301-313, 325-337a Lucene main checkout (a9779ef3c193) lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:2160-2174
- Who is affected
a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/package-info.java:92-100; TermsQuery.java:107-129
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:54, 64-66, 82, 122-125, 467-468; package-info.java:65-67, 73-74, 94a Lucene main checkout (a9779ef3c193) lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1535-1536, 1701, 2160-2174
- TestNumericJoinFromDocWithoutKey.java (JUnit test):
repro/LFV-766/src/org/apache/lucene/search/join/TestNumericJoinFromDocWithoutKey.java(64 lines)
Made from the executed repro on main a9779ef3c193; the PNG is the final summary frame. Re-rendered on 2026-10-27 after the doc's checks: the title card says every to-document keyed 0 or "" is returned, the score step names the from query's two boosted ConstantScoreQuery clauses, and the Affected line says 3.6.x throws instead of returning the extra hit.
assets/LFV-766/bug.png(556 KB)
