TSTLookup keeps suggestions in a ternary search tree and walks it with one Java stack frame per tree level whenever it adds, stores, loads or measures. A tree thousands of levels deep runs the thread stack out.
- A ternary search tree stores one character per node, with three links:
loto a smaller character at the same position of the key,eqto the key's next character,hito a larger character at the same position. - The lo/hi links at one position form an ordinary binary search tree with no rebalancing. Insert a, b, c, ... in sorted order and each new node becomes the
hichild of the last one: a chain, not a tree.
hi link: one chain 20,993 nodes deep. store() takes one stack frame per node and runs out; the same keys through build() stay shallow. Select the still to open it at full size. Generated with AI, checked against the code.- You call
add()once for each of the 20,992 CJK Unified Ideographs, U+4E00 to U+9FFF, in code-point order, thenstore()the lookup to save it. - You expect
store()to return true, as its javadoc promises (failure is afalsereturn or anIOException), and as it does for the same keys added throughbuild()or in shuffled order. - Instead
store()throwsStackOverflowErroron a 2 MiB thread stack, and so doload()andramBytesUsed().get()andlookup()still work: the lookup works in memory but cannot be saved or reloaded. - Why: each larger character hangs off the previous one's
hilink, so the tree is one chain 20,993 nodes deep, andstore(),load(),ramBytesUsed()andadd()itself use one Java stack frame per node. build()balances only the lo/hi links, and every character of a key adds a level, so one 20,000-character suggestion overflowsbuild()in a fresh JVM, whileWFSTCompletionLookupaccepts it.
Applications using the opt-in TSTLookup whose tree gets deep: a word list with thousands of distinct first characters (a CJK dictionary, say) fed to add() in sorted order, or one suggestion thousands of characters long, even through build(). The error is loud and rare, but such a lookup cannot be saved or reloaded.
Mechanism, terms and numbers829 words
TSTLookup keeps its suggestions in a ternary search tree, one character per node. Four methods walk that tree recursively, one Java stack frame per tree level: TSTAutocomplete.insert (behind add() and build()), TSTLookup.writeRecursively (store()), readRecursively (load()) and TernaryTreeNode.sizeInBytes (ramBytesUsed()). At each key position the lo/hi links form a plain binary search tree with no rebalancing, so add() in sorted order builds a chain about as deep as the number of distinct first characters (that sorted insertion leaves this tree highly unbalanced was noted by the TST's author, Ankul Garg, on SOLR-1316 in 2009, as a speed concern). Every character of a key also adds one level, so one long suggestion is deep even through build().
Adding the 20,992 CJK Unified Ideographs in code-point order gives a tree 20,993 levels deep: add(), get() and lookup() work, but store(), load() and ramBytesUsed() throw StackOverflowError on the JVM's default thread stack (2040 KiB on Linux aarch64). One 20,000-character suggestion makes build() itself overflow in a fresh JVM. Lookup.store and load document failure only as a false return or an IOException, and add() "always returns true" with no order or length limit. The same keys through build(), or add()ed in shuffled order, work. Every release from 3.3.0 to 10.6.0, and main, has the recursive code; 11 of those releases, Solr 3.1.0, the 10.6.0 release commit and main were run and fail. An iterative rewrite of the four walks, in the same byte format, fixes it.
- The tree. A
TernaryTreeNodeholds one character (splitchar) and the linksloKid,eqKid,hiKid(TernaryTreeNode.java:27-40).TSTLookupstarts from a sentinel root whosesplitcharis U+0000 (TSTLookup.java:43,:125,:277). - Insertion recurses once per level.
TSTAutocomplete.insert(TSTAutocomplete.java:57-86) calls itself throughloKid(:73) andhiKid(:83) without advancing the key position, and througheqKidonce per character (:66,:76).add()calls it on the root with no balancing (TSTLookup.java:149-153). - Sorted
add()builds a chain. Increasing characters each become thehiKidof the previous one: 20,992 one-character keys give ahiKidchain under the root, depth 20,993. Law T1 (the finder's):add()in increasing or decreasing order gives depth at least 1 + the number of distinct first characters, whatever the key length; checked against the real tree read back throughstore()for 81 values of n from 0 to 2,960 (every n up to 49, then steps of 97) and 300 random sorted and reverse-sorted dictionaries, 0 mismatches. build()balances only the lo/hi links. It sorts the input (TSTLookup.java:117-142) and inserts it middle-first throughbalancedTree(TSTAutocomplete.java:40-46, itself only about log2(n) deep). Law T2 (proved, and checked on every non-empty set of up to 4 of 39 short keys): throughbuild()the depth is at most the longest key length + floor(log2 n) + 1, and the bound is tight. Short keys are safe; each character of a long key adds oneeqKidlevel.- The other walks recurse once per level too.
writeRecursively(TSTLookup.java:242-265, calls at:257,:260,:263) forstore()(:267-272);readRecursively(:218-239, calls at:229,:233,:237) forload()(:274-280);TernaryTreeNode.sizeInBytes(TernaryTreeNode.java:50-69, calls at:53,:56,:59) forramBytesUsed()(TSTLookup.java:282-290).get()andlookup()useprefixCompletion(TSTAutocomplete.java:97-148), which already walks with an explicitArrayDeque: they work even on a 41,002-level tree with a 256 KiB thread. - Side note: the
add()javadoc (TSTLookup.java:145) reads "Adds a new node if <code>key</code> already exists, otherwise replaces its value", the inverse of whatinsertdoes (sinced4aace10381, 2012).
Largest tree depth (store, load, ramBytesUsed) or key length (build) that survives; "sorted add" = how many of 41,000 one-character keys, added in sorted order, went in before the first add() threw (> 41,000 = all of them); a fresh JVM per trial, JDK 25.0.3, Linux aarch64, -da, full tiered JIT. "Cold" = no warm-up, "warm" = every entry point compiled first.
| thread stack | JIT | sorted add | store | load | ramBytesUsed | build (one long key) |
|---|---|---|---|---|---|---|
| 1 MiB (Linux x64 default) | cold | 19,635 | 5,351 | 5,328 | 6,054 | 5,888 |
| 1 MiB | warm | 19,637 | 16,829 | 10,742 | 10,709 | 9,816 |
| 2040 KiB (Linux aarch64 default) | cold | > 41,000 | 11,262 | 10,822 | 12,549 | 12,390 |
| 2040 KiB | warm | > 41,000 | 35,407 | 29,565 | 22,531 | 20,653 |
The thresholds move with the JIT state, and not monotonically: after a warm-up one check loaded 15,000 and 40,000 levels on 1 MiB while 20,992 still failed, and on 10.5.2 with -ea the 20,000-character build() survived the default stack while store(), load() and ramBytesUsed() of that tree still failed. With Lucene's local test flags (-XX:TieredStopAtLevel=1, C1 only) the sorted add() of 20,992 keys already overflows a 2 MiB thread. Only Linux aarch64 was run.
Exposure and likelihood319 words
- Who is exposed. Users of
TSTLookup(opt-in, not deprecated;FSTCompletionLookup's javadoc points to it). - Trigger 1: sorted
add()of a word list with thousands of distinct first characters, in practice a CJK-scale dictionary (for Latin-script words each per-position chain is bounded by the alphabet; a reading of the code, not run).add()has no in-tree caller, but it is public. - Trigger 2: one long suggestion, through
build()oradd(). An independent check reached it through Lucene's ownLuceneDictionaryover an indexedStringFieldterm of 8,000 bytes:TSTLookup.buildthrewStackOverflowErroron a 1 MiB thread in 8 of 8 runs (main and 10.5.2); 12,000- and 32,000-byte terms failed too. - What breaks. The lookup works in memory but cannot be stored or loaded, so it is lost on restart; a
load()that fails this way also leaves the lookup half replaced (a separate bug, LFV-979). - Scale. Thousands of levels: in a fresh JVM on a 1 MiB thread
store()/load()fail from about 5,300 levels andbuild()from about 5,900 characters; on a 2040 KiB thread from about 10,800-12,500 levels and 12,400 characters. The JIT state moves these numbers. - How likely. Rare: it needs a degenerate input shape, and the searches found no public report since 2011. The error is a loud
StackOverflowErrorin the calling thread, with no index damage; but astore()that overflows has already written part of the tree (the count first, then each node before the walk goes below it), so the output it was writing to holds an incomplete copy (a reading of the code, not run). - Workarounds.
build()instead of sortedadd()(short keys only), a cap on suggestion length, or running the steps that walk the tree on a thread with a larger stack:store(),load()andramBytesUsed(), and for a long suggestionbuild()/add()too (the sortedadd()itself also overflows under C1-only JIT flags).
The test shows that a TSTLookup filled with add() in sorted order (the 20,992 CJK Unified Ideographs U+4E00..U+9FFF, one character each, on a thread with a fixed 2 MiB stack) answers lookup() but cannot be stored.
/*
* 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.suggest.tst;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.lucene.store.ByteBuffersDataOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTSTLookupSortedAddStore extends LuceneTestCase {
public void testStoreAfterSortedAdd() throws Exception {
TSTLookup lookup = new TSTLookup();
AtomicReference<Throwable> storeError = new AtomicReference<>();
Thread t =
new Thread(
null,
() -> {
for (char c = '\u4E00'; c <= '\u9FFF'; c++) {
lookup.add(String.valueOf(c), 1L);
}
try {
lookup.store(new ByteBuffersDataOutput());
} catch (Throwable e) {
storeError.set(e);
}
},
"tst-2MiB-stack",
2L << 20);
t.start();
t.join();
assertEquals(1, lookup.lookup("\u9FFF", false, 1).size());
if (storeError.get() != null) {
throw new AssertionError(
"expected store() of the 20,992 add()ed keys to return true, but it threw "
+ storeError.get(),
storeError.get());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-985/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$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.suggest.tst.TestTSTLookupSortedAddStore
- Expected vs actual
- Expected:
store()returns true (Lookup#store documents only false or IOException). Actual: StackOverflowError in the recursiveTSTLookup.writeRecursively(one frame per tree level; the sorted adds make a ~21,000-level chain). The threshold depends on the JIT state; 12 of 12 fresh JVMs failed on this host.
More files, environment, full output50 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 store() of the 20,992 add()ed keys to return true, but it threw java.lang.StackOverflowError
org.apache.lucene.search.suggest.tst.TestTSTLookupSortedAddStore.testStoreAfterSortedAdd(TestTSTLookupSortedAddStore.java:50)
Caused by: java.lang.StackOverflowError
java.base/java.nio.Buffer.<init>(Buffer.java:255)
java.base/java.nio.ByteBuffer.<init>(ByteBuffer.java:323)
java.base/java.nio.HeapByteBuffer.<init>(HeapByteBuffer.java:75)
java.base/java.nio.ByteBuffer.allocate(ByteBuffer.java:398)
org.apache.lucene.store.ByteBuffersDataOutput.appendBlock(ByteBuffersDataOutput.java:525)
[...]add()"always returns true" with no order or length limit;store()andload()name onlyfalseor anIOExceptionas failure.- Order alone flips it: the same keys pass via
build()or shuffled.build()accepts 32,759-byte suggestions, yet a 20,000-character one overflows it (fresh JVM). - Lucene fixed the same one-frame-per-character trap in suggester building as a bug (LUCENE-5628, 4.8.1).
The full argument343 words
- Contract.
TSTLookup.add: "This method always returns true", no order or length limit (TSTLookup.java:144-153).Lookup.store: "Persist the constructed lookup data to a directory. Optional operation. ... @return true if successful, false if unsuccessful or not supported. @throws IOException when fatal IO error occurs."Lookup.load: "Discard current lookup data and load it from a previously saved copy", with the same failure modes (Lookup.java:236-252). - Lengths the module accepts.
build()sorts throughOfflineSorter, which rejects entries over 32,767 bytes with a clearIllegalArgumentException(OfflineSorter.java:550-551); with the 8-byte weight,build()accepts suggestions up to 32,759 UTF-8 bytes (run by the fact check: 32,759 bytes accepted, 32,760 rejected with "len must be <= 32767; got 32768"). The 20,000-character suggestion is ASCII, 20,000 bytes.WFSTCompletionLookupbuilds, stores and loads it on a 1 MiB thread. - Order alone flips the result. The same 20,992 keys through
build(), oradd()ed in the shuffled order tested, complete every step; the limit is tree depth, not key count. - Tests. Only
TestPersistenceand the@IgnoredTestLookupBenchmarkuseTSTLookup, throughbuild()with short keys; no test asserts or excludes deep trees. - Precedent. LUCENE-5628, under Lucene 4.8.1 "Bug fixes" (core's automaton code, used when building suggesters): "Change getFiniteStrings to iterative not recursive implementation, so that building suggesters on a long suggestion doesn't risk overflowing the stack; previously it consumed one Java stack frame per character in the expanded suggestion. If you are building a suggester this is a nasty trap." More CHANGES entries that removed or bounded stack-exhausting recursion: GITHUB#16423, #11813, #11537, LUCENE-10181, LUCENE-7914, LUCENE-7956 (listed by the intended-lens reviewer, OpenAI GPT-5.6) and GITHUB#12286, "Toposort use iterator to avoid stackoverflow" (added by the finder).
- History. On SOLR-1316 (2009) the TST's author, Ankul Garg, wrote that adding strings "in sorted order in TST will make the tree highly unbalanced" and that
balancedTree"takes care of that"; Andrzej Bialecki asked whether it could be rebalanced on the fly. They discussed speed;store()/load()(2011) and the recursive size method (2014) did not exist yet.
The devil's-advocate check215 words
The strongest "works as designed" case: (a) the authors knew sorted insertion unbalances the tree and handled it only in build(); add() is a vestige (no longer in the Lookup API since LUCENE-3807, no in-tree caller, not counted by getCount()). (b) Maintainers declined iterative rewrites for very long input before: on LUCENE-5659 (#6721, Won't Fix) Robert Muir declined an iterative isFinite and called such use "abuse of the automaton pkg for what its not designed to do"; on LUCENE-6156 (#7218, Not A Problem) Michael McCandless wrote that making the code non-recursive "makes the code more complex". (c) Lookup is @lucene.experimental and Accountable @lucene.internal (a point from the refute-lens reviewer, OpenAI GPT-5.6).
The answer: nothing in (a) is documented, and the long-key leg needs no sorted input: build(), the path the authors made safe, overflows on one legal suggestion that WFSTCompletionLookup accepts. On (b), both refused code paths were later changed by bounding or removing the recursion (LUCENE-7914 and GITHUB#11813 for isFinite; GITHUB#11537 / PR #12462 for RegExp), and LUCENE-5628 was fixed as a bug. (c) weakens only the ramBytesUsed() leg. This shows the behavior is not documented as intended; it does not settle the design choice: a maintainer could still prefer a length limit, deprecation or no change over a rewrite.
Recommended A. Walk the tree iteratively (validated)
Walk the tree without recursion: insert loops down and appends the rest of the key as an eqKid chain; store, load and sizeInBytes use an explicit ArrayDeque in the same pre-order, so the stored bytes do not change. With it the repro and every suggest-module test pass.
All options and trade-offs5 options · 2,080 words
Option A was applied in scratch worktrees of main 645d4fe14a20 and run through gradle (seed DEADBEEF) and the repro harness. Options B to E were not run.
Three files, +127/-82 lines:
TSTAutocomplete.insert: a loop walks down (lo/hi without advancing the key position, eq advancing it) and remembers the parent and the link; at the first missing node it appends the rest of the key as a chain ofeqKidnodes. Same signature, return value and tree.writeRecursively/readRecursivelybecomewriteTree/readTree: pre-order with an explicitArrayDeque, kids pushed hi, eq, lo so they come off lo, eq, hi. The byte format is unchanged, and the split-char read/write lines keep their content, so LFV-699's fix (it changes exactly those lines) composes with this one; the lines move into the new loops with new indentation, so whichever patch lands second needs a trivial rebase.TernaryTreeNode.sizeInBytes: the same sum with an explicit stack.- The
add()javadoc: "Adds a new node if <code>key</code> does not exist yet, otherwise replaces its value."
diff --git a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java
index 787d9b78230..ca821148c88 100644
--- a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java
+++ b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java
@@ -58,30 +58,53 @@ public class TSTAutocomplete {
if (s == null || s.length() <= x) {
return currentNode;
}
- if (currentNode == null) {
- TernaryTreeNode newNode = new TernaryTreeNode();
- newNode.splitchar = s.charAt(x);
- currentNode = newNode;
- if (x < s.length() - 1) {
- currentNode.eqKid = insert(currentNode.eqKid, s, val, x + 1);
+ // Walk down iteratively: the tree can be deeper than the thread stack allows recursion
+ // (keys added in sorted order chain up the loKid/hiKid links; long keys chain up eqKid).
+ TernaryTreeNode parent = null;
+ int link = 0; // the link from parent to node: 0 = loKid, 1 = eqKid, 2 = hiKid
+ TernaryTreeNode node = currentNode;
+ while (node != null) {
+ char c = s.charAt(x);
+ if (node.splitchar > c) {
+ parent = node;
+ link = 0;
+ node = node.loKid;
+ } else if (node.splitchar < c) {
+ parent = node;
+ link = 2;
+ node = node.hiKid;
+ } else if (x < s.length() - 1) {
+ parent = node;
+ link = 1;
+ node = node.eqKid;
+ x++;
} else {
- currentNode.token = s.toString();
- currentNode.val = val;
- return currentNode;
- }
- } else if (currentNode.splitchar > s.charAt(x)) {
- currentNode.loKid = insert(currentNode.loKid, s, val, x);
- } else if (currentNode.splitchar == s.charAt(x)) {
- if (x < s.length() - 1) {
- currentNode.eqKid = insert(currentNode.eqKid, s, val, x + 1);
- } else {
- currentNode.token = s.toString();
- currentNode.val = val;
+ node.token = s.toString();
+ node.val = val;
return currentNode;
}
+ }
+ // the rest of the key becomes a new chain of eqKid nodes
+ node = new TernaryTreeNode();
+ node.splitchar = s.charAt(x);
+ if (parent == null) {
+ currentNode = node;
+ } else if (link == 0) {
+ parent.loKid = node;
+ } else if (link == 1) {
+ parent.eqKid = node;
} else {
- currentNode.hiKid = insert(currentNode.hiKid, s, val, x);
+ parent.hiKid = node;
+ }
+ while (x < s.length() - 1) {
+ x++;
+ TernaryTreeNode next = new TernaryTreeNode();
+ next.splitchar = s.charAt(x);
+ node.eqKid = next;
+ node = next;
}
+ node.token = s.toString();
+ node.val = val;
return currentNode;
}
diff --git a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java
index 04a59768e58..6da272e3db9 100644
--- a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java
+++ b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java
@@ -17,6 +17,7 @@
package org.apache.lucene.search.suggest.tst;
import java.io.IOException;
+import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -142,7 +143,7 @@ public class TSTLookup extends Lookup {
}
/**
- * Adds a new node if <code>key</code> already exists, otherwise replaces its value.
+ * Adds a new node if <code>key</code> does not exist yet, otherwise replaces its value.
*
* <p>This method always returns true.
*/
@@ -214,60 +215,72 @@ public class TSTLookup extends Lookup {
private static final byte HAS_TOKEN = 0x08;
private static final byte HAS_VALUE = 0x10;
- // pre-order traversal
- private void readRecursively(DataInput in, TernaryTreeNode node) throws IOException {
- node.splitchar = in.readString().charAt(0);
- byte mask = in.readByte();
- if ((mask & HAS_TOKEN) != 0) {
- node.token = in.readString();
- }
- if ((mask & HAS_VALUE) != 0) {
- node.val = in.readLong();
- }
- if ((mask & LO_KID) != 0) {
- node.loKid = new TernaryTreeNode();
- readRecursively(in, node.loKid);
- }
- if ((mask & EQ_KID) != 0) {
- node.eqKid = new TernaryTreeNode();
- readRecursively(in, node.eqKid);
- }
- if ((mask & HI_KID) != 0) {
- node.hiKid = new TernaryTreeNode();
- readRecursively(in, node.hiKid);
+ // Pre-order traversal with an explicit stack: the tree can be deeper than the thread stack
+ // allows recursion (keys added in sorted order, or one long key).
+ private void readTree(DataInput in, TernaryTreeNode root) throws IOException {
+ ArrayDeque<TernaryTreeNode> stack = new ArrayDeque<>();
+ stack.push(root);
+ while (!stack.isEmpty()) {
+ TernaryTreeNode node = stack.pop();
+ node.splitchar = in.readString().charAt(0);
+ byte mask = in.readByte();
+ if ((mask & HAS_TOKEN) != 0) {
+ node.token = in.readString();
+ }
+ if ((mask & HAS_VALUE) != 0) {
+ node.val = in.readLong();
+ }
+ // the kids follow in the order lo, eq, hi: push them in reverse
+ if ((mask & HI_KID) != 0) {
+ node.hiKid = new TernaryTreeNode();
+ stack.push(node.hiKid);
+ }
+ if ((mask & EQ_KID) != 0) {
+ node.eqKid = new TernaryTreeNode();
+ stack.push(node.eqKid);
+ }
+ if ((mask & LO_KID) != 0) {
+ node.loKid = new TernaryTreeNode();
+ stack.push(node.loKid);
+ }
}
}
- // pre-order traversal
- private void writeRecursively(DataOutput out, TernaryTreeNode node) throws IOException {
- // write out the current node
- out.writeString(new String(new char[] {node.splitchar}, 0, 1));
- // prepare a mask of kids
- byte mask = 0;
- if (node.eqKid != null) mask |= EQ_KID;
- if (node.loKid != null) mask |= LO_KID;
- if (node.hiKid != null) mask |= HI_KID;
- if (node.token != null) mask |= HAS_TOKEN;
- if (node.val != null) mask |= HAS_VALUE;
- out.writeByte(mask);
- if (node.token != null) out.writeString(node.token);
- if (node.val != null) out.writeLong(((Number) node.val).longValue());
- // recurse and write kids
- if (node.loKid != null) {
- writeRecursively(out, node.loKid);
- }
- if (node.eqKid != null) {
- writeRecursively(out, node.eqKid);
- }
- if (node.hiKid != null) {
- writeRecursively(out, node.hiKid);
+ // Pre-order traversal with an explicit stack, in the order readTree expects.
+ private void writeTree(DataOutput out, TernaryTreeNode root) throws IOException {
+ ArrayDeque<TernaryTreeNode> stack = new ArrayDeque<>();
+ stack.push(root);
+ while (!stack.isEmpty()) {
+ TernaryTreeNode node = stack.pop();
+ // write out the current node
+ out.writeString(new String(new char[] {node.splitchar}, 0, 1));
+ // prepare a mask of kids
+ byte mask = 0;
+ if (node.eqKid != null) mask |= EQ_KID;
+ if (node.loKid != null) mask |= LO_KID;
+ if (node.hiKid != null) mask |= HI_KID;
+ if (node.token != null) mask |= HAS_TOKEN;
+ if (node.val != null) mask |= HAS_VALUE;
+ out.writeByte(mask);
+ if (node.token != null) out.writeString(node.token);
+ if (node.val != null) out.writeLong(((Number) node.val).longValue());
+ // write the kids in the order lo, eq, hi: push them in reverse
+ if (node.hiKid != null) {
+ stack.push(node.hiKid);
+ }
+ if (node.eqKid != null) {
+ stack.push(node.eqKid);
+ }
+ if (node.loKid != null) {
+ stack.push(node.loKid);
+ }
}
}
@Override
public synchronized boolean store(DataOutput output) throws IOException {
output.writeVLong(count);
- writeRecursively(output, root);
+ writeTree(output, root);
return true;
}
@@ -275,7 +288,7 @@ public class TSTLookup extends Lookup {
public synchronized boolean load(DataInput input) throws IOException {
count = input.readVLong();
root = new TernaryTreeNode();
- readRecursively(input, root);
+ readTree(input, root);
return true;
}
diff --git a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java
index 6bbba74641b..95b1b9076fc 100644
--- a/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java
+++ b/lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java
@@ -16,6 +16,7 @@
*/
package org.apache.lucene.search.suggest.tst;
+import java.util.ArrayDeque;
import org.apache.lucene.util.RamUsageEstimator;
/** The class creates a TST node. */
@@ -47,24 +48,32 @@ public class TernaryTreeNode {
Object val;
+ /** Returns the memory used by this node and the subtree below it. */
long sizeInBytes() {
- long mem = RamUsageEstimator.shallowSizeOf(this);
- if (loKid != null) {
- mem += loKid.sizeInBytes();
+ // explicit stack: the tree can be deeper than the thread stack allows recursion
+ long mem = 0;
+ ArrayDeque<TernaryTreeNode> stack = new ArrayDeque<>();
+ stack.push(this);
+ while (!stack.isEmpty()) {
+ TernaryTreeNode node = stack.pop();
+ mem += RamUsageEstimator.shallowSizeOf(node);
+ if (node.loKid != null) {
+ stack.push(node.loKid);
+ }
+ if (node.eqKid != null) {
+ stack.push(node.eqKid);
+ }
+ if (node.hiKid != null) {
+ stack.push(node.hiKid);
+ }
+ if (node.token != null) {
+ mem +=
+ RamUsageEstimator.shallowSizeOf(node.token)
+ + RamUsageEstimator.NUM_BYTES_ARRAY_HEADER
+ + Character.BYTES * (long) node.token.length();
+ }
+ mem += RamUsageEstimator.shallowSizeOf(node.val);
}
- if (eqKid != null) {
- mem += eqKid.sizeInBytes();
- }
- if (hiKid != null) {
- mem += hiKid.sizeInBytes();
- }
- if (token != null) {
- mem +=
- RamUsageEstimator.shallowSizeOf(token)
- + RamUsageEstimator.NUM_BYTES_ARRAY_HEADER
- + Character.BYTES * (long) token.length();
- }
- mem += RamUsageEstimator.shallowSizeOf(val);
return mem;
}
}
Trade-offs
- The explicit stacks live on the heap: about one entry per pending sibling on the current path, small next to the tree.
- Nothing is rebalanced: sorted
add()still builds a chain and visits about n²/2 nodes for n keys, as today; only stack use changes. No benchmark was run. - A
load()that fails part-way leaves slightly different garbage (kids are allocated before their bytes are read); either way the lookup is half replaced (LFV-979).
What the checks validated
- Stock: the repro fails with
java.lang.StackOverflowError in add()under Lucene's default local test JVM flags (this step, 2026-10-30) and with... in store()under the full JIT (TEST_JVM_ARGS=-XX:+UseParallelGC, an earlier run of this step). - With option A applied (this step, 2026-10-30): the repro and
TestPersistence, the only upstream test that runsTSTLookup, pass under the default flags with assertions on::lucene:suggest:test (SUCCESS): 3 tests, 0 failures. - With option A, an earlier run of this step on the same commit: all of
:lucene:suggest:testwith the repro added passes (32 classes, 237 tests, 7 skipped, 0 failures); the repro passes with the full JIT;:lucene:suggest:check -x test(format, ecjLint, forbiddenApis) passes with the test and the fix added (not run on stock code plus the test). - Repro harness, patched classes shadowed: the repro passes with
-eaand-da; the finder's 8-test JUnit passes 8 of 8 (6 of 8 fail stock), coveringbuild()andadd()of a 20,000-character key,load()of a stored deep tree andramBytesUsed()on 1 MiB threads. - Byte-level check (400 random dictionaries, 78,005 observations of
store()bytes, bytes stored afterload(),get,lookup,ramBytesUsed): the same SHA-256 on stock and patched code; a mutant whosereadTreepushes lo before hi fails. It covers only trees the stock code can store.
Cap the key length in insert (or in build() and add()), with a clear exception, as OfflineSorter does for entries over 32,767 bytes.
Trade-offs
Smaller, but it covers only the long-key leg: the sorted-add depth comes from the number of distinct first characters, so it would also need a depth limit that rejects add() calls that work today, at a threshold that depends on the stack size.
Say in the add() javadoc that it does not rebalance and that keys added in sorted order build a tree that store() and load() may not handle; recommend build().
Trade-offs
No code change, but build() still overflows on one long legal suggestion.
Keep the lo/hi links of each key position balanced as add() inserts (rotations, or a periodic rebuild), so sorted adds no longer chain.
Trade-offs
Covers only the sorted-add leg: a long key is one eqKid level per character, which no rebalancing shortens, so build() of a long suggestion still needs option A or B. More invasive than A, and it changes the tree shape that sorted adds give today (a reading of the code, not run).
Deprecate it, and later remove it, in favour of the FST-based suggesters, as Michael McCandless suggested for the sibling Jaspell trie on LUCENE-5775 (#6837); Jaspell was removed in 9.0.0 (LUCENE-8638).
Trade-offs
No code fix for current users until they migrate, and FSTCompletionLookup's javadoc still points to TSTLookup.
Applications that build a TSTLookup from indexed or user-contributed text should cap the suggestion length, and prefer build() to sorted add(), until this is fixed.
Semi-formal: a probe that added 5,000 two-character keys in sorted order failed at load(). An independent model of the tree was then compared with the real tree read back through store(), bounded-exhaustively for small inputs (774,462 checks, 0 mismatches), which gave the two depth laws; the bug was then reduced to one public-API test.
The method in full153 words
- First signal. A planning probe added 5,000 two-character keys in sorted order:
load()threwStackOverflowErroron a 1 MiB thread (2026-10-28). - Method: bounded-exhaustive differential model check. An independent iterative model of the ternary tree against the real tree read back through the public
store()output: everyadd()sequence of up to 4 keys from 12 strings and every non-emptybuild()set of up to 4 of 39 keys, plus sequential and random scopes (774,462 checks, 0 mismatches; a lo/hi-swap mutant of the model was rejected). That established law T1 (sortedadd(): depth at least 1 + distinct first characters) and law T2 (build(): depth at most key length + floor(log2 n) + 1). - Then the finder measured the stack threshold of every entry point (fresh JVMs, 512 KiB to 2 MiB, cold and warm JIT), wrote a public-API JUnit (8 tests), and the repro was later minimized to one test.
No prior report found (GitHub issues, PRs and commits, JIRA, the Lucene and Solr lists, downstream trackers and the web; 2026-10-28/29, rechecked 2026-10-30). The closest item is a 2009 design remark on SOLR-1316 that sorted insertion unbalances this tree, about speed, credited where the mechanism is explained.
Searches5 searches
Read and not this bug: #6837 / LUCENE-5775 (JaspellTernarySearchTrie.ramBytesUsed StackOverflowError from a parent-pointer cycle in another class; this tree has no cycle: a 15,002-level tree completes on a 256 MiB thread), #6721 / LUCENE-5659, #7218 / LUCENE-6156, #11510 / LUCENE-10474 and PR #12462 (automaton and RegExp recursion), SOLR-2375 (added store/load), Stack Overflow 33749956 (a FuzzySuggester overflow). No open PR touches the tst package. Gaps: GitHub search does not index diffs or every review comment; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (two runs) | 2026-10-28 and 2026-10-29 | 51 queries each over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web | 124 distinct hits each, 0 high overlap; no report |
| GitHub (manual) | 2026-10-29 | apache/lucene (TSTLookup, TSTAutocomplete, TernaryTreeNode, balancedTree, suggest/tst, ternary or suggest StackOverflowError, ramBytesUsed StackOverflowError), apache/solr, Lucene.NET, Elasticsearch, OpenSearch; changed files of all 414 open apache/lucene PRs | no report; no PR touches the tst package |
| JIRA (LUCENE, SOLR, LUCENENET) | 2026-10-29 | queries; SOLR-1316, SOLR-2375 and seven more issues read in full | only the SOLR-1316 design discussion (speed, no failure) |
| Mailing lists, web and Stack Exchange | 2026-10-29 | solr-user@, Solr dev@, Lucene general@ and java-user@; web searches; Stack Exchange | no report; a FuzzySuggester question and a heap-use thread, both different |
| GitHub issue search (recheck) | 2026-10-30 | TSTLookup in apache/lucene, newest first | newest hit #8108 (2016); nothing new |
As old as the suggester: the recursive insert came with SOLR-1316 (2010), store/load with SOLR-2375 (2011), the recursive size method with LUCENE-5463 (2014, from 4.8.0). First releases: Solr 3.1.0 and Lucene 3.3.0. Executed on Solr 3.1.0, 11 Lucene releases to 10.5.2, the 10.6.0 commit and main. Never fixed.
Versions tested10 rows
Introduced with the suggester itself: SOLR-1316 "Create autosuggest component" (ad8bfb3bc4d, Andrzej Bialecki, 2010-08-23; branch_3x port d2353e16e2e), a Solr JIRA patch with no pull request; 27 of the 30 lines of TSTAutocomplete.java:57-86 still come from it. The store()/load() recursion came with SOLR-2375 (e71e6823520, 2011-03-01; branch_3x ab2691c7740). LUCENE-5463 (fab92b83d0d, apache/lucene#6526, Adrien Grand, 2014-02-24) made sizeInBytes recursive; 4.7.2's did not recurse.
First releases. Solr 3.1.0 (solr-core, 2011-03-30) and Lucene 3.3.0 (lucene-spellchecker, via LUCENE-3135); Solr 1.4.1 and Lucene 3.2.0 have no TST classes. The size leg first in 4.8.0 (named sizeInBytes() in 4.8.x, ramBytesUsed() from 4.9.0).
Read. All 123 release tags in the history clone from 4.0.0-alpha to 10.5.2 and the 8 tags from 3.1 to 3.6.2 have the 4 recursive insert calls and the 6 recursive store/load calls; every tag from 4.8.0 has the 3 recursive size calls. The clone has no tags for 8.8.2 to 8.11.4; 8.11.4 was executed instead. So "affected" for every release rests on the source of the tags plus the selected runs below. Upstream has not changed the tst package since 8e2c6aa1d58 (2025-06-27). 10.6.0 was released from 4ae946e5a9b9; the hunt ran 10.6.0-SNAPSHOT jars built at that commit.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails through gradle (in add() with the default test flags, in store() with the full JIT) and in a plain JVM (6 of 6) |
10.6.0 (4ae946e5a9b9) | affected | all three legs: sorted add fails in store(), a 20,000-character key in build(), and ramBytesUsed() |
| 10.5.2, 9.12.3, 8.11.4, 7.7.3, 6.6.6 | affected | all three legs |
| 5.5.5, 4.10.4 | affected | sorted-add leg: store() fails |
| 4.8.0 | size leg | the first release with the recursive sizeInBytes: StackOverflowError |
| 4.7.2 | no size leg | sizeInBytes did not recurse; insert and store/load recursion present |
| 4.0.0, 3.6.2 | affected | sorted-add leg fails in store(), the long key in add() |
| Lucene 3.3.0, Solr 3.1.0 | first | the first releases with the code: both legs fail |
| Lucene 3.2.0, Solr 1.4.1 | not affected | no TST classes (compile error) |
the fix on main 645d4fe14a20 | not affected | the repro passes, and every test of :lucene:suggest:test |
An independent reproducer, reviews by a reviewer from another model family, a devil's advocate, an adjudication, a re-run with fresh seeds and a credibility audit re-ran or re-read it; every executed check reproduced it. This doc re-ran the repro through gradle on 645d4fe14a20 and validated the fix.
Checks and verdicts456 words
The cross-model reviews ran reading only (they could not run code). The hunt's checks ran on main a9779ef3c193, whose suggest module equals 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model review, refute lens | not real (procedural) | OpenAI GPT-5.6, reading only: voted not real on procedure only, because it could not run code; it agreed with the root cause, found the inputs in contract, and noted that Accountable is @lucene.internal. |
| Cross-model review, intended lens | real (medium) | Reading only: no documented order or length limit; it listed Lucene's precedents for removing such recursion. |
| Independent reproducer | real | Own 4-test JUnit, one JVM per method, default stack: the sorted CJK block fails at store(), load() and ramBytesUsed(), the 20,000-character suggestion at build(); controls pass; also on 10.5.2 and 9.12.3. A step that throws is redone on a 256 MiB thread so the next step runs, so its load() failure is of bytes stored that way. It corrected the first threshold claims. |
| Cross-model review, root-cause lens | real (medium) | Reading only, plus an executed check run by the hunt: insertion order alone flips pass and fail, the tree has no cycle, balancedTree is not the trigger, the fix gives byte-identical store() output. |
| Intended-behaviour devil's advocate | real (medium) | Built the strongest works-as-designed case and rejected it; reached the long-key leg through LuceneDictionary over an indexed term (24 of 24 failures on a 1 MiB thread). |
| Adjudication | confirmed (high) | Re-ran the independent JUnit with a new seed, assertions on and off: both bug tests fail, both controls pass. |
| Registration check and credibility audit | reproduces; keep | Fresh seeds reproduce it; a plain main() program without the test framework shows add() and lookup() working and store() and ramBytesUsed() overflowing. |
| Repro minimization | one test, fails | One test on 645d4fe14a20: 12 of 12 fresh JVMs fail at store(); passes with the fix. |
| Fix validation (this doc) | fix validated | Gradle in scratch worktrees of 645d4fe14a20: the repro fails stock and passes with option A, together with TestPersistence; an earlier run of this step passed every test of :lucene:suggest:test with the fix. |
| Fact checks and diagram check of this doc | bug holds; wording corrected | An independent fact check re-ran the gradle repro on 645d4fe14a20 (stock: in add() under the default flags, in store() under the full JIT) and the fix (the repro and TestPersistence pass under both), the harness repro with assertions on and off, the 32,759-byte build() limit and a release-tag scan. A reading-only cross-model fact check (OpenAI GPT-5.6) found the core report sound. A diagram check found the visuals correct. The errors, overclaims and missing caveats they found are fixed in this doc. |
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 590 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,586 words
- Registry title
- TSTLookup recurses once per ternary-tree level in add/build, store, load and ramBytesUsed, so a deep tree (short keys added in sorted order, or one suggestion thousands of characters long) throws StackOverflowError, often only when the lookup is stored or loaded (every release since 3.3.0)
- Area
- suggest / ternary search tree suggester (TSTLookup add, build, store, load and ramBytesUsed; TSTAutocomplete.insert; TernaryTreeNode.sizeInBytes)
- Priority
- Low, rank 619: The opt-in TSTLookup throws StackOverflowError only for degenerate deep trees such as a large sorted CJK list or a suggestion thousands of characters long, a loud rare-input crash.
- Impact class
- crash
- Impact note
- Hits applications that use the opt-in TSTLookup suggester and end up with a deep tree: either they add() a word list in sorted order whose entries start with thousands of distinct characters (for example a CJK dictionary; add, get and lookup still work, but store(), load() or ramBytesUsed() throw StackOverflowError, so the lookup cannot be persisted or reloaded), or one suggestion is thousands of characters long, which already fails in build() or add(); short keys through build() are safe, the threshold depends on the thread stack size and the JIT state (in the measured fresh JVMs about 5,300 tree levels or 6,000-10,000 characters on a 1 MiB thread, about 12,000-16,000 levels or 20,000 characters on a 2 MiB stack), the error is loud, and every release since 3.3.0 has it.
- Summary
- You add the 20,992 CJK Unified Ideographs to a TSTLookup in code-point order, or build one from a single suggestion of 20,000 characters, and expect to store it, load it back and ask its size, as Lookup.store and Lookup.load promise (they document failure only as a false return or an IOException) and as WFSTCompletionLookup does with the same 20,000-character suggestion. Instead add(), get() and lookup() work, but store(), load() and ramBytesUsed() throw StackOverflowError on the JVM's default thread stack, and for the long suggestion build() itself overflows, because TSTAutocomplete.insert, TSTLookup.writeRecursively and readRecursively and TernaryTreeNode.sizeInBytes each use one Java stack frame per tree level. Adding keys in sorted order turns each level's lo/hi links into a chain (that sorted insertion unbalances this tree was noted by its author, Ankul Garg, on SOLR-1316 in 2009, as a speed concern), so the tree gets about as deep as the number of distinct first characters, and every character of a key adds one more level even in build().
- Introduced
- ad8bfb3bc4de0d9eed8e4d44116ea216ca711766 (SOLR-1316 'Create autosuggest component', svn r988120, committed by Andrzej Bialecki; branch_3x port d2353e16e2e2; a Solr JIRA patch, no GitHub PR): the recursive TSTAutocomplete.insert behind add() and build() [read: git blame of TSTAutocomplete.java:57-86 on main, 27 of 30 lines]; the store/load leg came with e71e68235205 (SOLR-2375 'Store & Load functionality for Suggester Lookup implementations', branch_3x ab2691c77406), whose writeRecursively/readRecursively recursion is unchanged on main (TSTLookup.java:218-265) [read]; the ramBytesUsed leg with fab92b83d0df (LUCENE-5463, apache/lucene#6526, Adrien Grand; branch_4x 7e95e51347d4), which made TernaryTreeNode.sizeInBytes recursive [read; exec: 4.7.2 not recursive, 4.8.0 overflows]. Not a regression: the recursion has been there since the first release that shipped the suggester. (2010-08-23 (insert: add/build leg); 2011-03-01 (store/load leg); 2014-02-24 (ramBytesUsed leg))
- First affected version
- 3.3.0 [exec] (first Lucene release with TSTLookup, lucene-spellchecker via LUCENE-3135; the same code shipped first in Solr 3.1.0's solr-core [exec]); Lucene 3.2.0 and Solr 1.4.1 have no TST classes [exec: compile error]; the ramBytesUsed leg first in 4.8.0 [exec] (4.7.2's sizeInBytes was not recursive [exec])
- Affected versions
- every Lucene release from 3.3.0 through 10.5.2, 10.6.0 (released 2026-10-29; branch_10_6 head 4ae946e5a9b9) and main a9779ef3c193, plus Solr 3.1.0 and 3.2.0 (solr-core); not fixed upstream. [exec] main with -ea and -da, the 10.6 RC, 10.5.2, 9.12.3, 8.11.4, 7.7.3 and 6.6.6 (all three legs: sorted add fails in store, a long key in build, ramBytesUsed); 5.5.5 and 4.10.4 (sorted-add leg); 4.0.0, 3.6.2, 3.3.0 and Solr 3.1.0 (sorted-add and long-key legs); 4.8.0 (ramBytesUsed leg). [read] all 123 release tags 4.0.0-alpha to 10.5.2 in the history clone and all 8 tags 3.1 to 3.6.2 have the 4 recursive insert calls and the 6 recursive store/load calls, and every tag from 4.8.0 the 3 recursive sizeInBytes calls; upstream has not changed the tst package since 8e2c6aa1d58 (author date 2025-06-27). All runs on JDK 25.0.3 (aarch64), mostly on 1 MiB threads; the step that fails and the depth at which it fails depend on the stack size and the JIT state.
- 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 “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; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-29, read-only; the prior-report record verdict new, plus the finder's search at 2026-10-28, the independent reproduction's and the intended-behaviour check's searches, the adjudication's refresh and a recheck): a scripted prior-report search in two runs (51 queries each over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 124 distinct hits each, 0 high overlap), manual GitHub searches on apache/lucene (TSTLookup, TSTAutocomplete, TernaryTreeNode, balancedTree, suggest/tst, ternary or suggest StackOverflowError, ramBytesUsed StackOverflowError), apache/solr, Lucene.NET, Elasticsearch and OpenSearch, JIRA queries on LUCENE, SOLR and LUCENENET with the full comments of SOLR-1316, SOLR-2375 and seven more issues read, the Solr and Lucene lists, web and Stack Exchange searches, and the changed files of all 414 open apache/lucene PRs and of the 46 closed PRs updated since 2026-10-15 (none touches lucene/suggest/.../tst/). Recheck: none of the 12 apache/lucene PRs created since 2026-10-27 changes a file under lucene/suggest (changed-file lists read), none of the 60 issues and PRs updated since then names TSTLookup, TSTAutocomplete or TernaryTreeNode, tracking issue #16937 does not list it, and the tst package's last upstream commit is 8e2c6aa1d58 (author date 2025-06-27; upstream main head 645d4fe14a20, 2026-10-28). Related, not reports of this bug: SOLR-1316 (2009: the TST's author, Ankul Garg, wrote that sorted insertion makes the tree highly unbalanced and that adding keys one by one does not keep it balanced, and Andrzej Bialecki asked whether it could be rebalanced on the fly; a speed and memory discussion, no failure, before store/load and ramBytesUsed existed), #6837 / LUCENE-5775 (JaspellTernarySearchTrie.ramBytesUsed StackOverflowError from a parent-pointer cycle in another class, removed in 9.0), #6721 / LUCENE-5659 and #7218 / LUCENE-6156 (recursion on long input in automaton and RegExp code, resolved Won't Fix and Not A Problem at the time, later rewritten), #11510 / LUCENE-10474 and PR #12462 (RegExp recursion), SOLR-2375 (added store/load), and Stack Overflow question 33749956 (a FuzzySuggester stack overflow, not TST).
- Lucene commit tested
- main
645d4fe14a20(2026-10-28) through gradle in scratch worktrees (removed afterwards), seed DEADBEEF: stock, the one-test repro fails under Lucene's default local test JVM flags (inadd()) and with the full tiered JIT (instore()); with the recommended fix it passes, together with every test of:lucene:suggest:test. Through the repro harness on645d4fe14a20: the one test (6 of 6 fresh JVMs fail, assertions on and off; 12 of 12 for the minimized original), the finder's 8-test JUnit (6 fail; 8 pass with the fix) and a byte-level digest of 400 random dictionaries (identical on stock and fixed). Earlier, on maina9779ef3c193, whose suggest module equals645d4fe14a20: an independent 4-test JUnit (also on 10.5.2 and 9.12.3) and a model check of the tree shape;main()drivers on Solr 3.1.0, 11 Lucene releases from 3.3.0 to 10.5.2 and the 10.6.0 release commit4ae946e5a9b9. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:57-86 (insert recurses at :66, :73, :76, :83: through loKid/hiKid without advancing the character index and through eqKid per character), :31-45 (balancedTree javadoc: balanced insertion 'reduces insertion and search times significantly'; logarithmic recursion)lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:117-142 (build sorts the input and calls balancedTree), :144-153 (add: insert on the root with no balancing, 'This method always returns true'; :145 javadoc inverted), :218-239 (readRecursively, recursive calls at :229, :233, :237), :242-265 (writeRecursively, :257, :260, :263), :267-280 (store and load call them), :284-290 (ramBytesUsed calls root.sizeInBytes)lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java:50-60 (sizeInBytes recurses at :53, :56, :59)lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (@lucene.experimental), :236-252 (store: 'Persist the constructed lookup data', true if successful, false if unsuccessful or not supported, @throws IOException; load: 'Discard current lookup data and load it from a previously saved copy')
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:40-46 (balancedTree), :57-86 (insert; recursive calls at :66, :73, :76, :83), :97-148 (prefixCompletion, explicit ArrayDeque) @ 645d4fe14a20lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:43 and :125 (sentinel root), :117-142 (build), :144-153 (add and its javadoc), :218-239 (readRecursively), :242-265 (writeRecursively), :267-280 (store, load), :282-290 (ramBytesUsed) @ 645d4fe14a20lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java:27-40 (fields), :50-69 (sizeInBytes; calls at :53, :56, :59) @ 645d4fe14a20
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:67-68 (points to TSTLookup) @ 645d4fe14a20lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:242-272 (store writes the count, then each node before recursing) @ 645d4fe14a20
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:144-153 @ 645d4fe14a20lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (@lucene.experimental), :236-252 (store and load javadoc) @ 645d4fe14a20lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:550-551 @ 645d4fe14a20lucene/CHANGES.txt:12334-12355 (Lucene 4.8.1, Bug fixes, LUCENE-5628) @ 645d4fe14a20lucene/suggest/src/test/org/apache/lucene/search/suggest/TestPersistence.java, TestLookupBenchmark.java:51 (@Ignore) @ 645d4fe14a20
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:550-551 @ 645d4fe14a20lucene/CHANGES.txt:4630 (Lucene 9.0.0), :4660-4661 (LUCENE-8638 removes the Jaspell suggester); lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:67-68 @ 645d4fe14a20
- TestTSTLookupSortedAddStore.java (JUnit test):
repro/LFV-985/src/org/apache/lucene/search/suggest/tst/TestTSTLookupSortedAddStore.java(53 lines)
Made in parallel with this doc; its notes are in the diagram README.
assets/LFV-985/bug.png(590 KB)
