BinaryPoint indexes fixed-width binary values as points. Its query factories are newExactQuery "for matching an exact 1D point", newRangeQuery for 1D and n-dimensional ranges, and newSetQuery for a set of 1D values. You pass each value as a byte[]; the javadoc does not say whether the query copies it.
scratch[0] = 2 the first query prints binary(2), equals the second, and their OR counts 1 document instead of 2. A range built for [1, 2] counts 0 after 9 is written into its arrays; the two-dimension query and newSetQuery keep their values. Select the still to open it at full size. Generated with AI, checked against the code.- Index three documents with a one-dimensional
BinaryPointfield holding the keys 1, 2 and 3 (one byte each). - Write key 1 into a scratch
byte[]and buildq1 = BinaryPoint.newExactQuery(field, scratch). Write key 2 into the same array and buildq2. OR the two. Nothing has been searched yet, and no query cache is involved. - You expect 2 hits, keys 1 and 2, as with
newSetQuery, which keeps the value it was given. - You get 1 hit. Both queries hold the scratch array itself, so
q1now means key 2: it printsbinary(2)and equalsq2. A range built for [1, 2] whose arrays you then overwrite with 9 matches 0 documents instead of 2. - Why: for one dimension
BinaryPoint.packreturns your array instead of a copy, andPointRangeQuerystores what it gets; for two or more dimensions it packs a new array. No exception, and the index is untouched.
Applications that build one-dimensional BinaryPoint exact or range queries from a byte[] they later refill or reuse, such as fixed-width keys encoded into one scratch buffer in a loop. Default configuration, no query cache needed, any index size. Two-dimension BinaryPoint, newSetQuery and the typed points (IntPoint, ...) are unaffected; Lucene itself never calls these factories.
Mechanism, terms and numbers567 words
BinaryPoint.newExactQuery and the one-dimensional BinaryPoint.newRangeQuery build a PointRangeQuery whose bounds are the caller's own byte[]: BinaryPoint.pack returns new BytesRef(point[0]) for one dimension (BinaryPoint.java:93-95), and PointRangeQuery stores the array it gets (PointRangeQuery.java:94-95). Matching, equals, hashCode and toString then read whatever the array holds now. A caller that encodes keys 1, 2 and 3 into one scratch buffer and ORs one exact query per key gets 1 hit instead of 3, before any search and with the query cache off; a range built for keys [1, 2] whose bound arrays are reused for key 3 matches 1 document instead of 2. The same factory copies the bounds for two or more dimensions, and a one-value newSetQuery keeps its value. Every release that has BinaryPoint (6.0.0 to 10.5.2) and main does this; no in-tree code calls these factories. Cloning the packed bounds for one dimension fixes it, and the point-query and document test suites pass with that change.
BinaryPoint: a field that indexes a fixed-width binary value (or one per dimension) as a point. Its static factories build point queries:newExactQuery(field, byte[]),newRangeQuery(field, byte[], byte[]),newRangeQuery(field, byte[][], byte[][])andnewSetQuery(field, byte[]...)(class javadoc,BinaryPoint.java:35-44).pack()(BinaryPoint.java:86-119): turns one value per dimension into one packedBytesRef. The field constructor uses it (:129-131), and so does the n-dimension range factory (:192-194).PointRangeQuery: the query the exact and range factories return. Its constructor takes the packed lower and upper bounds (PointRangeQuery.java:72-98).
- For one dimension,
pack()returnsnew BytesRef(point[0])(BinaryPoint.java:93-95): a wrapper over the caller's array, no copy. For two or more it allocates a new array and copies each dimension in (:114-118). newExactQuery(field, value)callsnewRangeQuery(field, value, value)(:160-162), which wraps both arrays intobyte[][](:177-180) and calls the n-dimension factory, which passespack(lowerValue).bytesandpack(upperValue).bytestoPointRangeQuery(:192-194). With one dimension these are the caller's arrays.PointRangeQuerystores them as given (PointRangeQuery.java:94-95) and reads them each time it matches (:134-145,:267-268,:285-286,:600-601), hashes (:479-488), compares (:490-501) or prints (:503-530). Its getters return clones (:471-477), so the bounds cannot be changed through the query, only through the caller's own array.- So the query is consistent with itself but follows the caller's buffer: whatever the array holds when the query runs is what it matches.
- Three documents, keys 1, 2, 3 as 4-byte sortable ints, query cache off. Encode each key into one scratch buffer and OR one
newExactQueryper key: 1 hit instead of 3 (the same loop with one-valuenewSetQuery: 3). - Build
newRangeQueryfor keys [1, 2], then write key 3 into both bound arrays: 1 document instead of 2. The two-dimension overload built from the same arrays: 2. - Build
newExactQueryfor key 1, write key 2 into the array: the query printskey:[binary(80 0 0 2) TO binary(80 0 0 2)]instead ofbinary(80 0 0 1). - With one-byte keys (the diagram's example): two exact queries from one scratch array, OR-ed, count 1 instead of 2, and
q1.equals(q2)is true with the samehashCode; a range built for [1, 2] counts 0 after 9 is written into its arrays.
Exposure and likelihood264 words
- Who is exposed. Applications that build one-dimensional
BinaryPointexact or range queries from abyte[]they later refill or reuse while the query is still in use. Typical shape: fixed-width binary keys (sortable ints, hashes, ids) encoded into one scratch buffer in a loop to OR several exact queries.NumericUtils.intToSortableBytes(value, result, offset)writes into a caller-supplied array, so the loop is an ordinary pattern. - Default vs opt-in. No special configuration: a plain
new IndexWriterConfig()andnew IndexSearcher(reader)(the test framework picks a random codec and directory for each seed, and every seed used failed the same way); no query cache needed (the repro turns it off and builds every query before the first search); no index-size prerequisite (3 documents). The typed point classes (IntPoint,LongPoint, ...) take numbers and build their own arrays;BinaryPointwith two or more dimensions andnewSetQuerycopy. No file outside the tests callsBinaryPoint.newExactQueryornewRangeQuery. - Silent. Wrong matches (an OR loses hits, a range matches another range), no exception, no log line, nothing written to the index. Queries built for different keys become
equalswith the samehashCode. - With a query cache. The hunt's cache probe (
LRUQueryCache, 16 and 1 partitions) served 0 wrong hits to a freshly built twin; after a key'shashCodedrifts, 32 bytes of accounted query RAM stayed aftercleanUp. Not counted as impact. - How likely. Rare: it needs the reuse pattern on a less-used API, and the recorded searches found no report since 6.0.0 (2016). Once the pattern is used, the wrong results are deterministic.
The test shows that BinaryPoint.newExactQuery on a one-dimensional field keeps the caller's byte[] as its bounds, so writing into that array after building the query changes what the query matches.
/*
* 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.document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBinaryPointExactQueryOwnsValue extends LuceneTestCase {
public void testRefillingCallerArrayDoesNotChangeBuiltQuery() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new BinaryPoint("f", new byte[] {1}));
w.addDocument(doc);
}
byte[] scratch = {1};
Query q = BinaryPoint.newExactQuery("f", scratch);
scratch[0] = 2;
try (DirectoryReader r = DirectoryReader.open(dir)) {
// expected 1 hit (the query was built for key 1); actual 0: the query reads the refilled array
assertEquals(1, new IndexSearcher(r).count(q));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-751/, 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.document.TestBinaryPointExactQueryOwnsValue
- Expected vs actual
- Expected 1 hit (the query was built for value {1}, which the only document holds); actual 0 after the caller writes 2 into the array (
expected:<1> but was:<0>), with-eaand-da.
More files, environment, full output34 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:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.document.TestBinaryPointExactQueryOwnsValue.testRefillingCallerArrayDoesNotChangeBuiltQuery(TestBinaryPointExactQueryOwnsValue.java:42)- One method,
newRangeQuery(String, byte[][], byte[][]), copies the bounds for two or more dimensions and shares them for one; its javadoc mentions neither. - A one-value
newSetQuerykeeps its value after a refill;newExactQuery, which returns "a query matching documents with this exact value", does not. - Where Lucene shares a caller's array it says "not copied" (
Field); these factories say nothing.
The full argument325 words
- One method, two answers.
newRangeQuery(String, byte[][], byte[][])owns its bounds for two or more dimensions and shares the caller's array for one (BinaryPoint.java:93-95vs:114-118, both reached from:192-194). Its javadoc (:182-191) does not mention the dimension count. - Same class, same request. The class javadoc lists
newExactQuery"for matching an exact 1D point" andnewSetQuery"for matching a set of 1D values" (:38-40). A one-valuenewSetQuery(field, scratch)keeps its value after a refill (PointInSetQueryencodes it into its own storage);newExactQuery(field, scratch)does not.newExactQuery's javadoc: "@return a query matching documents with this exact value" (:158). - Where Lucene shares, it says so.
Field: "NOTE: the provided byte[] is not copied so be sure not to change it until you're done with this field" (Field.java:156-157);MultiPhraseQuery.Builder: "The array is not copied" (MultiPhraseQuery.java:111,:122). These factories carry no such note. - The project's recorded direction. LUCENE-6369 (#7429): "It is very important for the query cache that queries be either immutable or clone deeply so that they cannot change after having been put into the cache", superseded by #7648, "queries should now be immutable". LUCENE-6821 (#7879) made
Termcopy itsBytesRefbecause building queries from shared bytes "is a bit trappy". LUCENE-7307 (#8361) made these very bounds clone on the way out (PointRangeQuery.java:471-477). - Tests. No upstream test asserts the sharing.
testPointRangeEqualsandtestPointExactEquals(TestPointQueries.java:2255-2265,:2324-2332) build the "other" query fromones.clone()instead of changingones. - History: an accident.
pack()'s one-dimension shortcut came from LUCENE-6881 (f12cb52a3b8, 2015) for the field constructor. The query path started usingpack()in LUCENE-7117/7118 (e1a1dbfabcc9, 2016), whose 14 comments discuss thebyte[][]equals/hashCode bugs and never array ownership. Before that commit the factories kept the caller's arrays for every dimension count (unreleased code), so the two-dimension copy is a side effect of packing, not a design.
The devil's-advocate check339 words
The strongest "works as designed" case, built by the refute review, the intended-lens review and the devil's advocate: no javadoc promises a copy; PointRangeQuery's constructor is protected and "Expert" and keeps its arrays; keeping caller values without a note is common in Lucene's query API: in the hunt's differential probe, 13 other query entry points follow a later write into the value they were given (the kNN query targets and the adaptive vector-similarity queries, TermRangeQuery for equals/hashCode, SortedSetDocValuesField.newSlowRangeQuery, SortedDocValuesField.newSlowExactQuery, SynonymQuery.Builder.addTerm(BytesRef, float), Intervals.term(BytesRef), RescoreTopNQuery.createFullPrecisionRescorerQuery, ConstKnnFloatValueSource), while PhraseQuery, TermInSetQuery and TermQuery copy; SortedSetDocValuesField.newSlowExactQuery keeps its bytes too (confirmed by running it); in LUCENE-6321 (#7382) a committer wrote "who is modifying term bytes? This seems like an abuse case"; and IndexSearcher.setQueryCache says "When using a query cache, queries should not be modified after they have been passed to IndexSearcher" (IndexSearcher.java:268-269).
Why it fails: the write goes into the caller's own array, before any query reaches a searcher and with no cache, through a public factory whose behaviour depends on the dimension count, so the IndexSearcher note does not apply. The same factory copies for two or more dimensions, so "the caller owns the array" is not the method's rule either. (A maintainer can still call a write into a retained array a modification of the query; that ownership question is what the issue asks them to settle.) The LUCENE-6321 remark continues "But maybe its a good idea to do a little "extra" clone work to make these core queries defensive", and the project then made Term copy. Users call the factories, not the protected constructor. Verdict: a defect (adjudication: confirmed, medium confidence; every verifier: real). The adjudication's own tier is Low because no in-tree code reuses such an array and the query-cache leg showed no wrong hits; the registry priority, medium, is a separate scale. The contract is implicit, which is why it is filed as an inconsistency and a robustness fix, with a javadoc note as the fallback.
Recommended Clone the packed bounds for one dimension in the n-dimension factory
Copy the packed bounds when there is one dimension in BinaryPoint.newRangeQuery(String, byte[][], byte[][]), the method every one-dimensional factory goes through (+11/-2). The field constructor keeps its documented sharing. The repro, TestPointQueries, every document suite plus the point suites (62 suites, 589 tests) and :lucene:core:check -x test pass.
byte[] lower = pack(lowerValue).bytes;
byte[] upper = pack(upperValue).bytes;
// pack() returns the caller's array for one dimension (fine for the field constructor); copy
// it so that the query owns its bounds, as it already does for two or more dimensions
if (lowerValue.length == 1) {
lower = lower.clone();
}
if (upperValue.length == 1) {
upper = upper.clone();
}
return new PointRangeQuery(field, lower, upper, lowerValue.length) {All options and trade-offs4 options · 613 words
One site fixes newExactQuery and both newRangeQuery overloads, because they all route through the n-dimension factory. pack() runs first, so its argument checks are unchanged. Each side is copied by its own dimension count; this also covers a mismatched input the method documents as rejected but does not check ({{1},{2}} against {{3,4}} is accepted on main), which is a separate issue. Workaround until a fix ships: pass a fresh array per query (scratch.clone()), or use newSetQuery for several exact values.
Clone the packed bounds for one dimension in the n-dimension factory (validated by the checks) (recommended)
In newRangeQuery(String, byte[][], byte[][]), call pack() for both sides first, then clone() each side that has one dimension, and pass the results to PointRangeQuery. +11/-2 in BinaryPoint.java.
byte[] lower = pack(lowerValue).bytes;
byte[] upper = pack(upperValue).bytes;
// pack() returns the caller's array for one dimension (fine for the field constructor); copy
// it so that the query owns its bounds, as it already does for two or more dimensions
if (lowerValue.length == 1) {
lower = lower.clone();
}
if (upperValue.length == 1) {
upper = upper.clone();
}
return new PointRangeQuery(field, lower, upper, lowerValue.length) {Trade-offs
- Cost: two small array copies per one-dimensional query; nothing changes on the indexing path.
- Behaviour change: code that, on purpose or not, retargets an already-built one-dimensional query by writing into its array would stop doing so; that has been observable since 6.0.0. This option limits the change to the
BinaryPointfactory. pack()runs before the copy, so null and length errors stayIllegalArgumentException(an earlier draft readlowerValue.lengthfirst and turned a null argument into aNullPointerException; the refute review caught it).- A one-condition variant (clone both sides when
lowerValue.length == 1) behaves the same on valid input and was the version the earlier checks shadowed in.
What the checks validated
- Scratch worktree of main
a9779ef3c193, gradle: the new test passes 3 of 3 with assertions on and off;TestBinaryPointQueryArrayOwnership,TestPointQueries,TestPointValuesandTestFieldExistsQuery: 4 suites, 121 tests, 2 skipped, 0 failures, assertions on and off. - Every
org.apache.lucene.documentsuite plusTestPoint*andTestPointValues: 62 suites, 589 tests, 28 skipped, 0 failures. :lucene:core:check -x testpasses (checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisMain25,forbiddenApisTest).- Standalone runner with the fix shadowed: the finder's test 2/2, the independent reproducer's 6/6 and the devil's advocate's 4/4 pass.
this.lowerPoint = lowerPoint.clone(); this.upperPoint = upperPoint.clone(); in the protected constructor.
Trade-offs
Also covers subclasses outside Lucene that pass shared arrays. Every point range query then pays two extra copies, although IntPoint, LongPoint, the two-dimension BinaryPoint path and the other typed factories already pass fresh arrays, and it changes an Expert constructor's behaviour, so the behaviour change of option A reaches every PointRangeQuery subclass. Not run.
Drop the one-dimension shortcut in pack().
Trade-offs
Not recommended: it adds one allocation and copy per one-dimensional BinaryPoint field instance on the indexing path (two or more dimensions are already copied), and the field would no longer share the caller's array, a field-side behaviour change, to fix a query-side problem. Field itself would still keep the array it is given. Not run.
Document on newExactQuery and the one-dimensional newRangeQuery that the provided byte[] is not copied, as Field does, and say on newRangeQuery(String, byte[][], byte[][]) that its arrays are kept when there is one dimension and copied when there are more.
Trade-offs
No behaviour change and no cost, but the same method still copies for two or more dimensions, and the encode loop still loses hits. The fallback if maintainers prefer no code change.
An application that builds an access filter from a reused key buffer would silently match the wrong keys, but that depends on how it uses the API, so this is an ordinary public bug.
A bytecode ownership scan flagged that PointRangeQuery keeps the arrays it is given. A differential probe then showed the one-dimensional BinaryPoint factories following the caller's buffer while the two-dimension path did not, and the cause was read in BinaryPoint.pack. Independent AI tools then reproduced, reviewed and judged it.
The method in full155 words
Method: semi-formal (the hunt's tier for tool-guided finds that are not a formal tool's own output), a hunt unit (search-side argument ownership), 2026-10-27, stock main jars.
- A bytecode ownership scan (an abstract interpretation of operand stack and locals over 17 module jars, with per-method keep summaries iterated to a fixpoint) listed
PointRangeQuery's constructor as keeping itsbyte[]bounds. - The unit's plan expected the
BinaryPointfactories to pack a copy (a negative control). A differential probe over 20 query entry points (mutate after construction, two queries from one buffer, mutate after a cached search; 21 rows with oneSortrow) showed the one-dimensionBinaryPointexact and range factories failing while the two-dimension control passed. - The cause was then read in
BinaryPoint.pack. The scan alone did not single outBinaryPoint; the signal came from the probe. A public-API JUnit test confirmed it, and independent AI tools reproduced, reviewed and judged it (see the checks).
No prior report found (best-effort search, 2026-10-27): 207 recorded query entries over GitHub issues, PRs and commits, downstream projects, four mailing lists, JIRA and the web (12 planned web queries not run), plus the changed files of the 408 open PRs. Closest context: LUCENE-7117/7118 (#8172/#8173), which created this code path, and LUCENE-7307 (#8361), which made the bound getters return copies.
Searches4 searches
No issue, PR, review comment, commit, JIRA ticket, list thread, downstream issue or web page says that the one-dimensional BinaryPoint exact or range factories keep the caller's byte[], or that BinaryPoint.pack shares the array for one dimension while copying for two or more. Unchanged on upstream main f7bcc0abd816, branch_10x and branch_10_5; BinaryPoint.java last changed on main in #13795 (2024, the newSetQuery comparator only). No open PR touches BinaryPoint.java; none of the eight open PRs touching PointRangeQuery.java (#709, #12528, #13711, #13988, #14625, #14647, #15290, #16847) copies the bounds in the constructor; #16847's hit came from the REST re-read of its 438 files and was re-checked read-only on 2026-10-27 (+5/-5, bounds untouched).
Closest items, none a report of this bug: #8173 / #8172 (LUCENE-7118/7117) introduced the pack(..).bytes query path, and the body and 14 comments never discuss ownership; #8361 (LUCENE-7307) made the getters clone; #7429 (LUCENE-6369), #7879 (LUCENE-6821) and #7382 (LUCENE-6321) are the immutable-query and Term-copy discussions (same class of problem, other fix sites); #12158 / #12135 (KeywordField.newSetQuery clones so as not to sort the caller's array: the opposite direction); open PR #14625 would compute an equalValues flag from the bounds once in the constructor, which a later caller write would leave stale (read, not run). Gaps: GitHub search reads one page per query and tokenizes identifiers poorly (the open-PR file scan covers that); list search is keyword-based; 4 web attempts were throttled and re-run and the rest of the planned web queries were not run; private channels cannot be searched. So this shows only that the recorded searches found no report.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, lucene-solr, solr; Elasticsearch, OpenSearch, Lucene.NET; all of GitHub) | 2026-10-27 | three scripted prior-report search runs (48 issue/PR, 9 commit and 6 downstream-repository queries) and 42 manual GraphQL searches; 32 issues/PRs read in full or grepped | no report; only 6 apache/lucene items name BinaryPoint at all |
| Open PRs | 2026-10-27 | the changed files of the 408 open apache/lucene PRs (the first 100 files of each through GraphQL; the four PRs with more files re-read through the REST files API); diffs of the 8 that touch PointRangeQuery.java grepped | none touches BinaryPoint.java; none copies the bounds |
| Mailing lists and JIRA archive | 2026-10-27 | 60 list queries over dev@, java-user@, issues@ and commits@, 6 direct list searches, 12 JIRA-archive queries and 3 JIRA REST queries (LUCENE, SOLR) | no report |
| Web | 2026-10-27 | 5 manual web searches (4 throttled attempts re-run); 12 web queries planned by the scripted prior-report search were left to that step, and the ones not re-run by hand were not run | no relevant page |
In every release since 6.0.0: LUCENE-7117/7118 (2016) routed the n-dimension query factory through pack(), which copies two or more dimensions but not one (a shortcut written for the field constructor); the unreleased factories before it kept the caller's arrays for every dimension count. Every release run from 6.0.0 to 10.5.2, and main, fails the repro; 5.5.5 has no BinaryPoint.
Versions tested6 rows
Current form from e1a1dbfabcc9 (master, 2016-03-20, @rmuir, "LUCENE-7117, LUCENE-7118: Remove multidimensional arrays from PointRangeQuery"; JIRA era, no PR; migrated as #8172 and #8173). It changed the n-dimension newRangeQuery to new PointRangeQuery(field, pack(lowerValue).bytes, pack(upperValue).bytes, lowerValue.length), which gave two or more dimensions a private packed copy and left one dimension sharing. Keeping the caller's arrays is older (precursors below). On branch_6_0 the same change is d8c4e6977b0e, the commit in the 6.0.0 tag (e1a1dbfabcc9 itself is not), and on branch_6x 51b109620be7; the 6.0.0 tag's BinaryPoint.java has the shortcut at line 87 and the call at line 176.
Precursors (unreleased): LUCENE-6881 (f12cb52a3b8, 2015-11-08) added pack()'s one-dimension shortcut for the field constructor; LUCENE-7045 (099e0311398, 2016-02-23) added the factories, which then passed the caller's byte[][] through for every dimension count; LUCENE-7061 (e80ab3926c1) gave them their current signatures. No release ever copied the one-dimension bounds. Since then the lines are unchanged apart from the 2020 reformat (2d6ad2fee6d, LUCENE-9570). No release after 10.5.2 exists yet; branch_10x and branch_10_5 were read, not run.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test: 3 of 3 fail (gradle and the standalone runner, assertions on and off); the finder's test: 2 of 2 fail |
| 10.5.2, 9.12.3 | affected | this doc's test: 3 of 3 fail, the same messages; the finder's and the independent reproducer's tests fail too |
| 9.1.0 | affected | the finder's test: 2 of 2 fail |
| 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0 | release sweep | the finder's test, adapted for the pre-9.1 test framework: 2 of 2 fail, the two-dimension control passes |
| 5.5.5 | no API | BinaryPoint does not exist; the repro does not compile |
| main + the fix | not affected | every repro passes; the point-query and document suites and :lucene:core:check -x test pass |
An independent reproducer, a devil's advocate and an audit found it real with their own tests; an adjudication confirmed it by re-running two of them with fresh seeds. A read-only review by another model family agreed; a refute-lens review that could not run code gave a procedural "not real". This doc's gradle runs reproduced it on main.
Checks and verdicts682 words
Every check on main ran on the stock jars with assertions on and off (the fix checks with a shadowed class). The reviews by the other model family (OpenAI GPT-5.6) ran read-only because they could not execute code; separate runs re-ran the finder's test each time, and it failed as claimed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2, 9.12.3 and 9.1.0; its two-dimension control passes; passes with the fix, as do the upstream TestPointQueries methods matched by test*Binary* (testRandomBinaryTiny, testRandomBinaryMedium, testInvalidPointInSetBinaryQuery), testExactPoints and testPointRangeEquals (5 run, 0 failed). |
| cross-model review, refute lens | not real (low, procedural) | An independent reviewer from another model family (OpenAI GPT-5.6), read-only: could not execute, so "not reproduced" by construction. Its reading confirms the sharing for the three one-dimension entry points and the copy for two or more; it found the null-argument flaw in the first fix draft, which was then fixed. A separate re-run failed 2 of 2 on 10.5.2. |
| Independent reproducer | real | Own 6-test JUnit, query cache off: the 3 bug legs fail on main (-ea, -da), 10.5.2 and 9.12.3 (OR counts 1 instead of 2, q1.equals(q2) true; ranges count 0 instead of 2); the 3 controls pass; 6 of 6 pass with the fix. |
| Cross-model review, intended lens | real (medium) | The other model family, read-only: the intended-behaviour case is moderate but not enough (no no-copy note, the write can precede the first search, the same factory copies for two or more dimensions). A separate probe printed owns=false for the three one-dimension legs and for SortedSetDocValuesField.newSlowExactQuery, owns=true for the two-dimension control. |
| Devil's advocate | real (moderate) | Built the strongest "works as designed" case and rejected it; its probe (the encode loop over keys 1-3, 1 hit instead of 3) fails 3 of 4 on main (-ea, -da) and 10.5.2, the set-query control passes. |
| adjudication | confirmed (medium) | Re-ran the independent reproduction's JUnit with seed 1DE5A11 (3 of 6 fail with -ea and with -da) and the finder's with 5A5A5A5A (2 of 2 fail); no duplicate. |
| registry re-runs and audit | keep | New seeds: the finder's test fails 2 of 2 (-ea, -da) and passes with the fix; the independent reproduction's fails 3 of 6. A fourth, independently written encode-loop test fails 1 of 1 (1 hit instead of 3) with its 2-D, newSetQuery and IntPoint controls passing, and passes with the fix. |
| this doc's runs | reproduced, fix validated | The doc's 3-test JUnit fails 3 of 3 through gradle (assertions on and off) and the standalone runner on main, 10.5.2 and 9.12.3; with the fix it and the point-query and document suites pass, and :lucene:core:check -x test passes. |
| Fact check of this doc (independent) | reproduced; 5 text errors, fixed | Re-ran the doc's gradle command on its own copy of a9779ef3c193: 3 of 3 fail with assertions on and off, the same messages; with the fix 3/3 pass and TestPointQueries passes; the runner fails 3/3 on main, 10.5.2 and 9.12.3. Its own probe showed the refilled query equals a fresh key-2 query. Errors: a tier reason, an unexecuted equals claim, a quote, a Field line and the check task name; all fixed. |
| Fact check of this doc (OpenAI GPT-5.6, read-only) | core finding holds; wording corrected | A reading-only review: 8 factual errors (0 high, 4 medium, 4 low), 5 overclaims and 5 missing caveats, none of which changes the mechanism or the repro; it rated the "intended" case moderate. The history wording, a test-method name, the prior-search breakdown and the probe count were corrected and the caveats added. |
| Diagram check | values correct; marks fixed and re-rendered | Re-ran the diagram probe on main with assertions on and off and with the fix: every on-screen value, API name and line matched. Its one error, check and cross marks too thick to read at small sizes, was fixed by scaling the strokes and the PNG were re-rendered together. |
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 523 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,559 words
- Registry title
- BinaryPoint.newExactQuery and the one-dimensional newRangeQuery keep the caller's byte[] as the query bounds (BinaryPoint.pack returns the caller's array for one dimension), while the same factory packs a private copy for two or more dimensions, so a caller that refills its buffer silently changes queries it already built
- Area
- document / point query factories (BinaryPoint, PointRangeQuery)
- Priority
- Medium, rank 84: Core BinaryPoint one-dimensional exact and range queries silently match the wrong documents when the caller refills its byte[], a rare reuse pattern with transient query-side damage, so it closes the mid-Medium point-query group well below the durable caller-array rows LFV-644 and LFV-643.
- Impact class
- silent wrong results
- Impact note
- Hits only applications that build one-dimensional BinaryPoint exact or range queries from a byte[] they later refill or reuse (for example fixed-width keys encoded into one scratch buffer in a loop to OR several exact queries), with the default configuration and no query cache needed, before or after the first search: the queries silently match whatever the buffer holds now (an OR of them loses hits) and their equals, hashCode and toString change with it, with no exception and no index damage, in every release since 6.0.0; BinaryPoint queries with two or more dimensions, BinaryPoint.newSetQuery and the typed point factories (IntPoint, LongPoint, ...) build their own arrays and are unaffected, and no in-tree production code calls these factories.
- Summary
- You write a one-dimensional binary key into a scratch byte[], build BinaryPoint.newExactQuery(field, scratch), write the next key into the same array, build a second query and OR the two; you expect each query to match the key it was built for, as BinaryPoint's two-dimension range queries and its one-value newSetQuery do. Instead both queries now mean the second key (they compare equal and print the same bounds) and the OR finds 1 document instead of 2; likewise a one-dimensional newRangeQuery whose lo/hi arrays the caller overwrites afterwards matches 0 documents instead of the range it was built for. BinaryPoint.pack returns new BytesRef(point[0]), a wrapper over the caller's array, when there is one dimension, and PointRangeQuery stores that array as its bounds, so matching, equals, hashCode and toString read whatever the caller's array holds at the time.
- Introduced
- e1a1dbfabcc9 (LUCENE-7117/LUCENE-7118 'Remove multidimensional arrays from PointRangeQuery', Robert Muir (rmuir), migrated as apache/lucene#8172 and #8173; JIRA era, no pull request; committed the same day on branch_6_0 as d8c4e6977b0e, the copy in 6.0.0, and on branch_6x as 51b109620be7) [read: git log, git show and git blame in the history clone]: it made BinaryPoint.newRangeQuery(String, byte[][], byte[][]) pass pack(lowerValue).bytes and pack(upperValue).bytes to PointRangeQuery, so two or more dimensions get a private packed array while one dimension keeps the caller's array through pack()'s shortcut 'if (point.length == 1) return new BytesRef(point[0])', which LUCENE-6881 (f12cb52a3b8, 2015-11-08) had written for the field constructor (Field documents its byte[] values as not copied). In the unreleased code between LUCENE-7045 (099e0311398, 2016-02-23) and this commit the query kept the caller's byte[][] for every dimension count, so no released version ever copied the one-dimension bounds. The lines are unchanged on main apart from the 2020 reformat (2d6ad2fee6d, LUCENE-9570) (2016-03-20)
- First affected version
- 6.0.0 [exec: the version-adapted repro fails 2 of 2 on the 6.0.0 jars; read: the 6.0.0 tag's BinaryPoint.java has pack(lowerValue).bytes and the one-dimension shortcut]; 5.5.5 and older are not affected because BinaryPoint does not exist there (the repro does not compile) [exec]
- Affected versions
- Every release that has BinaryPoint, 6.0.0 through 10.5.2, and main a9779ef3c193; never fixed (unchanged on upstream main f7bcc0abd816, branch_10x and branch_10_5; no open PR touches BinaryPoint.java; read 2026-10-27). [exec] the finder's JUnit (or its pre-9.1 adaptation, which differs only in the test-framework packages and the IndexWriterConfig constructor) fails 2 of 2 with the same two messages on 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 and main (-ea and -da), and its two-dimension control passes in every one of them; the independent reproduction's JUnit fails 3 of 6 (the three bug legs; three controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's probe fails 3 of 4 on main (-ea and -da) and 10.5.2. [read] the releases in between carry the same code. 10.6.x and later branches were not run. All runs with JDK 25.0.3 on aarch64; the behaviour is plain Java array sharing, independent of codec and platform
- 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
- semi-formal
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new: 207 recorded query entries, namely three scripted prior-report searches (the finder's run plus follow-up runs A, identifiers, and B, ownership and immutability terms: 48 GitHub issue/PR, 9 commit, 6 downstream-repository, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 12 JIRA-archive queries, and 12 tool-planned web queries left pending), 42 manual GitHub GraphQL searches (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and all of GitHub), 6 direct mailing-list searches, 3 JIRA REST queries over LUCENE and SOLR, 5 manual web searches, the changed files of all 408 open apache/lucene PRs and the full history of the files; 19 hits read, none the same bug; only 6 apache/lucene items name BinaryPoint at all). Closest items, not reports: #8173 and #8172 (LUCENE-7118/7117, introduced the pack() query path; the body and all 14 comments discuss the byte[][] equals/hashCode bugs, not ownership); #8361 (LUCENE-7307, the getters clone the bounds on the way out); #7429 (LUCENE-6369, queries should be immutable or clone deeply) and #7879 (LUCENE-6821, TermQuery and Term copy their bytes because sharing was 'trappy'); #7382 (LUCENE-6321, modifying query bytes called 'an abuse case', with the extra clone accepted); #12158 and #12135 (KeywordField.newSetQuery clones the caller's array because the query sorts it, the opposite direction); open PR #14625 (would compute an equalValues flag from the bounds at construction, which a later caller write would leave stale [read, not executed]). Not fixed upstream: BinaryPoint.java last changed on main by 644feeb02a97 (#13795, 2024-09-17, newSetQuery comparator only); the shortcut and the call are unchanged on main f7bcc0abd816, branch_10x and branch_10_5; no open PR touches BinaryPoint.java, and none of the eight open PRs that touch PointRangeQuery.java copies the bounds in the constructor. Gaps: GitHub search reads one page per query and tokenizes identifiers in code poorly (the open-PR file scan covers that), list search is keyword-based, 4 manual web attempts were throttled and re-run, private channels are not searchable
- 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 10.5.2 and 9.12.3 release jars (this doc's test). The finder's test, or its pre-9.1 adaptation, fails on 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 and main; 5.5.5 has noBinaryPoint. The cross-model reviews readea57360912ed, whereBinaryPoint.javaandPointRangeQuery.javaare byte-identical. The fix was validated in the scratch worktree: the new test,TestPointQueries,TestPointValues,TestFieldExistsQuery(assertions on and off), everyorg.apache.lucene.documentsuite and:lucene:core:check -x test.
- Finding record
lucene/core/src/java/org/apache/lucene/document/BinaryPoint.java:35-44 (at a9779ef3c193) (class javadoc: newExactQuery 'for matching an exact 1D point', newSetQuery 'for matching a set of 1D values', newRangeQuery for 1D and n-dimensional ranges), :86-119 (pack: :93-95 'if (point.length == 1) return new BytesRef(point[0]);', :114-118 two or more dimensions copied into a new array), :149-162 (newExactQuery javadoc '@return a query matching documents with this exact value'; body newRangeQuery(field, value, value)), :164-180 (one-dimension newRangeQuery wraps the arrays into byte[][]), :182-194 (n-dimension newRangeQuery passes pack(lowerValue).bytes and pack(upperValue).bytes to PointRangeQuery), :238-239 (newSetQuery clones the outer values array before sorting it)lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java:94-95 (at a9779ef3c193) (the constructor stores lowerPoint and upperPoint as given), :471-477 (getLowerPoint/getUpperPoint return clones)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:268-269 (at a9779ef3c193) ('When using a query cache, queries should not be modified after they have been passed to IndexSearcher') and lucene/core/src/java/org/apache/lucene/document/Field.java:156-160 (a binary field value 'is not copied')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/document/BinaryPoint.java:35-44, :86-119, :129-131, :149-210lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java:62-98, :134-145, :267-268, :285-286, :471-530, :600-601
- Who is affected
lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:172-178lucene/core/src/java/org/apache/lucene/document/IntPoint.java:173-218 (typed factories encode into new arrays)grep of lucene outside src/test for BinaryPoint.newExactQuery / BinaryPoint.newRangeQuery (no hit), 2026-10-27
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/document/BinaryPoint.java:35-44, :93-95, :114-118, :149-194, :238-239lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java:62-98, :471-477lucene/core/src/java/org/apache/lucene/document/Field.java:153-160; lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:111, :122; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:263-272lucene/core/src/test/org/apache/lucene/search/TestPointQueries.java:2255-2265, :2324-2332GitHub apache/lucene#7382 (LUCENE-6321) comments, read-only 2026-10-27: "who is modifying term bytes? This seems like an abuse case" and "do a little "extra" clone work"
- Suggested fix
lucene/core/src/java/org/apache/lucene/document/BinaryPoint.java:129-131, :182-194; lucene/core/src/java/org/apache/lucene/document/Field.java:153-160; lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java:72-98
- History
a Lucene clone tag releases/lucene-solr/6.0.0: lucene/core/src/java/org/apache/lucene/document/BinaryPoint.java:87, :176
- TestBinaryPointExactQueryOwnsValue.java (JUnit test):
repro/LFV-751/src/org/apache/lucene/document/TestBinaryPointExactQueryOwnsValue.java(46 lines)
A light summary card, made in parallel with this doc from an executed per-step trace on main a9779ef3c193 with assertions on and off (and the fix as control); the PNG is the summary card. This caption and alt text follow the summary card as re-rendered after the diagram check (legible check and cross marks at every size, a narrower title, two copies per query in the fix's cost).
assets/LFV-751/bug.png(523 KB)
![Summary card. Title: BinaryPoint's one-dimensional exact and range queries keep the caller's byte[], so refilling it changes them. Panel 1, one scratch array, two exact queries: scratch[0] = 1, q1 = newExactQuery("k1", scratch); scratch[0] = 2, q2 = newExactQuery("k1", scratch); then count q1 OR q2 (two SHOULD clauses); both queries point at the one scratch cell; q1 prints binary(2) (expected binary(1)), q1.equals(q2) is true (expected false), count(q1 OR q2) is 1 (expected 2). Panel 2, the caller writes after building: ranges built for [1, 2], then 9 written (hit counts; the last row shows the matched document); the one-dimension newRangeQuery and the byte[][] overload with one dimension count 0 (expected 2); the two-dimension overload counts 2; newSetQuery still matches doc 1. Panel 3, the call chain: newExactQuery to newRangeQuery(byte[], byte[]) to newRangeQuery(byte[][], byte[][]) to pack(), which returns new BytesRef(point[0]) for one dimension (no copy) and a new array for two or more, and PointRangeQuery stores it. Notes: executed on Lucene main with assertions on and off; affected 6.0.0 to 10.5.2 and main; fix: clone the packed bounds when there is one dimension; likely objection: PointRangeQuery's Expert constructor keeps its arrays, but the caller only writes its own array, before any search, and the same factory copies for two or more dimensions. Right and wrong values carry a check or a cross mark.](../assets/LFV-751/bug.png)