All findings · Lucene finding · LFV-786 · tstlookup-build-discards-tree-before-reading-input

TSTLookup.build throws away its suggestions before it reads the new input

You rebuild a TSTLookup in place: a lookup() made while the rebuild reads its new input returns nothing (upstream's semaphore-ordered test), and if the rebuild throws, the lookup stays empty while getCount() still reports the old build. FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep serving their previous suggestions in both cases.

Summary

Priority
Low, rank 620TSTLookup serves no suggestions with a stale count only after a build that threw, and the next good build repairs it, so this transient issue follows its TSTLookup sibling LFV-251.
Impact
minor
Since
3.3 (empty during a rebuild); 5.0.0 (stale count after a failed rebuild, LUCENE-5404)introduced 2014-02-07 · over 12 years
Affected
3.3 to 10.6.0 (the latest release), and main
Status
documented
Found by
bounded-exhaustive fault-injection check of suggester rebuilds

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

build() publishes an empty tree first and fills it only after the whole input has been read and sorted; the entry count is reset only after the sort. So the old suggestions are gone for the whole rebuild, and stay gone if reading or sorting throws, while getCount() keeps the old number.

Background: suggesters and rebuilds

  1. A suggester (a Lookup) is built from an InputIterator of entries, each a text and a weight, for example from a dictionary file. lookup("ba") then returns the entries that start with ba.
  2. TSTLookup keeps the entries in a ternary search tree; getCount() is documented as "the number of entries the lookup was built with".
  3. When the dictionary changes, an application can call build() again on the same instance. Upstream's rebuild test checks that five other suggesters keep returning their previous suggestions until the new build is complete.
Summary card titled 'Lucene: TSTLookup.build() throws its tree away before it reads the new input', in three panels. Panel 1, 'Rebuild a TSTLookup that serves lookups': input1 is bar/10 (build 1), input2 is bar/10 and baz/5 (the rebuild), or its 2nd next() throws IOException; code: s.build(input1) gives lookup('ba') = [bar/10], then s.build(input2) in a background thread while s.lookup('ba', false, 2) and s.getCount() are called. Panel 2, 'build() empties the tree first': TSTLookup.java lines 125 (root = new TernaryTreeNode(), runs first), 129 (new SortedInputIterator, reads and sorts all input), 130 (count = 0, only after the sort) and 141 (balancedTree fills the new root last); count still 1 from build 1 while root points to an empty node and build 1's tree is unreachable: no suggestions, yet getCount() = 1. Panel 3, lookup('ba', false, 2) and getCount() executed on main with assertions on and off, expected versus actual: while the rebuild reads its input, after the rebuild threw IOException, and after store() + load(), each expected [bar/10], count 1 and actually [], count 1. FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep bar in all three; the fix (build into locals, set root and count last) makes the probe and the minimal repro pass; upstream's rebuild test (SuggestRebuildTestUtil, LUCENE-10292), applied to TSTLookup, fails: expected:<1> but was:<0> suggestions for 'ba'. Footer: lookups find nothing during a rebuild since 3.3 (SOLR-1316, 2010); a failed rebuild leaves the old count since 5.0.0 (LUCENE-5404, 2014; executed on 22 releases 5.0.0 to 10.5.2, on 10.6.0 and on main); Lookup documents neither lookups during build() nor the state after a failed build().
TSTLookup built from bar/10, then rebuilt from bar/10 + baz/5: the tree is emptied before the input is read, so lookup("ba") returns nothing during the rebuild, and after a rebuild that throws it stays empty while getCount() still says 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build a TSTLookup from one entry, bar (weight 10): lookup("ba") returns [bar] and getCount() is 1.
  2. You rebuild the same instance from bar/10 + baz/5 while lookups continue, as upstream's rebuild test does for five other suggesters. You expect [bar] until the rebuild returns, then [bar, baz].
  3. You get [] (expected:<1> but was:<0>) as soon as the rebuild starts reading its input; [bar, baz] once it returns.
  4. If the rebuild throws instead (an IOException on its 2nd entry), lookup("ba") stays [] while getCount() still says 1, until a later good build; store() + load() copy that state. FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep [bar].
  5. Why: build() first swaps in an empty tree (TSTLookup.java:125), then reads and sorts all input (:128-129), and only then resets the count (:130) and fills the tree (:141).

Who is affected

Applications that use the opt-in TSTLookup suggester and rebuild a live instance in place: lookups served while build() runs, or the instance kept after a build() that threw (an I/O error, a temp-directory failure, an entry too long for the sort). Any dictionary size, assertions on or off. Transient: the next good build repairs it.

Mechanism, terms and numbers736 words

TSTLookup.build() replaces its ternary search tree with a new, empty one before it reads the input. The SortedInputIterator it creates next reads every entry and sorts them, and only after that does build() reset the entry count and fill the tree that lookups already see. So a lookup made while a rebuild reads its input finds nothing (upstream's own rebuild harness, which the tests of five other suggesters run and which orders the two threads with semaphores, fails on TSTLookup), and a rebuild that throws while the SortedInputIterator constructor reads or sorts the input (an IOException from the input, a temp-directory failure, an entry of 32,760 bytes or more, whose sort record with its 8-byte weight exceeds 32767 bytes) leaves a lookup with no suggestions whose getCount() still reports the previous build; store() and load() carry that pair into a new instance. After a rebuild whose input throws an IOException, FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep their previous suggestions: they already built their new FST aside and assigned it at the end of build() before 9.2.0, and LUCENE-10292 (9.2.0, Bug Fixes) made their count do the same; it did not touch TSTLookup. A later successful build repairs the state. Lookup documents neither failure atomicity nor concurrent build()/lookup(), so this is a low-priority consistency bug; building into local variables and assigning them at the end fixes both halves.

The bug

  1. build() first does root = new TernaryTreeNode(); (TSTLookup.java:125). root is the field every lookup reads (:43, not volatile; lookup() calls autocomplete.prefixCompletion(root, key, 0), :188), so from here on lookups see an empty tree.
  2. Then new SortedInputIterator(tempDir, tempFileNamePrefix, iterator, ...) (:128-129). Its constructor ends with this.reader = sort(); (SortedInputIterator.java:80), which writes every entry of the input to a temp file and sorts it (:175-195). Anything thrown here leaves build(): an IOException from the input (BytesRefIterator.next() is declared throws IOException), a temp-directory failure, or IllegalArgumentException("len must be <= 32767 ...") when the record, the entry plus its 8-byte weight (SortedInputIterator.java:216, :239-240), is longer than 32767 bytes, so for an entry of 32,760 bytes or more (OfflineSorter.java:550-551).
  3. Only then count = 0; (:130) and count++ per sorted entry (:139). The failed-rebuild state below comes from a throw in the constructor of step 2, before this reset (every executed fault is of that kind); a throw later, while the loop (:135-140) reads the sorted entries back, would leave the empty tree with a partial count instead (by reading the code; not run).
  4. Last, autocomplete.balancedTree(..., root) (:141) fills the root that lookups already see.
  5. store() writes count and then the tree (:268-272); load() reads both back (:274-280). Both are synchronized; build() and lookup() are not, so this orders nothing between a rebuild and lookups.

The numbers

  • During a rebuild (the repro): built from bar/10, rebuilt from bar/10 + baz/5 by upstream's SuggestRebuildTestUtil, lookup("ba") returns 0 suggestions instead of 1 while the rebuild sits in its first next(); assertions on and off, also on the 10.5.2 and 10.6.0 jars. The repro stops at that check; a separate semaphore-controlled probe with the same data shows 2 suggestions ([bar/10, baz/5], getCount() 2) after the rebuild returns.
  • After a failed rebuild (a probe): same data, the input throws IOException on its 2nd next(). TSTLookup: lookup("ba") = [], getCount() = 1; the same after store() + load() into a new instance; [bar, baz], 2 after a later good build. FSTCompletionLookup, WFSTCompletionLookup, AnalyzingSuggester: [bar], 1 (the same after a temp-file failure; an entry of 32,760 bytes does not fail the FST and WFST builds at all). With the fix, TSTLookup keeps [bar], 1.

How the siblings do it

WFSTCompletionLookup, FSTCompletionLookup and AnalyzingSuggester build their new FST aside and assign it at the end of build(); they already did so before 9.2.0. LUCENE-10292 changed only their count to work the same way: a local newCount, assigned after the FST, in a volatile field (WFSTCompletionLookup: long newCount = 0; :107; fst = ...; count = newCount; :129-130; private volatile long count :73; FSTCompletionLookup :103, :213, :245; AnalyzingSuggester :167, :410, :589-590).

Exposure and likelihood226 words
  • Who is exposed. Applications that use TSTLookup (the application picks it; nothing in Lucene selects it by default) and rebuild the same instance in place: lookups served while build() runs, or the instance kept after a build() that threw.
  • What happens. During the rebuild every lookup returns nothing until the input has been read, sorted and copied, a window that covers the whole read of the dictionary (by reading the code; not timed). After a failed rebuild the lookup answers from an empty tree while getCount() still reports the last successful build's number of entries; store() persists that pair, so loading the stored file later keeps it. No exception beyond the one the failing input already threw.
  • Transient. The next successful build(), or a load() of a good file, repairs it.
  • How likely. Rare: it needs an in-place rebuild of a live instance, or a rebuild that fails and an application that keeps serving the instance. Nobody has reported either half since 2010 and 2014.
  • Scale. Independent of dictionary size (the probe uses 1 and 2 entries). The failed-rebuild half is single-threaded and deterministic; the during-rebuild repro is deterministic because the harness orders the two threads with semaphores. For callers that do not synchronize, root and count are plain fields, so the Java memory model does not say when another thread sees the empty tree.

Minimal repro

One JUnit test that runs upstream's own rebuild harness, SuggestRebuildTestUtil.testLookupsDuringReBuild, on TSTLookup: build from bar/10, rebuild from bar/10 + baz/5 in the harness's background thread, and check lookup("ba") while the rebuild reads its input. Deterministic (semaphores, no timing), with a ByteBuffersDirectory.

TestTSTLookupDuringRebuild.java (one test)39 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.List;
import org.apache.lucene.search.suggest.Input;
import org.apache.lucene.search.suggest.SuggestRebuildTestUtil;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTSTLookupDuringRebuild extends LuceneTestCase {

  public void testLookupsDuringReBuild() throws Exception {
    try (Directory tempDir = new ByteBuffersDirectory()) {
      // expected: "ba" still finds bar while the rebuild reads its input; actual: no suggestions
      SuggestRebuildTestUtil.testLookupsDuringReBuild(
          new TSTLookup(tempDir, "tst"),
          List.of(new Input("bar", 10)),
          s -> assertEquals(1, s.lookup("ba", false, 2).size()),
          List.of(new Input("baz", 5)),
          s -> assertEquals(2, s.lookup("ba", false, 2).size()));
    }
  }
}
Command
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupDuringRebuild" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
lookup("ba") keeps returning 1 suggestion (bar) while the rebuild reads its input, then 2.
Observed
0 suggestions during the rebuild: expected:<1> but was:<0>, with assertions on and off.

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

More files, environment, full output240 words

Copy the file to lucene/suggest/src/test/org/apache/lucene/search/suggest/tst/ (a new directory) and run the command. The harness, Input and InputArrayIterator are already in that test source set. The harness builds from [bar/10] and checks 1 result; then it rebuilds from [bar/10, baz/5] in one background thread whose input blocks in every next() until the main thread has re-run the check; after the rebuild it checks 2 results. Concurrency is the claimed behaviour of this half; the thread is the harness's own. The semaphores also order the two threads' memory accesses, so the test shows what a lookup sees while the rebuild reads its input; it does not show that unsynchronized concurrent calls are safe, with or without the fix. Pass -Ptests.asserts explicitly, because by default the build picks it from the seed. The failed-rebuild half (an IOException while the input is read) is described with its executed numbers in the explanation above and is not a second repro. The test is the suggested regression test as it stands.

Environment

lucenemain 645d4fe14a20 (gradle); also fails on the 10.5.2 and 10.6.0 jars
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupDuringRebuild" -Ptests.seed=DEADBEEF -Ptests.asserts=true

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestTSTLookupDuringRebuild > testLookupsDuringReBuild FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at org.apache.lucene.search.suggest.tst.TestTSTLookupDuringRebuild.lambda$testLookupsDuringReBuild$0(TestTSTLookupDuringRebuild.java:34)
        at org.apache.lucene.search.suggest.SuggestRebuildTestUtil.testLookupsDuringReBuild(SuggestRebuildTestUtil.java:84)
        at org.apache.lucene.search.suggest.tst.TestTSTLookupDuringRebuild.testLookupsDuringReBuild(TestTSTLookupDuringRebuild.java:31)
1 test completed, 1 failed

# with the recommended fix, assertions on and off:
:lucene:suggest:test (SUCCESS): 1 test
BUILD SUCCESSFUL

Why it looks unintended

  • Upstream's own rebuild test (SuggestRebuildTestUtil.testLookupsDuringReBuild, "Lookup impls can return suggestions during a 'rebuild'") runs for five other suggesters; applied unchanged to TSTLookup, it fails.
  • LUCENE-10292 (9.2.0, Bug Fixes) fixed lookups during build() for the infix suggesters and getCount() for the FST-based ones; TSTLookup was not covered.
  • After a rebuild that throws IOException, FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep their previous suggestions.
The full argument363 words
  • Upstream tests this property. SuggestRebuildTestUtil is "Reusable Logic for confirming that Lookup impls can return suggestions during a 'rebuild'" (SuggestRebuildTestUtil.java:29). testLookupsDuringReBuild runs in TestWFSTCompletion, TestFSTCompletion, TestAnalyzingSuggester, TestFreeTextSuggester and TestAnalyzingInfixSuggester. TSTLookup has no test class: it appears only in TestPersistence and the @Ignored TestLookupBenchmark.
  • Analogous problems in the infix and FST-based suggesters were fixed as bugs. CHANGES.txt, 9.2.0, Bug Fixes: "LUCENE-10292: Suggest: Fix AnalyzingInfixSuggester / BlendedInfixSuggester to correctly return existing lookup() results during concurrent build(). Fix other FST based suggesters so that getCount() returned results consistent with lookup() during concurrent build()." gh#11328 is labelled type:bug.
  • TSTLookup was missed, not excluded. Chris Hostetter, the fix's author, wrote on gh#11328 (2022-04-07): "I refactored the test code so that it could be applied to all other Lookup impls (that have a build() method) and found that while none of the other impls had the same problem of .lookup() failing to return suggestions during a (re)build"; the comment goes on to say that a few FST-based ones had an inconsistent getCount(). None of the issue's 20 comments mentions TST. The issue description (Chris Hostetter, 2021-12-06) states the expectation: "With all other Lookup implementations I've examined, it is possible to call lookup() regardless of whether another thread is concurrently calling build()" and "Typically this is works because the build() method uses temporary datastructures until it's "build logic" is complete, at which point it atomically replaces the datastructures used by the lookup() method." (verbatim, typos included).
  • The count's contract. getCount(): "Get the number of entries the lookup was built with" (Lookup.java:172; the field comment at TSTLookup.java:46 says the same). After a failed rebuild TSTLookup answers from 0 entries and reports the previous build's count.
  • No recorded decision. The reset at :125 is from the class's first version (SOLR-1316, 2010); count = 0 after the sort is from LUCENE-5404 (2014), whose review discussed the count's type and persistence, not failures.
  • Not a documented contract. Lookup promises nothing about the state after build() throws or about lookups during build() (see below). The case rests on upstream's own test, on what the siblings do and on the getCount() wording.
The devil's-advocate check267 words

The strongest case: Lookup is @lucene.experimental and documents neither what build() leaves behind when it throws nor concurrent build()/lookup(). On gh#11328 Dawid Weiss asked "how did you arrive at the conclusion that Lookup.build can be called concurrently? I don't think this is mentioned anywhere in Lookup documentation" (2022-04-27), said the test "tests the behavior that isn't guaranteed or documented by the Lookup class" and that "Concurrency concerns should be handled by the code that uses Lookups" (2022-04-29); Chris Hostetter agreed "there's nothing in the docs that implies/guarantees anything about what getCount will return during a build" (2022-04-28). TSTLookup.add() changes the tree without touching count, root and count are plain fields (so even the fix gives a concurrent reader no visibility guarantee), an empty lookup is a valid state on its own, and the CHANGES entry names AnalyzingInfixSuggester / BlendedInfixSuggester and "other FST based suggesters", which TSTLookup is not.

Why it fails: that shows the behaviour is unspecified, not intended. Dawid Weiss also wrote "I don't want you to revert the changes you made", and the harness and its five call sites are still on main. The failed-rebuild half needs no concurrency: one thread, a legal IOException, and the instance then serves nothing while its count still reports the last successful build, which none of the three siblings above does in the probe. Verdict: a low-priority consistency bug; "improvement" would be a fair label. It is not a thread-safety violation, it rests on no strict count invariant, and not every sibling is failure-atomic (FreeTextSuggester resets totTokens before it reads its input).

Suggested fix

Recommended A. Build the new tree and count in locals, assign them at the end

Build the new tree and count in locals and assign root and count only after the tree is complete, as the siblings publish their FST and (since LUCENE-10292) their count (+6/-4 lines in TSTLookup.build()). Validated: the repro and the closest suites pass with assertions on and off; :lucene:suggest:test (assertions on): 239 tests, 0 failures (7 skipped); :lucene:suggest:check -x test passes.

All options and trade-offs4 options ยท 873 words

Options A and B were applied in one scratch worktree of main 645d4fe14a20 (stock first, then each patch) and run through gradle with assertions on and off; the worktree was removed afterwards. C and D were not run.

A. Build the new tree and count in locals, assign them at the end (validated by the checks) (recommended)

TernaryTreeNode newRoot and long newCount replace the early root = ... and count = 0; after balancedTree(..., newRoot) the method assigns root = newRoot; count = newCount;. +6/-4 lines in TSTLookup.build(), the way the siblings publish their new FST and, since LUCENE-10292, their count. It fixes both halves: lookups keep the previous tree while the rebuild reads its input, and a rebuild that throws leaves the previous tree and count in place. root and count stay two separate plain writes: the fix removes the long empty window, but it is not atomic and gives a concurrent reader no visibility guarantee (trade-offs below).

--- 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
@@ -122,12 +122,12 @@
     if (iterator.hasContexts()) {
       throw new IllegalArgumentException("this suggester doesn't support contexts");
     }
-    root = new TernaryTreeNode();
+    TernaryTreeNode newRoot = new TernaryTreeNode();
 
     // make sure it's sorted and the comparator uses UTF16 sort order
     iterator =
         new SortedInputIterator(tempDir, tempFileNamePrefix, iterator, utf8SortedAsUTF16SortOrder);
-    count = 0;
+    long newCount = 0;
     ArrayList<String> tokens = new ArrayList<>();
     ArrayList<Number> vals = new ArrayList<>();
     BytesRef spare;
@@ -136,9 +136,11 @@
       charsSpare.copyUTF8Bytes(spare);
       tokens.add(charsSpare.toString());
       vals.add(iterator.weight());
-      count++;
+      newCount++;
     }
-    autocomplete.balancedTree(tokens.toArray(), vals.toArray(), 0, tokens.size() - 1, root);
+    autocomplete.balancedTree(tokens.toArray(), vals.toArray(), 0, tokens.size() - 1, newRoot);
+    root = newRoot;
+    count = newCount;
   }
 
   /**

Trade-offs

  • root and count are still set by two separate writes, as in the siblings, so a lookup at that very moment can see one without the other. With A neither field is volatile (the siblings' count is), so nothing guarantees when another thread sees the new tree; option B makes count volatile, which lookup() does not read, so B does not publish root safely either. A removes the long empty window, not every race.
  • The previous tree stays reachable until the new one is complete, so peak heap during a rebuild holds the old tree, the token and weight lists and the new tree, as the siblings keep their old FST (by reading the code). The current order lets the old tree be collected before the read, which lowers that peak; that may be why build() clears it first, though no source comment or commit message gives a reason. The fix trades memory for consistency.
  • A rebuild from an empty input still ends with an empty tree and count = 0, as before.

What the checks validated

  • Gradle in a scratch worktree of main 645d4fe14a20, seed DEADBEEF, assertions on and off: the repro fails stock (expected:<1> but was:<0>) and passes with the fix.
  • The repro, the hunt's 2-test failed-rebuild JUnit (added for this check only; not part of the repro) and the upstream TestPersistence, TestWFSTCompletion and TestFSTCompletion: 5 classes, 26 tests; stock 3 failures, with the fix 0, each way.
  • All of :lucene:suggest:test with the fix, assertions on: 33 classes (the 31 upstream ones plus the repro and the hunt's 2-test JUnit), 239 tests, 7 skipped, 0 failures.
  • :lucene:suggest:check -x test (google-java-format, ecjLint, forbiddenApis) passes with the fix and the repro added.
  • Re-checked in a fresh scratch worktree of 645d4fe14a20 (seed DEADBEEF): the repro fails stock and passes with the fix, together with TestPersistence, assertions on and off.
  • Through the repro harness: with the fix shadowed, the failed-rebuild probe keeps [bar], getCount() = 1 after the failed rebuild and after store() + load(). Earlier, the fault-injection harness, run with the fix and assertions off, found no mixed states (the temp-file mismatches of a separate candidate remain), and the finder's, the independent reproduction's and the intended-behaviour check's checks pass with it.

B. Option A plus private volatile long count (validated by the checks)

Mirrors the siblings exactly: WFSTCompletionLookup, FSTCompletionLookup and AnalyzingSuggester declare count volatile and assign it after the structure. Making root volatile too would be the same idea for the tree (not run).

Trade-offs

Gives getCount() cross-thread visibility (not root, which lookups read and which stays plain), but it is a step towards promising concurrent use, the API question Dawid Weiss raised on gh#11328; it does not answer it.

What the checks validated

Gradle, same worktree and seed, assertions on and off: the repro and the same 5 classes (26 tests) pass, and :lucene:suggest:check -x test passes. The whole module was not run for B.

C. Document it instead (not run)

Say on Lookup.build() or TSTLookup that the lookup may return nothing while build() runs and may be empty if build() throws, and that callers should build a new instance and swap it in.

Trade-offs

The smallest change, in line with Dawid Weiss's view and the open LUCENE-4492 (#5558, immutable suggesters), but TSTLookup stays the one suggester that behaves this way, and the stale getCount() after a failed rebuild stays.

D. Application-side workaround (no Lucene change)

Build a new TSTLookup and replace the reference only after build() returns.

Trade-offs

Avoids both halves today; every application has to know to do it.

Downstream note

Solr's legacy spell-check Suggester uses TSTLookup when lookupImpl is not set and rebuilds that same instance in place, so its suggestions would be empty during a rebuild and after a failed one (read in Solr's source, not run).

How it was found

A bounded-exhaustive fault-injection check of suggester rebuilds: build each Lookup, fail its second build with every injected fault (inputs of up to 2 and 3 entries), and require the previous build or an empty lookup. For TSTLookup, 1,890 of 4,323 checks per assertion mode left an empty tree with the previous count; with the fix (assertions off), none.

The method in full212 words
  • Method (semi-formal): bounded-exhaustive fault-injection translation validation on the stock jar, in a hunt unit on suggester rebuild failure atomicity. The harness builds each Lookup twice (every input of up to 2 entries for the first build and up to 3 for the second, from a fixed entry set), injects every fault that can fail the second build (an IOException or RuntimeException from the InputIterator, createTempOutput and openInput failures, an entry whose sort record exceeds 32767 bytes) and requires the state after the failure (lookups and getCount()) to equal the previous build or an empty lookup.
  • Result for TSTLookup. 4,323 checks per assertion mode, 2,409 failed second builds, 1,890 mixed states (empty tree, previous count) across all five fault kinds; 0 in the recorded run with the fix (assertions off), where only the temp-file mismatches of a separate candidate remain. The plan had named the early tree reset as a lead; the harness showed the count disagreement that makes it a mixed state rather than an empty lookup.
  • Reduction. The finder reduced it to a public-API JUnit (a FileDictionary whose stream throws after its first line, and a latch-ordered lookup during a rebuild). The repro was later minimized to one test that runs upstream's own rebuild harness on TSTLookup.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27: GitHub issues, PRs and code, JIRA, the Lucene and Solr lists, downstream repositories, the web and the changed files of all 409 open PRs; 180 queries). Closest: LUCENE-10292 (9.2.0), which fixed the same property for the other suggesters and left TSTLookup out.

Searches5 searches

Read and not this bug: gh#11328 / LUCENE-10292 (the same property for the other suggesters; TSTLookup not mentioned, changed or tested; the closest related item); the dev@ thread of 2022-04-27 (Dawid Weiss: Lookup.build does not document concurrent rebuilds); gh#5558 / LUCENE-4492 (open since 2012: suggesters should be immutable); gh#6467 / LUCENE-5404 (added getCount() and the count = 0 after the sort; failures not discussed); SOLR-9434 (2016, Invalid: no suggestions during a build, for AnalyzingInfixSuggester). No maintainer has triaged it. Gaps: Stack Overflow 35708094 ("Query on suggester while build dictionary") could not be read; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (two specs)2026-10-27158 queries: apache/lucene issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, JIRA, the webno report; LUCENE-10292 as nearest prior art
gh#11328 / LUCENE-10292 (read in full)2026-10-27 and 2026-10-30body, 20 comments, 4 patches, the three commitsno mention of TST; the fix and its tests cover the other suggesters only
JIRA and Solr lists2026-10-2713 SOLR and 2 LUCENE queries; solr-user@, users@, dev@ and issues@solrSOLR-9434 (another suggester), no report
GitHub (manual)2026-10-27apache/solr, elasticsearch, OpenSearch, lucenenet and all of GitHub; the touched files of all 409 open apache/lucene PRsno report; no open PR touches suggest/tst/
Web2026-10-2710 searchesno report; one Q&A page unreadable

History

The tree reset before the read is from the class's first version (SOLR-1316, 2010; in Lucene since 3.3). The stale count after a failed rebuild is from LUCENE-5404 (b5926da2d67, 2014), first released in 5.0.0. Executed on releases from 3.6.2 to 10.6.0 (the latest) and main; never fixed.

Versions tested8 rows

Tree reset before the read (the during-rebuild half). ad8bfb3bc4de, SOLR-1316 "Create autosuggest component" (Andrzej Bialecki, 2010-08-23): its build() already starts with root = new TernaryTreeNode();. It was ported to branch_3x as d2353e16e2e (2010-09-07) and shipped in Solr 3.1/3.2; Lucene got it in 3.3 (571ae5043c8, "LUCENE-3135, SOLR-2378: backport all suggester functionality").

Count reset after the sort (the failed-rebuild state). b5926da2d67, LUCENE-5404 "Add .getCount method to all suggesters (Lookup), persist count metadata on .store(), ..." (Areek Zillur, trunk, 2014-02-07; issue #6467, patch era, no PR), placed count = 0 after the SortedInputIterator constructor. It reached a release through 55594bad9b0 (LUCENE-5944, Robert Muir, 2014-09-18), the commit that backported trunk's changes into the newly created branch_5x (the branch was created that day by c046bec1445, whose TSTLookup.build has no count lines); first release 5.0.0. The branch_4x commit fdf4595051a omitted the count lines, so in 4.7.0-4.10.4 getCount() is always 0 and a failed rebuild leaves a consistent empty lookup.

Never fixed. LUCENE-10292 (5015dc6dbb8, 2022-04-08; 9.2.0) changed only the siblings. The never-released Solr reference_impl commit 6800ee73c0a (2020) only made TSTLookup.build synchronized. 10.6.0, the latest release (its jars name commit 4ae946e5a9b), fails the one-test repro with assertions on and off and gives [] with getCount() = 1 in a failed-rebuild probe (both executed in the fact check).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails through gradle, assertions on and off; the failed-rebuild probe gives [], getCount() = 1
main a9779ef3c193 affectedthe hunt's 2-test JUnit and an independent 3-test JUnit fail (assertions on and off); upstream's harness fails
10.6.0 latest releasethe one-test repro fails (assertions on and off); a failed-rebuild probe gives [], getCount() = 1 after the failed rebuild and after store() + load() (fact check)
10.5.2 affectedthe one-test repro fails (assertions off; on as well in the fact check); both JUnits fail
5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2 to 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0 first: 5.0.0version-portable probe: count=2 fo->[] after the failed rebuild and after store/load; lookups during the rebuild find nothing (that half not run on 8.8.2 to 8.11.3)
4.10.4, 4.7.0, 4.6.1, 4.0.0, 3.6.2 during-rebuild onlylookups during a rebuild find nothing; a failed rebuild leaves a consistent empty lookup (getCount() 0 or no such API)
3.3.0 readthe probe does not compile (store/load take java.io.File); the reset before the read is in the code
the fix on main 645d4fe14a20 not affectedoption A: the repro and the closest suites pass (assertions on and off); :lucene:suggest:test: 239 tests, 0 failures (7 skipped), assertions on

Independent checks

An independent reproducer, a reviewer from another model family, a devil's advocate, an adjudication, a re-run and a credibility audit re-ran or re-read it; every executed check reproduced it. This doc re-ran the one-test repro through gradle on 645d4fe14a20 and validated the fix.

Checks and verdicts567 words

The cross-model review ran reading only (it could not run code); every claim it read was executed by the other checks. The hunt's checks ran on main a9779ef3c193, whose suggest module equals 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn 3-test JUnit (a hand-written input whose second next() throws): fails 3 of 3 on main (assertions on and off), 10.5.2 and 9.12.3 (failed rebuild; the same after store()/load(); lookup during the rebuild). Sibling controls (WFST, FST, AnalyzingSuggester) pass; the fix turns it green.
Cross-model review refute: not real (procedural); intended: real (medium)An independent reviewer from another model family (OpenAI GPT-5.6), reading only. Its static reading agrees with the root cause and the in-contract trigger; its strongest point is that Lookup documents neither failure atomicity nor concurrent build/lookup. A separate run re-ran the finder's JUnit (fails; passes with the fix) and a sibling test of its own.
Intended-behaviour devil's advocate real (medium)Built the works-as-designed case and rejected it. Upstream's SuggestRebuildTestUtil, unchanged, fails on TSTLookup (assertions on and off) and passes with the fix; found the FreeTextSuggester totTokens caveat.
Adjudication confirmed (medium), minorRe-ran the independent reproduction with a new seed: 3 of 3 fail with assertions on; with them off the 3 TSTLookup tests fail and the 3 sibling controls pass; 3 of 3 pass with the fix. Scope of the claim: no thread-safety violation, no strict count invariant, and not every sibling is failure-atomic.
Registration check and credibility audit reproduces; keepFresh seeds: the finder's JUnit fails 2 of 2 (assertions on and off) and passes with the fix; the audit re-ran it again and re-read the code: keep.
Repro minimization one test, failsReduced to one test that runs upstream's harness on TSTLookup, on 645d4fe14a20: fails with assertions on and off (two seeds), passes with the fix shadowed.
Fix validation (this doc) fix validatedGradle in a scratch worktree of 645d4fe14a20: the repro fails stock and passes with option A, assertions on and off; with A the closest suites pass, :lucene:suggest:test gives 239 tests, 0 failures (7 skipped) with assertions on, and :lucene:suggest:check -x test passes; option B passes the repro, the closest suites and the check. The repro also fails on the 10.5.2 jars, and a probe of the failed-rebuild half gives the numbers above.
Fact checks (this doc) real; wording fixed (two rounds)Round 1 (2026-10-30): an independent fact check re-ran the doc's gradle command on a fresh worktree of 645d4fe14a20 (assertions on and off: the same output), the repro on main with a new seed and on the 10.5.2 and 10.6.0 jars, and its own failed-rebuild probe with three fault kinds on main, with the fix and on 10.6.0; every result is as described. A reviewer from another model family (OpenAI GPT-5.6) read the brief against the sources, reading only. Both found wording errors (the LUCENE-10292 precedent, the sort-record limit, the 10.6.0 release). Round 2: a second independent fact check re-ran the repro (assertions on and off, a new seed, with the fix, on 10.6.0 and 10.5.2), the doc's gradle command and its own probe, and found two wording errors (the role of the branch_5x commit, a test-count shorthand); the cross-model reviewer found a reference-scope error, four overclaims and four missing caveats. All are fixed here.

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,577 words

Finding record

LFV-786, snapshot of 2026-11-06

Registry title
TSTLookup.build empties its tree before it reads the new input but resets its count only after the input is sorted, so a rebuild that throws while the input is read or sorted leaves a lookup that suggests nothing while getCount() and store()/load() still report the previous build; lookups made while a rebuild reads its input also find nothing
Area
suggest / ternary search tree suggester (TSTLookup.build: rebuild failure atomicity and rebuild consistency)
Priority
Low, rank 620: TSTLookup serves no suggestions with a stale count only after a build that threw, and the next good build repairs it, so this transient issue follows its TSTLookup sibling LFV-251.
Impact class
minor
Impact note
Applications that use the opt-in TSTLookup suggester (default settings, any dictionary size, -ea and -da alike) and keep serving an instance after a rebuild of it threw while its input was read or sorted (an IOException or RuntimeException from the InputIterator, a temp-directory failure, or an entry of 32,760 bytes or more, whose sort record with its 8-byte weight exceeds 32767 bytes) get no suggestions from lookup() while getCount() still returns the previous count, and store() persists that pair so load() reproduces it, until a later build succeeds; the weaker half, lookups made on the same instance while a rebuild is still reading its input also return nothing, rests on concurrent build/lookup, which Lookup does not document; FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep serving the previous build in both situations; released 5.0.0 to 10.6.0 (the during-rebuild half since 3.3).
Summary
You build a TSTLookup from {foo/10, bar/5}, rebuild it from a dictionary whose stream throws IOException after its first line, catch the exception and keep using the lookup. You expect either the previous build (lookup('fo') returns foo and getCount() 2), which FSTCompletionLookup, WFSTCompletionLookup and AnalyzingSuggester keep after the same failed rebuild, or an empty lookup with getCount() 0. Instead lookup('fo') returns nothing while getCount() still returns 2, and store()+load() carry that state into a new lookup, because build() replaces the tree with an empty one before it reads and sorts the input and resets the count only after that; for the same reason a lookup made while a rebuild is still reading its input finds nothing.
Introduced
b5926da2d67820f5fdf8c8ec9b2e642b74d12070 (trunk, svn r1565810, 'LUCENE-5404: Add .getCount method to all suggesters (Lookup), persist count metadata on .store(), ...'; issue LUCENE-5404, migrated as apache/lucene#6467; JIRA patch era, no pull request) [read: git blame and git log -S in the full-history clone]: it placed 'count = 0' after the SortedInputIterator constructor, which reads and sorts the whole input (main TSTLookup.java:130), and count++ in the copy loop (:139), next to the older 'root = new TernaryTreeNode()' before the input is read (:125), so a build that throws while its input is read leaves an empty tree next to the previous count; it reached a release through 55594bad9b07af90de4ab2a2fb0bac8e8c2b0b3c (LUCENE-5944, Robert Muir, 2014-09-18), the commit that backported trunk's changes into the newly created branch_5x (the branch was created that day by c046bec1445, whose TSTLookup.build has no count lines). The tree reset alone (the during-rebuild half, and the loss of the previous suggestions after a failed rebuild) comes from ad8bfb3bc4de0d9eed8e4d44116ea216ca711766 (SOLR-1316, 2010-08-23) and is in every Lucene release since 3.3. The branch_4x commit of LUCENE-5404 (fdf4595051a) omitted the two count lines, so 4.7.0-4.10.4 report getCount() 0 after every build and a failed rebuild there leaves a consistent empty lookup. (2014-02-07 (trunk commit b5926da2d67); 2014-09-18 (branch_5x commit 55594bad9b0); first release 5.0.0, tagged 2015-02-19. Tree reset before the read: 2010-08-23 (ad8bfb3bc4de))
First affected version
5.0.0 [exec] for the failed-rebuild state (empty tree with the previous count); 4.10.4 is the last release without it [exec: 3.6.2, 4.0.0, 4.6.1, 4.7.0 and 4.10.4 leave an empty lookup with getCount() 0 or no getCount API]. The during-rebuild half fails on every executed release back to 3.6.2 and is in Lucene since 3.3 [read; 3.3.0 does not compile with the probe because its store/load take java.io.File]
Affected versions
Failed-rebuild half: every release from 5.0.0 through 10.6.0 (the latest) and main a9779ef3c193; not fixed upstream. [exec] the follow-up's version-portable probe fails on 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 and main (state count=2 fo->[] after the failed rebuild and after store/load); the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4; the independent reproduction's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3; [exec, fact check 2026-10-30] on the 10.6.0 jars (Maven Central, SHA-1 verified, manifest 10.6.0 4ae946e5a9b9) the one-test repro fails with -ea and -da and a failed-rebuild probe gives lookup(ba)=[] count=1 after the failed rebuild and after store/load. [read] every tagged release from 5.0.0 to 10.5.2 (98 tags in the history clone) has the same build() ordering. During-rebuild half: [exec] fails on 3.6.2, 4.0.0, 4.6.1, 4.7.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (fact check) and main; [read] since 3.3. The upstream file is unchanged since 8e2c6aa1d587 (2025-06-27) and no open PR touches lucene/suggest/.../tst/ (prior-report record, 2026-10-27).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): a scripted prior-report search in two runs plus manual searches, 180 queries in all (GitHub issues, PRs, commits and code, downstream repositories, the dev, java-user, issues and commits lists, Solr lists, JIRA LUCENE and SOLR, the web), the full text and comments of gh#11328 / LUCENE-10292 and its four patches, gh#6467 / LUCENE-5404, gh#5558 / LUCENE-4492, and the changed files of 409 open apache/lucene PRs; 15 hits assessed, none the same bug (one Q&A page could not be read). Nearest: gh#11328 / LUCENE-10292 (closed, 9.2.0 Bug Fixes), which made the infix suggesters keep returning their previous suggestions during a rebuild and made AnalyzingSuggester, FSTCompletionLookup, WFSTCompletionLookup and FreeTextSuggester keep getCount() consistent with lookup() during a rebuild, and tests five suggesters with SuggestRebuildTestUtil, but does not mention, change or test TSTLookup. Not fixed: the upstream file is unchanged since 8e2c6aa1d587 (2025-06-27).
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree (removed afterwards), seed DEADBEEF, assertions on and off: stock, the one-test repro fails; with fix A it passes, together with the closest suites (TestPersistence, TestWFSTCompletion, TestFSTCompletion and the hunt's 2-test failed-rebuild JUnit), all of :lucene:suggest:test (assertions on) and :lucene:suggest:check -x test; fix B (A plus a volatile count) passes the repro, the closest suites and the check. Through the repro harness: the one-test repro on 645d4fe14a20 (assertions on and off; fix shadowed), on the 10.5.2 jars (assertions off; on as well in the fact check) and on the 10.6.0 jars (assertions on and off; fact check); a failed-rebuild probe on 645d4fe14a20 (assertions on and off; fix shadowed) and, in the fact check, on 10.6.0; the hunt's 2-test JUnit and an independent 3-test JUnit on main a9779ef3c193, 10.5.2 and 9.12.3 (the hunt's also on 8.11.4); a version-portable probe on 19 releases from 3.6.2 to 10.5.2 for both halves, and 8 more 8.x releases for the failed-rebuild half. The suggest module is identical at a9779ef3c193 and 645d4fe14a20. 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/TSTLookup.java:46-47 (at a9779ef3c193) (field comment 'Number of entries the lookup was built with')
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:125 (at a9779ef3c193) (root = new TernaryTreeNode() before any input is read), :128-129 (SortedInputIterator constructor), :130 (count = 0 only after it), :139 (count++), :141 (balancedTree fills the already published root)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:268-271 (at a9779ef3c193) (store writes count and root), :275-279 (load), :57-59 (no-arg constructor passes a null tempDir)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:172 (at a9779ef3c193) (getCount javadoc 'Get the number of entries the lookup was built with'), :179-182 (build javadoc; no failure-state or thread-safety statement)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/SortedInputIterator.java:80 (at a9779ef3c193) (this.reader = sort(): the constructor reads and sorts the whole input)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:43, (at 645d4fe14a20) 46-47, 117-142 (build), 183-209 (lookup), 267-280 (store, load), 292-295 (getCount)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/SortedInputIterator.java:80 (at 645d4fe14a20) (this.reader = sort()), 175-195 (sort reads all input, temp file, offline sort)
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:550-551 (at 645d4fe14a20) (len <= Short.MAX_VALUE)
  • lucene/core/src/java/org/apache/lucene/util/BytesRefIterator.java:34 (at 645d4fe14a20) (next() throws IOException); lucene/suggest/src/java/org/apache/lucene/search/suggest/InputIterator.java:33
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:171-176 (at 645d4fe14a20) (getCount javadoc), 178-182 (build javadoc)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:73, (at 645d4fe14a20) 107, 127-130; fst/FSTCompletionLookup.java:103, 213, 238, 245; analyzing/AnalyzingSuggester.java:167, 410, 589-590
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/SortedInputIterator.java:216 (at 645d4fe14a20) (record length = entry + 8-byte weight), 239-240
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:135-140 (at 645d4fe14a20) (copy loop after the count reset), 267-280 (store and load are synchronized; build and lookup are not)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:125, (at 645d4fe14a20) 128-130, 141, 268-272
Why it looks unintended
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/SuggestRebuildTestUtil.java:29, (at 645d4fe14a20) 45-97
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestWFSTCompletion.java:92-95; (at 645d4fe14a20) fst/TestFSTCompletion.java:243-246; analyzing/TestAnalyzingSuggester.java:173-177; analyzing/TestFreeTextSuggester.java:98-101; TestPersistence.java:51, 68-69; TestLookupBenchmark.java:51
  • lucene/CHANGES.txt:4176 (at 645d4fe14a20) (9.2.0), 4278 (Bug Fixes), 4289-4291 (LUCENE-10292)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (at 645d4fe14a20) (@lucene.experimental), 172, 178-182, 246
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:46, (at 645d4fe14a20) 125, 130, 149-153 (add)
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:73, (at 645d4fe14a20) 107, 129-130 (sibling pattern)
History
  • a Lucene clone: git show 4ae946e5a9b:lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java (lines 124, 127-129, 138, 140)

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

Repro files

About the diagrams

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.