All findings · Lucene finding · LFV-985 · tstlookup-recursion-per-tree-level-stackoverflow

TSTLookup throws StackOverflowError on deep trees: a list added in sorted order cannot be stored or loaded, and one long suggestion breaks build()

A TSTLookup filled by add() in sorted order (the 20,992 CJK ideographs) answers lookup(), but store(), load() and ramBytesUsed() throw StackOverflowError, and one 20,000-character suggestion breaks build() itself in a fresh JVM. Each of these methods recurses once per tree level; every release since 3.3.0 has it.

Summary

Priority
Low, rank 619The 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
crash
Since
3.3.0 (2011; the code came with SOLR-1316, 2010)introduced 2010-08-23 · over 16 years
Affected
3.3.0 to 10.6.0, and main (by source; selected releases executed)
Status
documented
Found by
model check of the tree's depth against an independent model (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Two things to know first

  1. A ternary search tree stores one character per node, with three links: lo to a smaller character at the same position of the key, eq to the key's next character, hi to a larger character at the same position.
  2. 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 hi child of the last one: a chain, not a tree.
Summary card: a TSTLookup filled with add() in sorted order, the CJK Unified Ideographs U+4E00 to U+9FFF as one-character suggestions. Each new character becomes the hiKid of the previous node, a chain 20,993 levels deep. store() recurses once per level and throws StackOverflowError on a 2 MiB thread stack, while lookup() still answers. The same keys through build() give a shallow tree; one 20,000-character suggestion is deep even through build().
The 20,992 one-character suggestions, added in sorted order, hang off each other's 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.

The finding, step by step

  1. You call add() once for each of the 20,992 CJK Unified Ideographs, U+4E00 to U+9FFF, in code-point order, then store() the lookup to save it.
  2. You expect store() to return true, as its javadoc promises (failure is a false return or an IOException), and as it does for the same keys added through build() or in shuffled order.
  3. Instead store() throws StackOverflowError on a 2 MiB thread stack, and so do load() and ramBytesUsed(). get() and lookup() still work: the lookup works in memory but cannot be saved or reloaded.
  4. Why: each larger character hangs off the previous one's hi link, so the tree is one chain 20,993 nodes deep, and store(), load(), ramBytesUsed() and add() itself use one Java stack frame per node.
  5. build() balances only the lo/hi links, and every character of a key adds a level, so one 20,000-character suggestion overflows build() in a fresh JVM, while WFSTCompletionLookup accepts it.

Who is affected

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 mechanism (code at 645d4fe14a20)

  1. The tree. A TernaryTreeNode holds one character (splitchar) and the links loKid, eqKid, hiKid (TernaryTreeNode.java:27-40). TSTLookup starts from a sentinel root whose splitchar is U+0000 (TSTLookup.java:43, :125, :277).
  2. Insertion recurses once per level. TSTAutocomplete.insert (TSTAutocomplete.java:57-86) calls itself through loKid (:73) and hiKid (:83) without advancing the key position, and through eqKid once per character (:66, :76). add() calls it on the root with no balancing (TSTLookup.java:149-153).
  3. Sorted add() builds a chain. Increasing characters each become the hiKid of the previous one: 20,992 one-character keys give a hiKid chain 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 through store() 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.
  4. build() balances only the lo/hi links. It sorts the input (TSTLookup.java:117-142) and inserts it middle-first through balancedTree (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): through build() 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 one eqKid level.
  5. The other walks recurse once per level too. writeRecursively (TSTLookup.java:242-265, calls at :257, :260, :263) for store() (:267-272); readRecursively (:218-239, calls at :229, :233, :237) for load() (:274-280); TernaryTreeNode.sizeInBytes (TernaryTreeNode.java:50-69, calls at :53, :56, :59) for ramBytesUsed() (TSTLookup.java:282-290). get() and lookup() use prefixCompletion (TSTAutocomplete.java:97-148), which already walks with an explicit ArrayDeque: they work even on a 41,002-level tree with a 256 KiB thread.
  6. 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 what insert does (since d4aace10381, 2012).

Where the stack runs out

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 stackJITsorted addstoreloadramBytesUsedbuild (one long key)
1 MiB (Linux x64 default)cold19,6355,3515,3286,0545,888
1 MiBwarm19,63716,82910,74210,7099,816
2040 KiB (Linux aarch64 default)cold> 41,00011,26210,82212,54912,390
2040 KiBwarm> 41,00035,40729,56522,53120,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() or add(). An independent check reached it through Lucene's own LuceneDictionary over an indexed StringField term of 8,000 bytes: TSTLookup.build threw StackOverflowError on 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 and build() 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 StackOverflowError in the calling thread, with no index damage; but a store() 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 sorted add() (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() and ramBytesUsed(), and for a long suggestion build()/add() too (the sorted add() itself also overflows under C1-only JIT flags).

