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.
- A suggester (a
Lookup) is built from anInputIteratorof entries, each a text and a weight, for example from a dictionary file.lookup("ba")then returns the entries that start withba. TSTLookupkeeps the entries in a ternary search tree;getCount()is documented as "the number of entries the lookup was built with".- 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.
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.- You build a
TSTLookupfrom one entry,bar(weight 10):lookup("ba")returns[bar]andgetCount()is 1. - 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]. - You get
[](expected:<1> but was:<0>) as soon as the rebuild starts reading its input;[bar, baz]once it returns. - If the rebuild throws instead (an
IOExceptionon its 2nd entry),lookup("ba")stays[]whilegetCount()still says 1, until a later good build;store()+load()copy that state.FSTCompletionLookup,WFSTCompletionLookupandAnalyzingSuggesterkeep[bar]. - 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).
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.
build()first doesroot = new TernaryTreeNode();(TSTLookup.java:125).rootis the field every lookup reads (:43, not volatile;lookup()callsautocomplete.prefixCompletion(root, key, 0),:188), so from here on lookups see an empty tree.- Then
new SortedInputIterator(tempDir, tempFileNamePrefix, iterator, ...)(:128-129). Its constructor ends withthis.reader = sort();(SortedInputIterator.java:80), which writes every entry of the input to a temp file and sorts it (:175-195). Anything thrown here leavesbuild(): anIOExceptionfrom the input (BytesRefIterator.next()is declaredthrows IOException), a temp-directory failure, orIllegalArgumentException("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). - Only then
count = 0;(:130) andcount++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). - Last,
autocomplete.balancedTree(..., root)(:141) fills the root that lookups already see. store()writescountand then the tree (:268-272);load()reads both back (:274-280). Both aresynchronized;build()andlookup()are not, so this orders nothing between a rebuild and lookups.
- During a rebuild (the repro): built from
bar/10, rebuilt frombar/10 +baz/5 by upstream'sSuggestRebuildTestUtil,lookup("ba")returns 0 suggestions instead of 1 while the rebuild sits in its firstnext(); 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
IOExceptionon its 2ndnext().TSTLookup:lookup("ba") = [],getCount() = 1; the same afterstore()+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,TSTLookupkeeps[bar], 1.
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 whilebuild()runs, or the instance kept after abuild()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 aload()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,
rootandcountare plain fields, so the Java memory model does not say when another thread sees the empty tree.
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.
/*
* 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()));
}
}
}
./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.
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.
| lucene | main 645d4fe14a20 (gradle); also fails on the 10.5.2 and 10.6.0 jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupDuringRebuild" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# 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- Upstream's own rebuild test (
SuggestRebuildTestUtil.testLookupsDuringReBuild, "Lookup impls can return suggestions during a 'rebuild'") runs for five other suggesters; applied unchanged toTSTLookup, it fails. - LUCENE-10292 (9.2.0, Bug Fixes) fixed lookups during
build()for the infix suggesters andgetCount()for the FST-based ones;TSTLookupwas not covered. - After a rebuild that throws
IOException,FSTCompletionLookup,WFSTCompletionLookupandAnalyzingSuggesterkeep their previous suggestions.
The full argument363 words
- Upstream tests this property.
SuggestRebuildTestUtilis "Reusable Logic for confirming that Lookup impls can return suggestions during a 'rebuild'" (SuggestRebuildTestUtil.java:29).testLookupsDuringReBuildruns inTestWFSTCompletion,TestFSTCompletion,TestAnalyzingSuggester,TestFreeTextSuggesterandTestAnalyzingInfixSuggester.TSTLookuphas no test class: it appears only inTestPersistenceand the@IgnoredTestLookupBenchmark. - 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. TSTLookupwas 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 otherLookupimpls (that have abuild()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 inconsistentgetCount(). 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 calllookup()regardless of whether another thread is concurrently callingbuild()" and "Typically this is works because thebuild()method uses temporary datastructures until it's "build logic" is complete, at which point it atomically replaces the datastructures used by thelookup()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 atTSTLookup.java:46says the same). After a failed rebuildTSTLookupanswers from 0 entries and reports the previous build's count. - No recorded decision. The reset at
:125is from the class's first version (SOLR-1316, 2010);count = 0after the sort is from LUCENE-5404 (2014), whose review discussed the count's type and persistence, not failures. - Not a documented contract.
Lookuppromises nothing about the state afterbuild()throws or about lookups duringbuild()(see below). The case rests on upstream's own test, on what the siblings do and on thegetCount()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).
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
rootandcountare 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'countis), so nothing guarantees when another thread sees the new tree; option B makescountvolatile, whichlookup()does not read, so B does not publishrootsafely 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,TestWFSTCompletionandTestFSTCompletion: 5 classes, 26 tests; stock 3 failures, with the fix 0, each way. - All of
:lucene:suggest:testwith 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 withTestPersistence, assertions on and off. - Through the repro harness: with the fix shadowed, the failed-rebuild probe keeps
[bar],getCount() = 1after the failed rebuild and afterstore()+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.
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.
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.
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.
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).
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
Lookuptwice (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 (anIOExceptionorRuntimeExceptionfrom theInputIterator,createTempOutputandopenInputfailures, an entry whose sort record exceeds 32767 bytes) and requires the state after the failure (lookups andgetCount()) 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
FileDictionarywhose 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 onTSTLookup.
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (two specs) | 2026-10-27 | 158 queries: apache/lucene issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, JIRA, the web | no report; LUCENE-10292 as nearest prior art |
| gh#11328 / LUCENE-10292 (read in full) | 2026-10-27 and 2026-10-30 | body, 20 comments, 4 patches, the three commits | no mention of TST; the fix and its tests cover the other suggesters only |
| JIRA and Solr lists | 2026-10-27 | 13 SOLR and 2 LUCENE queries; solr-user@, users@, dev@ and issues@solr | SOLR-9434 (another suggester), no report |
| GitHub (manual) | 2026-10-27 | apache/solr, elasticsearch, OpenSearch, lucenenet and all of GitHub; the touched files of all 409 open apache/lucene PRs | no report; no open PR touches suggest/tst/ |
| Web | 2026-10-27 | 10 searches | no report; one Q&A page unreadable |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails through gradle, assertions on and off; the failed-rebuild probe gives [], getCount() = 1 |
main a9779ef3c193 | affected | the hunt's 2-test JUnit and an independent 3-test JUnit fail (assertions on and off); upstream's harness fails |
| 10.6.0 | latest release | the 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 | affected | the 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.0 | version-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 only | lookups during a rebuild find nothing; a failed rebuild leaves a consistent empty lookup (getCount() 0 or no such API) |
| 3.3.0 | read | the 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 affected | option A: the repro and the closest suites pass (assertions on and off); :lucene:suggest:test: 239 tests, 0 failures (7 skipped), assertions on |
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.
| Check | Verdict | What it found |
|---|---|---|
| Independent reproducer | real | Own 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), minor | Re-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; keep | Fresh 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, fails | Reduced 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 validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 481 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,577 words
- 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,TestFSTCompletionand 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 on645d4fe14a20(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 on645d4fe14a20(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 maina9779ef3c193, 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 ata9779ef3c193and645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- 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:33lucene/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-590lucene/suggest/src/java/org/apache/lucene/search/suggest/SortedInputIterator.java:216 (at 645d4fe14a20) (record length = entry + 8-byte weight), 239-240lucene/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-97lucene/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:51lucene/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, 246lucene/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)
- TestTSTLookupDuringRebuild.java (one test):
repro/LFV-786/src/org/apache/lucene/search/suggest/tst/TestTSTLookupDuringRebuild.java(39 lines)
assets/LFV-786/bug.png(481 KB)
![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().](../assets/LFV-786/bug.png)