Only the total is wrong; the hits, their order and their distances are correct.
- Index 3 documents with one
LatLonPointeach, commit, and delete one. Until a merge, the segment still holds the deleted copy. - Call
LatLonPoint.nearest(searcher, "point", 40, 50, 10). You expect 2 hits andtotalHits= 2 with relationEQUAL_TO, which means exact. - You get the 2 right hits, but
totalHits= "3 hits",EQUAL_TO: more than the 2 live documents in the whole index. - Default settings show it too: 10 documents and one
updateDocumentgive 10 hits and "11 hits". With 100 unmerged updates of 1,000 documents the total is "1100 hits". - Why: the total is the sum of each segment's
PointValues.getDocCount(), "documents that have indexed at least one point", which ignores deletions. The live docs are used only to filter the hits.
Anyone who calls the public LatLonPoint.nearest and reads totalHits (an "N places found" count, paging) on an index with deleted or updated documents not yet merged away. Default settings; each deleted or replaced copy with the field adds one, usually until a merge. The hits are never wrong. Code search found no caller in Elasticsearch, OpenSearch or Solr.
Mechanism, terms and numbers427 words
LatLonPoint.nearest(searcher, field, lat, lon, n) finds the n indexed points closest to a location. It skips deleted documents when it picks the hits, but it takes its total from PointValues#getDocCount(), an index statistic that still counts deleted documents, and labels that total TotalHits.Relation.EQUAL_TO (exact). After an ordinary delete or update, and until a merge drops the old copy, the hits are right but the "exact" total is too high by the number of non-live documents that carry the field; it can exceed reader.numDocs(). Core has had it since nearest moved there in 10.0.0: all 12 10.x releases up to 10.5.2, 10.6.0 (by reading) and main ea57360912ed.
LatLonPoint.nearest: a public static method that walks the BKD tree of aLatLonPointfield and returns the n closest points asTopFieldDocs. Its javadoc says it is "functionally equivalent to runningMatchAllDocsQuerywith aLatLonDocValuesField#newDistanceSort", but faster.TotalHits: thevalueplus arelation.EQUAL_TOmeans the value is the exact hit count;GREATER_THAN_OR_EQUAL_TOmeans it is a lower bound.- Deleted but not merged: a delete, or the old copy left by
updateDocumentorsoftUpdateDocument, only clears the document's live bit. The document stays in its segment, and in the segment's statistics, until a merge rewrites the segment without it.
LatLonPoint.java:421-430loops over the segments. For each one it addspoints.getDocCount()to the total (:425): "the total number of documents that have indexed at least one point" (PointValues.java:495-496), deletions not subtracted.- The same loop collects each segment's live docs (
:428), but only forNearestNeighbor, whose visitor skips every document whose live bit is clear (NearestNeighbor.java:156). So the hits are right. :442returnsnew TotalHits(totalHits, TotalHits.Relation.EQUAL_TO).
| index | maxDoc / numDocs | hits | totalHits |
|---|---|---|---|
| 3 docs, no deletion (control) | 3 / 3 | 3 | "3 hits" (right) |
| 3 docs, 1 deleted | 3 / 2 | 2 | "3 hits" EQUAL_TO |
default IndexWriterConfig, 10 docs, 1 updateDocument | 11 / 10 | 10 | "11 hits" EQUAL_TO |
1,000 docs, 100 updateDocument calls, n = 100 | 1100 / 1000 | 100 | "1100 hits" EQUAL_TO |
| 6 docs, 4 with a point, one of those deleted | 6 / 5 | 3 | "4 hits" EQUAL_TO |
softUpdateDocument with a soft-deletes field does the same (maxDoc 4, numDocs 3, "4 hits"), and so does a reader that hides documents through getLiveDocs(). For a fixed field and reader, the wrong number does not depend on lat, lon or n: the total is summed before the search runs.
Exposure and likelihood276 words
- Who is hit. Callers of the public, non-experimental static
LatLonPoint.nearestthat readTopFieldDocs.totalHits: a displayed result count, a page count, or any logic that trusts anEQUAL_TOtotal. - Default, not opt-in. No setting is needed: the default
IndexWriterConfig, codec, merge policy andIndexSearchershow it after oneupdateDocument. Tests useNoMergePolicyonly to keep the deleted document in place. - Size of the error. The total is too high by the number of non-live documents that carry the field in the searched segments. An update deletes (or soft-deletes) every document that matches its term, so with unique ids it adds one: 100 updates of 1,000 documents give "1100 hits" (executed). The old copy counts as long as a segment holds it: usually until a merge, longer under a
SoftDeletesRetentionMergePolicy. The defaultTieredMergePolicytargets at most 20% deleted documents (TieredMergePolicy.java:121-124); that is a goal for choosing merges, not a hard cap. For a dense field at 20% deleted the total is about 25% above the live count, and for a sparse field the relative error can be larger. This is arithmetic, not measured. - What the damage looks like. A total that is too high and claims to be exact. Possible application effects (hypothetical): a count larger than the results that can be served, or a loop that pages until it has
totalHitsresults. - Reach. In-tree, the only non-test caller is the JMH benchmark (
NearestNeighborBenchmark.java:109). A code search of the default branches of Elasticsearch, OpenSearch and Solr (2026-10-25, repeated 2026-10-26) found no call; code search has limits, and private applications are unknown. So it mainly affects applications that use Lucene directly.
The test shows that LatLonPoint.nearest returns an EQUAL_TO TotalHits that counts a deleted (unmerged) document.
/*
* 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;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.LatLonPoint;
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.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestNearestTotalHitsDeletedDoc extends LuceneTestCase {
public void testDeletedDocNotCounted() throws Exception {
IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 3; i++) {
Document doc = new Document();
doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
doc.add(new LatLonPoint("point", 0, 0));
w.addDocument(doc);
}
w.deleteDocuments(new Term("id", "0"));
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
TotalHits total = LatLonPoint.nearest(searcher, "point", 0, 0, 10).totalHits;
boolean exact = total.relation() == TotalHits.Relation.EQUAL_TO;
// 2 live documents: the deleted one must not be counted; actual EQUAL_TO 3
assertTrue(
"expected exactly 2 hits (or a lower bound <= 2), got " + total,
exact ? total.value() == 2 : total.value() <= 2);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-088/, 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: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.TestNearestTotalHitsDeletedDoc
- Expected vs actual
- Expected, with 3 documents and 1 deleted: exactly 2 hits (or a lower bound <= 2). Actual (-ea and -da): EQUAL_TO 3 (
got 3 hits).
More files, environment, full output42 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: expected exactly 2 hits (or a lower bound <= 2), got 3 hits
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.search.TestNearestTotalHitsDeletedDoc.testDeletedDocNotCounted(TestNearestTotalHitsDeletedDoc.java:50)TotalHitsdefinesEQUAL_TOas "The total hit count is equal to value";nearestitself drops deleted documents from its hits.- The "exact" total can exceed
reader.numDocs(). Lucene's count shortcuts trust such statistics only without deletions or when every document has the field. - No javadoc, test or review calls the total approximate;
EQUAL_TOwas added mechanically in 2018 (LUCENE-8430).
The full argument327 words
- Contract.
TotalHits.java:30-32: the relation says "Whether value is the exact hit count, in which case relation is equal to EQUAL_TO, or a lower bound";:38"The total hit count is equal to value".TopDocs.java:29: "The total number of hits for the query."Weight.java:182: an exact count "Counts the number of live documents that match". - Deleted documents are not hits.
nearestfilters them out of the hits (NearestNeighbor.java:156), so a total abovereader.numDocs()is wrong under any definition of a hit. - Lucene's own count shortcuts.
TermQuery.java:256,PointRangeQuery.java:353andFieldExistsQuery.java:247-251("No deletions: we can use points or terms doc count as a proxy") use the statistic as a count only whenhasDeletions() == false;FieldExistsQuery.java:261-270also accepts the case where every document has the field ("All docs in the leaf (live or deleted) have the field. Return the count of live docs."). Among these count pathsnearestis the outlier. They are precedents, not a universal rule:PointValues.getDocCount(IndexReader, field)(PointValues.java:132-147) computes the same deletion-blind sum, but as a documented statistic, not as a hit count. - Tests.
TestNearestis the only core test that callsnearest; its deletion tests (:43,:76) check the hits only. A sandbox test (TestFloatPointNearestNeighbor.java:69) calls it once and readsscoreDocsonly. No test reads itstotalHits. - History. The
getDocCountsum came with the method in 2016 (LUCENE-7069, sandbox), whentotalHitswas a plain number documented as the total number of hits;EQUAL_TOcame from LUCENE-8430 (2018), which converted everyTopDocsproducer; LUCENE-10266 (#897, 2022) moved it to core with the counting loop and theEQUAL_TOreturn unchanged. No review thread, CHANGES or MIGRATE entry discusses the total. - Framing for the issue. Base it on the
EQUAL_TOcontract and on "exact total >reader.numDocs()", not only on theMatchAllDocsQueryequivalence (see the devil's advocate). The multi-valued oddity (documented duplicates can makescoreDocs.lengthexceedtotalHits) is a side issue.
The devil's-advocate check231 words
The strongest "works as designed" case (built by the works-as-designed review and the devil's-advocate verifier; the cross-model reviews rate it moderate): nearest is a specialized BKD walk, not a Query, and the TopDocs javadoc scopes that class to hits "returned by IndexSearcher#search(Query,int)" (TopDocs.java:26); its own javadoc never says what totalHits holds; the total can be read as the field's physical cardinality, the deletion-blind sum that PointValues.getDocCount(IndexReader, field) also returns; the cheap O(segments) total has been there since 2016; an exact live count could need a pass over the points; LeafReader#searchNearestVectors documents its TotalHits as "the number of documents visited during the search" (LeafReader.java:249-252); and the MatchAllDocsQuery equivalence never pinned the total (sparse field, no deletions: nearest says 3, the equivalent 4).
Why it fails: nearest itself builds a TotalHits and labels it EQUAL_TO, which that record defines as the exact hit count, with no exception for callers outside IndexSearcher; nothing redefines TotalHits for LatLonPoint#nearest, and its javadoc presents it as functionally equivalent to a query; the physical-cardinality reading already has a documented home (PointValues.getDocCount) that does not claim to be a hit count; a total above the number of live documents is wrong under both readings (all live documents, or live documents with the field); and a lower bound marked GREATER_THAN_OR_EQUAL_TO keeps the O(segments) cost. Verdict: not intended, a defect.
Recommended Exact when the field is dense, lower bound otherwise (recommended)
In the per-segment loop, keep getDocCount() when the segment has no deletions; if every document has the field (docCount == maxDoc) use numDocs(), which is exact; otherwise add the lower bound docCount - numDeletedDocs and report GREATER_THAN_OR_EQUAL_TO. It adds a few reader calls per segment. Validated on 28 test classes (255 tests, 0 failures), with assertions on and off.
int docCount = points.getDocCount(); // index statistic: ignores deletions
if (reader.hasDeletions() == false) {
totalHits += docCount;
} else if (docCount == reader.maxDoc()) {
// every document has the field, so the live documents that have it are exactly numDocs
totalHits += reader.numDocs();
} else {
// only a lower bound is cheap to compute
totalHits += Math.max(0, docCount - reader.numDeletedDocs());
exactTotalHits = false;
}
// after the loop:
// relation = exactTotalHits ? EQUAL_TO : GREATER_THAN_OR_EQUAL_TOAll options and trade-offs4 options ยท 695 words
Two fixes were built and checked; the recommended one keeps exact totals in the common case where every document has a location. Counting exactly in every case is possible but costs a pass over the points.
fix-dense-exact.diff, +19/-4 in LatLonPoint.java (with the two tests: fix-dense-exact-with-tests.diff). If every document of the segment has a point, the live documents with a point are exactly numDocs(); FieldExistsQuery.java:261-270 applies the same rule (and :233-234 for norms). Otherwise at most numDeletedDocs of the docCount documents are deleted, so docCount - numDeletedDocs is a valid lower bound.
int docCount = points.getDocCount(); // index statistic: ignores deletions
if (reader.hasDeletions() == false) {
totalHits += docCount;
} else if (docCount == reader.maxDoc()) {
// every document has the field, so the live documents that have it are exactly numDocs
totalHits += reader.numDocs();
} else {
// only a lower bound is cheap to compute
totalHits += Math.max(0, docCount - reader.numDeletedDocs());
exactTotalHits = false;
}
// after the loop:
// relation = exactTotalHits ? EQUAL_TO : GREATER_THAN_OR_EQUAL_TOTrade-offs
- A sparse field with deletions gets a lower bound ("3+ hits" in the sparse case, where 3 is the true count) instead of an exact number. The bound can be loose: it subtracts every deleted document, also those without a point, so it can be 0 when most deleted documents have no point.
- It assumes a reader whose
numDocs(),hasDeletions()andgetLiveDocs()agree, asFilterLeafReader.java:37-38asks of wrappers; an inconsistent wrapper is not repaired. - Not benchmarked; the added work is a few integer reads per segment for standard segment readers, which cache
numDocs()(IndexReader#numDocs()may be O(maxDoc) in other readers). - Open draft #16847 edits the same lines (it widens
int totalHitstolong), so a PR may need a rebase.
What the checks validated
- Effect on the probe cases: "2 hits", "10 hits" and "1000 hits", all
EQUAL_TO; the no-deletion control is unchanged. - Gradle, clean checkout of
ea57360912edwith the patch, seed DEADBEEF: the repro, the edge test, the finder's, the independent reproduction's, the default-config and the hidden-docs tests,TestNearestand everyTestLatLon*suite indocumentandsearch: 28 classes, 255 tests, 0 failures, 19 skipped (18 nightly-only, 1@Ignore). - Assertions on and off, recorded: the same 28 classes through the repro harness on the
ea57360912edjars with the patched class,-eaand-da: 236 passed, 0 failed, the same 19 skipped. (A second gradle run meant for assertions off gave the same counts, but its log does not record the flag.) ./gradlew :lucene:core:check -x test(google-java-format, ecj lint including javadoc lint, forbidden APIs, licenses; error-prone skipped) passes with the patch and the two new tests. The same repro failed 2 of 2 in that checkout without the patch.
For segments with deletions add max(0, getDocCount - numDeletedDocs) and report GREATER_THAN_OR_EQUAL_TO. The simplest change.
Trade-offs
Any deletion turns the total into a lower bound, even where the exact number is free: "2+ hits", "10+ hits", "1000+ hits" in the probe cases.
What the checks validated
With the harness's shadow classes: the finder's, the independent reproduction's (4 run, 0 failed), the cross-model reviewers' and the default-config repros pass, and so does TestNearest (5 run, 0 failed); it also fixes 10.0.0's own file and the hidden-docs probe. The new repro passes; the edge test's two-segment case fails as designed (it expects EQUAL_TO).
For sparse segments with deletions, count the live documents with the field, for example with an IntersectVisitor over all points that checks the live docs. On a multi-valued field the visitor sees a document once per value, so the count must de-duplicate doc ids.
Trade-offs
Exact in every case, but O(points) per such segment and call, which works against the method's purpose.
Say that totalHits counts documents that have the field, including deleted but unmerged ones.
Trade-offs
Keeps an EQUAL_TO label on a number that is not the hit count, which contradicts TotalHits.
Code reading: an AI tool audited every Lucene method that returns a TotalHits labelled EQUAL_TO and flagged nearest, then wrote a JUnit test that fails on the stock main jar. No formal model was involved. Cross-model reviews and more AI verifiers reproduced it with their own tests, and an adjudication re-ran one of them with a new seed.
The method in full141 words
Method: code reading (the hunt's non-formal tier). The hunt unit on counts and totalHits made a grep inventory of every Weight#count override and every TotalHits(..., EQUAL_TO) producer, checking each against the contract "TotalHits never claims EQUAL_TO for a wrong count". LatLonPoint.nearest stood out because it labels an index statistic exact.
- The finder turned it into a JUnit test with a no-deletion control and ran it on the stock main jar with assertions on and off, and on 10.0.0 and 10.5.2.
- Before the candidate was filed, an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it, wrote its own committed-reader test, and voted real.
- Then an independent reproducer with two oracles, a second GPT-5.6 review with a works-as-designed lens, a devil's advocate for intended behaviour and an adjudication, who re-ran the independent reproduction's test with a new seed.
No prior report found (2026-10-25/26): 166 tool queries over GitHub, four mailing lists, the JIRA archive and the web, 9 manual searches, the review threads of the code's history and the files of all 412 open PRs. Open draft #16847 edits the same lines but keeps the over-count.
Searches5 searches
Verdict new, confidence moderately high, not certain: GitHub search does not index PR diffs.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene); downstream repositories | 2026-10-25/26 | 4 runs of the hunt's prior-art search tool (166 queries in total, with the mailing-list, JIRA and web queries below), for example LatLonPoint nearest totalHits, NearestNeighbor deleted, getDocCount deleted totalHits, TotalHits EQUAL_TO deleted documents; 9 manual searches | no report of this bug |
| Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web | 2026-10-25/26 | the tool's list, JIRA and web queries; two java-user@ threads on nearest read in full | nothing about the total |
| Review threads of the code's history | 2026-10-25/26 | #8125 (LUCENE-7069), #9023 (LUCENE-7974), LUCENE-8430, LUCENE-9820 (#7), #11302 / #897 (LUCENE-10266), #16154, #16847 | none discusses how the total is computed or treats deletions |
| Open PRs | 2026-10-25 | file lists of all 412 open apache/lucene PRs | #16847 (draft) edits the same lines, widens int to long, keeps the over-count; #12162 does not touch nearest |
| Downstream code | 2026-10-25 | LatLonPoint.nearest in Elasticsearch, OpenSearch and Solr | no call, no report |
In core since LUCENE-10266 (#897, fcd98fd3370b, 2022-06-13), first released in 10.0.0, which copied the 2016 sandbox counting loop unchanged; EQUAL_TO dates from LUCENE-8430 (2018). All 12 10.x releases up to 10.5.2, 10.6.0 (by reading) and main are affected; not fixed upstream.
Versions tested5 rows
Not a regression. The getDocCount sum came with the method in the sandbox (LUCENE-7069, f6c7fc7a2658, 2016-04-14, released in 6.1.0 through its branch_6x backport 5bbb5e7748f1); LUCENE-8430 (9ca053712af0, 2018-07-30) labelled it EQUAL_TO when totalHits became a TotalHits object; LUCENE-10266 (#897, fcd98fd3370b, 2022-06-13, author shahrs87, merged by jpountz) moved it to core; the counting loop and the EQUAL_TO return were copied unchanged, only the javadoc and the exception contract were edited. Core LatLonPoint.java is the same blob in all twelve 10.x releases up to 10.5.2; branch_10_6 and branch_10x carry the same file, so the released 10.6.0 has it too.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | fails, assertions on and off; the recommended fix passes |
| 10.0.0 to 10.5.2 (all 12 releases) | affected | the finder's JUnit fails on all 12 (also with assertions off on 10.0.0 and 10.5.2); the new repro fails on 10.0.0 and 10.5.2 |
| 6.1.0 to 9.12.3 | sandbox only | no nearest() in core; the sandbox copy gives the same wrong total (out of scope) |
| 6.0.0, 6.0.1 | no API | no nearest() at all |
recommended fix on ea57360912ed | not affected | repro passes; 255 tests, 0 failures |
The finder plus independent checks, all "real": a cross-model refute review, an independent reproducer with two oracles, a works-as-designed review, a devil's advocate and an adjudication who re-ran it with a new seed. The recommended fix was validated separately with gradle.
Checks and verdicts472 words
Every check ran on main ea57360912ed with assertions on and off unless noted.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Repro 2 run, 1 failed with -ea and -da; the no-deletion control passes; also fails on 10.0.0 and 10.5.2. |
| Cross-model refute review | real, high | OpenAI GPT-5.6 wrote its own committed-reader test (EQUAL_TO 4 vs exact 3), agreed on the root cause, and confirmed the hunt's fix sketch. |
| Independent reproducer | real | Own test with two oracles (MatchAllDocsQuery plus distance sort, and count(FieldExistsQuery)); hard delete, updateDocument and softUpdateDocument all inflate the total; 30 random-codec iterations: all 90 deletion runs fail, all 30 controls pass. |
| Works-as-designed review | real, high | OpenAI GPT-5.6 built the strongest intended case (cheap field-population metadata since 2016), rated it moderate and rejected it; for the same two live hits the total drops from 3 to 2 once deletions are expunged. |
| Devil's advocate | real | Committed reader: EQUAL_TO total 3 with numDocs 2. Advised framing the issue on the EQUAL_TO contract, not the MatchAllDocsQuery equivalence. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test with seed 1A2B3C4D: 4 run, 3 failed with -ea and -da, control passes. |
| Security screen | none | No deleted or hidden document is returned; for a given field and reader the number does not depend on the query arguments. |
| Fix validation (this doc) | recommended fix validated | Gradle in a clean checkout of ea57360912ed: 28 classes, 255 tests, 0 failures; :lucene:core:check -x test passes. The same 28 classes through the harness with assertions on and off: 236 passed, 0 failed (checks/runs/suite28-fixB-*). |
| Independent fact check | real; 6 wording fixes, applied | A separate AI tool checked every file:line, commit, count and quote, and re-ran the repro (main -ea and -da, a second seed, 10.0.0, 10.5.2), the edge test, the probe, the hidden-docs probe and both fixes. It found one factual slip (a sandbox test also calls nearest) and five attribution or wording nits; none affects whether the bug is valid. |
| Cross-model fact check | factually sound; 17 items, applied | An independent reviewer from another model family (OpenAI GPT-5.6) checked the brief against the code and the history: 4 errors (the total also depends on the field; an update can delete several documents; the independent reproduction equivalence number; the sandbox test), 6 overclaims (the 20% "bound", constant cost, "unchanged", "wherever", the assertions-off log, "no denial of service") and 7 missing caveats. It rated the intended-behaviour case moderate. |
| Diagram check | pass | Every value, API name, line number and the order of events in the PNG matched the code and the probe, which it re-ran. Its wording suggestions (when the live bit is cleared, the local total before td exists, the equivalence on the same documents, how long an old copy counts) were applied and the diagram re-rendered. |
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 479 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,062 words
- Registry title
- LatLonPoint#nearest reports an EQUAL_TO TotalHits that counts deleted documents
- Area
- document / LatLonPoint#nearest
- Priority
- Low, rank 344: The returned hits are correct; only the EQUAL_TO total of the niche LatLonPoint#nearest API also counts deleted documents.
- Impact class
- silent wrong results
- Impact note
- Callers of the public static LatLonPoint#nearest that read TopFieldDocs.totalHits on a reader with deleted-but-unmerged documents carrying the point field (every deleteDocuments, updateDocument or softUpdateDocument leaves one until a merge); default IndexWriterConfig, codec, merge policy and searcher; ordinary scale; the returned hits are correct, only the total is too high and labelled exact; released since 10.0.0.
- Summary
- You index documents with a LatLonPoint field, delete or update some of them, and call LatLonPoint.nearest(searcher, field, lat, lon, n) with n larger than the index. You expect TopFieldDocs.totalHits, which is labelled TotalHits.Relation.EQUAL_TO (exact), to count only documents that can be hits, here the hits returned. Instead the total is the per-segment sum of PointValues#getDocCount, an index statistic that still counts deleted documents, so 3 documents with one deleted return 2 hits but report exactly '3 hits', more than reader.numDocs().
- Introduced
- fcd98fd3370b (#897, LUCENE-10266 'Move nearest-neighbor search on points to core'), which copied the sandbox code into core unchanged [read: git show and git blame; exec: no 9.x release has core nearest(), 10.0.0 fails]. Not a regression: the getDocCount sum dates from f6c7fc7a2658 (LUCENE-7069, 2016, sandbox) and the EQUAL_TO label from 9ca053712af0 (LUCENE-8430, 2018, a mechanical change when totalHits became a TotalHits object) [read] (2022-06-13 (in core; sandbox origin 2016-04-14, EQUAL_TO label 2018-07-30))
- First affected version
- 10.0.0 (first release with nearest() in core; the same expression shipped in the sandbox from 6.1.0 to 9.12.3, out of scope)
- Affected versions
- every 10.x release before 10.6.0, 10.0.0 to 10.5.2 (12 releases), and main ea57360912ed [exec: the finder's JUnit on all 12 releases and main with -ea, and on 10.0.0, 10.5.2 and main with -da; the independent reproduction's own test on 10.0.0, 10.5.2 and main]; no 9.x release has nearest() in core [exec: the JUnit does not compile on 9.2.0, 9.3.0 and 9.12.3]; branch_10_6 and branch_10x carry the 10.5.2 file unchanged, so the released 10.6.0 has it too [read, 2026-10-25]; not fixed upstream [read]; the sandbox predecessors 6.1.0-9.12.3 give the same wrong total [exec: cross-version main() probe; out of scope]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-25/26: a scripted prior-report search, 4 runs with 166 queries over GitHub issues/PRs and commits, downstream repos, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 9 manual read-only gh searches on apache/lucene PRs and on Elasticsearch, OpenSearch and Solr; the review threads of the issues and PRs that created, changed or moved the code; file lists of all 412 open apache/lucene PRs): no report, no upstream fix, no open PR fixes it; confidence moderately high, not certain (GitHub search does not index PR diffs). Related prior items, none of them this bug: #8125 (LUCENE-7069, origin of the code), LUCENE-8430 (added the EQUAL_TO label), #11302 / #897 (LUCENE-10266, move to core), #9023 (LUCENE-7974, same pattern copied into the sandbox FloatPointNearestNeighbor), open draft #16847 (edits the same lines, widens int to long, keeps the over-count), #11078 / #239 (LUCENE-10040, deletions in kNN vector search, a different API)
- Lucene commit tested
- main
ea57360912ed(2026-10-25/26), assertions on and off; the Maven Central jars of all 12 releases from 10.0.0 to 10.5.2 (assertions on; 10.0.0 and 10.5.2 also off); a cross-version probe over 92 releases from 6.0.0 on (the sandbox method before 10.0.0); the recommended fix in a clean checkout ofea57360912edwith gradle, and through the repro harness with assertions on and off (2026-10-26).
- Finding record
lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:379-403 (nearest javadoc; :383 'functionally equivalent to running MatchAllDocsQuery with a LatLonDocValuesField#newDistanceSort'), :421-430 (per-leaf loop; :425 'totalHits += points.getDocCount();', live docs collected only for NearestNeighbor), :442 (new TotalHits(totalHits, TotalHits.Relation.EQUAL_TO))lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:156 (the visitor skips documents whose live bit is clear)lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41 (relation: EQUAL_TO = exact hit count, GREATER_THAN_OR_EQUAL_TO = lower bound)lucene/core/src/java/org/apache/lucene/search/TopDocs.java:29 ('The total number of hits for the query.')lucene/core/src/java/org/apache/lucene/index/PointValues.java:495-496 (getDocCount: 'the total number of documents that have indexed at least one point')lucene/core/src/java/org/apache/lucene/search/Weight.java:182 ('Counts the number of live documents that match')lucene/core/src/java/org/apache/lucene/search/TermQuery.java:256, PointRangeQuery.java:353, FieldExistsQuery.java:247 (docFreq / getDocCount used as a count only when the leaf has no deletions)lucene/sandbox/src/java/org/apache/lucene/sandbox/document/FloatPointNearestNeighbor.java:317, :333 (same pattern in the sandbox; read only)lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/NearestNeighborBenchmark.java (the only in-tree non-test caller of LatLonPoint.nearest)SECURITY.md:28, 50 (threat model)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:379-403, :421-430, :442Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:156Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PointValues.java:495-496Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:121-124 (deletesPctAllowed, "Default value is 20.")Lucene main ea57360912ed: lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/NearestNeighborBenchmark.java:109Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1901-1948 (updateDocument / softUpdateDocument act on every document matching the term)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:364-365, :520-524 (deletesPctAllowed used to choose merges)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-42 (carries soft-deleted documents across merges)
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41; search/TopDocs.java:29; search/Weight.java:182Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:256; search/PointRangeQuery.java:353; search/FieldExistsQuery.java:247-251Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/LeafReader.java:244-252 (searchNearestVectors TotalHits)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestNearest.java:43, :76Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TopDocs.java:26 (class javadoc: hits returned by IndexSearcher#search(Query,int))Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PointValues.java:132-147 (static getDocCount(IndexReader, field): the same deletion-blind sum)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:261-270 (count == maxDoc returns numDocs; count trusted only without deletions)Lucene main ea57360912ed: lucene/sandbox/src/test/org/apache/lucene/sandbox/document/TestFloatPointNearestNeighbor.java:69 (the only other test call of LatLonPoint.nearest; reads scoreDocs only)
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:233-234 (dense shortcut precedent)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:261-270 (the same rule in count())Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:37-38 (override numDocs() together with getLiveDocs())Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexReader.java:292-297; index/SegmentReader.java:234-236 (numDocs() cached in segment readers)
- TestNearestTotalHitsDeletedDoc.java (JUnit test):
repro/LFV-088/src/org/apache/lucene/search/TestNearestTotalHitsDeletedDoc.java(56 lines)
An independent diagram check compared every value, API name, line number and the order of events on screen, and this alt text, with the code at ea57360912ed and the probe output; its wording suggestions were applied and the diagram re-rendered.
assets/LFV-088/bug.png(479 KB)