Minimal repro

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.

TestTSTLookupSortedAddStore.java (JUnit test)53 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search.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());
    }
  }
}
Command
# 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 recursive TSTLookup.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.

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

More files, environment, full output50 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)
       [...]

Why it looks unintended

  • add() "always returns true" with no order or length limit; store() and load() name only false or an IOException as 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 through OfflineSorter, which rejects entries over 32,767 bytes with a clear IllegalArgumentException (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. WFSTCompletionLookup builds, stores and loads it on a 1 MiB thread.
  • Order alone flips the result. The same 20,992 keys through build(), or add()ed in the shuffled order tested, complete every step; the limit is tree depth, not key count.
  • Tests. Only TestPersistence and the @Ignored TestLookupBenchmark use TSTLookup, through build() 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.

Suggested fix

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.

A. Walk the tree iteratively (validated) (recommended)

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 of eqKid nodes. Same signature, return value and tree.
  • writeRecursively / readRecursively become writeTree / readTree: pre-order with an explicit ArrayDeque, 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 runs TSTLookup, 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:test with 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 -ea and -da; the finder's 8-test JUnit passes 8 of 8 (6 of 8 fail stock), covering build() and add() of a 20,000-character key, load() of a stored deep tree and ramBytesUsed() on 1 MiB threads.
  • Byte-level check (400 random dictionaries, 78,005 observations of store() bytes, bytes stored after load(), get, lookup, ramBytesUsed): the same SHA-256 on stock and patched code; a mutant whose readTree pushes lo before hi fails. It covers only trees the stock code can store.

B. Reject over-long suggestions with an IllegalArgumentException (not run)

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.

C. Document the limits (not run)

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.

D. Rebalance on add() (not run)

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

E. Deprecate TSTLookup (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.

Downstream note

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.

How it was found

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() threw StackOverflowError on 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: every add() sequence of up to 4 keys from 12 strings and every non-empty build() 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 (sorted add(): 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (two runs)2026-10-28 and 2026-10-2951 queries each over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web124 distinct hits each, 0 high overlap; no report
GitHub (manual)2026-10-29apache/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 PRsno report; no PR touches the tst package
JIRA (LUCENE, SOLR, LUCENENET)2026-10-29queries; SOLR-1316, SOLR-2375 and seven more issues read in fullonly the SOLR-1316 design discussion (speed, no failure)
Mailing lists, web and Stack Exchange2026-10-29solr-user@, Solr dev@, Lucene general@ and java-user@; web searches; Stack Exchangeno report; a FuzzySuggester question and a heap-use thread, both different
GitHub issue search (recheck)2026-10-30TSTLookup in apache/lucene, newest firstnewest hit #8108 (2016); nothing new

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe 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) affectedall 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 affectedall three legs
5.5.5, 4.10.4 affectedsorted-add leg: store() fails
4.8.0 size legthe first release with the recursive sizeInBytes: StackOverflowError
4.7.2 no size legsizeInBytes did not recurse; insert and store/load recursion present
4.0.0, 3.6.2 affectedsorted-add leg fails in store(), the long key in add()
Lucene 3.3.0, Solr 3.1.0 firstthe first releases with the code: both legs fail
Lucene 3.2.0, Solr 1.4.1 not affectedno TST classes (compile error)
the fix on main 645d4fe14a20 not affectedthe repro passes, and every test of :lucene:suggest:test

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat 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 realOwn 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; keepFresh 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, failsOne test on 645d4fe14a20: 12 of 12 fresh JVMs fail at store(); passes with the fix.
Fix validation (this doc) fix validatedGradle 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 correctedAn 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

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

Finding record

LFV-985, snapshot of 2026-11-06

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 (in add()) and with the full tiered JIT (in store()); with the recommended fix it passes, together with every test of :lucene:suggest:test. Through the repro harness on 645d4fe14a20: 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 main a9779ef3c193, whose suggest module equals 645d4fe14a20: 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 commit 4ae946e5a9b9. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

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) @ 645d4fe14a20
  • lucene/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) @ 645d4fe14a20
  • lucene/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) @ 645d4fe14a20
  • lucene/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 @ 645d4fe14a20
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (@lucene.experimental), :236-252 (store and load javadoc) @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:550-551 @ 645d4fe14a20
  • lucene/CHANGES.txt:12334-12355 (Lucene 4.8.1, Bug fixes, LUCENE-5628) @ 645d4fe14a20
  • lucene/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 @ 645d4fe14a20
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc; its notes are in the diagram README.

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