All findings · Lucene finding · LFV-766 · joinutil-sv-missing-from-value-joins-default

JoinUtil's single-valued joins treat a from-document without a join value as key 0 or the empty string

With multipleValuesPerDocument=false, JoinUtil.createJoinQuery treats a matching "from" document that has no join value as if its value were 0 (numeric join) or "" (term join). So an order with no customer joins customer 0: the join returns an extra hit and adds that order's score to customer 0.

Summary

Priority
Medium, rank 245A 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
silent wrong results
Since
4.0.0 (term join, 2012); 5.5.0 (numeric join)introduced 2012-01-16 · over 14 years
Affected
4.0.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
code reading, then a bounded-exhaustive check of JoinUtil against a brute-force join

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Diagram of the bug: orders o1 (customer 7) and o2 (no customer) on the from side, customers c7 (7) and c0 (0) on the to side. The single-valued join collects 7 for o1 and, because o2 has no value, 0 for o2, so it returns c0 and c7 instead of c7; with multipleValuesPerDocument=true it returns c7. When o1 (score 1) points at customer 0 and o2 (score 5) has no customer, customer 0 scores Max 5.0, Avg 3.0, Total 6.0 instead of 1.0.
The repro's orders and customers: the order without a customer is read as customer 0, so customer 0 joins and takes that order's score. Select the still to open it at full size. Generated with AI, checked against the code.
  1. 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).
  2. You expect [c7]: o1 points at customer 7 and o2 points at nobody. With multipleValuesPerDocument=true on the same data, that is what you get.
  3. 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].
  4. 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.

Who is affected

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.

Terms

  • 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-67 on main a9779ef3c193). The term overload reads the from field from SortedDocValues (or SortedSetDocValues); the numeric overload from NumericDocValues (or SortedNumericDocValues) 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.

The bug

  1. Routing. With multipleValuesPerDocument=false the term overload builds GenericTermsCollector.createCollectorSV (JoinUtil.java:106-108): TermsCollector.SV for ScoreMode.None, TermsWithScoreCollector.SV or SV.Avg for the scoring modes (GenericTermsCollector.java:116-128). The numeric overload uses its own single-valued collector (JoinUtil.java:265-293).
  2. The default. When advanceExact(doc) is false these collectors substitute a value instead of skipping the document. TermsCollector.SV.collect adds new BytesRef(BytesRef.EMPTY_BYTES) (TermsCollector.java:85-93); TermsWithScoreCollector.SV and SV.Avg do the same (:113-119, :171-177) and then fold the document's score into the empty term's slot. The numeric collector starts from long value = 0;, overwrites it only if advanceExact succeeds, and then adds it to the join set and, when scoring, to the score aggregator (JoinUtil.java:283-292).
  3. Second pass. Every to-document whose to field holds a collected value matches, so the one keyed "" or 0 matches, and its join score aggregates the real and the key-less from-documents.
  4. 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) and TestJoinUtil's reference collector (TestJoinUtil.java:2167-2174) do nothing for a document without a value. With multipleValuesPerDocument=true a single-valued field is read through DocValues.getSortedSet / getSortedNumeric, which wrap it as one value per document (DocValues.java:301-313, :325-337), so true gives the right answer on the same index.

The numbers

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 Integer or Long (executed; Float and Double +0.0 only in the hunt's bounded-exhaustive harness), with multipleValuesPerDocument=false; a fromQuery that matches at least one document without the from field; and a to-document keyed 0 (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 0 or "" 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 passes true; 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's multiValued() 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.equals compares the fields, fromQuery and the reader id, not the collected terms (TermsQuery.java:107-124, by design since LUCENE-7810). So the false and true joins over the same field compare equal while matching different documents; with a query cache that caches the join, count() of the true join returned the false join's cached 2 instead of 1. Without a cache, or with the fix, both return 1. Main's IndexSearcher has 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 with LRUQueryCache(1000, 50MB) and the default caching policy, returns 1 (executed by the fact check).
  • Reach. JoinUtil is @lucene.experimental; no public report was found in the searched sources. Likelihood: plausible.

Minimal repro

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.

TestNumericJoinFromDocWithoutKey.java (JUnit test)64 lines
/*
 * 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);
      }
    }
  }
}
Command
# 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].

All files of this repro: repro/LFV-766/

More files, environment, full output39 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • The javadoc joins on "the same terms in the from field"; a document without the field has no term, not "" or 0.
  • multipleValuesPerDocument=true gives 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 picks true at 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 when advanceExact is 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, because DocTerms.getTerm returned an empty BytesRef, never null. The skip took effect in 4.2.0 (BinaryDocValues.MISSING), then docsWithField (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' fromQuery to 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 offered exists(docID), unused. Missing numeric doc values read as 0. The iterator migrations LUCENE-7407 and LUCENE-8152 wrote the old defaults out as explicit else branches; 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.

Suggested fix

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.

Skip from-documents without a value (validated by the checks) (recommended)

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 0 or "" (undocumented) lose those hits; say so in a CHANGES entry (Bug Fixes).
  • Coordination. Open PR #16805 ("Collector manager join", active) reworks GenericTermsCollector, TermsWithScoreCollector and the numeric collector for CollectorManager and keeps long value = 0 (it does not touch TermsCollector), 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 same SV.collect methods is a different defect and fix line.
  • Add a regression test with a value-less from-document and to-documents keyed 0 and ""; the repro is one.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the repro passes 3 of 3 and TestJoinUtil 16 of 16, with assertions on and off; the whole lucene/join module with the repro added: 17 classes, 107 tests, 3 skipped, 0 failures; checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass. 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 a9779ef3c193 jars: the repro and the independent reproducer's test pass 3 of 3; upstream TestJoinUtil passes 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.

Document the default instead

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.

Workaround for users today (no Lucene change)

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.

Downstream note

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.

How it was found

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 JoinUtil overload 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Solr, Lucene.NET, Elasticsearch, OpenSearch; dev@, java-user@, issues@, commits@; JIRA archive; the web2026-10-27the 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 runno 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-2716 manual searches; bodies and comments of 31 issues and PRs read or grepped; changed files of all 408 open apache/lucene PRsopen PRs #16805 (reworks the collectors except TermsCollector) and #13747 (moves them all) keep the defaults
LUCENE and SOLR JIRA2026-10-2729 issues with all comments; 5 JQL searchesSOLR-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 Overflow2026-10-274 list threads read in full; 10 web queries; Stack Overflow through its APInothing about this bug; two Stack Overflow questions report too few join hits (the opposite symptom)

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedJUnit 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 affectedJUnit 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 affectedports: 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 onlyterm 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 symptomcreateJoinQuery throws NullPointerException on the same input
3.5.0 no JoinUtilthe API does not exist
main + fix not affectedthe JUnit, both ports and TestJoinUtil pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHarness: 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 realOwn 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 realBuilt 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-sensitiveA 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 keepRe-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 validatedGradle 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 correctedAn 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,666 words

Finding record

LFV-766, snapshot of 2026-11-06

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 whole lucene/join module, and the format, ECJ and forbidden-API checks. Upstream main f7bcc0abd816 (2026-10-27) has the same lines (read, not executed).

Lucene and upstream sources

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-128
  • a 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, 145
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:25; DocValues.java:301-313, 325-337
  • a 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, 94
  • a Lucene main checkout (a9779ef3c193) lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1535-1536, 1701, 2160-2174

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.