One line in SimpleTextPointTree.clone() copies the reference to the original's per-level "saved bound" array instead of the array, so the two cursors overwrite each other's saved bounds.
- Each segment indexes a points field as a BKD tree: inner nodes split the value range, leaves hold up to 512 points.
PointValues.getPointTree()returns a cursor on the root. - The cursor moves with
moveToChild(),moveToSibling()andmoveToParent(), andgetMinPackedValue()/getMaxPackedValue()return the current node's bounds. Going down narrows one bound to the split value; going back up restores it. - To restore it, the SimpleText cursor saves the old bound in a small array, one per tree level.
clone()returns a second cursor whose root is the current node.
- Index 1,024 docs with one
IntPointfield, values 0..1023, usingSimpleTextCodec: one segment, two leaves split at 512. TakegetPointTree(), step down and back up (moveToChild(),moveToParent()), andclone()it at the root. - Move the clone down to its left child
[0, 512]. Move the original down and over to its right child[512, 1023]. Move the clone back up to its root. - You expect the clone to report its root's bounds
[0, 1023]again:clone()promises a new tree rooted at that node, and the default codec gives[0, 1023]for the same calls. - You get
[0, 0].clone()copied a reference to the original's per-level "saved bound" array, not the array. The original'smoveToSibling()saved its min, 0, there, and the clone'smoveToParent()restored that as its max. - No error is thrown. It works both ways, and
visitDocIDs()on an inner node writes the array too. No Lucene query moves trees like this, so stock queries are not affected.
Only code that calls the experimental PointTree API directly on a SimpleTextCodec index, clones a tree and keeps moving both. SimpleText is documented as debug-only, but Lucene's randomized tests pick it now and then, so such hand-written traversal tests can fail rarely. No Lucene query is affected, and the default codec is correct.
Mechanism, terms and numbers705 words
PointValues.PointTree#clone promises that "the current node becomes the root of the new tree". In the SimpleText points format, SimpleTextPointTree.clone() copies the reference to the original's per-level array that remembers the split dimension's old bound (SimpleTextBKDReader.java:131) instead of copying the array. Both trees then save into, and restore from, the same array at the clone's root level, so a move of one tree changes the bounds the other restores.
With 1,024 IntPoint values 0..1023, seven calls leave the clone at its root reporting [0, 0] instead of [0, 1023]; the default codec gives [0, 1023] for the same calls. It needs the debug-only SimpleText codec and code that drives the experimental PointTree API by hand; no Lucene query moves trees this way. It is in every release since the API appeared in 9.1.0, and the fix is one line in clone(): copy the array instead of its reference, or do not copy it at all, which is what the default format's clone does with its own saved-bound array.
public PointTree clone() { // :124-134
SimpleTextPointTree index =
new SimpleTextPointTree(in.clone(), nodeID, level, minPackedValue, maxPackedValue);
if (isLeafNode() == false) {
// copy node data
index.splitDims[level] = splitDims[level];
index.splitDimValueStack[level] = splitDimValueStack[level]; // :131, the reference
}
return index;
}splitDimValueStack"holds the previous value of the split dimension", onebyte[]per level (:92-93, allocated per tree at:110). A tree fromgetPointTree()starts at node 1, level 1 (:78-81).pushLeft()(:145-181, used bymoveToChild()) allocates the level's array only if it is null (:154-156), saves the current max of the split dimension into it (:158-159), then narrows max to the split value.pushRight()(:193-227, the second half ofmoveToSibling()) saves the current min into the same array (:204-205), then narrows min.pop()(:238-258, used bymoveToParent()and the first half ofmoveToSibling()) copies the array back into max (coming up from a left child) or min (from a right child).visitDocIDs()/visitDocValues()on an inner node runpushLeft,pop,pushRight,popon the tree itself (:359-365,:391-397), so they fill and overwrite the array too.
| step | call | tree | clone | the shared slot |
|---|---|---|---|---|
| 1 | tree.moveToChild() | [0, 512] | - | tree allocates it, saves max 1023 |
| 2 | tree.moveToParent() | [0, 1023] | - | tree restores max 1023 |
| 3 | clone = tree.clone() | [0, 1023] | [0, 1023] | clone gets the same array |
| 4 | clone.moveToChild() | [0, 1023] | [0, 512] | clone saves max 1023 |
| 5 | tree.moveToChild() | [0, 512] | [0, 512] | tree saves max 1023 |
| 6 | tree.moveToSibling() | [512, 1023] | [0, 512] | tree saves its min 0 |
| 7 | clone.moveToParent() | [512, 1023] | [0, 0] (default codec: [0, 1023]) | clone restores max = 0 |
- The array is shared only if the original had already allocated it at that level before
clone(): after amoveToChild()+moveToParent(), or a visit of an inner node. Otherwise the clone allocates its own on its first push. - Then one tree must push at that level between the other tree's push and pop there.
- It works both ways. An independent verifier's 6-call trigger has no
moveToParent():visitDocIDsat the root,clone(),tree.moveToChild(),clone.moveToChild(),clone.moveToSibling(),tree.moveToSibling(); on values 0..9999 the original's right child reports[5000, 0](max < min) instead of[5000, 9999]. - The shortest known trigger has 5 calls (the devil's-advocate verifier's visit variant):
tree.visitDocIDs(v)at the root,clone(),clone.moveToChild(),tree.visitDocIDs(v),clone.moveToParent(); the clone's root then reports[0, 0]for values 0..1023. The seven moves-only calls are used here because they need no visitor.
BKDReader's clone (BKDReader.java:341-376) builds the clone through the constructor that allocates a fresh splitDimValueStack for every tree (:317) and never copies that array, so the clone fills its own slot on its first push (:407-412). The byte array it does deep-copy, splitValuesStack[level].clone() (:366), is a different one: the per-level split values, which SimpleText reads from splitPackedValues instead. Its clone shares no per-level array with the original.
Exposure and likelihood247 words
- Who. Direct users of
PointValues.PointTree(@lucene.experimental,PointValues.java:235-240) on a SimpleText index that clone a tree and keep moving both trees: a custom BKD walker, or a test that navigates trees by hand. - Opt-in, debug-only codec.
SimpleTextPointsFormat: "For debugging, curiosity, transparency only!! Do not use this codec in production." (SimpleTextPointsFormat.java:27);SimpleTextCodec: "FOR RECREATIONAL USE ONLY" (SimpleTextCodec.java:35). The default codec gives the right bounds for the same calls. - Randomized tests. The test framework's random codec picks
SimpleTextCodecwhenrandom.nextInt(11) == 9andrarely()(SetupAndRestoreStaticEnv.java:144,:185-190). A suite that drivesPointTreeclones by hand can therefore fail rarely, and only under that codec, which looks like flakiness. - No in-tree query reaches it.
NearestNeighborclones right after descending, at a level the source tree has not pushed at, so nothing is shared (NearestNeighbor.java:298-299).IndexSortSortedNumericDocValuesRangeQuery.lastDocmoves the original only sideways or up after its clone at:366, and its clone at:404comes right after a descent, as inNearestNeighbor. A brute-force check of both on SimpleText ran 10,800 checks with 0 wrong results. - Scale. Any segment with more than one leaf, that is more than 512 points per field (
SimpleTextPointsWriter.java:83,BKDConfig.DEFAULT_MAX_POINTS_IN_LEAF_NODE). - Effect. Wrong node bounds (
[0, 0]for values 0..1023; also max < min). A traversal that prunes on them could skip or keep the wrong cells. No exception, no hang, no index damage; the same with assertions on and off.
One JUnit test, public API only, deterministic: 1,024 IntPoint values in a SimpleTextCodec index, then the seven PointTree calls. The assertTrue calls only check that each move happened; the last line checks the clone's root max.
/*
* 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.codecs.simpletext;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.PointValues.PointTree;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSimpleTextPointTreeCloneIndependence extends LuceneTestCase {
public void testMovingOriginalDoesNotChangeClone() throws Exception {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new SimpleTextCodec());
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 1024; i++) { // two leaves of 512 points
Document doc = new Document();
doc.add(new IntPoint("f", i));
w.addDocument(doc);
}
try (DirectoryReader r = DirectoryReader.open(w)) {
PointTree tree = getOnlyLeafReader(r).getPointValues("f").getPointTree();
assertTrue(tree.moveToChild());
assertTrue(tree.moveToParent());
PointTree clone = tree.clone();
assertTrue(clone.moveToChild());
assertTrue(tree.moveToChild());
assertTrue(tree.moveToSibling());
assertTrue(clone.moveToParent());
// expected: the clone's root bounds max 1023 again; actual: 0 (overwritten by the original)
assertEquals(1023, IntPoint.decodeDimension(clone.getMaxPackedValue(), 0));
}
}
}
}
cp $REPRO/TestSimpleTextPointTreeCloneIndependence.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/
cd $LUCENE
./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- Back at its root, the clone reports max 1023 (bounds
[0, 1023]), as the default codec does. - Observed
AssertionError: expected:<1023> but was:<0>: the clone's root reports[0, 0], with assertions on and off.
More files, environment, full output207 words
- Smallest input. Two leaves are enough (1,024 points); the first
moveToChild()+moveToParent()is needed because the per-level array is allocated lazily, and without it the clone would allocate its own. - Deterministic.
ByteBuffersDirectory, fixed data, one thread, nothing randomized; the same result with assertions on and off. - Releases. The same file fails unchanged on 10.6.0 and 9.1.0; 9.0.0 has no
PointTree. - As the regression test: this same test, for example as a method of
TestSimpleTextPointsFormat. It passes with either fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and 9.1.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestSimpleTextPointTreeCloneIndependence.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/
cd $LUCENE
./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestSimpleTextPointTreeCloneIndependence > testMovingOriginalDoesNotChangeClone FAILED
java.lang.AssertionError: expected:<1023> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:579C430CFEBB0A1C]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence.testMovingOriginalDoesNotChangeClone(TestSimpleTextPointTreeCloneIndependence.java:50)
# main 645d4fe14a20, assertions on and off (standalone): the same AssertionError
# releases 10.6.0 (assertions on and off) and 9.1.0: the same AssertionError
# release 9.0.0: does not compile (no PointValues.PointTree)
# the same calls with the default codec: pass (the clone reports [0, 1023])
# with either fix: BUILD SUCCESSFUL (the test and TestSimpleTextPointsFormat)PointTree#clonepromises "the current node becomes the root of the new tree"; LUCENE-10262 (9.1.0) explicitly allowed random navigation.- The default codec gives
[0, 1023]for the same calls, and Lucene's ownNearestNeighborrelies on clones moving independently. - Nothing documents the sharing; LUCENE-10264, an earlier sharing bug in this same class, was fixed as a bug.
The full argument205 words
- Contract.
PointValues.java:242: "Clone, the current node becomes the root of the new tree.":263-267: the min and max "of the current node". - Random navigation is allowed. LUCENE-10262 (
a590c6d2a08, #476, 9.1.0; "This change allows random navigation of a PointValues#PointTree") removed "The method should not be called after a successful call to moveToParent()" fromclone()andmoveToChild(). It updatedBKDReader, not the SimpleText fork. In the review of the introducing PR (#7), a committer had called the old rule "quite trappy". - In-tree reliance.
NearestNeighbor.java:298: "we must clone the index so that we can recurse left and right "concurrently"". - The copy gains nothing. The clone always writes the slot in its own
pushLeftbefore anypopat that level reads it.BKDReader's clone does not copy its ownsplitDimValueStackat all (BKDReader.java:341-376), neither at the introducing commit nor on main. - Precedent. LUCENE-10264 (#11300, fixed by #478, 9.1.0): trees from
SimpleTextBKDReader.getPointTree()shared oneIndexInput; fixed as a bug. That fix editedclone()but left line 131. - Why tests miss it.
BasePointsFormatTestCase.assertSize(:1086-1126) clones and "rarely" continues with the clone, but never interleaves moves of a clone and its original;randomPointTreeNavigation(:1128-1143) uses one tree.
The devil's-advocate check136 words
Strongest case for "not a bug": SimpleText is a debug codec, PointTree is @lucene.experimental, and clone() never says "deep copy", so sharing is acceptable.
Why it fails: those notes limit impact, not correctness. @lucene.experimental means the API may change incompatibly, not that its current contract is void. The codec implements the public PointTree API and the test framework runs suites on it at random. The javadoc promises a new tree, LUCENE-10262 allows random navigation, and the sibling implementation does not share. Of the cross-model reviews (OpenAI GPT-5.6), the works-as-designed review rated the intended-behaviour case weak, and the read-only refute-lens review rated it moderate ("This limits support and priority, but does not document corrupted bounds as intended"), while agreeing on the root cause and that the inputs are in contract.
What it does establish: low priority.
Recommended A. Clone the array (recommended)
Clone the array instead of sharing its reference: index.splitDimValueStack[level] = splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone(); (one line in SimpleTextBKDReader.clone()). Validated: the test fails before and passes after, and TestSimpleTextPointsFormat passes (21 tests, 0 failures).
if (isLeafNode() == false) {
// copy node data
index.splitDims[level] = splitDims[level];
index.splitDimValueStack[level] =
splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone();
}All options and trade-offs2 options · 368 words
Two one-line options. Both were applied in a scratch worktree of main 645d4fe14a20 and run with gradle against the minimal test and TestSimpleTextPointsFormat.
In SimpleTextPointTree.clone() (SimpleTextBKDReader.java:131), copy the array instead of its reference.
if (isLeafNode() == false) {
// copy node data
index.splitDims[level] = splitDims[level];
index.splitDimValueStack[level] =
splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone();
}Trade-offs
- Correct without any invariant: the clone starts from the same saved bound, in its own array, so it is easy to review. It follows the pattern
BKDReader.cloneuses for a different per-level array (splitValuesStack[level].clone(),BKDReader.java:366); for its ownsplitDimValueStack,BKDReadercopies nothing, which is fix B. - Cost: one small array (bytes per dimension) per clone, only on this debug codec.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it;TestSimpleTextPointsFormat: 21 tests, 0 failures (3 skipped by the suite:testCheckIntegrityReadsAllBytes,testMergeStability,testRandomBinaryBig). - Standalone, with the change shadowed in, the minimal test passes with assertions on and off.
- Earlier, on main
a9779ef3c193: the finder's test (1 of 1), an independent verifier's 6 tests and the cross-model reviewer's 6 tests pass with the same change. - Not run: the whole
:lucene:codecs:testorcheck.
Delete index.splitDimValueStack[level] = splitDimValueStack[level];. The clone can only pop at its root level after its own pushLeft() there, which allocates and fills the slot (:154-159); moveToParent() and moveToSibling() at the clone's root return false (:185, :231). Suggested by the devil's-advocate verifier.
if (isLeafNode() == false) {
// copy node data
index.splitDims[level] = splitDims[level];
}Trade-offs
This is what BKDReader does for the same field: its clone never copies splitDimValueStack (BKDReader.java:341-376); each tree allocates its own (:317) and fills a slot on its first push (:407-412). One line shorter and no allocation, but its correctness rests on that push-before-pop invariant.
What the checks validated
- Gradle, the same scratch worktree and seed: the minimal test passes;
TestSimpleTextPointsFormat: 21 tests, 0 failures (3 skipped).
Relevant only to downstream code that walks PointTree clones by hand on SimpleText indexes, usually tests: it gets wrong node bounds, never an exception.
Semi-formal. A hunting AI tool modelling PointTree cursors read clone() and predicted the sharing; its bounded-exhaustive harness then ran every move/clone/visit sequence up to length 3 on up to 3 trees, plus 1,500 random sequences of length 14, checking every tree after every step. The long random sequences produced the failures, all on SimpleText.
The method in full163 words
- The hunt unit checked the coherence of
PointTreecursors across points formats on the stock main build. - The harness built a table of each node's true bounds from fresh trees, validated against the indexed points, then ran every sequence of length up to 3 over child, sibling, parent, clone,
visitDocIDsandvisitDocValueson up to 3 live trees, plus 1,500 random sequences of length 14, and checked every live tree after every step. - The saved log of that tier, which runs SimpleText trees only, holds 281 bound errors. The shortest known trigger has 5 calls (a visit, clone, child, visit, parent), longer than the exhaustive length-3 bound, so the signal came from the long random sequences, after a reading of
clone()had predicted it. - A second harness checked the two in-tree clone users (
NearestNeighbor,IndexSortSortedNumericDocValuesRangeQuery.lastDoc) on SimpleText: 10,800 checks, 0 wrong results. - The finder turned the finding into a public-API JUnit test; independent verifiers reproduced it with their own tests.
No report found (searched 2026-10-27): GitHub issues, PRs and commits, the JIRA archive, five mailing lists, downstream projects and the web, plus the changed files of all 408 open PRs. Closest: LUCENE-10264, an earlier sharing bug in the same class, and LUCENE-10262, which allowed random navigation.
Searches4 searches
Related items, none this bug: LUCENE-10264 (#11300, fixed by #478: an uncloned IndexInput shared by SimpleText trees; its fix edited clone() but left this line); LUCENE-10262 (#11298 / #476: lifted the navigation restrictions, changed BKDReader only); #7 (LUCENE-9820, the introducing PR; its review discussed the clone-after-moveToParent rule, not shared state); #486 (open, stale: touches the same file, not clone()); LUCENE-7690 and LUCENE-8704 (SimpleText points test failures from before the PointTree API). splitDimValueStack has 0 hits anywhere on GitHub. Gaps: builds@ seems to index CI mail bodies only in part, so an unreported CI failure cannot be ruled out; private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream projects) | 2026-10-27 | two scripted searches (identifier terms; contract and symptom terms) plus 41 manual searches; the changed-file lists of all 408 open apache/lucene PRs | no report; #486 is the only open PR touching the file, not clone() |
| JIRA archive | 2026-10-27 | 8 scripted queries and 5 issue reads, including the LUCENE-9820 worklog | only the introducing diff text in the LUCENE-9820 worklog |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@) | 2026-10-27 | 45 scripted queries and 15 direct archive searches, including builds@ 2022-2026 | no report |
| Web | 2026-10-27 | 15 queries | nothing relevant |
Introduced with the PointTree API in 9.1.0 by LUCENE-9820 (ad911df2605, 2021-11-19); LUCENE-10262 widened the navigation contract in the same release. Fails on every version run from 9.1.0 to 10.6.0 and on main; 9.0.0 has no PointTree. Not fixed upstream.
Versions tested6 rows
- Introduced:
ad911df2605"LUCENE-9820: Separate logic for reading the BKD index from logic to intersecting it (#7)" (2021-11-19), which created thePointTreecursor API andSimpleTextPointTreewith thisclone(). The same commit'sBKDReader.clonedid not copy its ownsplitDimValueStackat all (it deep-copied onlysplitValuesStack), so the default format never shared it. Backport to branch_9x:9adf7e27f97(#457).git log -Son the line finds only these two commits. - Contract widened:
a590c6d2a08(LUCENE-10262, #476, 2021-11-26) allowed clone andmoveToChild()aftermoveToParent(). Before it, only the visit trigger was inside the contract; both shipped in 9.1.0, so every release has the defect under the widened contract. The unreleased window between the two commits was not run. - Not a regression: the defect was born with the API.
- By reading: the exact line is present at every release tag from 9.1.0 to 10.6.0 and absent from 9.0.0; later edits to
clone()and the push/pop methods were mechanical (634c22c527e, LUCENE-10264;4404aa3fe9c, BKDConfig record accessors in 10.0.0). - Upstream: unchanged on main, branch_10x, branch_10_6 and branch_9_12 at the prior-report search (2026-10-27).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone); passes with either fix |
| 10.6.0 | affected | minimal test fails with assertions on and off |
main a9779ef3c193, 10.5.2, 10.0.0, 9.12.3 | affected | the finder's test and the 6-call visit trigger fail |
| 9.1.0 | first release | minimal test, the finder's test and the visit trigger fail |
| 9.0.0 | not affected | no PointValues.PointTree: the test does not compile |
| default codec, same calls | not affected | the clone reports [0, 1023] |
The finder, an independent reproducer, a cross-model reviewer (OpenAI GPT-5.6), a devil's advocate and an adjudication call it real; an earlier refute-lens review voted not real only because it could not run anything, while agreeing on the cause. Re-run on two main commits and on five releases from 9.1.0 to 10.6.0.
Checks and verdicts234 words
Gap: neither cross-model review could execute code itself; all execution evidence comes from Claude-family AI tools and separate runs of the tests.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Public-API JUnit fails on main (assertions on and off) and on 10.5.2 and 9.12.3 (assertions on); passes with fix A. |
| cross-model review, refute lens | not real by procedure, low | OpenAI GPT-5.6, read-only: could not run code; its reading agrees with the root cause and that the inputs are in contract. |
| independent reproducer | real | Own 6-test class: the 4 SimpleText cases fail on main, 10.5.2, 9.12.3 and 9.1.0; default-codec controls pass; 6 of 6 with fix A. |
| works-as-designed lens | real, medium | OpenAI GPT-5.6: intended-behaviour case rated weak; a separate run of its 6-test class fails 3 on main, 10.5.2 and 9.12.3 and passes with fix A. |
| devil's advocate | real | The same calls pass on the default codec; a visit variant with no moveToParent() before the clone also fails (5 calls); proposed fix B. |
| adjudication | confirmed, high | Re-ran the finder's and the independent reproduction's tests; the independent reproduction's passes 6 of 6 with fix A. |
| re-runs | reproduces | Follow-up and audit re-runs on a9779ef3c193; the minimal repro and this doc's gradle and standalone runs on 645d4fe14a20, 10.6.0 and 9.1.0; the fact check's gradle run of the documented command with assertions on and off. |
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 433 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,216 words
- Registry title
- SimpleText points: PointTree.clone() shares a saved split-value array with the tree it was cloned from, so moving one tree corrupts the node bounds the other reports
- Area
- codecs / SimpleText points format (SimpleTextBKDReader.SimpleTextPointTree#clone)
- Priority
- Low, rank 1208: Only custom code that drives the experimental PointTree API by hand on the test-oriented SimpleText codec sees corrupted node bounds, and no in-tree caller does this.
- Impact class
- minor
- Impact note
- Hits only code that uses the @lucene.experimental PointValues.PointTree API directly on an index written with the SimpleText codec (documented as debug-only, 'Do not use this codec in production', though Lucene's randomized test framework picks it, rarely), clones a tree after the original has already saved a bound at that level (moveToParent after a child move, or visitDocIDs/visitDocValues on a non-leaf node) and then moves both trees: the clone or the original then reports getMinPackedValue/getMaxPackedValue bounds that no longer enclose its node's points (even max < min), so a traversal that prunes on them can skip or keep the wrong cells; no in-tree query reaches it (the two in-tree clone users, NearestNeighbor and IndexSortSortedNumericDocValuesRangeQuery.lastDoc, passed 10,800 brute-force checks on SimpleText), any index size, -ea and -da, every release from 9.1.0 to 10.6.0 and main.
- Summary
- You index points with the SimpleText codec, take a PointTree from PointValues.getPointTree(), clone it and keep moving both the clone and the original, and expect each tree to report the bounds of its own current node, as PointTree#clone promises ('the current node becomes the root of the new tree') and as the default points format does. Instead SimpleTextPointTree.clone() copies the reference to the original's saved split-value array for the current level, so a move of one tree overwrites the bound that the other tree restores when it moves back up: after tree.moveToChild(), tree.moveToParent(), clone = tree.clone(), clone.moveToChild(), tree.moveToChild(), tree.moveToSibling(), clone.moveToParent(), the clone at its root reports max 0 instead of 2047 for the values 0..2047. Any traversal that prunes on those bounds can then miss or wrongly include cells; no in-tree query moves trees this way.
- Introduced
- ad911df2605591a63ca58d237182f84aa8384325 (main, 2021-11-19, by iverase): 'LUCENE-9820: Separate logic for reading the BKD index from logic to intersecting it' (apache/lucene#7), which created the PointValues.PointTree cursor API and SimpleTextPointTree, including clone() with the reference copy 'index.splitDimValueStack[level] = splitDimValueStack[level];' (the default BKDReader.clone in the same commit already cloned its per-level split values); branch_9x backport 9adf7e27f97b (#457) [read: git log -S on the line finds only these two commits; git blame on main attributes clone() to ad911df2605 except the in.clone() line of LUCENE-10264]. The contract was widened a week later by a590c6d2a08 (LUCENE-10262, #476, 2021-11-26, 'Lift up restrictions for navigating PointValues#PointTree'), which updated BKDReader.clone but not the SimpleText fork; from then on every trigger is inside the contract. (2021-11-19 (LUCENE-9820, the defect); 2021-11-26 (LUCENE-10262, contract widened to random navigation))
- First affected version
- 9.1.0 [exec]: LUCENE-9820 and LUCENE-10262 both first shipped there; 9.0.0 and all earlier releases have no PointTree API (the repro does not compile on 9.0.0) [exec], so the defect was born with the API and is not a regression
- Affected versions
- every release with the PointTree API, 9.1.0 through 9.12.3 and 10.0.0 through 10.5.2 (the last release before 10.6.0), 10.6.0, and main (a9779ef3c193 and 645d4fe14a20, unreleased 11.0); never fixed upstream. [exec] the minimal test fails on 10.6.0 and main 645d4fe14a20 (-ea and -da) and on 9.1.0 (-ea); the finder's JUnit fails 1 of 1 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea; main also -da); the independent reproduction's no-moveToParent visit trigger fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea); the independent reproduction's JUnit fails 4 of 6 with -da (4 of the 5 that run with -ea; its default-codec controls pass) on 10.5.2 and 9.12.3 (-ea and -da), 9.1.0 (-da) and main (-ea and -da); a separate JUnit fails 3 of 6 on 10.5.2, 9.12.3 and main (-ea and -da). [read] the other 28 releases: the clone() line is present at all 32 tags from 9.1.0 to 10.5.2 and absent from 9.0.0; the only later edits to clone/push/pop are mechanical (LUCENE-10264 in.clone() before 9.1.0; BKDConfig record accessors in 10.0.0).
- 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: exhaustive check
- Upstream and prior reports
- none found (2026-10-27): two scripted searches (A: identifiers such as SimpleTextBKDReader, SimpleTextPointTree and splitDimValueStack; B: contract and symptom terms; together 95 non-web queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists and the JIRA archive, plus 12 web queries), a re-read of the finder's run (45 non-web queries), 41 manual GitHub searches (splitDimValueStack has 0 hits anywhere on GitHub), the changed-file lists of all 408 open apache/lucene PRs (only the stale #486, visitor interfaces, touches the file, and not clone), 15 direct list-archive searches including builds@ 2022-2026, and 5 JIRA reads. Not fixed upstream: the line is unchanged since ad911df2605 on main f7bcc0abd816, branch_10x, branch_10_6 and branch_9_12. Related, not this bug: LUCENE-10264 (#11300, fixed by #478 in 9.1.0: an earlier state-sharing bug in the same class, the IndexInput of trees from getPointTree; its fix edited clone() but left this line) and LUCENE-10262 (#11298 / #476, 9.1.0: lifted the clone/moveToChild-after-moveToParent restriction and allowed random navigation; it changed BKDReader only). Gap: builds@ seems to index CI mail bodies only partly, so an unreported CI failure caused by this bug cannot be ruled out.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test with assertions on and off standalone (2026-10-29 and 2026-11-01), and with gradle in a scratch worktree before and after each of the two fixes, together withTestSimpleTextPointsFormat(2026-11-01); the same calls on the default codec pass. Releases 10.6.0 (assertions on and off) and 9.1.0 fail; 9.0.0 has noPointTreeand does not compile the test. Earlier runs: maina9779ef3c193, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. All runs used OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:124-135 (clone), :150-159 (pushLeft saves maxPackedValue into splitDimValueStack[level]), :200-205 (pushRight saves minPackedValue), :238-256 (pop restores); lucene/core/src/java/org/apache/lucene/index/PointValues.java:238-266 (@lucene.experimental, clone and getMin/MaxPackedValue javadoc); lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:341-366 (default clone, splitValuesStack[level].clone()); lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298; lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextPointsFormat.java:27 and SimpleTextCodec.java:35; lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreStaticEnv.java:186-190 (read at the follow-up step)
- The finding in 30 seconds
lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:78-81, :92-93, :110, :124-134, :136-143, :145-181, :183-191, :193-227, :229-236, :238-258, :336-367, :369-399 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:227, :317, :341-376 (clone; :366 splitValuesStack), :407-412 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/PointValues.java:235-267 (at 645d4fe14a20)lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextPointsFormat.java:27; SimpleTextCodec.java:35, :37; SimpleTextPointsWriter.java:83 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreStaticEnv.java:144, :185-190 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298-299; search/IndexSortSortedNumericDocValuesRangeQuery.java:366, :404 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/PointValues.java:242-243, :263-267 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:341-376 (at 645d4fe14a20 and ad911df2605: clone() never copies splitDimValueStack)lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/index/BasePointsFormatTestCase.java:1086-1143 (at 645d4fe14a20)
- Suggested fix
lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:154-159, :185, :231 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:317, :341-376, :366, :407-412 (at 645d4fe14a20)
- History
a Lucene clone: releases/lucene/9.0.0, 9.1.0, 10.5.2, 10.6.0 (SimpleTextBKDReader.java read; line 131 present from 9.1.0)
- TestSimpleTextPointTreeCloneIndependence.java:
repro/LFV-753/src/org/apache/lucene/codecs/simpletext/TestSimpleTextPointTreeCloneIndependence.java(54 lines)
assets/LFV-753/bug.png(433 KB)
![A SimpleText PointTree over values 0..1023 and its clone share one saved-bound slot for the root level. The clone goes down and saves max 1023; the original goes down and over to the right child and saves its min 0 in the same slot; the clone moves back up and restores max 0, so its root reports [0, 0] instead of [0, 1023]. The default codec reports [0, 1023].](../assets/LFV-753/bug.png)